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..2c9939fa9 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,28 @@ export class MessageProcessor { } } + /** + * Returns the aggregated data model for all surfaces that have 'sendDataModel' enabled. + */ + getClientDataModel(): A2uiClientDataModel | undefined { + const surfaces: Record = {}; + + for (const surface of this.model.surfacesMap.values()) { + if (surface.sendDataModel) { + surfaces[surface.id] = surface.dataModel.get("/"); + } + } + + if (Object.keys(surfaces).length === 0) { + return undefined; + } + + return { + version: "v0.9", + surfaces, + }; + } + /** * Subscribes to surface creation events. */ @@ -114,7 +137,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 +149,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..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,9 +18,13 @@ 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, + A2uiClientActionSchema, +} 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 +36,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 +47,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 +62,40 @@ 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 && + payload.event + ) { + const actionToValidate = { + name: payload.event.name, + surfaceId: this.id, + sourceComponentId, + timestamp: new Date().toISOString(), + context: payload.event.context || {}, + }; + + 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. } /** diff --git a/specification/v0_9/docs/renderer_guide.md b/specification/v0_9/docs/renderer_guide.md index 0eebda2ba..6f8fa5ed1 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; @@ -171,10 +175,17 @@ 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; - 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: Record, sourceComponentId: string): Promise; } ``` @@ -241,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; } @@ -252,14 +263,14 @@ class ComponentContext { readonly componentModel: ComponentModel; readonly dataContext: DataContext; readonly surfaceComponents: SurfaceComponentsModel; // The escape hatch - dispatchAction(action: any): Promise; + dispatchAction(action: Record): Promise; } ``` *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 { @@ -267,12 +278,27 @@ 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. + * This should be used by the transport layer to populate metadata (e.g., 'a2uiClientDataModel'). + */ + getClientDataModel(): A2uiClientDataModel | undefined; } ``` +#### 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