diff --git a/apps/website/content/docs/a2ui/guides/message-protocol.mdx b/apps/website/content/docs/a2ui/guides/message-protocol.mdx index bea9f91b9..42b1e5062 100644 --- a/apps/website/content/docs/a2ui/guides/message-protocol.mdx +++ b/apps/website/content/docs/a2ui/guides/message-protocol.mdx @@ -120,7 +120,7 @@ Details worth pinning down: - **Context is resolved.** The inbound Button's `action.event.context` is a plain object whose values are dynamic values (often `{ path }` bindings). The outbound message's `action.context` is the same keys with each value already resolved against the current data model — here `{ "path": "/origin" }` became `["LAX"]`. - **`label` is a Threadplane extension.** It's derived from the source component's authored text — for a Button-with-Text-child, the child Text's bare literal string ("Search flights"). It's optional; the transcript renderer uses it to label the user bubble, and backends may ignore it. -The client's current data model is only attached as `metadata.a2uiClientDataModel` when the surface's `createSurface` set `sendDataModel: true`. It's omitted otherwise. When present, it's an `A2uiClientDataModel` — `{ surfaces: Record> }`, the per-surface model keyed by `surfaceId`. See [the schema reference](/docs/a2ui/reference/schema#outbound-action-messages) for the full outbound shape. +The client's current data model is only attached as `metadata.a2uiClientDataModel` when the surface's `createSurface` set `sendDataModel: true`. It's omitted otherwise. When present, it's an `A2uiClientDataModel` — `{ surfaces: Record> }`, the per-surface **live** model keyed by `surfaceId` — user edits included, renderer-internal keys stripped. See [the schema reference](/docs/a2ui/reference/schema#outbound-action-messages) for the full outbound shape. ## Relationship to Google's A2UI diff --git a/apps/website/content/docs/chat/a2ui/overview.mdx b/apps/website/content/docs/chat/a2ui/overview.mdx index db31c5c03..05d86672b 100644 --- a/apps/website/content/docs/chat/a2ui/overview.mdx +++ b/apps/website/content/docs/chat/a2ui/overview.mdx @@ -153,7 +153,7 @@ onInput(event: Event): void { } ``` -The write-back protocol is client-side state. If the surface's `createSurface` set `sendDataModel: true`, outgoing action messages also include the current surface data model snapshot under `metadata.a2uiClientDataModel`. +The write-back protocol is client-side state. If the surface's `createSurface` set `sendDataModel: true`, outgoing action messages also include the current **live** surface data model (user edits included) under `metadata.a2uiClientDataModel`. ## Actions @@ -186,7 +186,7 @@ The surface-to-spec conversion turns this into a render `click` binding that cal } ``` -`label` is a Threadplane extension, derived from the Button's child Text; transcripts use it to label the user bubble. If the surface has `sendDataModel: true`, the emitted message also includes `metadata.a2uiClientDataModel` with the current surface data model snapshot. +`label` is a Threadplane extension, derived from the Button's child Text; transcripts use it to label the user bubble. If the surface has `sendDataModel: true`, the emitted message also includes `metadata.a2uiClientDataModel` with the live surface data model (user edits included). The other action form, `{ "functionCall": { "call": ..., "args": ... } }`, executes a client-side function locally instead of round-tripping to the agent — wired to the surface component's `a2ui:localAction` handler, with `openUrl` (new tab, `noopener`) built in. diff --git a/apps/website/content/docs/chat/api/api-docs.json b/apps/website/content/docs/chat/api/api-docs.json index b208c2883..db1ae3566 100644 --- a/apps/website/content/docs/chat/api/api-docs.json +++ b/apps/website/content/docs/chat/api/api-docs.json @@ -8639,6 +8639,20 @@ "```ts\nproviders: [provideViews({ ...a2uiBasicCatalog(), MyWidget: MyWidgetComponent })]\n```" ] }, + { + "name": "a2uiClientCapabilities", + "kind": "function", + "description": "The A2UI client capabilities this renderer supports — the typed\n`a2uiClientCapabilities` metadata a host attaches to agent requests so\nthe agent knows which component catalogs it may target\n(catalog negotiation, A2UI v0.9 transport metadata).", + "signature": "a2uiClientCapabilities(): A2uiClientCapabilities", + "params": [], + "returns": { + "type": "A2uiClientCapabilities", + "description": "" + }, + "examples": [ + "```ts\nconst metadata = { a2uiClientCapabilities: a2uiClientCapabilities() };\n```" + ] + }, { "name": "action", "kind": "function", diff --git a/docs/superpowers/plans/2026-08-17-a2ui-v09-phase4-senddatamodel.md b/docs/superpowers/plans/2026-08-17-a2ui-v09-phase4-senddatamodel.md new file mode 100644 index 000000000..94758d4f9 --- /dev/null +++ b/docs/superpowers/plans/2026-08-17-a2ui-v09-phase4-senddatamodel.md @@ -0,0 +1,32 @@ +# A2UI v0.9 Phase 4 — sendDataModel Round-Trip 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:** Complete the `createSurface.sendDataModel` round-trip: outbound action messages attach the **live** per-surface data model (user edits included, internal keys stripped), and hosts get a typed `a2uiClientCapabilities()` helper for catalog negotiation metadata. + +**Architecture:** Phase 1 already attaches `metadata.a2uiClientDataModel` when `surface.sendDataModel` — but from the agent-seeded snapshot. Phase 3 gave `` the live store; this phase passes the merged live model (minus the reserved `_a2uiChecks` key) into `buildA2uiActionMessage`, and adds a tiny capabilities helper. Base: main after #819. + +--- + +### Task 1: live model in the action metadata + +**Files:** Modify `libs/chat/src/lib/a2ui/surface.component.ts` (+spec). + +- [ ] Failing spec: a surface created with `sendDataModel: true` whose bound TextField was edited via the live store → emitted action's `metadata.a2uiClientDataModel.surfaces[surfaceId]` contains the live-typed value and does NOT contain `_a2uiChecks`. +- [ ] Implement: in the `a2ui:event` success path, call `buildA2uiActionMessage({...params, context}, { ...surf, dataModel: publicModel })` where `publicModel` = merged live model with `_a2uiChecks` removed. Green + commit. + +### Task 2: capabilities helper + +**Files:** Create `libs/chat/src/lib/a2ui/capabilities.ts` (+spec); modify `libs/chat/src/public-api.ts`. + +- [ ] `a2uiClientCapabilities(): A2uiClientCapabilities` returning `{ supportedCatalogIds: [A2UI_BASIC_CATALOG_ID] }` — the typed metadata hosts attach to requests for catalog negotiation. Export + api-docs. + +### Task 3: prompts + docs + +**Files:** `examples/*/python/src/schemas/a2ui_v09.py` twins (sendDataModel note), `apps/website/content/docs/a2ui/guides/message-protocol.mdx` + `chat/a2ui/overview.mdx` (live-model metadata wording), `npm run generate-api-docs`. + +### Task 4: verification + PR + +- [ ] `nx run-many -t lint test build -p a2ui chat`; pytest twins; affected sweep. +- [ ] Live Chrome smoke: prompt for a `sendDataModel: true` form; submit and verify (via thread state) that the outbound action carries `metadata.a2uiClientDataModel` with the live-typed values. +- [ ] PR `feat(a2ui): live sendDataModel round-trip + capabilities helper (Phase 4)`; merge on green. diff --git a/examples/ag-ui/python/src/schemas/a2ui_v09.py b/examples/ag-ui/python/src/schemas/a2ui_v09.py index 52b787aed..b090d8750 100644 --- a/examples/ag-ui/python/src/schemas/a2ui_v09.py +++ b/examples/ag-ui/python/src/schemas/a2ui_v09.py @@ -75,7 +75,11 @@ event action fires: failing checks block the action, display "message" under the offending input, and send the agent an error message {"version":"v0.9","error":{"code":"VALIDATION_FAILED",...}} instead. Bind the -checked value to the same {"path": ...} as the input's value. Example: +checked value to the same {"path": ...} as the input's value. + +Set "sendDataModel": true on createSurface when the agent should receive the +surface's full LIVE data model (including user edits) in the metadata of +every action message ({"metadata":{"a2uiClientDataModel":{"surfaces":{...}}}}). Example: {"id": "email", "component": "TextField", "label": "Email", "value": {"path": "/email"}, diff --git a/examples/chat/python/src/schemas/a2ui_v09.py b/examples/chat/python/src/schemas/a2ui_v09.py index 52b787aed..b090d8750 100644 --- a/examples/chat/python/src/schemas/a2ui_v09.py +++ b/examples/chat/python/src/schemas/a2ui_v09.py @@ -75,7 +75,11 @@ event action fires: failing checks block the action, display "message" under the offending input, and send the agent an error message {"version":"v0.9","error":{"code":"VALIDATION_FAILED",...}} instead. Bind the -checked value to the same {"path": ...} as the input's value. Example: +checked value to the same {"path": ...} as the input's value. + +Set "sendDataModel": true on createSurface when the agent should receive the +surface's full LIVE data model (including user edits) in the metadata of +every action message ({"metadata":{"a2uiClientDataModel":{"surfaces":{...}}}}). Example: {"id": "email", "component": "TextField", "label": "Email", "value": {"path": "/email"}, diff --git a/libs/chat/src/lib/a2ui/capabilities.spec.ts b/libs/chat/src/lib/a2ui/capabilities.spec.ts new file mode 100644 index 000000000..fac7f0d5f --- /dev/null +++ b/libs/chat/src/lib/a2ui/capabilities.spec.ts @@ -0,0 +1,12 @@ +// SPDX-License-Identifier: MIT +import { describe, expect, it } from 'vitest'; +import { A2UI_BASIC_CATALOG_ID } from '@threadplane/a2ui'; +import { a2uiClientCapabilities } from './capabilities'; + +describe('a2uiClientCapabilities', () => { + it('advertises the basic catalog', () => { + expect(a2uiClientCapabilities()).toEqual({ + supportedCatalogIds: [A2UI_BASIC_CATALOG_ID], + }); + }); +}); diff --git a/libs/chat/src/lib/a2ui/capabilities.ts b/libs/chat/src/lib/a2ui/capabilities.ts new file mode 100644 index 000000000..12ee0369d --- /dev/null +++ b/libs/chat/src/lib/a2ui/capabilities.ts @@ -0,0 +1,17 @@ +// SPDX-License-Identifier: MIT +import { A2UI_BASIC_CATALOG_ID, type A2uiClientCapabilities } from '@threadplane/a2ui'; + +/** + * The A2UI client capabilities this renderer supports — the typed + * `a2uiClientCapabilities` metadata a host attaches to agent requests so + * the agent knows which component catalogs it may target + * (catalog negotiation, A2UI v0.9 transport metadata). + * + * @example + * ```ts + * const metadata = { a2uiClientCapabilities: a2uiClientCapabilities() }; + * ``` + */ +export function a2uiClientCapabilities(): A2uiClientCapabilities { + return { supportedCatalogIds: [A2UI_BASIC_CATALOG_ID] }; +} diff --git a/libs/chat/src/lib/a2ui/surface.component.spec.ts b/libs/chat/src/lib/a2ui/surface.component.spec.ts index bcf15000d..bd2271aed 100644 --- a/libs/chat/src/lib/a2ui/surface.component.spec.ts +++ b/libs/chat/src/lib/a2ui/surface.component.spec.ts @@ -195,3 +195,43 @@ describe('A2uiSurfaceComponent — validation gate + live context (Phase 3)', () expect(fx.componentInstance.liveStore.get('/email')).toBe('user@typed.io'); }); }); + +describe('A2uiSurfaceComponent — sendDataModel live round-trip (Phase 4)', () => { + beforeEach(() => TestBed.configureTestingModule({ imports: [A2uiSurfaceComponent] })); + + it('attaches the live data model (internal keys stripped) when sendDataModel is true', () => { + const store = createA2uiSurfaceStore(); + store.apply({ version: 'v0.9', createSurface: { + surfaceId: 's3', catalogId: 'basic', sendDataModel: true, + } } as never); + store.apply({ version: 'v0.9', updateComponents: { + surfaceId: 's3', + components: [ + { id: 'root', component: 'Column', children: ['name', 'go'] }, + { id: 'name', component: 'TextField', label: 'Name', value: { path: '/name' }, + checks: [{ condition: { call: 'required', args: { value: { path: '/name' } } }, message: 'Required' }] }, + { id: 'go', component: 'Button', child: 'go-lbl', action: { event: { name: 'save' } } }, + { id: 'go-lbl', component: 'Text', text: 'Save' }, + ], + } } as never); + store.apply({ version: 'v0.9', updateDataModel: { surfaceId: 's3', path: '/name', value: '' } } as never); + + const fx = TestBed.createComponent(A2uiSurfaceComponent); + fx.componentRef.setInput('state', store.surfaceState('s3')()!); + fx.componentRef.setInput('catalog', a2uiBasicCatalog()); + const actions: { metadata?: { a2uiClientDataModel?: { surfaces: Record> } } }[] = []; + fx.componentInstance.action.subscribe((a) => actions.push(a)); + fx.detectChanges(); + + // User edits the bound field via the live store. + fx.componentInstance.liveStore.set('/name', 'Ada Lovelace'); + + const handler = fx.componentInstance.internalHandlers()['a2ui:event']; + handler({ surfaceId: 's3', sourceComponentId: 'go', name: 'save', context: {} }); + + expect(actions).toHaveLength(1); + const model = actions[0].metadata!.a2uiClientDataModel!.surfaces['s3']; + expect(model['name']).toBe('Ada Lovelace'); + expect('_a2uiChecks' in model).toBe(false); + }); +}); diff --git a/libs/chat/src/lib/a2ui/surface.component.ts b/libs/chat/src/lib/a2ui/surface.component.ts index 17d38712f..218e461f3 100644 --- a/libs/chat/src/lib/a2ui/surface.component.ts +++ b/libs/chat/src/lib/a2ui/surface.component.ts @@ -200,7 +200,14 @@ export class A2uiSurfaceComponent { } } - const message = buildA2uiActionMessage({ ...params, context }, surf); + // sendDataModel metadata must carry the LIVE model (user edits + // included), minus renderer-internal keys. + const { _a2uiChecks, ...publicModel } = liveModel; + void _a2uiChecks; + const message = buildA2uiActionMessage( + { ...params, context }, + { ...surf, dataModel: publicModel }, + ); this.action.emit(message); return message; }, diff --git a/libs/chat/src/public-api.ts b/libs/chat/src/public-api.ts index 1aaf7eb91..e9d68ff53 100644 --- a/libs/chat/src/public-api.ts +++ b/libs/chat/src/public-api.ts @@ -209,6 +209,7 @@ export { A2uiSurfaceComponent } from './lib/a2ui/surface.component'; export { buildA2uiActionMessage } from './lib/a2ui/build-action-message'; export { a2uiBasicCatalog } from './lib/a2ui/catalog/index'; export { emitBinding } from './lib/a2ui/catalog/emit-binding'; +export { a2uiClientCapabilities } from './lib/a2ui/capabilities'; // A2UI catalog components (for custom catalog composition via withViews) export { A2uiTextFieldComponent } from './lib/a2ui/catalog/text-field.component';