From 6e29e9a8637e32f58de1a78ac63944585bddd6c4 Mon Sep 17 00:00:00 2001 From: Jacob Simionato Date: Mon, 16 Mar 2026 12:51:47 +1030 Subject: [PATCH 1/4] Add work --- .../v0_9/processing/message-processor.test.ts | 55 ++++++++++++ .../src/v0_9/processing/message-processor.ts | 34 ++++++- .../v0_9/rendering/component-context.test.ts | 8 +- .../src/v0_9/rendering/component-context.ts | 2 +- .../src/v0_9/schema/client-to-server.test.ts | 90 +++++++++++++++++++ .../src/v0_9/schema/client-to-server.ts | 82 +++++++++++++++++ renderers/web_core/src/v0_9/schema/index.ts | 1 + .../v0_9/state/surface-group-model.test.ts | 8 +- .../src/v0_9/state/surface-model.test.ts | 26 +++++- .../web_core/src/v0_9/state/surface-model.ts | 30 +++++-- specification/v0_9/docs/renderer_guide.md | 40 +++++++-- 11 files changed, 350 insertions(+), 26 deletions(-) create mode 100644 renderers/web_core/src/v0_9/schema/client-to-server.test.ts create mode 100644 renderers/web_core/src/v0_9/schema/client-to-server.ts diff --git a/renderers/web_core/src/v0_9/processing/message-processor.test.ts b/renderers/web_core/src/v0_9/processing/message-processor.test.ts index a4c4b0ad1..22326ba41 100644 --- a/renderers/web_core/src/v0_9/processing/message-processor.test.ts +++ b/renderers/web_core/src/v0_9/processing/message-processor.test.ts @@ -46,6 +46,61 @@ describe("MessageProcessor", () => { const surface = processor.model.getSurface("s1"); assert.ok(surface); assert.strictEqual(surface.id, "s1"); + assert.strictEqual(surface.sendDataModel, false); + }); + + it("creates surface with sendDataModel enabled", () => { + processor.processMessages([ + { + version: "v0.9", + createSurface: { + surfaceId: "s1", + catalogId: "test-catalog", + sendDataModel: true, + }, + }, + ]); + const surface = processor.model.getSurface("s1"); + assert.strictEqual(surface?.sendDataModel, true); + }); + + it("getClientDataModel filters surfaces correctly", () => { + processor.processMessages([ + { + version: "v0.9", + createSurface: { surfaceId: "s1", catalogId: "test-catalog", sendDataModel: true }, + }, + { + version: "v0.9", + createSurface: { surfaceId: "s2", catalogId: "test-catalog", sendDataModel: false }, + }, + { + version: "v0.9", + updateDataModel: { surfaceId: "s1", value: { user: "Alice" } }, + }, + { + version: "v0.9", + updateDataModel: { surfaceId: "s2", value: { secret: "Bob" } }, + }, + ]); + + const dataModel = processor.getClientDataModel(); + assert.ok(dataModel); + assert.strictEqual(dataModel.version, "v0.9"); + assert.deepStrictEqual(dataModel.surfaces, { + s1: { user: "Alice" }, + }); + assert.strictEqual((dataModel.surfaces as any).s2, undefined); + }); + + it("getClientDataModel returns undefined if no surfaces have sendDataModel enabled", () => { + processor.processMessages([ + { + version: "v0.9", + createSurface: { surfaceId: "s1", catalogId: "test-catalog" }, + }, + ]); + assert.strictEqual(processor.getClientDataModel(), undefined); }); it("updates components on correct surface", () => { diff --git a/renderers/web_core/src/v0_9/processing/message-processor.ts b/renderers/web_core/src/v0_9/processing/message-processor.ts index df8c95c52..a2180fcf2 100644 --- a/renderers/web_core/src/v0_9/processing/message-processor.ts +++ b/renderers/web_core/src/v0_9/processing/message-processor.ts @@ -27,6 +27,7 @@ import { UpdateDataModelMessage, DeleteSurfaceMessage, } from "../schema/server-to-client.js"; +import { A2uiClientDataModel } from "../schema/client-to-server.js"; import { A2uiStateError, A2uiValidationError } from "../errors.js"; /** @@ -52,6 +53,30 @@ export class MessageProcessor { } } + /** + * Returns the aggregated data model for all surfaces that have 'sendDataModel' enabled. + */ + getClientDataModel(): A2uiClientDataModel | undefined { + const surfaces: Record = {}; + let hasAny = false; + + for (const surface of this.model.surfacesMap.values()) { + if (surface.sendDataModel) { + surfaces[surface.id] = surface.dataModel.get("/"); + hasAny = true; + } + } + + if (!hasAny) { + return undefined; + } + + return { + version: "v0.9", + surfaces, + }; + } + /** * Subscribes to surface creation events. */ @@ -114,7 +139,7 @@ export class MessageProcessor { private processCreateSurfaceMessage(message: CreateSurfaceMessage): void { const payload = message.createSurface; - const { surfaceId, catalogId, theme } = payload; + const { surfaceId, catalogId, theme, sendDataModel } = payload; // Find catalog const catalog = this.catalogs.find((c) => c.id === catalogId); @@ -126,7 +151,12 @@ export class MessageProcessor { throw new A2uiStateError(`Surface ${surfaceId} already exists.`); } - const surface = new SurfaceModel(surfaceId, catalog, theme); + const surface = new SurfaceModel( + surfaceId, + catalog, + theme, + sendDataModel ?? false, + ); this.model.addSurface(surface); } diff --git a/renderers/web_core/src/v0_9/rendering/component-context.test.ts b/renderers/web_core/src/v0_9/rendering/component-context.test.ts index 617c50dfc..64da7f6dc 100644 --- a/renderers/web_core/src/v0_9/rendering/component-context.test.ts +++ b/renderers/web_core/src/v0_9/rendering/component-context.test.ts @@ -37,15 +37,17 @@ describe("ComponentContext", () => { it("dispatches actions", async () => { const context = new ComponentContext(mockSurface, componentId); - let actionDispatched = null; + let actionDispatched: any = null; const subscription = mockSurface.onAction.subscribe((action: any) => { actionDispatched = action; }); - await context.dispatchAction({ type: "test" }); + await context.dispatchAction({ event: { name: "test", context: { a: 1 } } }); - assert.deepStrictEqual(actionDispatched, { type: "test" }); + assert.strictEqual(actionDispatched.name, "test"); + assert.strictEqual(actionDispatched.sourceComponentId, componentId); + assert.deepStrictEqual(actionDispatched.context, { a: 1 }); subscription.unsubscribe(); }); diff --git a/renderers/web_core/src/v0_9/rendering/component-context.ts b/renderers/web_core/src/v0_9/rendering/component-context.ts index b085f469c..c921a1101 100644 --- a/renderers/web_core/src/v0_9/rendering/component-context.ts +++ b/renderers/web_core/src/v0_9/rendering/component-context.ts @@ -56,7 +56,7 @@ export class ComponentContext { dataModelBasePath, surface.catalog.invoker ); - this._actionDispatcher = (action) => surface.dispatchAction(action); + this._actionDispatcher = (action) => surface.dispatchAction(action, this.componentModel.id); } private _actionDispatcher: (action: any) => Promise; diff --git a/renderers/web_core/src/v0_9/schema/client-to-server.test.ts b/renderers/web_core/src/v0_9/schema/client-to-server.test.ts new file mode 100644 index 000000000..80dc8a9b2 --- /dev/null +++ b/renderers/web_core/src/v0_9/schema/client-to-server.test.ts @@ -0,0 +1,90 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { describe, it } from "node:test"; +import * as assert from "node:assert"; +import { A2uiClientMessageSchema, A2uiClientDataModelSchema } from "./client-to-server.js"; + +describe("Client-to-Server Schema Verification", () => { + it("validates a valid action message", () => { + const validAction = { + version: "v0.9", + action: { + name: "submit", + surfaceId: "s1", + sourceComponentId: "c1", + timestamp: new Date().toISOString(), + context: { foo: "bar" }, + }, + }; + const result = A2uiClientMessageSchema.safeParse(validAction); + assert.ok(result.success, result.success ? "" : result.error.message); + }); + + it("validates a valid error message (validation failed)", () => { + const validError = { + version: "v0.9", + error: { + code: "VALIDATION_FAILED", + surfaceId: "s1", + path: "/components/0/text", + message: "Too short", + }, + }; + const result = A2uiClientMessageSchema.safeParse(validError); + assert.ok(result.success, result.success ? "" : result.error.message); + }); + + it("validates a valid error message (generic)", () => { + const validError = { + version: "v0.9", + error: { + code: "INTERNAL_ERROR", + message: "Something went wrong", + surfaceId: "s1", + }, + }; + const result = A2uiClientMessageSchema.safeParse(validError); + assert.ok(result.success, result.success ? "" : result.error.message); + }); + + it("validates a valid data model message", () => { + const validDataModel = { + version: "v0.9", + surfaces: { + s1: { user: "Alice" }, + s2: { cart: [] }, + }, + }; + const result = A2uiClientDataModelSchema.safeParse(validDataModel); + assert.ok(result.success, result.success ? "" : result.error.message); + }); + + it("fails on invalid version", () => { + const invalidAction = { + version: "v0.8", + action: { + name: "submit", + surfaceId: "s1", + sourceComponentId: "c1", + timestamp: new Date().toISOString(), + context: {}, + }, + }; + const result = A2uiClientMessageSchema.safeParse(invalidAction); + assert.strictEqual(result.success, false); + }); +}); diff --git a/renderers/web_core/src/v0_9/schema/client-to-server.ts b/renderers/web_core/src/v0_9/schema/client-to-server.ts new file mode 100644 index 000000000..9a939ce82 --- /dev/null +++ b/renderers/web_core/src/v0_9/schema/client-to-server.ts @@ -0,0 +1,82 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { z } from "zod"; + +/** + * Reports a user-initiated action from a component. + * Matches 'action' in specification/v0_9/json/client_to_server.json. + */ +export const A2uiClientActionSchema = z.object({ + name: z.string().describe("The name of the action, taken from the component's action.event.name property."), + surfaceId: z.string().describe("The id of the surface where the event originated."), + sourceComponentId: z.string().describe("The id of the component that triggered the event."), + timestamp: z.string().datetime().describe("An ISO 8601 timestamp of when the event occurred."), + context: z.record(z.any()).describe("A JSON object containing the key-value pairs from the component's action.event.context, after resolving all data bindings."), +}).strict(); + +/** + * Reports a client-side validation failure. + */ +export const A2uiValidationErrorSchema = z.object({ + code: z.literal("VALIDATION_FAILED"), + surfaceId: z.string().describe("The id of the surface where the error occurred."), + path: z.string().describe("The JSON pointer to the field that failed validation (e.g. '/components/0/text')."), + message: z.string().describe("A short one or two sentence description of why validation failed."), +}).strict(); + +/** + * Reports a generic client-side error. + */ +export const A2uiGenericErrorSchema = z.object({ + code: z.string().refine(c => c !== "VALIDATION_FAILED"), + message: z.string().describe("A short one or two sentence description of why the error occurred."), + surfaceId: z.string().describe("The id of the surface where the error occurred."), +}).passthrough(); + +/** + * Reports a client-side error. + * Matches 'error' in specification/v0_9/json/client_to_server.json. + */ +export const A2uiClientErrorSchema = z.union([ + A2uiValidationErrorSchema, + A2uiGenericErrorSchema, +]); + +/** + * A message sent from the A2UI client to the server. + * Matches specification/v0_9/json/client_to_server.json. + */ +export const A2uiClientMessageSchema = z.object({ + version: z.literal("v0.9"), +}).and(z.union([ + z.object({ action: A2uiClientActionSchema }), + z.object({ error: A2uiClientErrorSchema }), +])); + +/** + * Schema for the client data model synchronization. + * Matches specification/v0_9/json/client_data_model.json. + */ +export const A2uiClientDataModelSchema = z.object({ + version: z.literal("v0.9"), + surfaces: z.record(z.object({}).passthrough()).describe("A map of surface IDs to their current data models."), +}).strict(); + +export type A2uiClientAction = z.infer; +export type A2uiClientError = z.infer; +export type A2uiClientMessage = z.infer; +export type A2uiClientDataModel = z.infer; diff --git a/renderers/web_core/src/v0_9/schema/index.ts b/renderers/web_core/src/v0_9/schema/index.ts index d98dfd978..d7da3f61e 100644 --- a/renderers/web_core/src/v0_9/schema/index.ts +++ b/renderers/web_core/src/v0_9/schema/index.ts @@ -16,3 +16,4 @@ export * from "./common-types.js"; export * from "./server-to-client.js"; +export * from "./client-to-server.js"; diff --git a/renderers/web_core/src/v0_9/state/surface-group-model.test.ts b/renderers/web_core/src/v0_9/state/surface-group-model.test.ts index f05419ad4..51b0e77fe 100644 --- a/renderers/web_core/src/v0_9/state/surface-group-model.test.ts +++ b/renderers/web_core/src/v0_9/state/surface-group-model.test.ts @@ -82,8 +82,10 @@ describe("SurfaceGroupModel", () => { const surface = new SurfaceModel("s1", catalog, {}); model.addSurface(surface); - await surface.dispatchAction({ type: "test" }); - assert.deepStrictEqual(receivedAction, { type: "test" }); + await surface.dispatchAction({ event: { name: "test" } }, "c1"); + assert.strictEqual(receivedAction.name, "test"); + assert.strictEqual(receivedAction.surfaceId, "s1"); + assert.strictEqual(receivedAction.sourceComponentId, "c1"); }); it("stops propagating actions after deletion", async () => { @@ -96,7 +98,7 @@ describe("SurfaceGroupModel", () => { model.addSurface(surface); model.deleteSurface("s1"); - await surface.dispatchAction({ type: "test" }); + await surface.dispatchAction({ event: { name: "test" } }, "c1"); assert.strictEqual(callCount, 0); }); diff --git a/renderers/web_core/src/v0_9/state/surface-model.test.ts b/renderers/web_core/src/v0_9/state/surface-model.test.ts index 5f8471430..40ee48d2e 100644 --- a/renderers/web_core/src/v0_9/state/surface-model.test.ts +++ b/renderers/web_core/src/v0_9/state/surface-model.test.ts @@ -46,10 +46,28 @@ describe("SurfaceModel", () => { assert.ok(surface.componentsModel.get("c1")); }); - it("dispatches actions", async () => { - await surface.dispatchAction({ type: "click" }); + it("dispatches actions with metadata", async () => { + await surface.dispatchAction( + { event: { name: "click", context: { foo: "bar" } } }, + "comp-1" + ); + assert.strictEqual(actions.length, 1); + const action = actions[0]; + assert.strictEqual(action.name, "click"); + assert.strictEqual(action.surfaceId, "surface-1"); + assert.strictEqual(action.sourceComponentId, "comp-1"); + assert.deepStrictEqual(action.context, { foo: "bar" }); + assert.ok(action.timestamp); + assert.doesNotThrow(() => new Date(action.timestamp)); + }); + + it("dispatches actions with default context", async () => { + await surface.dispatchAction( + { event: { name: "click" } }, + "comp-1" + ); assert.strictEqual(actions.length, 1); - assert.strictEqual(actions[0].type, "click"); + assert.deepStrictEqual(actions[0].context, {}); }); it("creates a component context", () => { @@ -73,7 +91,7 @@ describe("SurfaceModel", () => { // After dispose, no more actions should be emitted. // The EventEmitter.dispose method clears all listeners. - surface.dispatchAction({ type: "click" }); + surface.dispatchAction({ event: { name: "click" } }, "c1"); assert.strictEqual( actionReceived, false, diff --git a/renderers/web_core/src/v0_9/state/surface-model.ts b/renderers/web_core/src/v0_9/state/surface-model.ts index 03c0922e4..ec7bba75e 100644 --- a/renderers/web_core/src/v0_9/state/surface-model.ts +++ b/renderers/web_core/src/v0_9/state/surface-model.ts @@ -18,9 +18,10 @@ import { DataModel } from "./data-model.js"; import { Catalog, ComponentApi } from "../catalog/types.js"; import { SurfaceComponentsModel } from "./surface-components-model.js"; import { EventEmitter, EventSource } from "../common/events.js"; +import { A2uiClientAction } from "../schema/client-to-server.js"; /** A function that listens for actions emitted from a surface. */ -export type ActionListener = (action: any) => void | Promise; +export type ActionListener = (action: A2uiClientAction) => void | Promise; /** * The state model for a single surface. @@ -32,10 +33,10 @@ export class SurfaceModel { /** The collection of component models for this surface. */ readonly componentsModel: SurfaceComponentsModel; - private readonly _onAction = new EventEmitter(); + private readonly _onAction = new EventEmitter(); /** Fires whenever an action is dispatched from this surface. */ - readonly onAction: EventSource = this._onAction; + readonly onAction: EventSource = this._onAction; /** * Creates a new surface model. @@ -43,11 +44,13 @@ export class SurfaceModel { * @param id The unique identifier for this surface. * @param catalog The component catalog used by this surface. * @param theme The theme to apply to this surface. + * @param sendDataModel If true, the client will send the full data model. */ constructor( readonly id: string, readonly catalog: Catalog, readonly theme: any = {}, + readonly sendDataModel: boolean = false, ) { this.dataModel = new DataModel({}); this.componentsModel = new SurfaceComponentsModel(); @@ -56,10 +59,25 @@ export class SurfaceModel { /** * Dispatches an action from this surface to listeners. * - * @param action The action object to dispatch. + * @param payload The action payload (name and context) to dispatch. + * @param sourceComponentId The ID of the component that triggered the action. */ - async dispatchAction(action: any): Promise { - await this._onAction.emit(action); + async dispatchAction( + payload: any, + sourceComponentId: string, + ): Promise { + if (payload && typeof payload === "object" && "event" in payload) { + const action: A2uiClientAction = { + name: payload.event.name, + surfaceId: this.id, + sourceComponentId, + timestamp: new Date().toISOString(), + context: payload.event.context || {}, + }; + await this._onAction.emit(action); + } + // Note: local functionCall actions are currently handled by the renderer or binder + // and do not necessarily need to be emitted here if they are not intended for the server. } /** diff --git a/specification/v0_9/docs/renderer_guide.md b/specification/v0_9/docs/renderer_guide.md index 0eebda2ba..7b8b3be5b 100644 --- a/specification/v0_9/docs/renderer_guide.md +++ b/specification/v0_9/docs/renderer_guide.md @@ -153,17 +153,21 @@ class SurfaceGroupModel { readonly onSurfaceCreated: EventSource>; readonly onSurfaceDeleted: EventSource; - readonly onAction: EventSource; + readonly onAction: EventSource; } -interface ActionEvent { +/** + * Matches 'action' in specification/v0_9/json/client_to_server.json. + */ +interface A2uiClientAction { + name: string; surfaceId: string; sourceComponentId: string; - name: string; + timestamp: string; // ISO 8601 context: Record; } -type ActionListener = (action: ActionEvent) => void | Promise; +type ActionListener = (action: A2uiClientAction) => void | Promise; class SurfaceModel { readonly id: string; @@ -172,9 +176,16 @@ class SurfaceModel { readonly dataModel: DataModel; readonly componentsModel: SurfaceComponentsModel; readonly theme?: any; + /** If true, the client should send the full data model with actions. */ + readonly sendDataModel: boolean; - readonly onAction: EventSource; - dispatchAction(action: ActionEvent): Promise; + readonly onAction: EventSource; + /** + * Dispatches an action from this surface. + * @param payload The raw action event from the component. + * @param sourceComponentId The ID of the component that triggered the action. + */ + dispatchAction(payload: any, sourceComponentId: string): Promise; } ``` @@ -259,7 +270,7 @@ class ComponentContext { *Escape Hatch*: Component implementations can use `ctx.surfaceComponents` to inspect the metadata of other components in the same surface (e.g. a `Row` checking if children have a `weight` property). This is discouraged but necessary for some layout engines. ### The Processing Layer (`MessageProcessor`) -The "Controller" that accepts the raw stream of A2UI messages, parses them, and mutates the Models. +The "Controller" that accepts the raw stream of A2UI messages, parses them, and mutates the Models. It also handles the aggregation of client state for synchronization. ```typescript class MessageProcessor { @@ -270,9 +281,24 @@ class MessageProcessor { processMessages(messages: any[]): void; addLifecycleListener(l: SurfaceLifecycleListener): () => void; getClientCapabilities(options?: CapabilitiesOptions): any; + + /** + * Returns the aggregated data model for all surfaces that have 'sendDataModel' enabled. + * This should be used by the transport layer to populate metadata (e.g., 'a2uiClientDataModel'). + */ + getClientDataModel(): any; } ``` +#### Client Data Model Synchronization +When a surface is created with `sendDataModel: true`, the client is responsible for sending the current state of that surface's data model back to the server whenever a client-to-server message (like an `action`) is sent. + +**Implementation Flow:** +1. The `MessageProcessor` tracks the `sendDataModel` flag for each surface. +2. The `getClientDataModel()` method iterates over all active surfaces and returns a map of data models for those where the flag is enabled. +3. The **Transport Layer** (e.g., A2A, MCP) calls `getClientDataModel()` before sending any message to the server. +4. If a non-empty data model map is returned, it is included in the transport's metadata field (e.g., `a2uiClientDataModel` in A2A metadata). + * **Component Lifecycle**: If an `updateComponents` message provides an existing `id` but a *different* `type`, the processor MUST remove the old component and create a fresh one to ensure framework renderers correctly reset their internal state. #### Generating Client Capabilities and Schema Types From 35b0be3bf3c7f976925484683c8c078f91a8d88a Mon Sep 17 00:00:00 2001 From: Jacob Simionato Date: Mon, 16 Mar 2026 13:07:35 +1030 Subject: [PATCH 2/4] docs: strongly type getClientDataModel in renderer guide --- specification/v0_9/docs/renderer_guide.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/specification/v0_9/docs/renderer_guide.md b/specification/v0_9/docs/renderer_guide.md index 7b8b3be5b..377e86164 100644 --- a/specification/v0_9/docs/renderer_guide.md +++ b/specification/v0_9/docs/renderer_guide.md @@ -286,7 +286,7 @@ class MessageProcessor { * Returns the aggregated data model for all surfaces that have 'sendDataModel' enabled. * This should be used by the transport layer to populate metadata (e.g., 'a2uiClientDataModel'). */ - getClientDataModel(): any; + getClientDataModel(): A2uiClientDataModel | undefined; } ``` From bf60851a0c49bcf352f08e8852cbac709129ec85 Mon Sep 17 00:00:00 2001 From: Jacob Simionato Date: Mon, 16 Mar 2026 13:12:28 +1030 Subject: [PATCH 3/4] address feedback --- .../src/v0_9/processing/message-processor.ts | 4 +-- .../web_core/src/v0_9/state/surface-model.ts | 26 ++++++++++++++++--- 2 files changed, 23 insertions(+), 7 deletions(-) diff --git a/renderers/web_core/src/v0_9/processing/message-processor.ts b/renderers/web_core/src/v0_9/processing/message-processor.ts index a2180fcf2..2c9939fa9 100644 --- a/renderers/web_core/src/v0_9/processing/message-processor.ts +++ b/renderers/web_core/src/v0_9/processing/message-processor.ts @@ -58,16 +58,14 @@ export class MessageProcessor { */ getClientDataModel(): A2uiClientDataModel | undefined { const surfaces: Record = {}; - let hasAny = false; for (const surface of this.model.surfacesMap.values()) { if (surface.sendDataModel) { surfaces[surface.id] = surface.dataModel.get("/"); - hasAny = true; } } - if (!hasAny) { + if (Object.keys(surfaces).length === 0) { return undefined; } diff --git a/renderers/web_core/src/v0_9/state/surface-model.ts b/renderers/web_core/src/v0_9/state/surface-model.ts index ec7bba75e..6c9ba921b 100644 --- a/renderers/web_core/src/v0_9/state/surface-model.ts +++ b/renderers/web_core/src/v0_9/state/surface-model.ts @@ -18,7 +18,10 @@ import { DataModel } from "./data-model.js"; import { Catalog, ComponentApi } from "../catalog/types.js"; import { SurfaceComponentsModel } from "./surface-components-model.js"; import { EventEmitter, EventSource } from "../common/events.js"; -import { A2uiClientAction } from "../schema/client-to-server.js"; +import { + A2uiClientAction, + A2uiClientActionSchema, +} from "../schema/client-to-server.js"; /** A function that listens for actions emitted from a surface. */ export type ActionListener = (action: A2uiClientAction) => void | Promise; @@ -66,15 +69,30 @@ export class SurfaceModel { payload: any, sourceComponentId: string, ): Promise { - if (payload && typeof payload === "object" && "event" in payload) { - const action: A2uiClientAction = { + if ( + payload && + typeof payload === "object" && + "event" in payload && + payload.event + ) { + const actionToValidate = { name: payload.event.name, surfaceId: this.id, sourceComponentId, timestamp: new Date().toISOString(), context: payload.event.context || {}, }; - await this._onAction.emit(action); + + const validationResult = + A2uiClientActionSchema.safeParse(actionToValidate); + if (validationResult.success) { + await this._onAction.emit(validationResult.data); + } else { + console.error( + "A2UI: Invalid action payload dispatched.", + validationResult.error.format(), + ); + } } // Note: local functionCall actions are currently handled by the renderer or binder // and do not necessarily need to be emitted here if they are not intended for the server. From b5c9c64ebf6298fe087fda1bb79d4f3590ce3231 Mon Sep 17 00:00:00 2001 From: Jacob Simionato Date: Mon, 16 Mar 2026 13:14:52 +1030 Subject: [PATCH 4/4] docs: strongly type interfaces in renderer_guide.md code examples --- specification/v0_9/docs/renderer_guide.md | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/specification/v0_9/docs/renderer_guide.md b/specification/v0_9/docs/renderer_guide.md index 377e86164..6f8fa5ed1 100644 --- a/specification/v0_9/docs/renderer_guide.md +++ b/specification/v0_9/docs/renderer_guide.md @@ -175,7 +175,7 @@ class SurfaceModel { readonly catalog: Catalog; readonly dataModel: DataModel; readonly componentsModel: SurfaceComponentsModel; - readonly theme?: any; + readonly theme?: Record; /** If true, the client should send the full data model with actions. */ readonly sendDataModel: boolean; @@ -185,7 +185,7 @@ class SurfaceModel { * @param payload The raw action event from the component. * @param sourceComponentId The ID of the component that triggered the action. */ - dispatchAction(payload: any, sourceComponentId: string): Promise; + dispatchAction(payload: Record, sourceComponentId: string): Promise; } ``` @@ -252,9 +252,9 @@ Transient objects created on-demand during rendering to solve "scope" and bindin class DataContext { constructor(dataModel: DataModel, path: string); readonly path: string; - set(path: string, value: any): void; - resolveDynamicValue(v: any): V; - subscribeDynamicValue(v: any, onChange: (v: V | undefined) => void): Subscription; + set(path: string, value: unknown): void; + resolveDynamicValue(v: DynamicValue): V; + subscribeDynamicValue(v: DynamicValue, onChange: (v: V | undefined) => void): Subscription; nested(relativePath: string): DataContext; } @@ -263,7 +263,7 @@ class ComponentContext { readonly componentModel: ComponentModel; readonly dataContext: DataContext; readonly surfaceComponents: SurfaceComponentsModel; // The escape hatch - dispatchAction(action: any): Promise; + dispatchAction(action: Record): Promise; } ``` @@ -278,9 +278,9 @@ class MessageProcessor { constructor(catalogs: Catalog[], actionHandler: ActionListener); - processMessages(messages: any[]): void; + processMessages(messages: A2uiMessage[]): void; addLifecycleListener(l: SurfaceLifecycleListener): () => void; - getClientCapabilities(options?: CapabilitiesOptions): any; + getClientCapabilities(options?: CapabilitiesOptions): A2uiClientCapabilities; /** * Returns the aggregated data model for all surfaces that have 'sendDataModel' enabled.