From beed2173aaf8b04426bcbc1de8f30af6dd1b7a5a Mon Sep 17 00:00:00 2001 From: Brian Love Date: Mon, 17 Aug 2026 07:46:13 -0700 Subject: [PATCH 01/15] docs: A2UI v0.9.1 stable migration design Co-Authored-By: Claude Fable 5 --- ...-08-17-a2ui-v09-stable-migration-design.md | 151 ++++++++++++++++++ 1 file changed, 151 insertions(+) create mode 100644 docs/superpowers/specs/2026-08-17-a2ui-v09-stable-migration-design.md diff --git a/docs/superpowers/specs/2026-08-17-a2ui-v09-stable-migration-design.md b/docs/superpowers/specs/2026-08-17-a2ui-v09-stable-migration-design.md new file mode 100644 index 000000000..34265e7be --- /dev/null +++ b/docs/superpowers/specs/2026-08-17-a2ui-v09-stable-migration-design.md @@ -0,0 +1,151 @@ +# A2UI Stable (v0.9.1) Migration + +**Date:** 2026-08-17 +**Status:** Approved +**Scope:** `libs/a2ui`, `libs/chat/src/lib/a2ui/**`, `libs/chat` streaming/content-classifier, example-app Python graphs + schema prompts (`examples/chat`, `examples/ag-ui`), cockpit a2ui graphs (`cockpit/chat/a2ui`, `cockpit/ag-ui/a2ui`, `deployments/ag-ui-dev/deps/a2ui`), e2e fixtures, website docs (`apps/website/content/docs/a2ui/**`, `chat/a2ui/**`). +**Supersedes:** the wire shape adopted in `2026-05-09-a2ui-v1-protocol-migration-design.md`. + +## Goal + +Migrate Threadplane's A2UI implementation from the shape we call "v1" (`beginRendering`/`surfaceUpdate`/`dataModelUpdate`, type-keyed component wrappers, `literalString` wrappers, `children.explicitList`) — now classified upstream as the deprecated v0.8-legacy lineage — to the **A2UI v0.9.1 stable release** (the current production protocol family), implemented to **full conformance**: wire re-shape plus client-side functions, validation checks, and the `sendDataModel` round-trip. Types and internals are structured so the v1.0 release candidate's additions (`callRendererFunction`, `agentFunctionResponse`, embedded `createSurface` trees, `actionResponse`) slot in without another breaking rewrite. + +Strategy: **Approach A — phased cutover.** Phase 1 is one atomic breaking re-shape across the whole repo (clean cutover, no legacy support, same playbook as the May migration). Phases 2–4 are additive, individually releasable PRs. Each phase: own branch/PR, merge on green, live Chrome verification for renderer-visible phases. + +## Wire-format diff (current → v0.9.1) + +### Envelopes + +Every envelope gains a required `"version": "v0.9"` field (v0.9.1 is a spec patch — it standardizes the `application/a2ui+json` MIME type but does not bump the wire version). + +``` +current → v0.9.1 +{ surfaceUpdate: {surfaceId, components} } { version:'v0.9', updateComponents: {surfaceId, components} } +{ dataModelUpdate: {surfaceId, path?, contents} } { version:'v0.9', updateDataModel: {surfaceId, path?, value?} } +{ beginRendering: {surfaceId, root, styles?} } (removed — no commit point; see Rendering below) +(no equivalent) { version:'v0.9', createSurface: {surfaceId, catalogId, theme?, sendDataModel?} } +{ deleteSurface: {surfaceId} } { version:'v0.9', deleteSurface: {surfaceId} } +``` + +`updateDataModel` semantics: `path` defaults to `/` (whole-model replace); **omitted `value` deletes** the key at `path` (array indices are set to undefined, preserving length). + +Client → agent messages: + +- **Action:** `{ action: { name, surfaceId, sourceComponentId, timestamp, context } }` — `context` is a plain object (not the current array of typed entries). +- **Error (Phase 3):** `{ error: { code, surfaceId, path, message } }`. +- **Capabilities / data model metadata (Phase 4):** `a2uiClientCapabilities { supportedCatalogIds, inlineCatalogs? }`, `a2uiClientDataModel { surfaces: { [surfaceId]: model } }`. + +### Rendering model + +No `beginRendering`. `createSurface` opens the surface; components arrive via `updateComponents`; **the component whose `id` is `"root"`** is the tree root. Per spec: rendering can begin as soon as `root` is defined; other components are buffered until then, and the tree fills in progressively. The surface store's deferral gate moves from "wait for beginRendering" to "wait for root". + +### Component shape + +Flat — type is a string, props are direct: + +```json +{ "id": "btn", "component": "Button", "child": "btn-text", "variant": "primary", "action": { "event": { "name": "submit" } } } +``` + +(current shape nests under a type-keyed wrapper: `{ "component": { "Button": { ... } } }`) + +### Dynamic values + +Bare literals; wrapping only for bindings and (Phase 2) function calls: + +```json +"text": "Hello" // literal — no literalString wrapper +"text": { "path": "/title" } // JSON-pointer binding (absolute) or relative in templates +"text": { "call": "formatString", "args": { ... } } // Phase 2 +``` + +The `isLiteralString/Number/Boolean` guards and wrappers are deleted; `isPathRef` stays; a new `isFunctionCall` guard is added. + +### Children + +```json +"children": ["a", "b"] // was { explicitList: [...] } +"children": { "path": "/items", "componentId": "row-template" } // was { template: { dataBinding, componentId } } +``` + +### Actions + +```json +"action": { "event": { "name": "submit", "context": { "flightId": { "path": "/selected" } } } } +"action": { "functionCall": { "call": "openUrl", "args": { "url": "..." } } } // Phase 2 +``` + +(current: `action: { name, context: [ {key, value} ] }` directly on the component) + +### Catalog changes (basic catalog, exact spec props) + +- **Text** — `text` (required), `variant`: h1–h5|caption|body. +- **Image** — `url` (required), `description`, `fit`: contain|cover|fill|none|scaleDown, `variant`: icon|avatar|smallFeature|mediumFeature|largeFeature|header. +- **Icon** — `name` (enum, svgPath object, or binding). +- **Video** — `url`. **AudioPlayer** — `url`, `description`. +- **Row/Column** — `children`, `justify`, `align` (spec enums). +- **List** — `children`, `direction`: vertical|horizontal, `align`. +- **Card** — `child` (required). +- **Tabs** — `tabs: [{ title, child }]` (replaces `tabItems`). +- **Modal** — `trigger` + `content` (replaces `entryPointChild`/`contentChild`). +- **Divider** — `axis`. +- **Button** — `child` (required — no text prop), `variant`: default|primary|borderless, `action` (required). +- **TextField** — `label` (required), `value`, `variant`: shortText|longText|number|obscured, `validationRegexp`. +- **CheckBox** — `label` (required), `value` (required). +- **ChoicePicker** — replaces **MultipleChoice**: `options: [{label, value}]`, `value` (DynamicStringList), `variant`: mutuallyExclusive|multipleSelection, `displayStyle`: checkbox|chips, `filterable`, `label`. +- **Slider** — `value` (required), `max` (required), `min` (default 0), `label`. +- **DateTimeInput** — `value` (required, ISO 8601), `enableDate`, `enableTime`, `min`, `max`, `label`. + +During Phase 1 implementation, types and the schema prompt are validated against the official machine-readable catalog schema at `a2ui.org/specification/v0_9/catalogs/basic/catalog.json`, not just the prose docs. + +## Phases + +### Phase 1 — wire-format cutover (breaking; the migration proper) + +One PR. No legacy acceptance, no dual shapes. + +1. **`libs/a2ui`** — rewrite `types.ts` to the v0.9 vocabulary (`A2uiCreateSurface`, `A2uiUpdateComponents`, `A2uiUpdateDataModel`, `A2uiDeleteSurface`; flat `A2uiComponent`; new dynamic-value/children/action types; client message types; `A2UI_MIME_TYPE = 'application/a2ui+json'` and `A2UI_WIRE_VERSION = 'v0.9'` constants). Parser mechanics (JSONL buffering, malformed-line skip) unchanged; typed to the new union; tolerant of unknown envelope keys (forward-compat with v1.0 messages — unknown envelopes are skipped, not errors). `resolveDynamic` handles bare literals + `{path}`; `{call}` returns `undefined` until Phase 2 (typed now). Guards updated. Pointer utils unchanged. +2. **`libs/chat` renderer** — surface store re-gated on `createSurface` + root-id buffering; `surface-to-spec` consumes flat components; catalog components re-propped per the table above; `MultipleChoice` → `ChoicePicker` (rename + variant/displayStyle support at parity level); Tabs/Modal/Button/TextField/Image/Text prop updates; `build-action-message` emits the spec client action (`context` object, `timestamp`, `sourceComponentId`); `action-label`, `extract-bindings`, `views`, `envelope-normalizer`, `partial-args-bridge`, `content-classifier` updated to the new keys. Public API renames documented in the changelog; api-docs regenerated. +3. **Python emitters** — `A2UI_V1_SCHEMA_PROMPT` → new `A2UI_V09_SCHEMA_PROMPT` generated to match the official schema (both example apps, kept byte-identical); `envelope_tool.py` pydantic models re-shaped; `envelope_normalizer.py` `_ENVELOPE_KEYS` updated; graph envelope-ordering logic switches from "beginRendering into slot 2" to "createSurface first, root early"; cockpit `c-a2ui` graphs' structured-output specs (`BookingFormSpec` etc.) re-emit flat components + v0.9 envelopes; `deployments/ag-ui-dev/deps/a2ui` copy synced. +4. **Fixtures & tests** — all 6 e2e fixtures regenerated in the new shape (respecting the aimock tool-result-ordering rule); TS + Python unit tests updated alongside each module (TDD: shape tests first). +5. **Docs** — the 13 a2ui/chat-a2ui MDX pages, READMEs, and generated api-docs updated to describe v0.9.1 only. + +**Exit criteria:** libs lint/test/build green; both example apps' e2e green (chat + ag-ui twins); cockpit a2ui e2e green; live Chrome smoke on examples/chat and the c-a2ui cockpit against a real LLM key renders a surface and round-trips a button action. + +### Phase 2 — client-side functions (additive) + +- `resolveDynamic` gains a **function registry**: standard functions `formatString` (with `${...}` interpolation, absolute/relative paths, nested calls, `\${` escape), `formatNumber`, `formatCurrency`, `formatDate`, `pluralize`, `and`, `or`, `not`. Registry is an injectable map so custom catalogs can extend it and v1.0's object-map function schemas can layer on. +- `action.functionCall` support in the renderer with `openUrl` as the standard local action (new-tab, rel=noopener). +- Schema prompt + docs updated to advertise functions. + +### Phase 3 — validation checks (additive) + +- `checks: [{ call, args, message }]` on input components; standard validators `required`, `regex`, `length`, `numeric`, `email` (plus TextField `validationRegexp`). +- Catalog input components display validation errors (existing `--tplane-*`/`--ds-*` token styling); invalid state blocks the enclosing action's event emission per spec. +- Client → agent `error` message (`VALIDATION_FAILED` etc.) wired through the surface component's outputs. + +### Phase 4 — sendDataModel round-trip (additive) + +- Honor `createSurface.sendDataModel`: when true, outgoing action messages carry `a2uiClientDataModel` (per-surface current model snapshot). +- `a2uiClientCapabilities` (supported catalog ids, inline catalogs) exposed as typed metadata the host app can attach to requests; the chat transports pass it through where the wire allows. +- Docs + schema prompt updates; live verification that agent-side graphs can read the round-tripped model. + +## Error handling + +- Parser: malformed JSONL lines skipped (unchanged); unknown envelope keys skipped (v1.0 forward-compat); envelopes for unknown surfaces buffered as today. +- Store: components for an unopened surface / missing root are buffered, never thrown; unknown component types render the default fallback. +- Resolver: unresolvable paths → `undefined` (progressive rendering per spec); unknown functions → `undefined` + one-time console warn. +- Validation (Phase 3): failed checks block event emission and emit the spec `error` message; never throw. + +## Testing + +- TDD per module. Unit: types compile-time assertions, parser envelope tests, resolver (literals/paths/functions), pointer delete-semantics (omitted `value`, array index), store root-buffering, per-catalog-component render tests, action-message shape. +- Python: envelope tool/normalizer/graph smoke tests re-shaped. +- E2E: existing a2ui specs over regenerated fixtures; remember aimock replay is ~atomic — incremental-update behavior (root-buffering) gets component-level vitest coverage, not e2e. +- Live gate per phase (renderer-visible phases): real-LLM serve + Chrome MCP drive, per the live-LLM smoke-gate practice. + +## Out of scope + +- v1.0 candidate features (`callRendererFunction`/`agentFunctionResponse`/`actionResponse`, embedded `createSurface` trees, strict UAX #31 identifier enforcement) — types are structured to accommodate them, not implemented. +- Custom/inline catalog *authoring* support beyond the typed metadata (Phase 4 types only). +- Theme schema rendering (`primaryColor`/`iconUrl`/`agentDisplayName`) — parsed and stored, surfaced to hosts, but no new theming engine; existing token styling stays. +- Any backward compatibility with the pre-migration shape. From 8dbe5fab9ee36fde08d3bd5d8d0d505e7113d38d Mon Sep 17 00:00:00 2001 From: Brian Love Date: Mon, 17 Aug 2026 07:49:25 -0700 Subject: [PATCH 02/15] docs: Phase 1 implementation plan for A2UI v0.9.1 cutover Co-Authored-By: Claude Fable 5 --- .../2026-08-17-a2ui-v09-phase1-cutover.md | 351 ++++++++++++++++++ 1 file changed, 351 insertions(+) create mode 100644 docs/superpowers/plans/2026-08-17-a2ui-v09-phase1-cutover.md diff --git a/docs/superpowers/plans/2026-08-17-a2ui-v09-phase1-cutover.md b/docs/superpowers/plans/2026-08-17-a2ui-v09-phase1-cutover.md new file mode 100644 index 000000000..34d15b924 --- /dev/null +++ b/docs/superpowers/plans/2026-08-17-a2ui-v09-phase1-cutover.md @@ -0,0 +1,351 @@ +# A2UI v0.9.1 Phase 1 — Wire-Format Cutover 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:** Atomically migrate every A2UI touchpoint (TS libs, Angular renderer, Python emitters, fixtures, docs) from the legacy `beginRendering`/`surfaceUpdate` shape to the A2UI v0.9.1 stable wire format. Clean cutover, no legacy acceptance. + +**Architecture:** `libs/a2ui` is the single canonical wire definition; everything else consumes it. Rewrite the protocol core first (types → guards → resolve → parser → pointer semantics), then the chat renderer (store → surface-to-spec → catalog), then Python emitters + prompts, then fixtures, then docs. Spec: `docs/superpowers/specs/2026-08-17-a2ui-v09-stable-migration-design.md` (the wire-format diff and catalog prop table there are normative for this plan). + +**Tech Stack:** Nx monorepo, Angular signals, vitest, Python/pydantic + pytest, Playwright e2e with aimock fixtures. + +**Phases 2–4** (client-side functions, checks, sendDataModel) get their own plan docs after this phase merges. + +--- + +## Ground rules for every task + +- TDD: update/write the failing spec first, run it (`npx nx test a2ui` / `npx nx test chat -- --run `), implement, re-run green, commit. +- NEVER `replace_all` for `ChatMessage`/`ChatInterrupt`-adjacent names, and here specifically `A2uiComponent*` substrings (`A2uiComponentView` must survive renames). +- Worktree prep (once, before any chat test/serve): run `node scripts/generate-public-key.mjs` if present and copy `node_modules/katex` from the main checkout if missing. +- New public exports ⇒ `npm run generate-api-docs` + commit before PR (CI fails on lint *errors* only; strip ANSI before grepping lint output). +- Implementation-time spec check: before Task 1, download the official catalog schema `https://a2ui.org/specification/v0_9/catalogs/basic/catalog.json` and the message schema linked from `https://a2ui.org/specification/v0.9-a2ui/` into the scratchpad; where this plan and the official schema disagree, **the official schema wins** (update the plan's prop table inline). + +### Task 0: Branch + worktree prep + +- [ ] `git checkout -b blove/a2ui-v09-phase1` from current worktree HEAD (which holds the design doc). +- [ ] Worktree dep gaps: `ls node_modules/katex || cp -R ~/repos/angular-agent-framework/node_modules/katex node_modules/`; run `node scripts/generate-public-key.mjs` if the script exists. +- [ ] Download official schemas to scratchpad; diff against the spec's catalog table; correct plan inline if needed. +- [ ] Baseline: `npx nx run-many -t test -p a2ui chat --exclude='*e2e*'` green before touching anything. + +### Task 1: `libs/a2ui/src/lib/types.ts` — full rewrite + +**Files:** Modify `libs/a2ui/src/lib/types.ts`, `libs/a2ui/src/lib/types.spec.ts`. + +- [ ] **Step 1: failing spec** — rewrite `types.spec.ts` compile-time assertions to the new vocabulary (envelopes with `version`, flat component, bare literals). Representative: + +```ts +const create: A2uiMessage = { + version: 'v0.9', + createSurface: { surfaceId: 's1', catalogId: A2UI_BASIC_CATALOG_ID, sendDataModel: true }, +}; +const update: A2uiMessage = { + version: 'v0.9', + updateComponents: { + surfaceId: 's1', + components: [ + { id: 'root', component: 'Column', children: ['title', 'cta'] }, + { id: 'title', component: 'Text', text: 'Hello', variant: 'h2' }, + { id: 'cta', component: 'Button', child: 'cta-text', variant: 'primary', + action: { event: { name: 'submit', context: { flightId: { path: '/selected' } } } } }, + ], + }, +}; +const data: A2uiMessage = { + version: 'v0.9', + updateDataModel: { surfaceId: 's1', path: '/selected', value: 'UA-42' }, +}; +const del: A2uiMessage = { version: 'v0.9', deleteSurface: { surfaceId: 's1' } }; +``` + +- [ ] **Step 2:** `npx nx test a2ui` — expect type errors (FAIL). +- [ ] **Step 3: implement.** New `types.ts` core (complete component-prop interfaces follow the spec table in the design doc §Catalog changes; each interface extends `A2uiComponentBase`): + +```ts +// SPDX-License-Identifier: MIT + +export const A2UI_WIRE_VERSION = 'v0.9'; +export const A2UI_MIME_TYPE = 'application/a2ui+json'; +export const A2UI_BASIC_CATALOG_ID = + 'https://a2ui.org/specification/v0_9/catalogs/basic/catalog.json'; + +// --- Dynamic values: bare literal | path binding | function call (typed now, resolved in Phase 2) +export interface A2uiPathRef { path: string } +export interface A2uiFunctionCall { call: string; args?: Record; returnType?: string } +export type DynamicString = string | A2uiPathRef | A2uiFunctionCall; +export type DynamicNumber = number | A2uiPathRef | A2uiFunctionCall; +export type DynamicBoolean = boolean | A2uiPathRef | A2uiFunctionCall; +export type DynamicStringList = string[] | A2uiPathRef | A2uiFunctionCall; +export type DynamicValue = unknown; + +// --- Children: static id list | template +export type A2uiChildren = string[] | { path: string; componentId: string }; + +// --- Actions +export interface A2uiEventAction { event: { name: string; context?: Record } } +export interface A2uiFunctionAction { functionCall: A2uiFunctionCall } +export type A2uiAction = A2uiEventAction | A2uiFunctionAction; + +// --- Validation checks (typed now, enforced in Phase 3) +export interface A2uiCheck { call: string; args?: Record; message?: string } + +// --- Components: flat, discriminated by `component` string +export interface A2uiComponentBase { + id: string; + component: string; + catalogId?: string; + weight?: number; + checks?: A2uiCheck[]; +} +export interface A2uiText extends A2uiComponentBase { component: 'Text'; text: DynamicString; variant?: 'h1'|'h2'|'h3'|'h4'|'h5'|'caption'|'body' } +export interface A2uiImage extends A2uiComponentBase { component: 'Image'; url: DynamicString; description?: DynamicString; fit?: 'contain'|'cover'|'fill'|'none'|'scaleDown'; variant?: 'icon'|'avatar'|'smallFeature'|'mediumFeature'|'largeFeature'|'header' } +export interface A2uiIcon extends A2uiComponentBase { component: 'Icon'; name: DynamicString | { svgPath: string } } +export interface A2uiVideo extends A2uiComponentBase { component: 'Video'; url: DynamicString } +export interface A2uiAudioPlayer extends A2uiComponentBase { component: 'AudioPlayer'; url: DynamicString; description?: DynamicString } +export interface A2uiRow extends A2uiComponentBase { component: 'Row'; children: A2uiChildren; justify?: 'start'|'center'|'end'|'spaceAround'|'spaceBetween'|'spaceEvenly'|'stretch'; align?: 'start'|'center'|'end'|'stretch' } +export interface A2uiColumn extends A2uiComponentBase { component: 'Column'; children: A2uiChildren; justify?: 'start'|'center'|'end'|'spaceAround'|'spaceBetween'|'spaceEvenly'|'stretch'; align?: 'start'|'center'|'end'|'stretch' } +export interface A2uiList extends A2uiComponentBase { component: 'List'; children: A2uiChildren; direction?: 'vertical'|'horizontal'; align?: 'start'|'center'|'end'|'stretch' } +export interface A2uiCard extends A2uiComponentBase { component: 'Card'; child: string } +export interface A2uiTabs extends A2uiComponentBase { component: 'Tabs'; tabs: { title: DynamicString; child: string }[] } +export interface A2uiModal extends A2uiComponentBase { component: 'Modal'; trigger: string; content: string } +export interface A2uiDivider extends A2uiComponentBase { component: 'Divider'; axis?: 'horizontal'|'vertical' } +export interface A2uiButton extends A2uiComponentBase { component: 'Button'; child: string; variant?: 'default'|'primary'|'borderless'; action: A2uiAction } +export interface A2uiCheckBox extends A2uiComponentBase { component: 'CheckBox'; label: DynamicString; value: DynamicBoolean } +export interface A2uiTextField extends A2uiComponentBase { component: 'TextField'; label: DynamicString; value?: DynamicString; variant?: 'shortText'|'longText'|'number'|'obscured'; validationRegexp?: string } +export interface A2uiDateTimeInput extends A2uiComponentBase { component: 'DateTimeInput'; value: DynamicString; enableDate?: boolean; enableTime?: boolean; min?: DynamicString; max?: DynamicString; label?: DynamicString } +export interface A2uiChoicePicker extends A2uiComponentBase { component: 'ChoicePicker'; options: { label: DynamicString; value: string }[]; value: DynamicStringList; variant?: 'mutuallyExclusive'|'multipleSelection'; displayStyle?: 'checkbox'|'chips'; filterable?: boolean; label?: DynamicString } +export interface A2uiSlider extends A2uiComponentBase { component: 'Slider'; value: DynamicNumber; max: number; min?: number; label?: DynamicString } + +export type A2uiCatalogComponent = + | A2uiText | A2uiImage | A2uiIcon | A2uiVideo | A2uiAudioPlayer + | A2uiRow | A2uiColumn | A2uiList | A2uiCard | A2uiTabs | A2uiModal | A2uiDivider + | A2uiButton | A2uiCheckBox | A2uiTextField | A2uiDateTimeInput | A2uiChoicePicker | A2uiSlider; +/** Any component, including non-basic-catalog types the renderer treats as unknown. */ +export type A2uiComponent = A2uiCatalogComponent | (A2uiComponentBase & Record); + +// --- Theme +export interface A2uiTheme { primaryColor?: string; iconUrl?: string; agentDisplayName?: string } + +// --- Envelopes (server → client) +export interface A2uiCreateSurface { surfaceId: string; catalogId: string; theme?: A2uiTheme; sendDataModel?: boolean } +export interface A2uiUpdateComponents { surfaceId: string; components: A2uiComponent[] } +export interface A2uiUpdateDataModel { surfaceId: string; path?: string; value?: unknown } +export interface A2uiDeleteSurface { surfaceId: string } +interface A2uiEnvelopeBase { version: string } +export type A2uiMessage = A2uiEnvelopeBase & ( + | { createSurface: A2uiCreateSurface } + | { updateComponents: A2uiUpdateComponents } + | { updateDataModel: A2uiUpdateDataModel } + | { deleteSurface: A2uiDeleteSurface } +); + +// --- Client → agent +export interface A2uiActionMessage { + version: string; + action: { + name: string; surfaceId: string; sourceComponentId: string; + timestamp: string; context?: Record; + /** Threadplane extension: human label for transcript bubbles (see 2026-05-19 design). */ + label?: string; + }; + metadata?: { a2uiClientDataModel?: A2uiClientDataModel }; +} +export interface A2uiErrorMessage { + version: string; + error: { code: string; surfaceId?: string; path?: string; message?: string }; +} +export interface A2uiClientDataModel { surfaces: Record> } +export interface A2uiClientCapabilities { supportedCatalogIds: string[]; inlineCatalogs?: unknown[] } + +// --- Internal surface model (renderer state, not wire) +export interface A2uiSurface { + surfaceId: string; + catalogId: string; + theme?: A2uiTheme; + sendDataModel?: boolean; + components: Map; + dataModel: Record; +} +``` + +Deleted names: `A2uiComponentDef`, `A2uiBeginRendering`, `A2uiSurfaceUpdate`, `A2uiDataModelUpdate`, `A2uiDataModelEntry`, `A2uiActionContextEntry`, `A2uiTabItem`, `A2uiMultipleChoice`, literal-wrapper Dynamic variants, `A2uiSurface.styles`. + +- [ ] **Step 4:** `npx nx test a2ui -- --run types` — types.spec green (other specs still red until Tasks 2–4). +- [ ] **Step 5:** Commit `feat(a2ui)!: v0.9 wire types`. + +### Task 2: guards + +**Files:** Modify `libs/a2ui/src/lib/guards.ts`, `guards.spec.ts`. + +- [ ] Failing spec: `isPathRef({path:'/x'})` true; `isFunctionCall({call:'formatDate'})` true; `isFunctionCall({path:'/x'})` false; literal-wrapper guards no longer exported (compile error if imported). +- [ ] Implement: keep `isPathRef` (unchanged); add: + +```ts +/** Returns true when `value` is an A2UI client-side function call. */ +export function isFunctionCall(value: unknown): value is { call: string; args?: Record } { + return typeof value === 'object' && value !== null + && 'call' in value && typeof (value as { call: unknown }).call === 'string'; +} +``` + +Delete `isLiteralString`/`isLiteralNumber`/`isLiteralBoolean`. +- [ ] `npx nx test a2ui -- --run guards` green. Commit. + +### Task 3: resolve + +**Files:** Modify `libs/a2ui/src/lib/resolve.ts`, `resolve.spec.ts`. + +- [ ] Failing spec: bare literals pass through (`'Hi'`, `5`, `true`, `['a']`); `{path}` absolute + scope-relative resolve; `{call:'formatString',...}` returns `undefined` (Phase 2 placeholder); arrays recurse; plain objects **without** `path`/`call` keys pass through unchanged (options arrays etc. — note: element-wise recursion applies to arrays so `[{path:'/x'}]` resolves element-wise). +- [ ] Implement: drop literal-wrapper unwrapping; keep `resolvePathRef` and array recursion; add `if (isFunctionCall(value)) return undefined; // Phase 2` before the path check. `isPathRef`/`isFunctionCall` imported from `./guards.js` (single source; delete resolve.ts's private copies). +- [ ] Green + commit. + +### Task 4: parser + +**Files:** Modify `libs/a2ui/src/lib/parser.ts`, `parser.spec.ts`. + +- [ ] Failing spec: parses the four v0.9 envelopes and **preserves `version`**; skips unknown envelope keys (`{"version":"v1.0","callRendererFunction":{...}}` → dropped, no throw); still skips malformed lines and buffers partial lines. +- [ ] Implement: `ENVELOPE_KEYS = ['createSurface','updateComponents','updateDataModel','deleteSurface']`; `parseEnvelope` returns `{ version: String(json['version'] ?? 'v0.9'), [key]: json[key] }`. +- [ ] Green + commit. + +### Task 5: pointer delete semantics + +**Files:** Modify `libs/a2ui/src/lib/pointer.ts` (only if needed), `pointer.spec.ts`. + +- [ ] Spec additions: `deleteByPointer(model,'/items/1')` on an array **sets index 1 to `undefined` preserving length** (v0.9 rule); object key removal unchanged; root delete returns `{}`. +- [ ] Run; fix `deleteByPointer` only if the array case fails (current impl may splice). Green + commit. + +### Task 6: `libs/a2ui` public API + README + +**Files:** Modify `libs/a2ui/src/index.ts`, `libs/a2ui/README.md`. + +- [ ] Export new names (constants, `isFunctionCall`, new types incl. `A2uiErrorMessage`, `A2uiClientCapabilities`, per-component interfaces, `A2uiCatalogComponent`); remove deleted names. `npx nx run a2ui:build` green. +- [ ] Rewrite README Quick-start/Capabilities to v0.9 (createSurface example, bare literals, root-id note, MIME type). Commit. + +### Task 7: chat surface store + +**Files:** Modify `libs/chat/src/lib/a2ui/surface-store.ts`, `surface-store.spec.ts`, `libs/chat/src/lib/a2ui/extract-bindings.ts` (+spec) if binding syntax touches wire shapes. + +- [ ] Failing specs (core semantics): + +```ts +it('opens a surface on createSurface and renders once root arrives', () => { + const store = createA2uiSurfaceStore(); + store.apply({ version: 'v0.9', createSurface: { surfaceId: 's1', catalogId: BASIC } }); + store.apply({ version: 'v0.9', updateComponents: { surfaceId: 's1', components: [ + { id: 'title', component: 'Text', text: 'Hi' }, + ] } }); + expect(store.surfaces().get('s1')).toBeUndefined(); // no root yet → buffered + store.apply({ version: 'v0.9', updateComponents: { surfaceId: 's1', components: [ + { id: 'root', component: 'Column', children: ['title'] }, + ] } }); + expect(store.surfaces().get('s1')?.components.size).toBe(2); // renders, tree fills progressively +}); +it('merges updateComponents by id instead of replacing the map', ...); +it('buffers components for a surface with no createSurface yet', ...); +it('applies updateDataModel value at path', ...); +it('replaces whole model when path omitted', ...); +it('deletes key when value omitted', ...); +it('keeps existing dataModel on re-open (createSurface for existing id)', ...); +``` + +- [ ] Implement: `SurfaceBuffer` becomes `{ create?: A2uiCreateSurface; components: Map; componentViews: Map; dataModelDeltas: { path?: string; value?: unknown; del?: boolean }[] }`. `apply()` branches: + - `createSurface`: record in buffer (or refresh catalogId/theme/sendDataModel on live surface); attempt commit. + - `updateComponents`: **merge** each component by id into buffer (or live surface) — v0.9 updates are incremental, not replace-all; project `A2uiComponentView` per component (type = `component` string field now; `def` = the flat component); attempt commit. + - commit condition: buffer has `create` AND a component with id `'root'` → build `A2uiSurface` (catalogId/theme/sendDataModel from `create`), fold buffered deltas via `setByPointer`/`deleteByPointer`, publish both signals, keep buffer for subsequent incremental merges (live surface path). + - `updateDataModel`: on live surface `path`+`value` → `setByPointer`; omitted `value` → `deleteByPointer`; omitted/`/` path with value → replace model; pre-commit → push delta. Readiness recompute unchanged (monotonic rule stays). + - `deleteSurface`: unchanged. +- [ ] Green (`npx nx test chat -- --run surface-store`) + commit. + +### Task 8: surface-to-spec + +**Files:** Modify `libs/chat/src/lib/a2ui/surface-to-spec.ts`, `surface-to-spec.spec.ts`. + +- [ ] Failing spec: flat component consumption; `action.event` → `a2ui:event` params with **resolved context object**; `action.functionCall` → ignored (Phase 2), no `on` emitted; children plain array; template `{path, componentId}` expansion (scope base = `${path}/${i}`); Tabs `tabs[{title,child}]` → children + `tabTitles`; Modal `trigger`/`content`; ChoicePicker options label resolution; root fallback logic unchanged. +- [ ] Implement: delete `unwrapComponentDef`; `RESERVED_PROP_KEYS = new Set(['id','component','catalogId','weight','checks','child','children','action','tabs','trigger','content'])`; `resolveAction` reads `action.event.name` / `.context` (object iteration, `resolveDynamic` each value); `childrenToList`: `Array.isArray(children)` → ids; `'path' in children` → template expansion off `children.path`; prop loop: `isPathRef` → `$bindState` binding (unchanged), `isFunctionCall` → skip prop (Phase 2), else `resolveDynamic`. +- [ ] Green + commit. + +### Task 9: catalog components (18 files) + +**Files:** Modify each of `libs/chat/src/lib/a2ui/catalog/*.component.ts` (+ its spec), `catalog/index.ts`; **rename** `multiple-choice.component.ts` → `choice-picker.component.ts` (`A2uiMultipleChoiceComponent` → `A2uiChoicePickerComponent`, selector/type key `ChoicePicker`). No `replace_all` — the `MultipleChoice` string appears in registry keys, specs, and prompts; touch each site explicitly. + +Prop mapping (old input → new input; template/CSS updated accordingly; all specs first, then implementation): + +| Component | Changes | +|---|---| +| Text | `usageHint` → `variant` (same enum values + `body` default) | +| Image | `alt` → `description`; drop `width`/`height`; add `fit` (object-fit map), `variant` (size class map) | +| Icon | `icon` → `name` (string enum or `{svgPath}`), drop `size` | +| Video/AudioPlayer | drop `autoPlay`/`controls` inputs (native controls stay on); AudioPlayer gains `description` | +| Row/Column | `alignment` → `align`, `distribution` → `justify` (spec enums incl. `spaceAround` camelCase → CSS map); drop `gap` input (keep token-based default gap) | +| List | add `align`; keep `direction` | +| Card | unchanged (`child`) | +| Tabs | `tabItems`/`tabTitles` plumbing unchanged in component; input rename only where it read `tabItems` | +| Divider | `direction` → `axis` | +| Modal | `entryPointChild`/`contentChild` → `trigger`/`content` | +| Button | `primary: boolean` → `variant: 'default'\|'primary'\|'borderless'` (class map) | +| CheckBox | `checked` → `value` (two-way emit path unchanged via `emitBinding`) | +| TextField | `text` → `value`; `textFieldType` → `variant` (drop `date` — DateTimeInput owns dates); keep `validationRegexp` as pass-through attr (enforced Phase 3) | +| DateTimeInput | `value` required; add `min`/`max` | +| ChoicePicker (was MultipleChoice) | `selections` → `value`; `maxAllowedSelections` → `variant` (`mutuallyExclusive` = radio-like single, `multipleSelection` = multi); add `displayStyle` (`checkbox`\|`chips` — chips = existing chip styling), `filterable` (text filter input when true) | +| Slider | `minValue`/`maxValue` → `min`/`max`; drop `step` input | + +- [ ] Specs first per component (`npx nx test chat -- --run catalog`), then implement, then green. +- [ ] Update `catalog/index.ts` registry (`MultipleChoice` key → `ChoicePicker`) and `a2uiBasicCatalog()` docs. +- [ ] Commit per logical group (layout, media, inputs). + +### Task 10: chat glue + public API + +**Files:** Modify `libs/chat/src/lib/a2ui/envelope-normalizer.ts` (+spec), `partial-args-bridge.ts` (+spec), `build-action-message.ts` (+spec), `action-label.ts` (+spec), `views.ts`/`component-view.ts` (+specs), `a2ui-default-fallback.component.ts`, `surface.component.ts` (+spec), `libs/chat/src/lib/streaming/content-classifier.ts` (+spec), `libs/chat/src/lib/compositions/chat/chat.component.ts` (envelope-key sniffer), `libs/chat/src/public-api.ts`. + +- [ ] `envelope-normalizer`: `ENVELOPE_KEYS = ['createSurface','updateComponents','updateDataModel','deleteSurface']`; same four arg shapes. +- [ ] `partial-args-bridge`: envelope-key list update; emission gating reviewed for "createSurface before components" ordering (was beginRendering-last). +- [ ] `build-action-message`: emit v0.9 client action — `{ version: 'v0.9', action: { name, surfaceId, sourceComponentId, timestamp, context } }`, context now a plain object built from the (already-resolved) `a2ui:event` params; label derivation now reads Button `child` → Text `text` **bare string**. +- [ ] `action-label`: parse the new serialized action JSON shape. +- [ ] `component-view`: `type` comes from `component` string; `def` type becomes flat `A2uiComponent`. +- [ ] `content-classifier` + `chat.component.ts`: sniff the new envelope keys (`createSurface`/`updateComponents`/`updateDataModel`/`deleteSurface`) in wrapped content; `---a2ui_JSON---` prefix unchanged. +- [ ] `public-api.ts`: re-export updates (ChoicePicker component, removed names). +- [ ] `npx nx test chat` fully green; `npx nx run chat:build` green; `npm run generate-api-docs`; commit. + +### Task 11: Python — examples/chat + examples/ag-ui (kept byte-identical pairs) + +**Files:** Modify in BOTH `examples/chat/python/src/` and `examples/ag-ui/python/src/`: `schemas/a2ui_v1.py` → rename `schemas/a2ui_v09.py`; `streaming/envelope_tool.py`; `streaming/envelope_normalizer.py`; `streaming/a2ui_partial_handler.py` (key list only); `graph.py`; tests under `python/tests/`. + +- [ ] Failing pytest first (`test_envelope_tool.py`, `test_envelope_normalizer.py`, `test_graph_smoke.py`): assert v0.9 envelopes — `version` field present, `createSurface` emitted first, components flat, root id present, no `beginRendering`. +- [ ] `a2ui_v09.py`: regenerate the schema prompt from the official message + basic-catalog schema (structure mirrors the old prompt: one-of envelope, per-component prop docs, bare literals, `{path}` binding, children forms, `action.event`). Cross-check every prop against the downloaded schema. +- [ ] `envelope_tool.py`: pydantic `Envelope` with optional `createSurface`/`updateComponents`/`updateDataModel`/`deleteSurface` + `version` default `'v0.9'`; docstring ordering rule: `createSurface` first, then `updateComponents` containing `root` early, then data. +- [ ] `graph.py`: reorder logic — ensure `createSurface` is emitted/slotted first and a root component exists in the first `updateComponents`; `A2UI_PREFIX` unchanged. +- [ ] `pytest examples/chat/python examples/ag-ui/python` green; `diff -r` the paired files byte-identical; commit. + +### Task 12: cockpit graphs + +**Files:** Modify `cockpit/chat/a2ui/python/src/graph.py`, `cockpit/ag-ui/a2ui/python/src/graph.py`, sync `deployments/ag-ui-dev/deps/a2ui/src/graph.py` (byte-identical to cockpit ag-ui copy). Cockpit examples stay standalone — duplicate, don't share. + +- [ ] `ALLOWED_COMPONENTS` + pydantic `A2uiComponent` model: flat shape (`component: str` + props), drop single-key validator; `MultipleChoice` → `ChoicePicker`. +- [ ] `_SurfaceSpec`/`BookingFormSpec`/`FlightResultsSpec`/`ConfirmationSpec` wrappers now emit v0.9 envelopes (`createSurface` + `updateComponents` with root + `updateDataModel` path/value). +- [ ] System prompts (`_BUILD_FORM_SYSTEM_TMPL`, `_SEARCH_FLIGHTS_SYSTEM`) rewritten: flat component examples, bare literals, `children: [...]`, `action.event`. +- [ ] Python tests (if any per graph) + `pytest` green; commit. + +### Task 13: fixtures + e2e + +**Files:** Modify `examples/chat/angular/e2e/fixtures/a2ui-surface.json`, `contact-form.json`; byte-identical copies in `examples/ag-ui/angular/e2e/fixtures/`; `cockpit/chat/a2ui/angular/e2e/fixtures/c-a2ui.json`; byte-identical `cockpit/ag-ui/a2ui/angular/e2e/fixtures/a2ui.json`; the four e2e specs + manual harnesses if they assert wire strings. + +- [ ] Regenerate fixture payloads in v0.9 shape (hand-translate; keep aimock entry ordering — `hasToolResult:true` entry BEFORE the plain `userMessage` entry). +- [ ] Kill orphaned dev servers on :4200/:2024 first; run `examples/chat` a2ui e2e, then the ag-ui twin (both must be updated together), then cockpit c-a2ui e2e (chat + ag-ui). All green; commit. + +### Task 14: docs + +**Files:** Modify `apps/website/content/docs/a2ui/**` (7 pages), `apps/website/content/docs/chat/a2ui/**` (4 pages), `apps/website/content/docs/render/concepts/json-render-vs-a2ui.mdx`, `apps/website/content/docs/chat/api/content-classifier.mdx`, `libs/chat/README.md`, root `README.md`, `examples/*/README.md`; regenerate both `api-docs.json` via `npm run generate-api-docs`. + +- [ ] Rewrite protocol snippets to v0.9 (envelopes, flat components, bare literals, root-id, ChoicePicker, MIME type); message-protocol.mdx is the envelope reference — mirror the design doc's diff table. +- [ ] Website builds: `npx nx build website` (or the repo's docs check); docs e2e (`docs.spec.ts`) selectors still pass; commit. + +### Task 15: verification + PR + +- [ ] `npx nx affected -t lint test build` — zero lint **errors** (strip ANSI before grepping), all tests green, builds green including one example prod build (`npx nx build chat-example --configuration=production` per strict:false footgun note; Maps env var not needed locally for build). +- [ ] Live Chrome gate: serve examples/chat with real `OPENAI_API_KEY` **only** (do not source whole .env — AG_UI_INTERNAL_TOKEN 401 trap), drive a generative-UI prompt in Chrome, verify a surface renders and a Button action round-trips; screenshot as proof. Repeat spot-check on cockpit c-a2ui. +- [ ] PR to main: title `feat(a2ui)!: migrate to A2UI v0.9.1 stable wire format`, body = design-doc summary + verification evidence. Merge on green per repo convention (Vercel is the only required check; address AI review comments; arm auto-merge). + +## Self-review notes + +- Spec coverage: design §Phase 1 items 1–5 map to Tasks 1–6 (libs/a2ui), 7–10 (renderer), 11–12 (Python), 13 (fixtures), 14 (docs), 15 (exit criteria). Phases 2–4 intentionally deferred to their own plans. +- Type consistency: `A2uiPathRef`/`A2uiFunctionCall`/`A2uiChildren`/`A2uiAction` names used consistently across Tasks 1, 3, 8; `A2uiComponentView.def` flat-component change appears in Tasks 7 and 10. +- Known judgment calls encoded: incremental `updateComponents` merge (v0.9) vs old replace; catalog `gap`/`step`/`width`/`height` prop drops (not in official schema); TextField `date` variant removed. From 3ec0087c198e85962ace4416bd8db0035c561ab7 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Mon, 17 Aug 2026 07:53:02 -0700 Subject: [PATCH 03/15] feat(a2ui)!: v0.9 wire types Co-Authored-By: Claude Fable 5 --- libs/a2ui/src/lib/types.spec.ts | 132 ++++++++--- libs/a2ui/src/lib/types.ts | 376 ++++++++++++++++++-------------- 2 files changed, 310 insertions(+), 198 deletions(-) diff --git a/libs/a2ui/src/lib/types.spec.ts b/libs/a2ui/src/lib/types.spec.ts index 9a7686e38..e353cb63e 100644 --- a/libs/a2ui/src/lib/types.spec.ts +++ b/libs/a2ui/src/lib/types.spec.ts @@ -1,69 +1,131 @@ // SPDX-License-Identifier: MIT import { describe, expect, test } from 'vitest'; +import { + A2UI_WIRE_VERSION, A2UI_MIME_TYPE, A2UI_BASIC_CATALOG_ID, +} from './types'; import type { - A2uiMessage, A2uiComponentDef, DynamicString, DynamicNumber, - A2uiButton, A2uiText, A2uiTextField, A2uiCard, A2uiMultipleChoice, - A2uiSurfaceUpdate, A2uiBeginRendering, A2uiDataModelUpdate, + A2uiMessage, A2uiComponent, DynamicString, + A2uiButton, A2uiText, A2uiTextField, A2uiCard, A2uiChoicePicker, + A2uiChildren, A2uiAction, A2uiActionMessage, A2uiErrorMessage, } from './types'; -describe('a2ui v1 types', () => { - test('DynamicString accepts literalString or path', () => { - const lit: DynamicString = { literalString: 'hello' }; +describe('a2ui v0.9 types', () => { + test('protocol constants', () => { + expect(A2UI_WIRE_VERSION).toBe('v0.9'); + expect(A2UI_MIME_TYPE).toBe('application/a2ui+json'); + expect(A2UI_BASIC_CATALOG_ID).toContain('catalogs/basic/catalog.json'); + }); + + test('DynamicString accepts bare literal, path binding, or function call', () => { + const lit: DynamicString = 'hello'; const ref: DynamicString = { path: '/title' }; + const call: DynamicString = { call: 'formatString', args: { value: { path: '/n' } } }; expect(lit).toBeDefined(); expect(ref).toBeDefined(); + expect(call).toBeDefined(); }); - test('A2uiComponentDef is discriminated by component type key', () => { - const button: A2uiComponentDef = { - Button: { child: 'btn-text', action: { name: 'click' } }, + test('components are flat, discriminated by component string', () => { + const button: A2uiButton = { + id: 'cta', component: 'Button', child: 'cta-text', variant: 'primary', + action: { event: { name: 'submit', context: { flightId: { path: '/selected' } } } }, }; - const text: A2uiComponentDef = { - Text: { text: { literalString: 'Hi' } }, - }; - expect('Button' in button).toBe(true); - expect('Text' in text).toBe(true); + const text: A2uiText = { id: 't', component: 'Text', text: 'Hi', variant: 'h2' }; + expect(button.component).toBe('Button'); + expect(text.component).toBe('Text'); }); - test('A2uiMessage discriminated by envelope key', () => { - const surfaceUpdate: A2uiMessage = { - surfaceUpdate: { + test('every envelope carries version and is discriminated by envelope key', () => { + const create: A2uiMessage = { + version: 'v0.9', + createSurface: { surfaceId: 's1', catalogId: A2UI_BASIC_CATALOG_ID, sendDataModel: true }, + }; + const update: A2uiMessage = { + version: 'v0.9', + updateComponents: { surfaceId: 's1', components: [ - { id: 'root', component: { Card: { child: 'inner' } } }, + { id: 'root', component: 'Column', children: ['title', 'cta'] } as A2uiComponent, + { id: 'title', component: 'Text', text: 'Hello' } as A2uiComponent, ], }, }; - const beginRendering: A2uiMessage = { - beginRendering: { surfaceId: 's1', root: 'root' }, + const data: A2uiMessage = { + version: 'v0.9', + updateDataModel: { surfaceId: 's1', path: '/selected', value: 'UA-42' }, }; - expect('surfaceUpdate' in surfaceUpdate).toBe(true); - expect('beginRendering' in beginRendering).toBe(true); + const del: A2uiMessage = { version: 'v0.9', deleteSurface: { surfaceId: 's1' } }; + expect('createSurface' in create).toBe(true); + expect('updateComponents' in update).toBe(true); + expect('updateDataModel' in data).toBe(true); + expect('deleteSurface' in del).toBe(true); + }); + + test('updateDataModel value is optional (omission means delete at path)', () => { + const del: A2uiMessage = { + version: 'v0.9', + updateDataModel: { surfaceId: 's1', path: '/stale' }, + }; + expect('updateDataModel' in del && del.updateDataModel.value).toBeUndefined(); + }); + + test('children accept a static id list or a template object', () => { + const list: A2uiChildren = ['a', 'b']; + const template: A2uiChildren = { path: '/items', componentId: 'row-template' }; + expect(Array.isArray(list)).toBe(true); + expect('componentId' in template).toBe(true); }); - test('A2uiTextField uses wrapped DynamicString for label and text', () => { + test('actions are event- or functionCall-shaped', () => { + const evt: A2uiAction = { event: { name: 'submit', context: { id: 'x' } } }; + const fn: A2uiAction = { functionCall: { call: 'openUrl', args: { url: 'https://x' } } }; + expect('event' in evt).toBe(true); + expect('functionCall' in fn).toBe(true); + }); + + test('A2uiTextField uses bare or bound label/value with v0.9 variant enum', () => { const tf: A2uiTextField = { - label: { literalString: 'Name' }, - text: { path: '/name' }, - textFieldType: 'shortText', + id: 'name', component: 'TextField', + label: 'Name', value: { path: '/name' }, variant: 'shortText', }; - expect(tf.label).toEqual({ literalString: 'Name' }); + expect(tf.label).toBe('Name'); }); test('A2uiCard has single child (not array)', () => { - const card: A2uiCard = { child: 'inner' }; + const card: A2uiCard = { id: 'c', component: 'Card', child: 'inner' }; expect(card.child).toBe('inner'); }); - test('A2uiMultipleChoice has selections + options + maxAllowedSelections', () => { - const mc: A2uiMultipleChoice = { - selections: { path: '/picked' }, + test('A2uiChoicePicker has options + value + variant/displayStyle', () => { + const cp: A2uiChoicePicker = { + id: 'pick', component: 'ChoicePicker', + value: { path: '/picked' }, options: [ - { label: { literalString: 'A' }, value: 'a' }, - { label: { literalString: 'B' }, value: 'b' }, + { label: 'A', value: 'a' }, + { label: { path: '/labels/b' }, value: 'b' }, ], - maxAllowedSelections: 1, + variant: 'mutuallyExclusive', + displayStyle: 'chips', + }; + expect(cp.options).toHaveLength(2); + }); + + test('outbound action message carries name/surface/source/timestamp/context', () => { + const msg: A2uiActionMessage = { + version: 'v0.9', + action: { + name: 'submit', surfaceId: 's1', sourceComponentId: 'cta', + timestamp: '2026-08-17T00:00:00Z', context: { flightId: 'UA-42' }, + }, + }; + expect(msg.action.name).toBe('submit'); + }); + + test('outbound error message shape', () => { + const err: A2uiErrorMessage = { + version: 'v0.9', + error: { code: 'VALIDATION_FAILED', surfaceId: 's1', path: '/name', message: 'required' }, }; - expect(mc.options).toHaveLength(2); + expect(err.error.code).toBe('VALIDATION_FAILED'); }); }); diff --git a/libs/a2ui/src/lib/types.ts b/libs/a2ui/src/lib/types.ts index 1e8c93f86..5b734abcf 100644 --- a/libs/a2ui/src/lib/types.ts +++ b/libs/a2ui/src/lib/types.ts @@ -1,274 +1,324 @@ // SPDX-License-Identifier: MIT -// --- Theme --- +// --- Protocol constants --- + +/** Wire version stamped on every A2UI v0.9-family envelope. */ +export const A2UI_WIRE_VERSION = 'v0.9'; +/** MIME type for A2UI payloads, standardized in the v0.9.1 release. */ +export const A2UI_MIME_TYPE = 'application/a2ui+json'; +/** Catalog id of the standard A2UI basic component catalog. */ +export const A2UI_BASIC_CATALOG_ID = + 'https://a2ui.org/specification/v0_9/catalogs/basic/catalog.json'; + +// --- Dynamic values --- +// A dynamic value is a bare literal, a data-model binding, or a client-side +// function call (typed here; function execution ships in a later phase). + +/** JSON-pointer data-model binding. Absolute (`/a/b`) or relative inside templates. */ +export interface A2uiPathRef { + path: string; +} -export interface A2uiTheme { - primaryColor?: string; - iconUrl?: string; - agentDisplayName?: string; +/** Client-side function invocation (e.g. `formatString`, `required`). */ +export interface A2uiFunctionCall { + call: string; + args?: Record; + returnType?: 'string' | 'number' | 'boolean' | 'array' | 'object' | 'any' | 'void'; } -// --- Dynamic value types (always wrapped in v1) --- +export type DynamicString = string | A2uiPathRef | A2uiFunctionCall; +export type DynamicNumber = number | A2uiPathRef | A2uiFunctionCall; +export type DynamicBoolean = boolean | A2uiPathRef | A2uiFunctionCall; +export type DynamicStringList = string[] | A2uiPathRef | A2uiFunctionCall; +/** Any dynamic value position where the concrete type is component-defined. */ +export type DynamicValue = unknown; -export type DynamicString = - | { literalString: string } - | { path: string }; +// --- Children --- -export type DynamicNumber = - | { literalNumber: number } - | { path: string }; +/** Static child-id list, or a template stamped per element of a data-model list. */ +export type A2uiChildren = string[] | { path: string; componentId: string }; -export type DynamicBoolean = - | { literalBoolean: boolean } - | { path: string }; +// --- Actions --- -export type DynamicStringList = - | { literalArray: string[] } - | { path: string }; +/** Dispatches a named event (with optional context) to the agent. */ +export interface A2uiEventAction { + event: { + name: string; + context?: Record; + }; +} -// --- Children --- +/** Executes a client-side function locally (e.g. `openUrl`). */ +export interface A2uiFunctionAction { + functionCall: A2uiFunctionCall; +} -export type A2uiChildren = - | { explicitList: string[] } - | { template: { componentId: string; dataBinding: string } }; +export type A2uiAction = A2uiEventAction | A2uiFunctionAction; -// --- Actions --- +// --- Validation checks (typed in Phase 1, enforced in Phase 3) --- -export interface A2uiActionContextEntry { - key: string; - value: DynamicString | DynamicNumber | DynamicBoolean; +export interface A2uiCheck { + call: string; + args?: Record; + message?: string; } -export interface A2uiAction { - name: string; - context?: A2uiActionContextEntry[]; +// --- Components (flat, discriminated by the `component` string) --- + +export interface A2uiComponentBase { + id: string; + component: string; + /** Overrides the surface's default catalog for this component. */ + catalogId?: string; + /** Flex-grow-like weight; only valid as a direct child of Row/Column. */ + weight?: number; + /** Accessibility attributes (spec `AccessibilityAttributes`). */ + accessibility?: Record; } -// --- Per-component property interfaces --- +/** Mixin for input components that support client-side validation checks. */ +export interface A2uiCheckable { + checks?: A2uiCheck[]; +} -export interface A2uiText { +export interface A2uiText extends A2uiComponentBase { + component: 'Text'; text: DynamicString; - usageHint?: 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'caption' | 'body'; + variant?: 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'caption' | 'body'; } -export interface A2uiImage { +export interface A2uiImage extends A2uiComponentBase { + component: 'Image'; url: DynamicString; - alt?: DynamicString; - width?: number; - height?: number; + description?: DynamicString; + fit?: 'contain' | 'cover' | 'fill' | 'none' | 'scaleDown'; + variant?: 'icon' | 'avatar' | 'smallFeature' | 'mediumFeature' | 'largeFeature' | 'header'; } -export interface A2uiIcon { - icon: DynamicString; - size?: number; +export interface A2uiIcon extends A2uiComponentBase { + component: 'Icon'; + name: DynamicString | { svgPath: string }; } -export interface A2uiVideo { +export interface A2uiVideo extends A2uiComponentBase { + component: 'Video'; url: DynamicString; - autoPlay?: boolean; - controls?: boolean; } -export interface A2uiAudioPlayer { +export interface A2uiAudioPlayer extends A2uiComponentBase { + component: 'AudioPlayer'; url: DynamicString; - autoPlay?: boolean; - controls?: boolean; + description?: DynamicString; } -export interface A2uiRow { +type A2uiJustify = + | 'start' | 'center' | 'end' + | 'spaceAround' | 'spaceBetween' | 'spaceEvenly' | 'stretch'; +type A2uiAlign = 'start' | 'center' | 'end' | 'stretch'; + +export interface A2uiRow extends A2uiComponentBase { + component: 'Row'; children: A2uiChildren; - gap?: number; - alignment?: 'start' | 'center' | 'end' | 'stretch'; - distribution?: 'start' | 'center' | 'end' | 'space-between' | 'space-around'; + justify?: A2uiJustify; + align?: A2uiAlign; } -export interface A2uiColumn { +export interface A2uiColumn extends A2uiComponentBase { + component: 'Column'; children: A2uiChildren; - gap?: number; - alignment?: 'start' | 'center' | 'end' | 'stretch'; + justify?: A2uiJustify; + align?: A2uiAlign; } -export interface A2uiList { +export interface A2uiList extends A2uiComponentBase { + component: 'List'; children: A2uiChildren; direction?: 'vertical' | 'horizontal'; + align?: A2uiAlign; } -export interface A2uiCard { +export interface A2uiCard extends A2uiComponentBase { + component: 'Card'; child: string; } -export interface A2uiTabItem { - title: DynamicString; - child: string; +export interface A2uiTabs extends A2uiComponentBase { + component: 'Tabs'; + tabs: { title: DynamicString; child: string }[]; } -export interface A2uiTabs { - tabItems: A2uiTabItem[]; +export interface A2uiModal extends A2uiComponentBase { + component: 'Modal'; + trigger: string; + content: string; } -export interface A2uiDivider { - direction?: 'horizontal' | 'vertical'; +export interface A2uiDivider extends A2uiComponentBase { + component: 'Divider'; + axis?: 'horizontal' | 'vertical'; } -export interface A2uiModal { - entryPointChild: string; - contentChild: string; - title?: DynamicString; -} - -export interface A2uiButton { +export interface A2uiButton extends A2uiComponentBase { + component: 'Button'; child: string; - primary?: boolean; + variant?: 'default' | 'primary' | 'borderless'; action: A2uiAction; } -export interface A2uiCheckBox { +export interface A2uiCheckBox extends A2uiComponentBase, A2uiCheckable { + component: 'CheckBox'; label: DynamicString; - checked: DynamicBoolean; - action?: A2uiAction; + value: DynamicBoolean; } -export interface A2uiTextField { +export interface A2uiTextField extends A2uiComponentBase, A2uiCheckable { + component: 'TextField'; label: DynamicString; - text?: DynamicString; - textFieldType?: 'date' | 'longText' | 'number' | 'shortText' | 'obscured'; + value?: DynamicString; + variant?: 'shortText' | 'longText' | 'number' | 'obscured'; validationRegexp?: string; } -export interface A2uiDateTimeInput { - label: DynamicString; - value?: DynamicString; +export interface A2uiDateTimeInput extends A2uiComponentBase, A2uiCheckable { + component: 'DateTimeInput'; + /** ISO 8601 value. */ + value: DynamicString; enableDate?: boolean; enableTime?: boolean; + min?: DynamicString; + max?: DynamicString; + label?: DynamicString; } -export interface A2uiMultipleChoice { - selections: DynamicStringList; +export interface A2uiChoicePicker extends A2uiComponentBase, A2uiCheckable { + component: 'ChoicePicker'; options: { label: DynamicString; value: string }[]; - maxAllowedSelections?: number; + value: DynamicStringList; + variant?: 'mutuallyExclusive' | 'multipleSelection'; + displayStyle?: 'checkbox' | 'chips'; + filterable?: boolean; label?: DynamicString; } -export interface A2uiSlider { +export interface A2uiSlider extends A2uiComponentBase, A2uiCheckable { + component: 'Slider'; value: DynamicNumber; - minValue?: number; - maxValue?: number; - step?: number; + max: number; + min?: number; label?: DynamicString; } -// --- Component wrapper (type-keyed discriminated union) --- - -export type A2uiComponentDef = - | { Text: A2uiText } - | { Image: A2uiImage } - | { Icon: A2uiIcon } - | { Video: A2uiVideo } - | { AudioPlayer: A2uiAudioPlayer } - | { Row: A2uiRow } - | { Column: A2uiColumn } - | { List: A2uiList } - | { Card: A2uiCard } - | { Tabs: A2uiTabs } - | { Divider: A2uiDivider } - | { Modal: A2uiModal } - | { Button: A2uiButton } - | { CheckBox: A2uiCheckBox } - | { TextField: A2uiTextField } - | { DateTimeInput: A2uiDateTimeInput } - | { MultipleChoice: A2uiMultipleChoice } - | { Slider: A2uiSlider }; - -export interface A2uiComponent { - id: string; - weight?: number; - component: A2uiComponentDef; -} +/** Union of the basic-catalog component shapes. */ +export type A2uiCatalogComponent = + | A2uiText | A2uiImage | A2uiIcon | A2uiVideo | A2uiAudioPlayer + | A2uiRow | A2uiColumn | A2uiList | A2uiCard | A2uiTabs | A2uiModal | A2uiDivider + | A2uiButton | A2uiCheckBox | A2uiTextField | A2uiDateTimeInput + | A2uiChoicePicker | A2uiSlider; -// --- Envelopes --- +/** + * Any component, including non-basic-catalog types. Renderers treat unknown + * `component` strings as unrenderable and fall back gracefully. + */ +export type A2uiComponent = A2uiCatalogComponent | (A2uiComponentBase & Record); -export interface A2uiSurfaceUpdate { - surfaceId: string; - components: A2uiComponent[]; +// --- Theme --- + +export interface A2uiTheme { + primaryColor?: string; + iconUrl?: string; + agentDisplayName?: string; } -export interface A2uiDataModelEntry { - key: string; - valueString?: string; - valueNumber?: number; - valueBoolean?: boolean; - valueMap?: A2uiDataModelEntry[]; +// --- Envelopes (agent → client) --- + +export interface A2uiCreateSurface { + surfaceId: string; + catalogId: string; + theme?: A2uiTheme; + /** When true, the client attaches the surface's full data model to every outbound message. */ + sendDataModel?: boolean; } -export interface A2uiDataModelUpdate { +export interface A2uiUpdateComponents { surfaceId: string; - path?: string; - contents: A2uiDataModelEntry[]; + components: A2uiComponent[]; } -export interface A2uiBeginRendering { +export interface A2uiUpdateDataModel { surfaceId: string; - root: string; - styles?: { font?: string; primaryColor?: string }; + /** JSON pointer into the data model. Omitted or `/` targets the whole model. */ + path?: string; + /** Replacement value at `path`. Omitted value deletes the key at `path`. */ + value?: unknown; } export interface A2uiDeleteSurface { surfaceId: string; } -export type A2uiMessage = - | { surfaceUpdate: A2uiSurfaceUpdate } - | { dataModelUpdate: A2uiDataModelUpdate } - | { beginRendering: A2uiBeginRendering } - | { deleteSurface: A2uiDeleteSurface }; - -// --- Surface (internal model, not constrained by wire format) --- - -export interface A2uiSurface { - surfaceId: string; - catalogId: string; - theme?: A2uiTheme; - sendDataModel?: boolean; - components: Map; - dataModel: Record; - /** Styles set by the agent via `beginRendering.styles`. The - * canonical v1 spec defines exactly two fields: `font` (primary - * font for the UI) and `primaryColor` (hex `#RRGGBB`). The renderer - * applies these as CSS custom properties on the surface root, - * overriding any consumer-set defaults for the duration of the - * surface's life. Anything richer (typography scale, spacing, - * elevation, etc.) is the renderer's private vocabulary and not - * communicated through this field. */ - styles?: { font?: string; primaryColor?: string }; +interface A2uiEnvelopeBase { + /** Wire version; `v0.9` for the stable family. */ + version: string; } -// --- Outbound shapes --- +export type A2uiMessage = A2uiEnvelopeBase & + ( + | { createSurface: A2uiCreateSurface } + | { updateComponents: A2uiUpdateComponents } + | { updateDataModel: A2uiUpdateDataModel } + | { deleteSurface: A2uiDeleteSurface } + ); + +// --- Client → agent messages --- export interface A2uiClientDataModel { - version: 'v1'; surfaces: Record>; } +export interface A2uiClientCapabilities { + supportedCatalogIds: string[]; + inlineCatalogs?: unknown[]; +} + export interface A2uiActionMessage { - version: 'v1'; + version: string; action: { name: string; surfaceId: string; sourceComponentId: string; timestamp: string; - context: Record; + context?: Record; /** - * Optional human-friendly label for the action — typically derived - * from the source component's authored text (e.g. a Button's child - * Text literalString). Set by `buildA2uiActionMessage` when the - * source is a Button-with-Text-child; left undefined otherwise. - * Used by the chat-lib's transcript renderer to label the user - * bubble; backends may ignore. See spec + * Threadplane extension: optional human-friendly label for the action — + * typically derived from the source component's authored text (e.g. a + * Button's child Text). Used by the chat-lib's transcript renderer to + * label the user bubble; backends may ignore. See spec * 2026-05-19-llm-generated-labels-design.md. */ label?: string; }; metadata?: { - a2uiClientDataModel: A2uiClientDataModel; + a2uiClientDataModel?: A2uiClientDataModel; }; } + +export interface A2uiErrorMessage { + version: string; + error: { + code: string; + surfaceId?: string; + path?: string; + message?: string; + }; +} + +// --- Surface (internal renderer model, not wire format) --- + +export interface A2uiSurface { + surfaceId: string; + catalogId: string; + theme?: A2uiTheme; + sendDataModel?: boolean; + components: Map; + dataModel: Record; +} From c36e8c80ff8c348646858f4cd0f83163bdd5c70d Mon Sep 17 00:00:00 2001 From: Brian Love Date: Mon, 17 Aug 2026 07:55:34 -0700 Subject: [PATCH 04/15] feat(a2ui)!: v0.9 parser, resolver, guards, pointer semantics, public API Co-Authored-By: Claude Fable 5 --- libs/a2ui/README.md | 67 ++++++++++--------- libs/a2ui/src/index.ts | 18 +++--- libs/a2ui/src/lib/guards.spec.ts | 31 +++------ libs/a2ui/src/lib/guards.ts | 19 ++---- libs/a2ui/src/lib/parser.spec.ts | 103 +++++++++++++++++------------- libs/a2ui/src/lib/parser.ts | 21 +++--- libs/a2ui/src/lib/pointer.spec.ts | 18 ++++++ libs/a2ui/src/lib/pointer.ts | 6 ++ libs/a2ui/src/lib/resolve.spec.ts | 38 ++++------- libs/a2ui/src/lib/resolve.ts | 52 ++++++--------- 10 files changed, 186 insertions(+), 187 deletions(-) diff --git a/libs/a2ui/README.md b/libs/a2ui/README.md index 6c8bd2541..178754fb8 100644 --- a/libs/a2ui/README.md +++ b/libs/a2ui/README.md @@ -1,6 +1,6 @@ # @threadplane/a2ui -The A2UI (Agent-to-UI) protocol type system and parsing/resolution utilities for TypeScript. Defines the wire format agents use to drive generative UI surfaces — framework-agnostic, no Angular dependency, runs in any TypeScript environment. +The A2UI (Agent-to-UI) protocol type system and parsing/resolution utilities for TypeScript, targeting the **A2UI v0.9.1 stable release**. Defines the wire format agents use to drive generative UI surfaces — framework-agnostic, no Angular dependency, runs in any TypeScript environment.

@@ -13,10 +13,10 @@ The A2UI (Agent-to-UI) protocol type system and parsing/resolution utilities for ## What it does -- **Protocol type system** — full TypeScript type vocabulary for every A2UI message, surface, component, layout, input, and media element an agent can emit. +- **Protocol type system** — full TypeScript type vocabulary for every A2UI v0.9 message, component, dynamic value, action, and client→agent message. - **Streaming message parser** — `createA2uiMessageParser()` returns a stateful parser that accepts JSONL chunks from a streaming agent response and emits typed `A2uiMessage` values as lines complete. -- **Dynamic value resolution** — `resolveDynamic()` resolves a dynamic value (literal wrapper or path-reference) against a client data model; four type guards (`isLiteralString`, `isLiteralNumber`, `isLiteralBoolean`, `isPathRef`) let you narrow dynamic values before passing them. -- **JSON-pointer utilities** — `getByPointer`, `setByPointer`, and `deleteByPointer` navigate and mutate the A2UI client data model using JSON-pointer paths. +- **Dynamic value resolution** — `resolveDynamic()` resolves a dynamic value (bare literal or `{ path }` binding) against a client data model; `isPathRef` / `isFunctionCall` guards narrow dynamic values before use. +- **JSON-pointer utilities** — `getByPointer`, `setByPointer`, and `deleteByPointer` navigate and mutate the A2UI client data model using JSON-pointer paths, including the v0.9 array-delete rule (index set to `undefined`, length preserved). - **Runtime-neutral** — pure TypeScript, no runtime dependencies, works in browsers and Node.js alike. Consumed by `@threadplane/chat` to render agent-emitted generative UI. ## Install @@ -41,12 +41,12 @@ const parser = createA2uiMessageParser(); function onChunk(chunk: string): void { const messages: A2uiMessage[] = parser.push(chunk); for (const msg of messages) { - if ('beginRendering' in msg) { - console.log('New surface:', msg.beginRendering.surfaceId); - } else if ('surfaceUpdate' in msg) { - console.log('Surface update for:', msg.surfaceUpdate.surfaceId); - } else if ('dataModelUpdate' in msg) { - console.log('Data model delta:', msg.dataModelUpdate.contents); + if ('createSurface' in msg) { + console.log('New surface:', msg.createSurface.surfaceId); + } else if ('updateComponents' in msg) { + console.log('Components for:', msg.updateComponents.surfaceId); + } else if ('updateDataModel' in msg) { + console.log('Data model update at:', msg.updateDataModel.path ?? '/'); } else if ('deleteSurface' in msg) { console.log('Delete surface:', msg.deleteSurface.surfaceId); } @@ -54,22 +54,20 @@ function onChunk(chunk: string): void { } ``` +Every envelope carries `"version": "v0.9"`. The component whose `id` is `"root"` is the tree root — rendering can begin as soon as it arrives, and the tree fills in progressively. + ### Resolve dynamic values against a data model ```typescript -import { - resolveDynamic, - isPathRef, - isLiteralString, -} from '@threadplane/a2ui'; +import { resolveDynamic } from '@threadplane/a2ui'; const model = { user: { name: 'Alice' } }; -// Literal string wrapper -const label = resolveDynamic({ literalString: 'Submit' }, model); +// Bare literal — passes through unchanged +const label = resolveDynamic('Submit', model); // => 'Submit' -// Path reference — resolves against the model via JSON pointer +// Path binding — resolves against the model via JSON pointer const name = resolveDynamic({ path: '/user/name' }, model); // => 'Alice' ``` @@ -90,7 +88,7 @@ const removed = deleteByPointer(updated, '/items/0/id'); ### Protocol message parsing -`createA2uiMessageParser()` returns an `A2uiMessageParser` with a single `push(chunk: string): A2uiMessage[]` method. The parser is stateful — it buffers partial lines between calls and emits complete messages as JSONL lines arrive. Malformed lines are silently skipped, which is safe for mid-stream partial JSON. +`createA2uiMessageParser()` returns an `A2uiMessageParser` with a single `push(chunk: string): A2uiMessage[]` method. The parser is stateful — it buffers partial lines between calls and emits complete messages as JSONL lines arrive. Malformed lines are silently skipped (safe for mid-stream partial JSON), and unknown envelope keys — such as future v1.0 messages — are skipped rather than treated as errors. ```typescript const parser = createA2uiMessageParser(); @@ -99,20 +97,18 @@ const messages = parser.push(chunk); // A2uiMessage[] ### Dynamic value resolution -`resolveDynamic(value, model, scope?)` unwraps A2UI dynamic values: +`resolveDynamic(value, model, scope?)` resolves A2UI v0.9 dynamic values: -- `{ literalString: string }`, `{ literalNumber: number }`, `{ literalBoolean: boolean }` — unwrap to the inner value. -- `{ path: string }` — looked up in `model` via JSON pointer. If an `A2uiScope` is provided, relative paths resolve against `scope.basePath`. -- Plain literals (string, number, boolean, `null`, plain objects) — passed through unchanged. +- Bare literals (string, number, boolean, string arrays, plain objects) — pass through unchanged. +- `{ path: string }` — looked up in `model` via JSON pointer. If an `A2uiScope` is provided, relative paths resolve against `scope.basePath` (used inside `children` templates). +- `{ call: string, args? }` — client-side function calls; typed today, executed in an upcoming release (currently resolve to `undefined`). -The four exported type guards narrow `unknown` values before use: +Type guards: | Guard | Narrows to | |---|---| -| `isLiteralString(v)` | `{ literalString: string }` | -| `isLiteralNumber(v)` | `{ literalNumber: number }` | -| `isLiteralBoolean(v)` | `{ literalBoolean: boolean }` | | `isPathRef(v)` | `{ path: string }` | +| `isFunctionCall(v)` | `{ call: string; args?: Record }` | ### JSON-pointer utilities @@ -122,20 +118,21 @@ Three immutable helpers operate on `Record` data models: |---|---| | `getByPointer(model, pointer)` | Read the value at `pointer`. Returns `undefined` if the path does not exist. | | `setByPointer(model, pointer, value)` | Return a new model with `value` written at `pointer`. | -| `deleteByPointer(model, pointer)` | Return a new model with the key at `pointer` removed. | +| `deleteByPointer(model, pointer)` | Return a new model with the key at `pointer` removed. Array indices are set to `undefined`, preserving length (v0.9 rule). | Pointers follow standard `/segment/segment` syntax. An empty pointer (`''` or `'/'`) targets the root. ### Type system -`@threadplane/a2ui` exports the complete A2UI type vocabulary as TypeScript `export type` declarations. Categories include: +`@threadplane/a2ui` exports the complete A2UI v0.9 type vocabulary. Categories include: -- **Protocol messages** — `A2uiMessage`, `A2uiBeginRendering`, `A2uiSurfaceUpdate`, `A2uiDataModelUpdate`, `A2uiDeleteSurface`, `A2uiSurface`, `A2uiDataModelEntry` -- **Components and layout** — `A2uiComponent`, `A2uiComponentDef`, `A2uiRow`, `A2uiColumn`, `A2uiCard`, `A2uiList`, `A2uiTabs`, `A2uiTabItem`, `A2uiModal`, `A2uiDivider` -- **Input elements** — `A2uiButton`, `A2uiTextField`, `A2uiCheckBox`, `A2uiSlider`, `A2uiMultipleChoice`, `A2uiDateTimeInput` -- **Media and display** — `A2uiText`, `A2uiImage`, `A2uiIcon`, `A2uiVideo`, `A2uiAudioPlayer` -- **Dynamic values** — `DynamicString`, `DynamicNumber`, `DynamicBoolean`, `DynamicStringList` -- **Actions and theming** — `A2uiAction`, `A2uiActionMessage`, `A2uiActionContextEntry`, `A2uiTheme`, `A2uiClientDataModel` +- **Protocol constants** — `A2UI_WIRE_VERSION` (`'v0.9'`), `A2UI_MIME_TYPE` (`'application/a2ui+json'`), `A2UI_BASIC_CATALOG_ID` +- **Envelopes** — `A2uiMessage`, `A2uiCreateSurface`, `A2uiUpdateComponents`, `A2uiUpdateDataModel`, `A2uiDeleteSurface` +- **Components** — `A2uiComponent`, `A2uiComponentBase`, `A2uiCatalogComponent`, plus per-component shapes: `A2uiText`, `A2uiImage`, `A2uiIcon`, `A2uiVideo`, `A2uiAudioPlayer`, `A2uiRow`, `A2uiColumn`, `A2uiList`, `A2uiCard`, `A2uiTabs`, `A2uiModal`, `A2uiDivider`, `A2uiButton`, `A2uiCheckBox`, `A2uiTextField`, `A2uiDateTimeInput`, `A2uiChoicePicker`, `A2uiSlider` +- **Dynamic values** — `DynamicString`, `DynamicNumber`, `DynamicBoolean`, `DynamicStringList`, `DynamicValue`, `A2uiPathRef`, `A2uiFunctionCall` +- **Actions and checks** — `A2uiAction`, `A2uiEventAction`, `A2uiFunctionAction`, `A2uiCheck`, `A2uiCheckable` +- **Client → agent** — `A2uiActionMessage`, `A2uiErrorMessage`, `A2uiClientDataModel`, `A2uiClientCapabilities` +- **Theming** — `A2uiTheme` - **Parser and scope** — `A2uiMessageParser`, `A2uiScope`, `A2uiChildren` ## Reliability diff --git a/libs/a2ui/src/index.ts b/libs/a2ui/src/index.ts index 4c36c12bb..5f6e96082 100644 --- a/libs/a2ui/src/index.ts +++ b/libs/a2ui/src/index.ts @@ -1,19 +1,21 @@ // SPDX-License-Identifier: MIT +export { A2UI_WIRE_VERSION, A2UI_MIME_TYPE, A2UI_BASIC_CATALOG_ID } from './lib/types.js'; export type { A2uiTheme, - DynamicString, DynamicNumber, DynamicBoolean, DynamicStringList, - A2uiChildren, A2uiActionContextEntry, A2uiAction, - A2uiComponent, A2uiComponentDef, + A2uiPathRef, A2uiFunctionCall, + DynamicString, DynamicNumber, DynamicBoolean, DynamicStringList, DynamicValue, + A2uiChildren, A2uiAction, A2uiEventAction, A2uiFunctionAction, A2uiCheck, + A2uiComponent, A2uiComponentBase, A2uiCatalogComponent, A2uiCheckable, A2uiText, A2uiImage, A2uiIcon, A2uiVideo, A2uiAudioPlayer, - A2uiRow, A2uiColumn, A2uiList, A2uiCard, A2uiTabs, A2uiTabItem, A2uiDivider, A2uiModal, - A2uiButton, A2uiCheckBox, A2uiTextField, A2uiDateTimeInput, A2uiMultipleChoice, A2uiSlider, - A2uiSurfaceUpdate, A2uiDataModelEntry, A2uiDataModelUpdate, A2uiBeginRendering, A2uiDeleteSurface, + A2uiRow, A2uiColumn, A2uiList, A2uiCard, A2uiTabs, A2uiDivider, A2uiModal, + A2uiButton, A2uiCheckBox, A2uiTextField, A2uiDateTimeInput, A2uiChoicePicker, A2uiSlider, + A2uiCreateSurface, A2uiUpdateComponents, A2uiUpdateDataModel, A2uiDeleteSurface, A2uiMessage, A2uiSurface, - A2uiClientDataModel, A2uiActionMessage, + A2uiClientDataModel, A2uiClientCapabilities, A2uiActionMessage, A2uiErrorMessage, } from './lib/types.js'; export { getByPointer, setByPointer, deleteByPointer } from './lib/pointer.js'; export { createA2uiMessageParser } from './lib/parser.js'; export type { A2uiMessageParser } from './lib/parser.js'; export { resolveDynamic } from './lib/resolve.js'; export type { A2uiScope } from './lib/resolve.js'; -export { isLiteralString, isLiteralNumber, isLiteralBoolean, isPathRef } from './lib/guards.js'; +export { isPathRef, isFunctionCall } from './lib/guards.js'; diff --git a/libs/a2ui/src/lib/guards.spec.ts b/libs/a2ui/src/lib/guards.spec.ts index 20d6d0556..cd14ec069 100644 --- a/libs/a2ui/src/lib/guards.spec.ts +++ b/libs/a2ui/src/lib/guards.spec.ts @@ -1,33 +1,22 @@ // SPDX-License-Identifier: MIT import { describe, expect, test } from 'vitest'; -import { isPathRef, isLiteralString, isLiteralNumber, isLiteralBoolean } from './guards'; +import { isPathRef, isFunctionCall } from './guards'; -describe('a2ui v1 guards', () => { +describe('a2ui v0.9 guards', () => { test('isPathRef', () => { expect(isPathRef({ path: '/x' })).toBe(true); - expect(isPathRef({ literalString: 'x' })).toBe(false); + expect(isPathRef({ call: 'formatString' })).toBe(false); expect(isPathRef(null)).toBe(false); expect(isPathRef('string')).toBe(false); expect(isPathRef(42)).toBe(false); }); - test('isLiteralString', () => { - expect(isLiteralString({ literalString: 'x' })).toBe(true); - expect(isLiteralString({ path: '/x' })).toBe(false); - expect(isLiteralString(null)).toBe(false); - expect(isLiteralString('x')).toBe(false); - }); - - test('isLiteralNumber', () => { - expect(isLiteralNumber({ literalNumber: 7 })).toBe(true); - expect(isLiteralNumber({ path: '/n' })).toBe(false); - expect(isLiteralNumber(null)).toBe(false); - }); - - test('isLiteralBoolean', () => { - expect(isLiteralBoolean({ literalBoolean: true })).toBe(true); - expect(isLiteralBoolean({ literalBoolean: false })).toBe(true); - expect(isLiteralBoolean({ path: '/b' })).toBe(false); - expect(isLiteralBoolean(null)).toBe(false); + test('isFunctionCall', () => { + expect(isFunctionCall({ call: 'formatDate' })).toBe(true); + expect(isFunctionCall({ call: 'required', args: { value: { path: '/x' } } })).toBe(true); + expect(isFunctionCall({ path: '/x' })).toBe(false); + expect(isFunctionCall(null)).toBe(false); + expect(isFunctionCall('formatDate')).toBe(false); + expect(isFunctionCall({ call: 42 })).toBe(false); }); }); diff --git a/libs/a2ui/src/lib/guards.ts b/libs/a2ui/src/lib/guards.ts index 11aa46168..45515b73e 100644 --- a/libs/a2ui/src/lib/guards.ts +++ b/libs/a2ui/src/lib/guards.ts @@ -6,17 +6,10 @@ export function isPathRef(value: unknown): value is { path: string } { && 'path' in value && typeof (value as { path: unknown }).path === 'string'; } -/** Returns true when `value` is an A2UI string literal wrapper. */ -export function isLiteralString(value: unknown): value is { literalString: string } { - return typeof value === 'object' && value !== null && 'literalString' in value; -} - -/** Returns true when `value` is an A2UI number literal wrapper. */ -export function isLiteralNumber(value: unknown): value is { literalNumber: number } { - return typeof value === 'object' && value !== null && 'literalNumber' in value; -} - -/** Returns true when `value` is an A2UI boolean literal wrapper. */ -export function isLiteralBoolean(value: unknown): value is { literalBoolean: boolean } { - return typeof value === 'object' && value !== null && 'literalBoolean' in value; +/** Returns true when `value` is an A2UI client-side function call. */ +export function isFunctionCall( + value: unknown, +): value is { call: string; args?: Record } { + return typeof value === 'object' && value !== null + && 'call' in value && typeof (value as { call: unknown }).call === 'string'; } diff --git a/libs/a2ui/src/lib/parser.spec.ts b/libs/a2ui/src/lib/parser.spec.ts index d8a2067f5..6e2d57fe5 100644 --- a/libs/a2ui/src/lib/parser.spec.ts +++ b/libs/a2ui/src/lib/parser.spec.ts @@ -2,50 +2,59 @@ import { describe, expect, test } from 'vitest'; import { createA2uiMessageParser } from './parser'; -describe('createA2uiMessageParser (v1)', () => { - test('parses surfaceUpdate envelope', () => { +describe('createA2uiMessageParser (v0.9)', () => { + test('parses createSurface envelope and preserves version', () => { const parser = createA2uiMessageParser(); const msgs = parser.push(JSON.stringify({ - surfaceUpdate: { - surfaceId: 's1', - components: [{ id: 'root', component: { Card: { child: 'inner' } } }], - }, + version: 'v0.9', + createSurface: { surfaceId: 's1', catalogId: 'basic' }, }) + '\n'); expect(msgs).toHaveLength(1); - expect('surfaceUpdate' in msgs[0]).toBe(true); + expect('createSurface' in msgs[0]).toBe(true); + expect(msgs[0].version).toBe('v0.9'); }); - test('parses dataModelUpdate envelope', () => { + test('parses updateComponents envelope', () => { const parser = createA2uiMessageParser(); const msgs = parser.push(JSON.stringify({ - dataModelUpdate: { + version: 'v0.9', + updateComponents: { surfaceId: 's1', - contents: [{ key: 'name', valueString: 'Brian' }], + components: [{ id: 'root', component: 'Card', child: 'inner' }], }, }) + '\n'); expect(msgs).toHaveLength(1); - expect('dataModelUpdate' in msgs[0]).toBe(true); + expect('updateComponents' in msgs[0]).toBe(true); }); - test('parses beginRendering envelope', () => { + test('parses updateDataModel envelope (value optional)', () => { const parser = createA2uiMessageParser(); - const msgs = parser.push(JSON.stringify({ - beginRendering: { surfaceId: 's1', root: 'root' }, - }) + '\n'); - expect(msgs).toHaveLength(1); - expect('beginRendering' in msgs[0]).toBe(true); + const msgs = parser.push( + JSON.stringify({ version: 'v0.9', updateDataModel: { surfaceId: 's1', path: '/name', value: 'Brian' } }) + '\n' + + JSON.stringify({ version: 'v0.9', updateDataModel: { surfaceId: 's1', path: '/stale' } }) + '\n', + ); + expect(msgs).toHaveLength(2); + expect('updateDataModel' in msgs[0]).toBe(true); + expect('updateDataModel' in msgs[1]).toBe(true); }); test('parses deleteSurface envelope', () => { const parser = createA2uiMessageParser(); - const msgs = parser.push(JSON.stringify({ deleteSurface: { surfaceId: 's1' } }) + '\n'); + const msgs = parser.push(JSON.stringify({ version: 'v0.9', deleteSurface: { surfaceId: 's1' } }) + '\n'); expect(msgs).toHaveLength(1); expect('deleteSurface' in msgs[0]).toBe(true); }); + test('defaults missing version to v0.9', () => { + const parser = createA2uiMessageParser(); + const msgs = parser.push(JSON.stringify({ deleteSurface: { surfaceId: 's1' } }) + '\n'); + expect(msgs).toHaveLength(1); + expect(msgs[0].version).toBe('v0.9'); + }); + test('handles partial JSONL across pushes', () => { const parser = createA2uiMessageParser(); - const json = JSON.stringify({ beginRendering: { surfaceId: 's1', root: 'root' } }); + const json = JSON.stringify({ version: 'v0.9', createSurface: { surfaceId: 's1', catalogId: 'basic' } }); const half = Math.floor(json.length / 2); expect(parser.push(json.slice(0, half))).toEqual([]); const msgs = parser.push(json.slice(half) + '\n'); @@ -55,23 +64,26 @@ describe('createA2uiMessageParser (v1)', () => { test('skips malformed lines silently', () => { const parser = createA2uiMessageParser(); const msgs = parser.push('{not valid json}\n' + JSON.stringify({ - beginRendering: { surfaceId: 's1', root: 'root' }, + version: 'v0.9', createSurface: { surfaceId: 's1', catalogId: 'basic' }, }) + '\n'); expect(msgs).toHaveLength(1); }); - test('rejects unknown envelope keys', () => { + test('skips unknown envelope keys (v1.0 forward-compat)', () => { const parser = createA2uiMessageParser(); - const msgs = parser.push(JSON.stringify({ unknownKey: { foo: 1 } }) + '\n'); + const msgs = parser.push( + JSON.stringify({ version: 'v1.0', callRendererFunction: { call: 'x', functionCallId: '1' } }) + '\n' + + JSON.stringify({ unknownKey: { foo: 1 } }) + '\n', + ); expect(msgs).toHaveLength(0); }); test('parses multiple messages in one chunk', () => { const parser = createA2uiMessageParser(); const chunk = [ - JSON.stringify({ surfaceUpdate: { surfaceId: 's1', components: [] } }), - JSON.stringify({ dataModelUpdate: { surfaceId: 's1', contents: [] } }), - JSON.stringify({ beginRendering: { surfaceId: 's1', root: 'root' } }), + JSON.stringify({ version: 'v0.9', createSurface: { surfaceId: 's1', catalogId: 'basic' } }), + JSON.stringify({ version: 'v0.9', updateComponents: { surfaceId: 's1', components: [] } }), + JSON.stringify({ version: 'v0.9', updateDataModel: { surfaceId: 's1', value: {} } }), ].join('\n') + '\n'; const msgs = parser.push(chunk); expect(msgs).toHaveLength(3); @@ -86,33 +98,36 @@ interface ParserRow { expectedKeys: readonly string[]; } -const BR = (root: string) => - JSON.stringify({ beginRendering: { surfaceId: 's', root } }); -const SU = () => - JSON.stringify({ surfaceUpdate: { surfaceId: 's', components: [] } }); +const CS = (id: string) => + JSON.stringify({ version: 'v0.9', createSurface: { surfaceId: id, catalogId: 'basic' } }); +const UC = () => + JSON.stringify({ version: 'v0.9', updateComponents: { surfaceId: 's', components: [] } }); const DM = (key: string) => - JSON.stringify({ dataModelUpdate: { surfaceId: 's', contents: [{ key, valueString: 'v' }] } }); + JSON.stringify({ version: 'v0.9', updateDataModel: { surfaceId: 's', path: `/${key}`, value: 'v' } }); + +const envelopeKeyOf = (m: object): string => + Object.keys(m).find((k) => k !== 'version') ?? 'version'; const parserRows: ParserRow[] = [ - { name: 'envelope with CRLF', chunks: [BR('r') + '\r\n'], expectedKeys: ['beginRendering'] }, - { name: 'envelope split mid-key', chunks: ['{"begin', 'Rendering":{"surfaceId":"s","root":"r"}}\n'], expectedKeys: ['beginRendering'] }, - { name: 'envelope split mid-string-value', chunks: ['{"beginRendering":{"surfaceId":"s","root":"', 'r"}}\n'], expectedKeys: ['beginRendering'] }, - { name: 'three envelopes one chunk', chunks: [[SU(), DM('k'), BR('r')].join('\n') + '\n'], expectedKeys: ['surfaceUpdate', 'dataModelUpdate', 'beginRendering'] }, + { name: 'envelope with CRLF', chunks: [CS('s') + '\r\n'], expectedKeys: ['createSurface'] }, + { name: 'envelope split mid-key', chunks: ['{"version":"v0.9","create', 'Surface":{"surfaceId":"s","catalogId":"basic"}}\n'], expectedKeys: ['createSurface'] }, + { name: 'envelope split mid-string-value', chunks: ['{"version":"v0.9","createSurface":{"surfaceId":"', 's","catalogId":"basic"}}\n'], expectedKeys: ['createSurface'] }, + { name: 'three envelopes one chunk', chunks: [[CS('s'), UC(), DM('k')].join('\n') + '\n'], expectedKeys: ['createSurface', 'updateComponents', 'updateDataModel'] }, { name: 'three envelopes char-by-char', - chunks: ([SU(), DM('k'), BR('r')].join('\n') + '\n').split(''), - expectedKeys: ['surfaceUpdate', 'dataModelUpdate', 'beginRendering'], + chunks: ([CS('s'), UC(), DM('k')].join('\n') + '\n').split(''), + expectedKeys: ['createSurface', 'updateComponents', 'updateDataModel'], }, - { name: 'malformed line then valid line', chunks: ['{garbage}\n' + BR('r') + '\n'], expectedKeys: ['beginRendering'] }, - { name: 'valid envelope no trailing newline waits', chunks: [BR('r')], expectedKeys: [] }, - { name: 'valid envelope, then trailing newline later', chunks: [BR('r'), '\n'], expectedKeys: ['beginRendering'] }, - { name: 'empty lines between envelopes', chunks: ['\n\n' + BR('r') + '\n\n' + BR('r2') + '\n'], expectedKeys: ['beginRendering', 'beginRendering'] }, - { name: 'whitespace before brace', chunks: [' ' + BR('r') + '\n'], expectedKeys: ['beginRendering'] }, + { name: 'malformed line then valid line', chunks: ['{garbage}\n' + CS('s') + '\n'], expectedKeys: ['createSurface'] }, + { name: 'valid envelope no trailing newline waits', chunks: [CS('s')], expectedKeys: [] }, + { name: 'valid envelope, then trailing newline later', chunks: [CS('s'), '\n'], expectedKeys: ['createSurface'] }, + { name: 'empty lines between envelopes', chunks: ['\n\n' + CS('s') + '\n\n' + CS('s2') + '\n'], expectedKeys: ['createSurface', 'createSurface'] }, + { name: 'whitespace before brace', chunks: [' ' + CS('s') + '\n'], expectedKeys: ['createSurface'] }, { name: 'unrecognised envelope key', chunks: ['{"mysteryUpdate":{}}\n'], expectedKeys: [] }, { name: 'mixed valid + unknown + valid', - chunks: [[BR('r'), '{"mysteryUpdate":{}}', BR('r2')].join('\n') + '\n'], - expectedKeys: ['beginRendering', 'beginRendering'], + chunks: [[CS('s'), '{"mysteryUpdate":{}}', CS('s2')].join('\n') + '\n'], + expectedKeys: ['createSurface', 'createSurface'], }, ]; @@ -122,7 +137,7 @@ describe('createA2uiMessageParser — input variance', () => { const keys: string[] = []; for (const chunk of row.chunks) { const msgs = parser.push(chunk); - for (const m of msgs) keys.push(Object.keys(m)[0]); + for (const m of msgs) keys.push(envelopeKeyOf(m)); } expect(keys).toEqual(row.expectedKeys); }); diff --git a/libs/a2ui/src/lib/parser.ts b/libs/a2ui/src/lib/parser.ts index 0f3793d02..2754bf280 100644 --- a/libs/a2ui/src/lib/parser.ts +++ b/libs/a2ui/src/lib/parser.ts @@ -1,7 +1,7 @@ // SPDX-License-Identifier: MIT -import type { A2uiMessage } from './types.js'; +import { A2UI_WIRE_VERSION, type A2uiMessage } from './types.js'; -const ENVELOPE_KEYS = ['surfaceUpdate', 'dataModelUpdate', 'beginRendering', 'deleteSurface'] as const; +const ENVELOPE_KEYS = ['createSurface', 'updateComponents', 'updateDataModel', 'deleteSurface'] as const; export interface A2uiMessageParser { /** Push a JSONL stream chunk and return every complete A2UI envelope parsed from it. */ @@ -9,16 +9,20 @@ export interface A2uiMessageParser { } /** - * Creates a stateful parser for newline-delimited A2UI message streams. + * Creates a stateful parser for newline-delimited A2UI v0.9 message streams. * * The parser buffers incomplete lines, skips malformed JSON, and returns only - * recognized A2UI envelopes: `surfaceUpdate`, `dataModelUpdate`, - * `beginRendering`, and `deleteSurface`. + * recognized A2UI envelopes: `createSurface`, `updateComponents`, + * `updateDataModel`, and `deleteSurface`. Unknown envelope keys (e.g. future + * v1.0 messages) are skipped rather than treated as errors. A missing + * `version` field defaults to `v0.9`. * * @example * ```ts * const parser = createA2uiMessageParser(); - * const messages = parser.push('{"beginRendering":{"surfaceId":"s1","root":"root"}}\n'); + * const messages = parser.push( + * '{"version":"v0.9","createSurface":{"surfaceId":"s1","catalogId":"basic"}}\n', + * ); * ``` */ export function createA2uiMessageParser(): A2uiMessageParser { @@ -27,8 +31,9 @@ export function createA2uiMessageParser(): A2uiMessageParser { function parseEnvelope(json: Record): A2uiMessage | null { for (const key of ENVELOPE_KEYS) { if (key in json && typeof json[key] === 'object' && json[key] !== null) { - // A2uiMessage is a discriminated union of single-key envelope objects. - return { [key]: json[key] } as unknown as A2uiMessage; + const version = typeof json['version'] === 'string' ? json['version'] : A2UI_WIRE_VERSION; + // A2uiMessage is a discriminated union of single-envelope-key objects. + return { version, [key]: json[key] } as unknown as A2uiMessage; } } return null; diff --git a/libs/a2ui/src/lib/pointer.spec.ts b/libs/a2ui/src/lib/pointer.spec.ts index 99de887de..b9a6f7b81 100644 --- a/libs/a2ui/src/lib/pointer.spec.ts +++ b/libs/a2ui/src/lib/pointer.spec.ts @@ -72,4 +72,22 @@ describe('deleteByPointer', () => { const result = deleteByPointer({ user: { name: 'Alice', age: 30 } }, '/user/age'); expect(result.user).toEqual({ name: 'Alice' }); }); + + it('clears the whole model for root pointer', () => { + expect(deleteByPointer({ a: 1 }, '/')).toEqual({}); + }); + + it('sets array index to undefined preserving length (v0.9 rule)', () => { + const result = deleteByPointer({ items: ['a', 'b', 'c'] }, '/items/1'); + expect(Array.isArray(result.items)).toBe(true); + expect((result.items as unknown[]).length).toBe(3); + expect((result.items as unknown[])[0]).toBe('a'); + expect((result.items as unknown[])[1]).toBeUndefined(); + expect((result.items as unknown[])[2]).toBe('c'); + }); + + it('leaves the model unchanged for a missing parent path', () => { + const model = { a: 1 }; + expect(deleteByPointer(model, '/missing/deep')).toEqual({ a: 1 }); + }); }); diff --git a/libs/a2ui/src/lib/pointer.ts b/libs/a2ui/src/lib/pointer.ts index f908f9760..0f9b332cf 100644 --- a/libs/a2ui/src/lib/pointer.ts +++ b/libs/a2ui/src/lib/pointer.ts @@ -90,6 +90,12 @@ export function deleteByPointer( const parent = getByPointer(model, '/' + parentPath.join('/')); if (parent == null || typeof parent !== 'object') return model; + if (Array.isArray(parent)) { + // v0.9 rule: deleting an array index sets it to undefined, preserving length. + const parentCopy = [...(parent as unknown[])]; + parentCopy[Number(key)] = undefined; + return setByPointer(model, '/' + parentPath.join('/'), parentCopy); + } const parentCopy = { ...(parent as Record) }; delete parentCopy[key]; return setByPointer(model, '/' + parentPath.join('/'), parentCopy); diff --git a/libs/a2ui/src/lib/resolve.spec.ts b/libs/a2ui/src/lib/resolve.spec.ts index 83a93e42c..a3e2c8399 100644 --- a/libs/a2ui/src/lib/resolve.spec.ts +++ b/libs/a2ui/src/lib/resolve.spec.ts @@ -2,42 +2,36 @@ import { describe, expect, test } from 'vitest'; import { resolveDynamic } from './resolve'; -describe('resolveDynamic (v1)', () => { +describe('resolveDynamic (v0.9)', () => { const model = { name: 'Brian', count: 7, active: true, tags: ['a', 'b'] }; - test('passes through bare literals (e.g. plain strings without wrappers)', () => { + test('passes through bare literals', () => { expect(resolveDynamic('hello', model)).toBe('hello'); expect(resolveDynamic(42, model)).toBe(42); + expect(resolveDynamic(true, model)).toBe(true); expect(resolveDynamic(null, model)).toBe(null); }); - test('unwraps literalString', () => { - expect(resolveDynamic({ literalString: 'hello' }, model)).toBe('hello'); - }); - - test('unwraps literalNumber', () => { - expect(resolveDynamic({ literalNumber: 7 }, model)).toBe(7); - }); - - test('unwraps literalBoolean', () => { - expect(resolveDynamic({ literalBoolean: true }, model)).toBe(true); - }); - - test('unwraps literalArray', () => { - expect(resolveDynamic({ literalArray: ['x', 'y'] }, model)).toEqual(['x', 'y']); - }); - test('resolves path against model', () => { expect(resolveDynamic({ path: '/name' }, model)).toBe('Brian'); expect(resolveDynamic({ path: '/count' }, model)).toBe(7); expect(resolveDynamic({ path: '/missing' }, model)).toBe(undefined); }); - test('recurses into arrays', () => { - const out = resolveDynamic([{ literalString: 'a' }, { path: '/name' }], model); + test('function calls resolve to undefined until Phase 2 ships execution', () => { + expect(resolveDynamic({ call: 'formatString', args: { value: 'x' } }, model)).toBeUndefined(); + expect(resolveDynamic({ call: 'required' }, model)).toBeUndefined(); + }); + + test('recurses into arrays element-wise', () => { + const out = resolveDynamic(['a', { path: '/name' }], model); expect(out).toEqual(['a', 'Brian']); }); + test('bare string arrays pass through', () => { + expect(resolveDynamic(['x', 'y'], model)).toEqual(['x', 'y']); + }); + test('returns plain object passthrough for unrecognized shapes', () => { const obj = { id: 'x', children: ['a'] }; expect(resolveDynamic(obj, model)).toEqual(obj); @@ -47,10 +41,6 @@ describe('resolveDynamic (v1)', () => { expect(resolveDynamic({ path: 'name' }, model, { basePath: '', item: undefined })).toBe('Brian'); }); - test('returns undefined for non-existent paths', () => { - expect(resolveDynamic({ path: '/missing' }, model)).toBeUndefined(); - }); - test('resolves array index path', () => { expect(resolveDynamic({ path: '/tags/0' }, model)).toBe('a'); expect(resolveDynamic({ path: '/tags/1' }, model)).toBe('b'); diff --git a/libs/a2ui/src/lib/resolve.ts b/libs/a2ui/src/lib/resolve.ts index 70a889894..5f8027e5f 100644 --- a/libs/a2ui/src/lib/resolve.ts +++ b/libs/a2ui/src/lib/resolve.ts @@ -1,34 +1,17 @@ // SPDX-License-Identifier: MIT import { getByPointer } from './pointer.js'; +import { isFunctionCall, isPathRef } from './guards.js'; export interface A2uiScope { basePath: string; item: unknown; } -interface PathRef { path: string } -interface LiteralString { literalString: string } -interface LiteralNumber { literalNumber: number } -interface LiteralBoolean { literalBoolean: boolean } -interface LiteralArray { literalArray: unknown[] } - -function isPathRef(v: unknown): v is PathRef { - return typeof v === 'object' && v !== null && 'path' in v && typeof (v as PathRef).path === 'string'; -} -function isLiteralString(v: unknown): v is LiteralString { - return typeof v === 'object' && v !== null && 'literalString' in v; -} -function isLiteralNumber(v: unknown): v is LiteralNumber { - return typeof v === 'object' && v !== null && 'literalNumber' in v; -} -function isLiteralBoolean(v: unknown): v is LiteralBoolean { - return typeof v === 'object' && v !== null && 'literalBoolean' in v; -} -function isLiteralArray(v: unknown): v is LiteralArray { - return typeof v === 'object' && v !== null && 'literalArray' in v; -} - -function resolvePathRef(ref: PathRef, model: Record, scope?: A2uiScope): unknown { +function resolvePathRef( + ref: { path: string }, + model: Record, + scope?: A2uiScope, +): unknown { const path = ref.path; if (path.startsWith('/')) return getByPointer(model, path); if (scope) return getByPointer(model, `${scope.basePath}/${path}`); @@ -36,17 +19,19 @@ function resolvePathRef(ref: PathRef, model: Record, scope?: A2 } /** - * Resolves an A2UI dynamic value against a client data model. + * Resolves an A2UI v0.9 dynamic value against a client data model. * - * Literal wrappers unwrap to their inner values, `{ path }` references read - * from the model by JSON-pointer path, arrays resolve recursively, and - * unrecognized plain values pass through unchanged. + * Bare literals (strings, numbers, booleans) pass through unchanged, `{ path }` + * references read from the model by JSON-pointer path, arrays resolve + * element-wise, and client-side function calls (`{ call }`) resolve to + * `undefined` until function execution ships. Unrecognized plain objects pass + * through unchanged. * * @example * ```ts * const model = { customer: { name: 'Ada' } }; * resolveDynamic({ path: '/customer/name' }, model); // 'Ada' - * resolveDynamic({ literalString: 'Checkout' }, model); // 'Checkout' + * resolveDynamic('Checkout', model); // 'Checkout' * ``` */ export function resolveDynamic( @@ -57,15 +42,14 @@ export function resolveDynamic( if (value == null) return value; if (Array.isArray(value)) return value.map(item => resolveDynamic(item, model, scope)); - // Literal wrappers — unwrap. Order matters less than mutual exclusivity. - if (isLiteralString(value)) return value.literalString; - if (isLiteralNumber(value)) return value.literalNumber; - if (isLiteralBoolean(value)) return value.literalBoolean; - if (isLiteralArray(value)) return value.literalArray; + // Client-side function call — execution ships in a later phase. Checked + // before path refs so `{ call, args: { path: ... } }`-style args never + // masquerade as bindings. + if (isFunctionCall(value)) return undefined; // Path reference if (isPathRef(value)) return resolvePathRef(value, model, scope); - // Plain literal passthrough (string, number, boolean, plain object without wrappers) + // Bare literal passthrough (string, number, boolean, plain object) return value; } From 5599f2a305eeaabceb0e867702efa8f3ec57f43a Mon Sep 17 00:00:00 2001 From: Brian Love Date: Mon, 17 Aug 2026 07:58:19 -0700 Subject: [PATCH 05/15] =?UTF-8?q?feat(chat)!:=20v0.9=20surface=20store=20?= =?UTF-8?q?=E2=80=94=20root-gated=20progressive=20rendering,=20path/value?= =?UTF-8?q?=20data=20model?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- libs/chat/src/lib/a2ui/component-view.ts | 22 +- libs/chat/src/lib/a2ui/extract-bindings.ts | 8 +- libs/chat/src/lib/a2ui/surface-store.spec.ts | 365 ++++++++++--------- libs/chat/src/lib/a2ui/surface-store.ts | 310 ++++++++-------- 4 files changed, 368 insertions(+), 337 deletions(-) diff --git a/libs/chat/src/lib/a2ui/component-view.ts b/libs/chat/src/lib/a2ui/component-view.ts index fdf7c592f..187b29fee 100644 --- a/libs/chat/src/lib/a2ui/component-view.ts +++ b/libs/chat/src/lib/a2ui/component-view.ts @@ -1,29 +1,29 @@ // SPDX-License-Identifier: MIT -import type { A2uiComponentDef } from '@threadplane/a2ui'; +import type { A2uiComponent } from '@threadplane/a2ui'; /** Chat-internal projection of an A2UI component, materialized by the * surface store. Distinct from the wire-format `A2uiComponent` in - * `@threadplane/a2ui` (which carries the raw `component: A2uiComponentDef` - * payload) — this type adds the per-component readiness fields the - * progressive renderer consumes. */ + * `@threadplane/a2ui` — this type adds the per-component readiness fields + * the progressive renderer consumes. */ export interface A2uiComponentView { /** The component's id (same as the wire-format `A2uiComponent.id`). */ readonly id: string; - /** The component type key — e.g. `'Button'`, `'TextField'` — matched - * against catalog `views` entries. */ + /** The component type — e.g. `'Button'`, `'TextField'` — matched + * against catalog `views` entries. Mirrors the wire `component` string. */ readonly type: string; /** Data model paths this component references via its `{$.path}` prop - * expressions. Extracted once on `surfaceUpdate` apply; immutable. */ + * expressions. Extracted once on `updateComponents` apply; immutable. */ readonly bindings: readonly string[]; /** Monotonic: `false` until every binding has resolved at least once * in the accumulated data model, then `true` forever. Once `true`, - * subsequent `dataModelUpdate` envelopes push new prop values but do + * subsequent `updateDataModel` envelopes push new prop values but do * NOT flip this back to `false`. */ readonly ready: boolean; - /** Resolved property bag. Meaningful only when `ready === true`. */ + /** Resolved property bag (reserved protocol keys stripped). Meaningful + * only when `ready === true`. */ readonly props: Readonly>; - /** The raw wire-format component def, retained so the slot directive + /** The raw wire-format component, retained so the slot directive * can look up the catalog entry by type and resolve nested children * on re-renders. */ - readonly def: A2uiComponentDef; + readonly def: A2uiComponent; } diff --git a/libs/chat/src/lib/a2ui/extract-bindings.ts b/libs/chat/src/lib/a2ui/extract-bindings.ts index 4f533bcee..a8e120224 100644 --- a/libs/chat/src/lib/a2ui/extract-bindings.ts +++ b/libs/chat/src/lib/a2ui/extract-bindings.ts @@ -1,5 +1,5 @@ // SPDX-License-Identifier: MIT -import type { A2uiComponentDef } from '@threadplane/a2ui'; +import type { A2uiComponent } from '@threadplane/a2ui'; const REF_PATTERN = /\{(\$\.[^}]+)\}/g; @@ -20,10 +20,10 @@ function walk(value: unknown, into: Set): void { } /** Extracts the set of data-model paths (e.g. `$.form.name`) referenced - * by `{$.path}` expressions inside a component's prop bag. Result is + * by `{$.path}` expressions inside a component's props. Result is * deduplicated and sorted for stable signal identity. */ -export function extractBindings(def: A2uiComponentDef): readonly string[] { +export function extractBindings(component: A2uiComponent): readonly string[] { const out = new Set(); - walk(def, out); + walk(component, out); return [...out].sort(); } diff --git a/libs/chat/src/lib/a2ui/surface-store.spec.ts b/libs/chat/src/lib/a2ui/surface-store.spec.ts index 8c1f4e534..9bf2840ca 100644 --- a/libs/chat/src/lib/a2ui/surface-store.spec.ts +++ b/libs/chat/src/lib/a2ui/surface-store.spec.ts @@ -2,6 +2,7 @@ import { describe, expect, test } from 'vitest'; import { TestBed } from '@angular/core/testing'; import { createA2uiSurfaceStore } from './surface-store'; +import type { A2uiComponent, A2uiMessage } from '@threadplane/a2ui'; function setup() { let store!: ReturnType; @@ -12,159 +13,172 @@ function setup() { return store; } -describe('A2uiSurfaceStore (v1, deferred-apply)', () => { +const BASIC = 'https://a2ui.org/specification/v0_9/catalogs/basic/catalog.json'; + +const createSurface = (surfaceId: string, extra?: Record): A2uiMessage => + ({ version: 'v0.9', createSurface: { surfaceId, catalogId: BASIC, ...extra } } as A2uiMessage); +const updateComponents = (surfaceId: string, components: A2uiComponent[]): A2uiMessage => + ({ version: 'v0.9', updateComponents: { surfaceId, components } } as A2uiMessage); +const updateDataModel = (surfaceId: string, body: Record): A2uiMessage => + ({ version: 'v0.9', updateDataModel: { surfaceId, ...body } } as A2uiMessage); + +describe('A2uiSurfaceStore (v0.9, root-gated progressive rendering)', () => { test('starts with no surfaces', () => { const store = setup(); expect(store.surfaces().size).toBe(0); }); - test('surfaceUpdate alone does not expose surface', () => { + test('createSurface alone does not expose a surface', () => { const store = setup(); - store.apply({ - surfaceUpdate: { - surfaceId: 's1', - components: [{ id: 'root', component: { Card: { child: 'inner' } } }], - }, - }); + store.apply(createSurface('s1')); expect(store.surfaces().size).toBe(0); }); - test('beginRendering commits buffered surfaceUpdate', () => { + test('components without createSurface stay buffered', () => { const store = setup(); - store.apply({ - surfaceUpdate: { - surfaceId: 's1', - components: [{ id: 'root', component: { Card: { child: 'inner' } } }], - }, - }); - store.apply({ beginRendering: { surfaceId: 's1', root: 'root' } }); - const surfaces = store.surfaces(); - expect(surfaces.size).toBe(1); - const s = surfaces.get('s1'); - expect(s?.components.has('root')).toBe(true); - expect(s?.dataModel).toEqual({}); + store.apply(updateComponents('s1', [ + { id: 'root', component: 'Card', child: 'inner' } as A2uiComponent, + ])); + expect(store.surfaces().size).toBe(0); }); - test('beginRendering commits buffered dataModelUpdate too', () => { + test('surface commits once createSurface + root component are both present', () => { const store = setup(); - store.apply({ - surfaceUpdate: { surfaceId: 's1', components: [ - { id: 'root', component: { Text: { text: { path: '/title' } } } }, - ] }, - }); - store.apply({ - dataModelUpdate: { - surfaceId: 's1', - contents: [{ key: 'title', valueString: 'Hello' }], - }, - }); - store.apply({ beginRendering: { surfaceId: 's1', root: 'root' } }); + store.apply(createSurface('s1')); + store.apply(updateComponents('s1', [ + { id: 'inner', component: 'Text', text: 'Hi' } as A2uiComponent, + ])); + expect(store.surfaces().size).toBe(0); // no root yet + store.apply(updateComponents('s1', [ + { id: 'root', component: 'Card', child: 'inner' } as A2uiComponent, + ])); const s = store.surfaces().get('s1'); - expect(s?.dataModel).toEqual({ title: 'Hello' }); + expect(s).toBeDefined(); + expect(s?.components.size).toBe(2); + expect(s?.catalogId).toBe(BASIC); }); - test('post-render dataModelUpdate applies incrementally', () => { + test('components arriving before createSurface commit as soon as it arrives', () => { const store = setup(); - store.apply({ surfaceUpdate: { surfaceId: 's1', components: [ - { id: 'root', component: { Text: { text: { literalString: 'x' } } } }, - ] } }); - store.apply({ beginRendering: { surfaceId: 's1', root: 'root' } }); - store.apply({ - dataModelUpdate: { surfaceId: 's1', contents: [{ key: 'count', valueNumber: 7 }] }, - }); - expect(store.surfaces().get('s1')?.dataModel).toEqual({ count: 7 }); + store.apply(updateComponents('s1', [ + { id: 'root', component: 'Text', text: 'Hi' } as A2uiComponent, + ])); + store.apply(createSurface('s1')); + expect(store.surfaces().get('s1')?.components.has('root')).toBe(true); }); - test('post-render surfaceUpdate stays buffered until second beginRendering', () => { + test('pre-commit updateDataModel deltas fold into the initial data model', () => { const store = setup(); - store.apply({ surfaceUpdate: { surfaceId: 's1', components: [ - { id: 'root', component: { Text: { text: { literalString: 'a' } } } }, - ] } }); - store.apply({ beginRendering: { surfaceId: 's1', root: 'root' } }); - store.apply({ surfaceUpdate: { surfaceId: 's1', components: [ - { id: 'root', component: { Text: { text: { literalString: 'b' } } } }, - ] } }); - // Without a second beginRendering, the new surfaceUpdate stays buffered. - const root = store.surfaces().get('s1')?.components.get('root'); - expect((root?.component as { Text: { text: { literalString: string } } }).Text.text.literalString).toBe('a'); - store.apply({ beginRendering: { surfaceId: 's1', root: 'root' } }); - const root2 = store.surfaces().get('s1')?.components.get('root'); - expect((root2?.component as { Text: { text: { literalString: string } } }).Text.text.literalString).toBe('b'); + store.apply(createSurface('s1')); + store.apply(updateDataModel('s1', { path: '/title', value: 'Hello' })); + store.apply(updateComponents('s1', [ + { id: 'root', component: 'Text', text: { path: '/title' } } as A2uiComponent, + ])); + expect(store.surfaces().get('s1')?.dataModel).toEqual({ title: 'Hello' }); }); - test('deleteSurface clears both buffer and committed surface', () => { + test('post-commit updateComponents merges by id (incremental, no re-commit gate)', () => { const store = setup(); - store.apply({ surfaceUpdate: { surfaceId: 's1', components: [ - { id: 'root', component: { Card: { child: 'inner' } } }, - ] } }); - store.apply({ beginRendering: { surfaceId: 's1', root: 'root' } }); - expect(store.surfaces().size).toBe(1); - store.apply({ deleteSurface: { surfaceId: 's1' } }); - expect(store.surfaces().size).toBe(0); + store.apply(createSurface('s1')); + store.apply(updateComponents('s1', [ + { id: 'root', component: 'Text', text: 'a' } as A2uiComponent, + ])); + store.apply(updateComponents('s1', [ + { id: 'extra', component: 'Text', text: 'b' } as A2uiComponent, + ])); + const s = store.surfaces().get('s1'); + expect(s?.components.size).toBe(2); + expect((s?.components.get('root') as { text?: unknown })?.text).toBe('a'); + store.apply(updateComponents('s1', [ + { id: 'root', component: 'Text', text: 'c' } as A2uiComponent, + ])); + expect((store.surfaces().get('s1')?.components.get('root') as { text?: unknown })?.text).toBe('c'); }); - test('dataModelUpdate before any surfaceUpdate is a no-op', () => { + test('post-commit updateDataModel writes value at path', () => { const store = setup(); - store.apply({ - dataModelUpdate: { surfaceId: 's1', contents: [{ key: 'name', valueString: 'B' }] }, - }); - store.apply({ beginRendering: { surfaceId: 's1', root: 'root' } }); - // No surfaceUpdate ever arrived; commit is a no-op. - expect(store.surfaces().size).toBe(0); + store.apply(createSurface('s1')); + store.apply(updateComponents('s1', [ + { id: 'root', component: 'Text', text: 'x' } as A2uiComponent, + ])); + store.apply(updateDataModel('s1', { path: '/count', value: 7 })); + expect(store.surfaces().get('s1')?.dataModel).toEqual({ count: 7 }); }); - test('surface() returns a signal for a specific surface', () => { + test('updateDataModel with omitted path replaces the whole model', () => { const store = setup(); - store.apply({ surfaceUpdate: { surfaceId: 's1', components: [ - { id: 'root', component: { Text: { text: { literalString: 'hi' } } } }, - ] } }); - store.apply({ beginRendering: { surfaceId: 's1', root: 'root' } }); - const s = store.surface('s1'); - expect(s()).toBeDefined(); - expect(s()!.surfaceId).toBe('s1'); + store.apply(createSurface('s1')); + store.apply(updateComponents('s1', [ + { id: 'root', component: 'Text', text: 'x' } as A2uiComponent, + ])); + store.apply(updateDataModel('s1', { path: '/a', value: 1 })); + store.apply(updateDataModel('s1', { value: { b: 2 } })); + expect(store.surfaces().get('s1')?.dataModel).toEqual({ b: 2 }); }); - test('captures styles from beginRendering (v1 spec)', () => { + test('updateDataModel with omitted value deletes the key at path', () => { const store = setup(); - store.apply({ surfaceUpdate: { surfaceId: 's1', components: [ - { id: 'root', component: { Text: { text: { literalString: 'hi' } } } }, - ] } }); - store.apply({ beginRendering: { - surfaceId: 's1', - root: 'root', - styles: { font: 'Roboto', primaryColor: '#FF6633' }, - } }); - const s = store.surfaces().get('s1')!; - expect(s.styles).toEqual({ font: 'Roboto', primaryColor: '#FF6633' }); + store.apply(createSurface('s1')); + store.apply(updateComponents('s1', [ + { id: 'root', component: 'Text', text: 'x' } as A2uiComponent, + ])); + store.apply(updateDataModel('s1', { path: '/a', value: 1 })); + store.apply(updateDataModel('s1', { path: '/b', value: 2 })); + store.apply(updateDataModel('s1', { path: '/a' })); + expect(store.surfaces().get('s1')?.dataModel).toEqual({ b: 2 }); }); - test('omits styles field when beginRendering does not include it', () => { + test('createSurface for an existing live surface is an idempotent refresh', () => { const store = setup(); - store.apply({ surfaceUpdate: { surfaceId: 's1', components: [ - { id: 'root', component: { Text: { text: { literalString: 'hi' } } } }, - ] } }); - store.apply({ beginRendering: { surfaceId: 's1', root: 'root' } }); - const s = store.surfaces().get('s1')!; - expect(s.styles).toBeUndefined(); + store.apply(createSurface('s1')); + store.apply(updateComponents('s1', [ + { id: 'root', component: 'Text', text: 'x' } as A2uiComponent, + ])); + store.apply(updateDataModel('s1', { path: '/a', value: 1 })); + store.apply(createSurface('s1', { sendDataModel: true })); + const s = store.surfaces().get('s1'); + expect(s?.components.size).toBe(1); + expect(s?.dataModel).toEqual({ a: 1 }); + expect(s?.sendDataModel).toBe(true); }); - test('preserves existing styles on re-render when new beginRendering omits them', () => { + test('captures theme + sendDataModel from createSurface', () => { const store = setup(); - store.apply({ surfaceUpdate: { surfaceId: 's1', components: [ - { id: 'root', component: { Text: { text: { literalString: 'hi' } } } }, - ] } }); - store.apply({ beginRendering: { - surfaceId: 's1', - root: 'root', - styles: { primaryColor: '#0A84FF' }, - } }); - // Second beginRendering without styles — keep prior. - store.apply({ surfaceUpdate: { surfaceId: 's1', components: [ - { id: 'root', component: { Text: { text: { literalString: 'hi' } } } }, - ] } }); - store.apply({ beginRendering: { surfaceId: 's1', root: 'root' } }); + store.apply(createSurface('s1', { theme: { primaryColor: '#FF6633' }, sendDataModel: true })); + store.apply(updateComponents('s1', [ + { id: 'root', component: 'Text', text: 'hi' } as A2uiComponent, + ])); const s = store.surfaces().get('s1')!; - expect(s.styles).toEqual({ primaryColor: '#0A84FF' }); + expect(s.theme).toEqual({ primaryColor: '#FF6633' }); + expect(s.sendDataModel).toBe(true); + }); + + test('deleteSurface clears buffer and committed surface', () => { + const store = setup(); + store.apply(createSurface('s1')); + store.apply(updateComponents('s1', [ + { id: 'root', component: 'Text', text: 'x' } as A2uiComponent, + ])); + expect(store.surfaces().size).toBe(1); + store.apply({ version: 'v0.9', deleteSurface: { surfaceId: 's1' } } as A2uiMessage); + expect(store.surfaces().size).toBe(0); + }); + + test('updateDataModel for an unknown surface with no components is buffered, not thrown', () => { + const store = setup(); + store.apply(updateDataModel('s1', { path: '/name', value: 'B' })); + expect(store.surfaces().size).toBe(0); + }); + + test('surface() returns a signal for a specific surface', () => { + const store = setup(); + store.apply(createSurface('s1')); + store.apply(updateComponents('s1', [ + { id: 'root', component: 'Text', text: 'hi' } as A2uiComponent, + ])); + const s = store.surface('s1'); + expect(s()).toBeDefined(); + expect(s()!.surfaceId).toBe('s1'); }); }); @@ -172,119 +186,114 @@ describe('createA2uiSurfaceStore — applyPartialArgs', () => { test('dispatches each envelope via apply() in order', () => { const store = setup(); const envelopes = [ - { surfaceUpdate: { surfaceId: 's1', components: [{ id: 'c', type: 'text', props: {} }] } }, - { beginRendering: { surfaceId: 's1', root: 'c' } }, - ]; + { version: 'v0.9', createSurface: { surfaceId: 's1', catalogId: BASIC } }, + { version: 'v0.9', updateComponents: { surfaceId: 's1', components: [{ id: 'root', component: 'Text', text: 'x' }] } }, + ] as A2uiMessage[]; store.applyPartialArgs('tc-1', envelopes); - expect(store.surfaces().get('s1')?.components.has('c')).toBe(true); + expect(store.surfaces().get('s1')?.components.has('root')).toBe(true); }); test('records the tool_call_id as live (queryable)', () => { const store = setup(); expect(store.isPartialLive('tc-1')).toBe(false); - store.applyPartialArgs('tc-1', [{ surfaceUpdate: { surfaceId: 's1', components: [] } }]); + store.applyPartialArgs('tc-1', [ + { version: 'v0.9', createSurface: { surfaceId: 's1', catalogId: BASIC } } as A2uiMessage, + ]); expect(store.isPartialLive('tc-1')).toBe(true); }); test('ignores invalid envelopes silently', () => { const store = setup(); - // missing required top-level key — apply() ignores store.applyPartialArgs('tc-x', [{ junk: 1 } as never]); expect(store.surfaces().size).toBe(0); - expect(store.isPartialLive('tc-x')).toBe(true); // still tracked + expect(store.isPartialLive('tc-x')).toBe(true); // still tracked }); }); describe('A2uiSurfaceStore — per-component readiness', () => { - const surfaceUpdate = (id: string, components: { id: string; def: unknown }[]) => ({ - surfaceUpdate: { - surfaceId: id, - components: components.map((c) => ({ id: c.id, component: c.def })), - }, - } as never); - const beginRendering = (id: string, root: string) => ({ - beginRendering: { surfaceId: id, root }, - } as never); - const dataModelUpdate = (id: string, contents: { key: string; valueString?: string }[]) => ({ - dataModelUpdate: { surfaceId: id, contents }, - } as never); - - test('extracts bindings from a component on surfaceUpdate apply', () => { + test('extracts bindings from a component on updateComponents apply', () => { const store = setup(); - store.apply(surfaceUpdate('s1', [ - { id: 'c1', def: { TextField: { value: '{$.form.name}' } } }, + store.apply(createSurface('s1')); + store.apply(updateComponents('s1', [ + { id: 'root', component: 'TextField', label: 'Name', value: '{$.form.name}' } as A2uiComponent, ])); - store.apply(beginRendering('s1', 'c1')); - const view = store.surfaceState('s1')()!.componentViews.get('c1')!; + const view = store.surfaceState('s1')()!.componentViews.get('root')!; expect(view.bindings).toEqual(['$.form.name']); + expect(view.type).toBe('TextField'); }); test('component.ready is false when bindings are unpopulated', () => { const store = setup(); - store.apply(surfaceUpdate('s1', [ - { id: 'c1', def: { TextField: { value: '{$.form.name}' } } }, + store.apply(createSurface('s1')); + store.apply(updateComponents('s1', [ + { id: 'root', component: 'TextField', label: 'Name', value: '{$.form.name}' } as A2uiComponent, ])); - store.apply(beginRendering('s1', 'c1')); - expect(store.surfaceState('s1')()!.componentViews.get('c1')!.ready).toBe(false); + expect(store.surfaceState('s1')()!.componentViews.get('root')!.ready).toBe(false); }); - test('component.ready becomes true when all bindings are populated by dataModelUpdate', () => { + test('component.ready becomes true when bindings are populated by updateDataModel', () => { const store = setup(); - store.apply(surfaceUpdate('s1', [ - { id: 'c1', def: { TextField: { value: '{$.form.name}' } } }, + store.apply(createSurface('s1')); + store.apply(updateComponents('s1', [ + { id: 'root', component: 'TextField', label: 'Name', value: '{$.form.name}' } as A2uiComponent, ])); - store.apply(beginRendering('s1', 'c1')); - store.apply({ - dataModelUpdate: { - surfaceId: 's1', - contents: [{ key: 'form', valueMap: [{ key: 'name', valueString: 'Ada' }] }], - }, - } as never); - const view = store.surfaceState('s1')()!.componentViews.get('c1')!; + store.apply(updateDataModel('s1', { path: '/form', value: { name: 'Ada' } })); + const view = store.surfaceState('s1')()!.componentViews.get('root')!; expect(view.ready).toBe(true); - const textFieldProps = view.props['TextField'] as Record; - expect(textFieldProps['value']).toBe('Ada'); + expect(view.props['value']).toBe('Ada'); }); test('resolveProps substitutes partial references (mixed literal + {$.path}) in props', () => { const store = setup(); - store.apply(surfaceUpdate('s1', [ - { id: 'c1', def: { Button: { label: 'Hello {$.name}!' } } }, + store.apply(createSurface('s1')); + store.apply(updateComponents('s1', [ + { id: 'root', component: 'Text', text: 'Hello {$.name}!' } as A2uiComponent, ])); - store.apply(beginRendering('s1', 'c1')); - const initialView = store.surfaceState('s1')()!.componentViews.get('c1')!; + const initialView = store.surfaceState('s1')()!.componentViews.get('root')!; expect(initialView.bindings).toEqual(['$.name']); expect(initialView.ready).toBe(false); - store.apply(dataModelUpdate('s1', [{ key: 'name', valueString: 'Ada' }])); - const view = store.surfaceState('s1')()!.componentViews.get('c1')!; + store.apply(updateDataModel('s1', { path: '/name', value: 'Ada' })); + const view = store.surfaceState('s1')()!.componentViews.get('root')!; expect(view.ready).toBe(true); - const buttonProps = view.props['Button'] as Record; - expect(buttonProps['label']).toBe('Hello Ada!'); + expect(view.props['text']).toBe('Hello Ada!'); }); - test('component.ready stays true after a later dataModelUpdate clears a binding (monotonic)', () => { + test('component.ready stays true after a later update clears a binding (monotonic)', () => { const store = setup(); - store.apply(surfaceUpdate('s1', [ - { id: 'c1', def: { TextField: { value: '{$.name}' } } }, + store.apply(createSurface('s1')); + store.apply(updateComponents('s1', [ + { id: 'root', component: 'TextField', label: 'n', value: '{$.name}' } as A2uiComponent, ])); - store.apply(beginRendering('s1', 'c1')); - store.apply(dataModelUpdate('s1', [{ key: 'name', valueString: 'Ada' }])); - expect(store.surfaceState('s1')()!.componentViews.get('c1')!.ready).toBe(true); - store.apply(dataModelUpdate('s1', [{ key: 'other', valueString: 'x' }])); - expect(store.surfaceState('s1')()!.componentViews.get('c1')!.ready).toBe(true); + store.apply(updateDataModel('s1', { path: '/name', value: 'Ada' })); + expect(store.surfaceState('s1')()!.componentViews.get('root')!.ready).toBe(true); + store.apply(updateDataModel('s1', { path: '/name' })); + expect(store.surfaceState('s1')()!.componentViews.get('root')!.ready).toBe(true); }); test('multiple components have independent readiness', () => { const store = setup(); - store.apply(surfaceUpdate('s1', [ - { id: 'a', def: { TextField: { value: '{$.x}' } } }, - { id: 'b', def: { TextField: { value: '{$.y}' } } }, + store.apply(createSurface('s1')); + store.apply(updateComponents('s1', [ + { id: 'root', component: 'TextField', label: 'a', value: '{$.x}' } as A2uiComponent, + { id: 'b', component: 'TextField', label: 'b', value: '{$.y}' } as A2uiComponent, ])); - store.apply(beginRendering('s1', 'a')); - store.apply(dataModelUpdate('s1', [{ key: 'x', valueString: '1' }])); + store.apply(updateDataModel('s1', { path: '/x', value: '1' })); const state = store.surfaceState('s1')()!; - expect(state.componentViews.get('a')!.ready).toBe(true); + expect(state.componentViews.get('root')!.ready).toBe(true); expect(state.componentViews.get('b')!.ready).toBe(false); }); + + test('reserved base keys are stripped from resolved props', () => { + const store = setup(); + store.apply(createSurface('s1')); + store.apply(updateComponents('s1', [ + { id: 'root', component: 'Text', text: 'Hi', weight: 2 } as A2uiComponent, + ])); + const view = store.surfaceState('s1')()!.componentViews.get('root')!; + expect(view.ready).toBe(true); + expect(view.props['id']).toBeUndefined(); + expect(view.props['component']).toBeUndefined(); + expect(view.props['text']).toBe('Hi'); + }); }); diff --git a/libs/chat/src/lib/a2ui/surface-store.ts b/libs/chat/src/lib/a2ui/surface-store.ts index d103b8c2c..96ca2c57d 100644 --- a/libs/chat/src/lib/a2ui/surface-store.ts +++ b/libs/chat/src/lib/a2ui/surface-store.ts @@ -2,20 +2,21 @@ import { computed, signal, type Signal } from '@angular/core'; import type { A2uiMessage, A2uiSurface, A2uiComponent, - A2uiSurfaceUpdate, A2uiDataModelUpdate, A2uiBeginRendering, A2uiDeleteSurface, - A2uiDataModelEntry, + A2uiCreateSurface, A2uiUpdateComponents, A2uiUpdateDataModel, A2uiDeleteSurface, } from '@threadplane/a2ui'; -import { setByPointer } from '@threadplane/a2ui'; +import { setByPointer, deleteByPointer } from '@threadplane/a2ui'; import type { A2uiComponentView } from './component-view'; import { extractBindings } from './extract-bindings'; +/** Pre-commit staging state for a surface: everything received before the + * commit condition (createSurface seen AND a `root` component defined). */ interface SurfaceBuffer { - /** Pending component map (replaces on next beginRendering). */ - components?: Map; - /** Pending per-component views (replaces on next beginRendering). */ - componentViews?: Map; - /** Pending data model deltas accumulated since last beginRendering. */ - dataModelDeltas: { path?: string; contents: A2uiDataModelEntry[] }[]; + create?: A2uiCreateSurface; + components: Map; + componentViews: Map; + /** Pending data model deltas accumulated before commit. `del` marks a + * v0.9 delete (envelope with omitted `value`). */ + dataModelDeltas: { path?: string; value?: unknown; del?: boolean }[]; } /** Chat-side state for a surface — wraps the wire-format `A2uiSurface` @@ -45,16 +46,10 @@ export interface A2uiSurfaceStore { surfaceState(surfaceId: string): Signal; } -function entriesToObject(entries: A2uiDataModelEntry[]): Record { - const out: Record = {}; - for (const e of entries) { - if ('valueString' in e && e.valueString !== undefined) out[e.key] = e.valueString; - else if ('valueNumber' in e && e.valueNumber !== undefined) out[e.key] = e.valueNumber; - else if ('valueBoolean' in e && e.valueBoolean !== undefined) out[e.key] = e.valueBoolean; - else if ('valueMap' in e && Array.isArray(e.valueMap)) out[e.key] = entriesToObject(e.valueMap); - } - return out; -} +/** Component-envelope keys that are protocol structure, not renderable props. */ +const RESERVED_VIEW_PROP_KEYS = new Set([ + 'id', 'component', 'catalogId', 'weight', 'accessibility', 'checks', +]); /** Returns true if `path` (in `$.a.b.c` form) resolves to a defined, * non-null value inside `dataModel`. Used to decide per-component @@ -106,12 +101,58 @@ function resolveProps(value: unknown, dataModel: Record): unkno return value; } +/** Resolve a flat v0.9 component into the renderable prop bag: reserved + * protocol keys stripped, `{$.path}` references substituted. */ +function resolveViewProps( + component: A2uiComponent, + dataModel: Record, +): Record { + const out: Record = {}; + for (const [k, v] of Object.entries(component as unknown as Record)) { + if (RESERVED_VIEW_PROP_KEYS.has(k)) continue; + out[k] = resolveProps(v, dataModel); + } + return out; +} + +function projectView(component: A2uiComponent): A2uiComponentView { + return { + id: component.id, + type: typeof component.component === 'string' ? component.component : 'Unknown', + bindings: extractBindings(component), + ready: false, + props: {}, + def: component, + }; +} + +/** Apply one v0.9 data-model mutation (set at path, whole-model replace, + * or delete-at-path when `value` is omitted). */ +function applyDataModelDelta( + dataModel: Record, + delta: { path?: string; value?: unknown; del?: boolean }, +): Record { + const path = delta.path && delta.path !== '/' ? delta.path : undefined; + if (delta.del) { + return path ? deleteByPointer(dataModel, path) : {}; + } + if (!path) { + return (delta.value ?? {}) as Record; + } + return setByPointer(dataModel, path, delta.value); +} + /** * Create an {@link A2uiSurfaceStore} — the per-conversation store that buffers - * streamed A2UI surface updates, tracks each surface's data model + lifecycle + * streamed A2UI v0.9 envelopes, tracks each surface's data model + lifecycle * state, and exposes them as signals for rendering. One store backs a chat * thread's A2UI surfaces. * + * A surface becomes visible once its `createSurface` envelope has arrived AND + * a component with id `root` has been defined (the v0.9 progressive-rendering + * rule). Everything received earlier is buffered; afterwards, components merge + * incrementally by id and data-model updates apply immediately. + * * @returns A fresh, empty {@link A2uiSurfaceStore}. * @example * ```ts @@ -126,142 +167,123 @@ export function createA2uiSurfaceStore(): A2uiSurfaceStore { function bufferOf(surfaceId: string): SurfaceBuffer { let b = buffers.get(surfaceId); - if (!b) { b = { dataModelDeltas: [] }; buffers.set(surfaceId, b); } + if (!b) { + b = { components: new Map(), componentViews: new Map(), dataModelDeltas: [] }; + buffers.set(surfaceId, b); + } return b; } - function apply(message: A2uiMessage): void { - if ('surfaceUpdate' in message) { - const upd = message.surfaceUpdate as A2uiSurfaceUpdate; - const b = bufferOf(upd.surfaceId); - const map = new Map(); - for (const c of upd.components) map.set(c.id, c); - b.components = map; - // Project per-component views with bindings extracted from prop - // expressions. ready starts false; props starts empty. - const views = new Map(); - for (const c of upd.components) { - const def = c.component; - const typeKey = (def && typeof def === 'object') - ? (Object.keys(def)[0] ?? 'Unknown') - : 'Unknown'; - views.set(c.id, { - id: c.id, - type: typeKey, - bindings: extractBindings(def), - ready: false, - props: {}, - def, - }); - } - b.componentViews = views; - return; + function publish(surface: A2uiSurface, views: Map): void { + const nextSurfaces = new Map(surfacesSignal()); + nextSurfaces.set(surface.surfaceId, surface); + surfacesSignal.set(nextSurfaces); + const nextStates = new Map(surfaceStatesSignal()); + nextStates.set(surface.surfaceId, { surface, componentViews: views }); + surfaceStatesSignal.set(nextStates); + } + + /** Recompute readiness/props for every view against `dataModel`, + * honoring the monotonic ready rule. */ + function refreshViews( + views: ReadonlyMap, + dataModel: Record, + ): Map { + const next = new Map(); + for (const [id, v] of views) { + const allResolved = v.bindings.every((p) => isResolved(dataModel, p)); + // Monotonic: once ready=true, stays true even if a later update + // clears a referenced path. + const nextReady = v.ready || allResolved; + next.set(id, { + ...v, + ready: nextReady, + props: nextReady ? resolveViewProps(v.def, dataModel) : v.props, + }); } - if ('dataModelUpdate' in message) { - const upd = message.dataModelUpdate as A2uiDataModelUpdate; - const surface = surfacesSignal().get(upd.surfaceId); - if (surface) { - // Already-rendered surface: apply incrementally. - let dataModel = surface.dataModel; - const obj = entriesToObject(upd.contents); - if (upd.path && upd.path !== '/') { - for (const [k, v] of Object.entries(obj)) { - dataModel = setByPointer(dataModel, `${upd.path}/${k}`, v); - } - } else { - dataModel = { ...dataModel, ...obj }; - } - const next = new Map(surfacesSignal()); - const nextSurface = { ...surface, dataModel }; - next.set(upd.surfaceId, nextSurface); - surfacesSignal.set(next); + return next; + } - // Recompute per-component readiness with the monotonic rule. - const prevState = surfaceStatesSignal().get(upd.surfaceId); - if (prevState) { - const nextViews = new Map(); - for (const [id, v] of prevState.componentViews) { - const allResolved = v.bindings.every((p) => isResolved(dataModel, p)); - // Monotonic: once ready=true, stays true even if a later - // update clears a referenced path. - const nextReady = v.ready || allResolved; - nextViews.set(id, { - ...v, - ready: nextReady, - props: nextReady - ? (resolveProps(v.def, dataModel) as Record) - : v.props, - }); - } - const nextStatesMap = new Map(surfaceStatesSignal()); - nextStatesMap.set(upd.surfaceId, { surface: nextSurface, componentViews: nextViews }); - surfaceStatesSignal.set(nextStatesMap); - } - } else { - // Pre-render: buffer the delta. - const b = bufferOf(upd.surfaceId); - b.dataModelDeltas.push({ path: upd.path, contents: upd.contents }); + /** Commit the buffer to a live surface if the v0.9 render condition holds: + * createSurface seen AND a `root` component defined. */ + function tryCommit(surfaceId: string): void { + const b = buffers.get(surfaceId); + if (!b || !b.create || !b.components.has('root')) return; + + let dataModel: Record = {}; + for (const d of b.dataModelDeltas) { + dataModel = applyDataModelDelta(dataModel, d); + } + + const surface: A2uiSurface = { + surfaceId, + catalogId: b.create.catalogId, + ...(b.create.theme ? { theme: b.create.theme } : {}), + ...(b.create.sendDataModel !== undefined ? { sendDataModel: b.create.sendDataModel } : {}), + components: new Map(b.components), + dataModel, + }; + publish(surface, refreshViews(b.componentViews, dataModel)); + buffers.delete(surfaceId); + } + + function apply(message: A2uiMessage): void { + if ('createSurface' in message) { + const create = message.createSurface; + const live = surfacesSignal().get(create.surfaceId); + if (live) { + // v0.9 calls createSurface-on-existing an agent error; tolerate it + // as an idempotent refresh of the surface's create-time fields. + const state = surfaceStatesSignal().get(create.surfaceId); + const surface: A2uiSurface = { + ...live, + catalogId: create.catalogId, + ...(create.theme !== undefined ? { theme: create.theme } : {}), + ...(create.sendDataModel !== undefined ? { sendDataModel: create.sendDataModel } : {}), + }; + publish(surface, new Map(state?.componentViews ?? [])); + return; } + bufferOf(create.surfaceId).create = create; + tryCommit(create.surfaceId); return; } - if ('beginRendering' in message) { - const begin = message.beginRendering as A2uiBeginRendering; - const b = buffers.get(begin.surfaceId); - if (!b || !b.components) return; // no surfaceUpdate yet — no-op - // Build initial data model from buffered deltas. - let dataModel: Record = {}; - for (const d of b.dataModelDeltas) { - const obj = entriesToObject(d.contents); - if (d.path && d.path !== '/') { - for (const [k, v] of Object.entries(obj)) { - dataModel = setByPointer(dataModel, `${d.path}/${k}`, v); - } - } else { - dataModel = { ...dataModel, ...obj }; + if ('updateComponents' in message) { + const upd = message.updateComponents as A2uiUpdateComponents; + const live = surfacesSignal().get(upd.surfaceId); + if (live) { + // Incremental merge by id into the live surface. + const components = new Map(live.components); + const state = surfaceStatesSignal().get(upd.surfaceId); + const views = new Map(state?.componentViews ?? []); + for (const c of upd.components) { + components.set(c.id, c); + views.set(c.id, projectView(c)); } + const surface: A2uiSurface = { ...live, components }; + publish(surface, refreshViews(views, surface.dataModel)); + return; } - // Merge with any existing surface's dataModel if this is a re-render. - const existing = surfacesSignal().get(begin.surfaceId); - if (existing) { - dataModel = { ...existing.dataModel, ...dataModel }; + const b = bufferOf(upd.surfaceId); + for (const c of upd.components) { + b.components.set(c.id, c); + b.componentViews.set(c.id, projectView(c)); } - // Capture v1 styles (font, primaryColor) from beginRendering. A - // re-render keeps any prior styles unless the new beginRendering - // explicitly overrides them — this matches the agent's likely - // intent ("change the data, keep the look"). - const nextStyles = begin.styles - ?? existing?.styles; - const surface: A2uiSurface = { - surfaceId: begin.surfaceId, - catalogId: 'basic', - components: b.components, - dataModel, - ...(nextStyles ? { styles: nextStyles } : {}), - }; - const next = new Map(surfacesSignal()); - next.set(begin.surfaceId, surface); - surfacesSignal.set(next); - - // Project per-component views with initial readiness based on the - // accumulated data model. - const baseViews = b.componentViews ?? new Map(); - const initialViews = new Map(); - for (const [id, v] of baseViews) { - const allResolved = v.bindings.every((p) => isResolved(dataModel, p)); - initialViews.set(id, { - ...v, - ready: allResolved, - props: allResolved - ? (resolveProps(v.def, dataModel) as Record) - : {}, - }); + tryCommit(upd.surfaceId); + return; + } + if ('updateDataModel' in message) { + const upd = message.updateDataModel as A2uiUpdateDataModel; + const delta = { path: upd.path, value: upd.value, del: !('value' in upd) || upd.value === undefined }; + const live = surfacesSignal().get(upd.surfaceId); + if (live) { + const dataModel = applyDataModelDelta(live.dataModel, delta); + const surface: A2uiSurface = { ...live, dataModel }; + const state = surfaceStatesSignal().get(upd.surfaceId); + publish(surface, refreshViews(state?.componentViews ?? new Map(), dataModel)); + } else { + bufferOf(upd.surfaceId).dataModelDeltas.push(delta); } - const nextStates = new Map(surfaceStatesSignal()); - nextStates.set(begin.surfaceId, { surface, componentViews: initialViews }); - surfaceStatesSignal.set(nextStates); - - // Reset buffer so subsequent surfaceUpdate is the next round. - buffers.set(begin.surfaceId, { dataModelDeltas: [] }); return; } if ('deleteSurface' in message) { From 01a380771ae89cb2bfebe29ab08d9c77199a18f9 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Mon, 17 Aug 2026 07:59:51 -0700 Subject: [PATCH 06/15] feat(chat)!: surface-to-spec consumes flat v0.9 components and event actions Co-Authored-By: Claude Fable 5 --- .../chat/src/lib/a2ui/surface-to-spec.spec.ts | 168 ++++++++++-------- libs/chat/src/lib/a2ui/surface-to-spec.ts | 79 ++++---- 2 files changed, 138 insertions(+), 109 deletions(-) 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 d5c3052d3..f45dd9d51 100644 --- a/libs/chat/src/lib/a2ui/surface-to-spec.spec.ts +++ b/libs/chat/src/lib/a2ui/surface-to-spec.spec.ts @@ -9,32 +9,26 @@ function makeSurface(components: A2uiComponent[], dataModel: Record { - it('resolves root component from surface', () => { - const surface = makeSurface([ - { id: 'root', component: { Column: { children: { explicitList: ['t1'] } } } }, - { id: 't1', component: { Text: { text: { literalString: 'Hello' } } } }, - ]); - expect(surface.components.get('root')!.component).toMatchObject({ Column: {} }); - }); +const c = (comp: Record): A2uiComponent => comp as unknown as A2uiComponent; - it('resolves DynamicString literalString prop', () => { +describe('surfaceToSpec (v0.9)', () => { + it('resolves bare literal prop', () => { const surface = makeSurface([ - { id: 'root', component: { Text: { text: { literalString: 'Hi' } } } }, + c({ id: 'root', component: 'Text', text: 'Hi' }), ]); const spec = surfaceToSpec(surface)!; expect(spec.elements['root'].props['text']).toBe('Hi'); }); - it('leaves DynamicString path prop as $bindState marker for json-render', () => { + it('leaves path prop as $bindState marker for json-render', () => { // Path refs preserve their dynamic resolution: surface-to-spec emits // a `$bindState` marker so json-render reads the current value from // its state store on every render. This is what enables user input - // (TextField, MultipleChoice, etc.) to call host.set(path, value) + // (TextField, ChoicePicker, etc.) to call host.set(path, value) // via injectRenderHost() and have the UI reflect those writes // immediately. const surface = makeSurface( - [{ id: 'root', component: { Text: { text: { path: '/greeting' } } } }], + [c({ id: 'root', component: 'Text', text: { path: '/greeting' } })], { greeting: 'Hello World' }, ); const spec = surfaceToSpec(surface)!; @@ -43,6 +37,25 @@ describe('surfaceToSpec (v1)', () => { expect(spec.state).toEqual({ greeting: 'Hello World' }); }); + it('omits function-call props until Phase 2 ships execution', () => { + const surface = makeSurface([ + c({ id: 'root', component: 'Text', text: { call: 'formatString', args: { value: 'x' } } }), + ]); + const spec = surfaceToSpec(surface)!; + expect('text' in spec.elements['root'].props).toBe(false); + }); + + it('strips protocol base keys from props', () => { + const surface = makeSurface([ + c({ id: 'root', component: 'Text', text: 'Hi', weight: 2, checks: [{ call: 'required' }] }), + ]); + const spec = surfaceToSpec(surface)!; + expect(spec.elements['root'].props['id']).toBeUndefined(); + expect(spec.elements['root'].props['component']).toBeUndefined(); + expect(spec.elements['root'].props['weight']).toBeUndefined(); + expect(spec.elements['root'].props['checks']).toBeUndefined(); + }); + it('returns null when surface has no components', () => { const surface = makeSurface([]); expect(surfaceToSpec(surface)).toBeNull(); @@ -50,8 +63,8 @@ describe('surfaceToSpec (v1)', () => { it('Card: single child rendered as length-1 children array', () => { const surface = makeSurface([ - { id: 'root', component: { Card: { child: 'inner' } } }, - { id: 'inner', component: { Text: { text: { literalString: 'body' } } } }, + c({ id: 'root', component: 'Card', child: 'inner' }), + c({ id: 'inner', component: 'Text', text: 'body' }), ]); const spec = surfaceToSpec(surface)!; expect(spec.elements['root'].children).toEqual(['inner']); @@ -59,18 +72,18 @@ describe('surfaceToSpec (v1)', () => { it('Button: child rendered as length-1 children array', () => { const surface = makeSurface([ - { id: 'root', component: { Button: { child: 'lbl', action: { name: 'click' } } } }, - { id: 'lbl', component: { Text: { text: { literalString: 'OK' } } } }, + c({ id: 'root', component: 'Button', child: 'lbl', action: { event: { name: 'click' } } }), + c({ id: 'lbl', component: 'Text', text: 'OK' }), ]); const spec = surfaceToSpec(surface)!; expect(spec.elements['root'].children).toEqual(['lbl']); }); - it('Column: explicitList children', () => { + it('Column: static children array', () => { const surface = makeSurface([ - { id: 'root', component: { Column: { children: { explicitList: ['a', 'b'] } } } }, - { id: 'a', component: { Text: { text: { literalString: 'A' } } } }, - { id: 'b', component: { Text: { text: { literalString: 'B' } } } }, + c({ id: 'root', component: 'Column', children: ['a', 'b'] }), + c({ id: 'a', component: 'Text', text: 'A' }), + c({ id: 'b', component: 'Text', text: 'B' }), ]); const spec = surfaceToSpec(surface)!; expect(spec.elements['root'].children).toEqual(['a', 'b']); @@ -79,9 +92,9 @@ describe('surfaceToSpec (v1)', () => { it('List: template expansion over dataModel array', () => { const surface = makeSurface( [ - { id: 'root', component: { List: { children: { template: { componentId: 'item', dataBinding: '/items' } } } } }, + c({ id: 'root', component: 'List', children: { componentId: 'item', path: '/items' } }), // Relative path 'name' is resolved against each item's basePath (/items/0, /items/1) - { id: 'item', component: { Text: { text: { path: 'name' } } } }, + c({ id: 'item', component: 'Text', text: { path: 'name' } }), ], { items: [{ name: 'Alice' }, { name: 'Bob' }] }, ); @@ -91,43 +104,39 @@ describe('surfaceToSpec (v1)', () => { expect(spec.elements['item__1'].props['text']).toBe('Bob'); }); - it('Modal: entryPointChild + contentChild as children array', () => { + it('Modal: trigger + content as children array', () => { const surface = makeSurface([ - { id: 'root', component: { Modal: { entryPointChild: 'trigger', contentChild: 'body', title: { literalString: 'My Modal' } } } }, - { id: 'trigger', component: { Button: { child: 'lbl', action: { name: 'open' } } } }, - { id: 'body', component: { Text: { text: { literalString: 'content' } } } }, - { id: 'lbl', component: { Text: { text: { literalString: 'Open' } } } }, + c({ id: 'root', component: 'Modal', trigger: 'trigger', content: 'body' }), + c({ id: 'trigger', component: 'Button', child: 'lbl', action: { event: { name: 'open' } } }), + c({ id: 'body', component: 'Text', text: 'content' }), + c({ id: 'lbl', component: 'Text', text: 'Open' }), ]); const spec = surfaceToSpec(surface)!; expect(spec.elements['root'].children).toEqual(['trigger', 'body']); }); - it('Tabs: tabItems children', () => { + it('Tabs: tabs[].child children + resolved tabTitles', () => { const surface = makeSurface([ - { id: 'root', component: { Tabs: { tabItems: [ - { title: { literalString: 'Tab 1' }, child: 'panel1' }, - { title: { literalString: 'Tab 2' }, child: 'panel2' }, - ] } } }, - { id: 'panel1', component: { Text: { text: { literalString: 'Panel 1' } } } }, - { id: 'panel2', component: { Text: { text: { literalString: 'Panel 2' } } } }, + c({ id: 'root', component: 'Tabs', tabs: [ + { title: 'Tab 1', child: 'panel1' }, + { title: 'Tab 2', child: 'panel2' }, + ] }), + c({ id: 'panel1', component: 'Text', text: 'Panel 1' }), + c({ id: 'panel2', component: 'Text', text: 'Panel 2' }), ]); const spec = surfaceToSpec(surface)!; expect(spec.elements['root'].children).toEqual(['panel1', 'panel2']); + expect(spec.elements['root'].props['tabTitles']).toEqual(['Tab 1', 'Tab 2']); }); - it('maps Button action to spec on.click binding', () => { + it('maps Button event action to spec on.click binding', () => { const surface = makeSurface([ - { id: 'root', component: { Column: { children: { explicitList: ['btn'] } } } }, - { - id: 'btn', - component: { Button: { - child: 'lbl', - action: { name: 'formSubmit', context: [ - { key: 'formId', value: { literalString: 'signup' } }, - ] }, - } }, - }, - { id: 'lbl', component: { Text: { text: { literalString: 'Submit' } } } }, + c({ id: 'root', component: 'Column', children: ['btn'] }), + c({ + id: 'btn', component: 'Button', child: 'lbl', + action: { event: { name: 'formSubmit', context: { formId: 'signup' } } }, + }), + c({ id: 'lbl', component: 'Text', text: 'Submit' }), ]); const spec = surfaceToSpec(surface)!; const btnElement = spec.elements['btn']; @@ -138,20 +147,15 @@ describe('surfaceToSpec (v1)', () => { }); }); - it('resolves action context DynamicValue path', () => { + it('resolves action context path bindings against the data model', () => { const surface = makeSurface( [ - { id: 'root', component: { Column: { children: { explicitList: ['btn'] } } } }, - { - id: 'btn', - component: { Button: { - child: 'lbl', - action: { name: 'submit', context: [ - { key: 'email', value: { path: '/email' } }, - ] }, - } }, - }, - { id: 'lbl', component: { Text: { text: { literalString: 'Go' } } } }, + c({ id: 'root', component: 'Column', children: ['btn'] }), + c({ + id: 'btn', component: 'Button', child: 'lbl', + action: { event: { name: 'submit', context: { email: { path: '/email' } } } }, + }), + c({ id: 'lbl', component: 'Text', text: 'Go' }), ], { email: 'alice@example.com' }, ); @@ -160,9 +164,19 @@ describe('surfaceToSpec (v1)', () => { expect(params['context']).toEqual({ email: 'alice@example.com' }); }); + it('functionCall actions emit no on binding (Phase 2)', () => { + const surface = makeSurface([ + c({ id: 'root', component: 'Button', child: 'lbl', + action: { functionCall: { call: 'openUrl', args: { url: 'https://x' } } } }), + c({ id: 'lbl', component: 'Text', text: 'Open' }), + ]); + const spec = surfaceToSpec(surface)!; + expect(spec.elements['root'].on).toBeUndefined(); + }); + it('passes through elements without actions unchanged', () => { const surface = makeSurface([ - { id: 'root', component: { Text: { text: { literalString: 'Hello' } } } }, + c({ id: 'root', component: 'Text', text: 'Hello' }), ]); const spec = surfaceToSpec(surface)!; expect(spec.elements['root'].on).toBeUndefined(); @@ -170,7 +184,7 @@ describe('surfaceToSpec (v1)', () => { it('initializes spec state from surface dataModel', () => { const surface = makeSurface( - [{ id: 'root', component: { Text: { text: { literalString: 'Hi' } } } }], + [c({ id: 'root', component: 'Text', text: 'Hi' })], { count: 0, name: 'test' }, ); const spec = surfaceToSpec(surface)!; @@ -179,29 +193,43 @@ describe('surfaceToSpec (v1)', () => { it('attaches _bindings prop for path ref values', () => { const surface = makeSurface( - [{ id: 'root', component: { TextField: { label: { literalString: 'Name' }, text: { path: '/name' } } } }], + [c({ id: 'root', component: 'TextField', label: 'Name', value: { path: '/name' } })], { name: 'Alice' }, ); const spec = surfaceToSpec(surface)!; - // Path refs become $bindState markers (see "leaves DynamicString - // path prop" test above). _bindings still maps prop name → path so - // catalog components can call host.set(path, value) via - // injectRenderHost() on user input. - expect(spec.elements['root'].props['text']).toEqual({ $bindState: '/name' }); - expect(spec.elements['root'].props['_bindings']).toEqual({ text: '/name' }); + // Path refs become $bindState markers (see "leaves path prop" test + // above). _bindings still maps prop name → path so catalog components + // can call host.set(path, value) via injectRenderHost() on user input. + expect(spec.elements['root'].props['value']).toEqual({ $bindState: '/name' }); + expect(spec.elements['root'].props['_bindings']).toEqual({ value: '/name' }); }); it('does not attach _bindings for literal values', () => { const surface = makeSurface([ - { id: 'root', component: { Text: { text: { literalString: 'Hello' } } } }, + c({ id: 'root', component: 'Text', text: 'Hello' }), ]); const spec = surfaceToSpec(surface)!; expect(spec.elements['root'].props['_bindings']).toBeUndefined(); }); + it('ChoicePicker: options labels resolved to plain strings', () => { + const surface = makeSurface( + [c({ id: 'root', component: 'ChoicePicker', value: { path: '/picked' }, options: [ + { label: 'A', value: 'a' }, + { label: { path: '/labels/b' }, value: 'b' }, + ] })], + { picked: [], labels: { b: 'Bee' } }, + ); + const spec = surfaceToSpec(surface)!; + expect(spec.elements['root'].props['options']).toEqual([ + { label: 'A', value: 'a' }, + { label: 'Bee', value: 'b' }, + ]); + }); + it('uses first component as root when no root component exists', () => { const surface = makeSurface([ - { id: 'child', component: { Text: { text: { literalString: 'No root' } } } }, + c({ id: 'child', component: 'Text', text: 'No root' }), ]); const spec = surfaceToSpec(surface)!; expect(spec.root).toBe('child'); diff --git a/libs/chat/src/lib/a2ui/surface-to-spec.ts b/libs/chat/src/lib/a2ui/surface-to-spec.ts index 2e5d33936..65d85c701 100644 --- a/libs/chat/src/lib/a2ui/surface-to-spec.ts +++ b/libs/chat/src/lib/a2ui/surface-to-spec.ts @@ -1,35 +1,35 @@ // SPDX-License-Identifier: MIT import type { Spec, UIElement } from '@json-render/core'; import type { - A2uiSurface, A2uiComponent, A2uiAction, A2uiChildren, - A2uiActionContextEntry, + A2uiSurface, A2uiAction, A2uiChildren, } from '@threadplane/a2ui'; -import { resolveDynamic, getByPointer, isPathRef } from '@threadplane/a2ui'; +import { resolveDynamic, getByPointer, isPathRef, isFunctionCall } from '@threadplane/a2ui'; -const RESERVED_PROP_KEYS = new Set(['child', 'children', 'action', 'tabItems', 'entryPointChild', 'contentChild']); +/** Keys that are protocol structure (base fields + child/action wiring), + * not renderable props. */ +const RESERVED_PROP_KEYS = new Set([ + 'id', 'component', 'catalogId', 'weight', 'accessibility', 'checks', + 'child', 'children', 'action', 'tabs', 'trigger', 'content', +]); type RenderedAction = Record }>; -/** Pull the (single) component-type key + its props from a v1 ComponentDef wrapper. */ -function unwrapComponentDef(def: A2uiComponent['component']): { type: string; props: Record } { - const entries = Object.entries(def as Record); - if (entries.length !== 1) { - return { type: 'Text', props: {} }; - } - const [type, props] = entries[0]; - return { type, props: (props ?? {}) as Record }; -} - function resolveAction( action: A2uiAction | undefined, surface: A2uiSurface, sourceComponentId: string, ): RenderedAction | undefined { - if (!action) return undefined; + if (!action || typeof action !== 'object') return undefined; + if (!('event' in action)) { + // functionCall actions execute client-side (Phase 2); nothing to wire yet. + return undefined; + } + const event = action.event; + if (!event || typeof event.name !== 'string') return undefined; const resolvedContext: Record = {}; - if (Array.isArray(action.context)) { - for (const entry of action.context as A2uiActionContextEntry[]) { - resolvedContext[entry.key] = resolveDynamic(entry.value, surface.dataModel); + if (event.context && typeof event.context === 'object') { + for (const [key, value] of Object.entries(event.context)) { + resolvedContext[key] = resolveDynamic(value, surface.dataModel); } } return { @@ -38,7 +38,7 @@ function resolveAction( params: { surfaceId: surface.surfaceId, sourceComponentId, - name: action.name, + name: event.name, context: resolvedContext, }, }, @@ -50,15 +50,14 @@ function childrenToList( surface: A2uiSurface, ): { ids: string[]; templateExpand?: { componentId: string; arrPath: string; arr: unknown[] } } | undefined { if (!children) return undefined; - if ('explicitList' in children) { - return { ids: children.explicitList }; + if (Array.isArray(children)) { + return { ids: children }; } - if ('template' in children) { - const t = children.template; - const arr = getByPointer(surface.dataModel, t.dataBinding); + if (typeof children === 'object' && 'componentId' in children && 'path' in children) { + const arr = getByPointer(surface.dataModel, children.path); if (!Array.isArray(arr)) return { ids: [] }; - const ids = arr.map((_, i) => `${t.componentId}__${i}`); - return { ids, templateExpand: { componentId: t.componentId, arrPath: t.dataBinding, arr } }; + const ids = arr.map((_, i) => `${children.componentId}__${i}`); + return { ids, templateExpand: { componentId: children.componentId, arrPath: children.path, arr } }; } return undefined; } @@ -69,7 +68,8 @@ export function surfaceToSpec(surface: A2uiSurface): Spec | null { const elements: Record = {}; for (const [id, comp] of surface.components) { - const { type, props: rawProps } = unwrapComponentDef(comp.component); + const type = typeof comp.component === 'string' ? comp.component : 'Text'; + const rawProps = comp as unknown as Record; const resolvedProps: Record = {}; const bindings: Record = {}; @@ -88,6 +88,9 @@ export function surfaceToSpec(surface: A2uiSurface): Spec | null { const path = (value as { path: string }).path; bindings[key] = path; resolvedProps[key] = { $bindState: path }; + } else if (isFunctionCall(value)) { + // Client-side function values ship in Phase 2 — omit until then. + continue; } else { resolvedProps[key] = resolveDynamic(value, surface.dataModel); } @@ -99,26 +102,23 @@ export function surfaceToSpec(surface: A2uiSurface): Spec | null { const action = (rawProps as { action?: A2uiAction }).action; const on = resolveAction(action, surface, id); - // Map children — handle Card single child / Button single child / Modal entryPointChild+contentChild / Tabs tabItems + // Map children — Card/Button single child, Modal trigger+content, Tabs tabs[]. let children: string[] | undefined; - if (type === 'Card' && typeof (rawProps as { child?: unknown }).child === 'string') { - children = [(rawProps as { child: string }).child]; - } else if (type === 'Button' && typeof (rawProps as { child?: unknown }).child === 'string') { - children = [(rawProps as { child: string }).child]; + if ((type === 'Card' || type === 'Button') && typeof rawProps['child'] === 'string') { + children = [rawProps['child'] as string]; } else if (type === 'Modal') { - const m = rawProps as { entryPointChild?: string; contentChild?: string }; const ids: string[] = []; - if (m.entryPointChild) ids.push(m.entryPointChild); - if (m.contentChild) ids.push(m.contentChild); + if (typeof rawProps['trigger'] === 'string') ids.push(rawProps['trigger'] as string); + if (typeof rawProps['content'] === 'string') ids.push(rawProps['content'] as string); children = ids; } else if (type === 'Tabs') { - const items = (rawProps as { tabItems?: { title?: unknown; child: string }[] }).tabItems ?? []; + const items = (rawProps as { tabs?: { title?: unknown; child: string }[] }).tabs ?? []; children = items.map(t => t.child); // Resolve tab titles and pass them as a plain string array for the Tabs component's tab bar. resolvedProps['tabTitles'] = items.map(t => t.title !== undefined ? String(resolveDynamic(t.title, surface.dataModel)) : '', ); - } else if (type === 'MultipleChoice') { + } else if (type === 'ChoicePicker') { // Resolve options[*].label (DynamicString) so the component receives plain strings. const opts = (rawProps as { options?: { label?: unknown; value: string }[] }).options ?? []; resolvedProps['options'] = opts.map(o => ({ @@ -126,14 +126,15 @@ export function surfaceToSpec(surface: A2uiSurface): Spec | null { value: o.value, })); } else { - const childInfo = childrenToList((rawProps as { children?: A2uiChildren }).children, surface); + const childInfo = childrenToList(rawProps['children'] as A2uiChildren | undefined, surface); if (childInfo) { children = childInfo.ids; if (childInfo.templateExpand) { const t = childInfo.templateExpand; const templateComp = surface.components.get(t.componentId); if (templateComp) { - const { type: tType, props: tRaw } = unwrapComponentDef(templateComp.component); + const tType = typeof templateComp.component === 'string' ? templateComp.component : 'Text'; + const tRaw = templateComp as unknown as Record; for (let i = 0; i < t.arr.length; i++) { const scope = { basePath: `${t.arrPath}/${i}`, item: t.arr[i] }; const itemProps: Record = {}; From a9467f1a2b12efc8e98346dcf672ae04587b5f70 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Mon, 17 Aug 2026 08:07:20 -0700 Subject: [PATCH 07/15] =?UTF-8?q?feat(chat)!:=20v0.9=20glue=20=E2=80=94=20?= =?UTF-8?q?createSurface=20synthesis=20bridge,=20plain-object=20action=20c?= =?UTF-8?q?ontext,=20envelope=20sniffers?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- libs/chat/src/lib/a2ui/action-label.spec.ts | 22 +-- libs/chat/src/lib/a2ui/action-label.ts | 7 +- .../src/lib/a2ui/build-action-message.spec.ts | 65 +++------ .../chat/src/lib/a2ui/build-action-message.ts | 58 +++----- .../src/lib/a2ui/envelope-normalizer.spec.ts | 10 +- libs/chat/src/lib/a2ui/envelope-normalizer.ts | 4 +- .../src/lib/a2ui/partial-args-bridge.spec.ts | 135 ++++++++---------- libs/chat/src/lib/a2ui/partial-args-bridge.ts | 114 +++++++-------- .../src/lib/a2ui/surface.component.spec.ts | 22 ++- libs/chat/src/lib/a2ui/surface.component.ts | 32 ++--- .../lib/compositions/chat/chat.component.ts | 8 +- libs/chat/src/public-api.ts | 17 ++- 12 files changed, 207 insertions(+), 287 deletions(-) diff --git a/libs/chat/src/lib/a2ui/action-label.spec.ts b/libs/chat/src/lib/a2ui/action-label.spec.ts index f0767487f..5a75d2a10 100644 --- a/libs/chat/src/lib/a2ui/action-label.spec.ts +++ b/libs/chat/src/lib/a2ui/action-label.spec.ts @@ -5,29 +5,35 @@ import { a2uiActionLabel } from './action-label'; describe('a2uiActionLabel', () => { it('returns the authored label when action.label is present', () => { const content = JSON.stringify({ - version: 'v1', + version: 'v0.9', action: { name: 'bookingSubmit', label: 'Search flights' }, }); expect(a2uiActionLabel(content)).toBe('Search flights'); }); it('falls back to camelCase humanization when no label', () => { - const content = JSON.stringify({ version: 'v1', action: { name: 'bookingSubmit' } }); + const content = JSON.stringify({ version: 'v0.9', action: { name: 'bookingSubmit' } }); expect(a2uiActionLabel(content)).toBe('Booking submit'); }); it('humanizes single-word action name', () => { - const content = JSON.stringify({ version: 'v1', action: { name: 'submit' } }); + const content = JSON.stringify({ version: 'v0.9', action: { name: 'submit' } }); expect(a2uiActionLabel(content)).toBe('Submit'); }); it('humanizes multi-camel action name', () => { - const content = JSON.stringify({ version: 'v1', action: { name: 'addItemToCart' } }); + const content = JSON.stringify({ version: 'v0.9', action: { name: 'addItemToCart' } }); expect(a2uiActionLabel(content)).toBe('Add item to cart'); }); - it('returns null for non-v1 messages', () => { - expect(a2uiActionLabel('{"version":"v2","action":{"name":"x"}}')).toBeNull(); + it('returns null when version is missing or not a version string', () => { + expect(a2uiActionLabel('{"action":{"name":"x"}}')).toBeNull(); + expect(a2uiActionLabel('{"version":123,"action":{"name":"x"}}')).toBeNull(); + expect(a2uiActionLabel('{"version":"1.0","action":{"name":"x"}}')).toBeNull(); + }); + + it('accepts future protocol versions (forward compat)', () => { + expect(a2uiActionLabel('{"version":"v1.0","action":{"name":"addItem"}}')).toBe('Add item'); }); it('returns null for non-action JSON', () => { @@ -49,7 +55,7 @@ describe('a2uiActionLabel', () => { it('prefers authored label over humanization', () => { // Even if name would humanize to "Foo bar", the label wins. const content = JSON.stringify({ - version: 'v1', + version: 'v0.9', action: { name: 'fooBar', label: 'Custom Label' }, }); expect(a2uiActionLabel(content)).toBe('Custom Label'); @@ -57,7 +63,7 @@ describe('a2uiActionLabel', () => { it('falls back to humanization when label is empty string', () => { const content = JSON.stringify({ - version: 'v1', + version: 'v0.9', action: { name: 'fooBar', label: '' }, }); expect(a2uiActionLabel(content)).toBe('Foo bar'); diff --git a/libs/chat/src/lib/a2ui/action-label.ts b/libs/chat/src/lib/a2ui/action-label.ts index 517072ecd..71d062749 100644 --- a/libs/chat/src/lib/a2ui/action-label.ts +++ b/libs/chat/src/lib/a2ui/action-label.ts @@ -2,7 +2,7 @@ /** * Synthesize a short human-readable label for a serialized A2UI action * message, so the chat composition can render "Search flights" instead - * of a raw `{"version":"v1","action":...}` JSON dump as a user bubble. + * of a raw `{"version":"v0.9","action":...}` JSON dump as a user bubble. * * Per the A2UI spec, action messages flow on the client → agent * return channel and are framed as typed events (closer to tool calls @@ -19,7 +19,7 @@ * "Booking submit"). Used when no label was stamped — typically * because the source component isn't a Button-with-Text-child. * - * Returns null for any content that isn't a v1 A2UI action message; + * Returns null for any content that isn't an A2UI action message; * callers should fall back to the original content in that case. * * Design context: a previous iteration shipped a hardcoded @@ -45,7 +45,8 @@ export function a2uiActionLabel(content: string): string | null { return null; } if (!isRecord(parsed)) return null; - if (parsed['version'] !== 'v1') return null; + const version = parsed['version']; + if (typeof version !== 'string' || !version.startsWith('v')) return null; const action = parsed['action']; if (!isRecord(action)) return null; const name = action['name']; diff --git a/libs/chat/src/lib/a2ui/build-action-message.spec.ts b/libs/chat/src/lib/a2ui/build-action-message.spec.ts index ef343f164..ce16b1ea2 100644 --- a/libs/chat/src/lib/a2ui/build-action-message.spec.ts +++ b/libs/chat/src/lib/a2ui/build-action-message.spec.ts @@ -13,11 +13,13 @@ function makeSurface( return { surfaceId: 's1', catalogId: 'basic', sendDataModel, components: map, dataModel }; } +const c = (comp: Record): A2uiComponent => comp as unknown as A2uiComponent; + function makeTextComp(): A2uiComponent { - return { id: 'root', component: { Text: { text: { literalString: 'hi' } } } }; + return c({ id: 'root', component: 'Text', text: 'hi' }); } -describe('buildA2uiActionMessage (v1)', () => { +describe('buildA2uiActionMessage (v0.9)', () => { it('builds an action message with required fields', () => { const surface = makeSurface([makeTextComp()]); const params = { @@ -27,7 +29,7 @@ describe('buildA2uiActionMessage (v1)', () => { context: {}, }; const msg = buildA2uiActionMessage(params, surface); - expect(msg.version).toBe('v1'); + expect(msg.version).toBe('v0.9'); expect(msg.action.name).toBe('formSubmit'); expect(msg.action.surfaceId).toBe('s1'); expect(msg.action.sourceComponentId).toBe('submit-btn'); @@ -35,40 +37,16 @@ describe('buildA2uiActionMessage (v1)', () => { expect(msg.metadata).toBeUndefined(); }); - it('wraps string context values as literalString DynamicValue', () => { + it('passes context values through unwrapped (v0.9 plain object)', () => { const surface = makeSurface([makeTextComp()]); const params = { surfaceId: 's1', sourceComponentId: 'btn', name: 'submit', - context: { surface: 'feedback' }, - }; - const msg = buildA2uiActionMessage(params, surface); - expect(msg.action.context['surface']).toEqual({ literalString: 'feedback' }); - }); - - it('wraps number context values as literalNumber DynamicValue', () => { - const surface = makeSurface([makeTextComp()]); - const params = { - surfaceId: 's1', - sourceComponentId: 'btn', - name: 'rate', - context: { score: 5 }, + context: { surface: 'feedback', score: 5, checked: true }, }; const msg = buildA2uiActionMessage(params, surface); - expect(msg.action.context['score']).toEqual({ literalNumber: 5 }); - }); - - it('wraps boolean context values as literalBoolean DynamicValue', () => { - const surface = makeSurface([makeTextComp()]); - const params = { - surfaceId: 's1', - sourceComponentId: 'btn', - name: 'toggle', - context: { checked: true }, - }; - const msg = buildA2uiActionMessage(params, surface); - expect(msg.action.context['checked']).toEqual({ literalBoolean: true }); + expect(msg.action.context).toEqual({ surface: 'feedback', score: 5, checked: true }); }); it('attaches data model when sendDataModel is true', () => { @@ -80,8 +58,7 @@ describe('buildA2uiActionMessage (v1)', () => { const params = { surfaceId: 's1', sourceComponentId: 'btn', name: 'submit', context: {} }; const msg = buildA2uiActionMessage(params, surface); expect(msg.metadata).toBeDefined(); - expect(msg.metadata!.a2uiClientDataModel.version).toBe('v1'); - expect(msg.metadata!.a2uiClientDataModel.surfaces['s1']).toEqual({ name: 'Alice', email: 'alice@co.com' }); + expect(msg.metadata!.a2uiClientDataModel!.surfaces['s1']).toEqual({ name: 'Alice', email: 'alice@co.com' }); }); it('does not attach data model when sendDataModel is false', () => { @@ -98,10 +75,11 @@ describe('buildA2uiActionMessage (v1)', () => { expect(msg.action.context).toEqual({}); }); - it('derives action.label from source Button child Text (wrapped literalString)', () => { + it('derives action.label from source Button child Text', () => { const components: A2uiComponent[] = [ - { id: 'submit-btn', component: { Button: { child: 'submit-label', action: { name: 'formSubmit' } } } }, - { id: 'submit-label', component: { Text: { text: { literalString: 'Search flights' } } } }, + c({ id: 'submit-btn', component: 'Button', child: 'submit-label', + action: { event: { name: 'formSubmit' } } }), + c({ id: 'submit-label', component: 'Text', text: 'Search flights' }), ]; const surface = makeSurface(components); const params = { surfaceId: 's1', sourceComponentId: 'submit-btn', name: 'formSubmit', context: {} }; @@ -109,23 +87,20 @@ describe('buildA2uiActionMessage (v1)', () => { expect(msg.action.label).toBe('Search flights'); }); - it('derives action.label from source Button child Text (raw string shorthand)', () => { - // The LLM sometimes authors `text` as a raw string (ergonomic shorthand) - // rather than the canonical `{ literalString }` shape. Both are valid in - // the wild — the derivation accepts both. Real example from c-a2ui. + it('leaves action.label undefined when Button child Text is a path binding', () => { const components: A2uiComponent[] = [ - { id: 'submit', component: { Button: { child: 'submit_label', action: { name: 'bookingSubmit' } } } }, - { id: 'submit_label', component: { Text: { text: 'Search flights' as unknown as { literalString: string } } } }, + c({ id: 'btn', component: 'Button', child: 'lbl', action: { event: { name: 'go' } } }), + c({ id: 'lbl', component: 'Text', text: { path: '/cta' } }), ]; const surface = makeSurface(components); - const params = { surfaceId: 's1', sourceComponentId: 'submit', name: 'bookingSubmit', context: {} }; + const params = { surfaceId: 's1', sourceComponentId: 'btn', name: 'go', context: {} }; const msg = buildA2uiActionMessage(params, surface); - expect(msg.action.label).toBe('Search flights'); + expect(msg.action.label).toBeUndefined(); }); it('leaves action.label undefined when source is not a Button', () => { const components: A2uiComponent[] = [ - { id: 'cb', component: { CheckBox: { label: { literalString: 'Agree' }, checked: { literalBoolean: false } } } }, + c({ id: 'cb', component: 'CheckBox', label: 'Agree', value: false }), ]; const surface = makeSurface(components); const params = { surfaceId: 's1', sourceComponentId: 'cb', name: 'agreeToggle', context: {} }; @@ -135,7 +110,7 @@ describe('buildA2uiActionMessage (v1)', () => { it('leaves action.label undefined when Button has no child Text id', () => { const components: A2uiComponent[] = [ - { id: 'submit-btn', component: { Button: { action: { name: 'formSubmit' } } as unknown as { child: string; action: { name: string } } } }, + c({ id: 'submit-btn', component: 'Button', action: { event: { name: 'formSubmit' } } }), ]; const surface = makeSurface(components); const params = { surfaceId: 's1', sourceComponentId: 'submit-btn', name: 'formSubmit', context: {} }; diff --git a/libs/chat/src/lib/a2ui/build-action-message.ts b/libs/chat/src/lib/a2ui/build-action-message.ts index db5d0a5f0..244a3db15 100644 --- a/libs/chat/src/lib/a2ui/build-action-message.ts +++ b/libs/chat/src/lib/a2ui/build-action-message.ts @@ -1,19 +1,13 @@ // SPDX-License-Identifier: MIT +import { A2UI_WIRE_VERSION } from '@threadplane/a2ui'; import type { A2uiSurface, A2uiActionMessage } from '@threadplane/a2ui'; -function toDynamicValue(v: unknown): unknown { - if (typeof v === 'string') return { literalString: v }; - if (typeof v === 'number') return { literalNumber: v }; - if (typeof v === 'boolean') return { literalBoolean: v }; - return { literalString: String(v) }; -} - /** * Derive a human-readable label for an outgoing action by walking from * the source component to its authored visible text. Today supported: - * Button → child Text → literalString. Returns null for other component - * types or when the linkage isn't well-formed; callers fall back to a - * camelCase humanization of `action.name`. + * Button → child Text → text. Returns null for other component types or + * when the linkage isn't well-formed; callers fall back to a camelCase + * humanization of `action.name`. * * Why: the chat-lib used to ship a hardcoded `KNOWN_LABELS` map * (bookingSubmit → 'Search flights') that embedded app-specific @@ -23,51 +17,40 @@ function toDynamicValue(v: unknown): unknown { */ function deriveActionLabel(surface: A2uiSurface, sourceId: string): string | null { const source = surface.components.get(sourceId); - if (!source) return null; - const buttonProps = (source.component as { Button?: { child?: string } }).Button; - if (!buttonProps?.child) return null; - const labelText = surface.components.get(buttonProps.child); - if (!labelText) return null; - const textProps = (labelText.component as { Text?: { text?: unknown } }).Text; - if (!textProps) return null; - // `text` may be either a raw string (LLM-author ergonomic shorthand) or - // a wrapped DynamicString `{ literalString: "..." }` (canonical v1 shape). - // Accept both so the label survives whichever form the LLM happens to emit. - const text = textProps.text; + if (!source || source.component !== 'Button') return null; + const childId = (source as { child?: unknown }).child; + if (typeof childId !== 'string') return null; + const labelText = surface.components.get(childId); + if (!labelText || labelText.component !== 'Text') return null; + // v0.9 dynamic strings are bare literals or `{ path }` bindings; only a + // bare literal is a usable authored label. + const text = (labelText as { text?: unknown }).text; if (typeof text === 'string') { return text.length > 0 ? text : null; } - if (text && typeof text === 'object' && typeof (text as { literalString?: unknown }).literalString === 'string') { - const literal = (text as { literalString: string }).literalString; - return literal.length > 0 ? literal : null; - } return null; } -/** Builds an A2uiActionMessage from handler params and the current surface. - * The action.context is serialized as v1 DynamicValue-wrapped entries. - * Sets action.label when the source component is a Button with a Text - * child whose literalString is non-empty. */ +/** Builds a v0.9 A2uiActionMessage from handler params and the current + * surface. The action.context is the resolved plain object the renderer + * produced from the component's `action.event.context`. Sets action.label + * when the source component is a Button with a Text child whose text is a + * non-empty bare literal. */ export function buildA2uiActionMessage( params: Record, surface: A2uiSurface, ): A2uiActionMessage { - const rawContext = (params['context'] as Record) ?? {}; - const wrappedContext: Record = {}; - for (const [k, v] of Object.entries(rawContext)) { - wrappedContext[k] = toDynamicValue(v); - } - + const context = (params['context'] as Record) ?? {}; const sourceComponentId = params['sourceComponentId'] as string; const message: A2uiActionMessage = { - version: 'v1', + version: A2UI_WIRE_VERSION, action: { name: params['name'] as string, surfaceId: surface.surfaceId, sourceComponentId, timestamp: new Date().toISOString(), - context: wrappedContext, + context, }, }; @@ -77,7 +60,6 @@ export function buildA2uiActionMessage( if (surface.sendDataModel) { message.metadata = { a2uiClientDataModel: { - version: 'v1', surfaces: { [surface.surfaceId]: surface.dataModel }, }, }; diff --git a/libs/chat/src/lib/a2ui/envelope-normalizer.spec.ts b/libs/chat/src/lib/a2ui/envelope-normalizer.spec.ts index ac9e305e5..eed1e8519 100644 --- a/libs/chat/src/lib/a2ui/envelope-normalizer.spec.ts +++ b/libs/chat/src/lib/a2ui/envelope-normalizer.spec.ts @@ -5,24 +5,24 @@ import { normalizeEnvelopeArgs } from './envelope-normalizer'; describe('normalizeEnvelopeArgs', () => { it('returns the list for the canonical {envelopes: [...]} shape', () => { - const args = { envelopes: [{ surfaceUpdate: { surfaceId: 's', components: [] } }] }; + const args = { envelopes: [{ updateComponents: { surfaceId: 's', components: [] } }] }; expect(normalizeEnvelopeArgs(args)).toEqual(args.envelopes); }); it('returns the list for the singular {envelope: [...]} typo shape', () => { - const args = { envelope: [{ beginRendering: { surfaceId: 's', root: 'r' } }] }; + const args = { envelope: [{ createSurface: { surfaceId: 's', catalogId: 'basic' } }] }; expect(normalizeEnvelopeArgs(args)).toEqual(args.envelope); }); it('unflattens positional {0: ..., 1: ...} keys in numeric order', () => { - const e1 = { surfaceUpdate: { surfaceId: 's', components: [] } }; - const e2 = { beginRendering: { surfaceId: 's', root: 'r' } }; + const e1 = { updateComponents: { surfaceId: 's', components: [] } }; + const e2 = { createSurface: { surfaceId: 's', catalogId: 'basic' } }; const args = { 1: e2, 0: e1 }; expect(normalizeEnvelopeArgs(args)).toEqual([e1, e2]); }); it('wraps a flat single envelope into a one-element array', () => { - const args = { surfaceUpdate: { surfaceId: 's', components: [] } }; + const args = { updateComponents: { surfaceId: 's', components: [] } }; expect(normalizeEnvelopeArgs(args)).toEqual([args]); }); diff --git a/libs/chat/src/lib/a2ui/envelope-normalizer.ts b/libs/chat/src/lib/a2ui/envelope-normalizer.ts index 86b3d605f..18f612aec 100644 --- a/libs/chat/src/lib/a2ui/envelope-normalizer.ts +++ b/libs/chat/src/lib/a2ui/envelope-normalizer.ts @@ -1,7 +1,7 @@ // libs/chat/src/lib/a2ui/envelope-normalizer.ts // SPDX-License-Identifier: MIT -const ENVELOPE_KEYS = ['surfaceUpdate', 'beginRendering', 'dataModelUpdate', 'deleteSurface'] as const; +const ENVELOPE_KEYS = ['createSurface', 'updateComponents', 'updateDataModel', 'deleteSurface'] as const; /** * The parent LLM may emit envelope-tool arguments in four shapes (observed in @@ -35,7 +35,7 @@ export function normalizeEnvelopeArgs( .sort((a, b) => a - b) .map((k) => (args as Record)[String(k)]); } - // (d) flat single envelope: { surfaceUpdate: {...} } | { beginRendering: ... } | etc + // (d) flat single envelope: { createSurface: {...} } | { updateComponents: ... } | etc if (ENVELOPE_KEYS.some((k) => k in args)) { return [args]; } diff --git a/libs/chat/src/lib/a2ui/partial-args-bridge.spec.ts b/libs/chat/src/lib/a2ui/partial-args-bridge.spec.ts index 508ed63b6..e6b3533f1 100644 --- a/libs/chat/src/lib/a2ui/partial-args-bridge.spec.ts +++ b/libs/chat/src/lib/a2ui/partial-args-bridge.spec.ts @@ -15,120 +15,115 @@ function makeStore(): A2uiSurfaceStore { return store; } +const CS_S = '{"version":"v0.9","createSurface":{"surfaceId":"s","catalogId":"basic"}}'; +const UC_ROOT = '{"version":"v0.9","updateComponents":{"surfaceId":"s","components":[{"id":"root","component":"Text","text":"hi"}]}}'; + describe('createPartialArgsBridge', () => { let store: A2uiSurfaceStore; beforeEach(() => { store = makeStore(); }); - function chunks(...frames: string[]): readonly string[] { - return frames; - } - - it('extracts a surfaceUpdate envelope as soon as it parses, mounts surface via synthetic beginRendering', () => { + it('mounts a surface once createSurface + root component are parsed', () => { const bridge = createPartialArgsBridge(store); - const frames = chunks( - '{"envelopes":[', - '{"envelopes":[{"surfaceUpdate":{"surfaceId":"s","components":[{"id":"root","type":"text","props":{}}]}}', - '{"envelopes":[{"surfaceUpdate":{"surfaceId":"s","components":[{"id":"root","type":"text","props":{}}]}},', - ); - for (const f of frames) bridge.push('tc-1', f); - // After surfaceUpdate parses and bridge synthesises beginRendering, the surface materialises. + bridge.push('tc-1', '{"envelopes":[' + CS_S + ','); + expect(store.surfaces().has('s')).toBe(false); + bridge.push('tc-1', '{"envelopes":[' + CS_S + ',' + UC_ROOT + ','); expect(store.surfaces().get('s')?.components.has('root')).toBe(true); }); - it('does not synthesise twice if the LLM emits its own beginRendering later', () => { + it('synthesises a createSurface when the stream leads with updateComponents', () => { + const bridge = createPartialArgsBridge(store); + bridge.push('tc-2', '{"envelopes":[' + UC_ROOT + ']}'); + const surface = store.surfaces().get('s'); + expect(surface).toBeTruthy(); + expect(surface!.components.has('root')).toBe(true); + expect(surface!.catalogId).toContain('catalogs/basic'); + }); + + it('does not double-create when the LLM emits its own createSurface later', () => { const bridge = createPartialArgsBridge(store); - const surfaceUpdate = JSON.stringify({ surfaceUpdate: { surfaceId: 's', components: [{ id: 'root', type: 'text', props: {} }] } }); - const beginRendering = JSON.stringify({ beginRendering: { surfaceId: 's', root: 'root' } }); - bridge.push('tc-2', '{"envelopes":[' + surfaceUpdate + ',' + beginRendering + ']}'); - // Same surface, single mount — components map unchanged across the second beginRendering. + bridge.push('tc-3', '{"envelopes":[' + UC_ROOT + ',' + CS_S + ']}'); const surface = store.surfaces().get('s'); expect(surface).toBeTruthy(); expect(surface!.components.size).toBe(1); + // The real createSurface refreshed catalogId (idempotent refresh). + expect(surface!.catalogId).toBe('basic'); }); it('handles the singular {envelope:[...]} shape', () => { const bridge = createPartialArgsBridge(store); - bridge.push('tc-3', '{"envelope":[{"surfaceUpdate":{"surfaceId":"s","components":[{"id":"root","type":"text","props":{}}]}}]}'); + bridge.push('tc-4', '{"envelope":[' + UC_ROOT + ']}'); expect(store.surfaces().get('s')?.components.has('root')).toBe(true); }); it('handles positional keys {0: env, 1: env}', () => { const bridge = createPartialArgsBridge(store); const envs = [ - { surfaceUpdate: { surfaceId: 's', components: [{ id: 'root', type: 'text', props: {} }] } }, - { dataModelUpdate: { surfaceId: 's', contents: [{ key: 'msg', valueString: 'hi' }] } }, + JSON.parse(UC_ROOT), + { version: 'v0.9', updateDataModel: { surfaceId: 's', path: '/msg', value: 'hi' } }, ]; - bridge.push('tc-4', JSON.stringify({ 0: envs[0], 1: envs[1] })); + bridge.push('tc-5', JSON.stringify({ 0: envs[0], 1: envs[1] })); expect(store.surfaces().get('s')?.dataModel).toEqual({ msg: 'hi' }); }); - it('marks tool_call_id as live in the store once the initial surface pair dispatches', () => { + it('marks tool_call_id as live in the store once envelopes dispatch', () => { const bridge = createPartialArgsBridge(store); - bridge.push( - 'tc-5', - '{"envelopes":[{"surfaceUpdate":{"surfaceId":"s","components":[{"id":"root","type":"text","props":{}}]}}]}', - ); - expect(store.isPartialLive('tc-5')).toBe(true); + bridge.push('tc-6', '{"envelopes":[' + UC_ROOT + ']}'); + expect(store.isPartialLive('tc-6')).toBe(true); }); it('does not dispatch the same envelope twice across incremental pushes', () => { const bridge = createPartialArgsBridge(store); - const piece1 = '{"envelopes":[{"surfaceUpdate":{"surfaceId":"s","components":[{"id":"root","type":"text","props":{}}]}}'; - const piece2 = piece1 + ',{"dataModelUpdate":{"surfaceId":"s","contents":[{"key":"k","valueString":"v"}]}}]}'; - bridge.push('tc-6', piece1); - bridge.push('tc-6', piece2); - // The dataModelUpdate appears only in the second push but bridge re-runs the parser - // against the cumulative buffer; the surfaceUpdate envelope must NOT re-dispatch. + const piece1 = '{"envelopes":[' + UC_ROOT; + const piece2 = piece1 + ',{"version":"v0.9","updateDataModel":{"surfaceId":"s","path":"/k","value":"v"}}]}'; + bridge.push('tc-7', piece1); + bridge.push('tc-7', piece2); + // The updateDataModel appears only in the second push but bridge re-runs the parser + // against the cumulative buffer; the updateComponents envelope must NOT re-dispatch. expect(store.surfaces().get('s')?.dataModel).toEqual({ k: 'v' }); }); it('marks tool_call_id as poisoned if a chunk is invalid JSON garbage', () => { const bridge = createPartialArgsBridge(store); - bridge.push('tc-7', '{{{not_json'); + bridge.push('tc-8', '{{{not_json'); // Subsequent valid pushes are ignored once poisoned. - bridge.push('tc-7', '{"envelopes":[{"surfaceUpdate":{"surfaceId":"s","components":[]}}]}'); + bridge.push('tc-8', '{"envelopes":[' + UC_ROOT + ']}'); expect(store.surfaces().size).toBe(0); }); - it('synthetic beginRendering picks first component when none has id="root"', () => { + it('keeps the surface unmounted until a root component is defined (v0.9 rule)', () => { const bridge = createPartialArgsBridge(store); - bridge.push('tc-8', '{"envelopes":[{"surfaceUpdate":{"surfaceId":"s","components":[{"id":"only","type":"text","props":{}}]}}]}'); + bridge.push('tc-9', '{"envelopes":[{"version":"v0.9","updateComponents":{"surfaceId":"s","components":[{"id":"only","component":"Text","text":"x"}]}}]}'); + expect(store.surfaces().has('s')).toBe(false); + bridge.push('tc-9', '{"envelopes":[{"version":"v0.9","updateComponents":{"surfaceId":"s","components":[{"id":"only","component":"Text","text":"x"}]}},{"version":"v0.9","updateComponents":{"surfaceId":"s","components":[{"id":"root","component":"Card","child":"only"}]}}]}'); + expect(store.surfaces().get('s')?.components.has('root')).toBe(true); expect(store.surfaces().get('s')?.components.has('only')).toBe(true); }); - it('incremental push waits for a component id before mounting the surface', () => { + it('incremental push waits for a complete updateComponents before dispatching', () => { const bridge = createPartialArgsBridge(store); - // 1: object started, no id on first component yet. - bridge.push('tc-9', '{"envelopes":[{"surfaceUpdate":{"surfaceId":"s","components":[{'); + // 1: object started, components array not yet an array of complete objects. + bridge.push('tc-10', '{"envelopes":[{"version":"v0.9","updateComponents":{"surfaceId":"s","components":[{'); expect(store.surfaces().has('s')).toBe(false); // 2: started the "id" key but no value yet. - bridge.push('tc-9', '{"envelopes":[{"surfaceUpdate":{"surfaceId":"s","components":[{"'); + bridge.push('tc-10', '{"envelopes":[{"version":"v0.9","updateComponents":{"surfaceId":"s","components":[{"'); expect(store.surfaces().has('s')).toBe(false); - // 3: id, type, props all present and component object is closed. - bridge.push( - 'tc-9', - '{"envelopes":[{"surfaceUpdate":{"surfaceId":"s","components":[{"id":"root","type":"text","props":{}}]}}', - ); + // 3: complete envelope closed. + bridge.push('tc-10', '{"envelopes":[' + UC_ROOT + ','); expect(store.surfaces().get('s')?.components.has('root')).toBe(true); }); - it('mounts the surface on first complete push and applies a dataModelUpdate on a later push', () => { + it('mounts the surface on first complete push and applies an updateDataModel on a later push', () => { const bridge = createPartialArgsBridge(store); - const surfaceUpdate = JSON.stringify({ - surfaceUpdate: { surfaceId: 's', components: [{ id: 'root', type: 'text', props: {} }] }, - }); - const dataModelUpdate = JSON.stringify({ - dataModelUpdate: { surfaceId: 's', contents: [{ key: 'greeting', valueString: 'hello' }] }, - }); - bridge.push('tc-10', '{"envelopes":[' + surfaceUpdate + ']}'); + const dm = '{"version":"v0.9","updateDataModel":{"surfaceId":"s","path":"/greeting","value":"hello"}}'; + bridge.push('tc-11', '{"envelopes":[' + UC_ROOT + ']}'); expect(store.surfaces().get('s')?.components.has('root')).toBe(true); expect(store.surfaces().get('s')?.dataModel).toEqual({}); - bridge.push('tc-10', '{"envelopes":[' + surfaceUpdate + ',' + dataModelUpdate + ']}'); + bridge.push('tc-11', '{"envelopes":[' + UC_ROOT + ',' + dm + ']}'); expect(store.surfaces().get('s')?.dataModel).toEqual({ greeting: 'hello' }); }); - it('synthetic beginRendering targets the first component when multiple have ids and none is "root"', () => { - // Spy on applyPartialArgs to inspect the synthesised beginRendering envelope. + it('synthesised createSurface targets the basic catalog and precedes the components', () => { + // Spy on applyPartialArgs to inspect the synthesised envelope order. const captured: A2uiMessage[][] = []; const orig = store.applyPartialArgs.bind(store); (store as { applyPartialArgs: typeof store.applyPartialArgs }).applyPartialArgs = ( @@ -139,22 +134,13 @@ describe('createPartialArgsBridge', () => { orig(toolCallId, envs); }; const bridge = createPartialArgsBridge(store); - bridge.push( - 'tc-11', - '{"envelopes":[{"surfaceUpdate":{"surfaceId":"s","components":[{"id":"alpha","type":"text","props":{}},{"id":"beta","type":"text","props":{}}]}}]}', - ); - const surface = store.surfaces().get('s'); - expect(surface).toBeTruthy(); - expect(surface!.components.has('alpha')).toBe(true); - expect(surface!.components.has('beta')).toBe(true); - // First dispatch should be [surfaceUpdate, synthesised beginRendering with root="alpha"]. + bridge.push('tc-12', '{"envelopes":[' + UC_ROOT + ']}'); expect(captured.length).toBeGreaterThan(0); const firstBatch = captured[0]; - const beginEnv = firstBatch.find((e) => 'beginRendering' in e) as - | { beginRendering: { surfaceId: string; root: string } } - | undefined; - expect(beginEnv).toBeTruthy(); - expect(beginEnv!.beginRendering.root).toBe('alpha'); + expect('createSurface' in firstBatch[0]).toBe(true); + expect('updateComponents' in firstBatch[1]).toBe(true); + const cs = (firstBatch[0] as { createSurface: { catalogId: string } }).createSurface; + expect(cs.catalogId).toContain('catalogs/basic/catalog.json'); }); }); @@ -166,8 +152,7 @@ interface BridgeRow { assert: (store: A2uiSurfaceStore, bridge: ReturnType) => void; } -const SURFACE_S_FULL = - '{"envelopes":[{"surfaceUpdate":{"surfaceId":"s","components":[{"id":"root","type":"text","props":{}}]}}]}'; +const SURFACE_S_FULL = '{"envelopes":[' + CS_S + ',' + UC_ROOT + ']}'; const bridgeRows: BridgeRow[] = [ { @@ -210,8 +195,8 @@ const bridgeRows: BridgeRow[] = [ { name: 'two tool_call_ids mount independent surfaces', pushes: [ - ['tc-7a', '{"envelopes":[{"surfaceUpdate":{"surfaceId":"a","components":[{"id":"root","type":"text","props":{}}]}}]}'], - ['tc-7b', '{"envelopes":[{"surfaceUpdate":{"surfaceId":"b","components":[{"id":"root","type":"text","props":{}}]}}]}'], + ['tc-7a', '{"envelopes":[{"version":"v0.9","updateComponents":{"surfaceId":"a","components":[{"id":"root","component":"Text","text":"A"}]}}]}'], + ['tc-7b', '{"envelopes":[{"version":"v0.9","updateComponents":{"surfaceId":"b","components":[{"id":"root","component":"Text","text":"B"}]}}]}'], ], assert: (store) => { expect(store.surfaces().get('a')?.components.has('root')).toBe(true); diff --git a/libs/chat/src/lib/a2ui/partial-args-bridge.ts b/libs/chat/src/lib/a2ui/partial-args-bridge.ts index fdef8d913..cd3fcec0b 100644 --- a/libs/chat/src/lib/a2ui/partial-args-bridge.ts +++ b/libs/chat/src/lib/a2ui/partial-args-bridge.ts @@ -1,7 +1,7 @@ // libs/chat/src/lib/a2ui/partial-args-bridge.ts // SPDX-License-Identifier: MIT import { createPartialJsonParser, materialize } from '@cacheplane/partial-json'; -import type { A2uiMessage, A2uiSurfaceUpdate } from '@threadplane/a2ui'; +import { A2UI_BASIC_CATALOG_ID, A2UI_WIRE_VERSION, type A2uiMessage } from '@threadplane/a2ui'; import type { A2uiSurfaceStore } from './surface-store'; import { normalizeEnvelopeArgs } from './envelope-normalizer'; @@ -20,15 +20,10 @@ interface BridgeState { parser: ReturnType; /** Number of envelopes already dispatched to the store. */ dispatchedCount: number; - /** - * Have we dispatched the initial surfaceUpdate + synthesised beginRendering - * pair for this turn yet? Until true, dispatch is deferred — we wait for - * the first surfaceUpdate to have at least one component with an `id` so - * `pickRoot` can target a real root and the surface actually mounts. - */ - surfacePairDispatched: boolean; - /** surfaceId the synthesised beginRendering targets (to avoid double-mounting). */ - synthesisedSurfaceId: string | null; + /** Surface ids for which a createSurface (real or synthesised) has been + * dispatched this turn — used to synthesise the missing createSurface + * exactly once per surface. */ + createDispatched: Set; /** Once true, all subsequent pushes are ignored. */ poisoned: boolean; } @@ -167,17 +162,15 @@ function isValidJsonPrefix(s: string): boolean { * tool_call.arguments JSON. Uses @cacheplane/partial-json to extract * structurally-complete envelope objects from the growing args string. * - * Synthesis safety net: if the first complete surfaceUpdate arrives and - * no beginRendering has been extracted yet, the bridge synthesises one - * targeted at the surfaceUpdate's first component (preferring id='root' - * if present). This makes the surface mount IMMEDIATELY after the first - * surfaceUpdate parses — without waiting for the LLM to emit beginRendering - * at the end of its envelope list — so the render-element fallback gate - * (PR #252) actually fires while dataModelUpdates flow in. + * Synthesis safety net: v0.9 requires a `createSurface` envelope before + * any `updateComponents`. If a complete `updateComponents` arrives for a + * surface with no `createSurface` seen yet this turn, the bridge + * synthesises one (basic catalog) so the surface can mount as soon as its + * `root` component is defined — the store gates rendering on + * createSurface + root, and fills the tree in progressively after that. * - * The store's apply() already treats repeated beginRendering for the same - * surfaceId as idempotent (just re-applies styles), so the LLM's eventual - * beginRendering (if any) is a no-op rather than a conflict. + * The store treats a later "real" createSurface for the same surface as an + * idempotent refresh, so LLMs that emit one out of order are harmless. */ export function createPartialArgsBridge(store: A2uiSurfaceStore): PartialArgsBridge { const states = new Map(); @@ -188,8 +181,7 @@ export function createPartialArgsBridge(store: A2uiSurfaceStore): PartialArgsBri s = { parser: createPartialJsonParser(), dispatchedCount: 0, - surfacePairDispatched: false, - synthesisedSurfaceId: null, + createDispatched: new Set(), poisoned: false, }; states.set(toolCallId, s); @@ -197,12 +189,6 @@ export function createPartialArgsBridge(store: A2uiSurfaceStore): PartialArgsBri return s; } - function pickRoot(components: readonly { id: string }[]): string | null { - if (components.length === 0) return null; - const explicitRoot = components.find((c) => c.id === 'root'); - return explicitRoot ? explicitRoot.id : components[0].id; - } - function push(toolCallId: string, argsSoFar: string): void { const state = stateOf(toolCallId); if (state.poisoned) return; @@ -228,29 +214,8 @@ export function createPartialArgsBridge(store: A2uiSurfaceStore): PartialArgsBri const envelopes = normalizeEnvelopeArgs(materialised); if (!envelopes) return; - // Phase 1: defer initial dispatch until the first envelope is a complete - // surfaceUpdate whose components include at least one entry with an `id` - // — otherwise pickRoot returns null and synthesis silently no-ops, leaving - // the surface unmounted forever. Once we have a pickable root, dispatch - // the surfaceUpdate AND a synthesised beginRendering as an atomic pair. - if (!state.surfacePairDispatched) { - const firstEnv = envelopes[0] as A2uiMessage | undefined; - if (!firstEnv || !('surfaceUpdate' in firstEnv)) return; - if (!isStructurallyComplete(firstEnv)) return; - const upd = (firstEnv as { surfaceUpdate: A2uiSurfaceUpdate }).surfaceUpdate; - if (upd.components.length === 0) return; - const root = pickRoot(upd.components); - if (!root) return; - state.surfacePairDispatched = true; - state.dispatchedCount = 1; // index 0 = the surfaceUpdate we just sent - state.synthesisedSurfaceId = upd.surfaceId; - store.applyPartialArgs(toolCallId, [ - firstEnv, - { beginRendering: { surfaceId: upd.surfaceId, root } }, - ]); - } - - // Phase 2: dispatch any newly-complete envelopes beyond the initial pair. + // Dispatch newly-complete envelopes in order, synthesising the missing + // createSurface when the stream leads with components. const newEnvelopes: A2uiMessage[] = []; for (let i = state.dispatchedCount; i < envelopes.length; i++) { const env = envelopes[i] as A2uiMessage; @@ -259,16 +224,17 @@ export function createPartialArgsBridge(store: A2uiSurfaceStore): PartialArgsBri // exist before earlier ones complete (envelopes are an ordered list). break; } - // Skip a "real" beginRendering for the synthesised surface — the store - // already treats it as idempotent (just re-applies styles), but we - // advance past it so dispatchedCount stays in sync with the index. - if ( - 'beginRendering' in env && - (env as { beginRendering: { surfaceId?: string } }).beginRendering.surfaceId === - state.synthesisedSurfaceId - ) { - state.dispatchedCount = i + 1; - continue; + if ('createSurface' in env) { + state.createDispatched.add(env.createSurface.surfaceId); + } else if ('updateComponents' in env) { + const surfaceId = env.updateComponents.surfaceId; + if (!state.createDispatched.has(surfaceId)) { + state.createDispatched.add(surfaceId); + newEnvelopes.push({ + version: A2UI_WIRE_VERSION, + createSurface: { surfaceId, catalogId: A2UI_BASIC_CATALOG_ID }, + }); + } } newEnvelopes.push(env); state.dispatchedCount = i + 1; @@ -289,12 +255,28 @@ export function createPartialArgsBridge(store: A2uiSurfaceStore): PartialArgsBri function isStructurallyComplete(env: unknown): env is A2uiMessage { if (!env || typeof env !== 'object' || Array.isArray(env)) return false; const obj = env as Record; - for (const k of ['surfaceUpdate', 'beginRendering', 'dataModelUpdate', 'deleteSurface']) { + for (const k of ['createSurface', 'updateComponents', 'updateDataModel', 'deleteSurface']) { if (k in obj && typeof obj[k] === 'object' && obj[k] !== null) { - // For surfaceUpdate, also require non-undefined surfaceId + components. - if (k === 'surfaceUpdate') { - const su = obj[k] as { surfaceId?: unknown; components?: unknown }; - return typeof su.surfaceId === 'string' && Array.isArray(su.components); + // For updateComponents, require surfaceId + components where every + // component has at least parsed its `id` and `component` fields — + // a half-streamed component object materialises as `{}` and must not + // dispatch (it would consume the envelope index and drop the real + // components forever, since re-parses skip dispatched indices). + if (k === 'updateComponents') { + const uc = obj[k] as { surfaceId?: unknown; components?: unknown }; + return typeof uc.surfaceId === 'string' + && Array.isArray(uc.components) + && uc.components.length > 0 + && uc.components.every((c) => + c != null && typeof c === 'object' + && typeof (c as { id?: unknown }).id === 'string' + && typeof (c as { component?: unknown }).component === 'string'); + } + // For createSurface, require both ids so a half-streamed envelope + // doesn't commit with an undefined catalogId. + if (k === 'createSurface') { + const cs = obj[k] as { surfaceId?: unknown; catalogId?: unknown }; + return typeof cs.surfaceId === 'string' && typeof cs.catalogId === 'string'; } return true; } diff --git a/libs/chat/src/lib/a2ui/surface.component.spec.ts b/libs/chat/src/lib/a2ui/surface.component.spec.ts index 87e511e88..5744971bb 100644 --- a/libs/chat/src/lib/a2ui/surface.component.spec.ts +++ b/libs/chat/src/lib/a2ui/surface.component.spec.ts @@ -52,28 +52,24 @@ describe('A2uiSurfaceComponent — empty surface', () => { describe('A2uiSurfaceComponent — nested children with real catalog (regression)', () => { beforeEach(() => TestBed.configureTestingModule({ imports: [A2uiSurfaceComponent] })); - it('renders Column children defined via children.explicitList', () => { - // Reproduces the contact-form bug: a Column with explicitList children + it('renders Column children defined via a children id list', () => { + // Reproduces the contact-form bug: a Column with listed children // must actually render those children. Prior to the fix, the slot path // pushed wrapped wire-format props onto the catalog component which // had no matching `Column` input — so childKeys stayed empty and the // Column rendered as an empty

. const store = createA2uiSurfaceStore(); - store.apply({ surfaceUpdate: { + 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: { explicitList: ['leaf'] }, - distribution: 'start', - alignment: 'stretch', - } } }, - { id: 'leaf', component: { Text: { - text: { literalString: 'Hello' }, - usageHint: 'h2', - } } }, + { id: 'root', component: 'Column', + children: ['leaf'], justify: 'start', align: 'stretch' }, + { id: 'leaf', component: 'Text', text: 'Hello', variant: 'h2' }, ], } } as never); - store.apply({ beginRendering: { surfaceId: 's1', root: 'root' } } as never); const state = store.surfaceState('s1')(); expect(state).toBeDefined(); diff --git a/libs/chat/src/lib/a2ui/surface.component.ts b/libs/chat/src/lib/a2ui/surface.component.ts index 176c955b9..56c232709 100644 --- a/libs/chat/src/lib/a2ui/surface.component.ts +++ b/libs/chat/src/lib/a2ui/surface.component.ts @@ -21,13 +21,11 @@ import type { A2uiViews } from './views'; NgComponentOutlet, ], changeDetection: ChangeDetectionStrategy.OnPush, - // The host applies the agent-set v1 styles (`beginRendering.styles`) - // as inline CSS custom properties + font-family. Catalog components - // consume `--a2ui-primary` for accents (buttons, sliders, focus, - // etc.); `font-family` cascades naturally from the host. + // The host applies the agent-set surface theme (`createSurface.theme`) + // as inline CSS custom properties. Catalog components consume + // `--a2ui-primary` for accents (buttons, sliders, focus, etc.). host: { '[style.--a2ui-primary]': 'primaryColor()', - '[style.font-family]': 'fontFamily()', }, template: ` @if (spec(); as s) { @@ -71,28 +69,20 @@ export class A2uiSurfaceComponent { readonly events = output(); readonly action = output(); - /** Agent-set primary color from `beginRendering.styles.primaryColor`. + /** Agent-set primary color from `createSurface.theme.primaryColor`. * Returns null when unset so the host binding doesn't override the * consumer's `:root`-level `--a2ui-primary` default. */ readonly primaryColor = computed(() => - (this.state()?.surface ?? this.surface())?.styles?.primaryColor ?? null + (this.state()?.surface ?? this.surface())?.theme?.primaryColor ?? null ); - /** Agent-set font family from `beginRendering.styles.font`. Returns - * null when unset so the host doesn't override consumer fonts. */ - readonly fontFamily = computed(() => - (this.state()?.surface ?? this.surface())?.styles?.font ?? null - ); - - /** Roots from the surface state — components whose ids appear as - * children of no other component. The wire spec includes - * `beginRendering.root` as the single root; that path stays usable - * but we keep the renderer permissive in case future surfaces emit - * multiple top-level components. + /** Roots from the surface state. The v0.9 wire contract reserves the + * component id `root` as the single tree root; we keep the renderer + * permissive in case future surfaces emit multiple top-level + * components. * * Conservative: returns only the first key from componentViews - * insertion order. The wire format's beginRendering.root carries the - * true root id; plumbing it through A2uiSurfaceState is a follow-up. */ + * insertion order. */ readonly rootIds = computed(() => { const st = this.state(); if (!st) return []; @@ -102,7 +92,7 @@ export class A2uiSurfaceComponent { /** Convert the A2UI surface to a json-render Spec for rendering. * Prefers `state().surface` (the progressively-built wire surface) * over the legacy `surface` input. surfaceToSpec handles - * children.explicitList → spec.children translation + reserved-key + * children-id-list → spec.children translation + reserved-key * filtering + path-ref → $bindState rewriting; the rendered tree * then uses render-element's standard input-mapping * (`childKeys: el.children`) so catalog components receive the diff --git a/libs/chat/src/lib/compositions/chat/chat.component.ts b/libs/chat/src/lib/compositions/chat/chat.component.ts index 9cef42afc..d430057ff 100644 --- a/libs/chat/src/lib/compositions/chat/chat.component.ts +++ b/libs/chat/src/lib/compositions/chat/chat.component.ts @@ -902,10 +902,10 @@ export class ChatComponent { // A2UI/json-render markers in the content string. const projectedContent = (m as { content?: unknown }).content; if (typeof projectedContent === 'string' && projectedContent.length > 0) { - // A2UI v1 envelope keys (canonical Google shape). - if (projectedContent.includes('"surfaceUpdate"') - || projectedContent.includes('"beginRendering"') - || projectedContent.includes('"dataModelUpdate"')) { + // A2UI v0.9 envelope keys (canonical Google shape). + if (projectedContent.includes('"createSurface"') + || projectedContent.includes('"updateComponents"') + || projectedContent.includes('"updateDataModel"')) { return true; } // json-render spec shape — looks like `{ "root": "...", "elements": ... }`. diff --git a/libs/chat/src/public-api.ts b/libs/chat/src/public-api.ts index 790a78f98..1aaf7eb91 100644 --- a/libs/chat/src/public-api.ts +++ b/libs/chat/src/public-api.ts @@ -214,7 +214,7 @@ export { emitBinding } from './lib/a2ui/catalog/emit-binding'; export { A2uiTextFieldComponent } from './lib/a2ui/catalog/text-field.component'; export { A2uiCheckBoxComponent } from './lib/a2ui/catalog/check-box.component'; export { A2uiButtonComponent } from './lib/a2ui/catalog/button.component'; -export { A2uiMultipleChoiceComponent } from './lib/a2ui/catalog/multiple-choice.component'; +export { A2uiChoicePickerComponent } from './lib/a2ui/catalog/choice-picker.component'; export { A2uiSliderComponent } from './lib/a2ui/catalog/slider.component'; export { A2uiDateTimeInputComponent } from './lib/a2ui/catalog/date-time-input.component'; export { A2uiTextComponent } from './lib/a2ui/catalog/text.component'; @@ -232,13 +232,16 @@ export { A2uiVideoComponent } from './lib/a2ui/catalog/video.component'; // A2UI types (re-exported from @threadplane/a2ui for convenience) export type { - A2uiActionMessage, A2uiClientDataModel, - A2uiSurface, A2uiComponent, A2uiTheme, - DynamicString, DynamicNumber, DynamicBoolean, - A2uiChildren, A2uiAction, A2uiActionContextEntry, - A2uiComponentDef, + A2uiActionMessage, A2uiErrorMessage, A2uiClientDataModel, A2uiClientCapabilities, + A2uiSurface, A2uiComponent, A2uiComponentBase, A2uiCatalogComponent, A2uiTheme, + DynamicString, DynamicNumber, DynamicBoolean, DynamicStringList, DynamicValue, + A2uiChildren, A2uiAction, A2uiEventAction, A2uiFunctionAction, A2uiCheck, + A2uiPathRef, A2uiFunctionCall, +} from '@threadplane/a2ui'; +export { + isPathRef, isFunctionCall, + A2UI_WIRE_VERSION, A2UI_MIME_TYPE, A2UI_BASIC_CATALOG_ID, } from '@threadplane/a2ui'; -export { isPathRef, isLiteralString, isLiteralNumber, isLiteralBoolean } from '@threadplane/a2ui'; // Client tools (declaration API — tools/action/view/ask + JSON-schema derivation) export { tools, action, view, ask } from './lib/client-tools/tools'; From 23d81a011ce436546cfaed20c7c9a40d2cd533f6 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Mon, 17 Aug 2026 08:13:28 -0700 Subject: [PATCH 08/15] =?UTF-8?q?feat(chat)!:=20v0.9=20catalog=20=E2=80=94?= =?UTF-8?q?=20flat=20props,=20ChoicePicker,=20spec-conformant=20enums?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- .../a2ui/catalog/audio-player.component.ts | 8 +- .../lib/a2ui/catalog/button.component.spec.ts | 29 ++- .../src/lib/a2ui/catalog/button.component.ts | 29 ++- .../a2ui/catalog/check-box.component.spec.ts | 17 +- .../lib/a2ui/catalog/check-box.component.ts | 21 +- .../catalog/choice-picker.component.spec.ts | 117 ++++++++++ .../a2ui/catalog/choice-picker.component.ts | 208 ++++++++++++++++++ .../src/lib/a2ui/catalog/column.component.ts | 51 ++++- .../catalog/date-time-input.component.spec.ts | 16 +- .../a2ui/catalog/date-time-input.component.ts | 12 +- .../src/lib/a2ui/catalog/divider.component.ts | 11 +- .../lib/a2ui/catalog/icon.component.spec.ts | 26 ++- .../src/lib/a2ui/catalog/icon.component.ts | 38 +++- .../lib/a2ui/catalog/image.component.spec.ts | 35 ++- .../src/lib/a2ui/catalog/image.component.ts | 83 +++---- libs/chat/src/lib/a2ui/catalog/index.ts | 4 +- .../src/lib/a2ui/catalog/list.component.ts | 9 +- .../lib/a2ui/catalog/modal.component.spec.ts | 9 +- .../src/lib/a2ui/catalog/modal.component.ts | 14 +- .../catalog/multiple-choice.component.spec.ts | 92 -------- .../a2ui/catalog/multiple-choice.component.ts | 137 ------------ .../src/lib/a2ui/catalog/row.component.ts | 47 ++-- .../lib/a2ui/catalog/slider.component.spec.ts | 6 +- .../src/lib/a2ui/catalog/slider.component.ts | 16 +- .../src/lib/a2ui/catalog/tabs.component.ts | 4 +- .../a2ui/catalog/text-field.component.spec.ts | 31 ++- .../lib/a2ui/catalog/text-field.component.ts | 31 +-- .../lib/a2ui/catalog/text.component.spec.ts | 20 +- .../src/lib/a2ui/catalog/text.component.ts | 7 +- .../src/lib/a2ui/catalog/video.component.ts | 6 +- .../compositions/chat/chat.component.spec.ts | 4 +- .../chat-generative-ui.component.spec.ts | 8 +- .../lib/streaming/content-classifier.spec.ts | 25 +-- 33 files changed, 710 insertions(+), 461 deletions(-) create mode 100644 libs/chat/src/lib/a2ui/catalog/choice-picker.component.spec.ts create mode 100644 libs/chat/src/lib/a2ui/catalog/choice-picker.component.ts delete mode 100644 libs/chat/src/lib/a2ui/catalog/multiple-choice.component.spec.ts delete mode 100644 libs/chat/src/lib/a2ui/catalog/multiple-choice.component.ts diff --git a/libs/chat/src/lib/a2ui/catalog/audio-player.component.ts b/libs/chat/src/lib/a2ui/catalog/audio-player.component.ts index 4b4647d7e..152109699 100644 --- a/libs/chat/src/lib/a2ui/catalog/audio-player.component.ts +++ b/libs/chat/src/lib/a2ui/catalog/audio-player.component.ts @@ -13,8 +13,7 @@ import type { Spec } from '@json-render/core';
`, @@ -36,11 +35,8 @@ import type { Spec } from '@json-render/core'; }) export class A2uiAudioPlayerComponent { readonly url = input(''); - /** v1 canonical prop: short description / title rendered above the player. */ + /** v0.9 prop: short description / title rendered above the player. */ readonly description = input(''); - /** v1 prop name: autoPlay (camelCase). */ - readonly autoPlay = input(false); - readonly controls = input(true); // Framework inputs required by the render harness. readonly bindings = input>({}); readonly emit = input<(event: string) => void>(() => { /* noop */ }); diff --git a/libs/chat/src/lib/a2ui/catalog/button.component.spec.ts b/libs/chat/src/lib/a2ui/catalog/button.component.spec.ts index d70cfa5f7..667ac1112 100644 --- a/libs/chat/src/lib/a2ui/catalog/button.component.spec.ts +++ b/libs/chat/src/lib/a2ui/catalog/button.component.spec.ts @@ -2,15 +2,38 @@ import { describe, it, expect } from 'vitest'; import { A2uiButtonComponent } from './button.component'; -describe('A2uiButtonComponent', () => { +describe('A2uiButtonComponent — v0.9 protocol', () => { // NOTE: Angular signal-based inputs can't be tested via TestBed without the - // angular() vite plugin (NG0303). v1: label is dropped; a child Text component - // is rendered inside the button via childKeys. The primary boolean controls styling. + // angular() vite plugin (NG0303). v0.9: a child Text component is rendered + // inside the button via childKeys. The `variant` enum controls styling: + // 'default' | 'primary' | 'borderless' (default 'default'). it('exports the component class', () => { expect(A2uiButtonComponent).toBeDefined(); }); + describe('variant → class logic', () => { + const VARIANT_CLASS: Record = { + default: 'a2ui-btn a2ui-btn--default', + primary: 'a2ui-btn a2ui-btn--primary', + borderless: 'a2ui-btn a2ui-btn--borderless', + }; + const cssClass = (variant: string) => VARIANT_CLASS[variant] ?? VARIANT_CLASS['default']; + + it('maps primary to the primary class', () => { + expect(cssClass('primary')).toBe('a2ui-btn a2ui-btn--primary'); + }); + it('maps default to the default class', () => { + expect(cssClass('default')).toBe('a2ui-btn a2ui-btn--default'); + }); + it('maps borderless to the borderless class', () => { + expect(cssClass('borderless')).toBe('a2ui-btn a2ui-btn--borderless'); + }); + it('falls back to default for unknown variants', () => { + expect(cssClass('bogus')).toBe('a2ui-btn a2ui-btn--default'); + }); + }); + it('has handleClick method', () => { expect(A2uiButtonComponent.prototype.handleClick).toBeInstanceOf(Function); }); diff --git a/libs/chat/src/lib/a2ui/catalog/button.component.ts b/libs/chat/src/lib/a2ui/catalog/button.component.ts index b9cae6484..69958bc1d 100644 --- a/libs/chat/src/lib/a2ui/catalog/button.component.ts +++ b/libs/chat/src/lib/a2ui/catalog/button.component.ts @@ -3,6 +3,14 @@ import { Component, input, ChangeDetectionStrategy } from '@angular/core'; import type { Spec } from '@json-render/core'; import { RenderElementComponent } from '@threadplane/render'; +type ButtonVariant = 'default' | 'primary' | 'borderless'; + +const VARIANT_CLASS: Record = { + default: 'a2ui-btn a2ui-btn--default', + primary: 'a2ui-btn a2ui-btn--primary', + borderless: 'a2ui-btn a2ui-btn--borderless', +}; + @Component({ selector: 'a2ui-button', standalone: true, @@ -10,7 +18,7 @@ import { RenderElementComponent } from '@threadplane/render'; changeDetection: ChangeDetectionStrategy.OnPush, template: ` + } + + } @else { + +
+ @for (opt of visibleOptions(); track opt.value) { + + } +
+ } + + `, + styles: [` + .a2ui-cp { display: flex; flex-direction: column; gap: var(--a2ui-spacing-1); } + .a2ui-cp__label { + font-size: var(--a2ui-typography-label-size); + font-weight: var(--a2ui-typography-label-weight); + color: var(--a2ui-label); + } + .a2ui-cp__filter { + padding: var(--a2ui-spacing-1) var(--a2ui-spacing-2); + font-size: var(--a2ui-typography-caption-size); + border-radius: var(--a2ui-shape-small); + background: var(--a2ui-input-bg); + color: var(--a2ui-on-surface); + border: 1px solid var(--a2ui-outline); + outline: none; + transition: border-color var(--a2ui-motion-duration-short) var(--a2ui-motion-easing-standard); + } + .a2ui-cp__filter:focus { + outline: var(--a2ui-focus-ring-width) solid var(--a2ui-focus-ring-color); + outline-offset: 2px; + border-color: var(--a2ui-primary); + } + .a2ui-cp__checks { display: flex; flex-direction: column; gap: var(--a2ui-spacing-2); } + .a2ui-cp__check-row { + display: flex; + align-items: center; + gap: var(--a2ui-spacing-2); + font-size: var(--a2ui-typography-body-size); + cursor: pointer; + } + .a2ui-cp__checkbox { + width: 16px; + height: 16px; + border-radius: var(--a2ui-shape-extra-small); + cursor: pointer; + accent-color: var(--a2ui-primary); + } + .a2ui-cp__chips { + display: flex; + flex-wrap: wrap; + gap: var(--a2ui-spacing-2); + } + .a2ui-cp__chip { + padding: var(--a2ui-spacing-1) var(--a2ui-spacing-3); + font-size: var(--a2ui-typography-body-size); + border-radius: var(--a2ui-shape-large, 9999px); + background: var(--a2ui-surface-variant); + color: var(--a2ui-on-surface); + border: 1px solid var(--a2ui-outline); + cursor: pointer; + transition: background var(--a2ui-motion-duration-short) var(--a2ui-motion-easing-standard), + border-color var(--a2ui-motion-duration-short) var(--a2ui-motion-easing-standard); + } + .a2ui-cp__chip--selected { + background: var(--a2ui-primary); + color: var(--a2ui-on-primary); + border-color: var(--a2ui-primary); + } + `], +}) +export class A2uiChoicePickerComponent { + private static _idCounter = 0; + /** Groups the radio inputs of this instance (mutuallyExclusive mode). */ + protected readonly _groupName = `a2ui-choice-picker-${++A2uiChoicePickerComponent._idCounter}`; + + private readonly host = injectRenderHost(); + + readonly label = input(''); + /** v0.9 prop: current selection (string[]). Normalized in `valueArray` + * because LLMs sometimes seed the data model with a scalar (e.g. `"5"`) + * instead of an array (`["5"]`); we coerce so .includes() works either way. */ + readonly value = input(undefined); + /** Resolved options with plain string labels (surface-to-spec resolves DynamicString). */ + readonly options = input([]); + /** v0.9 prop: 'mutuallyExclusive' (single-select, default) or 'multipleSelection'. */ + readonly variant = input<'mutuallyExclusive' | 'multipleSelection'>('mutuallyExclusive'); + /** v0.9 prop: render as 'checkbox' rows (default) or 'chips'. */ + readonly displayStyle = input<'checkbox' | 'chips'>('checkbox'); + /** v0.9 prop: when true, show a client-side option filter input. */ + readonly filterable = input(false); + readonly _bindings = input>({}); + // Framework inputs required by the render harness. + readonly bindings = input>({}); + readonly loading = input(false); + readonly childKeys = input([]); + readonly spec = input(undefined); + + protected readonly valueArray = computed(() => { + const v = this.value(); + if (Array.isArray(v)) return v; + if (v == null || v === '') return []; + return [String(v)]; + }); + + protected readonly isSingleSelect = computed(() => this.variant() !== 'multipleSelection'); + + /** Local, client-side option filter (only rendered when filterable). */ + protected readonly filterText = signal(''); + + protected readonly visibleOptions = computed(() => { + const f = this.filterText().trim().toLowerCase(); + const opts = this.options(); + return f ? opts.filter(o => o.label.toLowerCase().includes(f)) : opts; + }); + + protected isSelected(value: string): boolean { + return this.valueArray().includes(value); + } + + onFilterInput(event: Event): void { + this.filterText.set((event.target as HTMLInputElement).value); + } + + onCheckChange(value: string, event: Event): void { + const checked = (event.target as HTMLInputElement).checked; + if (this.isSingleSelect()) { + // Radio semantics: the chosen option replaces the selection. `value` is + // a string list on the wire, so write a one-element array. + if (checked) emitBinding(this.host, this._bindings(), 'value', [value]); + return; + } + emitBinding(this.host, this._bindings(), 'value', this.toggled(value, checked)); + } + + onChipToggle(value: string): void { + if (this.isSingleSelect()) { + emitBinding(this.host, this._bindings(), 'value', [value]); + return; + } + const checked = !this.isSelected(value); + emitBinding(this.host, this._bindings(), 'value', this.toggled(value, checked)); + } + + private toggled(value: string, checked: boolean): string[] { + const current = [...this.valueArray()]; + const idx = current.indexOf(value); + if (checked && idx === -1) { + current.push(value); + } else if (!checked && idx !== -1) { + current.splice(idx, 1); + } + // Pass the updated array directly (typed value, no JSON stringification needed). + return current; + } +} diff --git a/libs/chat/src/lib/a2ui/catalog/column.component.ts b/libs/chat/src/lib/a2ui/catalog/column.component.ts index 1f120ae7b..2718630d4 100644 --- a/libs/chat/src/lib/a2ui/catalog/column.component.ts +++ b/libs/chat/src/lib/a2ui/catalog/column.component.ts @@ -3,18 +3,32 @@ import { Component, computed, input } from '@angular/core'; import type { Spec } from '@json-render/core'; import { RenderElementComponent } from '@threadplane/render'; -type ColumnAlignment = 'start' | 'center' | 'end' | 'stretch'; +type ColumnAlign = 'start' | 'center' | 'end' | 'stretch'; +type ColumnJustify = 'start' | 'center' | 'end' | 'spaceAround' | 'spaceBetween' | 'spaceEvenly' | 'stretch'; -const ALIGN_MAP: Record = { +const ALIGN_MAP: Record = { start: 'flex-start', center: 'center', end: 'flex-end', stretch: 'stretch', }; +/** justify 'stretch' has no justify-content equivalent — children grow instead + * (see the --justify-stretch class below). */ +const JUSTIFY_MAP: Record = { + start: 'flex-start', center: 'center', end: 'flex-end', + spaceAround: 'space-around', spaceBetween: 'space-between', + spaceEvenly: 'space-evenly', stretch: 'normal', +}; + @Component({ selector: 'a2ui-column', standalone: true, imports: [RenderElementComponent], template: ` -
+
@for (key of childKeys(); track key) { } @@ -24,21 +38,40 @@ const ALIGN_MAP: Record = { .a2ui-col { display: flex; flex-direction: column; + gap: var(--a2ui-spacing-3); + } + .a2ui-col--justify-stretch > render-element { + flex: 1; } `], }) export class A2uiColumnComponent { readonly childKeys = input([]); readonly spec = input.required(); - readonly gap = input(3); - readonly alignment = input('start'); - readonly distribution = input<'start' | 'center' | 'end' | 'spaceBetween' | 'spaceAround' | 'spaceEvenly'>('start'); + /** v0.9 prop: cross-axis alignment (default 'stretch'). */ + readonly align = input('stretch'); + /** v0.9 prop: main-axis distribution (default 'start'). */ + readonly justify = input('start'); + /** Not part of the v0.9 catalog — kept for json-render generative-ui + * specs, which may set a numeric spacing unit (multiples of 4px) or a + * named size. Unset falls back to the CSS default gap. */ + readonly gap = input(undefined); // Framework inputs required by the render harness. readonly bindings = input>({}); readonly emit = input<(event: string) => void>(() => { /* noop */ }); readonly loading = input(false); - protected readonly alignItems = computed(() => ALIGN_MAP[this.alignment()] ?? 'flex-start'); - /** Convert the Tailwind gap unit (multiples of 4px) to pixels. */ - protected readonly gapPx = computed(() => this.gap() * 4); + protected readonly alignItems = computed(() => ALIGN_MAP[this.align()] ?? 'stretch'); + protected readonly justifyContent = computed(() => JUSTIFY_MAP[this.justify()] ?? 'flex-start'); + protected readonly cssClass = computed(() => + this.justify() === 'stretch' ? 'a2ui-col a2ui-col--justify-stretch' : 'a2ui-col', + ); + protected readonly gapPx = computed(() => { + const g = this.gap(); + if (typeof g === 'number' && Number.isFinite(g)) return g * 4; + if (g === 'small') return 8; + if (g === 'medium') return 12; + if (g === 'large') return 16; + return null; + }); } diff --git a/libs/chat/src/lib/a2ui/catalog/date-time-input.component.spec.ts b/libs/chat/src/lib/a2ui/catalog/date-time-input.component.spec.ts index 195c3a23d..eaeec9cfa 100644 --- a/libs/chat/src/lib/a2ui/catalog/date-time-input.component.spec.ts +++ b/libs/chat/src/lib/a2ui/catalog/date-time-input.component.spec.ts @@ -13,10 +13,11 @@ function makeHost(): { host: RenderHost; writes: Array<[string, unknown]> } { return { host, writes }; } -describe('A2uiDateTimeInputComponent — v1 protocol', () => { +describe('A2uiDateTimeInputComponent — v0.9 protocol', () => { // NOTE: Angular signal-based inputs can't be tested via TestBed without the - // angular() vite plugin (NG0303). v1: enableDate + enableTime booleans drive - // htmlInputType; validationResult was removed. + // angular() vite plugin (NG0303). v0.9: enableDate + enableTime booleans drive + // htmlInputType; optional `min`/`max` ISO strings map to the native input's + // min/max attributes (null when absent so the attribute is omitted). describe('htmlInputType logic', () => { const getType = (enableDate: boolean, enableTime: boolean): string => { @@ -42,6 +43,15 @@ describe('A2uiDateTimeInputComponent — v1 protocol', () => { }); }); + describe('min/max attribute logic', () => { + const attr = (v: string | undefined) => v || null; + + it('passes an ISO min through', () => expect(attr('2026-01-01')).toBe('2026-01-01')); + it('passes an ISO max through', () => expect(attr('2026-12-31')).toBe('2026-12-31')); + it('omits the attribute when unset', () => expect(attr(undefined)).toBeNull()); + it('omits the attribute when empty', () => expect(attr('')).toBeNull()); + }); + describe('onChange emit logic', () => { it('writes binding with date value', () => { const { host, writes } = makeHost(); 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 bc451d4a1..cd17e613b 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 @@ -17,6 +17,8 @@ import { emitBinding } from './emit-binding'; [id]="_inputId" [type]="htmlInputType()" [value]="value()" + [attr.min]="min() || null" + [attr.max]="max() || null" class="a2ui-dti__input" (change)="onChange($event)" /> @@ -53,12 +55,16 @@ export class A2uiDateTimeInputComponent { private readonly host = injectRenderHost(); readonly label = input(''); - /** v1 prop: value (resolved DynamicString). */ + /** v0.9 prop: ISO 8601 value (resolved DynamicString). Still renders when absent. */ readonly value = input(''); - /** v1 prop: enableDate — include date portion. */ + /** v0.9 prop: enableDate — include date portion. */ readonly enableDate = input(true); - /** v1 prop: enableTime — include time portion. */ + /** v0.9 prop: enableTime — include time portion. */ readonly enableTime = input(false); + /** v0.9 prop: ISO lower bound mapped to the native input's min. */ + readonly min = input(undefined); + /** v0.9 prop: ISO upper bound mapped to the native input's max. */ + readonly max = input(undefined); readonly _bindings = input>({}); // Framework inputs required by the render harness. readonly bindings = input>({}); diff --git a/libs/chat/src/lib/a2ui/catalog/divider.component.ts b/libs/chat/src/lib/a2ui/catalog/divider.component.ts index 60393b55b..03b0424a2 100644 --- a/libs/chat/src/lib/a2ui/catalog/divider.component.ts +++ b/libs/chat/src/lib/a2ui/catalog/divider.component.ts @@ -30,14 +30,9 @@ import type { Spec } from '@json-render/core'; `], }) export class A2uiDividerComponent { - /** Canonical v1 spec name. The LLM emits this. */ - readonly axis = input<'horizontal' | 'vertical' | undefined>(undefined); - /** Older alias retained for json-render usage and back-compat. */ - readonly direction = input<'horizontal' | 'vertical'>('horizontal'); - /** Effective axis — `axis` wins if provided, otherwise fall back to `direction`. */ - protected readonly orientation = computed<'horizontal' | 'vertical'>(() => - this.axis() ?? this.direction() - ); + /** v0.9 prop: divider axis (default 'horizontal'). */ + readonly axis = input<'horizontal' | 'vertical'>('horizontal'); + protected readonly orientation = computed<'horizontal' | 'vertical'>(() => this.axis()); // Framework inputs required by the render harness. readonly bindings = input>({}); readonly emit = input<(event: string) => void>(() => { /* noop */ }); diff --git a/libs/chat/src/lib/a2ui/catalog/icon.component.spec.ts b/libs/chat/src/lib/a2ui/catalog/icon.component.spec.ts index d9f8e20e5..cd516f93c 100644 --- a/libs/chat/src/lib/a2ui/catalog/icon.component.spec.ts +++ b/libs/chat/src/lib/a2ui/catalog/icon.component.spec.ts @@ -2,14 +2,34 @@ import { describe, it, expect } from 'vitest'; import { A2uiIconComponent, toMaterialSymbolName } from './icon.component'; -describe('A2uiIconComponent', () => { - // Display-only component: renders name() input as a . - // No methods, events, or bindings — purely declarative. +describe('A2uiIconComponent — v0.9 protocol', () => { + // v0.9: the `name` input is either a string (Material Symbols ligature, + // camelCase converted to snake_case) or an object { svgPath } rendered as + // an inline (viewBox "0 -960 960 960"). // Signal-based inputs require the angular() vite plugin for TestBed tests. it('exports the component class', () => { expect(A2uiIconComponent).toBeDefined(); }); + + describe('name shape discrimination', () => { + const svgPathOf = (name: string | { svgPath: string } | undefined): string | null => + typeof name === 'object' && name !== null && typeof name.svgPath === 'string' + ? name.svgPath + : null; + + it('treats a string name as a ligature (no svgPath)', () => { + expect(svgPathOf('check')).toBeNull(); + }); + + it('extracts svgPath from an object name', () => { + expect(svgPathOf({ svgPath: 'M480-480Z' })).toBe('M480-480Z'); + }); + + it('returns null for undefined', () => { + expect(svgPathOf(undefined)).toBeNull(); + }); + }); }); describe('toMaterialSymbolName', () => { diff --git a/libs/chat/src/lib/a2ui/catalog/icon.component.ts b/libs/chat/src/lib/a2ui/catalog/icon.component.ts index e42f6de18..fd68494fb 100644 --- a/libs/chat/src/lib/a2ui/catalog/icon.component.ts +++ b/libs/chat/src/lib/a2ui/catalog/icon.component.ts @@ -20,10 +20,17 @@ export function toMaterialSymbolName(name: string): string { selector: 'a2ui-icon', standalone: true, template: ` - @if (effectiveName(); as name) { + @if (svgPath(); as path) { + + } @else if (ligatureName(); as name) { {{ glyphName() }} @@ -38,6 +45,7 @@ export function toMaterialSymbolName(name: string): string { font-family: 'Material Symbols Outlined'; font-weight: normal; font-style: normal; + font-size: 1.125rem; line-height: 1; letter-spacing: normal; text-transform: none; @@ -54,14 +62,15 @@ export function toMaterialSymbolName(name: string): string { justify-content: center; user-select: none; } + .a2ui-icon--svg { + width: 1.125rem; + height: 1.125rem; + } `], }) export class A2uiIconComponent { - /** v1 canonical prop. */ - readonly name = input(undefined); - /** Pre-v1 alias retained for back-compat. */ - readonly icon = input(''); - readonly size = input(null); + /** v0.9 prop: a Material Symbols name (string) or an inline `{ svgPath }`. */ + readonly name = input(undefined); // Framework inputs required by the render harness. readonly bindings = input>({}); readonly emit = input<(event: string) => void>(() => { /* noop */ }); @@ -69,8 +78,19 @@ export class A2uiIconComponent { readonly childKeys = input([]); readonly spec = input(undefined); - protected readonly effectiveName = computed(() => this.name() ?? this.icon()); + /** Inline SVG path when `name` is the `{ svgPath }` object form. */ + protected readonly svgPath = computed(() => { + const n = this.name(); + return typeof n === 'object' && n !== null && typeof n.svgPath === 'string' + ? n.svgPath + : null; + }); + + /** The string ligature name when `name` is a string. */ + protected readonly ligatureName = computed(() => + typeof this.name() === 'string' ? (this.name() as string) : '', + ); /** The effective name as a Material Symbols ligature (camelCase → snake_case). */ - protected readonly glyphName = computed(() => toMaterialSymbolName(this.effectiveName())); + protected readonly glyphName = computed(() => toMaterialSymbolName(this.ligatureName())); } diff --git a/libs/chat/src/lib/a2ui/catalog/image.component.spec.ts b/libs/chat/src/lib/a2ui/catalog/image.component.spec.ts index b0c8f4bd4..f1def6f0a 100644 --- a/libs/chat/src/lib/a2ui/catalog/image.component.spec.ts +++ b/libs/chat/src/lib/a2ui/catalog/image.component.spec.ts @@ -2,12 +2,41 @@ import { describe, it, expect } from 'vitest'; import { A2uiImageComponent } from './image.component'; -describe('A2uiImageComponent', () => { - // Display-only component: renders url() and alt() as an . - // No methods, events, or bindings — purely declarative. +describe('A2uiImageComponent — v0.9 protocol', () => { + // Display-only component: renders url() as an with description() as + // its alt text. v0.9 props: `fit` maps to CSS object-fit ('scaleDown' → + // 'scale-down', default 'fill'); `variant` selects a sizing class on the + // host (default 'mediumFeature'). // Signal-based inputs require the angular() vite plugin for TestBed tests. it('exports the component class', () => { expect(A2uiImageComponent).toBeDefined(); }); + + describe('fit → object-fit logic', () => { + const FIT_MAP: Record = { + contain: 'contain', cover: 'cover', fill: 'fill', + none: 'none', scaleDown: 'scale-down', + }; + const objectFit = (fit: string) => FIT_MAP[fit] ?? 'fill'; + + it('maps scaleDown to scale-down', () => expect(objectFit('scaleDown')).toBe('scale-down')); + it('maps identity values through', () => { + expect(objectFit('contain')).toBe('contain'); + expect(objectFit('cover')).toBe('cover'); + expect(objectFit('fill')).toBe('fill'); + expect(objectFit('none')).toBe('none'); + }); + it('defaults unknown fit to fill', () => expect(objectFit('bogus')).toBe('fill')); + }); + + describe('variant → class logic', () => { + const variantClass = (variant: string) => `a2ui-img--${variant}`; + + it('maps each variant to a sizing class', () => { + for (const v of ['icon', 'avatar', 'smallFeature', 'mediumFeature', 'largeFeature', 'header']) { + expect(variantClass(v)).toBe(`a2ui-img--${v}`); + } + }); + }); }); diff --git a/libs/chat/src/lib/a2ui/catalog/image.component.ts b/libs/chat/src/lib/a2ui/catalog/image.component.ts index c4aab0662..1ea794b1d 100644 --- a/libs/chat/src/lib/a2ui/catalog/image.component.ts +++ b/libs/chat/src/lib/a2ui/catalog/image.component.ts @@ -1,22 +1,19 @@ // SPDX-License-Identifier: MIT -import { Component, input } from '@angular/core'; +import { Component, computed, input } from '@angular/core'; import type { Spec } from '@json-render/core'; -/** v1 fit values mapped 1:1 to CSS object-fit. */ -type ImageFit = 'contain' | 'cover' | 'fill' | 'none' | 'scale-down'; +/** v0.9 fit values; 'scaleDown' maps to CSS object-fit: scale-down. */ +type ImageFit = 'contain' | 'cover' | 'fill' | 'none' | 'scaleDown'; -/** v1 usageHint maps to a sizing preset. The component renders fluid by - * default; usageHint sets a max-width / aspect-ratio to match common - * intents. */ -type ImageUsageHint = 'icon' | 'avatar' | 'smallFeature' | 'mediumFeature' | 'largeFeature' | 'header'; +/** v0.9 variant maps to a sizing preset class. */ +type ImageVariant = 'icon' | 'avatar' | 'smallFeature' | 'mediumFeature' | 'largeFeature' | 'header'; -const USAGE_HINT_STYLE: Record = { - icon: { maxWidth: '24px', aspectRatio: '1 / 1' }, - avatar: { maxWidth: '48px', aspectRatio: '1 / 1', borderRadius: '50%' }, - smallFeature: { maxWidth: '160px' }, - mediumFeature: { maxWidth: '320px' }, - largeFeature: { maxWidth: '480px' }, - header: { maxWidth: '100%', aspectRatio: '16 / 5' }, +const FIT_MAP: Record = { + contain: 'contain', + cover: 'cover', + fill: 'fill', + none: 'none', + scaleDown: 'scale-down', }; @Component({ @@ -24,15 +21,10 @@ const USAGE_HINT_STYLE: Record `, styles: [` @@ -41,17 +33,38 @@ const USAGE_HINT_STYLE: Record(''); - readonly alt = input(''); - readonly width = input(null); - readonly height = input(null); - /** v1 prop: CSS object-fit equivalent. */ - readonly fit = input(undefined); - /** v1 prop: sizing preset. */ - readonly usageHint = input(undefined); + /** v0.9 prop: alt text / accessible description. */ + readonly description = input(''); + /** v0.9 prop: CSS object-fit equivalent ('scaleDown' → 'scale-down'). */ + readonly fit = input('fill'); + /** v0.9 prop: sizing preset. */ + readonly variant = input('mediumFeature'); // Framework inputs required by the render harness. readonly bindings = input>({}); readonly emit = input<(event: string) => void>(() => { /* noop */ }); @@ -59,14 +72,6 @@ export class A2uiImageComponent { readonly childKeys = input([]); readonly spec = input(undefined); - protected explicitWidth(): string | null { - return this.width() != null ? this.width() + 'px' : null; - } - protected explicitHeight(): string | null { - return this.height() != null ? this.height() + 'px' : null; - } - protected hintStyle(): { maxWidth: string; aspectRatio?: string; borderRadius?: string } | null { - const h = this.usageHint(); - return h ? USAGE_HINT_STYLE[h] : null; - } + protected readonly objectFit = computed(() => FIT_MAP[this.fit()] ?? 'fill'); + protected readonly cssClass = computed(() => `a2ui-img a2ui-img--${this.variant()}`); } diff --git a/libs/chat/src/lib/a2ui/catalog/index.ts b/libs/chat/src/lib/a2ui/catalog/index.ts index ad5dab025..98801218e 100644 --- a/libs/chat/src/lib/a2ui/catalog/index.ts +++ b/libs/chat/src/lib/a2ui/catalog/index.ts @@ -4,6 +4,7 @@ import { A2uiAudioPlayerComponent } from './audio-player.component'; import { A2uiButtonComponent } from './button.component'; import { A2uiCardComponent } from './card.component'; import { A2uiCheckBoxComponent } from './check-box.component'; +import { A2uiChoicePickerComponent } from './choice-picker.component'; import { A2uiColumnComponent } from './column.component'; import { A2uiDateTimeInputComponent } from './date-time-input.component'; import { A2uiDividerComponent } from './divider.component'; @@ -11,7 +12,6 @@ import { A2uiIconComponent } from './icon.component'; import { A2uiImageComponent } from './image.component'; import { A2uiListComponent } from './list.component'; import { A2uiModalComponent } from './modal.component'; -import { A2uiMultipleChoiceComponent } from './multiple-choice.component'; import { A2uiRowComponent } from './row.component'; import { A2uiSliderComponent } from './slider.component'; import { A2uiTabsComponent } from './tabs.component'; @@ -37,6 +37,7 @@ export function a2uiBasicCatalog(): ViewRegistry { Button: A2uiButtonComponent, Card: A2uiCardComponent, CheckBox: A2uiCheckBoxComponent, + ChoicePicker: A2uiChoicePickerComponent, Column: A2uiColumnComponent, DateTimeInput: A2uiDateTimeInputComponent, Divider: A2uiDividerComponent, @@ -44,7 +45,6 @@ export function a2uiBasicCatalog(): ViewRegistry { Image: A2uiImageComponent, List: A2uiListComponent, Modal: A2uiModalComponent, - MultipleChoice: A2uiMultipleChoiceComponent, Row: A2uiRowComponent, Slider: A2uiSliderComponent, Tabs: A2uiTabsComponent, diff --git a/libs/chat/src/lib/a2ui/catalog/list.component.ts b/libs/chat/src/lib/a2ui/catalog/list.component.ts index 6842eb1fb..287ed3f6f 100644 --- a/libs/chat/src/lib/a2ui/catalog/list.component.ts +++ b/libs/chat/src/lib/a2ui/catalog/list.component.ts @@ -34,8 +34,8 @@ export class A2uiListComponent { readonly childKeys = input([]); readonly spec = input.required(); readonly direction = input<'vertical' | 'horizontal'>('vertical'); - /** v1 canonical prop: cross-axis alignment. */ - readonly alignment = input<'start' | 'center' | 'end' | 'stretch' | undefined>(undefined); + /** v0.9 prop: cross-axis alignment (default 'stretch'). */ + readonly align = input<'start' | 'center' | 'end' | 'stretch'>('stretch'); // Framework inputs required by the render harness. readonly bindings = input>({}); readonly emit = input<(event: string) => void>(() => { /* noop */ }); @@ -47,9 +47,8 @@ export class A2uiListComponent { : 'a2ui-list--vertical'; }); - protected readonly alignmentCss = computed(() => { - const a = this.alignment(); - if (!a) return null; + protected readonly alignmentCss = computed(() => { + const a = this.align(); return a === 'start' ? 'flex-start' : a === 'end' ? 'flex-end' : a; // center / stretch are valid CSS values as-is diff --git a/libs/chat/src/lib/a2ui/catalog/modal.component.spec.ts b/libs/chat/src/lib/a2ui/catalog/modal.component.spec.ts index f92670cf6..a81e5ff1c 100644 --- a/libs/chat/src/lib/a2ui/catalog/modal.component.spec.ts +++ b/libs/chat/src/lib/a2ui/catalog/modal.component.spec.ts @@ -4,9 +4,10 @@ import { A2uiModalComponent } from './modal.component'; describe('A2uiModalComponent', () => { // NOTE: Angular signal-based inputs can't be tested via TestBed without the - // angular() vite plugin (NG0303). v1 Modal manages its own open state internally: - // childKeys[0] = entryPointChild (trigger), childKeys[1] = contentChild (body). - // Clicking the entry point wrapper sets open=true; clicking the backdrop sets open=false. + // angular() vite plugin (NG0303). v0.9 Modal manages its own open state internally: + // childKeys[0] = trigger (inline entry point), childKeys[1] = content (body) — + // projected in that order by surface-to-spec. There is no title input in v0.9. + // Clicking the trigger wrapper sets open=true; clicking the backdrop sets open=false. it('exports the component class', () => { expect(A2uiModalComponent).toBeDefined(); @@ -16,7 +17,7 @@ describe('A2uiModalComponent', () => { const getEntryKey = (keys: string[]) => keys[0] ?? null; const getContentKey = (keys: string[]) => keys[1] ?? null; - it('maps childKeys[0] to entry point and childKeys[1] to content', () => { + it('maps childKeys[0] to trigger and childKeys[1] to content', () => { const keys = ['btn-open', 'modal-body']; expect(getEntryKey(keys)).toBe('btn-open'); expect(getContentKey(keys)).toBe('modal-body'); diff --git a/libs/chat/src/lib/a2ui/catalog/modal.component.ts b/libs/chat/src/lib/a2ui/catalog/modal.component.ts index 8e76352e9..1810b7bb6 100644 --- a/libs/chat/src/lib/a2ui/catalog/modal.component.ts +++ b/libs/chat/src/lib/a2ui/catalog/modal.component.ts @@ -40,9 +40,6 @@ import { RenderElementComponent } from '@threadplane/render'; (keydown.space)="open.set(false)" >
- @if (title()) { -

{{ title() }}

- } @if (contentKey(); as cKey) { } @@ -79,22 +76,15 @@ import { RenderElementComponent } from '@threadplane/render'; margin: 0 var(--a2ui-spacing-4); box-shadow: var(--a2ui-elevation-4); } - .a2ui-modal__title { - font-size: var(--a2ui-typography-h4-size); - font-weight: var(--a2ui-typography-h4-weight); - margin: 0 0 var(--a2ui-spacing-4); - } `], }) export class A2uiModalComponent { /** - * v1: childKeys[0] = entryPointChild (inline trigger), - * childKeys[1] = contentChild (modal body). + * v0.9: childKeys[0] = trigger (inline entry point), + * childKeys[1] = content (modal body). */ readonly childKeys = input([]); readonly spec = input.required(); - /** Resolved title string (from optional title DynamicString). */ - readonly title = input(''); // Framework inputs required by the render harness. readonly bindings = input>({}); readonly emit = input<(event: string) => void>(() => { /* noop */ }); diff --git a/libs/chat/src/lib/a2ui/catalog/multiple-choice.component.spec.ts b/libs/chat/src/lib/a2ui/catalog/multiple-choice.component.spec.ts deleted file mode 100644 index 279cdd835..000000000 --- a/libs/chat/src/lib/a2ui/catalog/multiple-choice.component.spec.ts +++ /dev/null @@ -1,92 +0,0 @@ -// SPDX-License-Identifier: MIT -import { describe, it, expect } from 'vitest'; -import type { RenderHost } from '@threadplane/render'; -import { A2uiMultipleChoiceComponent } from './multiple-choice.component'; -import { emitBinding } from './emit-binding'; - -function makeHost(): { host: RenderHost; writes: Array<[string, unknown]> } { - const writes: Array<[string, unknown]> = []; - const host: RenderHost = { - set: (p, v) => writes.push([p, v]), - emit: () => { /* noop */ }, - result: () => { /* noop */ }, - }; - return { host, writes }; -} - -describe('A2uiMultipleChoiceComponent', () => { - // NOTE: Angular signal-based inputs can't be tested via TestBed without the - // angular() vite plugin (NG0303). Tests verify the behavioral contracts for - // single-select (maxAllowedSelections <= 1) and multi-select (checkboxes) modes. - - it('exports the component class', () => { - expect(A2uiMultipleChoiceComponent).toBeDefined(); - }); - - describe('isSingleSelect logic', () => { - const isSingle = (max: number) => max <= 1; - it('is single-select when maxAllowedSelections is 1', () => expect(isSingle(1)).toBe(true)); - it('is single-select when maxAllowedSelections is 0', () => expect(isSingle(0)).toBe(true)); - it('is multi-select when maxAllowedSelections is 2', () => expect(isSingle(2)).toBe(false)); - it('is multi-select when maxAllowedSelections is 10', () => expect(isSingle(10)).toBe(false)); - }); - - describe('isSelected logic', () => { - const isSelected = (selections: string[], value: string) => selections.includes(value); - it('returns true when value is in selections', () => { - expect(isSelected(['a', 'b'], 'a')).toBe(true); - }); - it('returns false when value is not in selections', () => { - expect(isSelected(['a', 'b'], 'c')).toBe(false); - }); - it('returns false when selections is empty', () => { - expect(isSelected([], 'a')).toBe(false); - }); - }); - - describe('onSelectChange emit logic (single-select)', () => { - it('writes binding with selected string value', () => { - const { host, writes } = makeHost(); - const bindings = { selections: '/department' }; - const event = { target: { value: 'Engineering' } } as unknown as Event; - const val = (event.target as HTMLSelectElement).value; - emitBinding(host, bindings, 'selections', val); - expect(writes).toEqual([['/department', 'Engineering']]); - }); - }); - - describe('onCheckChange toggle logic (multi-select)', () => { - const toggle = (current: string[], value: string, checked: boolean): string[] => { - const result = [...current]; - const idx = result.indexOf(value); - if (checked && idx === -1) result.push(value); - else if (!checked && idx !== -1) result.splice(idx, 1); - return result; - }; - - it('adds value when checked', () => { - expect(toggle(['a'], 'b', true)).toEqual(['a', 'b']); - }); - it('removes value when unchecked', () => { - expect(toggle(['a', 'b'], 'a', false)).toEqual(['b']); - }); - it('does not duplicate when value already selected', () => { - expect(toggle(['a', 'b'], 'a', true)).toEqual(['a', 'b']); - }); - it('is a no-op when removing a value not in selections', () => { - expect(toggle(['a'], 'b', false)).toEqual(['a']); - }); - - it('writes binding with actual array (not JSON string)', () => { - const { host, writes } = makeHost(); - const bindings = { selections: '/colors' }; - const current = ['red', 'blue']; - emitBinding(host, bindings, 'selections', current); - expect(writes).toHaveLength(1); - expect(writes[0][0]).toBe('/colors'); - expect(writes[0][1]).toEqual(['red', 'blue']); - // Ensure it's the actual array, not a JSON string - expect(Array.isArray(writes[0][1])).toBe(true); - }); - }); -}); diff --git a/libs/chat/src/lib/a2ui/catalog/multiple-choice.component.ts b/libs/chat/src/lib/a2ui/catalog/multiple-choice.component.ts deleted file mode 100644 index b4296abcb..000000000 --- a/libs/chat/src/lib/a2ui/catalog/multiple-choice.component.ts +++ /dev/null @@ -1,137 +0,0 @@ -// SPDX-License-Identifier: MIT -import { Component, computed, input, ChangeDetectionStrategy } from '@angular/core'; -import type { Spec } from '@json-render/core'; -import { injectRenderHost } from '@threadplane/render'; -import { emitBinding } from './emit-binding'; - -/** Resolved option shape — label and value are plain strings after surface-to-spec resolves them. */ -interface ResolvedOption { - label: string; - value: string; -} - -@Component({ - selector: 'a2ui-multiple-choice', - standalone: true, - changeDetection: ChangeDetectionStrategy.OnPush, - template: ` -
- @if (label()) { - {{ label() }} - } - - @if (isSingleSelect()) { - - - } @else { - -
- @for (opt of options(); track opt.value) { - - } -
- } -
- `, - styles: [` - .a2ui-mc { display: flex; flex-direction: column; gap: var(--a2ui-spacing-1); } - .a2ui-mc__label { - font-size: var(--a2ui-typography-label-size); - font-weight: var(--a2ui-typography-label-weight); - color: var(--a2ui-label); - } - .a2ui-mc__select { - padding: var(--a2ui-spacing-2) var(--a2ui-spacing-3); - font-size: var(--a2ui-typography-body-size); - border-radius: var(--a2ui-shape-small); - background: var(--a2ui-input-bg); - color: var(--a2ui-on-surface); - border: 1px solid var(--a2ui-outline); - outline: none; - transition: border-color var(--a2ui-motion-duration-short) var(--a2ui-motion-easing-standard); - } - .a2ui-mc__select:focus { - outline: var(--a2ui-focus-ring-width) solid var(--a2ui-focus-ring-color); - outline-offset: 2px; - border-color: var(--a2ui-primary); - } - .a2ui-mc__checks { display: flex; flex-direction: column; gap: var(--a2ui-spacing-2); } - .a2ui-mc__check-row { - display: flex; - align-items: center; - gap: var(--a2ui-spacing-2); - font-size: var(--a2ui-typography-body-size); - cursor: pointer; - } - .a2ui-mc__checkbox { - width: 16px; - height: 16px; - border-radius: var(--a2ui-shape-extra-small); - cursor: pointer; - accent-color: var(--a2ui-primary); - } - `], -}) -export class A2uiMultipleChoiceComponent { - private readonly host = injectRenderHost(); - - readonly label = input(''); - /** Resolved current selections from surface-to-spec. Normalized in - * `selectionsArray` because LLMs sometimes seed the data model with a - * scalar (e.g. `"5"`) instead of an array (`["5"]`); we coerce so - * .includes() works either way. */ - readonly selections = input(undefined); - - protected readonly selectionsArray = computed(() => { - const v = this.selections(); - if (Array.isArray(v)) return v; - if (v == null || v === '') return []; - return [String(v)]; - }); - /** Resolved options with plain string labels (surface-to-spec resolves DynamicString). */ - readonly options = input([]); - /** When ≤ 1 — render as single-select