From 40c7756602706d1455d1517b7a4681b0ba4c3e09 Mon Sep 17 00:00:00 2001 From: Jacob Simionato Date: Thu, 22 Jan 2026 09:31:01 +1030 Subject: [PATCH 1/2] feat: Support component deletion via component: null Update server_to_client.json schema to allow component: null. Update protocol documentation. Add component_deletion.json test cases. Update evaluation framework to support and test this feature. --- specification/v0_9/docs/a2ui_protocol.md | 15 ++-- .../v0_9/eval/src/generation_flow.ts | 1 + specification/v0_9/eval/src/prompts.ts | 5 ++ specification/v0_9/eval/src/validator.ts | 6 ++ specification/v0_9/json/server_to_client.json | 21 +++++- .../v0_9/test/cases/component_deletion.json | 70 +++++++++++++++++++ 6 files changed, 112 insertions(+), 6 deletions(-) create mode 100644 specification/v0_9/test/cases/component_deletion.json diff --git a/specification/v0_9/docs/a2ui_protocol.md b/specification/v0_9/docs/a2ui_protocol.md index a266a140a..3f9758be5 100644 --- a/specification/v0_9/docs/a2ui_protocol.md +++ b/specification/v0_9/docs/a2ui_protocol.md @@ -148,12 +148,18 @@ This message signals the client to create a new surface and begin rendering it. ### `updateComponents` -This message provides a list of UI components to be added to or updated within a specific surface. The components are provided as a flat list, and their relationships are defined by ID references in an adjacency list. This message may only be sent to a surface that has already been created. Note that components may reference children or data bindings that do not yet exist; clients should handle this gracefully by rendering placeholders (progressive rendering). +This message provides a list of UI components to be added, updated, or removed within a specific surface. The components are provided as a flat list, and their relationships are defined by ID references in an adjacency list. This message may only be sent to a surface that has already been created. + +**Adding or Updating Components:** +To add or update a component, provide the full component definition object. If a component with the same `id` already exists, it will be replaced. + +**Deleting Components:** +To delete a component, provide an object with the component's `id` and set the `component` property to `null`. This removes the component from the client's memory. **Properties:** - `surfaceId` (string, required): The unique identifier for the UI surface to be updated. This is typically a name with meaning (e.g. "user_profile_card"), and it has to be unique within the context of the GenUI session. -- `components` (array, required): A list of component objects. The components are provided as a flat list, and their relationships are defined by ID references in an adjacency list. +- `components` (array, required): A list of component objects (for updates) or tombstone objects (for deletions). **Example:** @@ -173,9 +179,8 @@ This message provides a list of UI components to be added to or updated within a "text": "John Doe" }, { - "id": "user_title", - "component": "Text", - "text": "Software Engineer" + "id": "old_component_id", + "component": null } ] } diff --git a/specification/v0_9/eval/src/generation_flow.ts b/specification/v0_9/eval/src/generation_flow.ts index a5d9018aa..d369c680f 100644 --- a/specification/v0_9/eval/src/generation_flow.ts +++ b/specification/v0_9/eval/src/generation_flow.ts @@ -56,6 +56,7 @@ Standard Instructions: 13. Do NOT use a 'style' property. Use standard properties like 'align', 'justify', 'variant', etc. 14. Do NOT invent properties that are not in the schema. Check the 'properties' list for each component type. 15. Use 'checks' property for validation rules if required. +16. To delete a component, set its 'component' property to null (e.g. { "id": "...", "component": null }). ${catalogRules ? `\nInstructions specific to this catalog:\n${catalogRules}` : ""} Schemas: diff --git a/specification/v0_9/eval/src/prompts.ts b/specification/v0_9/eval/src/prompts.ts index 6a5335041..0027f521a 100644 --- a/specification/v0_9/eval/src/prompts.ts +++ b/specification/v0_9/eval/src/prompts.ts @@ -26,6 +26,11 @@ export const prompts: TestPrompt[] = [ description: "A DeleteSurface message to remove a UI surface.", promptText: `Generate a JSON message containing a deleteSurface for the surface 'dashboard-surface-1'.`, }, + { + name: "deleteComponent", + description: "An updateComponents message to delete a component.", + promptText: `Generate an 'updateComponents' message for surfaceId 'main'. This message should delete the component with ID 'loading-spinner' and update the component with ID 'status-text' to have the text "Loaded".`, + }, { name: "dogBreedGenerator", description: diff --git a/specification/v0_9/eval/src/validator.ts b/specification/v0_9/eval/src/validator.ts index e98aae7d8..9c6765757 100644 --- a/specification/v0_9/eval/src/validator.ts +++ b/specification/v0_9/eval/src/validator.ts @@ -297,6 +297,12 @@ export class Validator { } const componentType = component.component; + + // Handle deletion (tombstone) + if (componentType === null) { + return; + } + if (!componentType || typeof componentType !== "string") { errors.push(`Component '${id}' is missing 'component' property.`); return; diff --git a/specification/v0_9/json/server_to_client.json b/specification/v0_9/json/server_to_client.json index 176136fc2..ae11a8229 100644 --- a/specification/v0_9/json/server_to_client.json +++ b/specification/v0_9/json/server_to_client.json @@ -60,7 +60,26 @@ "description": "A list containing all UI components for the surface.", "minItems": 1, "items": { - "$ref": "standard_catalog.json#/$defs/anyComponent" + "oneOf": [ + { + "$ref": "standard_catalog.json#/$defs/anyComponent" + }, + { + "type": "object", + "description": "A tombstone to remove a component.", + "properties": { + "id": { + "$ref": "common_types.json#/$defs/ComponentId" + }, + "component": { + "const": null, + "description": "Set to null to delete this component." + } + }, + "required": ["id", "component"], + "additionalProperties": false + } + ] } } }, diff --git a/specification/v0_9/test/cases/component_deletion.json b/specification/v0_9/test/cases/component_deletion.json new file mode 100644 index 000000000..a72f3abed --- /dev/null +++ b/specification/v0_9/test/cases/component_deletion.json @@ -0,0 +1,70 @@ +{ + "schema": "server_to_client.json", + "tests": [ + { + "description": "UpdateComponents with component deletion (tombstone)", + "valid": true, + "data": { + "updateComponents": { + "surfaceId": "test_surface", + "components": [ + { + "id": "item_to_delete", + "component": null + } + ] + } + } + }, + { + "description": "UpdateComponents mixing update and deletion", + "valid": true, + "data": { + "updateComponents": { + "surfaceId": "test_surface", + "components": [ + { + "id": "new_item", + "component": "Text", + "text": "Hello" + }, + { + "id": "old_item", + "component": null + } + ] + } + } + }, + { + "description": "Deletion with extra properties (should be invalid)", + "valid": false, + "data": { + "updateComponents": { + "surfaceId": "test_surface", + "components": [ + { + "id": "item_to_delete", + "component": null, + "text": "Should not be here" + } + ] + } + } + }, + { + "description": "Deletion missing ID (should be invalid)", + "valid": false, + "data": { + "updateComponents": { + "surfaceId": "test_surface", + "components": [ + { + "component": null + } + ] + } + } + } + ] +} From 706868a1a81641574b86465b8aa4f4ea2c93972a Mon Sep 17 00:00:00 2001 From: Jacob Simionato Date: Thu, 29 Jan 2026 22:06:05 +1030 Subject: [PATCH 2/2] fix: Address PR feedback --- specification/v0_9/docs/a2ui_protocol.md | 25 ++++++++++++++++--- .../v0_9/eval/src/generation_flow.ts | 2 +- specification/v0_9/eval/src/validator.ts | 6 ++--- specification/v0_9/json/server_to_client.json | 8 ++---- .../v0_9/test/cases/button_checks.json | 4 +-- .../v0_9/test/cases/component_deletion.json | 11 +++----- .../test/cases/contact_form_example.jsonl | 2 +- 7 files changed, 33 insertions(+), 25 deletions(-) diff --git a/specification/v0_9/docs/a2ui_protocol.md b/specification/v0_9/docs/a2ui_protocol.md index 3f9758be5..d3ff9af4b 100644 --- a/specification/v0_9/docs/a2ui_protocol.md +++ b/specification/v0_9/docs/a2ui_protocol.md @@ -148,13 +148,13 @@ This message signals the client to create a new surface and begin rendering it. ### `updateComponents` -This message provides a list of UI components to be added, updated, or removed within a specific surface. The components are provided as a flat list, and their relationships are defined by ID references in an adjacency list. This message may only be sent to a surface that has already been created. +This message provides a list of UI components to be added, updated, or removed within a specific surface. The components are provided as a flat list, and their relationships are defined by ID references in an adjacency list. This message may only be sent to a surface that has already been created. Note that components may reference children or data bindings that do not yet exist; clients should handle this gracefully by rendering placeholders (progressive rendering). **Adding or Updating Components:** To add or update a component, provide the full component definition object. If a component with the same `id` already exists, it will be replaced. **Deleting Components:** -To delete a component, provide an object with the component's `id` and set the `component` property to `null`. This removes the component from the client's memory. +To delete a component, provide an object with the component's `id` and omit the `component` property. This removes the component from the client's memory. **Properties:** @@ -179,8 +179,25 @@ To delete a component, provide an object with the component's `id` and set the ` "text": "John Doe" }, { - "id": "old_component_id", - "component": null + "id": "user_title", + "component": "Text", + "text": "Software Engineer" + } + ] + } +} + +{ + "updateComponents": { + "surfaceId": "user_profile_card", + "components": [ + { + "id": "root", + "component": "Column", + "children": ["user_name"] + }, + { + "id": "user_title" } ] } diff --git a/specification/v0_9/eval/src/generation_flow.ts b/specification/v0_9/eval/src/generation_flow.ts index d369c680f..328487504 100644 --- a/specification/v0_9/eval/src/generation_flow.ts +++ b/specification/v0_9/eval/src/generation_flow.ts @@ -56,7 +56,7 @@ Standard Instructions: 13. Do NOT use a 'style' property. Use standard properties like 'align', 'justify', 'variant', etc. 14. Do NOT invent properties that are not in the schema. Check the 'properties' list for each component type. 15. Use 'checks' property for validation rules if required. -16. To delete a component, set its 'component' property to null (e.g. { "id": "...", "component": null }). +16. To delete a component, output an object with ONLY the 'id' property (omit the 'component' property). ${catalogRules ? `\nInstructions specific to this catalog:\n${catalogRules}` : ""} Schemas: diff --git a/specification/v0_9/eval/src/validator.ts b/specification/v0_9/eval/src/validator.ts index 9c6765757..c78eee535 100644 --- a/specification/v0_9/eval/src/validator.ts +++ b/specification/v0_9/eval/src/validator.ts @@ -299,12 +299,12 @@ export class Validator { const componentType = component.component; // Handle deletion (tombstone) - if (componentType === null) { + if (componentType === undefined || componentType === null) { return; } - if (!componentType || typeof componentType !== "string") { - errors.push(`Component '${id}' is missing 'component' property.`); + if (typeof componentType !== "string") { + errors.push(`Component '${id}' has invalid 'component' property.`); return; } diff --git a/specification/v0_9/json/server_to_client.json b/specification/v0_9/json/server_to_client.json index ae11a8229..170f0ee7a 100644 --- a/specification/v0_9/json/server_to_client.json +++ b/specification/v0_9/json/server_to_client.json @@ -66,17 +66,13 @@ }, { "type": "object", - "description": "A tombstone to remove a component.", + "description": "A tombstone to remove a component. Omit the 'component' property to indicate deletion.", "properties": { "id": { "$ref": "common_types.json#/$defs/ComponentId" - }, - "component": { - "const": null, - "description": "Set to null to delete this component." } }, - "required": ["id", "component"], + "required": ["id"], "additionalProperties": false } ] diff --git a/specification/v0_9/test/cases/button_checks.json b/specification/v0_9/test/cases/button_checks.json index 8e271945b..bfcf5a1b6 100644 --- a/specification/v0_9/test/cases/button_checks.json +++ b/specification/v0_9/test/cases/button_checks.json @@ -125,7 +125,7 @@ "id": "btn_primary", "component": "Button", "child": "txt_primary", - "action": { "name": "submit" }, + "action": { "event": { "name": "submit" } }, "variant": "primary" }, { "id": "txt_primary", "component": "Text", "text": "Submit" } @@ -144,7 +144,7 @@ "id": "btn_borderless", "component": "Button", "child": "txt_borderless", - "action": { "name": "cancel" }, + "action": { "event": { "name": "cancel" } }, "variant": "borderless" }, { "id": "txt_borderless", "component": "Text", "text": "Cancel" } diff --git a/specification/v0_9/test/cases/component_deletion.json b/specification/v0_9/test/cases/component_deletion.json index a72f3abed..d5f077d41 100644 --- a/specification/v0_9/test/cases/component_deletion.json +++ b/specification/v0_9/test/cases/component_deletion.json @@ -9,8 +9,7 @@ "surfaceId": "test_surface", "components": [ { - "id": "item_to_delete", - "component": null + "id": "item_to_delete" } ] } @@ -29,8 +28,7 @@ "text": "Hello" }, { - "id": "old_item", - "component": null + "id": "old_item" } ] } @@ -45,7 +43,6 @@ "components": [ { "id": "item_to_delete", - "component": null, "text": "Should not be here" } ] @@ -59,9 +56,7 @@ "updateComponents": { "surfaceId": "test_surface", "components": [ - { - "component": null - } + {} ] } } diff --git a/specification/v0_9/test/cases/contact_form_example.jsonl b/specification/v0_9/test/cases/contact_form_example.jsonl index a5a03c40e..075714b90 100644 --- a/specification/v0_9/test/cases/contact_form_example.jsonl +++ b/specification/v0_9/test/cases/contact_form_example.jsonl @@ -1,4 +1,4 @@ {"createSurface":{"surfaceId":"contact_form_1","catalogId":"https://a2ui.dev/specification/v0_9/standard_catalog.json"}} -{"updateComponents":{"surfaceId":"contact_form_1","components":[{"id":"root","component":"Card","child":"form_container"},{"id":"form_container","component":"Column","children":["header_row","name_row","email_group","phone_group","pref_group","divider_1","newsletter_checkbox","submit_button"],"justify":"start","align":"stretch"},{"id":"header_row","component":"Row","children":["header_icon","header_text"],"align":"center"},{"id":"header_icon","component":"Icon","name":"mail"},{"id":"header_text","component":"Text","text":"# Contact Us","variant":"h2"},{"id":"name_row","component":"Row","children":["first_name_group","last_name_group"],"justify":"spaceBetween"},{"id":"first_name_group","component":"Column","children":["first_name_label","first_name_field"],"weight":1},{"id":"first_name_label","component":"Text","text":"First Name","variant":"caption"},{"id":"first_name_field","component":"TextField","label":"First Name","value":{"path":"/contact/firstName"},"variant":"shortText"},{"id":"last_name_group","component":"Column","children":["last_name_label","last_name_field"],"weight":1},{"id":"last_name_label","component":"Text","text":"Last Name","variant":"caption"},{"id":"last_name_field","component":"TextField","label":"Last Name","value":{"path":"/contact/lastName"},"variant":"shortText"},{"id":"email_group","component":"Column","children":["email_label","email_field"]},{"id":"email_label","component":"Text","text":"Email Address","variant":"caption"},{"id":"email_field","component":"TextField","label":"Email","value":{"path":"/contact/email"},"variant":"shortText","checks":[{"call":"required","args":[{"path":"/contact/email"}],"message":"Email is required."},{"call":"email","args":[{"path":"/contact/email"}],"message":"Please enter a valid email address."}]},{"id":"phone_group","component":"Column","children":["phone_label","phone_field"]},{"id":"phone_label","component":"Text","text":"Phone Number","variant":"caption"},{"id":"phone_field","component":"TextField","label":"Phone","value":{"path":"/contact/phone"},"variant":"shortText","checks":[{"call":"regex","args":[{"path":"/contact/phone"},"^\\d{10}$\\"],"message":"Phone number must be 10 digits."}]},{"id":"pref_group","component":"Column","children":["pref_label","pref_picker"]},{"id":"pref_label","component":"Text","text":"Preferred Contact Method","variant":"caption"},{"id":"pref_picker","component":"ChoicePicker","variant":"mutuallyExclusive","options":[{"label":"Email","value":"email"},{"label":"Phone","value":"phone"},{"label":"SMS","value":"sms"}],"value":{"path":"/contact/preference"}},{"id":"divider_1","component":"Divider","axis":"horizontal"},{"id":"newsletter_checkbox","component":"CheckBox","label":"Subscribe to our newsletter","value":{"path":"/contact/subscribe"}},{"id":"submit_button_label","component":"Text","text":"Send Message"},{"id":"submit_button","component":"Button","child":"submit_button_label","variant":"primary","action":{"name":"submitContactForm","context":{"formId":"contact_form_1","clientTime":{"call":"now","returnType":"string"},"isNewsletterSubscribed":{"path":"/contact/subscribe"}}}}}]}} +{"updateComponents": {"surfaceId": "contact_form_1", "components": [{"id": "root", "component": "Card", "child": "form_container"}, {"id": "form_container", "component": "Column", "children": ["header_row", "name_row", "email_group", "phone_group", "pref_group", "divider_1", "newsletter_checkbox", "submit_button"], "justify": "start", "align": "stretch"}, {"id": "header_row", "component": "Row", "children": ["header_icon", "header_text"], "align": "center"}, {"id": "header_icon", "component": "Icon", "name": "mail"}, {"id": "header_text", "component": "Text", "text": "# Contact Us", "variant": "h2"}, {"id": "name_row", "component": "Row", "children": ["first_name_group", "last_name_group"], "justify": "spaceBetween"}, {"id": "first_name_group", "component": "Column", "children": ["first_name_label", "first_name_field"], "weight": 1}, {"id": "first_name_label", "component": "Text", "text": "First Name", "variant": "caption"}, {"id": "first_name_field", "component": "TextField", "label": "First Name", "value": {"path": "/contact/firstName"}, "variant": "shortText"}, {"id": "last_name_group", "component": "Column", "children": ["last_name_label", "last_name_field"], "weight": 1}, {"id": "last_name_label", "component": "Text", "text": "Last Name", "variant": "caption"}, {"id": "last_name_field", "component": "TextField", "label": "Last Name", "value": {"path": "/contact/lastName"}, "variant": "shortText"}, {"id": "email_group", "component": "Column", "children": ["email_label", "email_field"]}, {"id": "email_label", "component": "Text", "text": "Email Address", "variant": "caption"}, {"id": "email_field", "component": "TextField", "label": "Email", "value": {"path": "/contact/email"}, "variant": "shortText", "checks": [{"call": "required", "args": [{"path": "/contact/email"}], "message": "Email is required."}, {"call": "email", "args": [{"path": "/contact/email"}], "message": "Please enter a valid email address."}]}, {"id": "phone_group", "component": "Column", "children": ["phone_label", "phone_field"]}, {"id": "phone_label", "component": "Text", "text": "Phone Number", "variant": "caption"}, {"id": "phone_field", "component": "TextField", "label": "Phone", "value": {"path": "/contact/phone"}, "variant": "shortText", "checks": [{"call": "regex", "args": [{"path": "/contact/phone"}, "^\\d{10}$"], "message": "Phone number must be 10 digits."}]}, {"id": "pref_group", "component": "Column", "children": ["pref_label", "pref_picker"]}, {"id": "pref_label", "component": "Text", "text": "Preferred Contact Method", "variant": "caption"}, {"id": "pref_picker", "component": "ChoicePicker", "variant": "mutuallyExclusive", "options": [{"label": "Email", "value": "email"}, {"label": "Phone", "value": "phone"}, {"label": "SMS", "value": "sms"}], "value": {"path": "/contact/preference"}}, {"id": "divider_1", "component": "Divider", "axis": "horizontal"}, {"id": "newsletter_checkbox", "component": "CheckBox", "label": "Subscribe to our newsletter", "value": {"path": "/contact/subscribe"}}, {"id": "submit_button_label", "component": "Text", "text": "Send Message"}, {"id": "submit_button", "component": "Button", "child": "submit_button_label", "variant": "primary", "action": {"event": {"name": "submitContactForm", "context": {"formId": "contact_form_1", "clientTime": {"call": "now", "returnType": "string"}, "isNewsletterSubscribed": {"path": "/contact/subscribe"}}}}}]}} {"updateDataModel":{"surfaceId":"contact_form_1","path":"/contact","value":{"firstName":"John","lastName":"Doe","email":"john.doe@example.com","phone":"1234567890","preference":["email"],"subscribe":true}}} {"deleteSurface":{"surfaceId":"contact_form_1"}} \ No newline at end of file