From 1f8d8b9e98e7bddaafc01e9b4fa0421aeaeb6277 Mon Sep 17 00:00:00 2001 From: Polina Cherkasova Date: Wed, 26 Aug 2026 14:31:51 -0700 Subject: [PATCH 01/22] Define the Dart a2ui_agent API and its tests, limited to protocol v0.9 Implements the API surface described by blueprints/modules/a2ui_agent.blueprint.md for the Dart agent SDK, moves the pieces that belong to a2ui_core into a2ui_core, and moves shared test data into conformance/ so every SDK is measured against one dataset. Most of the agent API throws UnimplementedError; the mechanical parts are implemented. Tests describe the intended behaviour of everything still stubbed and are marked skip: with the reason, so `dart test` doubles as the implementation checklist. a2ui_core - Catalog now takes two type parameters, Catalog, so agents can hold schema-only functions. Breaking. - Adds A2uiProtocolVersion, Catalog.fromJson/catalogSchema/copyWith, A2uiRendererCapabilities, A2uiValidator and the conformance error categories. - Fixes DataModel.set silently dropping a write through a primitive. conformance/ - New core/data_model.yaml, migrated from web_core's data-model.test.ts. - New agent/request_processor.yaml for the blueprint's primary use case. - Basic-catalog cases added to core/catalog.yaml, agent/parser.yaml and agent/inference_format.yaml, referencing the published spec catalog by path. renderers/web_core - data-model.test.ts becomes a harness over the shared dataset; JavaScript specific behaviour stays local and is documented on both sides. CI - Adds a dart_packages job so the Dart packages are formatted, analyzed with --fatal-infos, and tested. --- .github/workflows/flutter_packages_test.yml | 25 + conformance/README.md | 8 + conformance/agent/inference_format.yaml | 58 ++ conformance/agent/parser.yaml | 58 ++ conformance/agent/request_processor.yaml | 245 +++++++ conformance/conformance_schema.json | 116 +++- conformance/core/catalog.yaml | 95 +++ conformance/core/data_model.yaml | 627 ++++++++++++++++++ dart/a2ui_agent/CHANGELOG.md | 14 + dart/a2ui_agent/README.md | 82 ++- .../example/a2ui_agent_example.dart | 76 ++- dart/a2ui_agent/lib/a2ui_agent.dart | 40 +- .../src/catalog_transformers/base.dart} | 26 +- .../lib/src/catalog_transformers/pruning.dart | 59 ++ dart/a2ui_agent/lib/src/inference_format.dart | 44 ++ .../direct_json/constants.dart | 41 ++ .../inference_formats/direct_json/format.dart | 65 ++ .../inference_formats/direct_json/parser.dart | 83 +++ .../direct_json/prompt_generator.dart | 41 ++ .../direct_json/streaming.dart | 57 ++ .../inference_formats/express/compiler.dart | 35 + .../express/constants.dart} | 8 +- .../inference_formats/express/decompiler.dart | 28 + .../src/inference_formats/express/format.dart | 51 ++ .../src/inference_formats/express/parser.dart | 71 ++ .../express/prompt_generator.dart | 32 + dart/a2ui_agent/lib/src/parser/parser.dart | 79 +++ .../lib/src/parser/response_part.dart | 121 ++++ .../lib/src/processor/catalog_config.dart | 63 ++ .../lib/src/processor/catalog_providers.dart | 110 +++ .../lib/src/processor/generator.dart | 73 ++ .../lib/src/processor/processor.dart | 77 +++ dart/a2ui_agent/lib/src/prompt/generator.dart | 35 + .../lib/src/utils/catalog_resolver.dart | 37 ++ dart/a2ui_agent/pubspec.yaml | 7 +- .../test/catalog_transformers_test.dart | 178 +++++ .../conformance/agent_conformance_test.dart | 177 +++++ .../test/conformance/conformance_harness.dart | 100 +++ .../test/e2e/primary_use_case_test.dart | 249 +++++++ .../direct_json_format_test.dart | 231 +++++++ .../direct_json_parser_test.dart | 242 +++++++ .../direct_json_streaming_test.dart | 156 +++++ .../test/inference_formats/express_test.dart | 220 ++++++ dart/a2ui_agent/test/parser/parser_test.dart | 131 ++++ .../test/parser/response_part_test.dart | 136 ++++ .../test/processor/catalog_config_test.dart | 123 ++++ .../processor/catalog_providers_test.dart | 143 ++++ .../test/processor/generator_test.dart | 261 ++++++++ .../test/processor/processor_test.dart | 272 ++++++++ dart/a2ui_agent/test/test_catalogs.dart | 91 +++ .../test/utils/catalog_resolver_test.dart | 179 +++++ dart/a2ui_core/CHANGELOG.md | 23 + dart/a2ui_core/lib/a2ui_core.dart | 9 + dart/a2ui_core/lib/src/core/catalog.dart | 347 +++++++++- dart/a2ui_core/lib/src/core/contexts.dart | 3 +- dart/a2ui_core/lib/src/core/data_model.dart | 9 + dart/a2ui_core/lib/src/core/messages.dart | 28 +- .../lib/src/core/minimal_catalog.dart | 2 +- .../lib/src/core/renderer_capabilities.dart | 147 ++++ .../a2ui_core/lib/src/core/surface_model.dart | 2 +- dart/a2ui_core/lib/src/primitives/errors.dart | 54 ++ .../lib/src/primitives/protocol_version.dart | 66 ++ .../lib/src/primitives/reactivity.dart | 1 + .../lib/src/processing/processor.dart | 8 +- .../lib/src/validation/validator.dart | 120 ++++ dart/a2ui_core/pubspec.yaml | 4 +- dart/a2ui_core/test/catalog_json_test.dart | 258 +++++++ .../test/conformance/conformance_harness.dart | 90 +++ .../data_model_conformance_test.dart | 210 ++++++ .../a2ui_core/test/protocol_version_test.dart | 83 +++ .../test/renderer_capabilities_test.dart | 135 ++++ dart/a2ui_core/test/validator_test.dart | 289 ++++++++ renderers/web_core/package.json | 3 +- .../web_core/src/v0_9/conformance/harness.ts | 63 ++ .../src/v0_9/state/data-model.test.ts | 504 ++++++-------- yarn.lock | 1 + 76 files changed, 7682 insertions(+), 353 deletions(-) create mode 100644 conformance/agent/request_processor.yaml create mode 100644 conformance/core/data_model.yaml rename dart/a2ui_agent/{test/a2ui_agent_test.dart => lib/src/catalog_transformers/base.dart} (54%) create mode 100644 dart/a2ui_agent/lib/src/catalog_transformers/pruning.dart create mode 100644 dart/a2ui_agent/lib/src/inference_format.dart create mode 100644 dart/a2ui_agent/lib/src/inference_formats/direct_json/constants.dart create mode 100644 dart/a2ui_agent/lib/src/inference_formats/direct_json/format.dart create mode 100644 dart/a2ui_agent/lib/src/inference_formats/direct_json/parser.dart create mode 100644 dart/a2ui_agent/lib/src/inference_formats/direct_json/prompt_generator.dart create mode 100644 dart/a2ui_agent/lib/src/inference_formats/direct_json/streaming.dart create mode 100644 dart/a2ui_agent/lib/src/inference_formats/express/compiler.dart rename dart/a2ui_agent/lib/src/{a2ui_agent_base.dart => inference_formats/express/constants.dart} (72%) create mode 100644 dart/a2ui_agent/lib/src/inference_formats/express/decompiler.dart create mode 100644 dart/a2ui_agent/lib/src/inference_formats/express/format.dart create mode 100644 dart/a2ui_agent/lib/src/inference_formats/express/parser.dart create mode 100644 dart/a2ui_agent/lib/src/inference_formats/express/prompt_generator.dart create mode 100644 dart/a2ui_agent/lib/src/parser/parser.dart create mode 100644 dart/a2ui_agent/lib/src/parser/response_part.dart create mode 100644 dart/a2ui_agent/lib/src/processor/catalog_config.dart create mode 100644 dart/a2ui_agent/lib/src/processor/catalog_providers.dart create mode 100644 dart/a2ui_agent/lib/src/processor/generator.dart create mode 100644 dart/a2ui_agent/lib/src/processor/processor.dart create mode 100644 dart/a2ui_agent/lib/src/prompt/generator.dart create mode 100644 dart/a2ui_agent/lib/src/utils/catalog_resolver.dart create mode 100644 dart/a2ui_agent/test/catalog_transformers_test.dart create mode 100644 dart/a2ui_agent/test/conformance/agent_conformance_test.dart create mode 100644 dart/a2ui_agent/test/conformance/conformance_harness.dart create mode 100644 dart/a2ui_agent/test/e2e/primary_use_case_test.dart create mode 100644 dart/a2ui_agent/test/inference_formats/direct_json_format_test.dart create mode 100644 dart/a2ui_agent/test/inference_formats/direct_json_parser_test.dart create mode 100644 dart/a2ui_agent/test/inference_formats/direct_json_streaming_test.dart create mode 100644 dart/a2ui_agent/test/inference_formats/express_test.dart create mode 100644 dart/a2ui_agent/test/parser/parser_test.dart create mode 100644 dart/a2ui_agent/test/parser/response_part_test.dart create mode 100644 dart/a2ui_agent/test/processor/catalog_config_test.dart create mode 100644 dart/a2ui_agent/test/processor/catalog_providers_test.dart create mode 100644 dart/a2ui_agent/test/processor/generator_test.dart create mode 100644 dart/a2ui_agent/test/processor/processor_test.dart create mode 100644 dart/a2ui_agent/test/test_catalogs.dart create mode 100644 dart/a2ui_agent/test/utils/catalog_resolver_test.dart create mode 100644 dart/a2ui_core/lib/src/core/renderer_capabilities.dart create mode 100644 dart/a2ui_core/lib/src/primitives/protocol_version.dart create mode 100644 dart/a2ui_core/lib/src/validation/validator.dart create mode 100644 dart/a2ui_core/test/catalog_json_test.dart create mode 100644 dart/a2ui_core/test/conformance/conformance_harness.dart create mode 100644 dart/a2ui_core/test/conformance/data_model_conformance_test.dart create mode 100644 dart/a2ui_core/test/protocol_version_test.dart create mode 100644 dart/a2ui_core/test/renderer_capabilities_test.dart create mode 100644 dart/a2ui_core/test/validator_test.dart create mode 100644 renderers/web_core/src/v0_9/conformance/harness.ts diff --git a/.github/workflows/flutter_packages_test.yml b/.github/workflows/flutter_packages_test.yml index 379fb01657..3755671069 100644 --- a/.github/workflows/flutter_packages_test.yml +++ b/.github/workflows/flutter_packages_test.yml @@ -85,6 +85,31 @@ jobs: (cd "$dir" && dart pub get && layerlens --fail-on-cycles --except "lib/src/schema" --except "lib/src/core") done + dart_packages: + name: dart/${{ matrix.package }} + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + package: [a2ui_core, a2ui_agent] + steps: + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + persist-credentials: false + - uses: ./.github/actions/setup-dart + - name: Install dependencies + working-directory: dart/${{ matrix.package }} + run: dart pub get + - name: Check formatting + working-directory: dart/${{ matrix.package }} + run: dart format --output=none --set-exit-if-changed . + - name: Analyze code + working-directory: dart/${{ matrix.package }} + run: dart analyze --fatal-infos + - name: Run tests + working-directory: dart/${{ matrix.package }} + run: dart test --test-randomize-ordering-seed=random + analyze_and_test: needs: matrix name: ${{ matrix.package.name }} (${{ matrix.flutter_version }}) diff --git a/conformance/README.md b/conformance/README.md index e2d40d5a2a..146fa62cd8 100644 --- a/conformance/README.md +++ b/conformance/README.md @@ -11,12 +11,14 @@ Test suites are organized by functional domain: - `core/catalog.yaml`: Contains test cases for catalog operations (prune, render, load). - `core/accessibility.yaml`: Contains test cases for accessibility attributes and checks. - `core/validator.yaml`: Contains test cases for schema and structural validators, verifying structural integrity, cycle detection, and reachability. +- `core/data_model.yaml`: Contains test cases for the reactive data model: JSON Pointer resolution, structural auto-vivification, and observer notification routing. ### Agent (`agent/`) - `agent/streaming_parser.yaml`: Contains test cases for streaming parser implementations, verifying chunk buffering, incremental yielding, and edge cases like cut tokens. - `agent/parser.yaml`: Contains test cases for non-streaming parsing and payload fixing. - `agent/inference_format.yaml`: Contains test cases for inference formats and schema managers (select_catalog, load_catalog, generate_prompt). +- `agent/request_processor.yaml`: Contains end-to-end test cases for the agent turn described in `blueprints/modules/a2ui_agent.blueprint.md`: negotiate catalogs against renderer capabilities, render the system prompt snippet, and parse a full model response into deliverable A2UI messages. ### Extensions (`extensions/`) @@ -25,8 +27,14 @@ Test suites are organized by functional domain: All static test data and simplified schemas are located in the `test_data/` directory. +Cases may also reference published specification artifacts by relative path, for example `"../specification/v0_9_1/catalogs/basic/catalog.json"`. Path-valued fields such as `catalog_schema`, `s2c_schema` and `catalog_configs[].path` are resolved relative to this `conformance/` directory. Referencing the specification directly, rather than copying it here, keeps suites measured against the published contract instead of a snapshot that can drift. + `conformance_schema.json` at the root is the JSON schema that validates the structure of the YAML test files themselves. +## Scope of a shared dataset + +A suite in this directory is a contract every implementation must satisfy, so it holds only behaviour that can hold across languages. Behaviour that is genuinely language specific stays in the owning package's own tests, with a note pointing back here. `core/data_model.yaml`, migrated from `renderers/web_core/src/v0_9/state/data-model.test.ts`, documents the exclusions it made and why. + ## Usage in SDKs Each language SDK must implement a test harness that: diff --git a/conformance/agent/inference_format.yaml b/conformance/agent/inference_format.yaml index 573db42f84..52708d95f0 100644 --- a/conformance/agent/inference_format.yaml +++ b/conformance/agent/inference_format.yaml @@ -326,3 +326,61 @@ - "### Catalog Schema:" - '"Text": {' - "---END A2UI JSON SCHEMA---" + +# --- Basic catalog loading and negotiation (v0.9) --- +# +# These cases load the published basic catalog schema from +# `specification/v0_9_1/catalogs/basic/catalog.json` rather than any SDK's own +# catalog, so implementations are measured against the same document. Paths are +# resolved relative to the `conformance/` directory. + +- name: test_load_basic_catalog_v0_9 + description: Loads the published v0.9 basic catalog document from disk. + action: load_catalog + catalog_configs: + - name: basic + path: "../specification/v0_9_1/catalogs/basic/catalog.json" + expect: + supported_catalog_ids: ["https://a2ui.org/specification/v0_9/catalogs/basic/catalog.json"] + +- name: test_select_basic_catalog_by_id_v0_9 + description: A renderer that declares the basic catalog id negotiates to it. + action: select_catalog + args: + supported_catalogs: + - catalogId: "https://a2ui.org/specification/v0_9/catalogs/basic/catalog.json" + components: {} + - catalogId: id_custom + components: {} + client_capabilities: + supportedCatalogIds: ["https://a2ui.org/specification/v0_9/catalogs/basic/catalog.json"] + expect_selected: "https://a2ui.org/specification/v0_9/catalogs/basic/catalog.json" + +- name: test_select_catalog_no_overlap_v0_9 + description: A renderer that supports no registered catalog cannot be served. + action: select_catalog + args: + supported_catalogs: + - catalogId: "https://a2ui.org/specification/v0_9/catalogs/basic/catalog.json" + components: {} + client_capabilities: + supportedCatalogIds: ["https://example.com/catalogs/unknown.json"] + expect_error: + category: "CatalogError" + message: "no matching catalog" + +- name: test_select_catalog_rejects_unsupported_protocol_version + description: >- + Capabilities that carry no entry for a supported protocol version are + rejected rather than silently defaulting. + action: select_catalog + args: + supported_catalogs: + - catalogId: "https://a2ui.org/specification/v0_9/catalogs/basic/catalog.json" + components: {} + client_capabilities_versioned: + "v1.0": + supportedCatalogIds: ["https://a2ui.org/specification/v0_9/catalogs/basic/catalog.json"] + expect_error: + category: "ValidationError" + message: "v0.9" diff --git a/conformance/agent/parser.yaml b/conformance/agent/parser.yaml index ff2ea5bbea..b453e2dfe4 100644 --- a/conformance/agent/parser.yaml +++ b/conformance/agent/parser.yaml @@ -147,3 +147,61 @@ action: fix_payload input: '[{"a": [1, 2, 3,]}]' expect: [{"a": [1, 2, 3]}] + +# --- Basic catalog payloads (v0.9) --- + +- name: test_parse_response_v0_9_basic_catalog_surface + description: Parses a v0.9 payload that creates a surface against the basic catalog. + catalog: + version: "0.9" + catalog_schema: "../specification/v0_9_1/catalogs/basic/catalog.json" + action: parse_full + input: >- + Here you go.[{"version": "v0.9", "createSurface": {"surfaceId": + "s1", "catalogId": + "https://a2ui.org/specification/v0_9/catalogs/basic/catalog.json"}}] + expect: + - text: "Here you go." + a2ui: + - version: "v0.9" + createSurface: + surfaceId: "s1" + catalogId: "https://a2ui.org/specification/v0_9/catalogs/basic/catalog.json" + +- name: test_parse_response_rejects_v1_0_message + description: A payload message declaring v1.0 is rejected by a v0.9 implementation. + catalog: + version: "0.9" + catalog_schema: "../specification/v0_9_1/catalogs/basic/catalog.json" + action: parse_full + input: >- + [{"version": "v1.0", "createSurface": {"surfaceId": "s1", + "catalogId": + "https://a2ui.org/specification/v0_9/catalogs/basic/catalog.json"}}] + expect_error: + category: "ValidationError" + message: "v1.0" + +- name: test_parse_response_rejects_message_without_version + description: A payload message that omits the version field is rejected. + catalog: + version: "0.9" + catalog_schema: "../specification/v0_9_1/catalogs/basic/catalog.json" + action: parse_full + input: >- + [{"createSurface": {"surfaceId": "s1", "catalogId": + "https://a2ui.org/specification/v0_9/catalogs/basic/catalog.json"}}] + expect_error: + category: "ValidationError" + message: "version" + +- name: test_parse_response_rejects_unknown_message_type + description: A payload envelope that names no known message body is rejected. + catalog: + version: "0.9" + catalog_schema: "../specification/v0_9_1/catalogs/basic/catalog.json" + action: parse_full + input: '[{"version": "v0.9", "notAMessage": {}}]' + expect_error: + category: "ValidationError" + message: "Unknown A2UI message type" diff --git a/conformance/agent/request_processor.yaml b/conformance/agent/request_processor.yaml new file mode 100644 index 0000000000..80a98dc505 --- /dev/null +++ b/conformance/agent/request_processor.yaml @@ -0,0 +1,245 @@ +# Copyright 2024 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. + +# The primary use case from `blueprints/modules/a2ui_agent.blueprint.md` section +# 5: an agent registers the catalogs it supports, negotiates them against a +# renderer's declared capabilities, renders a system prompt snippet, and parses +# the resulting model response into deliverable A2UI messages. +# +# These cases run against the published basic catalog schema +# (`specification/v0_9_1/catalogs/basic/catalog.json`) rather than any single +# SDK's built-in catalog, so every implementation is measured against the same +# contract. + +- name: test_primary_use_case_basic_catalog_login_form + description: >- + End to end agent turn against the basic catalog: negotiate catalogs, render + the prompt snippet, parse a full model response into text and A2UI parts. + catalog: + version: "0.9" + catalog_schema: "../specification/v0_9_1/catalogs/basic/catalog.json" + action: process_request + args: + client_capabilities: + "v0.9": + supportedCatalogIds: ["https://a2ui.org/specification/v0_9/catalogs/basic/catalog.json"] + allowed_components: [Card, Column, Text, TextField, Button] + allowed_functions: [required, email] + llm_response: |- + Here is the sign-in form you asked for. + + [ + { + "version": "v0.9", + "createSurface": { + "surfaceId": "login-surface", + "catalogId": "https://a2ui.org/specification/v0_9/catalogs/basic/catalog.json", + "sendDataModel": true + } + }, + { + "version": "v0.9", + "updateComponents": { + "surfaceId": "login-surface", + "components": [ + { + "id": "root", + "component": "Card", + "child": "form-column" + }, + { + "id": "form-column", + "component": "Column", + "children": [ + "title", + "email-field", + "submit-button" + ] + }, + { + "id": "title", + "component": "Text", + "text": "Welcome back", + "variant": "h2" + }, + { + "id": "email-field", + "component": "TextField", + "value": { + "path": "/email" + }, + "label": "Email", + "checks": [ + { + "condition": { + "call": "required", + "args": { + "value": { + "path": "/email" + } + } + }, + "message": "Email is required" + } + ] + }, + { + "id": "submit-label", + "component": "Text", + "text": "Sign in" + }, + { + "id": "submit-button", + "component": "Button", + "child": "submit-label", + "variant": "primary", + "action": { + "event": { + "name": "submitLogin", + "context": { + "form": "login" + } + } + } + } + ] + } + }, + { + "version": "v0.9", + "updateDataModel": { + "surfaceId": "login-surface", + "path": "/email", + "value": "" + } + } + ] + + Let me know if you would like a password field as well. + expect_active_catalog_ids: ["https://a2ui.org/specification/v0_9/catalogs/basic/catalog.json"] + expect_prompt_contains: + - "" + - '"Card"' + - '"TextField"' + - '"required"' + expect: + - text: "Here is the sign-in form you asked for." + a2ui: + [ + { + "version": "v0.9", + "createSurface": + { + "surfaceId": "login-surface", + "catalogId": "https://a2ui.org/specification/v0_9/catalogs/basic/catalog.json", + "sendDataModel": true, + }, + }, + { + "version": "v0.9", + "updateComponents": + { + "surfaceId": "login-surface", + "components": + [ + {"id": "root", "component": "Card", "child": "form-column"}, + { + "id": "form-column", + "component": "Column", + "children": ["title", "email-field", "submit-button"], + }, + {"id": "title", "component": "Text", "text": "Welcome back", "variant": "h2"}, + { + "id": "email-field", + "component": "TextField", + "value": {"path": "/email"}, + "label": "Email", + "checks": + [ + { + "condition": + {"call": "required", "args": {"value": {"path": "/email"}}}, + "message": "Email is required", + }, + ], + }, + {"id": "submit-label", "component": "Text", "text": "Sign in"}, + { + "id": "submit-button", + "component": "Button", + "child": "submit-label", + "variant": "primary", + "action": {"event": {"name": "submitLogin", "context": {"form": "login"}}}, + }, + ], + }, + }, + { + "version": "v0.9", + "updateDataModel": {"surfaceId": "login-surface", "path": "/email", "value": ""}, + }, + ] + - text: "Let me know if you would like a password field as well." + +- name: test_primary_use_case_rejects_unsupported_version + description: >- + A model response that declares a protocol version other than v0.9 is + rejected rather than delivered to the renderer. + catalog: + version: "0.9" + catalog_schema: "../specification/v0_9_1/catalogs/basic/catalog.json" + action: process_request + args: + client_capabilities: + "v0.9": + supportedCatalogIds: ["https://a2ui.org/specification/v0_9/catalogs/basic/catalog.json"] + llm_response: |- + [{"version": "v1.0", "createSurface": {"surfaceId": "s1", "catalogId": "https://a2ui.org/specification/v0_9/catalogs/basic/catalog.json"}}] + expect_error: + category: "ValidationError" + message: "v1.0" + +- name: test_primary_use_case_rejects_missing_version + description: >- + A model response whose messages omit the version field is rejected. + catalog: + version: "0.9" + catalog_schema: "../specification/v0_9_1/catalogs/basic/catalog.json" + action: process_request + args: + client_capabilities: + "v0.9": + supportedCatalogIds: ["https://a2ui.org/specification/v0_9/catalogs/basic/catalog.json"] + llm_response: |- + [{"createSurface": {"surfaceId": "s1", "catalogId": "https://a2ui.org/specification/v0_9/catalogs/basic/catalog.json"}}] + expect_error: + category: "ValidationError" + message: "version" + +- name: test_primary_use_case_rejects_unknown_catalog + description: >- + A renderer that supports no catalog the agent has registered cannot be + served. + catalog: + version: "0.9" + catalog_schema: "../specification/v0_9_1/catalogs/basic/catalog.json" + action: process_request + args: + client_capabilities: + "v0.9": + supportedCatalogIds: ["https://example.com/catalogs/unknown.json"] + llm_response: "unused" + expect_error: + category: "CatalogError" + message: "no matching catalog" diff --git a/conformance/conformance_schema.json b/conformance/conformance_schema.json index 1663fb6e4f..7b85685b8d 100644 --- a/conformance/conformance_schema.json +++ b/conformance/conformance_schema.json @@ -91,7 +91,9 @@ "try_activate", "select_newest", "verify_cuttable_keys", - "accessibility_check" + "accessibility_check", + "data_model", + "process_request" ] } }, @@ -121,7 +123,9 @@ {"$ref": "#/$defs/TryActivateTest"}, {"$ref": "#/$defs/SelectNewestTest"}, {"$ref": "#/$defs/VerifyCuttableKeysTest"}, - {"$ref": "#/$defs/AccessibilityCheckTest"} + {"$ref": "#/$defs/AccessibilityCheckTest"}, + {"$ref": "#/$defs/DataModelTest"}, + {"$ref": "#/$defs/ProcessRequestTest"} ] } ] @@ -483,6 +487,111 @@ }, "required": ["action", "surface", "assertions"] }, + "DataModelTest": { + "type": "object", + "properties": { + "action": {"const": "data_model"}, + "initial": { + "description": "Initial contents of the data model. Defaults to an empty object." + }, + "watch": { + "type": "array", + "description": "Paths observed for the duration of the case. A path may repeat to attach more than one observer.", + "items": {"type": "string"} + }, + "steps": { + "type": "array", + "description": "Operations applied to the model, in order.", + "items": { + "type": "object", + "properties": { + "op": { + "type": "string", + "description": "The operation to apply.", + "enum": ["get", "set", "delete", "dispose"] + }, + "path": { + "type": "string", + "description": "JSON Pointer the operation targets. Required for every op except dispose." + }, + "value": {"description": "Value to write. Only meaningful for set."}, + "expect": { + "description": "Value the path is expected to hold. Only meaningful for get." + }, + "expect_absent": { + "type": "boolean", + "description": "Whether the path is expected to hold no value. Only meaningful for get." + }, + "expect_type": { + "type": "string", + "enum": ["list", "object"], + "description": "Structural type the path is expected to hold. Only meaningful for get." + }, + "expect_values": { + "type": "object", + "description": "Value each watched path holds after this step, keyed by path." + }, + "expect_notified": { + "type": "array", + "items": {"type": "string"}, + "description": "Watched paths whose observers fired for this step, one entry per observer that fired." + }, + "expect_error": {"$ref": "#/$defs/ExpectError"} + }, + "required": ["op"] + } + } + }, + "required": ["steps"] + }, + "ProcessRequestTest": { + "type": "object", + "properties": { + "action": {"const": "process_request"}, + "args": { + "type": "object", + "description": "Inputs to one agent turn.", + "properties": { + "client_capabilities": { + "type": "object", + "description": "The a2uiClientCapabilities object the renderer sent." + }, + "allowed_components": { + "type": "array", + "items": {"type": "string"}, + "description": "Component allowlist applied to the registered catalogs." + }, + "allowed_functions": { + "type": "array", + "items": {"type": "string"}, + "description": "Function allowlist applied to the registered catalogs." + }, + "accepts_inline_catalogs": { + "type": "boolean", + "description": "Whether the agent accepts catalogs supplied inline by the renderer." + }, + "llm_response": { + "type": "string", + "description": "The complete raw model response for the turn." + } + }, + "required": ["client_capabilities", "llm_response"] + }, + "expect_active_catalog_ids": { + "type": "array", + "items": {"type": "string"}, + "description": "Catalog ids negotiated for the turn." + }, + "expect_prompt_contains": { + "type": "array", + "items": {"type": "string"}, + "description": "Substrings the generated system prompt snippet must contain." + }, + "expect": {"type": "array", "description": "Expected response parts, in order."}, + "expect_error": {"$ref": "#/$defs/ExpectError"} + }, + "required": ["args"] + }, "ExpectError": { "oneOf": [ { @@ -500,7 +609,8 @@ "CatalogError", "IntegrityError", "RecursionError", - "CompileError" + "CompileError", + "DataError" ] }, "message": { diff --git a/conformance/core/catalog.yaml b/conformance/core/catalog.yaml index 92f75311fa..e058d2771c 100644 --- a/conformance/core/catalog.yaml +++ b/conformance/core/catalog.yaml @@ -475,3 +475,98 @@ action: verify_cuttable_keys expect: custom_cuttable_keys: ["customKey1", "customKey2"] + +# --- Basic catalog pruning (v0.9) --- +# +# The `$defs/anyComponent` and `$defs/anyFunction` unions of a catalog document +# must be narrowed alongside the entries they reference, otherwise a pruned +# catalog still advertises components the agent may not emit. The ref shapes +# here match `specification/v0_9_1/catalogs/basic/catalog.json`. + +- name: test_prune_components_narrows_any_component_union + description: Pruning components narrows the anyComponent union to the kept components. + catalog: + version: "0.9" + catalog_schema: + catalogId: "https://a2ui.org/specification/v0_9/catalogs/basic/catalog.json" + components: + Text: {type: object} + Card: {type: object} + Button: {type: object} + Video: {type: object} + $defs: + anyComponent: + oneOf: + - $ref: "#/components/Text" + - $ref: "#/components/Card" + - $ref: "#/components/Button" + - $ref: "#/components/Video" + action: prune + args: + allowed_components: [Text, Card, Button] + expect: + catalog_schema: + catalogId: "https://a2ui.org/specification/v0_9/catalogs/basic/catalog.json" + components: + Text: {type: object} + Card: {type: object} + Button: {type: object} + $defs: + anyComponent: + oneOf: + - $ref: "#/components/Text" + - $ref: "#/components/Card" + - $ref: "#/components/Button" + +- name: test_prune_functions_narrows_any_function_union + description: Pruning functions narrows the anyFunction union to the kept functions. + catalog: + version: "0.9" + catalog_schema: + catalogId: "https://a2ui.org/specification/v0_9/catalogs/basic/catalog.json" + components: + Text: {type: object} + functions: + required: {type: object} + email: {type: object} + openUrl: {type: object} + $defs: + anyFunction: + oneOf: + - $ref: "#/functions/required" + - $ref: "#/functions/email" + - $ref: "#/functions/openUrl" + action: prune + args: + allowed_functions: [required, email] + expect: + catalog_schema: + catalogId: "https://a2ui.org/specification/v0_9/catalogs/basic/catalog.json" + components: + Text: {type: object} + functions: + required: {type: object} + email: {type: object} + $defs: + anyFunction: + oneOf: + - $ref: "#/functions/required" + - $ref: "#/functions/email" + +- name: test_prune_unknown_component_names_are_ignored + description: Allowlist entries the catalog does not declare are ignored, not errors. + catalog: + version: "0.9" + catalog_schema: + catalogId: basic + components: + Text: {type: object} + Card: {type: object} + action: prune + args: + allowed_components: [Text, NotInCatalog] + expect: + catalog_schema: + catalogId: basic + components: + Text: {type: object} diff --git a/conformance/core/data_model.yaml b/conformance/core/data_model.yaml new file mode 100644 index 0000000000..9b3ed51297 --- /dev/null +++ b/conformance/core/data_model.yaml @@ -0,0 +1,627 @@ +# Copyright 2024 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. + +# Behaviour of the reactive data model: JSON Pointer resolution, structural +# auto-vivification, and observer notification routing. +# +# Migrated from `renderers/web_core/src/v0_9/state/data-model.test.ts` so that +# every implementation of the data model is measured against one dataset. +# +# Each case builds a model from `initial`, attaches an observer to every path in +# `watch`, then runs `steps` in order. A step is one of: +# +# op: get read `path`; assert `expect`, `expect_absent` or `expect_type` +# (`list` or `object`) +# op: set write `value` at `path` +# op: delete remove the value at `path` from its parent container +# op: dispose detach every observer +# +# A `set`, `delete` or `dispose` step may assert `expect_notified` (the watched +# paths whose observers fired for that step, with one entry per observer) and +# `expect_values` (the value each watched path holds afterwards). +# +# Deliberately left out of this suite, because the behaviour does not currently +# hold across implementations. Each is covered by a package local test instead. +# +# * JavaScript prototype pollution guards (`__proto__`, `constructor`, +# `prototype`) and `Object.prototype` property leakage, which exist only +# because JavaScript objects have a prototype chain. +# * `null` and `undefined` path arguments, which languages with non-nullable +# string types cannot express. +# * The distinction between storing `undefined` and removing a key, which +# languages without `undefined` cannot express. Removal is expressed here as +# an explicit `delete` step. +# * Rejection of leading zero list indices, which `web_core` enforces and the +# Dart implementation does not. +# * A cap on auto-vivified list indices. Dart lists are dense, so writing +# `/items/999999999` must be rejected to avoid allocating the whole list; +# JavaScript arrays are sparse, so the same write is cheap and is allowed. +# * Whether an observer fires when a write leaves its value unchanged. The +# Dart implementation notifies unconditionally, because a mutable container +# can change in place without changing identity; `web_core` copies +# containers on read and notifies only on an actual change. This shows up +# when the root is replaced: every observer is visited, but only those whose +# value actually changed fire in `web_core`. + +- name: test_data_model_initializes_empty + description: >- + A model created without data starts as an empty object. + catalog: + version: "0.9" + action: data_model + steps: + - op: "get" + path: "/" + expect: {} + +- name: test_data_model_retrieves_root + description: >- + The root path returns the whole data model. + catalog: + version: "0.9" + action: data_model + initial: {"user": {"name": "Alice", "settings": {"theme": "dark"}}, "items": ["a", "b", "c"]} + steps: + - op: "get" + path: "/" + expect: {"user": {"name": "Alice", "settings": {"theme": "dark"}}, "items": ["a", "b", "c"]} + +- name: test_data_model_retrieves_nested_path + description: >- + Nested object paths resolve segment by segment. + catalog: + version: "0.9" + action: data_model + initial: {"user": {"name": "Alice", "settings": {"theme": "dark"}}, "items": ["a", "b", "c"]} + steps: + - op: "get" + path: "/user/name" + expect: "Alice" + - op: "get" + path: "/user/settings/theme" + expect: "dark" + +- name: test_data_model_retrieves_list_items + description: >- + List elements resolve by numeric segment. + catalog: + version: "0.9" + action: data_model + initial: {"user": {"name": "Alice", "settings": {"theme": "dark"}}, "items": ["a", "b", "c"]} + steps: + - op: "get" + path: "/items/0" + expect: "a" + - op: "get" + path: "/items/1" + expect: "b" + +- name: test_data_model_absent_for_missing_paths + description: >- + Paths that do not exist resolve to no value. + catalog: + version: "0.9" + action: data_model + initial: {"user": {"name": "Alice", "settings": {"theme": "dark"}}, "items": ["a", "b", "c"]} + steps: + - op: "get" + path: "/user/age" + expect_absent: true + - op: "get" + path: "/unknown/path" + expect_absent: true + +- name: test_data_model_absent_through_empty_segment + description: >- + Traversing through a segment that holds no value yields no value. + catalog: + version: "0.9" + action: data_model + initial: {"user": {"name": "Alice", "settings": {"theme": "dark"}}, "items": ["a", "b", "c"]} + steps: + - op: "set" + path: "/nullable/inner" + value: 1 + - op: "delete" + path: "/nullable/inner" + - op: "get" + path: "/nullable/deep/path" + expect_absent: true + +- name: test_data_model_sets_existing_path + description: >- + Setting an existing path replaces its value. + catalog: + version: "0.9" + action: data_model + initial: {"user": {"name": "Alice", "settings": {"theme": "dark"}}, "items": ["a", "b", "c"]} + steps: + - op: "set" + path: "/user/name" + value: "Bob" + - op: "get" + path: "/user/name" + expect: "Bob" + +- name: test_data_model_sets_new_path + description: >- + Setting a new key on an existing object adds it. + catalog: + version: "0.9" + action: data_model + initial: {"user": {"name": "Alice", "settings": {"theme": "dark"}}, "items": ["a", "b", "c"]} + steps: + - op: "set" + path: "/user/age" + value: 30 + - op: "get" + path: "/user/age" + expect: 30 + +- name: test_data_model_creates_intermediate_objects + description: >- + Missing intermediate objects are created on write. + catalog: + version: "0.9" + action: data_model + initial: {"user": {"name": "Alice", "settings": {"theme": "dark"}}, "items": ["a", "b", "c"]} + steps: + - op: "set" + path: "/a/b/c" + value: "foo" + - op: "get" + path: "/a/b/c" + expect: "foo" + - op: "get" + path: "/a/b" + expect_type: "object" + +- name: test_data_model_delete_removes_key + description: >- + Deleting a path removes the key from its parent object. + catalog: + version: "0.9" + action: data_model + initial: {"user": {"name": "Alice", "settings": {"theme": "dark"}}, "items": ["a", "b", "c"]} + steps: + - op: "delete" + path: "/user/name" + - op: "get" + path: "/user/name" + expect_absent: true + - op: "get" + path: "/user" + expect: {"settings": {"theme": "dark"}} + +- name: test_data_model_list_set_and_get + description: >- + Writing a numeric segment into a missing path creates a list. + catalog: + version: "0.9" + action: data_model + initial: {"user": {"name": "Alice", "settings": {"theme": "dark"}}, "items": ["a", "b", "c"]} + steps: + - op: "set" + path: "/list/0" + value: "hello" + - op: "get" + path: "/list/0" + expect: "hello" + - op: "get" + path: "/list" + expect_type: "list" + +- name: test_data_model_list_append + description: >- + Writing successive indices appends to a list. + catalog: + version: "0.9" + action: data_model + initial: {"user": {"name": "Alice", "settings": {"theme": "dark"}}, "items": ["a", "b", "c"]} + steps: + - op: "set" + path: "/list/0" + value: "hello" + - op: "set" + path: "/list/1" + value: "world" + - op: "get" + path: "/list" + expect: ["hello", "world"] + +- name: test_data_model_list_update_index + description: >- + Writing an existing index replaces that element. + catalog: + version: "0.9" + action: data_model + initial: {"user": {"name": "Alice", "settings": {"theme": "dark"}}, "items": ["a", "b", "c"]} + steps: + - op: "set" + path: "/items/1" + value: "updated" + - op: "get" + path: "/items" + expect: ["a", "updated", "c"] + +- name: test_data_model_creates_nested_structures + description: >- + Auto-vivification picks a list or an object based on the next segment. + catalog: + version: "0.9" + action: data_model + initial: {"user": {"name": "Alice", "settings": {"theme": "dark"}}, "items": ["a", "b", "c"]} + steps: + - op: "set" + path: "/a/b/0/c" + value: 123 + - op: "get" + path: "/a/b/0/c" + expect: 123 + - op: "get" + path: "/a/b" + expect_type: "list" + - op: "get" + path: "/a/b/0" + expect_type: "object" + - op: "set" + path: "/x/y/z" + value: "hello" + - op: "get" + path: "/x/y/z" + expect: "hello" + - op: "set" + path: "/nestedList/0/0" + value: "inner" + - op: "get" + path: "/nestedList" + expect_type: "list" + - op: "get" + path: "/nestedList/0" + expect_type: "list" + +- name: test_data_model_get_out_of_range_index + description: >- + Out of range and non numeric list indices resolve to no value on read. + catalog: + version: "0.9" + action: data_model + initial: {"user": {"name": "Alice", "settings": {"theme": "dark"}}, "items": ["a", "b", "c"]} + steps: + - op: "get" + path: "/items/99" + expect_absent: true + - op: "get" + path: "/items/-1" + expect_absent: true + - op: "get" + path: "/items/invalid" + expect_absent: true + +- name: test_data_model_normalizes_trailing_slash + description: >- + A trailing slash is not a distinct path. + catalog: + version: "0.9" + action: data_model + initial: {"user": {"name": "Alice", "settings": {"theme": "dark"}}, "items": ["a", "b", "c"]} + watch: ["/foo"] + steps: + - op: "set" + path: "/foo/" + value: "bar" + expect_notified: ["/foo"] + - op: "get" + path: "/foo/" + expect: "bar" + - op: "get" + path: "/foo" + expect: "bar" + +- name: test_data_model_replaces_root + description: >- + Setting the root path replaces the whole model. + catalog: + version: "0.9" + action: data_model + initial: {"user": {"name": "Alice", "settings": {"theme": "dark"}}, "items": ["a", "b", "c"]} + watch: ["/", "/unrelated"] + steps: + - op: "set" + path: "/" + value: {"newRoot": "foo"} + expect_values: {"/": {"newRoot": "foo"}} + - op: "get" + path: "" + expect: {"newRoot": "foo"} + +- name: test_data_model_escaped_slash + description: >- + A ~1 escape addresses a key containing a slash (RFC 6901). + catalog: + version: "0.9" + action: data_model + initial: {"user": {"name": "Alice", "settings": {"theme": "dark"}}, "items": ["a", "b", "c"]} + steps: + - op: "set" + path: "/user/detailed~1info" + value: "some info" + - op: "get" + path: "/user/detailed~1info" + expect: "some info" + - op: "get" + path: "/user" + expect: {"name": "Alice", "settings": {"theme": "dark"}, "detailed/info": "some info"} + +- name: test_data_model_escaped_tilde + description: >- + A ~0 escape addresses a key containing a tilde (RFC 6901). + catalog: + version: "0.9" + action: data_model + initial: {"user": {"name": "Alice", "settings": {"theme": "dark"}}, "items": ["a", "b", "c"]} + steps: + - op: "set" + path: "/user/profile~0name" + value: "profile~name" + - op: "get" + path: "/user/profile~0name" + expect: "profile~name" + - op: "get" + path: "/user" + expect: {"name": "Alice", "settings": {"theme": "dark"}, "profile~name": "profile~name"} + +- name: test_data_model_escaped_mixed + description: >- + Both escapes may appear in one segment. + catalog: + version: "0.9" + action: data_model + initial: {"user": {"name": "Alice", "settings": {"theme": "dark"}}, "items": ["a", "b", "c"]} + steps: + - op: "set" + path: "/user/a~0b~1c" + value: "value" + - op: "get" + path: "/user/a~0b~1c" + expect: "value" + - op: "get" + path: "/user" + expect: {"name": "Alice", "settings": {"theme": "dark"}, "a~b/c": "value"} + +- name: test_data_model_escape_order + description: >- + A ~01 sequence decodes to ~1, not to a slash. + catalog: + version: "0.9" + action: data_model + initial: {"user": {"name": "Alice", "settings": {"theme": "dark"}}, "items": ["a", "b", "c"]} + steps: + - op: "set" + path: "/user/a~01b" + value: "value" + - op: "get" + path: "/user/a~01b" + expect: "value" + - op: "get" + path: "/user" + expect: {"name": "Alice", "settings": {"theme": "dark"}, "a~1b": "value"} + +- name: test_data_model_rejects_write_through_primitive + description: >- + Writing through a primitive intermediate segment is an error. + catalog: + version: "0.9" + action: data_model + initial: {"user": {"name": "Alice", "settings": {"theme": "dark"}}, "items": ["a", "b", "c"]} + steps: + - op: "set" + path: "/user/name" + value: "not an object" + - op: "set" + path: "/user/name/first" + value: "Alice" + expect_error: {"category": "DataError", "message": "Cannot set path"} + +- name: test_data_model_rejects_write_through_list_primitive + description: >- + Writing through a primitive list element is an error. + catalog: + version: "0.9" + action: data_model + initial: {"user": {"name": "Alice", "settings": {"theme": "dark"}}, "items": ["a", "b", "c"]} + steps: + - op: "set" + path: "/items/0/foo" + value: "bar" + expect_error: {"category": "DataError", "message": "Cannot set path"} + +- name: test_data_model_rejects_non_numeric_list_segment + description: >- + A non numeric segment cannot address a list element on write. + catalog: + version: "0.9" + action: data_model + initial: {"user": {"name": "Alice", "settings": {"theme": "dark"}}, "items": ["a", "b", "c"]} + steps: + - op: "set" + path: "/items/foo" + value: "bar" + expect_error: {"category": "DataError", "message": "non-numeric segment"} + +- name: test_data_model_rejects_non_numeric_list_segment_intermediate + description: >- + A non numeric intermediate segment cannot address a list element. + catalog: + version: "0.9" + action: data_model + initial: {"user": {"name": "Alice", "settings": {"theme": "dark"}}, "items": ["a", "b", "c"]} + steps: + - op: "set" + path: "/items/foo/bar" + value: "value" + expect_error: {"category": "DataError", "message": "non-numeric segment"} + +- name: test_data_model_notifies_exact_path + description: >- + An observer fires when its own path is written. + catalog: + version: "0.9" + action: data_model + initial: {"user": {"name": "Alice", "settings": {"theme": "dark"}}, "items": ["a", "b", "c"]} + watch: ["/user/name"] + steps: + - op: "set" + path: "/user/name" + value: "Charlie" + expect_values: {"/user/name": "Charlie"} + expect_notified: ["/user/name"] + +- name: test_data_model_notifies_ancestor + description: >- + An observer on a container fires when a descendant is written. + catalog: + version: "0.9" + action: data_model + initial: {"user": {"name": "Alice", "settings": {"theme": "dark"}}, "items": ["a", "b", "c"]} + watch: ["/user"] + steps: + - op: "set" + path: "/user/name" + value: "Dave" + expect_values: {"/user": {"name": "Dave", "settings": {"theme": "dark"}}} + expect_notified: ["/user"] + +- name: test_data_model_notifies_descendant + description: >- + An observer on a leaf fires when an ancestor is replaced. + catalog: + version: "0.9" + action: data_model + initial: {"user": {"name": "Alice", "settings": {"theme": "dark"}}, "items": ["a", "b", "c"]} + watch: ["/user/settings/theme"] + steps: + - op: "set" + path: "/user/settings" + value: {"theme": "light"} + expect_values: {"/user/settings/theme": "light"} + expect_notified: ["/user/settings/theme"] + +- name: test_data_model_notifies_root_observer + description: >- + A root observer fires on any write. + catalog: + version: "0.9" + action: data_model + initial: {"user": {"name": "Alice", "settings": {"theme": "dark"}}, "items": ["a", "b", "c"]} + watch: ["/"] + steps: + - op: "set" + path: "/newProp" + value: "test" + expect_notified: ["/"] + +- name: test_data_model_notifies_parent_on_child_write + description: >- + A container observer sees the updated container value. + catalog: + version: "0.9" + action: data_model + initial: {"user": {"name": "Alice", "settings": {"theme": "dark"}}, "items": ["a", "b", "c"]} + watch: ["/parent"] + steps: + - op: "set" + path: "/parent" + value: {"child": "initial"} + - op: "set" + path: "/parent/child" + value: "updated" + expect_values: {"/parent": {"child": "updated"}} + +- name: test_data_model_does_not_notify_unrelated + description: >- + An observer does not fire for an unrelated path. + catalog: + version: "0.9" + action: data_model + initial: {"a": 1, "b": 2} + watch: ["/b"] + steps: + - op: "set" + path: "/a" + value: 99 + expect_notified: [] + +- name: test_data_model_does_not_notify_prefix_sibling + description: >- + A sibling whose name merely shares a prefix is not a descendant. + catalog: + version: "0.9" + action: data_model + initial: {"foo": 1, "foobar": 2} + watch: ["/foo", "/foobar"] + steps: + - op: "set" + path: "/foo" + value: 99 + expect_notified: ["/foo"] + +- name: test_data_model_multiple_observers_same_path + description: >- + Every observer on a path is notified. + catalog: + version: "0.9" + action: data_model + initial: {"user": {"name": "Alice", "settings": {"theme": "dark"}}, "items": ["a", "b", "c"]} + watch: ["/user/name", "/user/name"] + steps: + - op: "set" + path: "/user/name" + value: "Eve" + expect_values: {"/user/name": "Eve"} + expect_notified: ["/user/name", "/user/name"] + +- name: test_data_model_observe_absent_path + description: >- + Observing a path that does not exist yet starts empty and fires on + creation. + catalog: + version: "0.9" + action: data_model + initial: {"user": {"name": "Alice", "settings": {"theme": "dark"}}, "items": ["a", "b", "c"]} + watch: ["/non/existent"] + steps: + - op: "get" + path: "/non/existent" + expect_absent: true + - op: "set" + path: "/non/existent" + value: "exists now" + expect_values: {"/non/existent": "exists now"} + expect_notified: ["/non/existent"] + +- name: test_data_model_stops_notifying_after_dispose + description: >- + Disposing the model detaches every observer. + catalog: + version: "0.9" + action: data_model + initial: {"user": {"name": "Alice", "settings": {"theme": "dark"}}, "items": ["a", "b", "c"]} + watch: ["/"] + steps: + - op: "dispose" + - op: "set" + path: "/foo" + value: "bar" + expect_notified: [] diff --git a/dart/a2ui_agent/CHANGELOG.md b/dart/a2ui_agent/CHANGELOG.md index 13d26a5fb9..56c77e6c14 100644 --- a/dart/a2ui_agent/CHANGELOG.md +++ b/dart/a2ui_agent/CHANGELOG.md @@ -1,5 +1,19 @@ # [a2ui_agent](https://pub.dev/packages/a2ui_agent) Changelog +## 0.0.2-wip001 + +- Defined the agent SDK API surface described by the a2ui_agent blueprint, + limited to protocol v0.9: `A2uiGenerator`, `A2uiRequestProcessor`, + `CatalogConfig`, `FileSystemCatalogProvider`, `InMemoryCatalogProvider`, + `CatalogTransformer` with component and function pruning, `PromptGenerator`, + `Parser` with `TextPart`, `RawA2uiPart` and `A2uiPart`, `InferenceFormat` and + `InferenceFormatFactory`, and the DIRECT_JSON and EXPRESS formats. +- Catalog loading, catalog transformers, format wiring, response parts and + `Parser.parseResponse` are implemented. Prompt generation, response parsing, + streaming, capability negotiation and the EXPRESS format throw + `UnimplementedError`; the tests that describe them are marked `skip:` with the + reason. + ## 0.0.1-wip001 - Initial version. diff --git a/dart/a2ui_agent/README.md b/dart/a2ui_agent/README.md index 0bbb0e5d82..b1149a595a 100644 --- a/dart/a2ui_agent/README.md +++ b/dart/a2ui_agent/README.md @@ -1,3 +1,83 @@ # A2UI Agent SDK -TODO: add readme +The Dart agent SDK for [A2UI](https://github.com/a2ui-project/a2ui): catalog +management, capability negotiation, prompt engineering, response parsing and +payload validation for agents that generate UI. + +It implements **version 0.9** of the A2UI protocol. Payloads and capabilities +that declare any other version, or that omit the version, are rejected. + +## Status + +This package currently defines the API surface described by +[the agent blueprint](https://github.com/a2ui-project/a2ui/blob/main/blueprints/modules/a2ui_agent.blueprint.md). +Catalog loading, catalog transformers, format wiring and response part handling +are implemented; prompt generation, response parsing, streaming, capability +negotiation and the EXPRESS format throw `UnimplementedError`. + +The tests describe the intended behaviour of everything that is still stubbed +and are marked `skip:` with the reason, so `dart test` doubles as the +implementation checklist. + +## Architecture + +| Layer | Type | Role | +| ------- | ----------------------------------------------------------- | -------------------------------------------------- | +| Facade | `A2uiGenerator` | Long-lived, holds every catalog the agent supports | +| Facade | `A2uiRequestProcessor` | Per request, bound to one renderer's capabilities | +| Catalog | `CatalogConfig`, `CatalogProvider` | Load a catalog and attach its transformers | +| Catalog | `ComponentPruningTransformer`, `FunctionPruningTransformer` | Narrow a catalog to an allowlist | +| Format | `InferenceFormat`, `InferenceFormatFactory` | Pair a prompt generator with a parser | +| Format | `DirectJsonFormat`, `ExpressFormat` | The concrete wire formats | +| Output | `Parser`, `TextPart`, `A2uiPart` | Tokenize, compile and validate model output | + +Protocol models, catalogs, renderer capabilities and payload validation live in +[`a2ui_core`](https://github.com/a2ui-project/a2ui/tree/main/dart/a2ui_core), +which both agents and renderers depend on. + +## Usage + +```dart +// Once, at agent startup. +final generator = A2uiGenerator( + catalogs: [ + CatalogConfig.fromPath( + 'specification/v0_9_1/catalogs/basic/catalog.json', + transformers: [ + ComponentPruningTransformer(['Card', 'Column', 'Text', 'Button']), + ], + ), + ], +); + +// Per request, against the renderer's declared capabilities. +final processor = generator.createProcessor(rendererCapabilities); +final output = await callYourModel(processor.promptSnippet); + +for (final part in processor.parseResponse(output)) { + switch (part) { + case TextPart(:final text): + sendTextToUser(text); + case A2uiPart(:final a2ui): + sendA2uiToRenderer(a2ui); + case ResponsePart(): + break; + } +} +``` + +See [`example/a2ui_agent_example.dart`](example/a2ui_agent_example.dart) for the +full walkthrough. + +## Testing + +```sh +dart test +``` + +Behavioural tests are driven by the shared datasets in +[`conformance/`](https://github.com/a2ui-project/a2ui/tree/main/conformance) and +run against the published +[basic catalog schema](https://github.com/a2ui-project/a2ui/tree/main/specification/v0_9_1/catalogs/basic), +not a catalog implemented inside this package, so every A2UI SDK is measured +against the same contract. diff --git a/dart/a2ui_agent/example/a2ui_agent_example.dart b/dart/a2ui_agent/example/a2ui_agent_example.dart index e28aab3cf3..a93daedc78 100644 --- a/dart/a2ui_agent/example/a2ui_agent_example.dart +++ b/dart/a2ui_agent/example/a2ui_agent_example.dart @@ -13,8 +13,80 @@ // limitations under the License. import 'package:a2ui_agent/a2ui_agent.dart'; +import 'package:a2ui_core/a2ui_core.dart'; +/// One agent turn, from startup to messages ready for a renderer. +/// +/// Most of the SDK is still stubbed, so running this throws +/// [UnimplementedError] at the first unimplemented step. It is written to show +/// the intended shape of an integration. void main() { - var awesome = Awesome(); - awesome.toString(); + // 1. Agent startup. Register every catalog the agent can generate UI for, + // narrowed to the components and functions this agent actually uses. + final generator = A2uiGenerator( + catalogs: [ + CatalogConfig.fromPath( + 'specification/v0_9_1/catalogs/basic/catalog.json', + transformers: [ + ComponentPruningTransformer(['Card', 'Column', 'Text', 'Button']), + FunctionPruningTransformer(['required', 'email']), + ], + ), + ], + examples: { + 'a confirmation card': [ + CreateSurfaceMessage( + surfaceId: 'confirmation', + catalogId: + 'https://a2ui.org/specification/v0_9/' + 'catalogs/basic/catalog.json', + ), + ], + }, + ); + + // 2. Per request. Negotiate the agent's catalogs against what the renderer + // says it can render. `a2uiClientCapabilities` arrives in transport + // metadata, for example the A2A message metadata. + final capabilities = A2uiRendererCapabilities.fromJson({ + 'v0.9': { + 'supportedCatalogIds': [ + 'https://a2ui.org/specification/v0_9/catalogs/basic/catalog.json', + ], + }, + }); + final A2uiRequestProcessor processor = + generator.createProcessor(capabilities); + + // 3. Inference. Prepend your own role and workflow preamble to the snippet, + // then call your model with it. + final systemPrompt = + 'You are a helpful assistant.\n\n${processor.promptSnippet}'; + final String modelOutput = callYourModel(systemPrompt); + + // 4. Parse and validate. Conversational text and A2UI payloads come back in + // the order the model emitted them. + final List parts = processor.parseResponse(modelOutput); + + // 5. Deliver. Text goes to the chat transcript; messages go to the renderer. + for (final part in parts) { + switch (part) { + case TextPart(:final String text): + sendTextToUser(text); + case A2uiPart(:final List a2ui): + sendA2uiToRenderer(a2ui); + case ResponsePart(): + break; + } + } } + +/// Stands in for your model call. +String callYourModel(String systemPrompt) => + throw UnimplementedError('Wire this up to your model.'); + +/// Stands in for delivering conversational text. +void sendTextToUser(String text) {} + +/// Stands in for delivering A2UI messages to a renderer. +void sendA2uiToRenderer(List messages) {} diff --git a/dart/a2ui_agent/lib/a2ui_agent.dart b/dart/a2ui_agent/lib/a2ui_agent.dart index b8aace0387..746e03525a 100644 --- a/dart/a2ui_agent/lib/a2ui_agent.dart +++ b/dart/a2ui_agent/lib/a2ui_agent.dart @@ -12,8 +12,42 @@ // See the License for the specific language governing permissions and // limitations under the License. +/// The A2UI agent SDK: catalog management, capability negotiation, prompt +/// engineering, response parsing and payload validation for agents that +/// generate A2UI. +/// +/// This SDK implements version 0.9 of the A2UI protocol. Payloads and +/// capabilities that declare any other version, or omit the version, are +/// rejected. library; -export 'src/a2ui_agent_base.dart'; - -// TODO: Export any libraries intended for clients of this package. +// Catalog transformers. +export 'src/catalog_transformers/base.dart'; +export 'src/catalog_transformers/pruning.dart'; +// Inference format contracts. +export 'src/inference_format.dart'; +// DIRECT_JSON format. +export 'src/inference_formats/direct_json/constants.dart'; +export 'src/inference_formats/direct_json/format.dart'; +export 'src/inference_formats/direct_json/parser.dart'; +export 'src/inference_formats/direct_json/prompt_generator.dart'; +export 'src/inference_formats/direct_json/streaming.dart'; +// EXPRESS format. +export 'src/inference_formats/express/compiler.dart'; +export 'src/inference_formats/express/constants.dart'; +export 'src/inference_formats/express/decompiler.dart'; +export 'src/inference_formats/express/format.dart'; +export 'src/inference_formats/express/parser.dart'; +export 'src/inference_formats/express/prompt_generator.dart'; +// Parser contracts. +export 'src/parser/parser.dart'; +export 'src/parser/response_part.dart'; +// High-level application facade. +export 'src/processor/catalog_config.dart'; +export 'src/processor/catalog_providers.dart'; +export 'src/processor/generator.dart'; +export 'src/processor/processor.dart'; +// Prompt generation contracts. +export 'src/prompt/generator.dart'; +// Capability negotiation helpers. +export 'src/utils/catalog_resolver.dart'; diff --git a/dart/a2ui_agent/test/a2ui_agent_test.dart b/dart/a2ui_agent/lib/src/catalog_transformers/base.dart similarity index 54% rename from dart/a2ui_agent/test/a2ui_agent_test.dart rename to dart/a2ui_agent/lib/src/catalog_transformers/base.dart index fea717f80d..ca050fbdcf 100644 --- a/dart/a2ui_agent/test/a2ui_agent_test.dart +++ b/dart/a2ui_agent/lib/src/catalog_transformers/base.dart @@ -12,19 +12,19 @@ // See the License for the specific language governing permissions and // limitations under the License. -import 'package:a2ui_agent/a2ui_agent.dart'; -import 'package:test/test.dart'; +import 'package:a2ui_core/a2ui_core.dart'; -void main() { - group('A group of tests', () { - final awesome = Awesome(); +/// A transformation rule applied to a catalog before it is rendered into a +/// prompt or used for payload validation. +/// +/// Transformers narrow a pristine catalog; they never widen it. +abstract class CatalogTransformer< + C extends ComponentApi, + F extends FunctionApi +> { + const CatalogTransformer(); - setUp(() { - // Additional setup goes here. - }); - - test('First Test', () { - expect(awesome.isAwesome, isTrue); - }); - }); + /// Transforms [catalog] into a modified catalog of the same component and + /// function types. + Catalog transform(Catalog catalog); } diff --git a/dart/a2ui_agent/lib/src/catalog_transformers/pruning.dart b/dart/a2ui_agent/lib/src/catalog_transformers/pruning.dart new file mode 100644 index 0000000000..8864780e81 --- /dev/null +++ b/dart/a2ui_agent/lib/src/catalog_transformers/pruning.dart @@ -0,0 +1,59 @@ +// Copyright 2024 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 'package:a2ui_core/a2ui_core.dart'; + +import 'base.dart'; + +/// Prunes catalog component definitions to an allowlist. +/// +/// Names in the allowlist that the catalog does not declare are ignored, so a +/// single transformer can be reused across catalogs. +class ComponentPruningTransformer + extends CatalogTransformer { + /// The components to keep. + final Set allowedComponents; + + ComponentPruningTransformer(Iterable allowedComponents) + : allowedComponents = Set.unmodifiable(allowedComponents); + + @override + Catalog transform(Catalog catalog) => catalog.copyWith( + components: [ + for (final MapEntry entry in catalog.components.entries) + if (allowedComponents.contains(entry.key)) entry.value, + ], + ); +} + +/// Prunes catalog function definitions to an allowlist. +/// +/// Names in the allowlist that the catalog does not declare are ignored, so a +/// single transformer can be reused across catalogs. +class FunctionPruningTransformer + extends CatalogTransformer { + /// The functions to keep. + final Set allowedFunctions; + + FunctionPruningTransformer(Iterable allowedFunctions) + : allowedFunctions = Set.unmodifiable(allowedFunctions); + + @override + Catalog transform(Catalog catalog) => catalog.copyWith( + functions: [ + for (final MapEntry entry in catalog.functions.entries) + if (allowedFunctions.contains(entry.key)) entry.value, + ], + ); +} diff --git a/dart/a2ui_agent/lib/src/inference_format.dart b/dart/a2ui_agent/lib/src/inference_format.dart new file mode 100644 index 0000000000..4b20895647 --- /dev/null +++ b/dart/a2ui_agent/lib/src/inference_format.dart @@ -0,0 +1,44 @@ +// Copyright 2024 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 'package:a2ui_core/a2ui_core.dart'; + +import 'parser/parser.dart'; +import 'prompt/generator.dart'; + +/// Pairs a prompt generator (agent input) with a parser (agent output) for one +/// wire format. +abstract class InferenceFormat { + const InferenceFormat(); + + /// The prompt generator for this format. + PromptGenerator get promptGenerator; + + /// Creates a fresh, turn-scoped parser bound to this format's catalogs. + Parser createParser(); +} + +/// Constructs [InferenceFormat] strategies bound to a set of active catalogs. +abstract class InferenceFormatFactory< + C extends ComponentApi, + F extends FunctionApi +> { + const InferenceFormatFactory(); + + /// Constructs an [InferenceFormat] bound to [catalogs]. + InferenceFormat createFormat( + List> catalogs, { + Map>? examples, + }); +} diff --git a/dart/a2ui_agent/lib/src/inference_formats/direct_json/constants.dart b/dart/a2ui_agent/lib/src/inference_formats/direct_json/constants.dart new file mode 100644 index 0000000000..4545648972 --- /dev/null +++ b/dart/a2ui_agent/lib/src/inference_formats/direct_json/constants.dart @@ -0,0 +1,41 @@ +// Copyright 2024 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. + +/// The sentinel tag that opens a DIRECT_JSON payload block. +const String a2uiJsonOpenTag = ''; + +/// The sentinel tag that closes a DIRECT_JSON payload block. +const String a2uiJsonCloseTag = ''; + +/// The tag wrapping catalog schemas in generated system instructions. +const String a2uiSchemaOpenTag = ''; + +/// The closing counterpart of [a2uiSchemaOpenTag]. +const String a2uiSchemaCloseTag = ''; + +/// String property keys whose values may be auto-closed when a streamed chunk +/// cuts them mid-token. +/// +/// These carry display text, so a truncated value renders as partial text +/// rather than as a structural error. Keys outside this set are held back +/// until the stream completes them. +const Set defaultProgressiveKeys = { + 'altText', + 'caption', + 'hint', + 'label', + 'literalString', + 'text', + 'valueString', +}; diff --git a/dart/a2ui_agent/lib/src/inference_formats/direct_json/format.dart b/dart/a2ui_agent/lib/src/inference_formats/direct_json/format.dart new file mode 100644 index 0000000000..a4a9662d67 --- /dev/null +++ b/dart/a2ui_agent/lib/src/inference_formats/direct_json/format.dart @@ -0,0 +1,65 @@ +// Copyright 2024 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 'package:a2ui_core/a2ui_core.dart'; + +import '../../inference_format.dart'; +import '../../parser/parser.dart'; +import 'parser.dart'; +import 'prompt_generator.dart'; + +/// The standard A2UI JSON payload format, enclosed in `` tags. +class DirectJsonFormat + extends InferenceFormat { + /// The active catalogs bound to this format. + final List> catalogs; + + /// The payload envelope names the model may emit. + final List? allowedMessages; + + @override + final DirectJsonPromptGenerator promptGenerator; + + DirectJsonFormat( + this.catalogs, { + Map>? examples, + this.allowedMessages, + }) : promptGenerator = DirectJsonPromptGenerator( + catalogs, + examples: examples, + allowedMessages: allowedMessages, + ); + + @override + Parser createParser() => DirectJsonParser(catalogs: catalogs); +} + +/// Builds [DirectJsonFormat] strategies bound to a set of active catalogs. +class DirectJsonFormatFactory + extends InferenceFormatFactory { + /// The payload envelope names the model may emit. + final List? allowedMessages; + + const DirectJsonFormatFactory({this.allowedMessages}); + + @override + DirectJsonFormat createFormat( + List> catalogs, { + Map>? examples, + }) => DirectJsonFormat( + catalogs, + examples: examples, + allowedMessages: allowedMessages, + ); +} diff --git a/dart/a2ui_agent/lib/src/inference_formats/direct_json/parser.dart b/dart/a2ui_agent/lib/src/inference_formats/direct_json/parser.dart new file mode 100644 index 0000000000..6e5be16c0e --- /dev/null +++ b/dart/a2ui_agent/lib/src/inference_formats/direct_json/parser.dart @@ -0,0 +1,83 @@ +// Copyright 2024 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 'package:a2ui_core/a2ui_core.dart'; + +import '../../parser/parser.dart'; +import '../../parser/response_part.dart'; +import 'constants.dart'; +import 'streaming.dart'; + +/// Parses A2UI JSON payload envelopes enclosed in `` sentinel tags. +/// +/// One instance parses one LLM turn: [parseChunk] accumulates streaming state +/// that must not be shared across turns. +class DirectJsonParser + extends Parser { + /// The active catalogs compiled payloads are validated against. + final List> catalogs; + + /// An override for [progressiveKeys]. + final Set? customProgressiveKeys; + + /// The validator applied to compiled payloads. + final A2uiValidator validator; + + DirectJsonParser({ + required this.catalogs, + this.customProgressiveKeys, + A2uiValidator? validator, + }) : validator = validator ?? A2uiValidator(catalogs: catalogs); + + /// The string property keys safe to auto-close when a stream cuts them + /// mid-token. + Set get progressiveKeys => + customProgressiveKeys ?? defaultProgressiveKeys; + + @override + bool get supportsStreaming => true; + + /// The stream processor backing [parseChunk] for this turn. + late final DirectJsonStreamProcessor streamProcessor = + DirectJsonStreamProcessor( + catalogs: catalogs, + progressiveKeys: progressiveKeys, + validator: validator, + ); + + @override + String wrap(List blocks) { + throw UnimplementedError('DirectJsonParser.wrap'); + } + + @override + List unwrap(String content) { + throw UnimplementedError('DirectJsonParser.unwrap'); + } + + @override + List compile(String formatContent) { + throw UnimplementedError('DirectJsonParser.compile'); + } + + @override + String decompile(List a2uiPayload) { + throw UnimplementedError('DirectJsonParser.decompile'); + } + + @override + List parseChunk(String chunk, {bool wrapped = true}) { + throw UnimplementedError('DirectJsonParser.parseChunk'); + } +} diff --git a/dart/a2ui_agent/lib/src/inference_formats/direct_json/prompt_generator.dart b/dart/a2ui_agent/lib/src/inference_formats/direct_json/prompt_generator.dart new file mode 100644 index 0000000000..f4cd1362f0 --- /dev/null +++ b/dart/a2ui_agent/lib/src/inference_formats/direct_json/prompt_generator.dart @@ -0,0 +1,41 @@ +// Copyright 2024 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 'package:a2ui_core/a2ui_core.dart'; + +import '../../prompt/generator.dart'; + +/// Renders system instructions for the DIRECT_JSON format. +/// +/// The generated snippet embeds the active catalog schemas inside +/// `` tags and instructs the model to emit A2UI payloads inside +/// `` tags. +class DirectJsonPromptGenerator + extends PromptGenerator { + /// The payload envelope names the model may emit. + /// + /// When null, every envelope of the active protocol version is allowed. + final List? allowedMessages; + + DirectJsonPromptGenerator( + super.catalogs, { + super.examples, + this.allowedMessages, + }); + + @override + String generate() { + throw UnimplementedError('DirectJsonPromptGenerator.generate'); + } +} diff --git a/dart/a2ui_agent/lib/src/inference_formats/direct_json/streaming.dart b/dart/a2ui_agent/lib/src/inference_formats/direct_json/streaming.dart new file mode 100644 index 0000000000..a70a5ae775 --- /dev/null +++ b/dart/a2ui_agent/lib/src/inference_formats/direct_json/streaming.dart @@ -0,0 +1,57 @@ +// Copyright 2024 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 'package:a2ui_core/a2ui_core.dart'; + +import '../../parser/response_part.dart'; + +/// Incrementally decodes a streamed DIRECT_JSON response. +/// +/// Buffers partial tokens, heals string values whose key is listed in +/// [progressiveKeys], and yields messages only once they are structurally +/// complete and reachable from a surface root. +class DirectJsonStreamProcessor { + /// The active catalogs yielded payloads are validated against. + final List> catalogs; + + /// String keys whose values may be auto-closed when cut mid-token. + final Set progressiveKeys; + + /// The validator applied to yielded payloads. + final A2uiValidator validator; + + DirectJsonStreamProcessor({ + required this.catalogs, + required this.progressiveKeys, + A2uiValidator? validator, + }) : validator = validator ?? A2uiValidator(catalogs: catalogs); + + /// Feeds the next chunk of the stream and returns the parts it completed. + List process(String chunk, {bool wrapped = true}) { + throw UnimplementedError('DirectJsonStreamProcessor.process'); + } + + /// Flushes any buffered content at the end of a stream. + /// + /// Throws [A2uiParseError] if the buffer still holds an unterminated payload + /// block. + List finish() { + throw UnimplementedError('DirectJsonStreamProcessor.finish'); + } + + /// Discards all buffered state so the processor can start a new turn. + void reset() { + throw UnimplementedError('DirectJsonStreamProcessor.reset'); + } +} diff --git a/dart/a2ui_agent/lib/src/inference_formats/express/compiler.dart b/dart/a2ui_agent/lib/src/inference_formats/express/compiler.dart new file mode 100644 index 0000000000..9c80c0d720 --- /dev/null +++ b/dart/a2ui_agent/lib/src/inference_formats/express/compiler.dart @@ -0,0 +1,35 @@ +// Copyright 2024 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 'package:a2ui_core/a2ui_core.dart'; + +/// Lexes and parses `` DSL expressions into A2UI messages. +/// +/// The Express grammar is defined by +/// `specification/inference_formats/express/Express.g4`. +class ExpressCompiler { + /// The active catalogs used to resolve component and function signatures. + final List> catalogs; + + ExpressCompiler({required this.catalogs}); + + /// Compiles an Express DSL string into A2UI messages. + /// + /// Throws [A2uiCompileError] if [source] is not a well-formed Express + /// expression, and [A2uiValidationError] if it names components or functions + /// the active catalogs do not declare. + List compile(String source) { + throw UnimplementedError('ExpressCompiler.compile'); + } +} diff --git a/dart/a2ui_agent/lib/src/a2ui_agent_base.dart b/dart/a2ui_agent/lib/src/inference_formats/express/constants.dart similarity index 72% rename from dart/a2ui_agent/lib/src/a2ui_agent_base.dart rename to dart/a2ui_agent/lib/src/inference_formats/express/constants.dart index ee41f40cae..dfdd19c77d 100644 --- a/dart/a2ui_agent/lib/src/a2ui_agent_base.dart +++ b/dart/a2ui_agent/lib/src/inference_formats/express/constants.dart @@ -12,6 +12,8 @@ // See the License for the specific language governing permissions and // limitations under the License. -class Awesome { - bool get isAwesome => true; -} +/// The sentinel tag that opens an EXPRESS payload block. +const String a2uiExpressOpenTag = ''; + +/// The sentinel tag that closes an EXPRESS payload block. +const String a2uiExpressCloseTag = ''; diff --git a/dart/a2ui_agent/lib/src/inference_formats/express/decompiler.dart b/dart/a2ui_agent/lib/src/inference_formats/express/decompiler.dart new file mode 100644 index 0000000000..ac0f3c51c5 --- /dev/null +++ b/dart/a2ui_agent/lib/src/inference_formats/express/decompiler.dart @@ -0,0 +1,28 @@ +// Copyright 2024 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 'package:a2ui_core/a2ui_core.dart'; + +/// Converts A2UI messages back into Express DSL notation. +class ExpressDecompiler { + /// The active catalogs used to resolve positional argument order. + final List> catalogs; + + ExpressDecompiler({required this.catalogs}); + + /// Decompiles [a2uiPayload] into an Express DSL string. + String decompile(List a2uiPayload) { + throw UnimplementedError('ExpressDecompiler.decompile'); + } +} diff --git a/dart/a2ui_agent/lib/src/inference_formats/express/format.dart b/dart/a2ui_agent/lib/src/inference_formats/express/format.dart new file mode 100644 index 0000000000..b3aa054e86 --- /dev/null +++ b/dart/a2ui_agent/lib/src/inference_formats/express/format.dart @@ -0,0 +1,51 @@ +// Copyright 2024 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 'package:a2ui_core/a2ui_core.dart'; + +import '../../inference_format.dart'; +import '../../parser/parser.dart'; +import 'parser.dart'; +import 'prompt_generator.dart'; + +/// The compact Express DSL format, enclosed in `` tags. +class ExpressFormat + extends InferenceFormat { + /// The active catalogs bound to this format. + final List> catalogs; + + @override + final ExpressPromptGenerator promptGenerator; + + ExpressFormat(this.catalogs, {Map>? examples}) + : promptGenerator = ExpressPromptGenerator( + catalogs, + examples: examples, + ); + + @override + Parser createParser() => ExpressParser(catalogs: catalogs); +} + +/// Builds [ExpressFormat] strategies bound to a set of active catalogs. +class ExpressFormatFactory + extends InferenceFormatFactory { + const ExpressFormatFactory(); + + @override + ExpressFormat createFormat( + List> catalogs, { + Map>? examples, + }) => ExpressFormat(catalogs, examples: examples); +} diff --git a/dart/a2ui_agent/lib/src/inference_formats/express/parser.dart b/dart/a2ui_agent/lib/src/inference_formats/express/parser.dart new file mode 100644 index 0000000000..712b6dcf11 --- /dev/null +++ b/dart/a2ui_agent/lib/src/inference_formats/express/parser.dart @@ -0,0 +1,71 @@ +// Copyright 2024 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 'package:a2ui_core/a2ui_core.dart'; + +import '../../parser/parser.dart'; +import '../../parser/response_part.dart'; +import 'compiler.dart'; +import 'decompiler.dart'; + +/// Parses Express DSL payloads enclosed in `` sentinel tags. +/// +/// Delegates compilation to [ExpressCompiler] and decompilation to +/// [ExpressDecompiler]. +class ExpressParser + extends Parser { + /// The active catalogs compiled payloads are validated against. + final List> catalogs; + + /// The compiler backing [compile]. + final ExpressCompiler compiler; + + /// The decompiler backing [decompile]. + final ExpressDecompiler decompiler; + + ExpressParser({ + required this.catalogs, + ExpressCompiler? compiler, + ExpressDecompiler? decompiler, + }) : compiler = compiler ?? ExpressCompiler(catalogs: catalogs), + decompiler = decompiler ?? ExpressDecompiler(catalogs: catalogs); + + @override + bool get supportsStreaming => true; + + @override + String wrap(List blocks) { + throw UnimplementedError('ExpressParser.wrap'); + } + + @override + List unwrap(String content) { + throw UnimplementedError('ExpressParser.unwrap'); + } + + @override + List compile(String formatContent) { + throw UnimplementedError('ExpressParser.compile'); + } + + @override + String decompile(List a2uiPayload) { + throw UnimplementedError('ExpressParser.decompile'); + } + + @override + List parseChunk(String chunk, {bool wrapped = true}) { + throw UnimplementedError('ExpressParser.parseChunk'); + } +} diff --git a/dart/a2ui_agent/lib/src/inference_formats/express/prompt_generator.dart b/dart/a2ui_agent/lib/src/inference_formats/express/prompt_generator.dart new file mode 100644 index 0000000000..ce1fac35d5 --- /dev/null +++ b/dart/a2ui_agent/lib/src/inference_formats/express/prompt_generator.dart @@ -0,0 +1,32 @@ +// Copyright 2024 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 'package:a2ui_core/a2ui_core.dart'; + +import '../../prompt/generator.dart'; + +/// Renders system instructions for the EXPRESS format. +/// +/// Describes catalog components and functions as compact positional +/// signatures, which costs far fewer output tokens than the JSON schemas the +/// DIRECT_JSON generator emits. +class ExpressPromptGenerator + extends PromptGenerator { + ExpressPromptGenerator(super.catalogs, {super.examples}); + + @override + String generate() { + throw UnimplementedError('ExpressPromptGenerator.generate'); + } +} diff --git a/dart/a2ui_agent/lib/src/parser/parser.dart b/dart/a2ui_agent/lib/src/parser/parser.dart new file mode 100644 index 0000000000..44915973bf --- /dev/null +++ b/dart/a2ui_agent/lib/src/parser/parser.dart @@ -0,0 +1,79 @@ +// Copyright 2024 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 'package:a2ui_core/a2ui_core.dart'; + +import 'response_part.dart'; + +/// Tokenizes LLM output, unwraps format tags, and compiles raw format +/// expressions into A2UI payload messages. +/// +/// A parser instance is turn scoped: streaming state accumulated by +/// [parseChunk] belongs to a single LLM response. +abstract class Parser { + const Parser(); + + /// Whether this parser can process streamed chunks via [parseChunk]. + bool get supportsStreaming => false; + + /// Converts raw response parts back into a single string, adding the + /// format's enclosing tags around each raw A2UI section and concatenating + /// conversational text parts. + String wrap(List blocks); + + /// Tokenizes an LLM response into an ordered list of raw parts, extracting + /// raw format content between sentinel tags while preserving the order in + /// which the model emitted them. + /// + /// Throws [A2uiParseError] if the response contains no well-formed content + /// for this format. + List unwrap(String content); + + /// Compiles a raw format content string into validated A2UI messages. + /// + /// Throws [A2uiCompileError] if the content cannot be compiled, and + /// [A2uiValidationError] if the compiled messages are not valid for the + /// active catalogs or declare an unsupported protocol version. + List compile(String formatContent); + + /// Decompiles A2UI messages into this format's raw notation. + String decompile(List a2uiPayload); + + /// Parses a complete, non-streamed LLM response. + /// + /// Preserves the chronological order of conversational text and A2UI payload + /// blocks. When [wrapped] is false the whole of [content] is treated as a + /// single raw A2UI block. + List parseResponse(String content, {bool wrapped = true}) { + if (!wrapped) return [A2uiPart(compile(content))]; + final parts = []; + for (final RawResponsePart raw in unwrap(content)) { + switch (raw.part) { + case TextPart(:final String text): + parts.add(TextPart(text)); + case RawA2uiPart(:final String a2uiRaw): + parts.add(A2uiPart(compile(a2uiRaw))); + case ResponsePart(): + throw StateError('Unexpected raw part: ${raw.part}'); + } + } + return parts; + } + + /// Processes an incremental chunk of a streamed LLM response. + /// + /// Returns only the parts newly completed by this chunk. Buffered, still + /// incomplete content is retained for the next call. + List parseChunk(String chunk, {bool wrapped = true}); +} diff --git a/dart/a2ui_agent/lib/src/parser/response_part.dart b/dart/a2ui_agent/lib/src/parser/response_part.dart new file mode 100644 index 0000000000..2fa5003779 --- /dev/null +++ b/dart/a2ui_agent/lib/src/parser/response_part.dart @@ -0,0 +1,121 @@ +// Copyright 2024 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 'package:a2ui_core/a2ui_core.dart'; +import 'package:collection/collection.dart'; + +/// A slice of an LLM response. +/// +/// A parsed response is a list of [TextPart] and [A2uiPart]; an unwrapped but +/// not yet compiled response is a list of [RawResponsePart], whose `part` is a +/// [TextPart] or a [RawA2uiPart]. +sealed class ResponsePart { + const ResponsePart(); +} + +/// Conversational text extracted from an LLM response. +final class TextPart extends ResponsePart { + /// The text content intended for user display. + final String text; + + const TextPart(this.text); + + @override + bool operator ==(Object other) => other is TextPart && other.text == text; + + @override + int get hashCode => text.hashCode; + + @override + String toString() => 'TextPart(${_ellipsize(text)})'; +} + +/// An uncompiled A2UI content block extracted from an LLM response. +final class RawA2uiPart extends ResponsePart { + /// The raw uncompiled format content (raw JSON, DSL, or XML). + final String a2uiRaw; + + const RawA2uiPart(this.a2uiRaw); + + @override + bool operator ==(Object other) => + other is RawA2uiPart && other.a2uiRaw == a2uiRaw; + + @override + int get hashCode => a2uiRaw.hashCode; + + @override + String toString() => 'RawA2uiPart(${_ellipsize(a2uiRaw)})'; +} + +/// Compiled A2UI payload messages ready to deliver to a renderer. +final class A2uiPart extends ResponsePart { + /// The validated messages to deliver to client renderers. + final List a2ui; + + const A2uiPart(this.a2ui); + + @override + bool operator ==(Object other) => + other is A2uiPart && + const DeepCollectionEquality().equals( + other.a2ui.map((m) => m.toJson()).toList(), + a2ui.map((m) => m.toJson()).toList(), + ); + + @override + int get hashCode => + const DeepCollectionEquality().hash(a2ui.map((m) => m.toJson()).toList()); + + @override + String toString() => 'A2uiPart(${a2ui.length} message(s))'; +} + +/// An uncompiled token from an LLM response stream. +/// +/// [part] is a [TextPart] or a [RawA2uiPart]; passing any other kind of +/// [ResponsePart] throws [ArgumentError]. +class RawResponsePart { + /// The underlying content: conversational [TextPart] or uncompiled + /// [RawA2uiPart]. + final ResponsePart part; + + /// Whether this part is complete, that is not truncated mid-stream. + final bool isFinal; + + RawResponsePart(this.part, {this.isFinal = true}) { + if (part is! TextPart && part is! RawA2uiPart) { + throw ArgumentError.value( + part, + 'part', + 'RawResponsePart holds a TextPart or a RawA2uiPart', + ); + } + } + + @override + bool operator ==(Object other) => + other is RawResponsePart && + other.part == part && + other.isFinal == isFinal; + + @override + int get hashCode => Object.hash(part, isFinal); + + @override + String toString() => 'RawResponsePart($part, isFinal: $isFinal)'; +} + +String _ellipsize(String value) => + value.length <= 40 ? "'$value'" : "'${value.substring(0, 40)}...'"; diff --git a/dart/a2ui_agent/lib/src/processor/catalog_config.dart b/dart/a2ui_agent/lib/src/processor/catalog_config.dart new file mode 100644 index 0000000000..c70f6c3d8d --- /dev/null +++ b/dart/a2ui_agent/lib/src/processor/catalog_config.dart @@ -0,0 +1,63 @@ +// Copyright 2024 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 'package:a2ui_core/a2ui_core.dart'; + +import '../catalog_transformers/base.dart'; +import 'catalog_providers.dart'; + +/// A [CatalogConfig] over schema-only catalogs. +/// +/// This is the shape agents use, since [Catalog.fromJson] produces schema-only +/// catalogs and an agent never evaluates a catalog function. +typedef SchemaCatalogConfig = CatalogConfig; + +/// Associates a catalog with the transformations applied to it before it is +/// used for prompting or validation. +class CatalogConfig { + /// The pristine catalog, as loaded from a [CatalogProvider]. + final Catalog catalog; + + /// Transformers applied in order by [transformedCatalog]. + final List> transformers; + + const CatalogConfig(this.catalog, {this.transformers = const []}); + + /// Loads a catalog from a JSON file on disk. + /// + /// Throws the errors documented on [FileSystemCatalogProvider.load]. + static SchemaCatalogConfig fromPath( + String catalogPath, { + List> transformers = + const [], + A2uiProtocolVersion? protocolVersion, + String? catalogId, + }) => CatalogConfig( + FileSystemCatalogProvider( + catalogPath, + protocolVersion: protocolVersion, + catalogId: catalogId, + ).load(), + transformers: transformers, + ); + + /// The catalog after applying every configured transformer in order. + Catalog get transformedCatalog { + Catalog current = catalog; + for (final CatalogTransformer transformer in transformers) { + current = transformer.transform(current); + } + return current; + } +} diff --git a/dart/a2ui_agent/lib/src/processor/catalog_providers.dart b/dart/a2ui_agent/lib/src/processor/catalog_providers.dart new file mode 100644 index 0000000000..8e471a282b --- /dev/null +++ b/dart/a2ui_agent/lib/src/processor/catalog_providers.dart @@ -0,0 +1,110 @@ +// Copyright 2024 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 'dart:convert'; +import 'dart:io'; + +import 'package:a2ui_core/a2ui_core.dart'; + +/// Loads a catalog definition from some backing store. +/// +/// There is deliberately no bundled provider: nothing needs to ship with the +/// SDK, because the catalogs an agent supports are either read from disk, +/// supplied in memory, or sent inline by the renderer. +abstract class CatalogProvider { + const CatalogProvider(); + + /// Loads and returns the catalog. + Catalog load(); +} + +/// Loads a catalog definition from a JSON file on the local filesystem. +class FileSystemCatalogProvider + extends CatalogProvider { + /// The path to the catalog JSON file. + final String path; + + /// The protocol version the loaded catalog is expected to declare. + final A2uiProtocolVersion? protocolVersion; + + /// The catalog id the loaded catalog is expected to declare. + final String? catalogId; + + const FileSystemCatalogProvider( + this.path, { + this.protocolVersion, + this.catalogId, + }); + + /// Reads and parses the catalog file. + /// + /// Throws [A2uiCatalogError] if the file is missing or is not a JSON object, + /// or if [catalogId] conflicts with the document. Throws + /// [A2uiValidationError] if the document declares an unsupported protocol + /// version, or one conflicting with [protocolVersion]. + @override + Catalog load() { + final file = File(path); + if (!file.existsSync()) { + throw A2uiCatalogError('Catalog file not found: $path'); + } + final Object? decoded; + try { + decoded = jsonDecode(file.readAsStringSync()); + } on FormatException catch (e) { + throw A2uiCatalogError( + 'Catalog file $path is not valid JSON: ${e.message}', + ); + } + if (decoded is! Map) { + throw A2uiCatalogError('Catalog file $path must contain a JSON object.'); + } + return Catalog.fromJson( + decoded, + expectedProtocolVersion: protocolVersion, + expectedCatalogId: catalogId, + ); + } +} + +/// Loads a catalog definition from an in-memory schema map. +class InMemoryCatalogProvider + extends CatalogProvider { + /// The raw catalog schema. + final Map catalog; + + /// The protocol version the catalog is expected to declare. + final A2uiProtocolVersion? protocolVersion; + + /// The catalog id the catalog is expected to declare. + final String? catalogId; + + const InMemoryCatalogProvider( + this.catalog, { + this.protocolVersion, + this.catalogId, + }); + + /// Parses the in-memory schema. + /// + /// Throws [A2uiCatalogError] if the schema is malformed or if [catalogId] + /// conflicts with it, and [A2uiValidationError] if it declares an + /// unsupported protocol version or one conflicting with [protocolVersion]. + @override + Catalog load() => Catalog.fromJson( + catalog, + expectedProtocolVersion: protocolVersion, + expectedCatalogId: catalogId, + ); +} diff --git a/dart/a2ui_agent/lib/src/processor/generator.dart b/dart/a2ui_agent/lib/src/processor/generator.dart new file mode 100644 index 0000000000..90897c7292 --- /dev/null +++ b/dart/a2ui_agent/lib/src/processor/generator.dart @@ -0,0 +1,73 @@ +// Copyright 2024 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 'package:a2ui_core/a2ui_core.dart'; + +import '../inference_format.dart'; +import '../inference_formats/direct_json/format.dart'; +import 'catalog_config.dart'; +import 'processor.dart'; + +/// The agent-level, long-lived entry point to the A2UI agent SDK. +/// +/// Created once at agent startup with every catalog the agent can generate UI +/// for. Each incoming request produces an [A2uiRequestProcessor] pre-negotiated +/// against that renderer's capabilities. +class A2uiGenerator { + /// Every catalog configuration this agent supports, in preference order. + final List> catalogs; + + /// Few-shot example turns shared across sessions. + /// + /// Validated against the negotiated catalogs by [createProcessor]. + final Map>? examples; + + /// The format factory used when no per-request override is supplied. + final InferenceFormatFactory inferenceFormatFactory; + + /// Whether the agent accepts catalogs supplied inline by the renderer. + final bool acceptsInlineCatalogs; + + A2uiGenerator({ + required this.catalogs, + this.examples, + this.acceptsInlineCatalogs = false, + InferenceFormatFactory? inferenceFormatFactory, + }) : inferenceFormatFactory = + inferenceFormatFactory ?? DirectJsonFormatFactory(); + + /// Creates a processor bound to a renderer's declared capabilities. + /// + /// Throws [A2uiCatalogError] if no registered catalog matches the renderer, + /// and [A2uiValidationError] if the capabilities declare no entry for the + /// protocol version this SDK implements, or if [examples] are not valid for + /// the negotiated catalogs. + A2uiRequestProcessor createProcessor( + A2uiRendererCapabilities rendererCapabilities, { + InferenceFormatFactory? inferenceFormatFactory, + }) { + throw UnimplementedError('A2uiGenerator.createProcessor'); + } + + /// The capabilities this agent advertises to renderers. + /// + /// Mirrors `specification/v0_9_1/json/server_capabilities.json`. + Map get agentCapabilities => { + 'a2uiVersions': [A2uiProtocolVersion.v0_9.jsonValue], + 'supportedCatalogIds': [ + for (final CatalogConfig config in catalogs) config.catalog.id, + ], + 'acceptsInlineCatalogs': acceptsInlineCatalogs, + }; +} diff --git a/dart/a2ui_agent/lib/src/processor/processor.dart b/dart/a2ui_agent/lib/src/processor/processor.dart new file mode 100644 index 0000000000..334f2c0079 --- /dev/null +++ b/dart/a2ui_agent/lib/src/processor/processor.dart @@ -0,0 +1,77 @@ +// Copyright 2024 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 'package:a2ui_core/a2ui_core.dart'; + +import '../inference_format.dart'; +import '../inference_formats/direct_json/format.dart'; +import '../parser/parser.dart'; +import '../parser/response_part.dart'; + +/// The per-request facade: holds the catalogs negotiated for one renderer, +/// renders the system prompt snippet, creates turn-scoped parsers, and +/// validates model output. +/// +/// Obtained from `A2uiGenerator.createProcessor` rather than constructed +/// directly in most agents. +class A2uiRequestProcessor { + /// The negotiated catalogs active for this request. + final List> activeCatalogs; + + /// Few-shot example turns to include in the system prompt. + final Map>? examples; + + /// The inference format strategy used for prompting and parsing. + final InferenceFormat format; + + /// The validator applied to parsed payloads. + final A2uiValidator validator; + + A2uiRequestProcessor({ + required this.activeCatalogs, + this.examples, + InferenceFormatFactory? formatFactory, + A2uiValidator? validator, + }) : format = (formatFactory ?? DirectJsonFormatFactory()).createFormat( + activeCatalogs, + examples: examples, + ), + validator = validator ?? A2uiValidator(catalogs: activeCatalogs); + + /// The format-specific system prompt instruction snippet. + /// + /// The agent prepends its own role and workflow preamble. + String get promptSnippet => format.promptGenerator.generate(); + + /// Creates a parser scoped to a single LLM turn. + Parser createParser() => format.createParser(); + + /// Parses and validates a complete LLM response. + /// + /// Throws [A2uiParseError] if the response holds no well-formed payload + /// block, [A2uiCompileError] if a block cannot be compiled, and + /// [A2uiValidationError] if the compiled payload is invalid for + /// [activeCatalogs] or declares an unsupported protocol version. + List parseResponse(String content) { + throw UnimplementedError('A2uiRequestProcessor.parseResponse'); + } + + /// Validates few-shot [examples] against [activeCatalogs]. + /// + /// Throws [A2uiValidationError] if an example uses components or structures + /// the active catalogs do not support. + Future validateExamples() { + throw UnimplementedError('A2uiRequestProcessor.validateExamples'); + } +} diff --git a/dart/a2ui_agent/lib/src/prompt/generator.dart b/dart/a2ui_agent/lib/src/prompt/generator.dart new file mode 100644 index 0000000000..c606a92716 --- /dev/null +++ b/dart/a2ui_agent/lib/src/prompt/generator.dart @@ -0,0 +1,35 @@ +// Copyright 2024 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 'package:a2ui_core/a2ui_core.dart'; + +/// Builds the format-specific portion of an agent's system instructions. +/// +/// The caller owns the surrounding prompt: role and workflow preambles are +/// prepended and any suffix appended by the agent, not by this generator. +abstract class PromptGenerator { + /// The active catalogs to describe in the system instructions. + final List> catalogs; + + /// Few-shot example turns, keyed by a description of the turn. + /// + /// Each value is the A2UI payload the model is expected to produce for that + /// turn. + final Map>? examples; + + PromptGenerator(this.catalogs, {this.examples}); + + /// Renders the format-specific system instructions and catalog schemas. + String generate(); +} diff --git a/dart/a2ui_agent/lib/src/utils/catalog_resolver.dart b/dart/a2ui_agent/lib/src/utils/catalog_resolver.dart new file mode 100644 index 0000000000..29e650bc4c --- /dev/null +++ b/dart/a2ui_agent/lib/src/utils/catalog_resolver.dart @@ -0,0 +1,37 @@ +// Copyright 2024 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 'package:a2ui_core/a2ui_core.dart'; + +import '../processor/catalog_config.dart'; + +/// Negotiates renderer capabilities against the catalogs an agent supports. +/// +/// Returns the transformed catalogs the agent should prompt and validate +/// against for this session, in agent preference order. When the renderer +/// declares no supported catalog ids, the agent's first registered catalog is +/// used. When [acceptsInlineCatalogs] is true, catalogs the renderer supplies +/// inline are also eligible. +/// +/// Throws [A2uiCatalogError] if no registered catalog matches the renderer's +/// capabilities. Throws [A2uiValidationError] if [rendererCapabilities] +/// declares no capabilities for the protocol version this SDK implements. +List> +resolveCatalogs( + List> catalogs, + A2uiRendererCapabilities rendererCapabilities, { + bool acceptsInlineCatalogs = false, +}) { + throw UnimplementedError('resolveCatalogs'); +} diff --git a/dart/a2ui_agent/pubspec.yaml b/dart/a2ui_agent/pubspec.yaml index da83b2055f..f9921bd6b5 100644 --- a/dart/a2ui_agent/pubspec.yaml +++ b/dart/a2ui_agent/pubspec.yaml @@ -14,7 +14,7 @@ name: a2ui_agent description: The A2UI agent SDK. -version: 0.0.1-wip001 +version: 0.0.2-wip001 repository: https://github.com/a2ui-project/a2ui/tree/main/dart/a2ui_agent resolution: workspace @@ -23,7 +23,10 @@ environment: sdk: ">=3.10.0 <4.0.0" dependencies: - a2ui_core: ^0.1.1 + a2ui_core: ^0.2.0 + collection: ^1.18.0 dev_dependencies: + path: ^1.9.0 test: ^1.26.2 + yaml: ^3.1.2 diff --git a/dart/a2ui_agent/test/catalog_transformers_test.dart b/dart/a2ui_agent/test/catalog_transformers_test.dart new file mode 100644 index 0000000000..51eb0918b4 --- /dev/null +++ b/dart/a2ui_agent/test/catalog_transformers_test.dart @@ -0,0 +1,178 @@ +// Copyright 2024 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 'package:a2ui_agent/a2ui_agent.dart'; +import 'package:a2ui_core/a2ui_core.dart'; +import 'package:test/test.dart'; + +import 'test_catalogs.dart'; + +void main() { + group('ComponentPruningTransformer', () { + test('keeps only the allowed components', () { + final transformer = + ComponentPruningTransformer([ + 'Text', + 'Card', + ]); + + final SchemaCatalog pruned = transformer.transform(smallCatalog()); + + expect(pruned.components.keys.toSet(), {'Text', 'Card'}); + expect(pruned.functions.keys.toSet(), {'required', 'email'}); + }); + + test('ignores allowlist entries the catalog does not declare', () { + final transformer = + ComponentPruningTransformer([ + 'Text', + 'NotInCatalog', + ]); + + expect(transformer.transform(smallCatalog()).components.keys, ['Text']); + }); + + test('preserves the catalog id and protocol version', () { + final SchemaCatalog source = smallCatalog(); + final SchemaCatalog pruned = + ComponentPruningTransformer([ + 'Text', + ]).transform(source); + + expect(pruned.id, source.id); + expect(pruned.protocolVersion, source.protocolVersion); + }); + + test('does not mutate the source catalog', () { + final SchemaCatalog source = smallCatalog(); + ComponentPruningTransformer([ + 'Text', + ]).transform(source); + + expect(source.components.keys.toSet(), {'Text', 'Card', 'Button'}); + }); + + test('narrows the anyComponent union in the rendered document', () { + final SchemaCatalog pruned = + ComponentPruningTransformer([ + 'Text', + ]).transform(smallCatalog()); + + final oneOf = + ((pruned.catalogSchema[r'$defs']! as Map)['anyComponent']! + as Map)['oneOf']! + as List; + expect(oneOf.map((e) => (e! as Map)[r'$ref']), ['#/components/Text']); + }); + + test('prunes the published basic catalog', () { + final SchemaCatalog pruned = + ComponentPruningTransformer([ + 'Card', + 'Column', + 'Text', + 'TextField', + 'Button', + ]).transform(basicCatalog()); + + expect(pruned.components.keys.toSet(), { + 'Card', + 'Column', + 'Text', + 'TextField', + 'Button', + }); + expect(pruned.components.containsKey('Video'), isFalse); + }); + + test('exposes the allowlist as an unmodifiable set', () { + final transformer = + ComponentPruningTransformer([ + 'Text', + ]); + + expect(transformer.allowedComponents, {'Text'}); + expect( + () => transformer.allowedComponents.add('Card'), + throwsUnsupportedError, + ); + }); + }); + + group('FunctionPruningTransformer', () { + test('keeps only the allowed functions', () { + final SchemaCatalog pruned = + FunctionPruningTransformer([ + 'required', + ]).transform(smallCatalog()); + + expect(pruned.functions.keys, ['required']); + expect(pruned.components.keys.toSet(), {'Text', 'Card', 'Button'}); + }); + + test('ignores allowlist entries the catalog does not declare', () { + final SchemaCatalog pruned = + FunctionPruningTransformer([ + 'required', + 'notAFunction', + ]).transform(smallCatalog()); + + expect(pruned.functions.keys, ['required']); + }); + + test('narrows the anyFunction union in the rendered document', () { + final SchemaCatalog pruned = + FunctionPruningTransformer([ + 'email', + ]).transform(smallCatalog()); + + final oneOf = + ((pruned.catalogSchema[r'$defs']! as Map)['anyFunction']! + as Map)['oneOf']! + as List; + expect(oneOf.map((e) => (e! as Map)[r'$ref']), ['#/functions/email']); + }); + + test('exposes the allowlist as an unmodifiable set', () { + final transformer = + FunctionPruningTransformer([ + 'required', + ]); + + expect(transformer.allowedFunctions, {'required'}); + expect( + () => transformer.allowedFunctions.add('email'), + throwsUnsupportedError, + ); + }); + }); + + group('CatalogTransformer composition', () { + test('transformers chain to narrow both components and functions', () { + final transformers = + >[ + ComponentPruningTransformer(['Text']), + FunctionPruningTransformer(['required']), + ]; + + SchemaCatalog current = smallCatalog(); + for (final transformer in transformers) { + current = transformer.transform(current); + } + + expect(current.components.keys, ['Text']); + expect(current.functions.keys, ['required']); + }); + }); +} diff --git a/dart/a2ui_agent/test/conformance/agent_conformance_test.dart b/dart/a2ui_agent/test/conformance/agent_conformance_test.dart new file mode 100644 index 0000000000..8cbb2ffecc --- /dev/null +++ b/dart/a2ui_agent/test/conformance/agent_conformance_test.dart @@ -0,0 +1,177 @@ +// Copyright 2024 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 'package:a2ui_agent/a2ui_agent.dart'; +import 'package:a2ui_core/a2ui_core.dart'; +import 'package:test/test.dart'; + +import 'conformance_harness.dart'; + +/// Runs the shared conformance suites that apply to the agent SDK. +/// +/// Cases targeting protocol versions this SDK does not implement are skipped, +/// as are cases covering behaviour that is still stubbed. Each skip states its +/// reason, so the suite doubles as the implementation checklist. +void main() { + _runSuite('core/catalog.yaml'); + _runSuite('agent/inference_format.yaml'); + _runSuite('agent/parser.yaml'); + _runSuite('agent/streaming_parser.yaml'); + _runSuite('agent/request_processor.yaml'); +} + +void _runSuite(String suite) { + final List> cases = loadConformanceSuite(suite); + + group('conformance $suite', () { + test('suite is not empty', () => expect(cases, isNotEmpty)); + + for (final testCase in cases) { + final String? skipReason = _skipReason(testCase); + test( + testCase['name']! as String, + () => _runCase(testCase), + skip: skipReason, + ); + } + }); +} + +/// Why a case cannot run yet, or null when it can. +String? _skipReason(Map testCase) { + final String? version = caseVersion(testCase); + if (version != null && version != '0.9') { + return 'Targets protocol v$version; this SDK implements v0.9 only.'; + } + + final action = testCase['action']! as String; + switch (action) { + case 'prune': + final Map args = + (testCase['args'] as Map?) ?? const {}; + final Map expect = + (testCase['expect'] as Map?) ?? const {}; + if (!args.containsKey('allowed_components') && + !args.containsKey('allowed_functions')) { + return 'Message pruning is not part of the agent catalog transformers.'; + } + if (!expect.containsKey('catalog_schema')) { + return 'Only catalog schema pruning is modelled by this SDK.'; + } + return null; + case 'load_catalog': + if (testCase.containsKey('modifiers')) { + return 'Catalog schema modifiers are not implemented yet.'; + } + return null; + case 'select_catalog': + return 'resolveCatalogs is not implemented yet.'; + case 'generate_prompt': + return 'DirectJsonPromptGenerator.generate is not implemented yet.'; + case 'parse_full': + case 'fix_payload': + case 'has_parts': + return 'DirectJsonParser is not implemented yet.'; + case 'process_chunk': + case 'verify_cuttable_keys': + return 'DirectJsonStreamProcessor is not implemented yet.'; + case 'process_request': + return 'The agent turn is not implemented end to end yet.'; + default: + return 'Action "$action" is not exercised by the agent SDK.'; + } +} + +void _runCase(Map testCase) { + final action = testCase['action']! as String; + switch (action) { + case 'prune': + _runPrune(testCase); + case 'load_catalog': + _runLoadCatalog(testCase); + default: + fail('No agent harness for conformance action "$action".'); + } +} + +void _runPrune(Map testCase) { + final Map args = + (testCase['args'] as Map?) ?? const {}; + final expected = testCase['expect']! as Map; + + final transformers = >[ + if (args['allowed_components'] != null) + ComponentPruningTransformer( + (args['allowed_components']! as List).cast(), + ), + if (args['allowed_functions'] != null) + FunctionPruningTransformer( + (args['allowed_functions']! as List).cast(), + ), + ]; + + final CatalogConfig config = + SchemaCatalogConfig( + Catalog.fromJson(_catalogSchemaOf(testCase)), + transformers: transformers, + ); + + expect( + config.transformedCatalog.catalogSchema, + equals(expected['catalog_schema']), + reason: testCase['name'] as String?, + ); +} + +void _runLoadCatalog(Map testCase) { + final configs = testCase['catalog_configs']! as List; + final catalogs = [ + for (final Object? config in configs) + FileSystemCatalogProvider( + resolveConformancePath( + (config! as Map)['path']! as String, + ), + ).load(), + ]; + + final Map expected = + (testCase['expect'] as Map?) ?? const {}; + + if (expected['supported_catalog_ids'] != null) { + expect( + catalogs.map((c) => c.id).toList(), + equals(expected['supported_catalog_ids']), + reason: testCase['name'] as String?, + ); + } + if (expected['catalog_schema'] != null) { + expect( + catalogs.single.catalogSchema, + equals(expected['catalog_schema']), + reason: testCase['name'] as String?, + ); + } +} + +/// The catalog document a case runs against, inline or loaded from a path. +Map _catalogSchemaOf(Map testCase) { + final Map catalog = + (testCase['catalog'] as Map?) ?? const {}; + final Object? schema = catalog['catalog_schema']; + if (schema is Map) return schema; + if (schema is String) { + return InMemoryCatalogProvider(loadConformanceJson(schema)).catalog; + } + return {}; +} diff --git a/dart/a2ui_agent/test/conformance/conformance_harness.dart b/dart/a2ui_agent/test/conformance/conformance_harness.dart new file mode 100644 index 0000000000..4b69e855aa --- /dev/null +++ b/dart/a2ui_agent/test/conformance/conformance_harness.dart @@ -0,0 +1,100 @@ +// Copyright 2024 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 'dart:convert'; +import 'dart:io'; + +import 'package:path/path.dart' as p; +import 'package:yaml/yaml.dart'; + +/// Resolves a path from a conformance case against the `conformance/` +/// directory. +/// +/// Cases reference published specification artifacts with paths such as +/// `../specification/v0_9_1/catalogs/basic/catalog.json`. +String resolveConformancePath(String relativePath) => + p.normalize(p.join(_conformanceRoot(), relativePath)); + +String _conformanceRoot() { + // Walk up from the current directory until the conformance suite is found, + // so the harness works from the package directory and the workspace root. + Directory dir = Directory.current; + while (true) { + final candidate = Directory(p.join(dir.path, 'conformance')); + if (candidate.existsSync() && + File(p.join(candidate.path, 'conformance_schema.json')).existsSync()) { + return candidate.path; + } + final Directory parent = dir.parent; + if (parent.path == dir.path) { + throw StateError( + 'Could not locate the conformance/ directory above ' + '${Directory.current.path}.', + ); + } + dir = parent; + } +} + +/// Loads a conformance suite, for example `core/data_model.yaml`. +List> loadConformanceSuite(String suite) { + final file = File(resolveConformancePath(suite)); + if (!file.existsSync()) { + throw StateError('Conformance suite not found: ${file.path}'); + } + final Object? parsed = loadYaml(file.readAsStringSync()); + if (parsed is! YamlList) { + throw StateError('Conformance suite $suite must be a list of cases.'); + } + return [ + for (final Object? node in parsed) + normalizeYaml(node)! as Map, + ]; +} + +/// Converts YAML nodes into plain Dart maps, lists and scalars. +/// +/// The state models under test are typed against `Map` and +/// `List`, which `YamlMap` and `YamlList` do not satisfy. +Object? normalizeYaml(Object? node) { + if (node is YamlMap || node is Map) { + return { + for (final MapEntry entry + in (node as Map).cast().entries) + entry.key.toString(): normalizeYaml(entry.value), + }; + } + if (node is YamlList || node is List) { + return [ + for (final Object? item in node as List) normalizeYaml(item), + ]; + } + return node; +} + +/// Loads a JSON document referenced by a conformance case. +Map loadConformanceJson(String relativePath) { + final file = File(resolveConformancePath(relativePath)); + if (!file.existsSync()) { + throw StateError('Conformance data not found: ${file.path}'); + } + return jsonDecode(file.readAsStringSync()) as Map; +} + +/// The protocol version a case targets, or null when it declares none. +String? caseVersion(Map testCase) { + final Object? catalog = testCase['catalog']; + if (catalog is Map) return catalog['version'] as String?; + return null; +} diff --git a/dart/a2ui_agent/test/e2e/primary_use_case_test.dart b/dart/a2ui_agent/test/e2e/primary_use_case_test.dart new file mode 100644 index 0000000000..03c7610810 --- /dev/null +++ b/dart/a2ui_agent/test/e2e/primary_use_case_test.dart @@ -0,0 +1,249 @@ +// Copyright 2024 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 'package:a2ui_agent/a2ui_agent.dart'; +import 'package:a2ui_core/a2ui_core.dart'; +import 'package:test/test.dart'; + +import '../conformance/conformance_harness.dart'; +import '../test_catalogs.dart'; + +/// Marks the end-to-end walkthrough, which cannot pass until capability +/// negotiation, prompt generation and response parsing are implemented. Remove +/// the skip alongside those implementations. +const String pendingEndToEnd = + 'The agent turn is not implemented end to end yet.'; + +/// The end-to-end agent turn described in section 5 of +/// `blueprints/modules/a2ui_agent.blueprint.md`. +/// +/// The turn's inputs and expected outputs come from +/// `conformance/agent/request_processor.yaml`, so this walkthrough and the +/// other SDKs are measured against the same data. The model is stubbed: this +/// exercises the SDK, not an LLM. +void main() { + final List> cases = loadConformanceSuite( + 'agent/request_processor.yaml', + ); + + Map conformanceCase(String name) => cases.firstWhere( + (c) => c['name'] == name, + orElse: () => throw StateError('No conformance case named $name'), + ); + + group('primary use case: one agent turn against the basic catalog', () { + late Map data; + late Map args; + + setUp(() { + data = conformanceCase('test_primary_use_case_basic_catalog_login_form'); + args = data['args']! as Map; + }); + + test('negotiates, prompts, and parses a full turn', () { + // 1. Agent startup: register every catalog the agent can generate UI for, + // narrowed to the components and functions this agent uses. + final generator = A2uiGenerator( + catalogs: [ + CatalogConfig( + basicCatalog(), + transformers: [ + ComponentPruningTransformer( + (args['allowed_components']! as List).cast(), + ), + FunctionPruningTransformer( + (args['allowed_functions']! as List).cast(), + ), + ], + ), + ], + ); + + // 2. Request handling: negotiate against the renderer's capabilities. + final capabilities = A2uiRendererCapabilities.fromJson( + args['client_capabilities']! as Map, + ); + final A2uiRequestProcessor processor = + generator.createProcessor(capabilities); + + expect( + processor.activeCatalogs.map((c) => c.id), + (data['expect_active_catalog_ids']! as List).cast(), + ); + + // 3. Prompting: the snippet the agent prepends its own preamble to. + final String prompt = processor.promptSnippet; + for (final Object? fragment in data['expect_prompt_contains']! as List) { + expect(prompt, contains(fragment! as String)); + } + + // 4. Inference: a canned response stands in for the model. + final modelOutput = args['llm_response']! as String; + + // 5. Parsing and validation: what the agent delivers to the renderer. + final List parts = processor.parseResponse(modelOutput); + + final expected = data['expect']! as List; + expect(parts, hasLength(expected.length)); + + for (var i = 0; i < expected.length; i++) { + final expectedPart = expected[i]! as Map; + final ResponsePart actual = parts[i]; + + if (expectedPart.containsKey('a2ui')) { + expect(actual, isA(), reason: 'part $i'); + final messages = expectedPart['a2ui']! as List; + expect( + (actual as A2uiPart).a2ui.map((m) => m.toJson()).toList(), + equals(messages), + reason: 'part $i', + ); + } else { + expect(actual, isA(), reason: 'part $i'); + expect( + (actual as TextPart).text, + expectedPart['text'], + reason: 'part $i', + ); + } + } + }, skip: pendingEndToEnd); + + test('delivers a surface the renderer can render', () { + // The parsed payload must reconstruct into live surface state, which is + // what the renderer ultimately does with it. + final generator = A2uiGenerator( + catalogs: [CatalogConfig(basicCatalog())], + ); + final A2uiRequestProcessor processor = + generator.createProcessor( + A2uiRendererCapabilities.fromJson( + args['client_capabilities']! as Map, + ), + ); + + final List messages = processor + .parseResponse(args['llm_response']! as String) + .whereType() + .expand((part) => part.a2ui) + .toList(); + + expect(messages.first, isA()); + expect( + (messages.first as CreateSurfaceMessage).catalogId, + basicCatalogId, + ); + expect(messages.whereType(), isNotEmpty); + expect(messages.whereType(), isNotEmpty); + }, skip: pendingEndToEnd); + }); + + group('primary use case: rejected turns', () { + test( + 'rejects a payload declaring an unsupported protocol version', + () { + final Map data = conformanceCase( + 'test_primary_use_case_rejects_unsupported_version', + ); + final args = data['args']! as Map; + + final generator = A2uiGenerator( + catalogs: [CatalogConfig(basicCatalog())], + ); + final A2uiRequestProcessor + processor = generator.createProcessor( + A2uiRendererCapabilities.fromJson( + args['client_capabilities']! as Map, + ), + ); + + expect( + () => processor.parseResponse(args['llm_response']! as String), + throwsA(isA()), + ); + }, + skip: pendingEndToEnd, + ); + + test('rejects a payload whose messages omit the version', () { + final Map data = conformanceCase( + 'test_primary_use_case_rejects_missing_version', + ); + final args = data['args']! as Map; + + final generator = A2uiGenerator( + catalogs: [CatalogConfig(basicCatalog())], + ); + final A2uiRequestProcessor processor = + generator.createProcessor( + A2uiRendererCapabilities.fromJson( + args['client_capabilities']! as Map, + ), + ); + + expect( + () => processor.parseResponse(args['llm_response']! as String), + throwsA(isA()), + ); + }, skip: pendingEndToEnd); + + test( + 'rejects a renderer that supports no registered catalog', + () { + final Map data = conformanceCase( + 'test_primary_use_case_rejects_unknown_catalog', + ); + final args = data['args']! as Map; + + final generator = A2uiGenerator( + catalogs: [CatalogConfig(basicCatalog())], + ); + + expect( + () => generator.createProcessor( + A2uiRendererCapabilities.fromJson( + args['client_capabilities']! as Map, + ), + ), + throwsA(isA()), + ); + }, + skip: pendingEndToEnd, + ); + }); + + group('primary use case data', () { + test('the conformance suite carries every case this test drives', () { + expect( + cases.map((c) => c['name']), + containsAll([ + 'test_primary_use_case_basic_catalog_login_form', + 'test_primary_use_case_rejects_unsupported_version', + 'test_primary_use_case_rejects_missing_version', + 'test_primary_use_case_rejects_unknown_catalog', + ]), + ); + }); + + test('the turn is expressed against the published basic catalog', () { + final Map data = conformanceCase( + 'test_primary_use_case_basic_catalog_login_form', + ); + final catalog = data['catalog']! as Map; + + expect(catalog['version'], '0.9'); + expect(catalog['catalog_schema'], basicCatalogPath); + }); + }); +} diff --git a/dart/a2ui_agent/test/inference_formats/direct_json_format_test.dart b/dart/a2ui_agent/test/inference_formats/direct_json_format_test.dart new file mode 100644 index 0000000000..1e433bb2cd --- /dev/null +++ b/dart/a2ui_agent/test/inference_formats/direct_json_format_test.dart @@ -0,0 +1,231 @@ +// Copyright 2024 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 'package:a2ui_agent/a2ui_agent.dart'; +import 'package:a2ui_core/a2ui_core.dart'; +import 'package:test/test.dart'; + +import '../test_catalogs.dart'; + +/// Marks a test describing behaviour the DIRECT_JSON prompt generator does not +/// implement yet. Remove the skip alongside the implementation. +const String pendingPromptGenerator = + 'DirectJsonPromptGenerator.generate is not implemented yet.'; + +void main() { + group('DirectJsonFormatFactory', () { + test('builds a format bound to the given catalogs', () { + final DirectJsonFormat format = + const DirectJsonFormatFactory() + .createFormat([smallCatalog()]); + + expect( + format, + isA>(), + ); + expect(format.catalogs, hasLength(1)); + expect(format.catalogs.single.components.keys, contains('Text')); + }); + + test('passes examples through to the prompt generator', () { + final examples = >{ + 'a login form': [ + CreateSurfaceMessage(surfaceId: 's1', catalogId: basicCatalogId), + ], + }; + + final DirectJsonFormat format = + const DirectJsonFormatFactory() + .createFormat([smallCatalog()], examples: examples); + + expect(format.promptGenerator.examples, same(examples)); + }); + + test('passes the allowed message list through to the prompt generator', () { + final DirectJsonFormat format = + const DirectJsonFormatFactory( + allowedMessages: ['createSurface', 'updateComponents'], + ).createFormat([smallCatalog()]); + + expect(format.promptGenerator.allowedMessages, [ + 'createSurface', + 'updateComponents', + ]); + }); + }); + + group('DirectJsonFormat', () { + test('creates a fresh parser for each turn', () { + final format = DirectJsonFormat([ + smallCatalog(), + ]); + + final Parser first = format.createParser(); + final Parser second = format.createParser(); + + expect(first, isA>()); + expect(identical(first, second), isFalse); + }); + + test('binds the parser to the format catalogs', () { + final SchemaCatalog catalog = smallCatalog(); + final format = DirectJsonFormat([ + catalog, + ]); + + final parser = + format.createParser() + as DirectJsonParser; + + expect(parser.catalogs.single, same(catalog)); + }); + + test('exposes one prompt generator', () { + final format = DirectJsonFormat([ + smallCatalog(), + ]); + + expect(identical(format.promptGenerator, format.promptGenerator), isTrue); + }); + }); + + group('DirectJsonPromptGenerator', () { + test('holds the catalogs it will describe', () { + final generator = + DirectJsonPromptGenerator([ + smallCatalog(), + ]); + + expect(generator.catalogs, hasLength(1)); + expect(generator.examples, isNull); + expect(generator.allowedMessages, isNull); + }); + + test( + 'embeds the catalog schema in an a2ui_schema block', + () { + final generator = + DirectJsonPromptGenerator([ + basicCatalog(), + ]); + + final String prompt = generator.generate(); + + expect(prompt, contains(a2uiSchemaOpenTag)); + expect(prompt, contains(a2uiSchemaCloseTag)); + expect(prompt, contains('"Card"')); + expect(prompt, contains('"TextField"')); + expect(prompt, contains('"required"')); + }, + skip: pendingPromptGenerator, + ); + + test( + 'instructs the model to emit payloads inside a2ui-json tags', + () { + final generator = + DirectJsonPromptGenerator([ + smallCatalog(), + ]); + + final String prompt = generator.generate(); + + expect(prompt, contains(a2uiJsonOpenTag)); + expect(prompt, contains(a2uiJsonCloseTag)); + }, + skip: pendingPromptGenerator, + ); + + test( + 'describes only the components a pruned catalog still declares', + () { + final SchemaCatalog pruned = + ComponentPruningTransformer([ + 'Text', + ]).transform(smallCatalog()); + + final String prompt = + DirectJsonPromptGenerator([ + pruned, + ]).generate(); + + expect(prompt, contains('"Text"')); + expect(prompt, isNot(contains('"Button"'))); + }, + skip: pendingPromptGenerator, + ); + + test('renders the example turns it was given', () { + final generator = + DirectJsonPromptGenerator( + [smallCatalog()], + examples: { + 'a greeting': [ + CreateSurfaceMessage( + surfaceId: 's1', + catalogId: basicCatalogId, + ), + ], + }, + ); + + final String prompt = generator.generate(); + + expect(prompt, contains('a greeting')); + expect(prompt, contains('createSurface')); + }, skip: pendingPromptGenerator); + + test( + 'restricts the described envelopes to the allowed messages', + () { + final generator = + DirectJsonPromptGenerator( + [smallCatalog()], + allowedMessages: ['createSurface'], + ); + + final String prompt = generator.generate(); + + expect(prompt, contains('createSurface')); + expect(prompt, isNot(contains('deleteSurface'))); + }, + skip: pendingPromptGenerator, + ); + + test('describes the protocol version it targets', () { + final String prompt = + DirectJsonPromptGenerator([ + smallCatalog(), + ]).generate(); + + expect(prompt, contains('v0.9')); + }, skip: pendingPromptGenerator); + }); + + group('DIRECT_JSON constants', () { + test('name the sentinel tags the format uses', () { + expect(a2uiJsonOpenTag, ''); + expect(a2uiJsonCloseTag, ''); + expect(a2uiSchemaOpenTag, ''); + expect(a2uiSchemaCloseTag, ''); + }); + + test('list the string keys that may be healed mid-stream', () { + expect(defaultProgressiveKeys, contains('text')); + expect(defaultProgressiveKeys, contains('label')); + expect(defaultProgressiveKeys, contains('literalString')); + expect(defaultProgressiveKeys, isNot(contains('component'))); + }); + }); +} diff --git a/dart/a2ui_agent/test/inference_formats/direct_json_parser_test.dart b/dart/a2ui_agent/test/inference_formats/direct_json_parser_test.dart new file mode 100644 index 0000000000..14b2c58b0c --- /dev/null +++ b/dart/a2ui_agent/test/inference_formats/direct_json_parser_test.dart @@ -0,0 +1,242 @@ +// Copyright 2024 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 'package:a2ui_agent/a2ui_agent.dart'; +import 'package:a2ui_core/a2ui_core.dart'; +import 'package:test/test.dart'; + +import '../test_catalogs.dart'; + +/// Marks a test describing behaviour the DIRECT_JSON parser does not implement +/// yet. Remove the skip alongside the implementation. +const String pendingParser = 'DirectJsonParser is not implemented yet.'; + +DirectJsonParser parser({ + Set? progressiveKeys, +}) => DirectJsonParser( + catalogs: [basicCatalog()], + customProgressiveKeys: progressiveKeys, +); + +String wrapped(String payload) => '$a2uiJsonOpenTag$payload$a2uiJsonCloseTag'; + +const String createSurfaceJson = + '[{"version": "v0.9", "createSurface": {"surfaceId": "s1", "catalogId": ' + '"$basicCatalogId"}}]'; + +void main() { + group('DirectJsonParser configuration', () { + test('is bound to the catalogs it validates against', () { + expect(parser().catalogs.single.id, basicCatalogId); + }); + + test('defaults to the shared progressive key set', () { + expect(parser().progressiveKeys, defaultProgressiveKeys); + }); + + test('honours a custom progressive key set', () { + expect(parser(progressiveKeys: {'customCuttable'}).progressiveKeys, { + 'customCuttable', + }); + }); + + test('declares streaming support', () { + expect(parser().supportsStreaming, isTrue); + }); + + test('builds a validator over the same catalogs by default', () { + expect(parser().validator.catalogs.keys, [basicCatalogId]); + }); + + test('exposes a stream processor bound to its configuration', () { + final DirectJsonParser p = parser( + progressiveKeys: {'text'}, + ); + expect(p.streamProcessor.progressiveKeys, {'text'}); + expect(p.streamProcessor.catalogs.single.id, basicCatalogId); + }); + }); + + group('DirectJsonParser.unwrap', () { + test('extracts a single payload block', () { + final List parts = parser().unwrap( + wrapped(createSurfaceJson), + ); + + expect(parts, hasLength(1)); + expect(parts.single.part, const RawA2uiPart(createSurfaceJson)); + expect(parts.single.isFinal, isTrue); + }, skip: pendingParser); + + test('preserves surrounding conversational text in order', () { + final List parts = parser().unwrap( + 'Before\n${wrapped(createSurfaceJson)}\nAfter', + ); + + expect(parts, hasLength(3)); + expect(parts[0].part, const TextPart('Before')); + expect(parts[1].part, const RawA2uiPart(createSurfaceJson)); + expect(parts[2].part, const TextPart('After')); + }, skip: pendingParser); + + test('extracts several payload blocks in order', () { + final List parts = parser().unwrap( + '${wrapped("[1]")} middle ${wrapped("[2]")}', + ); + + expect( + parts.map((p) => p.part).whereType().map((p) => p.a2uiRaw), + ['[1]', '[2]'], + ); + }, skip: pendingParser); + + test('strips a markdown fence inside the payload block', () { + final List parts = parser().unwrap( + '$a2uiJsonOpenTag\n```json\n[]\n```\n$a2uiJsonCloseTag', + ); + + expect(parts.single.part, const RawA2uiPart('[]')); + }, skip: pendingParser); + + test('marks an unterminated block as not final', () { + final List parts = parser().unwrap( + '$a2uiJsonOpenTag[{"version"', + ); + + expect(parts.single.isFinal, isFalse); + }, skip: pendingParser); + + test('rejects a response with no payload block', () { + expect( + () => parser().unwrap('Just conversation.'), + throwsA(isA()), + ); + expect(() => parser().unwrap(''), throwsA(isA())); + }, skip: pendingParser); + + test('rejects an empty payload block', () { + expect( + () => parser().unwrap(wrapped('')), + throwsA(isA()), + ); + }, skip: pendingParser); + }); + + group('DirectJsonParser.compile', () { + test('compiles a payload into typed messages', () { + final List messages = parser().compile(createSurfaceJson); + + expect(messages, hasLength(1)); + expect(messages.single, isA()); + expect((messages.single as CreateSurfaceMessage).surfaceId, 's1'); + }, skip: pendingParser); + + test('repairs a trailing comma', () { + expect( + parser().compile( + '[{"version": "v0.9", "deleteSurface": {"surfaceId": "s1"}},]', + ), + hasLength(1), + ); + }, skip: pendingParser); + + test('wraps a bare object in a list', () { + expect( + parser().compile( + '{"version": "v0.9", "deleteSurface": {"surfaceId": "s1"}}', + ), + hasLength(1), + ); + }, skip: pendingParser); + + test('rejects a message declaring another protocol version', () { + expect( + () => parser().compile( + '[{"version": "v1.0", "deleteSurface": {"surfaceId": "s1"}}]', + ), + throwsA(isA()), + ); + }, skip: pendingParser); + + test('rejects a message that omits the version', () { + expect( + () => parser().compile('[{"deleteSurface": {"surfaceId": "s1"}}]'), + throwsA(isA()), + ); + }, skip: pendingParser); + + test('rejects content that is not JSON', () { + expect( + () => parser().compile('not json at all'), + throwsA(isA()), + ); + }, skip: pendingParser); + }); + + group('DirectJsonParser.decompile', () { + test('renders messages as formatted A2UI JSON', () { + final String rendered = parser().decompile([ + CreateSurfaceMessage(surfaceId: 's1', catalogId: basicCatalogId), + ]); + + expect(rendered, contains('"createSurface"')); + expect(rendered, contains('"surfaceId"')); + expect(parser().compile(rendered), hasLength(1)); + }, skip: pendingParser); + }); + + group('DirectJsonParser.wrap', () { + test('re-adds the sentinel tags around payload blocks', () { + final String content = parser().wrap([ + RawResponsePart(const TextPart('Here you go.')), + RawResponsePart(const RawA2uiPart('[]')), + ]); + + expect(content, contains('Here you go.')); + expect(content, contains(a2uiJsonOpenTag)); + expect(content, contains(a2uiJsonCloseTag)); + }, skip: pendingParser); + + test('round trips through unwrap', () { + final blocks = [ + RawResponsePart(const TextPart('Hi')), + RawResponsePart(const RawA2uiPart(createSurfaceJson)), + ]; + + expect(parser().unwrap(parser().wrap(blocks)), blocks); + }, skip: pendingParser); + }); + + group('DirectJsonParser.parseResponse', () { + test('returns text and compiled parts in order', () { + final List parts = parser().parseResponse( + 'Here you go.\n${wrapped(createSurfaceJson)}\nAnything else?', + ); + + expect(parts, hasLength(3)); + expect(parts[0], const TextPart('Here you go.')); + expect((parts[1] as A2uiPart).a2ui.single, isA()); + expect(parts[2], const TextPart('Anything else?')); + }, skip: pendingParser); + + test('compiles the whole response when it is not wrapped', () { + final List parts = parser().parseResponse( + createSurfaceJson, + wrapped: false, + ); + + expect(parts.single, isA()); + }, skip: pendingParser); + }); +} diff --git a/dart/a2ui_agent/test/inference_formats/direct_json_streaming_test.dart b/dart/a2ui_agent/test/inference_formats/direct_json_streaming_test.dart new file mode 100644 index 0000000000..71aa734448 --- /dev/null +++ b/dart/a2ui_agent/test/inference_formats/direct_json_streaming_test.dart @@ -0,0 +1,156 @@ +// Copyright 2024 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 'package:a2ui_agent/a2ui_agent.dart'; +import 'package:a2ui_core/a2ui_core.dart'; +import 'package:test/test.dart'; + +import '../test_catalogs.dart'; + +/// Marks a test describing behaviour the DIRECT_JSON stream processor does not +/// implement yet. Remove the skip alongside the implementation. +const String pendingStreaming = + 'DirectJsonStreamProcessor is not implemented yet.'; + +DirectJsonStreamProcessor processor({ + Set? progressiveKeys, +}) => DirectJsonStreamProcessor( + catalogs: [basicCatalog()], + progressiveKeys: progressiveKeys ?? defaultProgressiveKeys, +); + +void main() { + group('DirectJsonStreamProcessor configuration', () { + test('is bound to its catalogs and progressive keys', () { + final DirectJsonStreamProcessor p = + processor(progressiveKeys: {'text'}); + + expect(p.catalogs.single.id, basicCatalogId); + expect(p.progressiveKeys, {'text'}); + expect(p.validator.catalogs.keys, [basicCatalogId]); + }); + }); + + group('DirectJsonStreamProcessor.process', () { + test('yields conversational text as soon as it arrives', () { + expect(processor().process('Here is your '), [ + const TextPart('Here is your '), + ]); + }, skip: pendingStreaming); + + test('buffers a payload until the message is complete', () { + final DirectJsonStreamProcessor p = + processor(); + + expect(p.process('$a2uiJsonOpenTag[{"version": "v0.9",'), isEmpty); + + final List parts = p.process( + '"createSurface": {"surfaceId": "s1", "catalogId": ' + '"$basicCatalogId"}}', + ); + + expect(parts, hasLength(1)); + expect( + (parts.single as A2uiPart).a2ui.single, + isA(), + ); + }, skip: pendingStreaming); + + test('yields each completed message once', () { + final DirectJsonStreamProcessor p = + processor() + ..process('$a2uiJsonOpenTag[') + ..process( + '{"version": "v0.9", "createSurface": {"surfaceId": "s1", ' + '"catalogId": "$basicCatalogId"}},', + ); + + final List parts = p.process( + '{"version": "v0.9", "deleteSurface": {"surfaceId": "s1"}}]', + ); + + expect(parts, hasLength(1)); + expect( + (parts.single as A2uiPart).a2ui.single, + isA(), + ); + }, skip: pendingStreaming); + + test( + 'heals a string cut mid-token when its key is progressive', + () { + final DirectJsonStreamProcessor p = + processor()..process( + '$a2uiJsonOpenTag[{"version": "v0.9", "updateComponents": ' + '{"surfaceId": "s1", "components": [{"id": "t", ' + '"component": "Text", "text": "Partial te', + ); + + expect(p.progressiveKeys, contains('text')); + }, + skip: pendingStreaming, + ); + + test( + 'rejects a message declaring another protocol version', + () { + expect( + () => processor().process( + '$a2uiJsonOpenTag[{"version": "v1.0", "deleteSurface": ' + '{"surfaceId": "s1"}}]$a2uiJsonCloseTag', + ), + throwsA(isA()), + ); + }, + skip: pendingStreaming, + ); + }); + + group('DirectJsonStreamProcessor.finish', () { + test('reports an unterminated payload block', () { + final DirectJsonStreamProcessor p = + processor()..process('$a2uiJsonOpenTag[{"version"'); + + expect(p.finish, throwsA(isA())); + }, skip: pendingStreaming); + + test('flushes trailing conversational text', () { + final DirectJsonStreamProcessor p = + processor()..process('Trailing'); + + expect(p.finish(), isEmpty); + }, skip: pendingStreaming); + }); + + group('DirectJsonStreamProcessor.reset', () { + test('discards buffered state so a new turn can start', () { + final DirectJsonStreamProcessor p = + processor()..process('$a2uiJsonOpenTag[{"version"'); + + p.reset(); + + expect(p.process('Fresh turn.'), [const TextPart('Fresh turn.')]); + }, skip: pendingStreaming); + }); + + group('DirectJsonParser.parseChunk', () { + test('delegates to the stream processor', () { + final parser = DirectJsonParser( + catalogs: [basicCatalog()], + ); + + expect(parser.parseChunk('Hello'), [const TextPart('Hello')]); + }, skip: pendingStreaming); + }); +} diff --git a/dart/a2ui_agent/test/inference_formats/express_test.dart b/dart/a2ui_agent/test/inference_formats/express_test.dart new file mode 100644 index 0000000000..5c7e0f6d9e --- /dev/null +++ b/dart/a2ui_agent/test/inference_formats/express_test.dart @@ -0,0 +1,220 @@ +// Copyright 2024 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 'package:a2ui_agent/a2ui_agent.dart'; +import 'package:a2ui_core/a2ui_core.dart'; +import 'package:test/test.dart'; + +import '../test_catalogs.dart'; + +/// Marks a test describing behaviour the EXPRESS format does not implement yet. +/// +/// The format is declared so that agents can select it through +/// [InferenceFormatFactory], but the grammar, compiler and decompiler are still +/// to be written. Remove the skip alongside the implementation. +const String pendingExpress = 'The EXPRESS format is not implemented yet.'; + +const String expressSource = 'createSurface(s1, "$basicCatalogId")'; + +void main() { + group('ExpressFormatFactory', () { + test('builds a format bound to the given catalogs', () { + final ExpressFormat format = + const ExpressFormatFactory() + .createFormat([smallCatalog()]); + + expect(format, isA>()); + expect(format.catalogs, hasLength(1)); + }); + + test('passes examples through to the prompt generator', () { + final examples = >{ + 'a greeting': [ + CreateSurfaceMessage(surfaceId: 's1', catalogId: basicCatalogId), + ], + }; + + final ExpressFormat format = + const ExpressFormatFactory() + .createFormat([smallCatalog()], examples: examples); + + expect(format.promptGenerator.examples, same(examples)); + }); + + test('is interchangeable with the DIRECT_JSON factory', () { + final factories = + >[ + const DirectJsonFormatFactory(), + const ExpressFormatFactory(), + ]; + + for (final factory in factories) { + expect( + factory.createFormat([smallCatalog()]).promptGenerator.catalogs, + hasLength(1), + ); + } + }); + }); + + group('ExpressFormat', () { + test('creates a fresh parser for each turn', () { + final format = ExpressFormat([ + smallCatalog(), + ]); + + final Parser first = format.createParser(); + + expect(first, isA>()); + expect(identical(first, format.createParser()), isFalse); + }); + }); + + group('ExpressPromptGenerator', () { + test( + 'renders compact positional signatures for the catalog', + () { + final String prompt = + ExpressPromptGenerator([ + smallCatalog(), + ]).generate(); + + expect(prompt, contains(a2uiExpressOpenTag)); + expect(prompt, contains('Text')); + expect(prompt, contains('Card')); + }, + skip: pendingExpress, + ); + }); + + group('ExpressCompiler', () { + test('compiles a DSL expression into A2UI messages', () { + final List messages = + ExpressCompiler( + catalogs: [basicCatalog()], + ).compile(expressSource); + + expect(messages.single, isA()); + }, skip: pendingExpress); + + test('rejects a malformed expression', () { + expect( + () => ExpressCompiler( + catalogs: [basicCatalog()], + ).compile('createSurface('), + throwsA(isA()), + ); + }, skip: pendingExpress); + + test( + 'rejects a component the active catalogs do not declare', + () { + expect( + () => ExpressCompiler( + catalogs: [smallCatalog()], + ).compile('Video(v1, "https://example.com/clip.mp4")'), + throwsA(isA()), + ); + }, + skip: pendingExpress, + ); + }); + + group('ExpressDecompiler', () { + test('renders A2UI messages back into DSL notation', () { + final String rendered = + ExpressDecompiler( + catalogs: [basicCatalog()], + ).decompile([ + CreateSurfaceMessage(surfaceId: 's1', catalogId: basicCatalogId), + ]); + + expect(rendered, contains('createSurface')); + }, skip: pendingExpress); + }); + + group('ExpressParser', () { + test('declares streaming support', () { + expect( + ExpressParser( + catalogs: [smallCatalog()], + ).supportsStreaming, + isTrue, + ); + }); + + test('builds a compiler and decompiler over the same catalogs', () { + final parser = ExpressParser( + catalogs: [smallCatalog()], + ); + + expect(parser.compiler.catalogs, same(parser.catalogs)); + expect(parser.decompiler.catalogs, same(parser.catalogs)); + }); + + test('unwraps payloads from a2ui-express tags', () { + final List parts = + ExpressParser( + catalogs: [basicCatalog()], + ).unwrap('Hi\n$a2uiExpressOpenTag$expressSource$a2uiExpressCloseTag'); + + expect(parts, hasLength(2)); + expect(parts.first.part, const TextPart('Hi')); + }, skip: pendingExpress); + + test('delegates compilation to the compiler', () { + final List messages = + ExpressParser( + catalogs: [basicCatalog()], + ).compile(expressSource); + + expect(messages.single, isA()); + }, skip: pendingExpress); + + test('delegates decompilation to the decompiler', () { + final String rendered = + ExpressParser( + catalogs: [basicCatalog()], + ).decompile([ + CreateSurfaceMessage(surfaceId: 's1', catalogId: basicCatalogId), + ]); + + expect(rendered, contains('createSurface')); + }, skip: pendingExpress); + + test('processes streamed chunks', () { + final parser = ExpressParser( + catalogs: [basicCatalog()], + ); + + expect(parser.parseChunk('Hello'), [const TextPart('Hello')]); + }, skip: pendingExpress); + + test('rejects a message declaring another protocol version', () { + expect( + () => ExpressParser( + catalogs: [basicCatalog()], + ).compile('createSurface(s1, "$basicCatalogId", version: "v1.0")'), + throwsA(isA()), + ); + }, skip: pendingExpress); + }); + + group('EXPRESS constants', () { + test('name the sentinel tags the format uses', () { + expect(a2uiExpressOpenTag, ''); + expect(a2uiExpressCloseTag, ''); + }); + }); +} diff --git a/dart/a2ui_agent/test/parser/parser_test.dart b/dart/a2ui_agent/test/parser/parser_test.dart new file mode 100644 index 0000000000..d5bc226a8b --- /dev/null +++ b/dart/a2ui_agent/test/parser/parser_test.dart @@ -0,0 +1,131 @@ +// Copyright 2024 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 'package:a2ui_agent/a2ui_agent.dart'; +import 'package:a2ui_core/a2ui_core.dart'; +import 'package:test/test.dart'; + +/// A parser that records what the base class asks of it. +/// +/// Exercises `Parser.parseResponse`, the one behaviour the abstract class +/// supplies rather than delegating to a format implementation. +class RecordingParser extends Parser { + final List unwrapResult; + final List compiled = []; + final List unwrapped = []; + + RecordingParser(this.unwrapResult); + + @override + String wrap(List blocks) => blocks + .map( + (b) => switch (b.part) { + TextPart(:final String text) => text, + RawA2uiPart(:final String a2uiRaw) => '$a2uiRaw', + _ => '', + }, + ) + .join(); + + @override + List unwrap(String content) { + unwrapped.add(content); + return unwrapResult; + } + + @override + List compile(String formatContent) { + compiled.add(formatContent); + return [CreateSurfaceMessage(surfaceId: formatContent, catalogId: 'c')]; + } + + @override + String decompile(List a2uiPayload) => + a2uiPayload.length.toString(); + + @override + List parseChunk(String chunk, {bool wrapped = true}) => + throw UnimplementedError('RecordingParser.parseChunk'); +} + +void main() { + group('Parser.parseResponse', () { + test('preserves the order of text and payload blocks', () { + final parser = RecordingParser([ + RawResponsePart(const TextPart('before')), + RawResponsePart(const RawA2uiPart('first')), + RawResponsePart(const TextPart('between')), + RawResponsePart(const RawA2uiPart('second')), + RawResponsePart(const TextPart('after')), + ]); + + final List parts = parser.parseResponse('ignored'); + + expect(parts, hasLength(5)); + expect(parts[0], const TextPart('before')); + expect(parts[1], isA()); + expect(parts[2], const TextPart('between')); + expect(parts[3], isA()); + expect(parts[4], const TextPart('after')); + expect(parser.compiled, ['first', 'second']); + }); + + test('unwraps the content it is given', () { + final parser = RecordingParser([RawResponsePart(const TextPart('t'))]); + parser.parseResponse('raw response'); + expect(parser.unwrapped, ['raw response']); + }); + + test('compiles the whole content when it is not wrapped', () { + final parser = RecordingParser([]); + + final List parts = parser.parseResponse( + '[{"a": 1}]', + wrapped: false, + ); + + expect(parts, hasLength(1)); + expect(parts.single, isA()); + expect(parser.compiled, ['[{"a": 1}]']); + expect(parser.unwrapped, isEmpty, reason: 'unwrap must be skipped'); + }); + + test('returns no parts for an empty unwrap result', () { + expect(RecordingParser([]).parseResponse('anything'), isEmpty); + }); + + test('surfaces a compile failure to the caller', () { + final parser = _FailingParser(); + + expect( + () => parser.parseResponse('anything'), + throwsA(isA()), + ); + }); + }); + + group('Parser defaults', () { + test('does not claim streaming support', () { + expect(RecordingParser([]).supportsStreaming, isFalse); + }); + }); +} + +class _FailingParser extends RecordingParser { + _FailingParser() : super([RawResponsePart(const RawA2uiPart('bad'))]); + + @override + List compile(String formatContent) => + throw A2uiCompileError('boom', rawContent: formatContent); +} diff --git a/dart/a2ui_agent/test/parser/response_part_test.dart b/dart/a2ui_agent/test/parser/response_part_test.dart new file mode 100644 index 0000000000..7baf55be8f --- /dev/null +++ b/dart/a2ui_agent/test/parser/response_part_test.dart @@ -0,0 +1,136 @@ +// Copyright 2024 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 'package:a2ui_agent/a2ui_agent.dart'; +import 'package:a2ui_core/a2ui_core.dart'; +import 'package:test/test.dart'; + +A2uiMessage createSurface(String surfaceId) => + CreateSurfaceMessage(surfaceId: surfaceId, catalogId: 'c'); + +void main() { + group('TextPart', () { + test('carries the conversational text', () { + expect(const TextPart('hello').text, 'hello'); + }); + + test('compares by value', () { + expect(const TextPart('a'), const TextPart('a')); + expect(const TextPart('a'), isNot(const TextPart('b'))); + expect(const TextPart('a').hashCode, const TextPart('a').hashCode); + }); + + test('elides long text when described', () { + expect(const TextPart('short').toString(), "TextPart('short')"); + expect(TextPart('x' * 100).toString(), endsWith("...')")); + }); + }); + + group('RawA2uiPart', () { + test('carries the uncompiled format content', () { + expect(const RawA2uiPart('[{}]').a2uiRaw, '[{}]'); + }); + + test('compares by value', () { + expect(const RawA2uiPart('[]'), const RawA2uiPart('[]')); + expect(const RawA2uiPart('[]'), isNot(const RawA2uiPart('[{}]'))); + expect( + const RawA2uiPart('[]').hashCode, + const RawA2uiPart('[]').hashCode, + ); + }); + }); + + group('A2uiPart', () { + test('carries the compiled messages', () { + final part = A2uiPart([createSurface('s1')]); + expect(part.a2ui, hasLength(1)); + expect(part.a2ui.single, isA()); + }); + + test('compares by the JSON its messages render to', () { + expect(A2uiPart([createSurface('s1')]), A2uiPart([createSurface('s1')])); + expect( + A2uiPart([createSurface('s1')]), + isNot(A2uiPart([createSurface('s2')])), + ); + expect( + A2uiPart([createSurface('s1')]).hashCode, + A2uiPart([createSurface('s1')]).hashCode, + ); + }); + + test('describes itself by message count', () { + expect( + A2uiPart([createSurface('s1')]).toString(), + 'A2uiPart(1 message(s))', + ); + }); + }); + + group('RawResponsePart', () { + test('wraps a text part', () { + final part = RawResponsePart(const TextPart('hi')); + expect(part.part, const TextPart('hi')); + expect(part.isFinal, isTrue); + }); + + test('wraps an uncompiled A2UI part and records truncation', () { + final part = RawResponsePart(const RawA2uiPart('[{'), isFinal: false); + expect(part.part, const RawA2uiPart('[{')); + expect(part.isFinal, isFalse); + }); + + test('rejects a compiled part', () { + expect( + () => RawResponsePart(A2uiPart([createSurface('s1')])), + throwsArgumentError, + ); + }); + + test('compares by value', () { + expect( + RawResponsePart(const TextPart('a')), + RawResponsePart(const TextPart('a')), + ); + expect( + RawResponsePart(const TextPart('a')), + isNot(RawResponsePart(const TextPart('a'), isFinal: false)), + ); + expect( + RawResponsePart(const TextPart('a')).hashCode, + RawResponsePart(const TextPart('a')).hashCode, + ); + }); + + test('describes its content and completeness', () { + expect( + RawResponsePart(const TextPart('a'), isFinal: false).toString(), + "RawResponsePart(TextPart('a'), isFinal: false)", + ); + }); + }); + + group('ResponsePart hierarchy', () { + test('a parsed response holds text and compiled parts', () { + final parts = [ + const TextPart('hello'), + A2uiPart([createSurface('s1')]), + ]; + + expect(parts.whereType(), hasLength(1)); + expect(parts.whereType(), hasLength(1)); + }); + }); +} diff --git a/dart/a2ui_agent/test/processor/catalog_config_test.dart b/dart/a2ui_agent/test/processor/catalog_config_test.dart new file mode 100644 index 0000000000..b471c98a03 --- /dev/null +++ b/dart/a2ui_agent/test/processor/catalog_config_test.dart @@ -0,0 +1,123 @@ +// Copyright 2024 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 'package:a2ui_agent/a2ui_agent.dart'; +import 'package:a2ui_core/a2ui_core.dart'; +import 'package:test/test.dart'; + +import '../test_catalogs.dart'; + +void main() { + group('CatalogConfig', () { + test('returns the pristine catalog when no transformer is configured', () { + final SchemaCatalog catalog = smallCatalog(); + final CatalogConfig config = + CatalogConfig(catalog); + + expect(config.transformers, isEmpty); + expect(config.transformedCatalog.components.keys.toSet(), { + 'Text', + 'Card', + 'Button', + }); + }); + + test('applies its transformers in order', () { + final SchemaCatalogConfig config = SchemaCatalogConfig( + smallCatalog(), + transformers: [ + ComponentPruningTransformer(['Text', 'Card']), + ComponentPruningTransformer(['Text']), + ], + ); + + expect(config.transformedCatalog.components.keys, ['Text']); + }); + + test('narrows components and functions independently', () { + final SchemaCatalogConfig config = SchemaCatalogConfig( + smallCatalog(), + transformers: [ + ComponentPruningTransformer(['Text']), + FunctionPruningTransformer(['required']), + ], + ); + + final SchemaCatalog transformed = config.transformedCatalog; + expect(transformed.components.keys, ['Text']); + expect(transformed.functions.keys, ['required']); + }); + + test('leaves the pristine catalog untouched', () { + final SchemaCatalog catalog = smallCatalog(); + CatalogConfig( + catalog, + transformers: [ + ComponentPruningTransformer(['Text']), + ], + ).transformedCatalog; + + expect(catalog.components.keys.toSet(), {'Text', 'Card', 'Button'}); + }); + + test('recomputes the transformed catalog on each read', () { + final SchemaCatalogConfig config = SchemaCatalogConfig( + smallCatalog(), + transformers: [ + ComponentPruningTransformer(['Text']), + ], + ); + + expect( + identical(config.transformedCatalog, config.transformedCatalog), + isFalse, + ); + }); + + test('loads a catalog from disk', () { + final CatalogConfig config = + CatalogConfig.fromPath(basicCatalogFile()); + + expect(config.catalog.id, basicCatalogId); + expect(config.transformers, isEmpty); + }); + + test('loads a catalog from disk with transformers attached', () { + final CatalogConfig config = + CatalogConfig.fromPath( + basicCatalogFile(), + transformers: [ + ComponentPruningTransformer(['Text', 'Card']), + ], + catalogId: basicCatalogId, + protocolVersion: A2uiProtocolVersion.v0_9, + ); + + expect(config.transformedCatalog.components.keys.toSet(), { + 'Text', + 'Card', + }); + }); + + test('reports a catalog id that conflicts with the file', () { + expect( + () => CatalogConfig.fromPath( + basicCatalogFile(), + catalogId: 'https://example.com/other.json', + ), + throwsA(isA()), + ); + }); + }); +} diff --git a/dart/a2ui_agent/test/processor/catalog_providers_test.dart b/dart/a2ui_agent/test/processor/catalog_providers_test.dart new file mode 100644 index 0000000000..c858a0b138 --- /dev/null +++ b/dart/a2ui_agent/test/processor/catalog_providers_test.dart @@ -0,0 +1,143 @@ +// Copyright 2024 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 'dart:io'; + +import 'package:a2ui_agent/a2ui_agent.dart'; +import 'package:a2ui_core/a2ui_core.dart'; +import 'package:test/test.dart'; + +import '../test_catalogs.dart'; + +void main() { + group('FileSystemCatalogProvider', () { + test('loads the published basic catalog', () { + final SchemaCatalog catalog = FileSystemCatalogProvider( + basicCatalogFile(), + ).load(); + + expect(catalog.id, basicCatalogId); + expect(catalog.protocolVersion, A2uiProtocolVersion.v0_9); + expect(catalog.components.keys, contains('Button')); + expect(catalog.functions.keys, contains('required')); + }); + + test('accepts a matching expected catalog id', () { + expect( + FileSystemCatalogProvider( + basicCatalogFile(), + catalogId: basicCatalogId, + ).load().id, + basicCatalogId, + ); + }); + + test('rejects a conflicting expected catalog id', () { + expect( + FileSystemCatalogProvider( + basicCatalogFile(), + catalogId: 'https://example.com/other.json', + ).load, + throwsA(isA()), + ); + }); + + test('accepts a matching expected protocol version', () { + expect( + FileSystemCatalogProvider( + basicCatalogFile(), + protocolVersion: A2uiProtocolVersion.v0_9, + ).load().protocolVersion, + A2uiProtocolVersion.v0_9, + ); + }); + + test('reports a missing file', () { + expect( + const FileSystemCatalogProvider('/no/such/catalog.json').load, + throwsA( + isA().having( + (e) => e.message, + 'message', + contains('not found'), + ), + ), + ); + }); + + test('reports a file that is not valid JSON', () { + final Directory dir = Directory.systemTemp.createTempSync('a2ui_agent'); + addTearDown(() => dir.deleteSync(recursive: true)); + final file = File('${dir.path}/broken.json')..writeAsStringSync('{oops'); + + expect( + FileSystemCatalogProvider(file.path).load, + throwsA( + isA().having( + (e) => e.message, + 'message', + contains('not valid JSON'), + ), + ), + ); + }); + + test('reports a file that is not a JSON object', () { + final Directory dir = Directory.systemTemp.createTempSync('a2ui_agent'); + addTearDown(() => dir.deleteSync(recursive: true)); + final file = File('${dir.path}/list.json')..writeAsStringSync('[]'); + + expect( + FileSystemCatalogProvider(file.path).load, + throwsA(isA()), + ); + }); + }); + + group('InMemoryCatalogProvider', () { + test('parses an in-memory schema', () { + final SchemaCatalog catalog = InMemoryCatalogProvider( + basicCatalogJson(), + ).load(); + + expect(catalog.id, basicCatalogId); + }); + + test('rejects a conflicting expected catalog id', () { + expect( + const InMemoryCatalogProvider({ + 'catalogId': 'actual', + }, catalogId: 'expected').load, + throwsA(isA()), + ); + }); + + test('rejects a schema declaring an unsupported protocol version', () { + expect( + const InMemoryCatalogProvider({ + 'catalogId': 'c', + 'protocolVersion': 'v1.0', + }).load, + throwsA(isA()), + ); + }); + + test('rejects a schema without a catalog id', () { + expect( + const InMemoryCatalogProvider({}).load, + throwsA(isA()), + ); + }); + }); +} diff --git a/dart/a2ui_agent/test/processor/generator_test.dart b/dart/a2ui_agent/test/processor/generator_test.dart new file mode 100644 index 0000000000..ed080ee3c3 --- /dev/null +++ b/dart/a2ui_agent/test/processor/generator_test.dart @@ -0,0 +1,261 @@ +// Copyright 2024 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 'package:a2ui_agent/a2ui_agent.dart'; +import 'package:a2ui_core/a2ui_core.dart'; +import 'package:test/test.dart'; + +import '../test_catalogs.dart'; + +/// Marks a test describing behaviour capability negotiation does not implement +/// yet. Remove the skip alongside the implementation. +const String pendingNegotiation = + 'Capability negotiation is not implemented yet.'; + +A2uiGenerator generator({ + List? catalogs, + Map>? examples, + bool acceptsInlineCatalogs = false, + InferenceFormatFactory? factory, +}) => A2uiGenerator( + catalogs: catalogs ?? [CatalogConfig(basicCatalog())], + examples: examples, + acceptsInlineCatalogs: acceptsInlineCatalogs, + inferenceFormatFactory: factory, +); + +void main() { + group('A2uiGenerator configuration', () { + test('holds the catalog configurations it was registered with', () { + final A2uiGenerator g = generator(); + + expect(g.catalogs, hasLength(1)); + expect(g.catalogs.single.catalog.id, basicCatalogId); + }); + + test('defaults to the DIRECT_JSON inference format', () { + expect( + generator().inferenceFormatFactory, + isA>(), + ); + }); + + test('accepts an inference format override', () { + expect( + generator(factory: const ExpressFormatFactory()).inferenceFormatFactory, + isA>(), + ); + }); + + test('does not accept inline catalogs by default', () { + expect(generator().acceptsInlineCatalogs, isFalse); + }); + + test('holds the shared example turns', () { + final examples = >{ + 'a greeting': [ + CreateSurfaceMessage(surfaceId: 's1', catalogId: basicCatalogId), + ], + }; + + expect(generator(examples: examples).examples, same(examples)); + }); + }); + + group('A2uiGenerator.agentCapabilities', () { + test('advertises only the protocol version this SDK implements', () { + expect(generator().agentCapabilities['a2uiVersions'], ['v0.9']); + }); + + test('advertises every registered catalog id', () { + final A2uiGenerator g = generator( + catalogs: [ + CatalogConfig(basicCatalog()), + CatalogConfig(smallCatalog()), + ], + ); + + expect(g.agentCapabilities['supportedCatalogIds'], [ + basicCatalogId, + 'https://example.com/small.json', + ]); + }); + + test('advertises whether inline catalogs are accepted', () { + expect(generator().agentCapabilities['acceptsInlineCatalogs'], isFalse); + expect( + generator( + acceptsInlineCatalogs: true, + ).agentCapabilities['acceptsInlineCatalogs'], + isTrue, + ); + }); + + test('advertises the pristine catalog id, not a transformed copy', () { + final A2uiGenerator g = generator( + catalogs: [ + CatalogConfig( + basicCatalog(), + transformers: [ + ComponentPruningTransformer(['Text']), + ], + ), + ], + ); + + expect(g.agentCapabilities['supportedCatalogIds'], [basicCatalogId]); + }); + }); + + group('A2uiGenerator.createProcessor', () { + test('negotiates the catalog the renderer declares', () { + final A2uiRequestProcessor processor = + generator().createProcessor(basicCatalogCapabilities()); + + expect(processor.activeCatalogs, hasLength(1)); + expect(processor.activeCatalogs.single.id, basicCatalogId); + }, skip: pendingNegotiation); + + test('binds the processor to the transformed catalog', () { + final A2uiGenerator g = generator( + catalogs: [ + CatalogConfig( + basicCatalog(), + transformers: [ + ComponentPruningTransformer(['Text', 'Card']), + ], + ), + ], + ); + + final A2uiRequestProcessor processor = + g.createProcessor(basicCatalogCapabilities()); + + expect(processor.activeCatalogs.single.components.keys.toSet(), { + 'Text', + 'Card', + }); + }, skip: pendingNegotiation); + + test('passes the shared examples to the processor', () { + final examples = >{ + 'a greeting': [ + CreateSurfaceMessage(surfaceId: 's1', catalogId: basicCatalogId), + ], + }; + + final A2uiRequestProcessor processor = + generator( + examples: examples, + ).createProcessor(basicCatalogCapabilities()); + + expect(processor.examples, same(examples)); + }, skip: pendingNegotiation); + + test('uses the generator format factory by default', () { + final A2uiRequestProcessor processor = + generator( + factory: const ExpressFormatFactory(), + ).createProcessor(basicCatalogCapabilities()); + + expect( + processor.format, + isA>(), + ); + }, skip: pendingNegotiation); + + test('accepts a per-request format override', () { + final A2uiRequestProcessor processor = + generator().createProcessor( + basicCatalogCapabilities(), + inferenceFormatFactory: const ExpressFormatFactory(), + ); + + expect( + processor.format, + isA>(), + ); + }, skip: pendingNegotiation); + + test( + 'rejects a renderer that supports no registered catalog', + () { + expect( + () => generator().createProcessor( + A2uiRendererCapabilities.forCatalogIds([ + 'https://example.com/unknown.json', + ]), + ), + throwsA(isA()), + ); + }, + skip: pendingNegotiation, + ); + + test('rejects capabilities carrying no v0.9 entry', () { + expect( + () => A2uiRendererCapabilities.fromJson({ + 'v1.0': { + 'supportedCatalogIds': [basicCatalogId], + }, + }), + throwsA(isA()), + ); + }); + + test( + 'rejects examples that the negotiated catalog cannot express', + () { + final A2uiGenerator g = generator( + catalogs: [ + CatalogConfig( + basicCatalog(), + transformers: [ + ComponentPruningTransformer(['Text']), + ], + ), + ], + examples: { + 'uses a pruned component': [ + UpdateComponentsMessage( + surfaceId: 's1', + components: [ + {'id': 'v', 'component': 'Video', 'url': 'https://x/y.mp4'}, + ], + ), + ], + }, + ); + + expect( + () => g.createProcessor(basicCatalogCapabilities()), + throwsA(isA()), + ); + }, + skip: pendingNegotiation, + ); + + test('creates an independent processor per request', () { + final A2uiGenerator g = generator(); + + expect( + identical( + g.createProcessor(basicCatalogCapabilities()), + g.createProcessor(basicCatalogCapabilities()), + ), + isFalse, + ); + }, skip: pendingNegotiation); + }); +} diff --git a/dart/a2ui_agent/test/processor/processor_test.dart b/dart/a2ui_agent/test/processor/processor_test.dart new file mode 100644 index 0000000000..25866c11bc --- /dev/null +++ b/dart/a2ui_agent/test/processor/processor_test.dart @@ -0,0 +1,272 @@ +// Copyright 2024 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 'package:a2ui_agent/a2ui_agent.dart'; +import 'package:a2ui_core/a2ui_core.dart'; +import 'package:test/test.dart'; + +import '../test_catalogs.dart'; + +/// Marks a test describing behaviour the request processor does not implement +/// yet. Remove the skip alongside the implementation. +const String pendingProcessor = + 'A2uiRequestProcessor.parseResponse is not implemented yet.'; + +/// Marks a test describing prompt rendering, which the DIRECT_JSON prompt +/// generator does not implement yet. +const String pendingPrompt = + 'DirectJsonPromptGenerator.generate is not implemented yet.'; + +A2uiRequestProcessor processor({ + List? catalogs, + Map>? examples, + InferenceFormatFactory? factory, +}) => A2uiRequestProcessor( + activeCatalogs: catalogs ?? [basicCatalog()], + examples: examples, + formatFactory: factory, +); + +const String surfacePayload = + '[{"version": "v0.9", "createSurface": {"surfaceId": "s1", "catalogId": ' + '"$basicCatalogId"}}]'; + +void main() { + group('A2uiRequestProcessor configuration', () { + test('exposes the negotiated catalogs', () { + expect(processor().activeCatalogs.single.id, basicCatalogId); + }); + + test('defaults to the DIRECT_JSON format', () { + expect( + processor().format, + isA>(), + ); + }); + + test('accepts a format factory override', () { + expect( + processor(factory: const ExpressFormatFactory()).format, + isA>(), + ); + }); + + test('builds a validator over the negotiated catalogs', () { + expect(processor().validator.catalogs.keys, [basicCatalogId]); + expect(processor().validator.protocolVersion, A2uiProtocolVersion.v0_9); + }); + + test('exposes the example turns', () { + final examples = >{ + 'a greeting': [ + CreateSurfaceMessage(surfaceId: 's1', catalogId: basicCatalogId), + ], + }; + + expect(processor(examples: examples).examples, same(examples)); + }); + + test('creates a fresh parser per turn', () { + final A2uiRequestProcessor p = + processor(); + final Parser first = p.createParser(); + + expect(first, isA>()); + expect(identical(first, p.createParser()), isFalse); + }); + + test('binds the parser to the negotiated catalogs', () { + final parser = + processor().createParser() + as DirectJsonParser; + + expect(parser.catalogs.single.id, basicCatalogId); + }); + }); + + group('A2uiRequestProcessor.promptSnippet', () { + test('describes the negotiated catalogs', () { + final String snippet = processor().promptSnippet; + + expect(snippet, contains(a2uiJsonOpenTag)); + expect(snippet, contains('"Card"')); + expect(snippet, contains('"TextField"')); + }, skip: pendingPrompt); + + test('describes only what a pruned catalog still declares', () { + final SchemaCatalog pruned = + ComponentPruningTransformer([ + 'Text', + ]).transform(basicCatalog()); + + final String snippet = processor(catalogs: [pruned]).promptSnippet; + + expect(snippet, contains('"Text"')); + expect(snippet, isNot(contains('"Video"'))); + }, skip: pendingPrompt); + }); + + group('A2uiRequestProcessor.parseResponse', () { + test( + 'returns conversational text and compiled payloads in order', + () { + final List parts = processor().parseResponse( + 'Here you go.\n' + '$a2uiJsonOpenTag$surfacePayload$a2uiJsonCloseTag\n' + 'Anything else?', + ); + + expect(parts, hasLength(3)); + expect(parts[0], const TextPart('Here you go.')); + expect((parts[1] as A2uiPart).a2ui.single, isA()); + expect(parts[2], const TextPart('Anything else?')); + }, + skip: pendingProcessor, + ); + + test( + 'rejects a payload declaring another protocol version', + () { + expect( + () => processor().parseResponse( + '$a2uiJsonOpenTag' + '[{"version": "v1.0", "deleteSurface": {"surfaceId": "s1"}}]' + '$a2uiJsonCloseTag', + ), + throwsA(isA()), + ); + }, + skip: pendingProcessor, + ); + + test('rejects a payload whose messages omit the version', () { + expect( + () => processor().parseResponse( + '$a2uiJsonOpenTag[{"deleteSurface": {"surfaceId": "s1"}}]' + '$a2uiJsonCloseTag', + ), + throwsA(isA()), + ); + }, skip: pendingProcessor); + + test('rejects a response holding no payload block', () { + expect( + () => processor().parseResponse('Just conversation.'), + throwsA(isA()), + ); + }, skip: pendingProcessor); + + test( + 'rejects a surface created against an unnegotiated catalog', + () { + expect( + () => processor().parseResponse( + '$a2uiJsonOpenTag' + '[{"version": "v0.9", "createSurface": {"surfaceId": "s1", ' + '"catalogId": "https://example.com/unknown.json"}}]' + '$a2uiJsonCloseTag', + ), + throwsA(isA()), + ); + }, + skip: pendingProcessor, + ); + + test( + 'rejects a component the negotiated catalog does not declare', + () { + final SchemaCatalog pruned = + ComponentPruningTransformer([ + 'Text', + ]).transform(basicCatalog()); + + expect( + () => processor(catalogs: [pruned]).parseResponse( + '$a2uiJsonOpenTag' + '[{"version": "v0.9", "updateComponents": {"surfaceId": "s1", ' + '"components": [{"id": "v", "component": "Video", ' + '"url": "https://example.com/clip.mp4"}]}}]' + '$a2uiJsonCloseTag', + ), + throwsA(isA()), + ); + }, + skip: pendingProcessor, + ); + }); + + group('A2uiRequestProcessor.validateExamples', () { + test( + 'accepts examples the negotiated catalogs can express', + () { + final A2uiRequestProcessor p = + processor( + examples: { + 'a greeting': [ + CreateSurfaceMessage( + surfaceId: 's1', + catalogId: basicCatalogId, + ), + UpdateComponentsMessage( + surfaceId: 's1', + components: [ + {'id': 'root', 'component': 'Text', 'text': 'Hello'}, + ], + ), + ], + }, + ); + + expect(p.validateExamples(), completes); + }, + skip: pendingProcessor, + ); + + test( + 'rejects examples using a component the catalog does not declare', + () { + final SchemaCatalog pruned = + ComponentPruningTransformer([ + 'Text', + ]).transform(basicCatalog()); + + final A2uiRequestProcessor p = + processor( + catalogs: [pruned], + examples: { + 'uses a pruned component': [ + UpdateComponentsMessage( + surfaceId: 's1', + components: [ + { + 'id': 'v', + 'component': 'Video', + 'url': 'https://x/y.mp4', + }, + ], + ), + ], + }, + ); + + expect(p.validateExamples(), throwsA(isA())); + }, + skip: pendingProcessor, + ); + + test('accepts a processor with no examples', () { + expect(processor().validateExamples(), completes); + }, skip: pendingProcessor); + }); +} diff --git a/dart/a2ui_agent/test/test_catalogs.dart b/dart/a2ui_agent/test/test_catalogs.dart new file mode 100644 index 0000000000..966393cd4f --- /dev/null +++ b/dart/a2ui_agent/test/test_catalogs.dart @@ -0,0 +1,91 @@ +// Copyright 2024 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 'dart:convert'; +import 'dart:io'; + +import 'package:a2ui_core/a2ui_core.dart'; + +import 'conformance/conformance_harness.dart'; + +/// The path of the published basic catalog, relative to `conformance/`. +/// +/// Tests are measured against the specification's catalog rather than any +/// catalog implemented inside an SDK, so every implementation is held to the +/// same contract. +const String basicCatalogPath = + '../specification/v0_9_1/catalogs/basic/catalog.json'; + +/// The `catalogId` the published basic catalog declares. +const String basicCatalogId = + 'https://a2ui.org/specification/v0_9/catalogs/basic/catalog.json'; + +/// The absolute path of the published basic catalog. +String basicCatalogFile() => resolveConformancePath(basicCatalogPath); + +/// The published basic catalog document. +Map basicCatalogJson() => + jsonDecode(File(basicCatalogFile()).readAsStringSync()) + as Map; + +/// The published basic catalog, parsed. +SchemaCatalog basicCatalog() => Catalog.fromJson(basicCatalogJson()); + +/// Renderer capabilities declaring support for the basic catalog. +A2uiRendererCapabilities basicCatalogCapabilities() => + A2uiRendererCapabilities.forCatalogIds([basicCatalogId]); + +/// A small catalog used where the full basic catalog would obscure the case. +SchemaCatalog smallCatalog({String id = 'https://example.com/small.json'}) => + Catalog.fromJson({ + 'catalogId': id, + 'components': { + 'Text': {'type': 'object'}, + 'Card': {'type': 'object'}, + 'Button': {'type': 'object'}, + }, + 'functions': { + 'required': { + 'type': 'object', + 'properties': { + 'call': {'const': 'required'}, + 'args': {'type': 'object'}, + 'returnType': {'const': 'boolean'}, + }, + }, + 'email': { + 'type': 'object', + 'properties': { + 'call': {'const': 'email'}, + 'args': {'type': 'object'}, + 'returnType': {'const': 'boolean'}, + }, + }, + }, + r'$defs': { + 'anyComponent': { + 'oneOf': [ + {r'$ref': '#/components/Text'}, + {r'$ref': '#/components/Card'}, + {r'$ref': '#/components/Button'}, + ], + }, + 'anyFunction': { + 'oneOf': [ + {r'$ref': '#/functions/required'}, + {r'$ref': '#/functions/email'}, + ], + }, + }, + }); diff --git a/dart/a2ui_agent/test/utils/catalog_resolver_test.dart b/dart/a2ui_agent/test/utils/catalog_resolver_test.dart new file mode 100644 index 0000000000..3095cdeabd --- /dev/null +++ b/dart/a2ui_agent/test/utils/catalog_resolver_test.dart @@ -0,0 +1,179 @@ +// Copyright 2024 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 'package:a2ui_agent/a2ui_agent.dart'; +import 'package:a2ui_core/a2ui_core.dart'; +import 'package:test/test.dart'; + +import '../test_catalogs.dart'; + +/// Marks a test describing behaviour `resolveCatalogs` does not implement yet. +/// Remove the skip alongside the implementation. +const String pendingResolver = 'resolveCatalogs is not implemented yet.'; + +const String smallCatalogId = 'https://example.com/small.json'; + +List registered() => [ + CatalogConfig(basicCatalog()), + CatalogConfig(smallCatalog()), +]; + +void main() { + group('resolveCatalogs', () { + test('selects the catalog the renderer declares', () { + final List> catalogs = + resolveCatalogs( + registered(), + A2uiRendererCapabilities.forCatalogIds([smallCatalogId]), + ); + + expect(catalogs.map((c) => c.id), [smallCatalogId]); + }, skip: pendingResolver); + + test('selects every catalog the renderer and agent share', () { + final List> catalogs = + resolveCatalogs( + registered(), + A2uiRendererCapabilities.forCatalogIds([ + smallCatalogId, + basicCatalogId, + ]), + ); + + expect(catalogs.map((c) => c.id).toSet(), { + basicCatalogId, + smallCatalogId, + }); + }, skip: pendingResolver); + + test('returns catalogs in agent preference order', () { + final List> catalogs = + resolveCatalogs( + registered(), + A2uiRendererCapabilities.forCatalogIds([ + smallCatalogId, + basicCatalogId, + ]), + ); + + expect(catalogs.map((c) => c.id), [basicCatalogId, smallCatalogId]); + }, skip: pendingResolver); + + test( + 'falls back to the first registered catalog when none is declared', + () { + final List> catalogs = + resolveCatalogs( + registered(), + A2uiRendererCapabilities.forCatalogIds(const []), + ); + + expect(catalogs.map((c) => c.id), [basicCatalogId]); + }, + skip: pendingResolver, + ); + + test('returns transformed catalogs, not pristine ones', () { + final List> catalogs = resolveCatalogs( + [ + CatalogConfig( + basicCatalog(), + transformers: [ + ComponentPruningTransformer(['Text', 'Card']), + ], + ), + ], + A2uiRendererCapabilities.forCatalogIds([basicCatalogId]), + ); + + expect(catalogs.single.components.keys.toSet(), {'Text', 'Card'}); + }, skip: pendingResolver); + + test( + 'ignores inline catalogs unless the agent accepts them', + () { + final capabilities = A2uiRendererCapabilities( + v0_9: A2uiVersionCapabilities( + supportedCatalogIds: const [], + inlineCatalogs: [ + smallCatalog(id: 'https://example.com/inline.json'), + ], + ), + ); + + final List> catalogs = + resolveCatalogs(registered(), capabilities); + + expect( + catalogs.map((c) => c.id), + isNot(contains('https://example.com/inline.json')), + ); + }, + skip: pendingResolver, + ); + + test( + 'includes inline catalogs when the agent accepts them', + () { + final capabilities = A2uiRendererCapabilities( + v0_9: A2uiVersionCapabilities( + supportedCatalogIds: const [], + inlineCatalogs: [ + smallCatalog(id: 'https://example.com/inline.json'), + ], + ), + ); + + final List> catalogs = + resolveCatalogs( + registered(), + capabilities, + acceptsInlineCatalogs: true, + ); + + expect( + catalogs.map((c) => c.id), + contains('https://example.com/inline.json'), + ); + }, + skip: pendingResolver, + ); + + test( + 'rejects a renderer that shares no catalog with the agent', + () { + expect( + () => resolveCatalogs( + registered(), + A2uiRendererCapabilities.forCatalogIds([ + 'https://example.com/unknown.json', + ]), + ), + throwsA(isA()), + ); + }, + skip: pendingResolver, + ); + + test('rejects an agent with no registered catalogs', () { + expect( + () => resolveCatalogs( + [], + A2uiRendererCapabilities.forCatalogIds([basicCatalogId]), + ), + throwsA(isA()), + ); + }, skip: pendingResolver); + }); +} diff --git a/dart/a2ui_core/CHANGELOG.md b/dart/a2ui_core/CHANGELOG.md index 8220d87893..6dd5d084bd 100644 --- a/dart/a2ui_core/CHANGELOG.md +++ b/dart/a2ui_core/CHANGELOG.md @@ -1,5 +1,28 @@ # [a2ui_core](https://pub.dev/packages/a2ui_core) Changelog +## 0.2.0 + +- **Breaking:** `Catalog` now takes two type parameters, + `Catalog`, so that agents can + hold catalogs whose functions declare a signature without an implementation. + Renderers use `Catalog`. +- Added `A2uiProtocolVersion`, which gates every entry point on protocol v0.9 + and rejects payloads that declare another version or omit it. +- Added `Catalog.fromJson`, `Catalog.catalogSchema` and `Catalog.copyWith`, plus + the schema-only `CatalogComponent` and `CatalogFunction` and the + `SchemaCatalog` alias, so catalog documents round trip through the core layer. + A pruned catalog renders a pruned document, with the `$defs/anyComponent` and + `$defs/anyFunction` unions narrowed to match. +- Added `A2uiRendererCapabilities` and `A2uiVersionCapabilities`, mirroring + `client_capabilities.json` and the `web_core` client capability types. +- Added `A2uiValidator`, which parses payload envelopes and gates them on the + supported protocol version. Structural and catalog schema checks are declared + but not implemented yet. +- Added the `A2uiParseError`, `A2uiCompileError`, `A2uiCatalogError`, + `A2uiIntegrityError` and `A2uiRecursionError` categories. +- Fixed `DataModel.set` silently dropping a write whose parent path resolves to + a primitive; it now throws `A2uiDataError`. + ## 0.1.1 - The source code is moved from genui repo to a2ui repo. diff --git a/dart/a2ui_core/lib/a2ui_core.dart b/dart/a2ui_core/lib/a2ui_core.dart index 9a1b0d435a..bc9792180e 100644 --- a/dart/a2ui_core/lib/a2ui_core.dart +++ b/dart/a2ui_core/lib/a2ui_core.dart @@ -12,6 +12,10 @@ // See the License for the specific language governing permissions and // limitations under the License. +/// The A2UI core SDK: protocol messages, catalogs, reactive state models and +/// payload validation, shared by renderers and agents. +/// +/// This SDK implements version 0.9 of the A2UI protocol. library; // Protocol models. @@ -25,6 +29,7 @@ export 'src/core/contexts.dart'; export 'src/core/data_model.dart'; export 'src/core/messages.dart'; export 'src/core/minimal_catalog.dart'; +export 'src/core/renderer_capabilities.dart'; export 'src/core/surface_group_model.dart'; export 'src/core/surface_model.dart'; export 'src/primitives/cancellation.dart'; @@ -32,6 +37,8 @@ export 'src/primitives/data_path.dart'; export 'src/primitives/errors.dart'; // Event notifications for discrete lifecycle events. export 'src/primitives/event_notifier.dart'; +// Protocol version gating (v0.9 only). +export 'src/primitives/protocol_version.dart'; // Reactivity (re-exports preact_signals primitives). export 'src/primitives/reactivity.dart'; export 'src/processing/basic_functions.dart'; @@ -39,3 +46,5 @@ export 'src/processing/expressions.dart'; // Processing & expressions. export 'src/processing/processor.dart'; export 'src/rendering/binder.dart'; +// Payload validation. +export 'src/validation/validator.dart'; diff --git a/dart/a2ui_core/lib/src/core/catalog.dart b/dart/a2ui_core/lib/src/core/catalog.dart index e3682bb2aa..5ac8d7c458 100644 --- a/dart/a2ui_core/lib/src/core/catalog.dart +++ b/dart/a2ui_core/lib/src/core/catalog.dart @@ -14,6 +14,8 @@ import 'package:json_schema_builder/json_schema_builder.dart'; import '../primitives/cancellation.dart'; +import '../primitives/errors.dart'; +import '../primitives/protocol_version.dart'; import '../primitives/reactivity.dart'; import 'contexts.dart'; @@ -44,6 +46,10 @@ enum A2uiReturnType { } /// A definition of a UI function's API. +/// +/// This declares a function's signature only. Renderers that can also evaluate +/// the function supply a [FunctionImplementation] instead; agents, which only +/// need the signature to prompt and validate, use plain [FunctionApi] values. abstract class FunctionApi { String get name; A2uiReturnType get returnType; @@ -60,18 +66,345 @@ abstract class FunctionImplementation extends FunctionApi { ]); } +/// A [ComponentApi] backed directly by a catalog document's JSON schema. +/// +/// Produced by [Catalog.fromJson]. Carries no rendering behaviour, which makes +/// it the component representation used on the agent side. +class CatalogComponent implements ComponentApi { + @override + final String name; + + @override + final Schema schema; + + CatalogComponent({required this.name, required this.schema}); +} + +/// A [FunctionApi] backed directly by a catalog document's JSON schema. +/// +/// Produced by [Catalog.fromJson]. Declares a signature but cannot be +/// evaluated; see [FunctionImplementation] for the renderer-side counterpart. +class CatalogFunction implements FunctionApi { + @override + final String name; + + @override + final A2uiReturnType returnType; + + @override + final Schema argumentSchema; + + /// A human readable description of the function, when the catalog declares + /// one. + final String? description; + + CatalogFunction({ + required this.name, + required this.argumentSchema, + this.returnType = A2uiReturnType.any, + this.description, + }); +} + +/// A catalog whose components and functions carry schemas only. +/// +/// This is the shape [Catalog.fromJson] produces and the shape agents work +/// with, since an agent prompts and validates against signatures but never +/// evaluates a function. +typedef SchemaCatalog = Catalog; + /// A collection of available components and functions. -class Catalog { +/// +/// [C] is the component representation and [F] the function representation. +/// Renderers parameterise this with [FunctionImplementation] so functions can +/// be evaluated locally; agents parameterise it with [CatalogFunction], which +/// declares a signature only. +class Catalog { + /// The catalog id, as declared by the `catalogId` field of a catalog + /// document. final String id; - final Map components; - final Map functions; + + /// The protocol version this catalog conforms to. + final A2uiProtocolVersion protocolVersion; + + final Map components; + final Map functions; final Schema? themeSchema; + /// The catalog document this catalog was parsed from, when it was built by + /// [Catalog.fromJson]. + final Map? _sourceSchema; + Catalog({ required this.id, - required List components, - List functions = const [], + required List components, + List functions = const [], this.themeSchema, - }) : components = {for (var c in components) c.name: c}, - functions = {for (var f in functions) f.name: f}; + this.protocolVersion = A2uiProtocolVersion.v0_9, + Map? sourceSchema, + }) : components = {for (final c in components) c.name: c}, + functions = {for (final f in functions) f.name: f}, + _sourceSchema = sourceSchema; + + /// Parses a catalog document into a schema-only [Catalog]. + /// + /// Accepts both the catalog document form used under + /// `specification/*/catalogs/*/catalog.json`, where `functions` is a map of + /// name to JSON schema, and the inline catalog form used in renderer + /// capabilities, where `functions` is a list of function definitions. + /// + /// Throws [A2uiCatalogError] if the document is malformed or if + /// [expectedCatalogId] conflicts with the document's `catalogId`. Throws + /// [A2uiValidationError] if the document declares a protocol version this + /// SDK does not implement, or one that conflicts with + /// [expectedProtocolVersion]. + static SchemaCatalog fromJson( + Map json, { + A2uiProtocolVersion? expectedProtocolVersion, + String? expectedCatalogId, + }) { + final Object? rawId = json['catalogId']; + if (rawId is! String || rawId.isEmpty) { + throw A2uiCatalogError( + "Catalog document must declare a non-empty string 'catalogId'.", + ); + } + if (expectedCatalogId != null && expectedCatalogId != rawId) { + throw A2uiCatalogError( + "Catalog id mismatch: expected '$expectedCatalogId' but the document " + "declares '$rawId'.", + catalogId: rawId, + ); + } + + // `protocolVersion` is not declared by catalog documents before v1.0, so + // an absent value means the version this SDK implements. + final A2uiProtocolVersion version = json.containsKey('protocolVersion') + ? A2uiProtocolVersion.fromJson(json['protocolVersion'], details: json) + : A2uiProtocolVersion.v0_9; + if (expectedProtocolVersion != null && expectedProtocolVersion != version) { + throw A2uiValidationError( + 'Catalog protocol version mismatch: expected ' + "'${expectedProtocolVersion.jsonValue}' but the document declares " + "'${version.jsonValue}'.", + details: json, + ); + } + + return SchemaCatalog( + id: rawId, + protocolVersion: version, + components: _parseComponents(json['components'], rawId), + functions: _parseFunctions(json['functions'], rawId), + themeSchema: _parseTheme(json), + sourceSchema: json, + ); + } + + static List _parseComponents( + Object? raw, + String catalogId, + ) { + if (raw == null) return const []; + if (raw is! Map) { + throw A2uiCatalogError( + "Catalog 'components' must be an object mapping names to schemas.", + catalogId: catalogId, + ); + } + return [ + for (final MapEntry entry in raw.entries) + CatalogComponent( + name: entry.key! as String, + schema: Schema.fromMap(_asSchemaMap(entry.value)), + ), + ]; + } + + static List _parseFunctions(Object? raw, String catalogId) { + if (raw == null) return const []; + + // Inline catalog form: a list of {name, description, parameters, + // returnType} definitions. + if (raw is List) { + return [ + for (final Object? entry in raw) + if (entry is Map) + CatalogFunction( + name: entry['name']! as String, + description: entry['description'] as String?, + argumentSchema: Schema.fromMap( + _asSchemaMap(entry['parameters'] ?? const {}), + ), + returnType: A2uiReturnType.fromJson( + entry['returnType'] as String? ?? 'any', + ), + ), + ]; + } + + // Catalog document form: a map of name to the function's JSON schema, with + // the argument schema under `properties/args` and the declared return type + // under `properties/returnType/const`. + if (raw is! Map) { + throw A2uiCatalogError( + "Catalog 'functions' must be an object or a list of definitions.", + catalogId: catalogId, + ); + } + final functions = []; + for (final MapEntry entry in raw.entries) { + final Map schema = _asSchemaMap(entry.value); + final Map properties = + schema['properties'] as Map? ?? + const {}; + final Object? args = properties['args']; + final Object? returnType = properties['returnType']; + functions.add( + CatalogFunction( + name: entry.key! as String, + description: schema['description'] as String?, + argumentSchema: Schema.fromMap( + _asSchemaMap(args ?? const {}), + ), + returnType: A2uiReturnType.fromJson( + (returnType is Map ? returnType[r'const'] as String? : null) ?? + 'any', + ), + ), + ); + } + return functions; + } + + static Schema? _parseTheme(Map json) { + final Object? defs = json[r'$defs']; + final Object? theme = json['theme'] ?? (defs is Map ? defs['theme'] : null); + if (theme == null) return null; + return Schema.fromMap(_asSchemaMap(theme)); + } + + static Map _asSchemaMap(Object? value) { + if (value is Map) return value.cast(); + throw A2uiCatalogError('Expected a JSON schema object, got $value.'); + } + + /// The catalog document for this catalog, as JSON. + /// + /// When the catalog was built by [Catalog.fromJson], the original document is + /// returned with its `components`, `functions` and `$defs` narrowed to the + /// entries this catalog actually holds, so that a pruned catalog renders a + /// pruned document. Otherwise a document is synthesised from the component + /// and function schemas. + Map get catalogSchema { + final Map? source = _sourceSchema; + if (source == null) return _synthesizeSchema(); + + final Map document = _deepCopy(source); + document['catalogId'] = id; + if (source['components'] is Map) { + document['components'] = { + for (final String name in components.keys) + if ((source['components']! as Map).containsKey(name)) + name: _deepCopyValue((source['components']! as Map)[name]), + }; + } + final Object? sourceFunctions = source['functions']; + if (sourceFunctions is Map) { + document['functions'] = { + for (final String name in functions.keys) + if (sourceFunctions.containsKey(name)) + name: _deepCopyValue(sourceFunctions[name]), + }; + } else if (sourceFunctions is List) { + document['functions'] = [ + for (final Object? entry in sourceFunctions) + if (entry is Map && functions.containsKey(entry['name'])) + _deepCopyValue(entry), + ]; + } + _narrowAnyOneOf(document, 'anyComponent', '#/components/', components.keys); + _narrowAnyOneOf(document, 'anyFunction', '#/functions/', functions.keys); + return document; + } + + /// Drops `$defs//oneOf` entries whose `$ref` points at an entry that is + /// no longer part of this catalog. + static void _narrowAnyOneOf( + Map document, + String defName, + String refPrefix, + Iterable kept, + ) { + final Object? defs = document[r'$defs']; + if (defs is! Map) return; + final Object? any = defs[defName]; + if (any is! Map) return; + final Object? oneOf = any['oneOf']; + if (oneOf is! List) return; + final Set keptRefs = { + for (final String name in kept) '$refPrefix$name', + }; + any['oneOf'] = [ + for (final Object? entry in oneOf) + if (entry is! Map || + entry[r'$ref'] is! String || + !(entry[r'$ref']! as String).startsWith(refPrefix) || + keptRefs.contains(entry[r'$ref'])) + entry, + ]; + } + + Map _synthesizeSchema() => { + 'catalogId': id, + 'components': { + for (final MapEntry entry in components.entries) + entry.key: entry.value.schema.value, + }, + if (functions.isNotEmpty) + 'functions': [ + for (final F function in functions.values) + { + 'name': function.name, + 'returnType': function.returnType.jsonValue, + 'parameters': function.argumentSchema.value, + }, + ], + if (themeSchema != null) r'$defs': {'theme': themeSchema!.value}, + }; + + /// Returns a copy of this catalog with the given components and functions. + /// + /// Used by catalog transformers, which narrow a pristine catalog before it is + /// rendered into a prompt or used for validation. + Catalog copyWith({ + Iterable? components, + Iterable? functions, + Schema? themeSchema, + }) => Catalog( + id: id, + protocolVersion: protocolVersion, + components: (components ?? this.components.values).toList(), + functions: (functions ?? this.functions.values).toList(), + themeSchema: themeSchema ?? this.themeSchema, + sourceSchema: _sourceSchema, + ); + + static Map _deepCopy(Map map) => { + for (final MapEntry entry in map.entries) + entry.key: _deepCopyValue(entry.value), + }; + + static Object? _deepCopyValue(Object? value) { + if (value is Map) { + return { + for (final MapEntry entry in value.entries) + entry.key! as String: _deepCopyValue(entry.value), + }; + } + if (value is List) { + return [for (final Object? item in value) _deepCopyValue(item)]; + } + return value; + } } diff --git a/dart/a2ui_core/lib/src/core/contexts.dart b/dart/a2ui_core/lib/src/core/contexts.dart index 45fc6fc35f..eec81d54d0 100644 --- a/dart/a2ui_core/lib/src/core/contexts.dart +++ b/dart/a2ui_core/lib/src/core/contexts.dart @@ -148,7 +148,8 @@ class ComponentContext { } } -extension CatalogInvokerExtension on Catalog { +extension CatalogInvokerExtension + on Catalog { /// Invokes a catalog function by name with the given arguments. Object? invoke(String name, Map args, DataContext context) { final FunctionImplementation? fn = functions[name]; diff --git a/dart/a2ui_core/lib/src/core/data_model.dart b/dart/a2ui_core/lib/src/core/data_model.dart index e0a88c1b91..238af2d2d0 100644 --- a/dart/a2ui_core/lib/src/core/data_model.dart +++ b/dart/a2ui_core/lib/src/core/data_model.dart @@ -132,6 +132,15 @@ class DataModel { current.add(null); } current[index] = value; + } else { + // The parent of the final segment resolved to a primitive, so there + // is nothing to write into. Dropping the write silently would hide + // a malformed path, so report it. + throw A2uiDataError( + "Cannot set path '$path': '$lastSegment' is a property of a " + 'primitive value.', + path: path, + ); } } diff --git a/dart/a2ui_core/lib/src/core/messages.dart b/dart/a2ui_core/lib/src/core/messages.dart index e8c7ae16cc..3ce8749c10 100644 --- a/dart/a2ui_core/lib/src/core/messages.dart +++ b/dart/a2ui_core/lib/src/core/messages.dart @@ -13,28 +13,28 @@ // limitations under the License. import '../primitives/errors.dart'; +import '../primitives/protocol_version.dart'; /// Base class for all A2UI messages. abstract class A2uiMessage { + /// The protocol version this message declares, as it appears on the wire. final String version; + A2uiMessage({this.version = 'v0.9'}); + /// The parsed protocol version this message declares. + A2uiProtocolVersion get protocolVersion => + A2uiProtocolVersion.fromJson(version); + /// Deserializes a JSON envelope into a typed [A2uiMessage]. + /// + /// Throws [A2uiValidationError] if the envelope omits `version` or declares + /// a protocol version this SDK does not implement. factory A2uiMessage.fromJson(Map json) { - final Object? rawVersion = json['version']; - if (rawVersion is! String) { - throw A2uiValidationError( - "A2UI message must have a string 'version' field.", - details: json, - ); - } - if (rawVersion != 'v0.9') { - throw A2uiValidationError( - "A2UI message must have version 'v0.9' (got '$rawVersion').", - details: json, - ); - } - final String version = rawVersion; + final String version = A2uiProtocolVersion.fromJson( + json['version'], + details: json, + ).jsonValue; const messageBodyKeys = { 'createSurface', diff --git a/dart/a2ui_core/lib/src/core/minimal_catalog.dart b/dart/a2ui_core/lib/src/core/minimal_catalog.dart index 69d4186ebc..033de2f1a5 100644 --- a/dart/a2ui_core/lib/src/core/minimal_catalog.dart +++ b/dart/a2ui_core/lib/src/core/minimal_catalog.dart @@ -153,7 +153,7 @@ class CapitalizeFunction extends FunctionImplementation { } } -class MinimalCatalog extends Catalog { +class MinimalCatalog extends Catalog { MinimalCatalog() : super( id: 'https://a2ui.org/specification/v0_9/catalogs/minimal/minimal_catalog.json', diff --git a/dart/a2ui_core/lib/src/core/renderer_capabilities.dart b/dart/a2ui_core/lib/src/core/renderer_capabilities.dart new file mode 100644 index 0000000000..cc1018430d --- /dev/null +++ b/dart/a2ui_core/lib/src/core/renderer_capabilities.dart @@ -0,0 +1,147 @@ +// Copyright 2024 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 '../primitives/errors.dart'; +import '../primitives/protocol_version.dart'; +import 'catalog.dart'; + +/// The catalogs a renderer can render for a single protocol version. +/// +/// Mirrors the `A2uiVersionCapabilities` structure of +/// `specification/v0_9_1/json/client_capabilities.json`. +class A2uiVersionCapabilities { + /// Ids of the catalogs the renderer supports. + final List supportedCatalogIds; + + /// Catalogs supplied inline by the renderer. + /// + /// Only meaningful when the agent advertises `acceptsInlineCatalogs`. + final List inlineCatalogs; + + A2uiVersionCapabilities({ + required this.supportedCatalogIds, + this.inlineCatalogs = const [], + }); + + /// Parses a version capabilities object. + /// + /// Throws [A2uiValidationError] if `supportedCatalogIds` is missing or is not + /// a list of strings. + factory A2uiVersionCapabilities.fromJson(Map json) { + final Object? rawIds = json['supportedCatalogIds']; + if (rawIds is! List) { + throw A2uiValidationError( + "Renderer capabilities must declare a 'supportedCatalogIds' array.", + details: json, + ); + } + final Object? rawInline = json['inlineCatalogs']; + return A2uiVersionCapabilities( + supportedCatalogIds: [ + for (final Object? id in rawIds) + if (id is String) + id + else + throw A2uiValidationError( + "'supportedCatalogIds' must contain only strings.", + details: json, + ), + ], + inlineCatalogs: [ + if (rawInline is List) + for (final Object? catalog in rawInline) + Catalog.fromJson((catalog! as Map).cast()), + ], + ); + } + + Map toJson() => { + 'supportedCatalogIds': supportedCatalogIds, + if (inlineCatalogs.isNotEmpty) + 'inlineCatalogs': [ + for (final SchemaCatalog catalog in inlineCatalogs) + catalog.catalogSchema, + ], + }; +} + +/// The UI rendering capabilities a renderer advertises to an agent. +/// +/// Mirrors the `a2uiClientCapabilities` object of +/// `specification/v0_9_1/json/client_capabilities.json`, and the `web_core` +/// `A2uiClientCapabilities` type. +/// +/// This SDK implements v0.9 only, so a capabilities object that does not carry +/// a `v0.9` entry is rejected. Entries for other versions are preserved in +/// [unsupportedVersions] but are never negotiated against. +class A2uiRendererCapabilities { + /// The capabilities declared for v0.9. + final A2uiVersionCapabilities v0_9; + + /// Version keys present in the source object that this SDK does not + /// implement. + final List unsupportedVersions; + + A2uiRendererCapabilities({ + required this.v0_9, + this.unsupportedVersions = const [], + }); + + /// Convenience constructor for a renderer that supports catalogs by id only. + factory A2uiRendererCapabilities.forCatalogIds( + List supportedCatalogIds, { + List inlineCatalogs = const [], + }) => A2uiRendererCapabilities( + v0_9: A2uiVersionCapabilities( + supportedCatalogIds: supportedCatalogIds, + inlineCatalogs: inlineCatalogs, + ), + ); + + /// Parses an `a2uiClientCapabilities` object. + /// + /// Throws [A2uiValidationError] if the object carries no `v0.9` entry. + factory A2uiRendererCapabilities.fromJson(Map json) { + final Object? v09 = json[A2uiProtocolVersion.v0_9.jsonValue]; + if (v09 is! Map) { + throw A2uiValidationError( + 'Renderer capabilities must declare a ' + "'${A2uiProtocolVersion.v0_9.jsonValue}' entry; this SDK supports " + 'only ${A2uiProtocolVersion.supportedVersions}.', + details: json, + ); + } + return A2uiRendererCapabilities( + v0_9: A2uiVersionCapabilities.fromJson(v09.cast()), + unsupportedVersions: [ + for (final String key in json.keys) + if (key != A2uiProtocolVersion.v0_9.jsonValue) key, + ], + ); + } + + /// The capabilities declared for [version]. + /// + /// Throws [A2uiValidationError] for any version this SDK does not implement. + A2uiVersionCapabilities forVersion(A2uiProtocolVersion version) { + switch (version) { + case A2uiProtocolVersion.v0_9: + return v0_9; + } + } + + Map toJson() => { + A2uiProtocolVersion.v0_9.jsonValue: v0_9.toJson(), + }; +} diff --git a/dart/a2ui_core/lib/src/core/surface_model.dart b/dart/a2ui_core/lib/src/core/surface_model.dart index e6586bfaa4..448b43856e 100644 --- a/dart/a2ui_core/lib/src/core/surface_model.dart +++ b/dart/a2ui_core/lib/src/core/surface_model.dart @@ -25,7 +25,7 @@ import 'messages.dart'; /// The state model for a single UI surface. class SurfaceModel { final String id; - final Catalog catalog; + final Catalog catalog; final Map theme; final bool sendDataModel; diff --git a/dart/a2ui_core/lib/src/primitives/errors.dart b/dart/a2ui_core/lib/src/primitives/errors.dart index 77014d81fd..a524655b4d 100644 --- a/dart/a2ui_core/lib/src/primitives/errors.dart +++ b/dart/a2ui_core/lib/src/primitives/errors.dart @@ -51,3 +51,57 @@ class A2uiExpressionError extends A2uiError { class A2uiStateError extends A2uiError { A2uiStateError(String message) : super(message, 'STATE_ERROR'); } + +/// Thrown when an LLM response cannot be tokenized into A2UI parts. +class A2uiParseError extends A2uiError { + /// The raw content that could not be parsed. + final String? rawContent; + + A2uiParseError(String message, {this.rawContent}) + : super(message, 'PARSE_ERROR'); +} + +/// Thrown when a raw inference-format payload cannot be compiled into A2UI +/// messages. +class A2uiCompileError extends A2uiError { + /// The raw content that could not be compiled. + final String? rawContent; + + /// Parts that were compiled successfully before the failure. + final List partialResults; + + A2uiCompileError( + String message, { + this.rawContent, + this.partialResults = const [], + }) : super(message, 'COMPILE_ERROR'); +} + +/// Thrown when a catalog cannot be loaded, parsed, or negotiated. +class A2uiCatalogError extends A2uiError { + /// The catalog id involved, when known. + final String? catalogId; + + A2uiCatalogError(String message, {this.catalogId}) + : super(message, 'CATALOG_ERROR'); +} + +/// Thrown when a component graph is structurally invalid (unreachable roots, +/// duplicate ids, dangling references). +class A2uiIntegrityError extends A2uiError { + /// The component ids involved, when known. + final List componentIds; + + A2uiIntegrityError(String message, {this.componentIds = const []}) + : super(message, 'INTEGRITY_ERROR'); +} + +/// Thrown when a component graph exceeds the maximum allowed nesting depth or +/// contains a cycle. +class A2uiRecursionError extends A2uiError { + /// The chain of component ids that produced the cycle, when known. + final List cycle; + + A2uiRecursionError(String message, {this.cycle = const []}) + : super(message, 'RECURSION_ERROR'); +} diff --git a/dart/a2ui_core/lib/src/primitives/protocol_version.dart b/dart/a2ui_core/lib/src/primitives/protocol_version.dart new file mode 100644 index 0000000000..7e2a2d03cc --- /dev/null +++ b/dart/a2ui_core/lib/src/primitives/protocol_version.dart @@ -0,0 +1,66 @@ +// Copyright 2024 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 'errors.dart'; + +/// A version of the A2UI protocol. +/// +/// This SDK implements version 0.9 of the protocol only. Payloads that declare +/// any other version, or that omit the version entirely, are rejected by +/// [A2uiProtocolVersion.fromJson]. +enum A2uiProtocolVersion { + /// Version 0.9 of the A2UI protocol. + /// + /// Also covers v0.9.1, which is schema-compatible with v0.9 and shares the + /// same `version` wire value. + v0_9('v0.9'); + + const A2uiProtocolVersion(this.jsonValue); + + /// The value used for the `version` field on the wire. + final String jsonValue; + + /// Parses the `version` field of an A2UI payload. + /// + /// Throws [A2uiValidationError] if [value] is absent, is not a string, or + /// names a protocol version this SDK does not implement. + static A2uiProtocolVersion fromJson(Object? value, {Object? details}) { + if (value == null) { + throw A2uiValidationError( + "A2UI payloads must declare a 'version' field; this SDK supports " + 'only $supportedVersions.', + details: details, + ); + } + if (value is! String) { + throw A2uiValidationError( + "A2UI payloads must have a string 'version' field (got " + '${value.runtimeType}).', + details: details, + ); + } + for (final A2uiProtocolVersion version in values) { + if (version.jsonValue == value) return version; + } + throw A2uiValidationError( + "Unsupported A2UI protocol version '$value'; this SDK supports only " + '$supportedVersions.', + details: details, + ); + } + + /// A human readable list of the versions this SDK implements. + static String get supportedVersions => + values.map((v) => "'${v.jsonValue}'").join(', '); +} diff --git a/dart/a2ui_core/lib/src/primitives/reactivity.dart b/dart/a2ui_core/lib/src/primitives/reactivity.dart index 593993a1d9..0f1d590d86 100644 --- a/dart/a2ui_core/lib/src/primitives/reactivity.dart +++ b/dart/a2ui_core/lib/src/primitives/reactivity.dart @@ -12,6 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. +/// Reactive primitives, re-exported from `package:preact_signals`. library; export 'package:preact_signals/preact_signals.dart' diff --git a/dart/a2ui_core/lib/src/processing/processor.dart b/dart/a2ui_core/lib/src/processing/processor.dart index 3990e584e8..ddb49d036f 100644 --- a/dart/a2ui_core/lib/src/processing/processor.dart +++ b/dart/a2ui_core/lib/src/processing/processor.dart @@ -24,7 +24,7 @@ import '../primitives/errors.dart'; /// The central processor for A2UI messages. class MessageProcessor { final SurfaceGroupModel groupModel; - final List> catalogs; + final List> catalogs; MessageProcessor({ required this.catalogs, @@ -55,7 +55,7 @@ class MessageProcessor { } void _processCreateSurface(CreateSurfaceMessage message) { - final Catalog catalog = catalogs.firstWhere( + final Catalog catalog = catalogs.firstWhere( (c) => c.id == message.catalogId, orElse: () => throw A2uiStateError('Catalog not found: ${message.catalogId}'), @@ -140,7 +140,9 @@ class MessageProcessor { return {'v0.9': v09}; } - Map _generateInlineCatalog(Catalog catalog) { + Map _generateInlineCatalog( + Catalog catalog, + ) { final components = {}; for (final MapEntry entry in catalog.components.entries) { final Map jsonSchema = entry.value.schema.toJsonMap(); diff --git a/dart/a2ui_core/lib/src/validation/validator.dart b/dart/a2ui_core/lib/src/validation/validator.dart new file mode 100644 index 0000000000..092da5f6e1 --- /dev/null +++ b/dart/a2ui_core/lib/src/validation/validator.dart @@ -0,0 +1,120 @@ +// Copyright 2024 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 '../core/catalog.dart'; +import '../core/messages.dart'; +import '../primitives/errors.dart'; +import '../primitives/protocol_version.dart'; + +/// Validates A2UI payloads against the protocol schemas and a set of catalogs. +/// +/// Lives in `a2ui_core` rather than in the agent SDK because both renderers and +/// agents validate the same payloads against the same catalogs. +/// +/// This SDK implements protocol v0.9 only. Payloads that declare any other +/// version, or that omit the version, are rejected by [checkVersion] and by +/// [parseMessages]. +/// +/// The deep checks ([validateStructure] and [validateAgainstCatalogs]) are not +/// implemented yet and throw [UnimplementedError]; [validate], which composes +/// them, therefore also throws once a payload has passed envelope parsing. +class A2uiValidator { + /// The catalogs payloads are validated against, keyed by catalog id. + final Map> catalogs; + + /// The protocol version this validator accepts. + final A2uiProtocolVersion protocolVersion; + + A2uiValidator({ + List> catalogs = const [], + this.protocolVersion = A2uiProtocolVersion.v0_9, + }) : catalogs = {for (final Catalog c in catalogs) c.id: c}; + + /// Creates a validator for the protocol version named by [version]. + /// + /// Throws [A2uiValidationError] for any version this SDK does not implement. + factory A2uiValidator.forVersion( + Object? version, { + List> catalogs = const [], + }) => A2uiValidator( + catalogs: catalogs, + protocolVersion: A2uiProtocolVersion.fromJson(version), + ); + + /// Checks the `version` field of a single payload envelope. + /// + /// Throws [A2uiValidationError] if the envelope omits `version`, or declares + /// a version other than the one this validator accepts. + A2uiProtocolVersion checkVersion(Map envelope) { + final A2uiProtocolVersion version = A2uiProtocolVersion.fromJson( + envelope['version'], + details: envelope, + ); + if (version != protocolVersion) { + throw A2uiValidationError( + "Payload declares version '${version.jsonValue}' but this validator " + "accepts only '${protocolVersion.jsonValue}'.", + details: envelope, + ); + } + return version; + } + + /// Parses payload envelopes into typed messages, rejecting unsupported + /// versions and malformed envelopes. + /// + /// Throws [A2uiValidationError] for any envelope that is not a well-formed + /// message of the accepted protocol version. + List parseMessages(List> payload) { + final messages = []; + for (final envelope in payload) { + checkVersion(envelope); + messages.add(A2uiMessage.fromJson(Map.from(envelope))); + } + return messages; + } + + /// Checks that a message sequence forms a valid component graph. + /// + /// Covers component id uniqueness, reachability from the surface root, + /// dangling child references, cycle detection and the recursion depth cap. + /// + /// Throws [A2uiIntegrityError] for graph defects and [A2uiRecursionError] for + /// cycles and depth overruns. + void validateStructure(List messages) { + throw UnimplementedError('A2uiValidator.validateStructure'); + } + + /// Checks each component and function call against the schema of the catalog + /// the surface was created with. + /// + /// Throws [A2uiCatalogError] if a message names a catalog this validator does + /// not hold, and [A2uiValidationError] for schema violations. + Future validateAgainstCatalogs(List messages) { + throw UnimplementedError('A2uiValidator.validateAgainstCatalogs'); + } + + /// Validates a complete payload: envelope parsing, then structural checks, + /// then catalog schema checks. + /// + /// Returns the parsed messages. Throws [A2uiValidationError], + /// [A2uiIntegrityError], [A2uiRecursionError] or [A2uiCatalogError] as + /// described on the individual steps. + Future> validate(List> payload) async { + final List messages = parseMessages(payload); + validateStructure(messages); + await validateAgainstCatalogs(messages); + return messages; + } +} diff --git a/dart/a2ui_core/pubspec.yaml b/dart/a2ui_core/pubspec.yaml index b8ece956b1..1bd7625f4d 100644 --- a/dart/a2ui_core/pubspec.yaml +++ b/dart/a2ui_core/pubspec.yaml @@ -15,7 +15,7 @@ name: a2ui_core description: Core package for A2UI protocol. repository: https://github.com/a2ui-project/a2ui/tree/main/dart/a2ui_core -version: 0.1.1 +version: 0.2.0 resolution: workspace @@ -29,4 +29,6 @@ dependencies: preact_signals: ^1.9.4 dev_dependencies: + path: ^1.9.0 test: ^1.26.2 + yaml: ^3.1.2 diff --git a/dart/a2ui_core/test/catalog_json_test.dart b/dart/a2ui_core/test/catalog_json_test.dart new file mode 100644 index 0000000000..dc94d99402 --- /dev/null +++ b/dart/a2ui_core/test/catalog_json_test.dart @@ -0,0 +1,258 @@ +// Copyright 2024 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 'dart:convert'; +import 'dart:io'; + +import 'package:a2ui_core/a2ui_core.dart'; +import 'package:test/test.dart'; + +import 'conformance/conformance_harness.dart'; + +/// The published basic catalog, which agent-side tests are measured against. +const String basicCatalogPath = + '../specification/v0_9_1/catalogs/basic/catalog.json'; + +const String basicCatalogId = + 'https://a2ui.org/specification/v0_9/catalogs/basic/catalog.json'; + +Map loadBasicCatalogJson() => + jsonDecode( + File(resolveConformancePath(basicCatalogPath)).readAsStringSync(), + ) + as Map; + +void main() { + group('Catalog.fromJson', () { + test('parses the published basic catalog document', () { + final SchemaCatalog catalog = Catalog.fromJson(loadBasicCatalogJson()); + + expect(catalog.id, basicCatalogId); + expect(catalog.protocolVersion, A2uiProtocolVersion.v0_9); + expect( + catalog.components.keys, + containsAll(['Text', 'Card', 'Column', 'Button', 'TextField']), + ); + expect( + catalog.functions.keys, + containsAll(['required', 'email', 'formatNumber', 'openUrl']), + ); + expect(catalog.themeSchema, isNotNull); + }); + + test('reads a function argument schema and return type', () { + final SchemaCatalog catalog = Catalog.fromJson(loadBasicCatalogJson()); + + final CatalogFunction required = catalog.functions['required']!; + expect(required.name, 'required'); + expect(required.returnType, A2uiReturnType.boolean); + expect( + (required.argumentSchema.value['required']! as List).cast(), + ['value'], + ); + + expect( + catalog.functions['formatNumber']!.returnType, + A2uiReturnType.string, + ); + }); + + test('parses the inline catalog form used by renderer capabilities', () { + final SchemaCatalog catalog = Catalog.fromJson({ + 'catalogId': 'inline', + 'components': { + 'Text': {'type': 'object'}, + }, + 'functions': [ + { + 'name': 'greet', + 'description': 'Says hello.', + 'parameters': {'type': 'object'}, + 'returnType': 'string', + }, + ], + }); + + expect(catalog.id, 'inline'); + expect(catalog.components.keys, ['Text']); + expect(catalog.functions['greet']!.returnType, A2uiReturnType.string); + expect(catalog.functions['greet']!.description, 'Says hello.'); + }); + + test('defaults an undeclared function return type to any', () { + final SchemaCatalog catalog = Catalog.fromJson({ + 'catalogId': 'c', + 'functions': { + 'mystery': {'type': 'object', 'properties': {}}, + }, + }); + + expect(catalog.functions['mystery']!.returnType, A2uiReturnType.any); + }); + + test('rejects a document without a catalog id', () { + expect( + () => Catalog.fromJson({'components': {}}), + throwsA(isA()), + ); + expect( + () => Catalog.fromJson({'catalogId': ''}), + throwsA(isA()), + ); + }); + + test('rejects a catalog id that conflicts with the expected id', () { + expect( + () => Catalog.fromJson({ + 'catalogId': 'actual', + }, expectedCatalogId: 'expected'), + throwsA( + isA().having( + (e) => e.catalogId, + 'catalogId', + 'actual', + ), + ), + ); + }); + + test('accepts a catalog id that matches the expected id', () { + expect( + Catalog.fromJson({'catalogId': 'same'}, expectedCatalogId: 'same').id, + 'same', + ); + }); + + test('rejects a document declaring an unsupported protocol version', () { + expect( + () => Catalog.fromJson({'catalogId': 'c', 'protocolVersion': 'v1.0'}), + throwsA(isA()), + ); + }); + + test('treats an undeclared protocol version as v0.9', () { + // Catalog documents do not carry a protocol version before v1.0. + expect( + Catalog.fromJson({'catalogId': 'c'}).protocolVersion, + A2uiProtocolVersion.v0_9, + ); + }); + + test('rejects malformed components and functions', () { + expect( + () => Catalog.fromJson({'catalogId': 'c', 'components': 'nope'}), + throwsA(isA()), + ); + expect( + () => Catalog.fromJson({'catalogId': 'c', 'functions': 'nope'}), + throwsA(isA()), + ); + }); + }); + + group('Catalog.catalogSchema', () { + test('round trips the source document', () { + final Map source = loadBasicCatalogJson(); + final Map rendered = Catalog.fromJson( + source, + ).catalogSchema; + + expect(rendered['catalogId'], source['catalogId']); + expect( + (rendered['components']! as Map).keys.toSet(), + (source['components']! as Map).keys.toSet(), + ); + expect( + (rendered['functions']! as Map).keys.toSet(), + (source['functions']! as Map).keys.toSet(), + ); + }); + + test('does not alias the source document', () { + final Map source = loadBasicCatalogJson(); + final Map rendered = Catalog.fromJson( + source, + ).catalogSchema; + + (rendered['components']! as Map).remove('Text'); + expect((source['components']! as Map).containsKey('Text'), isTrue); + }); + + test('reflects a pruned catalog and narrows the anyComponent union', () { + final SchemaCatalog catalog = Catalog.fromJson(loadBasicCatalogJson()); + final SchemaCatalog pruned = catalog.copyWith( + components: [catalog.components['Text']!, catalog.components['Card']!], + ); + + final Map rendered = pruned.catalogSchema; + expect((rendered['components']! as Map).keys.toSet(), {'Text', 'Card'}); + + final oneOf = + ((rendered[r'$defs']! as Map)['anyComponent']! as Map)['oneOf']! + as List; + expect(oneOf.map((e) => (e! as Map)[r'$ref']).toSet(), { + '#/components/Text', + '#/components/Card', + }); + }); + + test('reflects pruned functions and narrows the anyFunction union', () { + final SchemaCatalog catalog = Catalog.fromJson(loadBasicCatalogJson()); + final SchemaCatalog pruned = catalog.copyWith( + functions: [catalog.functions['required']!], + ); + + final Map rendered = pruned.catalogSchema; + expect((rendered['functions']! as Map).keys.toSet(), {'required'}); + + final oneOf = + ((rendered[r'$defs']! as Map)['anyFunction']! as Map)['oneOf']! + as List; + expect(oneOf.map((e) => (e! as Map)[r'$ref']).toSet(), { + '#/functions/required', + }); + }); + + test('synthesises a document for a code defined catalog', () { + final Map rendered = MinimalCatalog().catalogSchema; + + expect(rendered['catalogId'], MinimalCatalog().id); + expect((rendered['components']! as Map).keys, contains('Text')); + }); + }); + + group('Catalog generics', () { + test('separates function signatures from function implementations', () { + // Agents hold schema-only functions; renderers hold implementations. + final SchemaCatalog agentCatalog = Catalog.fromJson( + loadBasicCatalogJson(), + ); + expect( + agentCatalog.functions.values, + everyElement(isA()), + ); + expect( + agentCatalog.functions.values, + isNot(anyElement(isA())), + ); + + final Catalog rendererCatalog = + MinimalCatalog(); + expect( + rendererCatalog.functions.values, + everyElement(isA()), + ); + }); + }); +} diff --git a/dart/a2ui_core/test/conformance/conformance_harness.dart b/dart/a2ui_core/test/conformance/conformance_harness.dart new file mode 100644 index 0000000000..0d563d6bbb --- /dev/null +++ b/dart/a2ui_core/test/conformance/conformance_harness.dart @@ -0,0 +1,90 @@ +// Copyright 2024 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 'dart:io'; + +import 'package:path/path.dart' as p; +import 'package:yaml/yaml.dart'; + +/// Resolves a path from a conformance case against the `conformance/` +/// directory. +/// +/// Cases reference published specification artifacts with paths such as +/// `../specification/v0_9_1/catalogs/basic/catalog.json`. +String resolveConformancePath(String relativePath) => + p.normalize(p.join(_conformanceRoot(), relativePath)); + +String _conformanceRoot() { + // Walk up from the current directory until the conformance suite is found, + // so the harness works from the package directory and the workspace root. + Directory dir = Directory.current; + while (true) { + final candidate = Directory(p.join(dir.path, 'conformance')); + if (candidate.existsSync() && + File(p.join(candidate.path, 'conformance_schema.json')).existsSync()) { + return candidate.path; + } + final Directory parent = dir.parent; + if (parent.path == dir.path) { + throw StateError( + 'Could not locate the conformance/ directory above ' + '${Directory.current.path}.', + ); + } + dir = parent; + } +} + +/// Loads a conformance suite, for example `core/data_model.yaml`. +List> loadConformanceSuite(String suite) { + final file = File(resolveConformancePath(suite)); + if (!file.existsSync()) { + throw StateError('Conformance suite not found: ${file.path}'); + } + final Object? parsed = loadYaml(file.readAsStringSync()); + if (parsed is! YamlList) { + throw StateError('Conformance suite $suite must be a list of cases.'); + } + return [ + for (final Object? node in parsed) + normalizeYaml(node)! as Map, + ]; +} + +/// Converts YAML nodes into plain Dart maps, lists and scalars. +/// +/// The state models under test are typed against `Map` and +/// `List`, which `YamlMap` and `YamlList` do not satisfy. +Object? normalizeYaml(Object? node) { + if (node is YamlMap || node is Map) { + return { + for (final MapEntry entry + in (node as Map).cast().entries) + entry.key.toString(): normalizeYaml(entry.value), + }; + } + if (node is YamlList || node is List) { + return [ + for (final Object? item in node as List) normalizeYaml(item), + ]; + } + return node; +} + +/// The protocol version a case targets, or null when it declares none. +String? caseVersion(Map testCase) { + final Object? catalog = testCase['catalog']; + if (catalog is Map) return catalog['version'] as String?; + return null; +} diff --git a/dart/a2ui_core/test/conformance/data_model_conformance_test.dart b/dart/a2ui_core/test/conformance/data_model_conformance_test.dart new file mode 100644 index 0000000000..80af48644a --- /dev/null +++ b/dart/a2ui_core/test/conformance/data_model_conformance_test.dart @@ -0,0 +1,210 @@ +// Copyright 2024 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 'package:a2ui_core/a2ui_core.dart'; +import 'package:test/test.dart'; + +import 'conformance_harness.dart'; + +/// Runs the shared `conformance/core/data_model.yaml` suite against +/// [DataModel]. +/// +/// Two mappings are worth calling out, both documented in the suite header: +/// +/// * `op: delete` maps to `set(path, null)`. Dart has no `undefined`, so +/// removing a key is expressed by writing null. +/// * `watch` attaches one observer per entry. Repeating a path attaches a +/// second observer to the same underlying signal. +void main() { + final List> cases = loadConformanceSuite( + 'core/data_model.yaml', + ); + + group('conformance core/data_model.yaml', () { + test('suite is not empty', () => expect(cases, isNotEmpty)); + + for (final testCase in cases) { + test(testCase['name']! as String, () { + _runCase(testCase); + }); + } + }); +} + +void _runCase(Map testCase) { + // The suite is parsed once and shared across cases, so the initial data is + // deep copied before the model mutates it. + final model = DataModel( + _deepCopy(testCase['initial']) ?? {}, + ); + addTearDown(model.dispose); + + final watched = <_Observer>[]; + final List watchPaths = + (testCase['watch'] as List?) ?? const []; + for (final path in watchPaths) { + watched.add(_Observer(model, path! as String)); + } + + final steps = testCase['steps']! as List; + for (var i = 0; i < steps.length; i++) { + final step = steps[i]! as Map; + final reason = '${testCase['name']} step $i (${step['op']})'; + for (final observer in watched) { + observer.resetCount(); + } + + final Object? expectError = step['expect_error']; + if (expectError != null) { + expect( + () => _applyOp(model, step), + throwsA(_matchesError(expectError as Map)), + reason: reason, + ); + continue; + } + + _applyOp(model, step, observers: watched, reason: reason); + _checkNotifications(step, watched, reason); + _checkWatchedValues(step, watched, reason); + } +} + +void _applyOp( + DataModel model, + Map step, { + List<_Observer> observers = const [], + String reason = '', +}) { + final op = step['op']! as String; + switch (op) { + case 'get': + final Object? actual = model.get(step['path']! as String); + if (step['expect_absent'] == true) { + expect(actual, isNull, reason: reason); + } + if (step.containsKey('expect_type')) { + expect( + actual, + step['expect_type'] == 'list' + ? isA>() + : isA>(), + reason: reason, + ); + } + if (step.containsKey('expect')) { + expect(actual, equals(step['expect']), reason: reason); + } + case 'set': + model.set(step['path']! as String, step['value']); + case 'delete': + // Dart has no `undefined`; writing null removes the key. + model.set(step['path']! as String, null); + case 'dispose': + model.dispose(); + default: + fail('Unknown data_model op: $op'); + } +} + +void _checkNotifications( + Map step, + List<_Observer> observers, + String reason, +) { + final Object? expected = step['expect_notified']; + if (expected == null) return; + final List expectedPaths = (expected as List).cast(); + final notified = [ + for (final _Observer observer in observers) + for (var i = 0; i < observer.changeCount; i++) observer.path, + ]; + expect( + notified..sort(), + equals([...expectedPaths]..sort()), + reason: '$reason: notified observers', + ); +} + +void _checkWatchedValues( + Map step, + List<_Observer> observers, + String reason, +) { + final Object? expected = step['expect_values']; + if (expected == null) return; + (expected as Map).forEach((path, value) { + final _Observer observer = observers.firstWhere( + (o) => o.path == path, + orElse: () => fail('$reason: $path is not watched'), + ); + expect(observer.signal.value, equals(value), reason: '$reason: $path'); + }); +} + +Matcher _matchesError(Map expectError) { + final category = expectError['category'] as String?; + final message = expectError['message'] as String?; + Matcher matcher = switch (category) { + 'DataError' => isA(), + 'ValidationError' => isA(), + 'CatalogError' => isA(), + 'IntegrityError' => isA(), + 'RecursionError' => isA(), + 'ParseError' => isA(), + 'CompileError' => isA(), + _ => isA(), + }; + if (message != null) { + matcher = allOf( + matcher, + predicate((Object? e) { + return RegExp(message).hasMatch(e.toString()); + }, 'message matching /$message/'), + ); + } + return matcher; +} + +/// Deep copies plain maps, lists and scalars parsed from a conformance suite. +Object? _deepCopy(Object? value) { + if (value is Map) { + return { + for (final MapEntry entry in value.entries) + entry.key.toString(): _deepCopy(entry.value), + }; + } + if (value is List) { + return [for (final Object? item in value) _deepCopy(item)]; + } + return value; +} + +/// A single observer attached to one path of a [DataModel]. +class _Observer { + final String path; + final ReadonlySignal signal; + int _count = 0; + + _Observer(DataModel model, this.path) : signal = model.watch(path) { + signal.subscribe((_) => _count++); + // preact_signals delivers an immediate callback on subscribe; the initial + // value is not a change. + _count = 0; + } + + int get changeCount => _count; + + void resetCount() => _count = 0; +} diff --git a/dart/a2ui_core/test/protocol_version_test.dart b/dart/a2ui_core/test/protocol_version_test.dart new file mode 100644 index 0000000000..635531114b --- /dev/null +++ b/dart/a2ui_core/test/protocol_version_test.dart @@ -0,0 +1,83 @@ +// Copyright 2024 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 'package:a2ui_core/a2ui_core.dart'; +import 'package:test/test.dart'; + +void main() { + group('A2uiProtocolVersion', () { + test('exposes v0.9 as its wire value', () { + expect(A2uiProtocolVersion.v0_9.jsonValue, 'v0.9'); + }); + + test('implements exactly one version', () { + expect(A2uiProtocolVersion.values, [A2uiProtocolVersion.v0_9]); + expect(A2uiProtocolVersion.supportedVersions, "'v0.9'"); + }); + + test('parses the supported version', () { + expect(A2uiProtocolVersion.fromJson('v0.9'), A2uiProtocolVersion.v0_9); + }); + + test('rejects an unspecified version', () { + expect( + () => A2uiProtocolVersion.fromJson(null), + throwsA( + isA().having( + (e) => e.message, + 'message', + contains("must declare a 'version' field"), + ), + ), + ); + }); + + test('rejects a version that is not a string', () { + expect( + () => A2uiProtocolVersion.fromJson(123), + throwsA(isA()), + ); + }); + + test('rejects earlier and later protocol versions', () { + for (final version in ['v0.8', 'v1.0', 'v0.9.1', '0.9', '']) { + expect( + () => A2uiProtocolVersion.fromJson(version), + throwsA( + isA().having( + (e) => e.message, + 'message', + contains('Unsupported A2UI protocol version'), + ), + ), + reason: version, + ); + } + }); + + test('carries the offending payload as error details', () { + final payload = {'version': 'v1.0'}; + expect( + () => A2uiProtocolVersion.fromJson('v1.0', details: payload), + throwsA( + isA().having( + (e) => e.details, + 'details', + same(payload), + ), + ), + ); + }); + }); +} diff --git a/dart/a2ui_core/test/renderer_capabilities_test.dart b/dart/a2ui_core/test/renderer_capabilities_test.dart new file mode 100644 index 0000000000..56d387296d --- /dev/null +++ b/dart/a2ui_core/test/renderer_capabilities_test.dart @@ -0,0 +1,135 @@ +// Copyright 2024 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 'package:a2ui_core/a2ui_core.dart'; +import 'package:test/test.dart'; + +void main() { + group('A2uiVersionCapabilities', () { + test('parses supported catalog ids', () { + final caps = A2uiVersionCapabilities.fromJson({ + 'supportedCatalogIds': ['a', 'b'], + }); + + expect(caps.supportedCatalogIds, ['a', 'b']); + expect(caps.inlineCatalogs, isEmpty); + }); + + test('parses inline catalogs into schema catalogs', () { + final caps = A2uiVersionCapabilities.fromJson({ + 'supportedCatalogIds': [], + 'inlineCatalogs': [ + { + 'catalogId': 'inline', + 'components': { + 'Gauge': {'type': 'object'}, + }, + }, + ], + }); + + expect(caps.inlineCatalogs, hasLength(1)); + expect(caps.inlineCatalogs.single.id, 'inline'); + expect(caps.inlineCatalogs.single.components.keys, ['Gauge']); + }); + + test('rejects missing or malformed supportedCatalogIds', () { + expect( + () => A2uiVersionCapabilities.fromJson({}), + throwsA(isA()), + ); + expect( + () => A2uiVersionCapabilities.fromJson({ + 'supportedCatalogIds': [1, 2], + }), + throwsA(isA()), + ); + }); + + test('round trips through JSON', () { + final caps = A2uiVersionCapabilities(supportedCatalogIds: ['a']); + expect(caps.toJson(), { + 'supportedCatalogIds': ['a'], + }); + }); + }); + + group('A2uiRendererCapabilities', () { + test('parses a v0.9 capabilities object', () { + final caps = A2uiRendererCapabilities.fromJson({ + 'v0.9': { + 'supportedCatalogIds': ['basic'], + }, + }); + + expect(caps.v0_9.supportedCatalogIds, ['basic']); + expect(caps.unsupportedVersions, isEmpty); + }); + + test('records version entries this SDK does not implement', () { + final caps = A2uiRendererCapabilities.fromJson({ + 'v0.9': { + 'supportedCatalogIds': ['basic'], + }, + 'v1.0': { + 'supportedCatalogIds': ['basic'], + }, + }); + + expect(caps.unsupportedVersions, ['v1.0']); + }); + + test('rejects capabilities carrying no v0.9 entry', () { + expect( + () => A2uiRendererCapabilities.fromJson({ + 'v1.0': { + 'supportedCatalogIds': ['basic'], + }, + }), + throwsA( + isA().having( + (e) => e.message, + 'message', + contains('v0.9'), + ), + ), + ); + expect( + () => A2uiRendererCapabilities.fromJson({}), + throwsA(isA()), + ); + }); + + test('builds capabilities from a list of catalog ids', () { + final caps = A2uiRendererCapabilities.forCatalogIds(['basic']); + expect(caps.v0_9.supportedCatalogIds, ['basic']); + }); + + test('resolves capabilities for a supported version', () { + final caps = A2uiRendererCapabilities.forCatalogIds(['basic']); + expect(caps.forVersion(A2uiProtocolVersion.v0_9).supportedCatalogIds, [ + 'basic', + ]); + }); + + test('round trips through JSON', () { + final json = { + 'v0.9': { + 'supportedCatalogIds': ['basic'], + }, + }; + expect(A2uiRendererCapabilities.fromJson(json).toJson(), json); + }); + }); +} diff --git a/dart/a2ui_core/test/validator_test.dart b/dart/a2ui_core/test/validator_test.dart new file mode 100644 index 0000000000..276e852cbb --- /dev/null +++ b/dart/a2ui_core/test/validator_test.dart @@ -0,0 +1,289 @@ +// Copyright 2024 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 'package:a2ui_core/a2ui_core.dart'; +import 'package:test/test.dart'; + +/// Marks a test that describes behaviour `A2uiValidator` does not implement +/// yet. Remove the skip alongside the implementation. +const String pendingValidator = + 'A2uiValidator deep checks are not implemented yet.'; + +const String catalogId = 'https://example.com/catalogs/test.json'; + +Map createSurface({String version = 'v0.9'}) => { + 'version': version, + 'createSurface': {'surfaceId': 's1', 'catalogId': catalogId}, +}; + +Map updateComponents(List> components) => + { + 'version': 'v0.9', + 'updateComponents': {'surfaceId': 's1', 'components': components}, + }; + +SchemaCatalog testCatalog() => Catalog.fromJson({ + 'catalogId': catalogId, + 'components': { + 'Card': { + 'type': 'object', + 'properties': { + 'component': {'const': 'Card'}, + 'child': {'type': 'string'}, + }, + }, + 'Text': { + 'type': 'object', + 'properties': { + 'component': {'const': 'Text'}, + 'text': {'type': 'string'}, + }, + 'required': ['text'], + }, + }, +}); + +void main() { + group('A2uiValidator version gating', () { + test('accepts payloads declaring the supported version', () { + final A2uiValidator validator = + A2uiValidator(catalogs: [testCatalog()]); + + expect(validator.checkVersion(createSurface()), A2uiProtocolVersion.v0_9); + expect(validator.parseMessages([createSurface()]), hasLength(1)); + expect( + validator.parseMessages([createSurface()]).single, + isA(), + ); + }); + + test('rejects payloads declaring another protocol version', () { + final A2uiValidator validator = + A2uiValidator(catalogs: [testCatalog()]); + + for (final version in ['v0.8', 'v0.9.1', 'v1.0']) { + expect( + () => validator.checkVersion(createSurface(version: version)), + throwsA(isA()), + reason: version, + ); + expect( + () => validator.parseMessages([createSurface(version: version)]), + throwsA(isA()), + reason: version, + ); + } + }); + + test('rejects payloads that omit the version', () { + final A2uiValidator validator = + A2uiValidator(catalogs: [testCatalog()]); + final Map> message = { + 'createSurface': {'surfaceId': 's1', 'catalogId': catalogId}, + }; + + expect( + () => validator.checkVersion(message), + throwsA(isA()), + ); + expect( + () => validator.parseMessages([message]), + throwsA(isA()), + ); + }); + + test('rejects an envelope naming no known message body', () { + final A2uiValidator validator = + A2uiValidator(catalogs: [testCatalog()]); + + expect( + () => validator.parseMessages([ + {'version': 'v0.9', 'notAMessage': {}}, + ]), + throwsA(isA()), + ); + }); + + test('is constructed for a supported version by name', () { + expect( + A2uiValidator.forVersion('v0.9').protocolVersion, + A2uiProtocolVersion.v0_9, + ); + }); + + test('cannot be constructed for an unsupported version', () { + expect( + () => A2uiValidator.forVersion('v1.0'), + throwsA(isA()), + ); + expect( + () => A2uiValidator.forVersion(null), + throwsA(isA()), + ); + }); + + test('indexes the catalogs it validates against by id', () { + final A2uiValidator validator = + A2uiValidator(catalogs: [testCatalog()]); + expect(validator.catalogs.keys, [catalogId]); + }); + }); + + group('A2uiValidator.validateStructure', () { + test('accepts a well formed component graph', () { + final A2uiValidator validator = + A2uiValidator(catalogs: [testCatalog()]); + final List messages = validator.parseMessages([ + createSurface(), + updateComponents([ + {'id': 'root', 'component': 'Card', 'child': 'label'}, + {'id': 'label', 'component': 'Text', 'text': 'Hello'}, + ]), + ]); + + expect(() => validator.validateStructure(messages), returnsNormally); + }, skip: pendingValidator); + + test('rejects duplicate component ids', () { + final A2uiValidator validator = + A2uiValidator(catalogs: [testCatalog()]); + final List messages = validator.parseMessages([ + createSurface(), + updateComponents([ + {'id': 'root', 'component': 'Text', 'text': 'a'}, + {'id': 'root', 'component': 'Text', 'text': 'b'}, + ]), + ]); + + expect( + () => validator.validateStructure(messages), + throwsA(isA()), + ); + }, skip: pendingValidator); + + test('rejects a child reference that names no component', () { + final A2uiValidator validator = + A2uiValidator(catalogs: [testCatalog()]); + final List messages = validator.parseMessages([ + createSurface(), + updateComponents([ + {'id': 'root', 'component': 'Card', 'child': 'missing'}, + ]), + ]); + + expect( + () => validator.validateStructure(messages), + throwsA(isA()), + ); + }, skip: pendingValidator); + + test('rejects a cycle in the component graph', () { + final A2uiValidator validator = + A2uiValidator(catalogs: [testCatalog()]); + final List messages = validator.parseMessages([ + createSurface(), + updateComponents([ + {'id': 'a', 'component': 'Card', 'child': 'b'}, + {'id': 'b', 'component': 'Card', 'child': 'a'}, + ]), + ]); + + expect( + () => validator.validateStructure(messages), + throwsA(isA()), + ); + }, skip: pendingValidator); + }); + + group('A2uiValidator.validateAgainstCatalogs', () { + test('accepts components that satisfy the catalog schema', () { + final A2uiValidator validator = + A2uiValidator(catalogs: [testCatalog()]); + final List messages = validator.parseMessages([ + createSurface(), + updateComponents([ + {'id': 'label', 'component': 'Text', 'text': 'Hello'}, + ]), + ]); + + expect(validator.validateAgainstCatalogs(messages), completes); + }, skip: pendingValidator); + + test('rejects a component missing a required property', () { + final A2uiValidator validator = + A2uiValidator(catalogs: [testCatalog()]); + final List messages = validator.parseMessages([ + createSurface(), + updateComponents([ + {'id': 'label', 'component': 'Text'}, + ]), + ]); + + expect( + validator.validateAgainstCatalogs(messages), + throwsA(isA()), + ); + }, skip: pendingValidator); + + test( + 'rejects a surface created against an unregistered catalog', + () { + final A2uiValidator validator = + A2uiValidator(catalogs: [testCatalog()]); + final List messages = validator.parseMessages([ + { + 'version': 'v0.9', + 'createSurface': { + 'surfaceId': 's1', + 'catalogId': 'https://example.com/catalogs/other.json', + }, + }, + ]); + + expect( + validator.validateAgainstCatalogs(messages), + throwsA(isA()), + ); + }, + skip: pendingValidator, + ); + }); + + group('A2uiValidator.validate', () { + test('returns the parsed messages for a valid payload', () async { + final A2uiValidator validator = + A2uiValidator(catalogs: [testCatalog()]); + + final List messages = await validator.validate([ + createSurface(), + updateComponents([ + {'id': 'label', 'component': 'Text', 'text': 'Hello'}, + ]), + ]); + + expect(messages, hasLength(2)); + expect(messages.first, isA()); + }, skip: pendingValidator); + + test('rejects an unsupported version before any deep check runs', () { + final A2uiValidator validator = + A2uiValidator(catalogs: [testCatalog()]); + + expect( + validator.validate([createSurface(version: 'v1.0')]), + throwsA(isA()), + ); + }); + }); +} diff --git a/renderers/web_core/package.json b/renderers/web_core/package.json index 99c0c62cfe..80a5d2434a 100644 --- a/renderers/web_core/package.json +++ b/renderers/web_core/package.json @@ -154,7 +154,8 @@ "gts": "^7.0.0", "rxjs": "^7.8.2", "typescript": "5.9.3", - "wireit": "^0.15.0-pre.2" + "wireit": "^0.15.0-pre.2", + "yaml": "^2.8.1" }, "dependencies": { "@preact/signals-core": "^1.14.2", diff --git a/renderers/web_core/src/v0_9/conformance/harness.ts b/renderers/web_core/src/v0_9/conformance/harness.ts new file mode 100644 index 0000000000..f222f7d370 --- /dev/null +++ b/renderers/web_core/src/v0_9/conformance/harness.ts @@ -0,0 +1,63 @@ +/* + * Copyright 2024 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 {existsSync, readFileSync} from 'node:fs'; +import * as path from 'node:path'; +import {fileURLToPath} from 'node:url'; +import {parse as parseYaml} from 'yaml'; + +/** + * Locates the repository's `conformance/` directory. + * + * Walks up from this module so the suites load whether the tests run from + * `src/` or from the compiled output in `dist/`. + */ +function conformanceRoot(): string { + let dir = path.dirname(fileURLToPath(import.meta.url)); + for (;;) { + const candidate = path.join(dir, 'conformance'); + if (existsSync(path.join(candidate, 'conformance_schema.json'))) { + return candidate; + } + const parent = path.dirname(dir); + if (parent === dir) { + throw new Error(`Could not locate the conformance/ directory above ${dir}.`); + } + dir = parent; + } +} + +/** + * Resolves a path from a conformance case against the `conformance/` directory. + */ +export function resolveConformancePath(relativePath: string): string { + return path.join(conformanceRoot(), relativePath); +} + +/** + * Loads a conformance suite, for example `core/data_model.yaml`. + */ +export function loadConformanceSuite>(suite: string): T[] { + const file = resolveConformancePath(suite); + if (!existsSync(file)) { + throw new Error(`Conformance suite not found: ${file}`); + } + const parsed = parseYaml(readFileSync(file, 'utf8')); + if (!Array.isArray(parsed)) { + throw new Error(`Conformance suite ${suite} must be a list of cases.`); + } + return parsed as T[]; +} diff --git a/renderers/web_core/src/v0_9/state/data-model.test.ts b/renderers/web_core/src/v0_9/state/data-model.test.ts index 1a72d0b7b0..28667a0eaf 100644 --- a/renderers/web_core/src/v0_9/state/data-model.test.ts +++ b/renderers/web_core/src/v0_9/state/data-model.test.ts @@ -16,9 +16,164 @@ import * as assert from 'node:assert'; import {describe, it, beforeEach} from 'node:test'; -import {DataModel} from './data-model.js'; +import {loadConformanceSuite} from '../conformance/harness.js'; +import {DataModel, type DataSubscription} from './data-model.js'; -describe('DataModel', () => { +/** + * Behaviour shared by every A2UI data model implementation lives in + * `conformance/core/data_model.yaml` and is exercised by the harness below. + * + * One mapping is worth calling out: `op: delete` maps to `set(path, undefined)`, + * because this implementation removes a key when its value becomes `undefined`. + * + * The `describe` blocks after the harness cover behaviour that is specific to + * this JavaScript implementation and therefore cannot be part of a shared, + * cross-language dataset: + * + * - prototype pollution guards (`__proto__`, `constructor`, `prototype`) and + * `Object.prototype` property leakage, which only exist because JavaScript + * objects have a prototype chain; + * - `null` and `undefined` path arguments, which languages with non-nullable + * string types cannot express; + * - the distinction between storing `undefined` and removing a key, which + * languages without `undefined` cannot express; + * - rejection of leading-zero list indices, which this implementation enforces + * and the Dart implementation currently does not; + * - unbounded list indices. Arrays here are sparse, so writing `/items/999999999` + * is cheap; Dart lists are dense, so the Dart implementation rejects the same + * write to avoid allocating the whole list; + * - notification on an unchanged value. This implementation copies containers on + * read and notifies only on an actual change, so replacing the root does not + * wake an observer whose own value stayed `undefined`. The Dart implementation + * notifies unconditionally. + */ + +interface ConformanceStep { + op: 'get' | 'set' | 'delete' | 'dispose'; + path?: string; + value?: unknown; + expect?: unknown; + expect_absent?: boolean; + expect_type?: 'list' | 'object'; + expect_values?: Record; + expect_notified?: string[]; + expect_error?: {category?: string; message?: string} | string; +} + +interface ConformanceCase { + name: string; + initial?: Record; + watch?: string[]; + steps: ConformanceStep[]; +} + +interface Observer { + path: string; + subscription: DataSubscription; + count: number; +} + +function deepCopy(value: T): T { + return value === undefined ? value : (JSON.parse(JSON.stringify(value)) as T); +} + +function errorMessagePattern(expectError: ConformanceStep['expect_error']): RegExp { + const message = typeof expectError === 'string' ? expectError : (expectError?.message ?? ''); + return new RegExp(message); +} + +function applyOp(model: DataModel, step: ConformanceStep, reason: string): void { + switch (step.op) { + case 'get': { + const actual = model.get(step.path!); + if (step.expect_absent === true) { + assert.strictEqual(actual, undefined, reason); + } + if (step.expect_type !== undefined) { + assert.strictEqual(Array.isArray(actual) ? 'list' : 'object', step.expect_type, reason); + } + if ('expect' in step) { + assert.deepStrictEqual(actual, step.expect, reason); + } + break; + } + case 'set': + model.set(step.path!, step.value); + break; + case 'delete': + // This implementation removes a key when its value becomes `undefined`. + model.set(step.path!, undefined); + break; + case 'dispose': + model.dispose(); + break; + default: + throw new Error(`Unknown data_model op: ${String(step.op)}`); + } +} + +function runCase(testCase: ConformanceCase): void { + // The suite is parsed once and shared across cases, so the initial data is + // deep copied before the model mutates it. + const model = new DataModel(deepCopy(testCase.initial) ?? {}); + const observers: Observer[] = (testCase.watch ?? []).map(path => { + const observer: Observer = {path, count: 0, subscription: undefined!}; + observer.subscription = model.subscribe(path, () => observer.count++); + return observer; + }); + + testCase.steps.forEach((step, index) => { + const reason = `${testCase.name} step ${index} (${step.op})`; + for (const observer of observers) { + observer.count = 0; + } + + if (step.expect_error !== undefined) { + assert.throws( + () => applyOp(model, step, reason), + errorMessagePattern(step.expect_error), + reason, + ); + return; + } + + applyOp(model, step, reason); + + if (step.expect_notified !== undefined) { + const notified: string[] = []; + for (const observer of observers) { + for (let i = 0; i < observer.count; i++) { + notified.push(observer.path); + } + } + assert.deepStrictEqual( + notified.sort(), + [...step.expect_notified].sort(), + `${reason}: notified observers`, + ); + } + + for (const [path, expected] of Object.entries(step.expect_values ?? {})) { + const observer = observers.find(o => o.path === path); + assert.ok(observer, `${reason}: ${path} is not watched`); + assert.deepStrictEqual(observer.subscription.value, expected, `${reason}: ${path}`); + } + }); +} + +describe('conformance core/data_model.yaml', () => { + const cases = loadConformanceSuite('core/data_model.yaml'); + + it('suite is not empty', () => { + assert.ok(cases.length > 0); + }); + + for (const testCase of cases) { + it(testCase.name, () => runCase(testCase)); + } +}); + +describe('DataModel (JavaScript specific)', () => { let model: DataModel; beforeEach(() => { @@ -33,59 +188,7 @@ describe('DataModel', () => { }); }); - // --- Initialization --- - - it('initializes with empty data if not provided', () => { - const emptyModel = new DataModel(); - assert.deepStrictEqual(emptyModel.get('/'), {}); - }); - - // --- Basic Retrieval --- - - it('retrieves root data', () => { - assert.deepStrictEqual(model.get('/'), { - user: {name: 'Alice', settings: {theme: 'dark'}}, - items: ['a', 'b', 'c'], - }); - }); - - it('retrieves nested path', () => { - assert.strictEqual(model.get('/user/name'), 'Alice'); - assert.strictEqual(model.get('/user/settings/theme'), 'dark'); - }); - - it('retrieves array items', () => { - assert.strictEqual(model.get('/items/0'), 'a'); - assert.strictEqual(model.get('/items/1'), 'b'); - }); - - it('returns undefined for non-existent paths', () => { - assert.strictEqual(model.get('/user/age'), undefined); - assert.strictEqual(model.get('/unknown/path'), undefined); - }); - - it('returns undefined when traversing through undefined/null segments', () => { - model.set('/nullable', null); - assert.strictEqual(model.get('/nullable/deep/path'), undefined); - }); - - // --- Updates --- - - it('sets value at existing path', () => { - model.set('/user/name', 'Bob'); - assert.strictEqual(model.get('/user/name'), 'Bob'); - }); - - it('sets value at new path', () => { - model.set('/user/age', 30); - assert.strictEqual(model.get('/user/age'), 30); - }); - - it('creates intermediate objects', () => { - model.set('/a/b/c', 'foo'); - assert.strictEqual(model.get('/a/b/c'), 'foo'); - assert.notStrictEqual(model.get('/a/b'), undefined); - }); + // --- undefined, which the shared dataset expresses as `op: delete` --- it('removes keys when value is undefined', () => { model.set('/user/name', undefined); @@ -93,54 +196,24 @@ describe('DataModel', () => { assert.strictEqual(Object.keys(model.get('/user')).includes('name'), false); }); - // --- Array / List Handling (Flutter Parity) --- - - it('List: set and get', () => { - model.set('/list/0', 'hello'); - assert.strictEqual(model.get('/list/0'), 'hello'); - assert.ok(Array.isArray(model.get('/list'))); - }); - - it('List: append and get', () => { - model.set('/list/0', 'hello'); - model.set('/list/1', 'world'); - assert.strictEqual(model.get('/list/0'), 'hello'); - assert.strictEqual(model.get('/list/1'), 'world'); - assert.strictEqual(model.get('/list').length, 2); - }); - - it('List: update existing index', () => { - model.set('/items/1', 'updated'); - assert.strictEqual(model.get('/items/1'), 'updated'); - }); + it('handles updates to undefined', () => { + model.set('/foo', 'bar'); + let val: unknown = 'initial'; + const sub = model.subscribe('/foo', v => (val = v)); - it('Nested structures are created automatically', () => { - // Should create nested map and list: { a: { b: [ { c: 123 } ] } } - model.set('/a/b/0/c', 123); - assert.strictEqual(model.get('/a/b/0/c'), 123); - assert.ok(Array.isArray(model.get('/a/b'))); - assert.ok(!Array.isArray(model.get('/a/b/0'))); - - // Should create nested maps - model.set('/x/y/z', 'hello'); - assert.strictEqual(model.get('/x/y/z'), 'hello'); - - // Should create nested lists - model.set('/nestedList/0/0', 'inner'); - assert.strictEqual(model.get('/nestedList/0/0'), 'inner'); - assert.ok(Array.isArray(model.get('/nestedList'))); - assert.ok(Array.isArray(model.get('/nestedList/0'))); + model.set('/foo', undefined); + assert.strictEqual(sub.value, undefined); + assert.strictEqual(val, undefined); }); - // --- Subscriptions --- + // --- Subscription objects --- it('returns a subscription object', () => { model.set('/a', 1); + let updatedValue: number | undefined; const sub = model.subscribe('/a', val => (updatedValue = val)); assert.strictEqual(sub.value, 1); - let updatedValue: number | undefined; - model.set('/a', 2); assert.strictEqual(sub.value, 2); assert.strictEqual(updatedValue, 2); @@ -151,89 +224,11 @@ describe('DataModel', () => { assert.strictEqual(updatedValue, 2); }); - it('notifies subscribers on exact match', () => { - let called = false; - model.subscribe('/user/name', val => { - assert.strictEqual(val, 'Charlie'); - called = true; - }); - model.set('/user/name', 'Charlie'); - assert.strictEqual(called, true, 'Callback was never called'); - }); - - it('notifies ancestor subscribers (Container Semantics)', () => { - let called = false; - model.subscribe('/user', (val: any) => { - assert.strictEqual(val.name, 'Dave'); - called = true; - }); - model.set('/user/name', 'Dave'); - assert.strictEqual(called, true, 'Callback was never called'); - }); - - it('notifies descendant subscribers', () => { - let called = false; - model.subscribe('/user/settings/theme', val => { - assert.strictEqual(val, 'light'); - called = true; - }); - - // We update the parent object - model.set('/user/settings', {theme: 'light'}); - assert.strictEqual(called, true, 'Callback was never called'); - }); - - it('notifies root subscriber', () => { - let called = false; - model.subscribe('/', (val: any) => { - assert.strictEqual(val.newProp, 'test'); - called = true; - }); - model.set('/newProp', 'test'); - assert.strictEqual(called, true, 'Callback was never called'); - }); - - it('notifies parent when child updates', () => { - model.set('/parent', {child: 'initial'}); - - let parentValue: any; - model.subscribe('/parent', val => (parentValue = val)); - - model.set('/parent/child', 'updated'); - assert.deepStrictEqual(parentValue, {child: 'updated'}); - }); - - it('stops notifying after dispose', () => { - let count = 0; - model.subscribe('/', () => count++); - - model.dispose(); - model.set('/foo', 'bar'); - assert.strictEqual(count, 0); - }); - - it('supports multiple subscribers to the same path', () => { - let callCount1 = 0; - let callCount2 = 0; - - const sub1 = model.subscribe('/user/name', () => callCount1++); - - const sub2 = model.subscribe('/user/name', () => callCount2++); - - model.set('/user/name', 'Eve'); - - assert.strictEqual(callCount1, 1); - assert.strictEqual(callCount2, 1); - assert.strictEqual(sub1.value, 'Eve'); - assert.strictEqual(sub2.value, 'Eve'); - }); - it('allows unsubscribing individual listeners', () => { let callCount1 = 0; let callCount2 = 0; const sub1 = model.subscribe('/user/name', () => callCount1++); - const sub2 = model.subscribe('/user/name', () => callCount2++); sub1.unsubscribe(); @@ -249,119 +244,37 @@ describe('DataModel', () => { assert.strictEqual(callCount2, 1); // still 1 }); - it('handles subscription to non-existent path', () => { - let val: any; - const sub = model.subscribe('/non/existent', v => (val = v)); - assert.strictEqual(sub.value, undefined); - - model.set('/non/existent', 'exists now'); - assert.strictEqual(sub.value, 'exists now'); - assert.strictEqual(val, 'exists now'); - }); - - it('handles updates to undefined', () => { - model.set('/foo', 'bar'); - let val: any = 'initial'; - const sub = model.subscribe('/foo', v => (val = v)); - - model.set('/foo', undefined); - assert.strictEqual(sub.value, undefined); - assert.strictEqual(val, undefined); - }); - - it('throws when trying to set nested property through a primitive', () => { - model.set('/user/name', 'not an object'); - assert.strictEqual(model.get('/user/name'), 'not an object'); - - assert.throws(() => { - model.set('/user/name/first', 'Alice'); - }, /Cannot set path/); - }); - - it('throws when using non-numeric segment on an array', () => { - assert.throws(() => { - model.set('/items/foo', 'bar'); - }, /Cannot use non-numeric segment/); - }); - - it('throws when using non-numeric segment on an array (intermediate)', () => { - model.set('/', {items: [1, 2, 3]}); - assert.throws(() => { - model.set('/items/foo/bar', 'value'); - }, /Cannot use non-numeric segment 'foo' on an array/); - }); - - it('normalizes trailing slashes', () => { - let callCount = 0; - model.subscribe('/foo', () => callCount++); - model.set('/foo/', 'bar'); // Trailing slash - assert.strictEqual(model.get('/foo/'), 'bar'); - assert.strictEqual(callCount, 1); - }); - - it('replaces root object on root update', () => { - let callCount = 0; - model.subscribe('/', () => callCount++); - // Just add another sub on a generic path to ensure notifyAllSubscribers loop hits multiple items - model.subscribe('/unrelated', () => {}); - - model.set('/', {newRoot: 'foo'}); - assert.deepStrictEqual(model.get(''), {newRoot: 'foo'}); - assert.strictEqual(callCount, 1); - }); + // --- Null and undefined path arguments --- it('throws when path is null or undefined', () => { - assert.throws(() => model.get(null as any), /Path cannot be null or undefined/); - assert.throws(() => model.get(undefined as any), /Path cannot be null or undefined/); - assert.throws(() => model.set(null as any, 'value'), /Path cannot be null or undefined/); - assert.throws(() => model.set(undefined as any, 'value'), /Path cannot be null or undefined/); + assert.throws(() => model.get(null as never), /Path cannot be null or undefined/); + assert.throws(() => model.get(undefined as never), /Path cannot be null or undefined/); + assert.throws(() => model.set(null as never, 'value'), /Path cannot be null or undefined/); + assert.throws(() => model.set(undefined as never, 'value'), /Path cannot be null or undefined/); }); it('calculates descendants against root path', () => { // This explicitly hits an internal method branch where parentPath === "/" - const isDescendant = (model as any).isDescendant.bind(model); + const isDescendant = ( + model as unknown as {isDescendant: (a: string, b: string) => boolean} + ).isDescendant.bind(model); assert.strictEqual(isDescendant('/user', '/'), true); assert.strictEqual(isDescendant('/', '/'), false); }); - describe('JSON Pointer Escaping (RFC 6901)', () => { - it('handles escaped slashes (~1)', () => { - model.set('/user/detailed~1info', 'some info'); - assert.strictEqual(model.get('/user/detailed~1info'), 'some info'); - - // Verify it was actually set as a key with a slash in the underlying object - const user = model.get('/user'); - assert.strictEqual(user['detailed/info'], 'some info'); - assert.strictEqual(user['detailed~1info'], undefined); - }); - - it('handles escaped tildes (~0)', () => { - model.set('/user/profile~0name', 'profile~name'); - assert.strictEqual(model.get('/user/profile~0name'), 'profile~name'); - - const user = model.get('/user'); - assert.strictEqual(user['profile~name'], 'profile~name'); - assert.strictEqual(user['profile~0name'], undefined); - }); - - it('handles mixed escaped characters', () => { - model.set('/user/a~0b~1c', 'value'); - assert.strictEqual(model.get('/user/a~0b~1c'), 'value'); - - const user = model.get('/user'); - assert.strictEqual(user['a~b/c'], 'value'); - }); - - it('handles escaped sequence order correctly (~01)', () => { - model.set('/user/a~01b', 'value'); - assert.strictEqual(model.get('/user/a~01b'), 'value'); + // --- Leading-zero list indices --- - const user = model.get('/user'); - assert.strictEqual(user['a~1b'], 'value'); - }); + it('rejects leading-zero array indices (RFC 6901)', () => { + assert.throws(() => { + model.set('/items/01', 'value'); + }, /Cannot use non-numeric segment/); + assert.throws(() => { + model.set('/items/01/nested', 'value'); + }, /Cannot use non-numeric segment/); + assert.strictEqual(model.get('/items/01'), undefined); }); - // --- Security Tests: Prototype Pollution Protection --- + // --- Security: prototype pollution protection --- it('prevents prototype pollution via __proto__ in set, get, getSignal, subscribe', () => { assert.throws( @@ -377,7 +290,7 @@ describe('DataModel', () => { () => model.subscribe('/__proto__/polluted', () => {}), /Forbidden path segment '__proto__'/, ); - assert.strictEqual(({} as any).polluted, undefined); + assert.strictEqual(({} as {polluted?: unknown}).polluted, undefined); }); it('prevents prototype pollution via constructor in set, get, getSignal, subscribe', () => { @@ -397,7 +310,7 @@ describe('DataModel', () => { () => model.subscribe('/constructor/prototype/polluted', () => {}), /Forbidden path segment 'constructor'/, ); - assert.strictEqual(({} as any).polluted, undefined); + assert.strictEqual(({} as {polluted?: unknown}).polluted, undefined); }); it('prevents prototype pollution via prototype in set, get, getSignal, subscribe', () => { @@ -417,7 +330,26 @@ describe('DataModel', () => { () => model.subscribe('/user/prototype/polluted', () => {}), /Forbidden path segment 'prototype'/, ); - assert.strictEqual(({} as any).polluted, undefined); + assert.strictEqual(({} as {polluted?: unknown}).polluted, undefined); + }); + + it('allows a large sparse array index', () => { + // Arrays here are sparse, so this allocates nothing. Implementations with + // dense lists reject the same write; see conformance/core/data_model.yaml. + model.set('/items/999999', 'x'); + assert.strictEqual(model.get('/items/999999'), 'x'); + }); + + it('does not wake an observer whose value did not change', () => { + let unrelatedCount = 0; + let rootCount = 0; + model.subscribe('/', () => rootCount++); + model.subscribe('/unrelated', () => unrelatedCount++); + + model.set('/', {newRoot: 'foo'}); + + assert.strictEqual(rootCount, 1); + assert.strictEqual(unrelatedCount, 0); }); it('does not leak Object.prototype inherited properties on get', () => { @@ -433,26 +365,4 @@ describe('DataModel', () => { model.set('/valueOf/nested', 'custom valueOf'); assert.strictEqual(model.get('/valueOf/nested'), 'custom valueOf'); }); - - it('throws when trying to set nested property through a primitive in an array', () => { - assert.throws(() => { - model.set('/items/0/foo', 'bar'); - }, /Cannot set path/); - }); - - it('returns undefined for out-of-bounds or non-numeric array index in get', () => { - assert.strictEqual(model.get('/items/99'), undefined); - assert.strictEqual(model.get('/items/-1'), undefined); - assert.strictEqual(model.get('/items/invalid'), undefined); - }); - - it('rejects leading-zero array indices (RFC 6901)', () => { - assert.throws(() => { - model.set('/items/01', 'value'); - }, /Cannot use non-numeric segment/); - assert.throws(() => { - model.set('/items/01/nested', 'value'); - }, /Cannot use non-numeric segment/); - assert.strictEqual(model.get('/items/01'), undefined); - }); }); diff --git a/yarn.lock b/yarn.lock index 5f247863fe..d8de83208c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -469,6 +469,7 @@ __metadata: rxjs: "npm:^7.8.2" typescript: "npm:5.9.3" wireit: "npm:^0.15.0-pre.2" + yaml: "npm:^2.8.1" zod: "npm:^3.25.76" zod-to-json-schema: "npm:^3.25.2" languageName: unknown From 95f1d785856e0eefb546e636a9dfad1236f26a45 Mon Sep 17 00:00:00 2001 From: Polina Cherkasova Date: Wed, 26 Aug 2026 14:38:32 -0700 Subject: [PATCH 02/22] Fix CI: format with the SDK CI uses, drop non-portable conformance cases Formatting: the Dart formatter in the SDK CI runs (3.13.1, via Flutter stable 3.47.1) disagreed with 3.12.2 on eight test files. Reformatted with 3.13.1. Conformance: six cases added to the shared suites asserted behaviour only the Dart SDK has, which broke the Python and Kotlin harnesses that already consume those suites. A shared suite is a contract every implementation satisfies, so these move back to Dart's own tests, where they were already covered: - parse_full rejecting an unsupported or missing protocol version, and an unknown message type. The Python and Kotlin parsers do not validate the version while parsing. - prune with allowed_functions. Function pruning is implemented by the Dart FunctionPruningTransformer only. - select_catalog raising when renderer and agent share no catalog. Kotlin raises with a different message; Python returns no selection. conformance/README.md now separates these "one SDK has it, others do not yet" gaps from genuine language-level exclusions, and lists the three above. The cases that do hold everywhere stay shared: basic catalog loading and selection, component pruning with anyComponent narrowing, and parsing a v0.9 basic catalog payload. --- conformance/README.md | 13 +- conformance/agent/inference_format.yaml | 29 --- conformance/agent/parser.yaml | 38 --- conformance/core/catalog.yaml | 35 --- .../test/e2e/primary_use_case_test.dart | 80 +++---- .../direct_json_format_test.dart | 122 +++++----- .../direct_json_streaming_test.dart | 46 ++-- .../test/inference_formats/express_test.dart | 44 ++-- .../test/processor/generator_test.dart | 76 +++--- .../test/processor/processor_test.dart | 222 ++++++++---------- .../test/utils/catalog_resolver_test.dart | 102 ++++---- dart/a2ui_core/test/validator_test.dart | 36 ++- 12 files changed, 328 insertions(+), 515 deletions(-) diff --git a/conformance/README.md b/conformance/README.md index 146fa62cd8..2dd2924a7e 100644 --- a/conformance/README.md +++ b/conformance/README.md @@ -33,7 +33,18 @@ Cases may also reference published specification artifacts by relative path, for ## Scope of a shared dataset -A suite in this directory is a contract every implementation must satisfy, so it holds only behaviour that can hold across languages. Behaviour that is genuinely language specific stays in the owning package's own tests, with a note pointing back here. `core/data_model.yaml`, migrated from `renderers/web_core/src/v0_9/state/data-model.test.ts`, documents the exclusions it made and why. +A suite in this directory is a contract every implementation must satisfy. Adding a case here asserts that every SDK already behaves that way, so a case that one SDK passes and another does not belongs in the failing SDK's own tests until the behaviour is agreed. Two kinds of exclusion come up: + +- **Language specific behaviour**, which cannot hold across implementations at all. `core/data_model.yaml`, migrated from `renderers/web_core/src/v0_9/state/data-model.test.ts`, lists the exclusions it made and why. +- **Behaviour one SDK has and others do not yet.** These are real gaps rather than disagreements, but a suite is not the place to record them, because it would turn every other SDK's build red. They belong in an issue and in the owning SDK's own tests. + +Behaviour currently in the second category, held by the Dart SDK only: + +- Rejecting a payload whose messages declare a protocol version the SDK does not implement, or omit `version` entirely, during `parse_full`. The Python and Kotlin parsers do not validate the version while parsing. +- Pruning catalog **functions**. The `prune` action currently supports `allowed_components` and `allowed_messages`; `allowed_functions` is implemented by the Dart `FunctionPruningTransformer` only. +- Raising an error from `select_catalog` when the renderer and agent share no catalog. Kotlin raises with a different message and Python returns no selection instead. + +The new `process_request` action does assert version rejection, because that action has no prior implementations and its contract is being defined with it. ## Usage in SDKs diff --git a/conformance/agent/inference_format.yaml b/conformance/agent/inference_format.yaml index 52708d95f0..953a057689 100644 --- a/conformance/agent/inference_format.yaml +++ b/conformance/agent/inference_format.yaml @@ -355,32 +355,3 @@ client_capabilities: supportedCatalogIds: ["https://a2ui.org/specification/v0_9/catalogs/basic/catalog.json"] expect_selected: "https://a2ui.org/specification/v0_9/catalogs/basic/catalog.json" - -- name: test_select_catalog_no_overlap_v0_9 - description: A renderer that supports no registered catalog cannot be served. - action: select_catalog - args: - supported_catalogs: - - catalogId: "https://a2ui.org/specification/v0_9/catalogs/basic/catalog.json" - components: {} - client_capabilities: - supportedCatalogIds: ["https://example.com/catalogs/unknown.json"] - expect_error: - category: "CatalogError" - message: "no matching catalog" - -- name: test_select_catalog_rejects_unsupported_protocol_version - description: >- - Capabilities that carry no entry for a supported protocol version are - rejected rather than silently defaulting. - action: select_catalog - args: - supported_catalogs: - - catalogId: "https://a2ui.org/specification/v0_9/catalogs/basic/catalog.json" - components: {} - client_capabilities_versioned: - "v1.0": - supportedCatalogIds: ["https://a2ui.org/specification/v0_9/catalogs/basic/catalog.json"] - expect_error: - category: "ValidationError" - message: "v0.9" diff --git a/conformance/agent/parser.yaml b/conformance/agent/parser.yaml index b453e2dfe4..3c4133be26 100644 --- a/conformance/agent/parser.yaml +++ b/conformance/agent/parser.yaml @@ -167,41 +167,3 @@ createSurface: surfaceId: "s1" catalogId: "https://a2ui.org/specification/v0_9/catalogs/basic/catalog.json" - -- name: test_parse_response_rejects_v1_0_message - description: A payload message declaring v1.0 is rejected by a v0.9 implementation. - catalog: - version: "0.9" - catalog_schema: "../specification/v0_9_1/catalogs/basic/catalog.json" - action: parse_full - input: >- - [{"version": "v1.0", "createSurface": {"surfaceId": "s1", - "catalogId": - "https://a2ui.org/specification/v0_9/catalogs/basic/catalog.json"}}] - expect_error: - category: "ValidationError" - message: "v1.0" - -- name: test_parse_response_rejects_message_without_version - description: A payload message that omits the version field is rejected. - catalog: - version: "0.9" - catalog_schema: "../specification/v0_9_1/catalogs/basic/catalog.json" - action: parse_full - input: >- - [{"createSurface": {"surfaceId": "s1", "catalogId": - "https://a2ui.org/specification/v0_9/catalogs/basic/catalog.json"}}] - expect_error: - category: "ValidationError" - message: "version" - -- name: test_parse_response_rejects_unknown_message_type - description: A payload envelope that names no known message body is rejected. - catalog: - version: "0.9" - catalog_schema: "../specification/v0_9_1/catalogs/basic/catalog.json" - action: parse_full - input: '[{"version": "v0.9", "notAMessage": {}}]' - expect_error: - category: "ValidationError" - message: "Unknown A2UI message type" diff --git a/conformance/core/catalog.yaml b/conformance/core/catalog.yaml index e058d2771c..11db44cf42 100644 --- a/conformance/core/catalog.yaml +++ b/conformance/core/catalog.yaml @@ -518,41 +518,6 @@ - $ref: "#/components/Card" - $ref: "#/components/Button" -- name: test_prune_functions_narrows_any_function_union - description: Pruning functions narrows the anyFunction union to the kept functions. - catalog: - version: "0.9" - catalog_schema: - catalogId: "https://a2ui.org/specification/v0_9/catalogs/basic/catalog.json" - components: - Text: {type: object} - functions: - required: {type: object} - email: {type: object} - openUrl: {type: object} - $defs: - anyFunction: - oneOf: - - $ref: "#/functions/required" - - $ref: "#/functions/email" - - $ref: "#/functions/openUrl" - action: prune - args: - allowed_functions: [required, email] - expect: - catalog_schema: - catalogId: "https://a2ui.org/specification/v0_9/catalogs/basic/catalog.json" - components: - Text: {type: object} - functions: - required: {type: object} - email: {type: object} - $defs: - anyFunction: - oneOf: - - $ref: "#/functions/required" - - $ref: "#/functions/email" - - name: test_prune_unknown_component_names_are_ignored description: Allowlist entries the catalog does not declare are ignored, not errors. catalog: diff --git a/dart/a2ui_agent/test/e2e/primary_use_case_test.dart b/dart/a2ui_agent/test/e2e/primary_use_case_test.dart index 03c7610810..932c16248c 100644 --- a/dart/a2ui_agent/test/e2e/primary_use_case_test.dart +++ b/dart/a2ui_agent/test/e2e/primary_use_case_test.dart @@ -150,31 +150,27 @@ void main() { }); group('primary use case: rejected turns', () { - test( - 'rejects a payload declaring an unsupported protocol version', - () { - final Map data = conformanceCase( - 'test_primary_use_case_rejects_unsupported_version', - ); - final args = data['args']! as Map; - - final generator = A2uiGenerator( - catalogs: [CatalogConfig(basicCatalog())], - ); - final A2uiRequestProcessor - processor = generator.createProcessor( - A2uiRendererCapabilities.fromJson( - args['client_capabilities']! as Map, - ), - ); + test('rejects a payload declaring an unsupported protocol version', () { + final Map data = conformanceCase( + 'test_primary_use_case_rejects_unsupported_version', + ); + final args = data['args']! as Map; - expect( - () => processor.parseResponse(args['llm_response']! as String), - throwsA(isA()), - ); - }, - skip: pendingEndToEnd, - ); + final generator = A2uiGenerator( + catalogs: [CatalogConfig(basicCatalog())], + ); + final A2uiRequestProcessor processor = + generator.createProcessor( + A2uiRendererCapabilities.fromJson( + args['client_capabilities']! as Map, + ), + ); + + expect( + () => processor.parseResponse(args['llm_response']! as String), + throwsA(isA()), + ); + }, skip: pendingEndToEnd); test('rejects a payload whose messages omit the version', () { final Map data = conformanceCase( @@ -198,29 +194,25 @@ void main() { ); }, skip: pendingEndToEnd); - test( - 'rejects a renderer that supports no registered catalog', - () { - final Map data = conformanceCase( - 'test_primary_use_case_rejects_unknown_catalog', - ); - final args = data['args']! as Map; + test('rejects a renderer that supports no registered catalog', () { + final Map data = conformanceCase( + 'test_primary_use_case_rejects_unknown_catalog', + ); + final args = data['args']! as Map; - final generator = A2uiGenerator( - catalogs: [CatalogConfig(basicCatalog())], - ); + final generator = A2uiGenerator( + catalogs: [CatalogConfig(basicCatalog())], + ); - expect( - () => generator.createProcessor( - A2uiRendererCapabilities.fromJson( - args['client_capabilities']! as Map, - ), + expect( + () => generator.createProcessor( + A2uiRendererCapabilities.fromJson( + args['client_capabilities']! as Map, ), - throwsA(isA()), - ); - }, - skip: pendingEndToEnd, - ); + ), + throwsA(isA()), + ); + }, skip: pendingEndToEnd); }); group('primary use case data', () { diff --git a/dart/a2ui_agent/test/inference_formats/direct_json_format_test.dart b/dart/a2ui_agent/test/inference_formats/direct_json_format_test.dart index 1e433bb2cd..84bfac4387 100644 --- a/dart/a2ui_agent/test/inference_formats/direct_json_format_test.dart +++ b/dart/a2ui_agent/test/inference_formats/direct_json_format_test.dart @@ -112,59 +112,47 @@ void main() { expect(generator.allowedMessages, isNull); }); - test( - 'embeds the catalog schema in an a2ui_schema block', - () { - final generator = - DirectJsonPromptGenerator([ - basicCatalog(), - ]); - - final String prompt = generator.generate(); - - expect(prompt, contains(a2uiSchemaOpenTag)); - expect(prompt, contains(a2uiSchemaCloseTag)); - expect(prompt, contains('"Card"')); - expect(prompt, contains('"TextField"')); - expect(prompt, contains('"required"')); - }, - skip: pendingPromptGenerator, - ); - - test( - 'instructs the model to emit payloads inside a2ui-json tags', - () { - final generator = - DirectJsonPromptGenerator([ - smallCatalog(), - ]); - - final String prompt = generator.generate(); - - expect(prompt, contains(a2uiJsonOpenTag)); - expect(prompt, contains(a2uiJsonCloseTag)); - }, - skip: pendingPromptGenerator, - ); - - test( - 'describes only the components a pruned catalog still declares', - () { - final SchemaCatalog pruned = - ComponentPruningTransformer([ - 'Text', - ]).transform(smallCatalog()); - - final String prompt = - DirectJsonPromptGenerator([ - pruned, - ]).generate(); - - expect(prompt, contains('"Text"')); - expect(prompt, isNot(contains('"Button"'))); - }, - skip: pendingPromptGenerator, - ); + test('embeds the catalog schema in an a2ui_schema block', () { + final generator = + DirectJsonPromptGenerator([ + basicCatalog(), + ]); + + final String prompt = generator.generate(); + + expect(prompt, contains(a2uiSchemaOpenTag)); + expect(prompt, contains(a2uiSchemaCloseTag)); + expect(prompt, contains('"Card"')); + expect(prompt, contains('"TextField"')); + expect(prompt, contains('"required"')); + }, skip: pendingPromptGenerator); + + test('instructs the model to emit payloads inside a2ui-json tags', () { + final generator = + DirectJsonPromptGenerator([ + smallCatalog(), + ]); + + final String prompt = generator.generate(); + + expect(prompt, contains(a2uiJsonOpenTag)); + expect(prompt, contains(a2uiJsonCloseTag)); + }, skip: pendingPromptGenerator); + + test('describes only the components a pruned catalog still declares', () { + final SchemaCatalog pruned = + ComponentPruningTransformer([ + 'Text', + ]).transform(smallCatalog()); + + final String prompt = + DirectJsonPromptGenerator([ + pruned, + ]).generate(); + + expect(prompt, contains('"Text"')); + expect(prompt, isNot(contains('"Button"'))); + }, skip: pendingPromptGenerator); test('renders the example turns it was given', () { final generator = @@ -186,22 +174,18 @@ void main() { expect(prompt, contains('createSurface')); }, skip: pendingPromptGenerator); - test( - 'restricts the described envelopes to the allowed messages', - () { - final generator = - DirectJsonPromptGenerator( - [smallCatalog()], - allowedMessages: ['createSurface'], - ); - - final String prompt = generator.generate(); - - expect(prompt, contains('createSurface')); - expect(prompt, isNot(contains('deleteSurface'))); - }, - skip: pendingPromptGenerator, - ); + test('restricts the described envelopes to the allowed messages', () { + final generator = + DirectJsonPromptGenerator( + [smallCatalog()], + allowedMessages: ['createSurface'], + ); + + final String prompt = generator.generate(); + + expect(prompt, contains('createSurface')); + expect(prompt, isNot(contains('deleteSurface'))); + }, skip: pendingPromptGenerator); test('describes the protocol version it targets', () { final String prompt = diff --git a/dart/a2ui_agent/test/inference_formats/direct_json_streaming_test.dart b/dart/a2ui_agent/test/inference_formats/direct_json_streaming_test.dart index 71aa734448..2caa4deaa8 100644 --- a/dart/a2ui_agent/test/inference_formats/direct_json_streaming_test.dart +++ b/dart/a2ui_agent/test/inference_formats/direct_json_streaming_test.dart @@ -87,34 +87,26 @@ void main() { ); }, skip: pendingStreaming); - test( - 'heals a string cut mid-token when its key is progressive', - () { - final DirectJsonStreamProcessor p = - processor()..process( - '$a2uiJsonOpenTag[{"version": "v0.9", "updateComponents": ' - '{"surfaceId": "s1", "components": [{"id": "t", ' - '"component": "Text", "text": "Partial te', - ); + test('heals a string cut mid-token when its key is progressive', () { + final DirectJsonStreamProcessor p = + processor()..process( + '$a2uiJsonOpenTag[{"version": "v0.9", "updateComponents": ' + '{"surfaceId": "s1", "components": [{"id": "t", ' + '"component": "Text", "text": "Partial te', + ); + + expect(p.progressiveKeys, contains('text')); + }, skip: pendingStreaming); - expect(p.progressiveKeys, contains('text')); - }, - skip: pendingStreaming, - ); - - test( - 'rejects a message declaring another protocol version', - () { - expect( - () => processor().process( - '$a2uiJsonOpenTag[{"version": "v1.0", "deleteSurface": ' - '{"surfaceId": "s1"}}]$a2uiJsonCloseTag', - ), - throwsA(isA()), - ); - }, - skip: pendingStreaming, - ); + test('rejects a message declaring another protocol version', () { + expect( + () => processor().process( + '$a2uiJsonOpenTag[{"version": "v1.0", "deleteSurface": ' + '{"surfaceId": "s1"}}]$a2uiJsonCloseTag', + ), + throwsA(isA()), + ); + }, skip: pendingStreaming); }); group('DirectJsonStreamProcessor.finish', () { diff --git a/dart/a2ui_agent/test/inference_formats/express_test.dart b/dart/a2ui_agent/test/inference_formats/express_test.dart index 5c7e0f6d9e..fa692606ab 100644 --- a/dart/a2ui_agent/test/inference_formats/express_test.dart +++ b/dart/a2ui_agent/test/inference_formats/express_test.dart @@ -82,20 +82,16 @@ void main() { }); group('ExpressPromptGenerator', () { - test( - 'renders compact positional signatures for the catalog', - () { - final String prompt = - ExpressPromptGenerator([ - smallCatalog(), - ]).generate(); - - expect(prompt, contains(a2uiExpressOpenTag)); - expect(prompt, contains('Text')); - expect(prompt, contains('Card')); - }, - skip: pendingExpress, - ); + test('renders compact positional signatures for the catalog', () { + final String prompt = + ExpressPromptGenerator([ + smallCatalog(), + ]).generate(); + + expect(prompt, contains(a2uiExpressOpenTag)); + expect(prompt, contains('Text')); + expect(prompt, contains('Card')); + }, skip: pendingExpress); }); group('ExpressCompiler', () { @@ -117,18 +113,14 @@ void main() { ); }, skip: pendingExpress); - test( - 'rejects a component the active catalogs do not declare', - () { - expect( - () => ExpressCompiler( - catalogs: [smallCatalog()], - ).compile('Video(v1, "https://example.com/clip.mp4")'), - throwsA(isA()), - ); - }, - skip: pendingExpress, - ); + test('rejects a component the active catalogs do not declare', () { + expect( + () => ExpressCompiler( + catalogs: [smallCatalog()], + ).compile('Video(v1, "https://example.com/clip.mp4")'), + throwsA(isA()), + ); + }, skip: pendingExpress); }); group('ExpressDecompiler', () { diff --git a/dart/a2ui_agent/test/processor/generator_test.dart b/dart/a2ui_agent/test/processor/generator_test.dart index ed080ee3c3..a6854c40d9 100644 --- a/dart/a2ui_agent/test/processor/generator_test.dart +++ b/dart/a2ui_agent/test/processor/generator_test.dart @@ -188,20 +188,16 @@ void main() { ); }, skip: pendingNegotiation); - test( - 'rejects a renderer that supports no registered catalog', - () { - expect( - () => generator().createProcessor( - A2uiRendererCapabilities.forCatalogIds([ - 'https://example.com/unknown.json', - ]), - ), - throwsA(isA()), - ); - }, - skip: pendingNegotiation, - ); + test('rejects a renderer that supports no registered catalog', () { + expect( + () => generator().createProcessor( + A2uiRendererCapabilities.forCatalogIds([ + 'https://example.com/unknown.json', + ]), + ), + throwsA(isA()), + ); + }, skip: pendingNegotiation); test('rejects capabilities carrying no v0.9 entry', () { expect( @@ -214,37 +210,33 @@ void main() { ); }); - test( - 'rejects examples that the negotiated catalog cannot express', - () { - final A2uiGenerator g = generator( - catalogs: [ - CatalogConfig( - basicCatalog(), - transformers: [ - ComponentPruningTransformer(['Text']), + test('rejects examples that the negotiated catalog cannot express', () { + final A2uiGenerator g = generator( + catalogs: [ + CatalogConfig( + basicCatalog(), + transformers: [ + ComponentPruningTransformer(['Text']), + ], + ), + ], + examples: { + 'uses a pruned component': [ + UpdateComponentsMessage( + surfaceId: 's1', + components: [ + {'id': 'v', 'component': 'Video', 'url': 'https://x/y.mp4'}, ], ), ], - examples: { - 'uses a pruned component': [ - UpdateComponentsMessage( - surfaceId: 's1', - components: [ - {'id': 'v', 'component': 'Video', 'url': 'https://x/y.mp4'}, - ], - ), - ], - }, - ); - - expect( - () => g.createProcessor(basicCatalogCapabilities()), - throwsA(isA()), - ); - }, - skip: pendingNegotiation, - ); + }, + ); + + expect( + () => g.createProcessor(basicCatalogCapabilities()), + throwsA(isA()), + ); + }, skip: pendingNegotiation); test('creates an independent processor per request', () { final A2uiGenerator g = generator(); diff --git a/dart/a2ui_agent/test/processor/processor_test.dart b/dart/a2ui_agent/test/processor/processor_test.dart index 25866c11bc..9a3cf8cf83 100644 --- a/dart/a2ui_agent/test/processor/processor_test.dart +++ b/dart/a2ui_agent/test/processor/processor_test.dart @@ -118,37 +118,29 @@ void main() { }); group('A2uiRequestProcessor.parseResponse', () { - test( - 'returns conversational text and compiled payloads in order', - () { - final List parts = processor().parseResponse( - 'Here you go.\n' - '$a2uiJsonOpenTag$surfacePayload$a2uiJsonCloseTag\n' - 'Anything else?', - ); - - expect(parts, hasLength(3)); - expect(parts[0], const TextPart('Here you go.')); - expect((parts[1] as A2uiPart).a2ui.single, isA()); - expect(parts[2], const TextPart('Anything else?')); - }, - skip: pendingProcessor, - ); - - test( - 'rejects a payload declaring another protocol version', - () { - expect( - () => processor().parseResponse( - '$a2uiJsonOpenTag' - '[{"version": "v1.0", "deleteSurface": {"surfaceId": "s1"}}]' - '$a2uiJsonCloseTag', - ), - throwsA(isA()), - ); - }, - skip: pendingProcessor, - ); + test('returns conversational text and compiled payloads in order', () { + final List parts = processor().parseResponse( + 'Here you go.\n' + '$a2uiJsonOpenTag$surfacePayload$a2uiJsonCloseTag\n' + 'Anything else?', + ); + + expect(parts, hasLength(3)); + expect(parts[0], const TextPart('Here you go.')); + expect((parts[1] as A2uiPart).a2ui.single, isA()); + expect(parts[2], const TextPart('Anything else?')); + }, skip: pendingProcessor); + + test('rejects a payload declaring another protocol version', () { + expect( + () => processor().parseResponse( + '$a2uiJsonOpenTag' + '[{"version": "v1.0", "deleteSurface": {"surfaceId": "s1"}}]' + '$a2uiJsonCloseTag', + ), + throwsA(isA()), + ); + }, skip: pendingProcessor); test('rejects a payload whose messages omit the version', () { expect( @@ -167,103 +159,83 @@ void main() { ); }, skip: pendingProcessor); - test( - 'rejects a surface created against an unnegotiated catalog', - () { - expect( - () => processor().parseResponse( - '$a2uiJsonOpenTag' - '[{"version": "v0.9", "createSurface": {"surfaceId": "s1", ' - '"catalogId": "https://example.com/unknown.json"}}]' - '$a2uiJsonCloseTag', - ), - throwsA(isA()), - ); - }, - skip: pendingProcessor, - ); - - test( - 'rejects a component the negotiated catalog does not declare', - () { - final SchemaCatalog pruned = - ComponentPruningTransformer([ - 'Text', - ]).transform(basicCatalog()); - - expect( - () => processor(catalogs: [pruned]).parseResponse( - '$a2uiJsonOpenTag' - '[{"version": "v0.9", "updateComponents": {"surfaceId": "s1", ' - '"components": [{"id": "v", "component": "Video", ' - '"url": "https://example.com/clip.mp4"}]}}]' - '$a2uiJsonCloseTag', - ), - throwsA(isA()), - ); - }, - skip: pendingProcessor, - ); + test('rejects a surface created against an unnegotiated catalog', () { + expect( + () => processor().parseResponse( + '$a2uiJsonOpenTag' + '[{"version": "v0.9", "createSurface": {"surfaceId": "s1", ' + '"catalogId": "https://example.com/unknown.json"}}]' + '$a2uiJsonCloseTag', + ), + throwsA(isA()), + ); + }, skip: pendingProcessor); + + test('rejects a component the negotiated catalog does not declare', () { + final SchemaCatalog pruned = + ComponentPruningTransformer([ + 'Text', + ]).transform(basicCatalog()); + + expect( + () => processor(catalogs: [pruned]).parseResponse( + '$a2uiJsonOpenTag' + '[{"version": "v0.9", "updateComponents": {"surfaceId": "s1", ' + '"components": [{"id": "v", "component": "Video", ' + '"url": "https://example.com/clip.mp4"}]}}]' + '$a2uiJsonCloseTag', + ), + throwsA(isA()), + ); + }, skip: pendingProcessor); }); group('A2uiRequestProcessor.validateExamples', () { - test( - 'accepts examples the negotiated catalogs can express', - () { - final A2uiRequestProcessor p = - processor( - examples: { - 'a greeting': [ - CreateSurfaceMessage( - surfaceId: 's1', - catalogId: basicCatalogId, - ), - UpdateComponentsMessage( - surfaceId: 's1', - components: [ - {'id': 'root', 'component': 'Text', 'text': 'Hello'}, - ], - ), - ], - }, - ); - - expect(p.validateExamples(), completes); - }, - skip: pendingProcessor, - ); - - test( - 'rejects examples using a component the catalog does not declare', - () { - final SchemaCatalog pruned = - ComponentPruningTransformer([ - 'Text', - ]).transform(basicCatalog()); - - final A2uiRequestProcessor p = - processor( - catalogs: [pruned], - examples: { - 'uses a pruned component': [ - UpdateComponentsMessage( - surfaceId: 's1', - components: [ - { - 'id': 'v', - 'component': 'Video', - 'url': 'https://x/y.mp4', - }, - ], - ), - ], - }, - ); - - expect(p.validateExamples(), throwsA(isA())); - }, - skip: pendingProcessor, - ); + test('accepts examples the negotiated catalogs can express', () { + final A2uiRequestProcessor p = + processor( + examples: { + 'a greeting': [ + CreateSurfaceMessage( + surfaceId: 's1', + catalogId: basicCatalogId, + ), + UpdateComponentsMessage( + surfaceId: 's1', + components: [ + {'id': 'root', 'component': 'Text', 'text': 'Hello'}, + ], + ), + ], + }, + ); + + expect(p.validateExamples(), completes); + }, skip: pendingProcessor); + + test('rejects examples using a component the catalog does not declare', () { + final SchemaCatalog pruned = + ComponentPruningTransformer([ + 'Text', + ]).transform(basicCatalog()); + + final A2uiRequestProcessor p = + processor( + catalogs: [pruned], + examples: { + 'uses a pruned component': [ + UpdateComponentsMessage( + surfaceId: 's1', + components: [ + {'id': 'v', 'component': 'Video', 'url': 'https://x/y.mp4'}, + ], + ), + ], + }, + ); + + expect(p.validateExamples(), throwsA(isA())); + }, skip: pendingProcessor); test('accepts a processor with no examples', () { expect(processor().validateExamples(), completes); diff --git a/dart/a2ui_agent/test/utils/catalog_resolver_test.dart b/dart/a2ui_agent/test/utils/catalog_resolver_test.dart index 3095cdeabd..f1e948884c 100644 --- a/dart/a2ui_agent/test/utils/catalog_resolver_test.dart +++ b/dart/a2ui_agent/test/utils/catalog_resolver_test.dart @@ -100,71 +100,55 @@ void main() { expect(catalogs.single.components.keys.toSet(), {'Text', 'Card'}); }, skip: pendingResolver); - test( - 'ignores inline catalogs unless the agent accepts them', - () { - final capabilities = A2uiRendererCapabilities( - v0_9: A2uiVersionCapabilities( - supportedCatalogIds: const [], - inlineCatalogs: [ - smallCatalog(id: 'https://example.com/inline.json'), - ], - ), - ); + test('ignores inline catalogs unless the agent accepts them', () { + final capabilities = A2uiRendererCapabilities( + v0_9: A2uiVersionCapabilities( + supportedCatalogIds: const [], + inlineCatalogs: [smallCatalog(id: 'https://example.com/inline.json')], + ), + ); - final List> catalogs = - resolveCatalogs(registered(), capabilities); + final List> catalogs = + resolveCatalogs(registered(), capabilities); - expect( - catalogs.map((c) => c.id), - isNot(contains('https://example.com/inline.json')), - ); - }, - skip: pendingResolver, - ); + expect( + catalogs.map((c) => c.id), + isNot(contains('https://example.com/inline.json')), + ); + }, skip: pendingResolver); - test( - 'includes inline catalogs when the agent accepts them', - () { - final capabilities = A2uiRendererCapabilities( - v0_9: A2uiVersionCapabilities( - supportedCatalogIds: const [], - inlineCatalogs: [ - smallCatalog(id: 'https://example.com/inline.json'), - ], - ), - ); + test('includes inline catalogs when the agent accepts them', () { + final capabilities = A2uiRendererCapabilities( + v0_9: A2uiVersionCapabilities( + supportedCatalogIds: const [], + inlineCatalogs: [smallCatalog(id: 'https://example.com/inline.json')], + ), + ); - final List> catalogs = - resolveCatalogs( - registered(), - capabilities, - acceptsInlineCatalogs: true, - ); + final List> catalogs = + resolveCatalogs( + registered(), + capabilities, + acceptsInlineCatalogs: true, + ); - expect( - catalogs.map((c) => c.id), - contains('https://example.com/inline.json'), - ); - }, - skip: pendingResolver, - ); + expect( + catalogs.map((c) => c.id), + contains('https://example.com/inline.json'), + ); + }, skip: pendingResolver); - test( - 'rejects a renderer that shares no catalog with the agent', - () { - expect( - () => resolveCatalogs( - registered(), - A2uiRendererCapabilities.forCatalogIds([ - 'https://example.com/unknown.json', - ]), - ), - throwsA(isA()), - ); - }, - skip: pendingResolver, - ); + test('rejects a renderer that shares no catalog with the agent', () { + expect( + () => resolveCatalogs( + registered(), + A2uiRendererCapabilities.forCatalogIds([ + 'https://example.com/unknown.json', + ]), + ), + throwsA(isA()), + ); + }, skip: pendingResolver); test('rejects an agent with no registered catalogs', () { expect( diff --git a/dart/a2ui_core/test/validator_test.dart b/dart/a2ui_core/test/validator_test.dart index 276e852cbb..d0be3872e3 100644 --- a/dart/a2ui_core/test/validator_test.dart +++ b/dart/a2ui_core/test/validator_test.dart @@ -236,28 +236,24 @@ void main() { ); }, skip: pendingValidator); - test( - 'rejects a surface created against an unregistered catalog', - () { - final A2uiValidator validator = - A2uiValidator(catalogs: [testCatalog()]); - final List messages = validator.parseMessages([ - { - 'version': 'v0.9', - 'createSurface': { - 'surfaceId': 's1', - 'catalogId': 'https://example.com/catalogs/other.json', - }, + test('rejects a surface created against an unregistered catalog', () { + final A2uiValidator validator = + A2uiValidator(catalogs: [testCatalog()]); + final List messages = validator.parseMessages([ + { + 'version': 'v0.9', + 'createSurface': { + 'surfaceId': 's1', + 'catalogId': 'https://example.com/catalogs/other.json', }, - ]); + }, + ]); - expect( - validator.validateAgainstCatalogs(messages), - throwsA(isA()), - ); - }, - skip: pendingValidator, - ); + expect( + validator.validateAgainstCatalogs(messages), + throwsA(isA()), + ); + }, skip: pendingValidator); }); group('A2uiValidator.validate', () { From 34450739b2ef6c3fc3538cdb2bcb30394dfbac82 Mon Sep 17 00:00:00 2001 From: Polina Cherkasova Date: Thu, 27 Aug 2026 11:04:17 -0700 Subject: [PATCH 03/22] Stop DataModel notifying observers whose value did not change Notifications bypassed the signal's equality check (`force: true`), so every observer on a path related to a write was woken, including observers whose own value was unchanged. Watching /a/b and then writing /a fired the observer even though /a/b was absent before and after. The check was bypassed because a Map or List mutated in place keeps its identity and would compare equal. Hand the signal a copy of a container instead: containers still compare unequal and still notify, while unchanged primitive and absent values no longer do. This is what the web_core renderer already does. Verified: an unchanged descendant no longer fires; a root replacement still wakes the root observer but not an unrelated one; an ancestor of an in-place mutation still fires; rewriting a path with the value it already holds does not. The two implementations now agree, so the behaviour moves into the shared suite: core/data_model.yaml regains the notification assertion on root replacement and gains cases for an unchanged descendant and a same-value rewrite. The corresponding exclusion note and the web_core local test are removed. --- conformance/core/data_model.yaml | 39 ++++++++++++++++--- dart/a2ui_core/CHANGELOG.md | 8 ++++ dart/a2ui_core/lib/src/core/data_model.dart | 16 ++++++-- .../src/v0_9/state/data-model.test.ts | 18 +-------- 4 files changed, 55 insertions(+), 26 deletions(-) diff --git a/conformance/core/data_model.yaml b/conformance/core/data_model.yaml index 9b3ed51297..7ff5a7937b 100644 --- a/conformance/core/data_model.yaml +++ b/conformance/core/data_model.yaml @@ -47,12 +47,6 @@ # * A cap on auto-vivified list indices. Dart lists are dense, so writing # `/items/999999999` must be rejected to avoid allocating the whole list; # JavaScript arrays are sparse, so the same write is cheap and is allowed. -# * Whether an observer fires when a write leaves its value unchanged. The -# Dart implementation notifies unconditionally, because a mutable container -# can change in place without changing identity; `web_core` copies -# containers on read and notifies only on an actual change. This shows up -# when the root is replaced: every observer is visited, but only those whose -# value actually changed fire in `web_core`. - name: test_data_model_initializes_empty description: >- @@ -341,6 +335,7 @@ - op: "set" path: "/" value: {"newRoot": "foo"} + expect_notified: ["/"] expect_values: {"/": {"newRoot": "foo"}} - op: "get" path: "" @@ -625,3 +620,35 @@ path: "/foo" value: "bar" expect_notified: [] + +- name: test_data_model_does_not_notify_unchanged_descendant + description: >- + An observer on a descendant path does not fire when an ancestor is + rewritten but the observer's own value is still absent. + catalog: + version: "0.9" + action: data_model + watch: ["/a/b"] + steps: + - op: "set" + path: "/a" + value: {"c": 1} + expect_notified: [] + +- name: test_data_model_does_not_notify_same_value_rewrite + description: >- + Writing a path the value it already holds does not fire its observers. + catalog: + version: "0.9" + action: data_model + initial: {"k": "v"} + watch: ["/k"] + steps: + - op: "set" + path: "/k" + value: "v" + expect_notified: [] + - op: "set" + path: "/k" + value: "w" + expect_notified: ["/k"] diff --git a/dart/a2ui_core/CHANGELOG.md b/dart/a2ui_core/CHANGELOG.md index 6dd5d084bd..d885e794a6 100644 --- a/dart/a2ui_core/CHANGELOG.md +++ b/dart/a2ui_core/CHANGELOG.md @@ -22,6 +22,14 @@ `A2uiIntegrityError` and `A2uiRecursionError` categories. - Fixed `DataModel.set` silently dropping a write whose parent path resolves to a primitive; it now throws `A2uiDataError`. +- **Behaviour change:** `DataModel` observers no longer fire when a write leaves + their own value unchanged. Notifications previously bypassed the signal's + equality check, so an observer on a path merely related to the write was woken + even when nothing it observes had changed. Containers are now handed to the + signal as a copy, so a container mutated in place still compares unequal and + still notifies, while unchanged primitive and absent values no longer do. This + matches the `web_core` renderer, and the shared behaviour is pinned by + `conformance/core/data_model.yaml`. ## 0.1.1 diff --git a/dart/a2ui_core/lib/src/core/data_model.dart b/dart/a2ui_core/lib/src/core/data_model.dart index 238af2d2d0..54e6c36ee6 100644 --- a/dart/a2ui_core/lib/src/core/data_model.dart +++ b/dart/a2ui_core/lib/src/core/data_model.dart @@ -191,9 +191,19 @@ class DataModel { } final Object? newValue = get(path); - // Force notification even if the value is the same reference, because - // mutable containers (Maps/Lists) may have changed in place. - sig.set(newValue, force: true); + // A container that was mutated in place keeps its identity, so handing the + // signal the live object would compare equal and suppress the + // notification. Hand it a copy instead, so containers always compare + // unequal, and let the signal's own equality check suppress notifications + // for values that genuinely did not change. + // + // Notifying unconditionally instead would wake every observer on a related + // path, including those whose own value is unchanged. + sig.set(switch (newValue) { + final Map map => Map.of(map), + final List list => List.of(list), + _ => newValue, + }); } void _pruneSignals() { diff --git a/renderers/web_core/src/v0_9/state/data-model.test.ts b/renderers/web_core/src/v0_9/state/data-model.test.ts index 28667a0eaf..fc378b12a0 100644 --- a/renderers/web_core/src/v0_9/state/data-model.test.ts +++ b/renderers/web_core/src/v0_9/state/data-model.test.ts @@ -41,11 +41,7 @@ import {DataModel, type DataSubscription} from './data-model.js'; * and the Dart implementation currently does not; * - unbounded list indices. Arrays here are sparse, so writing `/items/999999999` * is cheap; Dart lists are dense, so the Dart implementation rejects the same - * write to avoid allocating the whole list; - * - notification on an unchanged value. This implementation copies containers on - * read and notifies only on an actual change, so replacing the root does not - * wake an observer whose own value stayed `undefined`. The Dart implementation - * notifies unconditionally. + * write to avoid allocating the whole list. */ interface ConformanceStep { @@ -340,18 +336,6 @@ describe('DataModel (JavaScript specific)', () => { assert.strictEqual(model.get('/items/999999'), 'x'); }); - it('does not wake an observer whose value did not change', () => { - let unrelatedCount = 0; - let rootCount = 0; - model.subscribe('/', () => rootCount++); - model.subscribe('/unrelated', () => unrelatedCount++); - - model.set('/', {newRoot: 'foo'}); - - assert.strictEqual(rootCount, 1); - assert.strictEqual(unrelatedCount, 0); - }); - it('does not leak Object.prototype inherited properties on get', () => { assert.strictEqual(model.get('/toString'), undefined); assert.strictEqual(model.get('/valueOf'), undefined); From d9cb4a8ac92258cb36312729682923835cb3892c Mon Sep 17 00:00:00 2001 From: Polina Cherkasova Date: Thu, 27 Aug 2026 11:27:05 -0700 Subject: [PATCH 04/22] - --- .github/workflows/flutter_packages_test.yml | 27 +- conformance/conformance_schema.json | 70 ++++- conformance/core/message_processor.yaml | 236 ++++++++++++++++ .../message_processor_conformance_test.dart | 254 ++++++++++++++++++ 4 files changed, 558 insertions(+), 29 deletions(-) create mode 100644 conformance/core/message_processor.yaml create mode 100644 dart/a2ui_core/test/conformance/message_processor_conformance_test.dart diff --git a/.github/workflows/flutter_packages_test.yml b/.github/workflows/flutter_packages_test.yml index 3755671069..4ecafe53e9 100644 --- a/.github/workflows/flutter_packages_test.yml +++ b/.github/workflows/flutter_packages_test.yml @@ -46,7 +46,7 @@ jobs: - name: Generate testing matrix id: generate_matrix run: | - DIRS_TO_TEST=$(find samples -name pubspec.yaml -not -path "*/.dart_tool/*" -not -path "*/e2e_test/*" -exec dirname {} \;) + DIRS_TO_TEST=$(find samples dart -name pubspec.yaml -not -path "*/.dart_tool/*" -not -path "*/e2e_test/*" -exec dirname {} \;) JSON_MATRIX="[" FIRST=true @@ -85,31 +85,6 @@ jobs: (cd "$dir" && dart pub get && layerlens --fail-on-cycles --except "lib/src/schema" --except "lib/src/core") done - dart_packages: - name: dart/${{ matrix.package }} - runs-on: ubuntu-latest - strategy: - fail-fast: false - matrix: - package: [a2ui_core, a2ui_agent] - steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - with: - persist-credentials: false - - uses: ./.github/actions/setup-dart - - name: Install dependencies - working-directory: dart/${{ matrix.package }} - run: dart pub get - - name: Check formatting - working-directory: dart/${{ matrix.package }} - run: dart format --output=none --set-exit-if-changed . - - name: Analyze code - working-directory: dart/${{ matrix.package }} - run: dart analyze --fatal-infos - - name: Run tests - working-directory: dart/${{ matrix.package }} - run: dart test --test-randomize-ordering-seed=random - analyze_and_test: needs: matrix name: ${{ matrix.package.name }} (${{ matrix.flutter_version }}) diff --git a/conformance/conformance_schema.json b/conformance/conformance_schema.json index 7b85685b8d..4094844002 100644 --- a/conformance/conformance_schema.json +++ b/conformance/conformance_schema.json @@ -93,7 +93,8 @@ "verify_cuttable_keys", "accessibility_check", "data_model", - "process_request" + "process_request", + "process_messages" ] } }, @@ -125,7 +126,8 @@ {"$ref": "#/$defs/VerifyCuttableKeysTest"}, {"$ref": "#/$defs/AccessibilityCheckTest"}, {"$ref": "#/$defs/DataModelTest"}, - {"$ref": "#/$defs/ProcessRequestTest"} + {"$ref": "#/$defs/ProcessRequestTest"}, + {"$ref": "#/$defs/ProcessMessagesTest"} ] } ] @@ -592,6 +594,67 @@ }, "required": ["args"] }, + "ProcessMessagesTest": { + "type": "object", + "properties": { + "action": {"const": "process_messages"}, + "payload": { + "type": "array", + "description": "A2UI messages to process, in order.", + "items": {"type": "object"} + }, + "expect": { + "type": "object", + "description": "Expected state after the payload has been processed.", + "properties": { + "surfaces": { + "type": "object", + "description": "Expectations per open surface, keyed by surface id.", + "additionalProperties": { + "type": "object", + "properties": { + "catalogId": {"type": "string"}, + "sendDataModel": {"type": "boolean"}, + "components": { + "type": "object", + "description": "Expected components, keyed by component id.", + "additionalProperties": { + "type": "object", + "properties": { + "component": {"type": "string"}, + "properties": {"type": "object"} + } + } + }, + "data_model": { + "description": "Expected contents of the surface data model at the root." + } + } + } + }, + "absent_surfaces": { + "type": "array", + "items": {"type": "string"}, + "description": "Surface ids that must not be open." + }, + "client_data_model": { + "type": "object", + "description": "Expected aggregated data model sent back to the agent." + }, + "client_data_model_absent": { + "type": "boolean", + "description": "Whether there is no client data model to send." + }, + "client_capabilities": { + "type": "object", + "description": "Expected capabilities the renderer advertises." + } + } + }, + "expect_error": {"$ref": "#/$defs/ExpectError"} + }, + "required": ["payload"] + }, "ExpectError": { "oneOf": [ { @@ -610,7 +673,8 @@ "IntegrityError", "RecursionError", "CompileError", - "DataError" + "DataError", + "StateError" ] }, "message": { diff --git a/conformance/core/message_processor.yaml b/conformance/core/message_processor.yaml new file mode 100644 index 0000000000..904149cd20 --- /dev/null +++ b/conformance/core/message_processor.yaml @@ -0,0 +1,236 @@ +# Copyright 2024 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. + +# Behaviour of the message processor: the surface lifecycle, component graph +# mutation, per surface data model routing, and the capability and data model +# payloads a renderer sends back to the agent. +# +# Cases run against an empty catalog whose id is given by +# `catalog.catalog_schema.catalogId`. Each harness builds that catalog natively +# rather than parsing the schema, because renderers construct catalogs from +# code (Zod in `web_core`, `Schema` in Dart) and neither builds a renderer +# catalog from a JSON Schema document. The suite therefore pins the processor's +# state machine, not catalog loading; component schema validation is out of +# scope here for the same reason. +# +# `payload` is the list of messages to process, in order. `expect` asserts the +# resulting state: +# +# surfaces map of surface id to expectations, any of +# `catalogId`, `sendDataModel`, `components` +# (id to `component` and `properties`), and +# `data_model` (the whole model at `/`) +# absent_surfaces surface ids that must not be open +# client_data_model the aggregated payload sent back to the agent +# client_data_model_absent there is no such payload to send +# client_capabilities the capabilities the renderer advertises +# +# `expect_error` asserts that processing the payload raises instead. Error +# messages differ between implementations, so `message` is matched as a regular +# expression against a substring the implementations share. + +- name: test_processor_creates_surface + description: >- + A createSurface message registers a surface bound to the named catalog. + catalog: + version: "0.9" + catalog_schema: {"catalogId": "conformance-catalog", "components": {}} + action: process_messages + payload: [{"version": "v0.9", "createSurface": {"surfaceId": "s1", "catalogId": "conformance-catalog"}}] + expect: {"surfaces": {"s1": {"catalogId": "conformance-catalog", "sendDataModel": false}}} + +- name: test_processor_creates_surface_with_send_data_model + description: >- + sendDataModel is carried onto the surface it creates. + catalog: + version: "0.9" + catalog_schema: {"catalogId": "conformance-catalog", "components": {}} + action: process_messages + payload: [{"version": "v0.9", "createSurface": {"surfaceId": "s1", "catalogId": "conformance-catalog", "sendDataModel": true}}] + expect: {"surfaces": {"s1": {"sendDataModel": true}}} + +- name: test_processor_deletes_surface + description: >- + A deleteSurface message removes the surface. + catalog: + version: "0.9" + catalog_schema: {"catalogId": "conformance-catalog", "components": {}} + action: process_messages + payload: [{"version": "v0.9", "createSurface": {"surfaceId": "s1", "catalogId": "conformance-catalog"}}, {"version": "v0.9", "deleteSurface": {"surfaceId": "s1"}}] + expect: {"absent_surfaces": ["s1"]} + +- name: test_processor_adds_components + description: >- + updateComponents adds components to the named surface. + catalog: + version: "0.9" + catalog_schema: {"catalogId": "conformance-catalog", "components": {}} + action: process_messages + payload: [{"version": "v0.9", "createSurface": {"surfaceId": "s1", "catalogId": "conformance-catalog"}}, {"version": "v0.9", "updateComponents": {"surfaceId": "s1", "components": [{"id": "root", "component": "Text", "text": "Hello"}]}}] + expect: {"surfaces": {"s1": {"components": {"root": {"component": "Text", "properties": {"text": "Hello"}}}}}} + +- name: test_processor_updates_existing_component_properties + description: >- + Re-sending a component of the same type replaces its properties. + catalog: + version: "0.9" + catalog_schema: {"catalogId": "conformance-catalog", "components": {}} + action: process_messages + payload: [{"version": "v0.9", "createSurface": {"surfaceId": "s1", "catalogId": "conformance-catalog"}}, {"version": "v0.9", "updateComponents": {"surfaceId": "s1", "components": [{"id": "root", "component": "Text", "text": "Hello"}]}}, {"version": "v0.9", "updateComponents": {"surfaceId": "s1", "components": [{"id": "root", "component": "Text", "text": "World"}]}}] + expect: {"surfaces": {"s1": {"components": {"root": {"component": "Text", "properties": {"text": "World"}}}}}} + +- name: test_processor_recreates_component_when_type_changes + description: >- + Re-sending a component under a different type replaces the component + rather than merging into it. + catalog: + version: "0.9" + catalog_schema: {"catalogId": "conformance-catalog", "components": {}} + action: process_messages + payload: [{"version": "v0.9", "createSurface": {"surfaceId": "s1", "catalogId": "conformance-catalog"}}, {"version": "v0.9", "updateComponents": {"surfaceId": "s1", "components": [{"id": "root", "component": "Text", "text": "Hello"}]}}, {"version": "v0.9", "updateComponents": {"surfaceId": "s1", "components": [{"id": "root", "component": "Column", "children": ["a"]}]}}] + expect: {"surfaces": {"s1": {"components": {"root": {"component": "Column", "properties": {"children": ["a"]}}}}}} + +- name: test_processor_updates_components_on_the_named_surface_only + description: >- + Components are added to the surface the message names, not to every open + surface. + catalog: + version: "0.9" + catalog_schema: {"catalogId": "conformance-catalog", "components": {}} + action: process_messages + payload: [{"version": "v0.9", "createSurface": {"surfaceId": "s1", "catalogId": "conformance-catalog"}}, {"version": "v0.9", "createSurface": {"surfaceId": "s2", "catalogId": "conformance-catalog"}}, {"version": "v0.9", "updateComponents": {"surfaceId": "s2", "components": [{"id": "root", "component": "Text", "text": "Hello"}]}}] + expect: {"surfaces": {"s1": {"components": {}}, "s2": {"components": {"root": {"component": "Text", "properties": {"text": "Hello"}}}}}} + +- name: test_processor_routes_data_model_updates + description: >- + updateDataModel writes into the data model of the surface it names. + catalog: + version: "0.9" + catalog_schema: {"catalogId": "conformance-catalog", "components": {}} + action: process_messages + payload: [{"version": "v0.9", "createSurface": {"surfaceId": "s1", "catalogId": "conformance-catalog"}}, {"version": "v0.9", "updateDataModel": {"surfaceId": "s1", "path": "/user/name", "value": "Alice"}}] + expect: {"surfaces": {"s1": {"data_model": {"user": {"name": "Alice"}}}}} + +- name: test_processor_routes_data_model_updates_per_surface + description: >- + Each surface has its own data model. + catalog: + version: "0.9" + catalog_schema: {"catalogId": "conformance-catalog", "components": {}} + action: process_messages + payload: [{"version": "v0.9", "createSurface": {"surfaceId": "s1", "catalogId": "conformance-catalog"}}, {"version": "v0.9", "createSurface": {"surfaceId": "s2", "catalogId": "conformance-catalog"}}, {"version": "v0.9", "updateDataModel": {"surfaceId": "s1", "path": "/value", "value": "one"}}, {"version": "v0.9", "updateDataModel": {"surfaceId": "s2", "path": "/value", "value": "two"}}] + expect: {"surfaces": {"s1": {"data_model": {"value": "one"}}, "s2": {"data_model": {"value": "two"}}}} + +- name: test_processor_client_data_model_includes_opted_in_surfaces_only + description: >- + The aggregated client data model carries only surfaces created with + sendDataModel enabled. + catalog: + version: "0.9" + catalog_schema: {"catalogId": "conformance-catalog", "components": {}} + action: process_messages + payload: [{"version": "v0.9", "createSurface": {"surfaceId": "s1", "catalogId": "conformance-catalog", "sendDataModel": true}}, {"version": "v0.9", "updateDataModel": {"surfaceId": "s1", "path": "/foo", "value": "bar"}}, {"version": "v0.9", "createSurface": {"surfaceId": "s2", "catalogId": "conformance-catalog", "sendDataModel": false}}, {"version": "v0.9", "updateDataModel": {"surfaceId": "s2", "path": "/secret", "value": "baz"}}] + expect: {"client_data_model": {"surfaces": {"s1": {"foo": "bar"}}}} + +- name: test_processor_client_data_model_absent_without_opt_in + description: >- + No surface opting in means there is no client data model to send. + catalog: + version: "0.9" + catalog_schema: {"catalogId": "conformance-catalog", "components": {}} + action: process_messages + payload: [{"version": "v0.9", "createSurface": {"surfaceId": "s1", "catalogId": "conformance-catalog"}}, {"version": "v0.9", "updateDataModel": {"surfaceId": "s1", "path": "/foo", "value": "bar"}}] + expect: {"client_data_model_absent": true} + +- name: test_processor_client_capabilities_list_catalog_ids + description: >- + Client capabilities advertise the ids of the catalogs the renderer holds, + under the protocol version key. + catalog: + version: "0.9" + catalog_schema: {"catalogId": "conformance-catalog", "components": {}} + action: process_messages + payload: [] + expect: {"client_capabilities": {"v0.9": {"supportedCatalogIds": ["conformance-catalog"]}}} + +- name: test_processor_rejects_unknown_catalog + description: >- + A surface cannot be created against a catalog the renderer does not hold. + catalog: + version: "0.9" + catalog_schema: {"catalogId": "conformance-catalog", "components": {}} + action: process_messages + payload: [{"version": "v0.9", "createSurface": {"surfaceId": "s1", "catalogId": "https://example.com/not-registered.json"}}] + expect_error: {"category": "StateError", "message": "Catalog not found"} + +- name: test_processor_rejects_duplicate_surface + description: >- + A surface id cannot be created twice. + catalog: + version: "0.9" + catalog_schema: {"catalogId": "conformance-catalog", "components": {}} + action: process_messages + payload: [{"version": "v0.9", "createSurface": {"surfaceId": "s1", "catalogId": "conformance-catalog"}}, {"version": "v0.9", "createSurface": {"surfaceId": "s1", "catalogId": "conformance-catalog"}}] + expect_error: {"category": "StateError", "message": "already exists"} + +- name: test_processor_rejects_components_for_unknown_surface + description: >- + Components cannot be added to a surface that was never created. + catalog: + version: "0.9" + catalog_schema: {"catalogId": "conformance-catalog", "components": {}} + action: process_messages + payload: [{"version": "v0.9", "updateComponents": {"surfaceId": "s1", "components": [{"id": "root", "component": "Text", "text": "Hello"}]}}] + expect_error: {"category": "StateError", "message": "Surface not found"} + +- name: test_processor_rejects_data_model_update_for_unknown_surface + description: >- + The data model of a surface that was never created cannot be written. + catalog: + version: "0.9" + catalog_schema: {"catalogId": "conformance-catalog", "components": {}} + action: process_messages + payload: [{"version": "v0.9", "updateDataModel": {"surfaceId": "s1", "path": "/foo", "value": "bar"}}] + expect_error: {"category": "StateError", "message": "Surface not found"} + +- name: test_processor_rejects_component_without_id + description: >- + Every component in an updateComponents message needs an id. + catalog: + version: "0.9" + catalog_schema: {"catalogId": "conformance-catalog", "components": {}} + action: process_messages + payload: [{"version": "v0.9", "createSurface": {"surfaceId": "s1", "catalogId": "conformance-catalog"}}, {"version": "v0.9", "updateComponents": {"surfaceId": "s1", "components": [{"component": "Text", "text": "Hello"}]}}] + expect_error: {"category": "ValidationError", "message": "missing an 'id'"} + +- name: test_processor_rejects_new_component_without_type + description: >- + A component that does not exist yet cannot be created without naming its + type. + catalog: + version: "0.9" + catalog_schema: {"catalogId": "conformance-catalog", "components": {}} + action: process_messages + payload: [{"version": "v0.9", "createSurface": {"surfaceId": "s1", "catalogId": "conformance-catalog"}}, {"version": "v0.9", "updateComponents": {"surfaceId": "s1", "components": [{"id": "root", "text": "Hello"}]}}] + expect_error: {"category": "ValidationError", "message": "Cannot create component"} + +- name: test_processor_rejects_message_with_multiple_bodies + description: >- + An envelope carrying more than one message body is rejected. + catalog: + version: "0.9" + catalog_schema: {"catalogId": "conformance-catalog", "components": {}} + action: process_messages + payload: [{"version": "v0.9", "createSurface": {"surfaceId": "s1", "catalogId": "conformance-catalog"}, "deleteSurface": {"surfaceId": "s1"}}] + expect_error: {"category": "ValidationError"} diff --git a/dart/a2ui_core/test/conformance/message_processor_conformance_test.dart b/dart/a2ui_core/test/conformance/message_processor_conformance_test.dart new file mode 100644 index 0000000000..5e6c35ba5b --- /dev/null +++ b/dart/a2ui_core/test/conformance/message_processor_conformance_test.dart @@ -0,0 +1,254 @@ +// Copyright 2024 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 'package:a2ui_core/a2ui_core.dart'; +import 'package:test/test.dart'; + +import 'conformance_harness.dart'; + +/// Runs the shared `conformance/core/message_processor.yaml` suite against +/// [MessageProcessor]. +/// +/// The suite's catalog is built natively rather than parsed from the case, as +/// the suite header explains: renderers construct catalogs from code, so the +/// case supplies only the catalog id. +void main() { + final List> cases = loadConformanceSuite( + 'core/message_processor.yaml', + ); + + group('conformance core/message_processor.yaml', () { + test('suite is not empty', () => expect(cases, isNotEmpty)); + + for (final testCase in cases) { + test(testCase['name']! as String, () => _runCase(testCase)); + } + }); +} + +void _runCase(Map testCase) { + final catalog = _EmptyCatalog(_catalogIdOf(testCase)); + final processor = MessageProcessor(catalogs: [catalog]); + final List> payload = + (testCase['payload']! as List).cast>(); + final name = testCase['name']! as String; + + final Object? expectError = testCase['expect_error']; + if (expectError != null) { + expect( + () => _process(processor, payload), + throwsA(_matchesError(expectError as Map)), + reason: name, + ); + return; + } + + _process(processor, payload); + + final Map expected = + (testCase['expect'] as Map?) ?? const {}; + _checkSurfaces(processor, expected, name); + _checkAbsentSurfaces(processor, expected, name); + _checkClientDataModel(processor, expected, name); + _checkClientCapabilities(processor, expected, name); +} + +/// Converts each envelope and processes it. +/// +/// Conversion is part of processing for this suite: the Dart processor takes +/// typed messages, so a malformed envelope is rejected by +/// [A2uiMessage.fromJson] rather than by the processor itself. +void _process( + MessageProcessor processor, + List> payload, +) { + for (final envelope in payload) { + processor.processMessages([ + A2uiMessage.fromJson(Map.from(envelope)), + ]); + } +} + +void _checkSurfaces( + MessageProcessor processor, + Map expected, + String name, +) { + final surfaces = expected['surfaces'] as Map?; + if (surfaces == null) return; + + surfaces.forEach((surfaceId, raw) { + final SurfaceModel? surface = processor.groupModel.getSurface( + surfaceId, + ); + expect(surface, isNotNull, reason: '$name: surface $surfaceId is open'); + + final expectations = raw! as Map; + if (expectations.containsKey('catalogId')) { + expect( + surface!.catalog.id, + expectations['catalogId'], + reason: '$name: $surfaceId catalogId', + ); + } + if (expectations.containsKey('sendDataModel')) { + expect( + surface!.sendDataModel, + expectations['sendDataModel'], + reason: '$name: $surfaceId sendDataModel', + ); + } + if (expectations.containsKey('data_model')) { + expect( + surface!.dataModel.get('/'), + equals(expectations['data_model']), + reason: '$name: $surfaceId data model', + ); + } + final components = expectations['components'] as Map?; + if (components != null) { + _checkComponents(surface!, components, '$name: $surfaceId'); + } + }); +} + +void _checkComponents( + SurfaceModel surface, + Map expected, + String reason, +) { + for (final MapEntry entry in expected.entries) { + final ComponentModel? component = surface.componentsModel.get(entry.key); + expect(component, isNotNull, reason: '$reason: component ${entry.key}'); + + final expectations = entry.value! as Map; + if (expectations.containsKey('component')) { + expect( + component!.type, + expectations['component'], + reason: '$reason: ${entry.key} type', + ); + } + final properties = expectations['properties'] as Map?; + if (properties != null) { + properties.forEach((key, value) { + expect( + component!.properties[key], + equals(value), + reason: '$reason: ${entry.key}.$key', + ); + }); + } + } + if (expected.isEmpty) { + expect( + surface.componentsModel.all, + isEmpty, + reason: '$reason: no components', + ); + } +} + +void _checkAbsentSurfaces( + MessageProcessor processor, + Map expected, + String name, +) { + final absent = expected['absent_surfaces'] as List?; + if (absent == null) return; + for (final Object? surfaceId in absent) { + expect( + processor.groupModel.getSurface(surfaceId! as String), + isNull, + reason: '$name: surface $surfaceId is closed', + ); + } +} + +void _checkClientDataModel( + MessageProcessor processor, + Map expected, + String name, +) { + if (expected['client_data_model_absent'] == true) { + expect(processor.getClientDataModel(), isNull, reason: name); + } + final model = expected['client_data_model'] as Map?; + if (model == null) return; + + final Map? actual = processor.getClientDataModel(); + expect(actual, isNotNull, reason: name); + model.forEach((key, value) { + expect(actual![key], equals(value), reason: '$name: client data $key'); + }); +} + +void _checkClientCapabilities( + MessageProcessor processor, + Map expected, + String name, +) { + final capabilities = expected['client_capabilities'] as Map?; + if (capabilities == null) return; + + final Map actual = processor.getClientCapabilities(); + capabilities.forEach((version, value) { + final expectations = value! as Map; + final actualVersion = actual[version] as Map?; + expect(actualVersion, isNotNull, reason: '$name: capabilities $version'); + expectations.forEach((key, expectedValue) { + expect( + actualVersion![key], + equals(expectedValue), + reason: '$name: capabilities $version.$key', + ); + }); + }); +} + +String _catalogIdOf(Map testCase) { + final Map catalog = + (testCase['catalog'] as Map?) ?? const {}; + final schema = catalog['catalog_schema'] as Map?; + return schema?['catalogId'] as String? ?? 'conformance-catalog'; +} + +Matcher _matchesError(Map expectError) { + final category = expectError['category'] as String?; + final message = expectError['message'] as String?; + Matcher matcher = switch (category) { + 'StateError' => isA(), + 'ValidationError' => isA(), + 'DataError' => isA(), + 'CatalogError' => isA(), + 'IntegrityError' => isA(), + 'RecursionError' => isA(), + _ => isA(), + }; + if (message != null) { + matcher = allOf( + matcher, + predicate( + (e) => RegExp(message).hasMatch(e.toString()), + 'message matching /$message/', + ), + ); + } + return matcher; +} + +/// A catalog with no components, built natively as the suite requires. +class _EmptyCatalog extends Catalog { + _EmptyCatalog(String id) : super(id: id, components: const []); +} From 5fdcf5ba53cf73b209b46170dc42b3de417a7da5 Mon Sep 17 00:00:00 2001 From: Polina Cherkasova Date: Thu, 27 Aug 2026 11:27:31 -0700 Subject: [PATCH 05/22] Create message-processor.conformance.test.ts --- .../message-processor.conformance.test.ts | 182 ++++++++++++++++++ 1 file changed, 182 insertions(+) create mode 100644 renderers/web_core/src/v0_9/conformance/message-processor.conformance.test.ts diff --git a/renderers/web_core/src/v0_9/conformance/message-processor.conformance.test.ts b/renderers/web_core/src/v0_9/conformance/message-processor.conformance.test.ts new file mode 100644 index 0000000000..12ffaa64f3 --- /dev/null +++ b/renderers/web_core/src/v0_9/conformance/message-processor.conformance.test.ts @@ -0,0 +1,182 @@ +/* + * Copyright 2024 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 * as assert from 'node:assert'; +import {describe, it} from 'node:test'; +import {loadConformanceSuite} from './harness.js'; +import {MessageProcessor} from '../processing/message-processor.js'; +import {Catalog, ComponentApi} from '../catalog/types.js'; +import {SurfaceModel} from '../state/surface-model.js'; + +/** + * Runs the shared `conformance/core/message_processor.yaml` suite against + * `MessageProcessor`. + * + * The suite's catalog is built natively rather than parsed from the case, as + * the suite header explains: renderers construct catalogs from code, so the + * case supplies only the catalog id. + * + * This is additive. The hand-written tests in + * `src/v0_9/processing/message-processor.test.ts` still run, and cover + * behaviour that is specific to this implementation, notably Zod-based + * component schema validation and `REF:` handling in inline catalogs. + */ + +interface SurfaceExpectation { + catalogId?: string; + sendDataModel?: boolean; + components?: Record}>; + data_model?: unknown; +} + +interface ConformanceCase { + name: string; + catalog?: {catalog_schema?: {catalogId?: string}}; + payload: Array>; + expect?: { + surfaces?: Record; + absent_surfaces?: string[]; + client_data_model?: Record; + client_data_model_absent?: boolean; + client_capabilities?: Record>; + }; + expect_error?: {category?: string; message?: string} | string; +} + +/** A catalog with no components, built natively as the suite requires. */ +function emptyCatalog(id: string): Catalog { + return new Catalog(id, []); +} + +function catalogIdOf(testCase: ConformanceCase): string { + return testCase.catalog?.catalog_schema?.catalogId ?? 'conformance-catalog'; +} + +function errorPattern(expectError: ConformanceCase['expect_error']): RegExp { + const message = typeof expectError === 'string' ? expectError : (expectError?.message ?? ''); + return new RegExp(message); +} + +function checkComponents( + surface: SurfaceModel, + expected: NonNullable, + reason: string, +): void { + for (const [id, expectation] of Object.entries(expected)) { + const component = surface.componentsModel.get(id); + assert.ok(component, `${reason}: component ${id}`); + + if (expectation.component !== undefined) { + assert.strictEqual(component.type, expectation.component, `${reason}: ${id} type`); + } + for (const [key, value] of Object.entries(expectation.properties ?? {})) { + assert.deepStrictEqual(component.properties[key], value, `${reason}: ${id}.${key}`); + } + } + if (Object.keys(expected).length === 0) { + assert.strictEqual([...surface.componentsModel.entries].length, 0, `${reason}: no components`); + } +} + +function runCase(testCase: ConformanceCase): void { + const processor = new MessageProcessor([emptyCatalog(catalogIdOf(testCase))]); + const name = testCase.name; + + if (testCase.expect_error !== undefined) { + assert.throws( + () => processor.processMessages(testCase.payload as never), + errorPattern(testCase.expect_error), + name, + ); + return; + } + + processor.processMessages(testCase.payload as never); + const expected = testCase.expect ?? {}; + + for (const [surfaceId, expectation] of Object.entries(expected.surfaces ?? {})) { + const surface = processor.model.getSurface(surfaceId); + assert.ok(surface, `${name}: surface ${surfaceId} is open`); + + if (expectation.catalogId !== undefined) { + assert.strictEqual(surface.catalog.id, expectation.catalogId, `${name}: ${surfaceId} catalogId`); + } + if (expectation.sendDataModel !== undefined) { + assert.strictEqual( + surface.sendDataModel, + expectation.sendDataModel, + `${name}: ${surfaceId} sendDataModel`, + ); + } + if ('data_model' in expectation) { + assert.deepStrictEqual( + surface.dataModel.get('/'), + expectation.data_model, + `${name}: ${surfaceId} data model`, + ); + } + if (expectation.components !== undefined) { + checkComponents(surface, expectation.components, `${name}: ${surfaceId}`); + } + } + + for (const surfaceId of expected.absent_surfaces ?? []) { + assert.strictEqual( + processor.model.getSurface(surfaceId), + undefined, + `${name}: surface ${surfaceId} is closed`, + ); + } + + if (expected.client_data_model_absent === true) { + assert.strictEqual(processor.getClientDataModel(), undefined, name); + } + if (expected.client_data_model !== undefined) { + const actual = processor.getClientDataModel() as Record | undefined; + assert.ok(actual, name); + for (const [key, value] of Object.entries(expected.client_data_model)) { + assert.deepStrictEqual(actual[key], value, `${name}: client data ${key}`); + } + } + if (expected.client_capabilities !== undefined) { + const actual = processor.getClientCapabilities() as unknown as Record< + string, + Record + >; + for (const [version, expectations] of Object.entries(expected.client_capabilities)) { + assert.ok(actual[version], `${name}: capabilities ${version}`); + for (const [key, value] of Object.entries(expectations)) { + assert.deepStrictEqual( + actual[version][key], + value, + `${name}: capabilities ${version}.${key}`, + ); + } + } + } +} + +describe('conformance core/message_processor.yaml', () => { + const cases = loadConformanceSuite('core/message_processor.yaml'); + + it('suite is not empty', () => { + assert.ok(cases.length > 0); + }); + + for (const testCase of cases) { + it(testCase.name, () => runCase(testCase)); + } +}); From 589c824e8e4fb0e08002cad92247f10a602681b7 Mon Sep 17 00:00:00 2001 From: Polina Cherkasova Date: Thu, 27 Aug 2026 11:28:27 -0700 Subject: [PATCH 06/22] - --- conformance/README.md | 3 +- conformance/core/message_processor.yaml | 202 ++++++++++++++++-- .../message-processor.conformance.test.ts | 6 +- 3 files changed, 186 insertions(+), 25 deletions(-) diff --git a/conformance/README.md b/conformance/README.md index 2dd2924a7e..b3061250ba 100644 --- a/conformance/README.md +++ b/conformance/README.md @@ -12,6 +12,7 @@ Test suites are organized by functional domain: - `core/accessibility.yaml`: Contains test cases for accessibility attributes and checks. - `core/validator.yaml`: Contains test cases for schema and structural validators, verifying structural integrity, cycle detection, and reachability. - `core/data_model.yaml`: Contains test cases for the reactive data model: JSON Pointer resolution, structural auto-vivification, and observer notification routing. +- `core/message_processor.yaml`: Contains test cases for the message processor: the surface lifecycle, component graph mutation, per surface data model routing, and the capability and data model payloads a renderer sends back to the agent. ### Agent (`agent/`) @@ -44,7 +45,7 @@ Behaviour currently in the second category, held by the Dart SDK only: - Pruning catalog **functions**. The `prune` action currently supports `allowed_components` and `allowed_messages`; `allowed_functions` is implemented by the Dart `FunctionPruningTransformer` only. - Raising an error from `select_catalog` when the renderer and agent share no catalog. Kotlin raises with a different message and Python returns no selection instead. -The new `process_request` action does assert version rejection, because that action has no prior implementations and its contract is being defined with it. +The new `process_request` and `process_messages` actions are not loaded by the Python or Kotlin harnesses, which read a fixed list of suite files. `process_request` does assert version rejection, because that action has no prior implementations and its contract is being defined with it. ## Usage in SDKs diff --git a/conformance/core/message_processor.yaml b/conformance/core/message_processor.yaml index 904149cd20..459418949a 100644 --- a/conformance/core/message_processor.yaml +++ b/conformance/core/message_processor.yaml @@ -47,7 +47,8 @@ version: "0.9" catalog_schema: {"catalogId": "conformance-catalog", "components": {}} action: process_messages - payload: [{"version": "v0.9", "createSurface": {"surfaceId": "s1", "catalogId": "conformance-catalog"}}] + payload: + [{"version": "v0.9", "createSurface": {"surfaceId": "s1", "catalogId": "conformance-catalog"}}] expect: {"surfaces": {"s1": {"catalogId": "conformance-catalog", "sendDataModel": false}}} - name: test_processor_creates_surface_with_send_data_model @@ -57,7 +58,14 @@ version: "0.9" catalog_schema: {"catalogId": "conformance-catalog", "components": {}} action: process_messages - payload: [{"version": "v0.9", "createSurface": {"surfaceId": "s1", "catalogId": "conformance-catalog", "sendDataModel": true}}] + payload: + [ + { + "version": "v0.9", + "createSurface": + {"surfaceId": "s1", "catalogId": "conformance-catalog", "sendDataModel": true}, + }, + ] expect: {"surfaces": {"s1": {"sendDataModel": true}}} - name: test_processor_deletes_surface @@ -67,7 +75,11 @@ version: "0.9" catalog_schema: {"catalogId": "conformance-catalog", "components": {}} action: process_messages - payload: [{"version": "v0.9", "createSurface": {"surfaceId": "s1", "catalogId": "conformance-catalog"}}, {"version": "v0.9", "deleteSurface": {"surfaceId": "s1"}}] + payload: + [ + {"version": "v0.9", "createSurface": {"surfaceId": "s1", "catalogId": "conformance-catalog"}}, + {"version": "v0.9", "deleteSurface": {"surfaceId": "s1"}}, + ] expect: {"absent_surfaces": ["s1"]} - name: test_processor_adds_components @@ -77,8 +89,20 @@ version: "0.9" catalog_schema: {"catalogId": "conformance-catalog", "components": {}} action: process_messages - payload: [{"version": "v0.9", "createSurface": {"surfaceId": "s1", "catalogId": "conformance-catalog"}}, {"version": "v0.9", "updateComponents": {"surfaceId": "s1", "components": [{"id": "root", "component": "Text", "text": "Hello"}]}}] - expect: {"surfaces": {"s1": {"components": {"root": {"component": "Text", "properties": {"text": "Hello"}}}}}} + payload: + [ + {"version": "v0.9", "createSurface": {"surfaceId": "s1", "catalogId": "conformance-catalog"}}, + { + "version": "v0.9", + "updateComponents": + {"surfaceId": "s1", "components": [{"id": "root", "component": "Text", "text": "Hello"}]}, + }, + ] + expect: + { + "surfaces": + {"s1": {"components": {"root": {"component": "Text", "properties": {"text": "Hello"}}}}}, + } - name: test_processor_updates_existing_component_properties description: >- @@ -87,8 +111,25 @@ version: "0.9" catalog_schema: {"catalogId": "conformance-catalog", "components": {}} action: process_messages - payload: [{"version": "v0.9", "createSurface": {"surfaceId": "s1", "catalogId": "conformance-catalog"}}, {"version": "v0.9", "updateComponents": {"surfaceId": "s1", "components": [{"id": "root", "component": "Text", "text": "Hello"}]}}, {"version": "v0.9", "updateComponents": {"surfaceId": "s1", "components": [{"id": "root", "component": "Text", "text": "World"}]}}] - expect: {"surfaces": {"s1": {"components": {"root": {"component": "Text", "properties": {"text": "World"}}}}}} + payload: + [ + {"version": "v0.9", "createSurface": {"surfaceId": "s1", "catalogId": "conformance-catalog"}}, + { + "version": "v0.9", + "updateComponents": + {"surfaceId": "s1", "components": [{"id": "root", "component": "Text", "text": "Hello"}]}, + }, + { + "version": "v0.9", + "updateComponents": + {"surfaceId": "s1", "components": [{"id": "root", "component": "Text", "text": "World"}]}, + }, + ] + expect: + { + "surfaces": + {"s1": {"components": {"root": {"component": "Text", "properties": {"text": "World"}}}}}, + } - name: test_processor_recreates_component_when_type_changes description: >- @@ -98,8 +139,31 @@ version: "0.9" catalog_schema: {"catalogId": "conformance-catalog", "components": {}} action: process_messages - payload: [{"version": "v0.9", "createSurface": {"surfaceId": "s1", "catalogId": "conformance-catalog"}}, {"version": "v0.9", "updateComponents": {"surfaceId": "s1", "components": [{"id": "root", "component": "Text", "text": "Hello"}]}}, {"version": "v0.9", "updateComponents": {"surfaceId": "s1", "components": [{"id": "root", "component": "Column", "children": ["a"]}]}}] - expect: {"surfaces": {"s1": {"components": {"root": {"component": "Column", "properties": {"children": ["a"]}}}}}} + payload: + [ + {"version": "v0.9", "createSurface": {"surfaceId": "s1", "catalogId": "conformance-catalog"}}, + { + "version": "v0.9", + "updateComponents": + {"surfaceId": "s1", "components": [{"id": "root", "component": "Text", "text": "Hello"}]}, + }, + { + "version": "v0.9", + "updateComponents": + { + "surfaceId": "s1", + "components": [{"id": "root", "component": "Column", "children": ["a"]}], + }, + }, + ] + expect: + { + "surfaces": + { + "s1": + {"components": {"root": {"component": "Column", "properties": {"children": ["a"]}}}}, + }, + } - name: test_processor_updates_components_on_the_named_surface_only description: >- @@ -109,8 +173,24 @@ version: "0.9" catalog_schema: {"catalogId": "conformance-catalog", "components": {}} action: process_messages - payload: [{"version": "v0.9", "createSurface": {"surfaceId": "s1", "catalogId": "conformance-catalog"}}, {"version": "v0.9", "createSurface": {"surfaceId": "s2", "catalogId": "conformance-catalog"}}, {"version": "v0.9", "updateComponents": {"surfaceId": "s2", "components": [{"id": "root", "component": "Text", "text": "Hello"}]}}] - expect: {"surfaces": {"s1": {"components": {}}, "s2": {"components": {"root": {"component": "Text", "properties": {"text": "Hello"}}}}}} + payload: + [ + {"version": "v0.9", "createSurface": {"surfaceId": "s1", "catalogId": "conformance-catalog"}}, + {"version": "v0.9", "createSurface": {"surfaceId": "s2", "catalogId": "conformance-catalog"}}, + { + "version": "v0.9", + "updateComponents": + {"surfaceId": "s2", "components": [{"id": "root", "component": "Text", "text": "Hello"}]}, + }, + ] + expect: + { + "surfaces": + { + "s1": {"components": {}}, + "s2": {"components": {"root": {"component": "Text", "properties": {"text": "Hello"}}}}, + }, + } - name: test_processor_routes_data_model_updates description: >- @@ -119,7 +199,14 @@ version: "0.9" catalog_schema: {"catalogId": "conformance-catalog", "components": {}} action: process_messages - payload: [{"version": "v0.9", "createSurface": {"surfaceId": "s1", "catalogId": "conformance-catalog"}}, {"version": "v0.9", "updateDataModel": {"surfaceId": "s1", "path": "/user/name", "value": "Alice"}}] + payload: + [ + {"version": "v0.9", "createSurface": {"surfaceId": "s1", "catalogId": "conformance-catalog"}}, + { + "version": "v0.9", + "updateDataModel": {"surfaceId": "s1", "path": "/user/name", "value": "Alice"}, + }, + ] expect: {"surfaces": {"s1": {"data_model": {"user": {"name": "Alice"}}}}} - name: test_processor_routes_data_model_updates_per_surface @@ -129,8 +216,15 @@ version: "0.9" catalog_schema: {"catalogId": "conformance-catalog", "components": {}} action: process_messages - payload: [{"version": "v0.9", "createSurface": {"surfaceId": "s1", "catalogId": "conformance-catalog"}}, {"version": "v0.9", "createSurface": {"surfaceId": "s2", "catalogId": "conformance-catalog"}}, {"version": "v0.9", "updateDataModel": {"surfaceId": "s1", "path": "/value", "value": "one"}}, {"version": "v0.9", "updateDataModel": {"surfaceId": "s2", "path": "/value", "value": "two"}}] - expect: {"surfaces": {"s1": {"data_model": {"value": "one"}}, "s2": {"data_model": {"value": "two"}}}} + payload: + [ + {"version": "v0.9", "createSurface": {"surfaceId": "s1", "catalogId": "conformance-catalog"}}, + {"version": "v0.9", "createSurface": {"surfaceId": "s2", "catalogId": "conformance-catalog"}}, + {"version": "v0.9", "updateDataModel": {"surfaceId": "s1", "path": "/value", "value": "one"}}, + {"version": "v0.9", "updateDataModel": {"surfaceId": "s2", "path": "/value", "value": "two"}}, + ] + expect: + {"surfaces": {"s1": {"data_model": {"value": "one"}}, "s2": {"data_model": {"value": "two"}}}} - name: test_processor_client_data_model_includes_opted_in_surfaces_only description: >- @@ -140,7 +234,24 @@ version: "0.9" catalog_schema: {"catalogId": "conformance-catalog", "components": {}} action: process_messages - payload: [{"version": "v0.9", "createSurface": {"surfaceId": "s1", "catalogId": "conformance-catalog", "sendDataModel": true}}, {"version": "v0.9", "updateDataModel": {"surfaceId": "s1", "path": "/foo", "value": "bar"}}, {"version": "v0.9", "createSurface": {"surfaceId": "s2", "catalogId": "conformance-catalog", "sendDataModel": false}}, {"version": "v0.9", "updateDataModel": {"surfaceId": "s2", "path": "/secret", "value": "baz"}}] + payload: + [ + { + "version": "v0.9", + "createSurface": + {"surfaceId": "s1", "catalogId": "conformance-catalog", "sendDataModel": true}, + }, + {"version": "v0.9", "updateDataModel": {"surfaceId": "s1", "path": "/foo", "value": "bar"}}, + { + "version": "v0.9", + "createSurface": + {"surfaceId": "s2", "catalogId": "conformance-catalog", "sendDataModel": false}, + }, + { + "version": "v0.9", + "updateDataModel": {"surfaceId": "s2", "path": "/secret", "value": "baz"}, + }, + ] expect: {"client_data_model": {"surfaces": {"s1": {"foo": "bar"}}}} - name: test_processor_client_data_model_absent_without_opt_in @@ -150,7 +261,11 @@ version: "0.9" catalog_schema: {"catalogId": "conformance-catalog", "components": {}} action: process_messages - payload: [{"version": "v0.9", "createSurface": {"surfaceId": "s1", "catalogId": "conformance-catalog"}}, {"version": "v0.9", "updateDataModel": {"surfaceId": "s1", "path": "/foo", "value": "bar"}}] + payload: + [ + {"version": "v0.9", "createSurface": {"surfaceId": "s1", "catalogId": "conformance-catalog"}}, + {"version": "v0.9", "updateDataModel": {"surfaceId": "s1", "path": "/foo", "value": "bar"}}, + ] expect: {"client_data_model_absent": true} - name: test_processor_client_capabilities_list_catalog_ids @@ -171,7 +286,14 @@ version: "0.9" catalog_schema: {"catalogId": "conformance-catalog", "components": {}} action: process_messages - payload: [{"version": "v0.9", "createSurface": {"surfaceId": "s1", "catalogId": "https://example.com/not-registered.json"}}] + payload: + [ + { + "version": "v0.9", + "createSurface": + {"surfaceId": "s1", "catalogId": "https://example.com/not-registered.json"}, + }, + ] expect_error: {"category": "StateError", "message": "Catalog not found"} - name: test_processor_rejects_duplicate_surface @@ -181,7 +303,11 @@ version: "0.9" catalog_schema: {"catalogId": "conformance-catalog", "components": {}} action: process_messages - payload: [{"version": "v0.9", "createSurface": {"surfaceId": "s1", "catalogId": "conformance-catalog"}}, {"version": "v0.9", "createSurface": {"surfaceId": "s1", "catalogId": "conformance-catalog"}}] + payload: + [ + {"version": "v0.9", "createSurface": {"surfaceId": "s1", "catalogId": "conformance-catalog"}}, + {"version": "v0.9", "createSurface": {"surfaceId": "s1", "catalogId": "conformance-catalog"}}, + ] expect_error: {"category": "StateError", "message": "already exists"} - name: test_processor_rejects_components_for_unknown_surface @@ -191,7 +317,14 @@ version: "0.9" catalog_schema: {"catalogId": "conformance-catalog", "components": {}} action: process_messages - payload: [{"version": "v0.9", "updateComponents": {"surfaceId": "s1", "components": [{"id": "root", "component": "Text", "text": "Hello"}]}}] + payload: + [ + { + "version": "v0.9", + "updateComponents": + {"surfaceId": "s1", "components": [{"id": "root", "component": "Text", "text": "Hello"}]}, + }, + ] expect_error: {"category": "StateError", "message": "Surface not found"} - name: test_processor_rejects_data_model_update_for_unknown_surface @@ -201,7 +334,8 @@ version: "0.9" catalog_schema: {"catalogId": "conformance-catalog", "components": {}} action: process_messages - payload: [{"version": "v0.9", "updateDataModel": {"surfaceId": "s1", "path": "/foo", "value": "bar"}}] + payload: + [{"version": "v0.9", "updateDataModel": {"surfaceId": "s1", "path": "/foo", "value": "bar"}}] expect_error: {"category": "StateError", "message": "Surface not found"} - name: test_processor_rejects_component_without_id @@ -211,7 +345,15 @@ version: "0.9" catalog_schema: {"catalogId": "conformance-catalog", "components": {}} action: process_messages - payload: [{"version": "v0.9", "createSurface": {"surfaceId": "s1", "catalogId": "conformance-catalog"}}, {"version": "v0.9", "updateComponents": {"surfaceId": "s1", "components": [{"component": "Text", "text": "Hello"}]}}] + payload: + [ + {"version": "v0.9", "createSurface": {"surfaceId": "s1", "catalogId": "conformance-catalog"}}, + { + "version": "v0.9", + "updateComponents": + {"surfaceId": "s1", "components": [{"component": "Text", "text": "Hello"}]}, + }, + ] expect_error: {"category": "ValidationError", "message": "missing an 'id'"} - name: test_processor_rejects_new_component_without_type @@ -222,7 +364,14 @@ version: "0.9" catalog_schema: {"catalogId": "conformance-catalog", "components": {}} action: process_messages - payload: [{"version": "v0.9", "createSurface": {"surfaceId": "s1", "catalogId": "conformance-catalog"}}, {"version": "v0.9", "updateComponents": {"surfaceId": "s1", "components": [{"id": "root", "text": "Hello"}]}}] + payload: + [ + {"version": "v0.9", "createSurface": {"surfaceId": "s1", "catalogId": "conformance-catalog"}}, + { + "version": "v0.9", + "updateComponents": {"surfaceId": "s1", "components": [{"id": "root", "text": "Hello"}]}, + }, + ] expect_error: {"category": "ValidationError", "message": "Cannot create component"} - name: test_processor_rejects_message_with_multiple_bodies @@ -232,5 +381,12 @@ version: "0.9" catalog_schema: {"catalogId": "conformance-catalog", "components": {}} action: process_messages - payload: [{"version": "v0.9", "createSurface": {"surfaceId": "s1", "catalogId": "conformance-catalog"}, "deleteSurface": {"surfaceId": "s1"}}] + payload: + [ + { + "version": "v0.9", + "createSurface": {"surfaceId": "s1", "catalogId": "conformance-catalog"}, + "deleteSurface": {"surfaceId": "s1"}, + }, + ] expect_error: {"category": "ValidationError"} diff --git a/renderers/web_core/src/v0_9/conformance/message-processor.conformance.test.ts b/renderers/web_core/src/v0_9/conformance/message-processor.conformance.test.ts index 12ffaa64f3..02ce5912f0 100644 --- a/renderers/web_core/src/v0_9/conformance/message-processor.conformance.test.ts +++ b/renderers/web_core/src/v0_9/conformance/message-processor.conformance.test.ts @@ -112,7 +112,11 @@ function runCase(testCase: ConformanceCase): void { assert.ok(surface, `${name}: surface ${surfaceId} is open`); if (expectation.catalogId !== undefined) { - assert.strictEqual(surface.catalog.id, expectation.catalogId, `${name}: ${surfaceId} catalogId`); + assert.strictEqual( + surface.catalog.id, + expectation.catalogId, + `${name}: ${surfaceId} catalogId`, + ); } if (expectation.sendDataModel !== undefined) { assert.strictEqual( From b0d68de9368f9a2666575be3cabe66d85700f60b Mon Sep 17 00:00:00 2001 From: Polina Cherkasova Date: Thu, 27 Aug 2026 11:33:00 -0700 Subject: [PATCH 07/22] - --- dart/a2ui_agent/lib/src/parser/parser.dart | 6 +++++- dart/a2ui_core/lib/src/core/catalog.dart | 7 ++++--- .../lib/src/core/renderer_capabilities.dart | 9 ++++++++- .../test/renderer_capabilities_test.dart | 19 +++++++++++++++++++ 4 files changed, 36 insertions(+), 5 deletions(-) diff --git a/dart/a2ui_agent/lib/src/parser/parser.dart b/dart/a2ui_agent/lib/src/parser/parser.dart index 44915973bf..bb7da234e2 100644 --- a/dart/a2ui_agent/lib/src/parser/parser.dart +++ b/dart/a2ui_agent/lib/src/parser/parser.dart @@ -64,7 +64,11 @@ abstract class Parser { parts.add(TextPart(text)); case RawA2uiPart(:final String a2uiRaw): parts.add(A2uiPart(compile(a2uiRaw))); - case ResponsePart(): + case A2uiPart(): + // Unreachable: RawResponsePart rejects an already compiled part. + // Matching the concrete type rather than the sealed base keeps this + // switch exhaustive, so a new ResponsePart subtype is a compile + // error here rather than a silent fallthrough. throw StateError('Unexpected raw part: ${raw.part}'); } } diff --git a/dart/a2ui_core/lib/src/core/catalog.dart b/dart/a2ui_core/lib/src/core/catalog.dart index 5ac8d7c458..ed289f5add 100644 --- a/dart/a2ui_core/lib/src/core/catalog.dart +++ b/dart/a2ui_core/lib/src/core/catalog.dart @@ -302,11 +302,12 @@ class Catalog { final Map document = _deepCopy(source); document['catalogId'] = id; - if (source['components'] is Map) { + final Object? sourceComponents = source['components']; + if (sourceComponents is Map) { document['components'] = { for (final String name in components.keys) - if ((source['components']! as Map).containsKey(name)) - name: _deepCopyValue((source['components']! as Map)[name]), + if (sourceComponents.containsKey(name)) + name: _deepCopyValue(sourceComponents[name]), }; } final Object? sourceFunctions = source['functions']; diff --git a/dart/a2ui_core/lib/src/core/renderer_capabilities.dart b/dart/a2ui_core/lib/src/core/renderer_capabilities.dart index cc1018430d..42d43be0e4 100644 --- a/dart/a2ui_core/lib/src/core/renderer_capabilities.dart +++ b/dart/a2ui_core/lib/src/core/renderer_capabilities.dart @@ -61,7 +61,14 @@ class A2uiVersionCapabilities { inlineCatalogs: [ if (rawInline is List) for (final Object? catalog in rawInline) - Catalog.fromJson((catalog! as Map).cast()), + if (catalog is Map) + Catalog.fromJson(catalog.cast()) + else + throw A2uiValidationError( + "'inlineCatalogs' must contain only catalog objects (got " + '${catalog.runtimeType}).', + details: json, + ), ], ); } diff --git a/dart/a2ui_core/test/renderer_capabilities_test.dart b/dart/a2ui_core/test/renderer_capabilities_test.dart index 56d387296d..3b9774487e 100644 --- a/dart/a2ui_core/test/renderer_capabilities_test.dart +++ b/dart/a2ui_core/test/renderer_capabilities_test.dart @@ -44,6 +44,25 @@ void main() { expect(caps.inlineCatalogs.single.components.keys, ['Gauge']); }); + test('rejects an inline catalog that is not an object', () { + for (final malformed in [null, 'nope', 42, []]) { + expect( + () => A2uiVersionCapabilities.fromJson({ + 'supportedCatalogIds': [], + 'inlineCatalogs': [malformed], + }), + throwsA( + isA().having( + (e) => e.message, + 'message', + contains('inlineCatalogs'), + ), + ), + reason: '$malformed', + ); + } + }); + test('rejects missing or malformed supportedCatalogIds', () { expect( () => A2uiVersionCapabilities.fromJson({}), From 992db3a665d822b06352fc8dff4daecf483e3e71 Mon Sep 17 00:00:00 2001 From: Polina Cherkasova Date: Thu, 27 Aug 2026 12:47:52 -0700 Subject: [PATCH 08/22] Update pruning.dart --- dart/a2ui_agent/lib/src/catalog_transformers/pruning.dart | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/dart/a2ui_agent/lib/src/catalog_transformers/pruning.dart b/dart/a2ui_agent/lib/src/catalog_transformers/pruning.dart index 8864780e81..ee68ae74e5 100644 --- a/dart/a2ui_agent/lib/src/catalog_transformers/pruning.dart +++ b/dart/a2ui_agent/lib/src/catalog_transformers/pruning.dart @@ -18,8 +18,7 @@ import 'base.dart'; /// Prunes catalog component definitions to an allowlist. /// -/// Names in the allowlist that the catalog does not declare are ignored, so a -/// single transformer can be reused across catalogs. +/// Keeps only the components named in both the allowlist and catalog. class ComponentPruningTransformer extends CatalogTransformer { /// The components to keep. @@ -39,8 +38,7 @@ class ComponentPruningTransformer /// Prunes catalog function definitions to an allowlist. /// -/// Names in the allowlist that the catalog does not declare are ignored, so a -/// single transformer can be reused across catalogs. +/// Keeps only the functions named in the allowlist and catalog. class FunctionPruningTransformer extends CatalogTransformer { /// The functions to keep. From f427071b7b5b37f72731607419e3d1c11e24e6d8 Mon Sep 17 00:00:00 2001 From: Polina Cherkasova Date: Thu, 27 Aug 2026 13:01:29 -0700 Subject: [PATCH 09/22] - --- dart/a2ui_agent/test/e2e/minimal_snippet.dart | 111 ++++++++++++ .../test/e2e/minimal_snippet_test.dart | 165 ++++++++++++++++++ 2 files changed, 276 insertions(+) create mode 100644 dart/a2ui_agent/test/e2e/minimal_snippet.dart create mode 100644 dart/a2ui_agent/test/e2e/minimal_snippet_test.dart diff --git a/dart/a2ui_agent/test/e2e/minimal_snippet.dart b/dart/a2ui_agent/test/e2e/minimal_snippet.dart new file mode 100644 index 0000000000..a60b448d7b --- /dev/null +++ b/dart/a2ui_agent/test/e2e/minimal_snippet.dart @@ -0,0 +1,111 @@ +// Copyright 2024 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 'package:a2ui_agent/a2ui_agent.dart'; +import 'package:a2ui_core/a2ui_core.dart'; + +import '../test_catalogs.dart'; + +/// What one run of [userSnippet] produced, so a test can assert on each step +/// of the blueprint's example rather than only on its final output. +class UserSnippetResult { + /// Step 1: the long-lived generator created at agent startup. + final A2uiGenerator generator; + + /// Step 2: the processor negotiated for this request. + final A2uiRequestProcessor processor; + + /// Step 3: the snippet the agent prepends its own preamble to before + /// calling the model. + final String promptSnippet; + + /// Step 3: what the model returned. + final String llmOutput; + + /// Step 4: the parsed, validated response, with text and A2UI blocks in the + /// order the model emitted them. + final List responseParts; + + /// Step 5: the A2UI messages delivered to the renderer, flattened out of + /// [responseParts] in order. + final List a2uiPayload; + + const UserSnippetResult({ + required this.generator, + required this.processor, + required this.promptSnippet, + required this.llmOutput, + required this.responseParts, + required this.a2uiPayload, + }); +} + +/// The agent turn from the "Code Example" section of +/// `blueprints/modules/a2ui_agent.blueprint.md`, written against the Dart SDK. +/// +/// This is deliberately a transcription of the blueprint's Python rather than +/// idiomatic application code: it is how the published example reads once the +/// SDK is implemented, and the test around it is what stops the example and +/// the API drifting apart. +/// +/// The pieces the blueprint leaves to the agent author are parameters here: +/// [examples] stands in for `load_examples("./prompts/examples/**")`, and +/// [callLlm] for `myagent.call_llm(prompt_snippet, request_context)`. The +/// blueprint's second, custom catalog is left out: this SDK is scoped to the +/// published basic catalog, and a catalog loaded from disk exercises +/// [CatalogConfig.fromPath] rather than anything in the example's flow. +UserSnippetResult userSnippet({ + required A2uiRendererCapabilities rendererCapabilities, + required String Function(String promptSnippet) callLlm, + Map>? examples, +}) { + // 1. Agent startup: initialize the long-lived A2uiGenerator with the agent's + // catalog. Prompt examples passed here are validated during processor + // creation (createProcessor) against the active negotiated catalogs, and + // an example using components or structures the active catalog does not + // support raises an error. + final generator = A2uiGenerator( + catalogs: [CatalogConfig(basicCatalog())], + examples: examples, + ); + + // 2. In the request handler: retrieve the processor pre-negotiated against + // the renderer's capabilities. + final A2uiRequestProcessor processor = + generator.createProcessor(rendererCapabilities); + + // 3. Invoke the LLM to generate the output. + final String promptSnippet = processor.promptSnippet; + final String llmOutputText = callLlm(promptSnippet); + + // 4. Parse and validate the output using the processor. + final List responseParts = processor.parseResponse( + llmOutputText, + ); + + // 5. Deliver the A2UI payloads to the renderer. + final List a2uiPayload = [ + for (final A2uiPart part in responseParts.whereType()) + ...part.a2ui, + ]; + + return UserSnippetResult( + generator: generator, + processor: processor, + promptSnippet: promptSnippet, + llmOutput: llmOutputText, + responseParts: responseParts, + a2uiPayload: a2uiPayload, + ); +} diff --git a/dart/a2ui_agent/test/e2e/minimal_snippet_test.dart b/dart/a2ui_agent/test/e2e/minimal_snippet_test.dart new file mode 100644 index 0000000000..6768cd9a59 --- /dev/null +++ b/dart/a2ui_agent/test/e2e/minimal_snippet_test.dart @@ -0,0 +1,165 @@ +// Copyright 2024 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 'package:a2ui_agent/a2ui_agent.dart'; +import 'package:a2ui_core/a2ui_core.dart'; +import 'package:test/test.dart'; + +import '../conformance/conformance_harness.dart'; +import '../test_catalogs.dart'; +import 'minimal_snippet.dart'; + +/// Marks the assertions that cannot pass until capability negotiation, prompt +/// generation and response parsing are implemented. Remove the skip alongside +/// those implementations. +const String pendingSnippet = + 'The blueprint snippet cannot run end to end yet.'; + +/// Exercises [userSnippet], the transcription of the "Code Example" section of +/// `blueprints/modules/a2ui_agent.blueprint.md`. +/// +/// The model response and the expected parse come from +/// `conformance/agent/request_processor.yaml`, so the published example is +/// measured against the same data as every other SDK. +void main() { + final List> cases = loadConformanceSuite( + 'agent/request_processor.yaml', + ); + final Map data = cases.firstWhere( + (c) => c['name'] == 'test_primary_use_case_basic_catalog_login_form', + orElse: () => throw StateError('No primary use case in the suite'), + ); + final args = data['args']! as Map; + final llmResponse = args['llm_response']! as String; + + /// Runs the snippet with a stubbed model, recording the prompt it was given. + UserSnippetResult run({ + String? response, + List? supportedCatalogIds, + List? promptsSeen, + }) => userSnippet( + rendererCapabilities: A2uiRendererCapabilities.forCatalogIds( + supportedCatalogIds ?? [basicCatalogId], + ), + callLlm: (prompt) { + promptsSeen?.add(prompt); + return response ?? llmResponse; + }, + ); + + group('blueprint code example', () { + test('runs the five steps of the example end to end', () { + final promptsSeen = []; + final UserSnippetResult result = run(promptsSeen: promptsSeen); + + // Step 2: the basic catalog is negotiated, because the renderer + // declared support for it. + expect(result.processor.activeCatalogs.map((c) => c.id), [ + basicCatalogId, + ]); + + // Step 3: the model is called exactly once, with the prompt snippet the + // processor rendered. + expect(promptsSeen, [result.promptSnippet]); + for (final Object? fragment in data['expect_prompt_contains']! as List) { + expect(result.promptSnippet, contains(fragment! as String)); + } + expect(result.llmOutput, llmResponse); + + // Step 4: text and A2UI blocks come back in the order the model emitted + // them. + final expected = data['expect']! as List; + expect(result.responseParts, hasLength(expected.length)); + for (var i = 0; i < expected.length; i++) { + final expectedPart = expected[i]! as Map; + final ResponsePart actual = result.responseParts[i]; + if (expectedPart.containsKey('a2ui')) { + expect(actual, isA(), reason: 'part $i'); + expect( + (actual as A2uiPart).a2ui.map((m) => m.toJson()).toList(), + equals(expectedPart['a2ui']), + reason: 'part $i', + ); + } else { + expect(actual, isA(), reason: 'part $i'); + expect( + (actual as TextPart).text, + expectedPart['text'], + reason: 'part $i', + ); + } + } + + // Step 5: what is handed to the renderer is one flat, ordered payload. + expect(result.a2uiPayload.first, isA()); + expect( + (result.a2uiPayload.first as CreateSurfaceMessage).catalogId, + basicCatalogId, + ); + expect( + result.a2uiPayload.whereType(), + isNotEmpty, + ); + expect( + result.a2uiPayload.whereType(), + isNotEmpty, + ); + }, skip: pendingSnippet); + + test('rejects a renderer that does not support the basic catalog', () { + expect( + () => run(supportedCatalogIds: ['https://example.com/unknown.json']), + throwsA(isA()), + ); + }, skip: pendingSnippet); + + test('rejects a model payload declaring an unsupported version', () { + final Map rejected = cases.firstWhere( + (c) => c['name'] == 'test_primary_use_case_rejects_unsupported_version', + ); + expect( + () => run( + response: + (rejected['args']! as Map)['llm_response']! + as String, + ), + throwsA(isA()), + ); + }, skip: pendingSnippet); + }); + + group('blueprint code example inputs', () { + test('the agent registers the catalog the example loads', () { + // Step 1 of the example stands on its own: the generator is constructed + // and advertises the catalog before anything is negotiated. + final generator = A2uiGenerator( + catalogs: [CatalogConfig(basicCatalog())], + ); + expect(generator.agentCapabilities['supportedCatalogIds'], [ + basicCatalogId, + ]); + expect(generator.agentCapabilities['a2uiVersions'], ['v0.9']); + }); + + test('the example runs against the published basic catalog', () { + final SchemaCatalog catalog = basicCatalog(); + expect(catalog.id, basicCatalogId); + expect(catalog.protocolVersion, A2uiProtocolVersion.v0_9); + expect( + catalog.components.keys, + containsAll(['Card', 'Column', 'Text', 'TextField', 'Button']), + ); + }); + }); +} From f496522023db221fb9b52cfde3f080e133741637 Mon Sep 17 00:00:00 2001 From: Polina Cherkasova Date: Thu, 27 Aug 2026 13:20:10 -0700 Subject: [PATCH 10/22] Update minimal_snippet.dart --- dart/a2ui_agent/test/e2e/minimal_snippet.dart | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/dart/a2ui_agent/test/e2e/minimal_snippet.dart b/dart/a2ui_agent/test/e2e/minimal_snippet.dart index a60b448d7b..635f21a440 100644 --- a/dart/a2ui_agent/test/e2e/minimal_snippet.dart +++ b/dart/a2ui_agent/test/e2e/minimal_snippet.dart @@ -53,18 +53,6 @@ class UserSnippetResult { /// The agent turn from the "Code Example" section of /// `blueprints/modules/a2ui_agent.blueprint.md`, written against the Dart SDK. -/// -/// This is deliberately a transcription of the blueprint's Python rather than -/// idiomatic application code: it is how the published example reads once the -/// SDK is implemented, and the test around it is what stops the example and -/// the API drifting apart. -/// -/// The pieces the blueprint leaves to the agent author are parameters here: -/// [examples] stands in for `load_examples("./prompts/examples/**")`, and -/// [callLlm] for `myagent.call_llm(prompt_snippet, request_context)`. The -/// blueprint's second, custom catalog is left out: this SDK is scoped to the -/// published basic catalog, and a catalog loaded from disk exercises -/// [CatalogConfig.fromPath] rather than anything in the example's flow. UserSnippetResult userSnippet({ required A2uiRendererCapabilities rendererCapabilities, required String Function(String promptSnippet) callLlm, From 410199f63b873df709720c252f5a038d9d8e14e8 Mon Sep 17 00:00:00 2001 From: Polina Cherkasova Date: Thu, 27 Aug 2026 13:29:08 -0700 Subject: [PATCH 11/22] - --- .../example/a2ui_agent_example.dart | 20 ++--- dart/a2ui_agent/lib/a2ui_agent.dart | 9 +- .../lib/src/catalog_transformers/base.dart | 8 +- dart/a2ui_agent/lib/src/inference_format.dart | 9 +- .../direct_json/constants.dart | 8 +- .../inference_formats/direct_json/parser.dart | 6 +- .../direct_json/prompt_generator.dart | 10 +-- .../direct_json/streaming.dart | 12 ++- .../inference_formats/express/compiler.dart | 6 +- .../src/inference_formats/express/parser.dart | 3 - .../express/prompt_generator.dart | 5 +- dart/a2ui_agent/lib/src/parser/parser.dart | 45 ++++------ .../lib/src/parser/response_part.dart | 27 +++--- .../lib/src/processor/catalog_config.dart | 12 ++- .../lib/src/processor/catalog_providers.dart | 18 ++-- .../lib/src/processor/generator.dart | 25 +++--- .../lib/src/processor/processor.dart | 25 +++--- dart/a2ui_agent/lib/src/prompt/generator.dart | 13 ++- .../lib/src/utils/catalog_resolver.dart | 14 ++-- .../conformance/agent_conformance_test.dart | 3 +- .../test/conformance/conformance_harness.dart | 15 ++-- dart/a2ui_agent/test/e2e/minimal_snippet.dart | 22 ++--- .../test/e2e/minimal_snippet_test.dart | 28 +++---- .../test/e2e/primary_use_case_test.dart | 23 +++-- .../direct_json_format_test.dart | 3 +- .../direct_json_parser_test.dart | 3 +- .../direct_json_streaming_test.dart | 3 +- .../test/inference_formats/express_test.dart | 7 +- dart/a2ui_agent/test/parser/parser_test.dart | 6 +- .../test/processor/generator_test.dart | 3 +- .../test/processor/processor_test.dart | 6 +- dart/a2ui_agent/test/test_catalogs.dart | 7 +- .../test/utils/catalog_resolver_test.dart | 3 +- dart/a2ui_core/lib/a2ui_core.dart | 2 +- dart/a2ui_core/lib/src/core/catalog.dart | 83 ++++++++----------- dart/a2ui_core/lib/src/core/data_model.dart | 17 ++-- dart/a2ui_core/lib/src/core/messages.dart | 7 +- .../lib/src/core/renderer_capabilities.dart | 33 +++----- dart/a2ui_core/lib/src/primitives/errors.dart | 10 +-- .../lib/src/primitives/protocol_version.dart | 15 ++-- .../lib/src/validation/validator.dart | 55 +++++------- .../test/conformance/conformance_harness.dart | 15 ++-- .../data_model_conformance_test.dart | 15 ++-- .../message_processor_conformance_test.dart | 10 +-- dart/a2ui_core/test/validator_test.dart | 3 +- 45 files changed, 267 insertions(+), 405 deletions(-) diff --git a/dart/a2ui_agent/example/a2ui_agent_example.dart b/dart/a2ui_agent/example/a2ui_agent_example.dart index a93daedc78..62d9ffd464 100644 --- a/dart/a2ui_agent/example/a2ui_agent_example.dart +++ b/dart/a2ui_agent/example/a2ui_agent_example.dart @@ -17,12 +17,11 @@ import 'package:a2ui_core/a2ui_core.dart'; /// One agent turn, from startup to messages ready for a renderer. /// -/// Most of the SDK is still stubbed, so running this throws -/// [UnimplementedError] at the first unimplemented step. It is written to show -/// the intended shape of an integration. +/// Shows the intended shape of an integration. Most of the SDK is still +/// stubbed, so running this throws [UnimplementedError]. void main() { - // 1. Agent startup. Register every catalog the agent can generate UI for, - // narrowed to the components and functions this agent actually uses. + // 1. Agent startup. Register every catalog the agent supports, narrowed to + // the components and functions it uses. final generator = A2uiGenerator( catalogs: [ CatalogConfig.fromPath( @@ -45,9 +44,8 @@ void main() { }, ); - // 2. Per request. Negotiate the agent's catalogs against what the renderer - // says it can render. `a2uiClientCapabilities` arrives in transport - // metadata, for example the A2A message metadata. + // 2. Per request. Negotiate against what the renderer says it can render. + // `a2uiClientCapabilities` arrives in transport metadata. final capabilities = A2uiRendererCapabilities.fromJson({ 'v0.9': { 'supportedCatalogIds': [ @@ -58,14 +56,12 @@ void main() { final A2uiRequestProcessor processor = generator.createProcessor(capabilities); - // 3. Inference. Prepend your own role and workflow preamble to the snippet, - // then call your model with it. + // 3. Inference. Prepend your own preamble, then call your model. final systemPrompt = 'You are a helpful assistant.\n\n${processor.promptSnippet}'; final String modelOutput = callYourModel(systemPrompt); - // 4. Parse and validate. Conversational text and A2UI payloads come back in - // the order the model emitted them. + // 4. Parse and validate, in the order the model emitted. final List parts = processor.parseResponse(modelOutput); // 5. Deliver. Text goes to the chat transcript; messages go to the renderer. diff --git a/dart/a2ui_agent/lib/a2ui_agent.dart b/dart/a2ui_agent/lib/a2ui_agent.dart index 746e03525a..42835ae342 100644 --- a/dart/a2ui_agent/lib/a2ui_agent.dart +++ b/dart/a2ui_agent/lib/a2ui_agent.dart @@ -12,13 +12,10 @@ // See the License for the specific language governing permissions and // limitations under the License. -/// The A2UI agent SDK: catalog management, capability negotiation, prompt -/// engineering, response parsing and payload validation for agents that -/// generate A2UI. +/// The A2UI agent SDK: catalogs, capability negotiation, prompting, response +/// parsing and payload validation for agents that generate A2UI. /// -/// This SDK implements version 0.9 of the A2UI protocol. Payloads and -/// capabilities that declare any other version, or omit the version, are -/// rejected. +/// Implements protocol v0.9 only; any other version, or none, is rejected. library; // Catalog transformers. diff --git a/dart/a2ui_agent/lib/src/catalog_transformers/base.dart b/dart/a2ui_agent/lib/src/catalog_transformers/base.dart index ca050fbdcf..dee7f15f3c 100644 --- a/dart/a2ui_agent/lib/src/catalog_transformers/base.dart +++ b/dart/a2ui_agent/lib/src/catalog_transformers/base.dart @@ -14,17 +14,15 @@ import 'package:a2ui_core/a2ui_core.dart'; -/// A transformation rule applied to a catalog before it is rendered into a -/// prompt or used for payload validation. +/// A rule applied to a catalog before prompting or validation. /// -/// Transformers narrow a pristine catalog; they never widen it. +/// Transformers narrow a catalog; they never widen it. abstract class CatalogTransformer< C extends ComponentApi, F extends FunctionApi > { const CatalogTransformer(); - /// Transforms [catalog] into a modified catalog of the same component and - /// function types. + /// Narrows [catalog], preserving its component and function types. Catalog transform(Catalog catalog); } diff --git a/dart/a2ui_agent/lib/src/inference_format.dart b/dart/a2ui_agent/lib/src/inference_format.dart index 4b20895647..f4479b7322 100644 --- a/dart/a2ui_agent/lib/src/inference_format.dart +++ b/dart/a2ui_agent/lib/src/inference_format.dart @@ -17,26 +17,25 @@ import 'package:a2ui_core/a2ui_core.dart'; import 'parser/parser.dart'; import 'prompt/generator.dart'; -/// Pairs a prompt generator (agent input) with a parser (agent output) for one -/// wire format. +/// Pairs a prompt generator with a parser for one wire format. abstract class InferenceFormat { const InferenceFormat(); /// The prompt generator for this format. PromptGenerator get promptGenerator; - /// Creates a fresh, turn-scoped parser bound to this format's catalogs. + /// Creates a turn-scoped parser bound to this format's catalogs. Parser createParser(); } -/// Constructs [InferenceFormat] strategies bound to a set of active catalogs. +/// Constructs [InferenceFormat]s bound to a set of active catalogs. abstract class InferenceFormatFactory< C extends ComponentApi, F extends FunctionApi > { const InferenceFormatFactory(); - /// Constructs an [InferenceFormat] bound to [catalogs]. + /// Binds a format to [catalogs]. InferenceFormat createFormat( List> catalogs, { Map>? examples, diff --git a/dart/a2ui_agent/lib/src/inference_formats/direct_json/constants.dart b/dart/a2ui_agent/lib/src/inference_formats/direct_json/constants.dart index 4545648972..4837b9a93e 100644 --- a/dart/a2ui_agent/lib/src/inference_formats/direct_json/constants.dart +++ b/dart/a2ui_agent/lib/src/inference_formats/direct_json/constants.dart @@ -24,12 +24,8 @@ const String a2uiSchemaOpenTag = ''; /// The closing counterpart of [a2uiSchemaOpenTag]. const String a2uiSchemaCloseTag = ''; -/// String property keys whose values may be auto-closed when a streamed chunk -/// cuts them mid-token. -/// -/// These carry display text, so a truncated value renders as partial text -/// rather than as a structural error. Keys outside this set are held back -/// until the stream completes them. +/// String keys carrying display text, safe to auto-close when a chunk cuts +/// them mid-token. Other keys are held back until the stream completes them. const Set defaultProgressiveKeys = { 'altText', 'caption', diff --git a/dart/a2ui_agent/lib/src/inference_formats/direct_json/parser.dart b/dart/a2ui_agent/lib/src/inference_formats/direct_json/parser.dart index 6e5be16c0e..7000d96d71 100644 --- a/dart/a2ui_agent/lib/src/inference_formats/direct_json/parser.dart +++ b/dart/a2ui_agent/lib/src/inference_formats/direct_json/parser.dart @@ -21,8 +21,7 @@ import 'streaming.dart'; /// Parses A2UI JSON payload envelopes enclosed in `` sentinel tags. /// -/// One instance parses one LLM turn: [parseChunk] accumulates streaming state -/// that must not be shared across turns. +/// One instance per turn: [parseChunk] state must not be shared. class DirectJsonParser extends Parser { /// The active catalogs compiled payloads are validated against. @@ -40,8 +39,7 @@ class DirectJsonParser A2uiValidator? validator, }) : validator = validator ?? A2uiValidator(catalogs: catalogs); - /// The string property keys safe to auto-close when a stream cuts them - /// mid-token. + /// The keys safe to auto-close when a stream cuts them mid-token. Set get progressiveKeys => customProgressiveKeys ?? defaultProgressiveKeys; diff --git a/dart/a2ui_agent/lib/src/inference_formats/direct_json/prompt_generator.dart b/dart/a2ui_agent/lib/src/inference_formats/direct_json/prompt_generator.dart index f4cd1362f0..b0302cbcc3 100644 --- a/dart/a2ui_agent/lib/src/inference_formats/direct_json/prompt_generator.dart +++ b/dart/a2ui_agent/lib/src/inference_formats/direct_json/prompt_generator.dart @@ -18,14 +18,12 @@ import '../../prompt/generator.dart'; /// Renders system instructions for the DIRECT_JSON format. /// -/// The generated snippet embeds the active catalog schemas inside -/// `` tags and instructs the model to emit A2UI payloads inside -/// `` tags. +/// Embeds the catalog schemas in `` tags and asks the model for +/// payloads in `` tags. class DirectJsonPromptGenerator extends PromptGenerator { - /// The payload envelope names the model may emit. - /// - /// When null, every envelope of the active protocol version is allowed. + /// The envelope names the model may emit; null allows every envelope of + /// the active protocol version. final List? allowedMessages; DirectJsonPromptGenerator( diff --git a/dart/a2ui_agent/lib/src/inference_formats/direct_json/streaming.dart b/dart/a2ui_agent/lib/src/inference_formats/direct_json/streaming.dart index a70a5ae775..c531820588 100644 --- a/dart/a2ui_agent/lib/src/inference_formats/direct_json/streaming.dart +++ b/dart/a2ui_agent/lib/src/inference_formats/direct_json/streaming.dart @@ -18,9 +18,8 @@ import '../../parser/response_part.dart'; /// Incrementally decodes a streamed DIRECT_JSON response. /// -/// Buffers partial tokens, heals string values whose key is listed in -/// [progressiveKeys], and yields messages only once they are structurally -/// complete and reachable from a surface root. +/// Buffers partial tokens, heals [progressiveKeys] strings, and yields +/// messages only once complete and reachable from a surface root. class DirectJsonStreamProcessor { /// The active catalogs yielded payloads are validated against. final List> catalogs; @@ -42,15 +41,14 @@ class DirectJsonStreamProcessor { throw UnimplementedError('DirectJsonStreamProcessor.process'); } - /// Flushes any buffered content at the end of a stream. + /// Flushes buffered content at the end of a stream. /// - /// Throws [A2uiParseError] if the buffer still holds an unterminated payload - /// block. + /// Throws [A2uiParseError] if a payload block is still unterminated. List finish() { throw UnimplementedError('DirectJsonStreamProcessor.finish'); } - /// Discards all buffered state so the processor can start a new turn. + /// Discards buffered state for a new turn. void reset() { throw UnimplementedError('DirectJsonStreamProcessor.reset'); } diff --git a/dart/a2ui_agent/lib/src/inference_formats/express/compiler.dart b/dart/a2ui_agent/lib/src/inference_formats/express/compiler.dart index 9c80c0d720..bb8307cc5f 100644 --- a/dart/a2ui_agent/lib/src/inference_formats/express/compiler.dart +++ b/dart/a2ui_agent/lib/src/inference_formats/express/compiler.dart @@ -26,9 +26,9 @@ class ExpressCompiler { /// Compiles an Express DSL string into A2UI messages. /// - /// Throws [A2uiCompileError] if [source] is not a well-formed Express - /// expression, and [A2uiValidationError] if it names components or functions - /// the active catalogs do not declare. + /// Throws [A2uiCompileError] if [source] is malformed, and + /// [A2uiValidationError] if it names components or functions the catalogs + /// do not declare. List compile(String source) { throw UnimplementedError('ExpressCompiler.compile'); } diff --git a/dart/a2ui_agent/lib/src/inference_formats/express/parser.dart b/dart/a2ui_agent/lib/src/inference_formats/express/parser.dart index 712b6dcf11..087888f8b0 100644 --- a/dart/a2ui_agent/lib/src/inference_formats/express/parser.dart +++ b/dart/a2ui_agent/lib/src/inference_formats/express/parser.dart @@ -20,9 +20,6 @@ import 'compiler.dart'; import 'decompiler.dart'; /// Parses Express DSL payloads enclosed in `` sentinel tags. -/// -/// Delegates compilation to [ExpressCompiler] and decompilation to -/// [ExpressDecompiler]. class ExpressParser extends Parser { /// The active catalogs compiled payloads are validated against. diff --git a/dart/a2ui_agent/lib/src/inference_formats/express/prompt_generator.dart b/dart/a2ui_agent/lib/src/inference_formats/express/prompt_generator.dart index ce1fac35d5..e459ec4875 100644 --- a/dart/a2ui_agent/lib/src/inference_formats/express/prompt_generator.dart +++ b/dart/a2ui_agent/lib/src/inference_formats/express/prompt_generator.dart @@ -18,9 +18,8 @@ import '../../prompt/generator.dart'; /// Renders system instructions for the EXPRESS format. /// -/// Describes catalog components and functions as compact positional -/// signatures, which costs far fewer output tokens than the JSON schemas the -/// DIRECT_JSON generator emits. +/// Describes components and functions as positional signatures, far cheaper +/// in tokens than DIRECT_JSON's schemas. class ExpressPromptGenerator extends PromptGenerator { ExpressPromptGenerator(super.catalogs, {super.examples}); diff --git a/dart/a2ui_agent/lib/src/parser/parser.dart b/dart/a2ui_agent/lib/src/parser/parser.dart index bb7da234e2..768a0ef28e 100644 --- a/dart/a2ui_agent/lib/src/parser/parser.dart +++ b/dart/a2ui_agent/lib/src/parser/parser.dart @@ -16,45 +16,38 @@ import 'package:a2ui_core/a2ui_core.dart'; import 'response_part.dart'; -/// Tokenizes LLM output, unwraps format tags, and compiles raw format -/// expressions into A2UI payload messages. +/// Tokenizes LLM output and compiles it into A2UI messages. /// -/// A parser instance is turn scoped: streaming state accumulated by -/// [parseChunk] belongs to a single LLM response. +/// Turn scoped: streaming state from [parseChunk] belongs to one response. abstract class Parser { const Parser(); /// Whether this parser can process streamed chunks via [parseChunk]. bool get supportsStreaming => false; - /// Converts raw response parts back into a single string, adding the - /// format's enclosing tags around each raw A2UI section and concatenating - /// conversational text parts. + /// Renders raw parts back to one string, re-adding the format's tags. String wrap(List blocks); - /// Tokenizes an LLM response into an ordered list of raw parts, extracting - /// raw format content between sentinel tags while preserving the order in - /// which the model emitted them. + /// Tokenizes a response into raw parts, in the order the model emitted + /// them. /// - /// Throws [A2uiParseError] if the response contains no well-formed content - /// for this format. + /// Throws [A2uiParseError] if the response holds no well-formed content for + /// this format. List unwrap(String content); - /// Compiles a raw format content string into validated A2UI messages. + /// Compiles raw format content into validated A2UI messages. /// - /// Throws [A2uiCompileError] if the content cannot be compiled, and - /// [A2uiValidationError] if the compiled messages are not valid for the - /// active catalogs or declare an unsupported protocol version. + /// Throws [A2uiCompileError] if it cannot be compiled, and + /// [A2uiValidationError] if the result is invalid for the active catalogs + /// or declares an unsupported version. List compile(String formatContent); /// Decompiles A2UI messages into this format's raw notation. String decompile(List a2uiPayload); - /// Parses a complete, non-streamed LLM response. + /// Parses a complete, non-streamed response, preserving emission order. /// - /// Preserves the chronological order of conversational text and A2UI payload - /// blocks. When [wrapped] is false the whole of [content] is treated as a - /// single raw A2UI block. + /// When [wrapped] is false, all of [content] is one raw A2UI block. List parseResponse(String content, {bool wrapped = true}) { if (!wrapped) return [A2uiPart(compile(content))]; final parts = []; @@ -65,19 +58,17 @@ abstract class Parser { case RawA2uiPart(:final String a2uiRaw): parts.add(A2uiPart(compile(a2uiRaw))); case A2uiPart(): - // Unreachable: RawResponsePart rejects an already compiled part. - // Matching the concrete type rather than the sealed base keeps this - // switch exhaustive, so a new ResponsePart subtype is a compile - // error here rather than a silent fallthrough. + // Unreachable: RawResponsePart rejects compiled parts. Matching + // the concrete type keeps this switch exhaustive, so a new subtype + // is a compile error rather than a silent fallthrough. throw StateError('Unexpected raw part: ${raw.part}'); } } return parts; } - /// Processes an incremental chunk of a streamed LLM response. + /// Processes one chunk of a streamed response. /// - /// Returns only the parts newly completed by this chunk. Buffered, still - /// incomplete content is retained for the next call. + /// Returns the parts this chunk completed; incomplete content is buffered. List parseChunk(String chunk, {bool wrapped = true}); } diff --git a/dart/a2ui_agent/lib/src/parser/response_part.dart b/dart/a2ui_agent/lib/src/parser/response_part.dart index 2fa5003779..52b81ecf1a 100644 --- a/dart/a2ui_agent/lib/src/parser/response_part.dart +++ b/dart/a2ui_agent/lib/src/parser/response_part.dart @@ -15,18 +15,15 @@ import 'package:a2ui_core/a2ui_core.dart'; import 'package:collection/collection.dart'; -/// A slice of an LLM response. -/// -/// A parsed response is a list of [TextPart] and [A2uiPart]; an unwrapped but -/// not yet compiled response is a list of [RawResponsePart], whose `part` is a -/// [TextPart] or a [RawA2uiPart]. +/// A slice of an LLM response: [TextPart] and [A2uiPart] once parsed, +/// [RawResponsePart] before compilation. sealed class ResponsePart { const ResponsePart(); } -/// Conversational text extracted from an LLM response. +/// Conversational text from an LLM response. final class TextPart extends ResponsePart { - /// The text content intended for user display. + /// The text for user display. final String text; const TextPart(this.text); @@ -41,9 +38,9 @@ final class TextPart extends ResponsePart { String toString() => 'TextPart(${_ellipsize(text)})'; } -/// An uncompiled A2UI content block extracted from an LLM response. +/// An uncompiled A2UI block from an LLM response. final class RawA2uiPart extends ResponsePart { - /// The raw uncompiled format content (raw JSON, DSL, or XML). + /// The uncompiled format content (JSON, DSL, or XML). final String a2uiRaw; const RawA2uiPart(this.a2uiRaw); @@ -59,9 +56,9 @@ final class RawA2uiPart extends ResponsePart { String toString() => 'RawA2uiPart(${_ellipsize(a2uiRaw)})'; } -/// Compiled A2UI payload messages ready to deliver to a renderer. +/// Compiled A2UI messages, ready for a renderer. final class A2uiPart extends ResponsePart { - /// The validated messages to deliver to client renderers. + /// The validated messages. final List a2ui; const A2uiPart(this.a2ui); @@ -84,14 +81,12 @@ final class A2uiPart extends ResponsePart { /// An uncompiled token from an LLM response stream. /// -/// [part] is a [TextPart] or a [RawA2uiPart]; passing any other kind of -/// [ResponsePart] throws [ArgumentError]. +/// Throws [ArgumentError] unless [part] is a [TextPart] or a [RawA2uiPart]. class RawResponsePart { - /// The underlying content: conversational [TextPart] or uncompiled - /// [RawA2uiPart]. + /// The content: a [TextPart] or a [RawA2uiPart]. final ResponsePart part; - /// Whether this part is complete, that is not truncated mid-stream. + /// Whether this part is complete, not truncated mid-stream. final bool isFinal; RawResponsePart(this.part, {this.isFinal = true}) { diff --git a/dart/a2ui_agent/lib/src/processor/catalog_config.dart b/dart/a2ui_agent/lib/src/processor/catalog_config.dart index c70f6c3d8d..fa1ef350b3 100644 --- a/dart/a2ui_agent/lib/src/processor/catalog_config.dart +++ b/dart/a2ui_agent/lib/src/processor/catalog_config.dart @@ -17,14 +17,12 @@ import 'package:a2ui_core/a2ui_core.dart'; import '../catalog_transformers/base.dart'; import 'catalog_providers.dart'; -/// A [CatalogConfig] over schema-only catalogs. -/// -/// This is the shape agents use, since [Catalog.fromJson] produces schema-only -/// catalogs and an agent never evaluates a catalog function. +/// A [CatalogConfig] over schema-only catalogs, the shape agents use: an +/// agent never evaluates a catalog function. typedef SchemaCatalogConfig = CatalogConfig; -/// Associates a catalog with the transformations applied to it before it is -/// used for prompting or validation. +/// Pairs a catalog with the transformers applied before prompting or +/// validation. class CatalogConfig { /// The pristine catalog, as loaded from a [CatalogProvider]. final Catalog catalog; @@ -52,7 +50,7 @@ class CatalogConfig { transformers: transformers, ); - /// The catalog after applying every configured transformer in order. + /// The catalog with every transformer applied, in order. Catalog get transformedCatalog { Catalog current = catalog; for (final CatalogTransformer transformer in transformers) { diff --git a/dart/a2ui_agent/lib/src/processor/catalog_providers.dart b/dart/a2ui_agent/lib/src/processor/catalog_providers.dart index 8e471a282b..ef5fad953d 100644 --- a/dart/a2ui_agent/lib/src/processor/catalog_providers.dart +++ b/dart/a2ui_agent/lib/src/processor/catalog_providers.dart @@ -19,9 +19,8 @@ import 'package:a2ui_core/a2ui_core.dart'; /// Loads a catalog definition from some backing store. /// -/// There is deliberately no bundled provider: nothing needs to ship with the -/// SDK, because the catalogs an agent supports are either read from disk, -/// supplied in memory, or sent inline by the renderer. +/// There is deliberately no bundled provider: an agent's catalogs come from +/// disk, from memory, or inline from the renderer. abstract class CatalogProvider { const CatalogProvider(); @@ -49,10 +48,9 @@ class FileSystemCatalogProvider /// Reads and parses the catalog file. /// - /// Throws [A2uiCatalogError] if the file is missing or is not a JSON object, - /// or if [catalogId] conflicts with the document. Throws - /// [A2uiValidationError] if the document declares an unsupported protocol - /// version, or one conflicting with [protocolVersion]. + /// Throws [A2uiCatalogError] if the file is missing, is not a JSON object, + /// or conflicts with [catalogId], and [A2uiValidationError] if its version + /// is unsupported or conflicts with [protocolVersion]. @override Catalog load() { final file = File(path); @@ -98,9 +96,9 @@ class InMemoryCatalogProvider /// Parses the in-memory schema. /// - /// Throws [A2uiCatalogError] if the schema is malformed or if [catalogId] - /// conflicts with it, and [A2uiValidationError] if it declares an - /// unsupported protocol version or one conflicting with [protocolVersion]. + /// Throws [A2uiCatalogError] if the schema is malformed or conflicts with + /// [catalogId], and [A2uiValidationError] if its version is unsupported or + /// conflicts with [protocolVersion]. @override Catalog load() => Catalog.fromJson( catalog, diff --git a/dart/a2ui_agent/lib/src/processor/generator.dart b/dart/a2ui_agent/lib/src/processor/generator.dart index 90897c7292..e5518a800b 100644 --- a/dart/a2ui_agent/lib/src/processor/generator.dart +++ b/dart/a2ui_agent/lib/src/processor/generator.dart @@ -19,18 +19,16 @@ import '../inference_formats/direct_json/format.dart'; import 'catalog_config.dart'; import 'processor.dart'; -/// The agent-level, long-lived entry point to the A2UI agent SDK. +/// The long-lived entry point to the agent SDK. /// -/// Created once at agent startup with every catalog the agent can generate UI -/// for. Each incoming request produces an [A2uiRequestProcessor] pre-negotiated -/// against that renderer's capabilities. +/// Created once at startup with every catalog the agent supports; each request +/// produces an [A2uiRequestProcessor] negotiated for one renderer. class A2uiGenerator { /// Every catalog configuration this agent supports, in preference order. final List> catalogs; - /// Few-shot example turns shared across sessions. - /// - /// Validated against the negotiated catalogs by [createProcessor]. + /// Few-shot turns, validated against the negotiated catalogs by + /// [createProcessor]. final Map>? examples; /// The format factory used when no per-request override is supplied. @@ -49,10 +47,10 @@ class A2uiGenerator { /// Creates a processor bound to a renderer's declared capabilities. /// - /// Throws [A2uiCatalogError] if no registered catalog matches the renderer, - /// and [A2uiValidationError] if the capabilities declare no entry for the - /// protocol version this SDK implements, or if [examples] are not valid for - /// the negotiated catalogs. + /// Throws [A2uiCatalogError] if no registered catalog matches, and + /// [A2uiValidationError] if the capabilities declare no entry for the + /// version this SDK implements, or if [examples] are invalid for the + /// negotiated catalogs. A2uiRequestProcessor createProcessor( A2uiRendererCapabilities rendererCapabilities, { InferenceFormatFactory? inferenceFormatFactory, @@ -60,9 +58,8 @@ class A2uiGenerator { throw UnimplementedError('A2uiGenerator.createProcessor'); } - /// The capabilities this agent advertises to renderers. - /// - /// Mirrors `specification/v0_9_1/json/server_capabilities.json`. + /// The capabilities this agent advertises, mirroring + /// `specification/v0_9_1/json/server_capabilities.json`. Map get agentCapabilities => { 'a2uiVersions': [A2uiProtocolVersion.v0_9.jsonValue], 'supportedCatalogIds': [ diff --git a/dart/a2ui_agent/lib/src/processor/processor.dart b/dart/a2ui_agent/lib/src/processor/processor.dart index 334f2c0079..dfe7c84b91 100644 --- a/dart/a2ui_agent/lib/src/processor/processor.dart +++ b/dart/a2ui_agent/lib/src/processor/processor.dart @@ -19,12 +19,10 @@ import '../inference_formats/direct_json/format.dart'; import '../parser/parser.dart'; import '../parser/response_part.dart'; -/// The per-request facade: holds the catalogs negotiated for one renderer, -/// renders the system prompt snippet, creates turn-scoped parsers, and -/// validates model output. +/// The per-request facade: negotiated catalogs, prompt snippet, parsers and +/// validation for one renderer. /// -/// Obtained from `A2uiGenerator.createProcessor` rather than constructed -/// directly in most agents. +/// Usually obtained from `A2uiGenerator.createProcessor`. class A2uiRequestProcessor { /// The negotiated catalogs active for this request. final List> activeCatalogs; @@ -49,9 +47,8 @@ class A2uiRequestProcessor { ), validator = validator ?? A2uiValidator(catalogs: activeCatalogs); - /// The format-specific system prompt instruction snippet. - /// - /// The agent prepends its own role and workflow preamble. + /// The format-specific prompt snippet; the agent prepends its own + /// preamble. String get promptSnippet => format.promptGenerator.generate(); /// Creates a parser scoped to a single LLM turn. @@ -59,18 +56,18 @@ class A2uiRequestProcessor { /// Parses and validates a complete LLM response. /// - /// Throws [A2uiParseError] if the response holds no well-formed payload - /// block, [A2uiCompileError] if a block cannot be compiled, and - /// [A2uiValidationError] if the compiled payload is invalid for - /// [activeCatalogs] or declares an unsupported protocol version. + /// Throws [A2uiParseError] if it holds no well-formed payload block, + /// [A2uiCompileError] if a block cannot be compiled, and + /// [A2uiValidationError] if the payload is invalid for [activeCatalogs] or + /// declares an unsupported version. List parseResponse(String content) { throw UnimplementedError('A2uiRequestProcessor.parseResponse'); } /// Validates few-shot [examples] against [activeCatalogs]. /// - /// Throws [A2uiValidationError] if an example uses components or structures - /// the active catalogs do not support. + /// Throws [A2uiValidationError] if an example uses anything they do not + /// support. Future validateExamples() { throw UnimplementedError('A2uiRequestProcessor.validateExamples'); } diff --git a/dart/a2ui_agent/lib/src/prompt/generator.dart b/dart/a2ui_agent/lib/src/prompt/generator.dart index c606a92716..d96348aee3 100644 --- a/dart/a2ui_agent/lib/src/prompt/generator.dart +++ b/dart/a2ui_agent/lib/src/prompt/generator.dart @@ -16,20 +16,17 @@ import 'package:a2ui_core/a2ui_core.dart'; /// Builds the format-specific portion of an agent's system instructions. /// -/// The caller owns the surrounding prompt: role and workflow preambles are -/// prepended and any suffix appended by the agent, not by this generator. +/// The agent owns the surrounding preamble and suffix. abstract class PromptGenerator { - /// The active catalogs to describe in the system instructions. + /// The catalogs to describe. final List> catalogs; - /// Few-shot example turns, keyed by a description of the turn. - /// - /// Each value is the A2UI payload the model is expected to produce for that - /// turn. + /// Few-shot turns, keyed by description, valued by the payload the model + /// is expected to produce. final Map>? examples; PromptGenerator(this.catalogs, {this.examples}); - /// Renders the format-specific system instructions and catalog schemas. + /// Renders the instructions and catalog schemas. String generate(); } diff --git a/dart/a2ui_agent/lib/src/utils/catalog_resolver.dart b/dart/a2ui_agent/lib/src/utils/catalog_resolver.dart index 29e650bc4c..ffbb1356ca 100644 --- a/dart/a2ui_agent/lib/src/utils/catalog_resolver.dart +++ b/dart/a2ui_agent/lib/src/utils/catalog_resolver.dart @@ -18,15 +18,13 @@ import '../processor/catalog_config.dart'; /// Negotiates renderer capabilities against the catalogs an agent supports. /// -/// Returns the transformed catalogs the agent should prompt and validate -/// against for this session, in agent preference order. When the renderer -/// declares no supported catalog ids, the agent's first registered catalog is -/// used. When [acceptsInlineCatalogs] is true, catalogs the renderer supplies -/// inline are also eligible. +/// Returns the transformed catalogs to prompt and validate against, in agent +/// preference order, falling back to the first registered one. Inline catalogs +/// count only when [acceptsInlineCatalogs] is true. /// -/// Throws [A2uiCatalogError] if no registered catalog matches the renderer's -/// capabilities. Throws [A2uiValidationError] if [rendererCapabilities] -/// declares no capabilities for the protocol version this SDK implements. +/// Throws [A2uiCatalogError] if no registered catalog matches, and +/// [A2uiValidationError] if [rendererCapabilities] declares nothing for the +/// protocol version this SDK implements. List> resolveCatalogs( List> catalogs, diff --git a/dart/a2ui_agent/test/conformance/agent_conformance_test.dart b/dart/a2ui_agent/test/conformance/agent_conformance_test.dart index 8cbb2ffecc..ddc1e2b699 100644 --- a/dart/a2ui_agent/test/conformance/agent_conformance_test.dart +++ b/dart/a2ui_agent/test/conformance/agent_conformance_test.dart @@ -20,8 +20,7 @@ import 'conformance_harness.dart'; /// Runs the shared conformance suites that apply to the agent SDK. /// -/// Cases targeting protocol versions this SDK does not implement are skipped, -/// as are cases covering behaviour that is still stubbed. Each skip states its +/// Cases for unimplemented versions or stubbed behaviour are skipped with a /// reason, so the suite doubles as the implementation checklist. void main() { _runSuite('core/catalog.yaml'); diff --git a/dart/a2ui_agent/test/conformance/conformance_harness.dart b/dart/a2ui_agent/test/conformance/conformance_harness.dart index 4b69e855aa..1ddd7df072 100644 --- a/dart/a2ui_agent/test/conformance/conformance_harness.dart +++ b/dart/a2ui_agent/test/conformance/conformance_harness.dart @@ -18,17 +18,14 @@ import 'dart:io'; import 'package:path/path.dart' as p; import 'package:yaml/yaml.dart'; -/// Resolves a path from a conformance case against the `conformance/` -/// directory. -/// -/// Cases reference published specification artifacts with paths such as +/// Resolves a case's path against the `conformance/` directory, for example /// `../specification/v0_9_1/catalogs/basic/catalog.json`. String resolveConformancePath(String relativePath) => p.normalize(p.join(_conformanceRoot(), relativePath)); String _conformanceRoot() { - // Walk up from the current directory until the conformance suite is found, - // so the harness works from the package directory and the workspace root. + // Walk up, so the harness works from the package directory and the + // workspace root. Directory dir = Directory.current; while (true) { final candidate = Directory(p.join(dir.path, 'conformance')); @@ -63,10 +60,8 @@ List> loadConformanceSuite(String suite) { ]; } -/// Converts YAML nodes into plain Dart maps, lists and scalars. -/// -/// The state models under test are typed against `Map` and -/// `List`, which `YamlMap` and `YamlList` do not satisfy. +/// Converts YAML nodes into plain Dart maps, lists and scalars, which +/// `YamlMap` and `YamlList` are not. Object? normalizeYaml(Object? node) { if (node is YamlMap || node is Map) { return { diff --git a/dart/a2ui_agent/test/e2e/minimal_snippet.dart b/dart/a2ui_agent/test/e2e/minimal_snippet.dart index 635f21a440..5cf96f6a95 100644 --- a/dart/a2ui_agent/test/e2e/minimal_snippet.dart +++ b/dart/a2ui_agent/test/e2e/minimal_snippet.dart @@ -17,8 +17,7 @@ import 'package:a2ui_core/a2ui_core.dart'; import '../test_catalogs.dart'; -/// What one run of [userSnippet] produced, so a test can assert on each step -/// of the blueprint's example rather than only on its final output. +/// What one run of [userSnippet] produced, step by step. class UserSnippetResult { /// Step 1: the long-lived generator created at agent startup. final A2uiGenerator generator; @@ -26,19 +25,16 @@ class UserSnippetResult { /// Step 2: the processor negotiated for this request. final A2uiRequestProcessor processor; - /// Step 3: the snippet the agent prepends its own preamble to before - /// calling the model. + /// Step 3: the snippet the agent prepends its preamble to. final String promptSnippet; /// Step 3: what the model returned. final String llmOutput; - /// Step 4: the parsed, validated response, with text and A2UI blocks in the - /// order the model emitted them. + /// Step 4: the parsed response, in the order the model emitted it. final List responseParts; - /// Step 5: the A2UI messages delivered to the renderer, flattened out of - /// [responseParts] in order. + /// Step 5: the messages for the renderer, flattened from [responseParts]. final List a2uiPayload; const UserSnippetResult({ @@ -58,17 +54,15 @@ UserSnippetResult userSnippet({ required String Function(String promptSnippet) callLlm, Map>? examples, }) { - // 1. Agent startup: initialize the long-lived A2uiGenerator with the agent's - // catalog. Prompt examples passed here are validated during processor - // creation (createProcessor) against the active negotiated catalogs, and - // an example using components or structures the active catalog does not - // support raises an error. + // 1. Agent startup: initialize the long-lived A2uiGenerator with the + // agent's catalog. Examples passed here are validated against the + // negotiated catalogs by createProcessor. final generator = A2uiGenerator( catalogs: [CatalogConfig(basicCatalog())], examples: examples, ); - // 2. In the request handler: retrieve the processor pre-negotiated against + // 2. In the request handler: retrieve the processor pre-negotiated for // the renderer's capabilities. final A2uiRequestProcessor processor = generator.createProcessor(rendererCapabilities); diff --git a/dart/a2ui_agent/test/e2e/minimal_snippet_test.dart b/dart/a2ui_agent/test/e2e/minimal_snippet_test.dart index 6768cd9a59..9e225c3583 100644 --- a/dart/a2ui_agent/test/e2e/minimal_snippet_test.dart +++ b/dart/a2ui_agent/test/e2e/minimal_snippet_test.dart @@ -20,18 +20,16 @@ import '../conformance/conformance_harness.dart'; import '../test_catalogs.dart'; import 'minimal_snippet.dart'; -/// Marks the assertions that cannot pass until capability negotiation, prompt -/// generation and response parsing are implemented. Remove the skip alongside -/// those implementations. +/// Marks assertions needing negotiation, prompting and response parsing. const String pendingSnippet = 'The blueprint snippet cannot run end to end yet.'; -/// Exercises [userSnippet], the transcription of the "Code Example" section of +/// Exercises [userSnippet], the "Code Example" section of /// `blueprints/modules/a2ui_agent.blueprint.md`. /// -/// The model response and the expected parse come from -/// `conformance/agent/request_processor.yaml`, so the published example is -/// measured against the same data as every other SDK. +/// Its inputs and expected parse come from +/// `conformance/agent/request_processor.yaml`, the data every SDK is measured +/// against. void main() { final List> cases = loadConformanceSuite( 'agent/request_processor.yaml', @@ -63,22 +61,20 @@ void main() { final promptsSeen = []; final UserSnippetResult result = run(promptsSeen: promptsSeen); - // Step 2: the basic catalog is negotiated, because the renderer - // declared support for it. + // Step 2: the renderer declared the basic catalog, so it is + // negotiated. expect(result.processor.activeCatalogs.map((c) => c.id), [ basicCatalogId, ]); - // Step 3: the model is called exactly once, with the prompt snippet the - // processor rendered. + // Step 3: the model is called once, with the rendered snippet. expect(promptsSeen, [result.promptSnippet]); for (final Object? fragment in data['expect_prompt_contains']! as List) { expect(result.promptSnippet, contains(fragment! as String)); } expect(result.llmOutput, llmResponse); - // Step 4: text and A2UI blocks come back in the order the model emitted - // them. + // Step 4: parts come back in the order the model emitted them. final expected = data['expect']! as List; expect(result.responseParts, hasLength(expected.length)); for (var i = 0; i < expected.length; i++) { @@ -101,7 +97,7 @@ void main() { } } - // Step 5: what is handed to the renderer is one flat, ordered payload. + // Step 5: the renderer gets one flat, ordered payload. expect(result.a2uiPayload.first, isA()); expect( (result.a2uiPayload.first as CreateSurfaceMessage).catalogId, @@ -141,8 +137,8 @@ void main() { group('blueprint code example inputs', () { test('the agent registers the catalog the example loads', () { - // Step 1 of the example stands on its own: the generator is constructed - // and advertises the catalog before anything is negotiated. + // Step 1 stands on its own: the generator advertises its catalog + // before anything is negotiated. final generator = A2uiGenerator( catalogs: [CatalogConfig(basicCatalog())], ); diff --git a/dart/a2ui_agent/test/e2e/primary_use_case_test.dart b/dart/a2ui_agent/test/e2e/primary_use_case_test.dart index 932c16248c..127a8e9f1a 100644 --- a/dart/a2ui_agent/test/e2e/primary_use_case_test.dart +++ b/dart/a2ui_agent/test/e2e/primary_use_case_test.dart @@ -19,19 +19,16 @@ import 'package:test/test.dart'; import '../conformance/conformance_harness.dart'; import '../test_catalogs.dart'; -/// Marks the end-to-end walkthrough, which cannot pass until capability -/// negotiation, prompt generation and response parsing are implemented. Remove -/// the skip alongside those implementations. +/// Marks assertions needing negotiation, prompting and response parsing. const String pendingEndToEnd = 'The agent turn is not implemented end to end yet.'; -/// The end-to-end agent turn described in section 5 of +/// The agent turn from section 5 of /// `blueprints/modules/a2ui_agent.blueprint.md`. /// -/// The turn's inputs and expected outputs come from -/// `conformance/agent/request_processor.yaml`, so this walkthrough and the -/// other SDKs are measured against the same data. The model is stubbed: this -/// exercises the SDK, not an LLM. +/// Inputs and expected outputs come from +/// `conformance/agent/request_processor.yaml`, so every SDK is measured +/// against the same data. The model is stubbed. void main() { final List> cases = loadConformanceSuite( 'agent/request_processor.yaml', @@ -52,8 +49,8 @@ void main() { }); test('negotiates, prompts, and parses a full turn', () { - // 1. Agent startup: register every catalog the agent can generate UI for, - // narrowed to the components and functions this agent uses. + // 1. Agent startup: register every catalog, narrowed to the components + // and functions this agent uses. final generator = A2uiGenerator( catalogs: [ CatalogConfig( @@ -91,7 +88,7 @@ void main() { // 4. Inference: a canned response stands in for the model. final modelOutput = args['llm_response']! as String; - // 5. Parsing and validation: what the agent delivers to the renderer. + // 5. Parsing and validation: what goes to the renderer. final List parts = processor.parseResponse(modelOutput); final expected = data['expect']! as List; @@ -121,8 +118,8 @@ void main() { }, skip: pendingEndToEnd); test('delivers a surface the renderer can render', () { - // The parsed payload must reconstruct into live surface state, which is - // what the renderer ultimately does with it. + // The payload must reconstruct into live surface state, which is what + // the renderer does with it. final generator = A2uiGenerator( catalogs: [CatalogConfig(basicCatalog())], ); diff --git a/dart/a2ui_agent/test/inference_formats/direct_json_format_test.dart b/dart/a2ui_agent/test/inference_formats/direct_json_format_test.dart index 84bfac4387..0a81e9a136 100644 --- a/dart/a2ui_agent/test/inference_formats/direct_json_format_test.dart +++ b/dart/a2ui_agent/test/inference_formats/direct_json_format_test.dart @@ -18,8 +18,7 @@ import 'package:test/test.dart'; import '../test_catalogs.dart'; -/// Marks a test describing behaviour the DIRECT_JSON prompt generator does not -/// implement yet. Remove the skip alongside the implementation. +/// Marks behaviour the DIRECT_JSON prompt generator does not implement yet. const String pendingPromptGenerator = 'DirectJsonPromptGenerator.generate is not implemented yet.'; diff --git a/dart/a2ui_agent/test/inference_formats/direct_json_parser_test.dart b/dart/a2ui_agent/test/inference_formats/direct_json_parser_test.dart index 14b2c58b0c..1fff4126e8 100644 --- a/dart/a2ui_agent/test/inference_formats/direct_json_parser_test.dart +++ b/dart/a2ui_agent/test/inference_formats/direct_json_parser_test.dart @@ -18,8 +18,7 @@ import 'package:test/test.dart'; import '../test_catalogs.dart'; -/// Marks a test describing behaviour the DIRECT_JSON parser does not implement -/// yet. Remove the skip alongside the implementation. +/// Marks behaviour the DIRECT_JSON parser does not implement yet. const String pendingParser = 'DirectJsonParser is not implemented yet.'; DirectJsonParser parser({ diff --git a/dart/a2ui_agent/test/inference_formats/direct_json_streaming_test.dart b/dart/a2ui_agent/test/inference_formats/direct_json_streaming_test.dart index 2caa4deaa8..9de499fbfb 100644 --- a/dart/a2ui_agent/test/inference_formats/direct_json_streaming_test.dart +++ b/dart/a2ui_agent/test/inference_formats/direct_json_streaming_test.dart @@ -18,8 +18,7 @@ import 'package:test/test.dart'; import '../test_catalogs.dart'; -/// Marks a test describing behaviour the DIRECT_JSON stream processor does not -/// implement yet. Remove the skip alongside the implementation. +/// Marks behaviour the DIRECT_JSON stream processor does not implement yet. const String pendingStreaming = 'DirectJsonStreamProcessor is not implemented yet.'; diff --git a/dart/a2ui_agent/test/inference_formats/express_test.dart b/dart/a2ui_agent/test/inference_formats/express_test.dart index fa692606ab..8b49669f08 100644 --- a/dart/a2ui_agent/test/inference_formats/express_test.dart +++ b/dart/a2ui_agent/test/inference_formats/express_test.dart @@ -18,11 +18,8 @@ import 'package:test/test.dart'; import '../test_catalogs.dart'; -/// Marks a test describing behaviour the EXPRESS format does not implement yet. -/// -/// The format is declared so that agents can select it through -/// [InferenceFormatFactory], but the grammar, compiler and decompiler are still -/// to be written. Remove the skip alongside the implementation. +/// Marks behaviour the EXPRESS format does not implement yet: it is selectable +/// through [InferenceFormatFactory], but its grammar is still to be written. const String pendingExpress = 'The EXPRESS format is not implemented yet.'; const String expressSource = 'createSurface(s1, "$basicCatalogId")'; diff --git a/dart/a2ui_agent/test/parser/parser_test.dart b/dart/a2ui_agent/test/parser/parser_test.dart index d5bc226a8b..9e523a6a7e 100644 --- a/dart/a2ui_agent/test/parser/parser_test.dart +++ b/dart/a2ui_agent/test/parser/parser_test.dart @@ -16,10 +16,8 @@ import 'package:a2ui_agent/a2ui_agent.dart'; import 'package:a2ui_core/a2ui_core.dart'; import 'package:test/test.dart'; -/// A parser that records what the base class asks of it. -/// -/// Exercises `Parser.parseResponse`, the one behaviour the abstract class -/// supplies rather than delegating to a format implementation. +/// A parser that records what the base class asks of it, exercising +/// `Parser.parseResponse` — the one behaviour it does not delegate. class RecordingParser extends Parser { final List unwrapResult; final List compiled = []; diff --git a/dart/a2ui_agent/test/processor/generator_test.dart b/dart/a2ui_agent/test/processor/generator_test.dart index a6854c40d9..041395e355 100644 --- a/dart/a2ui_agent/test/processor/generator_test.dart +++ b/dart/a2ui_agent/test/processor/generator_test.dart @@ -18,8 +18,7 @@ import 'package:test/test.dart'; import '../test_catalogs.dart'; -/// Marks a test describing behaviour capability negotiation does not implement -/// yet. Remove the skip alongside the implementation. +/// Marks behaviour capability negotiation does not implement yet. const String pendingNegotiation = 'Capability negotiation is not implemented yet.'; diff --git a/dart/a2ui_agent/test/processor/processor_test.dart b/dart/a2ui_agent/test/processor/processor_test.dart index 9a3cf8cf83..fea27fd888 100644 --- a/dart/a2ui_agent/test/processor/processor_test.dart +++ b/dart/a2ui_agent/test/processor/processor_test.dart @@ -18,13 +18,11 @@ import 'package:test/test.dart'; import '../test_catalogs.dart'; -/// Marks a test describing behaviour the request processor does not implement -/// yet. Remove the skip alongside the implementation. +/// Marks behaviour the request processor does not implement yet. const String pendingProcessor = 'A2uiRequestProcessor.parseResponse is not implemented yet.'; -/// Marks a test describing prompt rendering, which the DIRECT_JSON prompt -/// generator does not implement yet. +/// Marks prompt rendering, which the DIRECT_JSON generator does not do yet. const String pendingPrompt = 'DirectJsonPromptGenerator.generate is not implemented yet.'; diff --git a/dart/a2ui_agent/test/test_catalogs.dart b/dart/a2ui_agent/test/test_catalogs.dart index 966393cd4f..1b6d6e8bfa 100644 --- a/dart/a2ui_agent/test/test_catalogs.dart +++ b/dart/a2ui_agent/test/test_catalogs.dart @@ -21,9 +21,8 @@ import 'conformance/conformance_harness.dart'; /// The path of the published basic catalog, relative to `conformance/`. /// -/// Tests are measured against the specification's catalog rather than any -/// catalog implemented inside an SDK, so every implementation is held to the -/// same contract. +/// Tests measure the SDK against the specification's catalog, not one of its +/// own. const String basicCatalogPath = '../specification/v0_9_1/catalogs/basic/catalog.json'; @@ -46,7 +45,7 @@ SchemaCatalog basicCatalog() => Catalog.fromJson(basicCatalogJson()); A2uiRendererCapabilities basicCatalogCapabilities() => A2uiRendererCapabilities.forCatalogIds([basicCatalogId]); -/// A small catalog used where the full basic catalog would obscure the case. +/// A small catalog, where the basic catalog would obscure the case. SchemaCatalog smallCatalog({String id = 'https://example.com/small.json'}) => Catalog.fromJson({ 'catalogId': id, diff --git a/dart/a2ui_agent/test/utils/catalog_resolver_test.dart b/dart/a2ui_agent/test/utils/catalog_resolver_test.dart index f1e948884c..e1dd3d7c39 100644 --- a/dart/a2ui_agent/test/utils/catalog_resolver_test.dart +++ b/dart/a2ui_agent/test/utils/catalog_resolver_test.dart @@ -18,8 +18,7 @@ import 'package:test/test.dart'; import '../test_catalogs.dart'; -/// Marks a test describing behaviour `resolveCatalogs` does not implement yet. -/// Remove the skip alongside the implementation. +/// Marks behaviour `resolveCatalogs` does not implement yet. const String pendingResolver = 'resolveCatalogs is not implemented yet.'; const String smallCatalogId = 'https://example.com/small.json'; diff --git a/dart/a2ui_core/lib/a2ui_core.dart b/dart/a2ui_core/lib/a2ui_core.dart index bc9792180e..010074ab99 100644 --- a/dart/a2ui_core/lib/a2ui_core.dart +++ b/dart/a2ui_core/lib/a2ui_core.dart @@ -15,7 +15,7 @@ /// The A2UI core SDK: protocol messages, catalogs, reactive state models and /// payload validation, shared by renderers and agents. /// -/// This SDK implements version 0.9 of the A2UI protocol. +/// Implements protocol v0.9. library; // Protocol models. diff --git a/dart/a2ui_core/lib/src/core/catalog.dart b/dart/a2ui_core/lib/src/core/catalog.dart index ed289f5add..d1c6871fd5 100644 --- a/dart/a2ui_core/lib/src/core/catalog.dart +++ b/dart/a2ui_core/lib/src/core/catalog.dart @@ -47,9 +47,8 @@ enum A2uiReturnType { /// A definition of a UI function's API. /// -/// This declares a function's signature only. Renderers that can also evaluate -/// the function supply a [FunctionImplementation] instead; agents, which only -/// need the signature to prompt and validate, use plain [FunctionApi] values. +/// Declares a signature only. Renderers that also evaluate the function supply +/// a [FunctionImplementation] instead. abstract class FunctionApi { String get name; A2uiReturnType get returnType; @@ -66,10 +65,10 @@ abstract class FunctionImplementation extends FunctionApi { ]); } -/// A [ComponentApi] backed directly by a catalog document's JSON schema. +/// A [ComponentApi] backed by a catalog document's JSON schema. /// -/// Produced by [Catalog.fromJson]. Carries no rendering behaviour, which makes -/// it the component representation used on the agent side. +/// Produced by [Catalog.fromJson]; carries no rendering behaviour, so it is +/// the agent-side representation. class CatalogComponent implements ComponentApi { @override final String name; @@ -80,10 +79,10 @@ class CatalogComponent implements ComponentApi { CatalogComponent({required this.name, required this.schema}); } -/// A [FunctionApi] backed directly by a catalog document's JSON schema. +/// A [FunctionApi] backed by a catalog document's JSON schema. /// -/// Produced by [Catalog.fromJson]. Declares a signature but cannot be -/// evaluated; see [FunctionImplementation] for the renderer-side counterpart. +/// Produced by [Catalog.fromJson]; declares a signature but cannot be +/// evaluated. See [FunctionImplementation] for the renderer-side counterpart. class CatalogFunction implements FunctionApi { @override final String name; @@ -94,8 +93,7 @@ class CatalogFunction implements FunctionApi { @override final Schema argumentSchema; - /// A human readable description of the function, when the catalog declares - /// one. + /// The function's description, when the catalog declares one. final String? description; CatalogFunction({ @@ -108,20 +106,16 @@ class CatalogFunction implements FunctionApi { /// A catalog whose components and functions carry schemas only. /// -/// This is the shape [Catalog.fromJson] produces and the shape agents work -/// with, since an agent prompts and validates against signatures but never -/// evaluates a function. +/// What [Catalog.fromJson] produces, and what agents work with: they prompt +/// and validate against signatures but never evaluate a function. typedef SchemaCatalog = Catalog; /// A collection of available components and functions. /// -/// [C] is the component representation and [F] the function representation. -/// Renderers parameterise this with [FunctionImplementation] so functions can -/// be evaluated locally; agents parameterise it with [CatalogFunction], which -/// declares a signature only. +/// [C] is the component representation and [F] the function representation: +/// renderers use [FunctionImplementation], agents [CatalogFunction]. class Catalog { - /// The catalog id, as declared by the `catalogId` field of a catalog - /// document. + /// The catalog id, from the document's `catalogId` field. final String id; /// The protocol version this catalog conforms to. @@ -131,8 +125,7 @@ class Catalog { final Map functions; final Schema? themeSchema; - /// The catalog document this catalog was parsed from, when it was built by - /// [Catalog.fromJson]. + /// The document this catalog was parsed from, if any. final Map? _sourceSchema; Catalog({ @@ -148,16 +141,13 @@ class Catalog { /// Parses a catalog document into a schema-only [Catalog]. /// - /// Accepts both the catalog document form used under - /// `specification/*/catalogs/*/catalog.json`, where `functions` is a map of - /// name to JSON schema, and the inline catalog form used in renderer - /// capabilities, where `functions` is a list of function definitions. + /// Accepts both forms of `functions`: the map of name to JSON schema used by + /// published catalog documents, and the list of definitions used by inline + /// catalogs in renderer capabilities. /// - /// Throws [A2uiCatalogError] if the document is malformed or if - /// [expectedCatalogId] conflicts with the document's `catalogId`. Throws - /// [A2uiValidationError] if the document declares a protocol version this - /// SDK does not implement, or one that conflicts with - /// [expectedProtocolVersion]. + /// Throws [A2uiCatalogError] if the document is malformed or conflicts with + /// [expectedCatalogId], and [A2uiValidationError] if its version is + /// unsupported or conflicts with [expectedProtocolVersion]. static SchemaCatalog fromJson( Map json, { A2uiProtocolVersion? expectedProtocolVersion, @@ -177,8 +167,8 @@ class Catalog { ); } - // `protocolVersion` is not declared by catalog documents before v1.0, so - // an absent value means the version this SDK implements. + // Catalog documents before v1.0 do not declare `protocolVersion`, so an + // absent value means the version this SDK implements. final A2uiProtocolVersion version = json.containsKey('protocolVersion') ? A2uiProtocolVersion.fromJson(json['protocolVersion'], details: json) : A2uiProtocolVersion.v0_9; @@ -224,8 +214,7 @@ class Catalog { static List _parseFunctions(Object? raw, String catalogId) { if (raw == null) return const []; - // Inline catalog form: a list of {name, description, parameters, - // returnType} definitions. + // Inline form: {name, description, parameters, returnType} definitions. if (raw is List) { return [ for (final Object? entry in raw) @@ -243,9 +232,9 @@ class Catalog { ]; } - // Catalog document form: a map of name to the function's JSON schema, with - // the argument schema under `properties/args` and the declared return type - // under `properties/returnType/const`. + // Document form: name to JSON schema, with arguments under + // `properties/args` and the return type under + // `properties/returnType/const`. if (raw is! Map) { throw A2uiCatalogError( "Catalog 'functions' must be an object or a list of definitions.", @@ -291,11 +280,10 @@ class Catalog { /// The catalog document for this catalog, as JSON. /// - /// When the catalog was built by [Catalog.fromJson], the original document is - /// returned with its `components`, `functions` and `$defs` narrowed to the - /// entries this catalog actually holds, so that a pruned catalog renders a - /// pruned document. Otherwise a document is synthesised from the component - /// and function schemas. + /// A parsed catalog returns its source document with `components`, + /// `functions` and `$defs` narrowed to what it still holds, so a pruned + /// catalog renders a pruned document. Otherwise the document is + /// synthesised from the schemas. Map get catalogSchema { final Map? source = _sourceSchema; if (source == null) return _synthesizeSchema(); @@ -329,8 +317,7 @@ class Catalog { return document; } - /// Drops `$defs//oneOf` entries whose `$ref` points at an entry that is - /// no longer part of this catalog. + /// Drops `$defs//oneOf` entries whose `$ref` names a pruned entry. static void _narrowAnyOneOf( Map document, String defName, @@ -374,10 +361,10 @@ class Catalog { if (themeSchema != null) r'$defs': {'theme': themeSchema!.value}, }; - /// Returns a copy of this catalog with the given components and functions. + /// A copy of this catalog with the given components and functions. /// - /// Used by catalog transformers, which narrow a pristine catalog before it is - /// rendered into a prompt or used for validation. + /// Used by catalog transformers to narrow a catalog before prompting or + /// validation. Catalog copyWith({ Iterable? components, Iterable? functions, diff --git a/dart/a2ui_core/lib/src/core/data_model.dart b/dart/a2ui_core/lib/src/core/data_model.dart index 54e6c36ee6..ef121ba523 100644 --- a/dart/a2ui_core/lib/src/core/data_model.dart +++ b/dart/a2ui_core/lib/src/core/data_model.dart @@ -133,9 +133,8 @@ class DataModel { } current[index] = value; } else { - // The parent of the final segment resolved to a primitive, so there - // is nothing to write into. Dropping the write silently would hide - // a malformed path, so report it. + // The parent resolved to a primitive, so there is nothing to + // write into. Dropping the write would hide a malformed path. throw A2uiDataError( "Cannot set path '$path': '$lastSegment' is a property of a " 'primitive value.', @@ -191,14 +190,10 @@ class DataModel { } final Object? newValue = get(path); - // A container that was mutated in place keeps its identity, so handing the - // signal the live object would compare equal and suppress the - // notification. Hand it a copy instead, so containers always compare - // unequal, and let the signal's own equality check suppress notifications - // for values that genuinely did not change. - // - // Notifying unconditionally instead would wake every observer on a related - // path, including those whose own value is unchanged. + // A container mutated in place keeps its identity, so the live object + // would compare equal and suppress the notification. Hand over a copy, + // and let the signal's equality check suppress genuinely unchanged + // values; notifying unconditionally would wake unaffected observers. sig.set(switch (newValue) { final Map map => Map.of(map), final List list => List.of(list), diff --git a/dart/a2ui_core/lib/src/core/messages.dart b/dart/a2ui_core/lib/src/core/messages.dart index 3ce8749c10..69e97b4ed8 100644 --- a/dart/a2ui_core/lib/src/core/messages.dart +++ b/dart/a2ui_core/lib/src/core/messages.dart @@ -17,19 +17,18 @@ import '../primitives/protocol_version.dart'; /// Base class for all A2UI messages. abstract class A2uiMessage { - /// The protocol version this message declares, as it appears on the wire. + /// The declared protocol version, as it appears on the wire. final String version; A2uiMessage({this.version = 'v0.9'}); - /// The parsed protocol version this message declares. + /// The declared protocol version, parsed. A2uiProtocolVersion get protocolVersion => A2uiProtocolVersion.fromJson(version); /// Deserializes a JSON envelope into a typed [A2uiMessage]. /// - /// Throws [A2uiValidationError] if the envelope omits `version` or declares - /// a protocol version this SDK does not implement. + /// Throws [A2uiValidationError] if `version` is missing or unsupported. factory A2uiMessage.fromJson(Map json) { final String version = A2uiProtocolVersion.fromJson( json['version'], diff --git a/dart/a2ui_core/lib/src/core/renderer_capabilities.dart b/dart/a2ui_core/lib/src/core/renderer_capabilities.dart index 42d43be0e4..2f4699d636 100644 --- a/dart/a2ui_core/lib/src/core/renderer_capabilities.dart +++ b/dart/a2ui_core/lib/src/core/renderer_capabilities.dart @@ -16,17 +16,14 @@ import '../primitives/errors.dart'; import '../primitives/protocol_version.dart'; import 'catalog.dart'; -/// The catalogs a renderer can render for a single protocol version. -/// -/// Mirrors the `A2uiVersionCapabilities` structure of -/// `specification/v0_9_1/json/client_capabilities.json`. +/// The catalogs a renderer can render for one protocol version, mirroring +/// `A2uiVersionCapabilities` in `client_capabilities.json`. class A2uiVersionCapabilities { /// Ids of the catalogs the renderer supports. final List supportedCatalogIds; - /// Catalogs supplied inline by the renderer. - /// - /// Only meaningful when the agent advertises `acceptsInlineCatalogs`. + /// Catalogs supplied inline, meaningful only when the agent advertises + /// `acceptsInlineCatalogs`. final List inlineCatalogs; A2uiVersionCapabilities({ @@ -36,8 +33,8 @@ class A2uiVersionCapabilities { /// Parses a version capabilities object. /// - /// Throws [A2uiValidationError] if `supportedCatalogIds` is missing or is not - /// a list of strings. + /// Throws [A2uiValidationError] unless `supportedCatalogIds` is a list of + /// strings and `inlineCatalogs`, if present, holds catalog objects. factory A2uiVersionCapabilities.fromJson(Map json) { final Object? rawIds = json['supportedCatalogIds']; if (rawIds is! List) { @@ -83,21 +80,17 @@ class A2uiVersionCapabilities { }; } -/// The UI rendering capabilities a renderer advertises to an agent. -/// -/// Mirrors the `a2uiClientCapabilities` object of -/// `specification/v0_9_1/json/client_capabilities.json`, and the `web_core` -/// `A2uiClientCapabilities` type. +/// The rendering capabilities a renderer advertises, mirroring +/// `a2uiClientCapabilities` in `client_capabilities.json` and web_core's +/// `A2uiClientCapabilities`. /// -/// This SDK implements v0.9 only, so a capabilities object that does not carry -/// a `v0.9` entry is rejected. Entries for other versions are preserved in -/// [unsupportedVersions] but are never negotiated against. +/// A capabilities object without a `v0.9` entry is rejected. Other versions +/// are kept in [unsupportedVersions] but never negotiated against. class A2uiRendererCapabilities { /// The capabilities declared for v0.9. final A2uiVersionCapabilities v0_9; - /// Version keys present in the source object that this SDK does not - /// implement. + /// Version keys in the source object that this SDK does not implement. final List unsupportedVersions; A2uiRendererCapabilities({ @@ -105,7 +98,7 @@ class A2uiRendererCapabilities { this.unsupportedVersions = const [], }); - /// Convenience constructor for a renderer that supports catalogs by id only. + /// A renderer that supports catalogs by id only. factory A2uiRendererCapabilities.forCatalogIds( List supportedCatalogIds, { List inlineCatalogs = const [], diff --git a/dart/a2ui_core/lib/src/primitives/errors.dart b/dart/a2ui_core/lib/src/primitives/errors.dart index a524655b4d..f602600493 100644 --- a/dart/a2ui_core/lib/src/primitives/errors.dart +++ b/dart/a2ui_core/lib/src/primitives/errors.dart @@ -61,8 +61,7 @@ class A2uiParseError extends A2uiError { : super(message, 'PARSE_ERROR'); } -/// Thrown when a raw inference-format payload cannot be compiled into A2UI -/// messages. +/// Thrown when a raw format payload cannot be compiled into A2UI messages. class A2uiCompileError extends A2uiError { /// The raw content that could not be compiled. final String? rawContent; @@ -86,8 +85,8 @@ class A2uiCatalogError extends A2uiError { : super(message, 'CATALOG_ERROR'); } -/// Thrown when a component graph is structurally invalid (unreachable roots, -/// duplicate ids, dangling references). +/// Thrown for a structurally invalid component graph: unreachable roots, +/// duplicate ids, dangling references. class A2uiIntegrityError extends A2uiError { /// The component ids involved, when known. final List componentIds; @@ -96,8 +95,7 @@ class A2uiIntegrityError extends A2uiError { : super(message, 'INTEGRITY_ERROR'); } -/// Thrown when a component graph exceeds the maximum allowed nesting depth or -/// contains a cycle. +/// Thrown when a component graph cycles or exceeds the depth cap. class A2uiRecursionError extends A2uiError { /// The chain of component ids that produced the cycle, when known. final List cycle; diff --git a/dart/a2ui_core/lib/src/primitives/protocol_version.dart b/dart/a2ui_core/lib/src/primitives/protocol_version.dart index 7e2a2d03cc..d3407ab662 100644 --- a/dart/a2ui_core/lib/src/primitives/protocol_version.dart +++ b/dart/a2ui_core/lib/src/primitives/protocol_version.dart @@ -16,14 +16,11 @@ import 'errors.dart'; /// A version of the A2UI protocol. /// -/// This SDK implements version 0.9 of the protocol only. Payloads that declare -/// any other version, or that omit the version entirely, are rejected by -/// [A2uiProtocolVersion.fromJson]. +/// This SDK implements v0.9 only; [fromJson] rejects anything else, and a +/// payload that omits the version. enum A2uiProtocolVersion { - /// Version 0.9 of the A2UI protocol. - /// - /// Also covers v0.9.1, which is schema-compatible with v0.9 and shares the - /// same `version` wire value. + /// Version 0.9, and the schema-compatible v0.9.1, which shares its wire + /// value. v0_9('v0.9'); const A2uiProtocolVersion(this.jsonValue); @@ -34,7 +31,7 @@ enum A2uiProtocolVersion { /// Parses the `version` field of an A2UI payload. /// /// Throws [A2uiValidationError] if [value] is absent, is not a string, or - /// names a protocol version this SDK does not implement. + /// names a version this SDK does not implement. static A2uiProtocolVersion fromJson(Object? value, {Object? details}) { if (value == null) { throw A2uiValidationError( @@ -60,7 +57,7 @@ enum A2uiProtocolVersion { ); } - /// A human readable list of the versions this SDK implements. + /// The versions this SDK implements, for error messages. static String get supportedVersions => values.map((v) => "'${v.jsonValue}'").join(', '); } diff --git a/dart/a2ui_core/lib/src/validation/validator.dart b/dart/a2ui_core/lib/src/validation/validator.dart index 092da5f6e1..2bd8cd0879 100644 --- a/dart/a2ui_core/lib/src/validation/validator.dart +++ b/dart/a2ui_core/lib/src/validation/validator.dart @@ -19,16 +19,12 @@ import '../primitives/protocol_version.dart'; /// Validates A2UI payloads against the protocol schemas and a set of catalogs. /// -/// Lives in `a2ui_core` rather than in the agent SDK because both renderers and -/// agents validate the same payloads against the same catalogs. +/// Lives in `a2ui_core` because renderers and agents validate the same +/// payloads against the same catalogs. Implements v0.9 only: [checkVersion] +/// and [parseMessages] reject any other version, or none. /// -/// This SDK implements protocol v0.9 only. Payloads that declare any other -/// version, or that omit the version, are rejected by [checkVersion] and by -/// [parseMessages]. -/// -/// The deep checks ([validateStructure] and [validateAgainstCatalogs]) are not -/// implemented yet and throw [UnimplementedError]; [validate], which composes -/// them, therefore also throws once a payload has passed envelope parsing. +/// The deep checks ([validateStructure], [validateAgainstCatalogs]) are not +/// implemented yet and throw [UnimplementedError], as does [validate]. class A2uiValidator { /// The catalogs payloads are validated against, keyed by catalog id. final Map> catalogs; @@ -41,9 +37,10 @@ class A2uiValidator { this.protocolVersion = A2uiProtocolVersion.v0_9, }) : catalogs = {for (final Catalog c in catalogs) c.id: c}; - /// Creates a validator for the protocol version named by [version]. + /// Creates a validator for [version]. /// - /// Throws [A2uiValidationError] for any version this SDK does not implement. + /// Throws [A2uiValidationError] for any version this SDK does not + /// implement. factory A2uiValidator.forVersion( Object? version, { List> catalogs = const [], @@ -52,10 +49,10 @@ class A2uiValidator { protocolVersion: A2uiProtocolVersion.fromJson(version), ); - /// Checks the `version` field of a single payload envelope. + /// Checks the `version` field of one payload envelope. /// - /// Throws [A2uiValidationError] if the envelope omits `version`, or declares - /// a version other than the one this validator accepts. + /// Throws [A2uiValidationError] if it is missing or is not the version this + /// validator accepts. A2uiProtocolVersion checkVersion(Map envelope) { final A2uiProtocolVersion version = A2uiProtocolVersion.fromJson( envelope['version'], @@ -71,11 +68,10 @@ class A2uiValidator { return version; } - /// Parses payload envelopes into typed messages, rejecting unsupported - /// versions and malformed envelopes. + /// Parses payload envelopes into typed messages. /// /// Throws [A2uiValidationError] for any envelope that is not a well-formed - /// message of the accepted protocol version. + /// message of the accepted version. List parseMessages(List> payload) { final messages = []; for (final envelope in payload) { @@ -85,32 +81,27 @@ class A2uiValidator { return messages; } - /// Checks that a message sequence forms a valid component graph. - /// - /// Covers component id uniqueness, reachability from the surface root, - /// dangling child references, cycle detection and the recursion depth cap. + /// Checks that a message sequence forms a valid component graph: unique + /// ids, reachability, no dangling references, no cycles, depth within cap. /// - /// Throws [A2uiIntegrityError] for graph defects and [A2uiRecursionError] for - /// cycles and depth overruns. + /// Throws [A2uiIntegrityError] for graph defects and [A2uiRecursionError] + /// for cycles and depth overruns. void validateStructure(List messages) { throw UnimplementedError('A2uiValidator.validateStructure'); } - /// Checks each component and function call against the schema of the catalog - /// the surface was created with. + /// Checks each component and function call against its surface's catalog. /// - /// Throws [A2uiCatalogError] if a message names a catalog this validator does - /// not hold, and [A2uiValidationError] for schema violations. + /// Throws [A2uiCatalogError] if a message names a catalog this validator + /// does not hold, and [A2uiValidationError] for schema violations. Future validateAgainstCatalogs(List messages) { throw UnimplementedError('A2uiValidator.validateAgainstCatalogs'); } - /// Validates a complete payload: envelope parsing, then structural checks, - /// then catalog schema checks. + /// Validates a complete payload: envelopes, then structure, then catalog + /// schemas. /// - /// Returns the parsed messages. Throws [A2uiValidationError], - /// [A2uiIntegrityError], [A2uiRecursionError] or [A2uiCatalogError] as - /// described on the individual steps. + /// Returns the parsed messages, and throws as the individual steps do. Future> validate(List> payload) async { final List messages = parseMessages(payload); validateStructure(messages); diff --git a/dart/a2ui_core/test/conformance/conformance_harness.dart b/dart/a2ui_core/test/conformance/conformance_harness.dart index 0d563d6bbb..d01f13de0a 100644 --- a/dart/a2ui_core/test/conformance/conformance_harness.dart +++ b/dart/a2ui_core/test/conformance/conformance_harness.dart @@ -17,17 +17,14 @@ import 'dart:io'; import 'package:path/path.dart' as p; import 'package:yaml/yaml.dart'; -/// Resolves a path from a conformance case against the `conformance/` -/// directory. -/// -/// Cases reference published specification artifacts with paths such as +/// Resolves a case's path against the `conformance/` directory, for example /// `../specification/v0_9_1/catalogs/basic/catalog.json`. String resolveConformancePath(String relativePath) => p.normalize(p.join(_conformanceRoot(), relativePath)); String _conformanceRoot() { - // Walk up from the current directory until the conformance suite is found, - // so the harness works from the package directory and the workspace root. + // Walk up, so the harness works from the package directory and the + // workspace root. Directory dir = Directory.current; while (true) { final candidate = Directory(p.join(dir.path, 'conformance')); @@ -62,10 +59,8 @@ List> loadConformanceSuite(String suite) { ]; } -/// Converts YAML nodes into plain Dart maps, lists and scalars. -/// -/// The state models under test are typed against `Map` and -/// `List`, which `YamlMap` and `YamlList` do not satisfy. +/// Converts YAML nodes into plain Dart maps, lists and scalars, which +/// `YamlMap` and `YamlList` are not. Object? normalizeYaml(Object? node) { if (node is YamlMap || node is Map) { return { diff --git a/dart/a2ui_core/test/conformance/data_model_conformance_test.dart b/dart/a2ui_core/test/conformance/data_model_conformance_test.dart index 80af48644a..312ad60253 100644 --- a/dart/a2ui_core/test/conformance/data_model_conformance_test.dart +++ b/dart/a2ui_core/test/conformance/data_model_conformance_test.dart @@ -20,12 +20,9 @@ import 'conformance_harness.dart'; /// Runs the shared `conformance/core/data_model.yaml` suite against /// [DataModel]. /// -/// Two mappings are worth calling out, both documented in the suite header: -/// -/// * `op: delete` maps to `set(path, null)`. Dart has no `undefined`, so -/// removing a key is expressed by writing null. -/// * `watch` attaches one observer per entry. Repeating a path attaches a -/// second observer to the same underlying signal. +/// Two mappings, both documented in the suite header: `op: delete` maps to +/// `set(path, null)`, since Dart has no `undefined`; `watch` attaches one +/// observer per entry, so a repeated path attaches a second. void main() { final List> cases = loadConformanceSuite( 'core/data_model.yaml', @@ -43,8 +40,7 @@ void main() { } void _runCase(Map testCase) { - // The suite is parsed once and shared across cases, so the initial data is - // deep copied before the model mutates it. + // The suite is shared across cases, so deep copy before mutating. final model = DataModel( _deepCopy(testCase['initial']) ?? {}, ); @@ -199,8 +195,7 @@ class _Observer { _Observer(DataModel model, this.path) : signal = model.watch(path) { signal.subscribe((_) => _count++); - // preact_signals delivers an immediate callback on subscribe; the initial - // value is not a change. + // preact_signals calls back on subscribe; that is not a change. _count = 0; } diff --git a/dart/a2ui_core/test/conformance/message_processor_conformance_test.dart b/dart/a2ui_core/test/conformance/message_processor_conformance_test.dart index 5e6c35ba5b..e94326e240 100644 --- a/dart/a2ui_core/test/conformance/message_processor_conformance_test.dart +++ b/dart/a2ui_core/test/conformance/message_processor_conformance_test.dart @@ -20,9 +20,8 @@ import 'conformance_harness.dart'; /// Runs the shared `conformance/core/message_processor.yaml` suite against /// [MessageProcessor]. /// -/// The suite's catalog is built natively rather than parsed from the case, as -/// the suite header explains: renderers construct catalogs from code, so the -/// case supplies only the catalog id. +/// Cases supply only a catalog id: renderers build catalogs from code, so each +/// harness builds one natively, as the suite header explains. void main() { final List> cases = loadConformanceSuite( 'core/message_processor.yaml', @@ -66,9 +65,8 @@ void _runCase(Map testCase) { /// Converts each envelope and processes it. /// -/// Conversion is part of processing for this suite: the Dart processor takes -/// typed messages, so a malformed envelope is rejected by -/// [A2uiMessage.fromJson] rather than by the processor itself. +/// Conversion counts as processing here: the Dart processor takes typed +/// messages, so [A2uiMessage.fromJson] rejects a malformed envelope first. void _process( MessageProcessor processor, List> payload, diff --git a/dart/a2ui_core/test/validator_test.dart b/dart/a2ui_core/test/validator_test.dart index d0be3872e3..58c93e666d 100644 --- a/dart/a2ui_core/test/validator_test.dart +++ b/dart/a2ui_core/test/validator_test.dart @@ -15,8 +15,7 @@ import 'package:a2ui_core/a2ui_core.dart'; import 'package:test/test.dart'; -/// Marks a test that describes behaviour `A2uiValidator` does not implement -/// yet. Remove the skip alongside the implementation. +/// Marks behaviour `A2uiValidator` does not implement yet. const String pendingValidator = 'A2uiValidator deep checks are not implemented yet.'; From 478867c466f7acd11e8db223b5e81973653fe573 Mon Sep 17 00:00:00 2001 From: Polina Cherkasova Date: Thu, 27 Aug 2026 13:51:09 -0700 Subject: [PATCH 12/22] Update README.md --- conformance/README.md | 20 -------------------- 1 file changed, 20 deletions(-) diff --git a/conformance/README.md b/conformance/README.md index b3061250ba..e2d40d5a2a 100644 --- a/conformance/README.md +++ b/conformance/README.md @@ -11,15 +11,12 @@ Test suites are organized by functional domain: - `core/catalog.yaml`: Contains test cases for catalog operations (prune, render, load). - `core/accessibility.yaml`: Contains test cases for accessibility attributes and checks. - `core/validator.yaml`: Contains test cases for schema and structural validators, verifying structural integrity, cycle detection, and reachability. -- `core/data_model.yaml`: Contains test cases for the reactive data model: JSON Pointer resolution, structural auto-vivification, and observer notification routing. -- `core/message_processor.yaml`: Contains test cases for the message processor: the surface lifecycle, component graph mutation, per surface data model routing, and the capability and data model payloads a renderer sends back to the agent. ### Agent (`agent/`) - `agent/streaming_parser.yaml`: Contains test cases for streaming parser implementations, verifying chunk buffering, incremental yielding, and edge cases like cut tokens. - `agent/parser.yaml`: Contains test cases for non-streaming parsing and payload fixing. - `agent/inference_format.yaml`: Contains test cases for inference formats and schema managers (select_catalog, load_catalog, generate_prompt). -- `agent/request_processor.yaml`: Contains end-to-end test cases for the agent turn described in `blueprints/modules/a2ui_agent.blueprint.md`: negotiate catalogs against renderer capabilities, render the system prompt snippet, and parse a full model response into deliverable A2UI messages. ### Extensions (`extensions/`) @@ -28,25 +25,8 @@ Test suites are organized by functional domain: All static test data and simplified schemas are located in the `test_data/` directory. -Cases may also reference published specification artifacts by relative path, for example `"../specification/v0_9_1/catalogs/basic/catalog.json"`. Path-valued fields such as `catalog_schema`, `s2c_schema` and `catalog_configs[].path` are resolved relative to this `conformance/` directory. Referencing the specification directly, rather than copying it here, keeps suites measured against the published contract instead of a snapshot that can drift. - `conformance_schema.json` at the root is the JSON schema that validates the structure of the YAML test files themselves. -## Scope of a shared dataset - -A suite in this directory is a contract every implementation must satisfy. Adding a case here asserts that every SDK already behaves that way, so a case that one SDK passes and another does not belongs in the failing SDK's own tests until the behaviour is agreed. Two kinds of exclusion come up: - -- **Language specific behaviour**, which cannot hold across implementations at all. `core/data_model.yaml`, migrated from `renderers/web_core/src/v0_9/state/data-model.test.ts`, lists the exclusions it made and why. -- **Behaviour one SDK has and others do not yet.** These are real gaps rather than disagreements, but a suite is not the place to record them, because it would turn every other SDK's build red. They belong in an issue and in the owning SDK's own tests. - -Behaviour currently in the second category, held by the Dart SDK only: - -- Rejecting a payload whose messages declare a protocol version the SDK does not implement, or omit `version` entirely, during `parse_full`. The Python and Kotlin parsers do not validate the version while parsing. -- Pruning catalog **functions**. The `prune` action currently supports `allowed_components` and `allowed_messages`; `allowed_functions` is implemented by the Dart `FunctionPruningTransformer` only. -- Raising an error from `select_catalog` when the renderer and agent share no catalog. Kotlin raises with a different message and Python returns no selection instead. - -The new `process_request` and `process_messages` actions are not loaded by the Python or Kotlin harnesses, which read a fixed list of suite files. `process_request` does assert version rejection, because that action has no prior implementations and its contract is being defined with it. - ## Usage in SDKs Each language SDK must implement a test harness that: From 417e18a56db2896d8f4d1fc55242d1a1e9585aaf Mon Sep 17 00:00:00 2001 From: Polina Cherkasova Date: Thu, 27 Aug 2026 16:16:41 -0700 Subject: [PATCH 13/22] move tests to web_core/tests/conformance/data-model.conformance.test.ts --- renderers/web_core/package.json | 3 + .../src/v0_9/state/data-model.test.ts | 488 +++++++++++------- .../data-model.conformance.test.ts | 155 ++++++ .../v0_9 => tests}/conformance/harness.ts | 0 .../message-processor.conformance.test.ts | 6 +- renderers/web_core/tsconfig.json | 2 +- 6 files changed, 459 insertions(+), 195 deletions(-) create mode 100644 renderers/web_core/tests/conformance/data-model.conformance.test.ts rename renderers/web_core/{src/v0_9 => tests}/conformance/harness.ts (100%) rename renderers/web_core/{src/v0_9 => tests}/conformance/message-processor.conformance.test.ts (96%) diff --git a/renderers/web_core/package.json b/renderers/web_core/package.json index 80a5d2434a..bc4dfc17e6 100644 --- a/renderers/web_core/package.json +++ b/renderers/web_core/package.json @@ -105,6 +105,7 @@ "files": [ "src/**/*.ts", "src/**/*.json", + "tests/**/*.ts", "tsconfig.json" ], "output": [ @@ -127,6 +128,7 @@ "files": [ "src/**/*.ts", "src/**/*.json", + "tests/**/*.ts", "scripts/**/*.js", "package.json", "tsconfig.json" @@ -137,6 +139,7 @@ "files": [ "src/**/*.ts", "src/**/*.json", + "tests/**/*.ts", "scripts/**/*.js", "package.json", "tsconfig.json" diff --git a/renderers/web_core/src/v0_9/state/data-model.test.ts b/renderers/web_core/src/v0_9/state/data-model.test.ts index fc378b12a0..1a72d0b7b0 100644 --- a/renderers/web_core/src/v0_9/state/data-model.test.ts +++ b/renderers/web_core/src/v0_9/state/data-model.test.ts @@ -16,160 +16,9 @@ import * as assert from 'node:assert'; import {describe, it, beforeEach} from 'node:test'; -import {loadConformanceSuite} from '../conformance/harness.js'; -import {DataModel, type DataSubscription} from './data-model.js'; +import {DataModel} from './data-model.js'; -/** - * Behaviour shared by every A2UI data model implementation lives in - * `conformance/core/data_model.yaml` and is exercised by the harness below. - * - * One mapping is worth calling out: `op: delete` maps to `set(path, undefined)`, - * because this implementation removes a key when its value becomes `undefined`. - * - * The `describe` blocks after the harness cover behaviour that is specific to - * this JavaScript implementation and therefore cannot be part of a shared, - * cross-language dataset: - * - * - prototype pollution guards (`__proto__`, `constructor`, `prototype`) and - * `Object.prototype` property leakage, which only exist because JavaScript - * objects have a prototype chain; - * - `null` and `undefined` path arguments, which languages with non-nullable - * string types cannot express; - * - the distinction between storing `undefined` and removing a key, which - * languages without `undefined` cannot express; - * - rejection of leading-zero list indices, which this implementation enforces - * and the Dart implementation currently does not; - * - unbounded list indices. Arrays here are sparse, so writing `/items/999999999` - * is cheap; Dart lists are dense, so the Dart implementation rejects the same - * write to avoid allocating the whole list. - */ - -interface ConformanceStep { - op: 'get' | 'set' | 'delete' | 'dispose'; - path?: string; - value?: unknown; - expect?: unknown; - expect_absent?: boolean; - expect_type?: 'list' | 'object'; - expect_values?: Record; - expect_notified?: string[]; - expect_error?: {category?: string; message?: string} | string; -} - -interface ConformanceCase { - name: string; - initial?: Record; - watch?: string[]; - steps: ConformanceStep[]; -} - -interface Observer { - path: string; - subscription: DataSubscription; - count: number; -} - -function deepCopy(value: T): T { - return value === undefined ? value : (JSON.parse(JSON.stringify(value)) as T); -} - -function errorMessagePattern(expectError: ConformanceStep['expect_error']): RegExp { - const message = typeof expectError === 'string' ? expectError : (expectError?.message ?? ''); - return new RegExp(message); -} - -function applyOp(model: DataModel, step: ConformanceStep, reason: string): void { - switch (step.op) { - case 'get': { - const actual = model.get(step.path!); - if (step.expect_absent === true) { - assert.strictEqual(actual, undefined, reason); - } - if (step.expect_type !== undefined) { - assert.strictEqual(Array.isArray(actual) ? 'list' : 'object', step.expect_type, reason); - } - if ('expect' in step) { - assert.deepStrictEqual(actual, step.expect, reason); - } - break; - } - case 'set': - model.set(step.path!, step.value); - break; - case 'delete': - // This implementation removes a key when its value becomes `undefined`. - model.set(step.path!, undefined); - break; - case 'dispose': - model.dispose(); - break; - default: - throw new Error(`Unknown data_model op: ${String(step.op)}`); - } -} - -function runCase(testCase: ConformanceCase): void { - // The suite is parsed once and shared across cases, so the initial data is - // deep copied before the model mutates it. - const model = new DataModel(deepCopy(testCase.initial) ?? {}); - const observers: Observer[] = (testCase.watch ?? []).map(path => { - const observer: Observer = {path, count: 0, subscription: undefined!}; - observer.subscription = model.subscribe(path, () => observer.count++); - return observer; - }); - - testCase.steps.forEach((step, index) => { - const reason = `${testCase.name} step ${index} (${step.op})`; - for (const observer of observers) { - observer.count = 0; - } - - if (step.expect_error !== undefined) { - assert.throws( - () => applyOp(model, step, reason), - errorMessagePattern(step.expect_error), - reason, - ); - return; - } - - applyOp(model, step, reason); - - if (step.expect_notified !== undefined) { - const notified: string[] = []; - for (const observer of observers) { - for (let i = 0; i < observer.count; i++) { - notified.push(observer.path); - } - } - assert.deepStrictEqual( - notified.sort(), - [...step.expect_notified].sort(), - `${reason}: notified observers`, - ); - } - - for (const [path, expected] of Object.entries(step.expect_values ?? {})) { - const observer = observers.find(o => o.path === path); - assert.ok(observer, `${reason}: ${path} is not watched`); - assert.deepStrictEqual(observer.subscription.value, expected, `${reason}: ${path}`); - } - }); -} - -describe('conformance core/data_model.yaml', () => { - const cases = loadConformanceSuite('core/data_model.yaml'); - - it('suite is not empty', () => { - assert.ok(cases.length > 0); - }); - - for (const testCase of cases) { - it(testCase.name, () => runCase(testCase)); - } -}); - -describe('DataModel (JavaScript specific)', () => { +describe('DataModel', () => { let model: DataModel; beforeEach(() => { @@ -184,7 +33,59 @@ describe('DataModel (JavaScript specific)', () => { }); }); - // --- undefined, which the shared dataset expresses as `op: delete` --- + // --- Initialization --- + + it('initializes with empty data if not provided', () => { + const emptyModel = new DataModel(); + assert.deepStrictEqual(emptyModel.get('/'), {}); + }); + + // --- Basic Retrieval --- + + it('retrieves root data', () => { + assert.deepStrictEqual(model.get('/'), { + user: {name: 'Alice', settings: {theme: 'dark'}}, + items: ['a', 'b', 'c'], + }); + }); + + it('retrieves nested path', () => { + assert.strictEqual(model.get('/user/name'), 'Alice'); + assert.strictEqual(model.get('/user/settings/theme'), 'dark'); + }); + + it('retrieves array items', () => { + assert.strictEqual(model.get('/items/0'), 'a'); + assert.strictEqual(model.get('/items/1'), 'b'); + }); + + it('returns undefined for non-existent paths', () => { + assert.strictEqual(model.get('/user/age'), undefined); + assert.strictEqual(model.get('/unknown/path'), undefined); + }); + + it('returns undefined when traversing through undefined/null segments', () => { + model.set('/nullable', null); + assert.strictEqual(model.get('/nullable/deep/path'), undefined); + }); + + // --- Updates --- + + it('sets value at existing path', () => { + model.set('/user/name', 'Bob'); + assert.strictEqual(model.get('/user/name'), 'Bob'); + }); + + it('sets value at new path', () => { + model.set('/user/age', 30); + assert.strictEqual(model.get('/user/age'), 30); + }); + + it('creates intermediate objects', () => { + model.set('/a/b/c', 'foo'); + assert.strictEqual(model.get('/a/b/c'), 'foo'); + assert.notStrictEqual(model.get('/a/b'), undefined); + }); it('removes keys when value is undefined', () => { model.set('/user/name', undefined); @@ -192,24 +93,54 @@ describe('DataModel (JavaScript specific)', () => { assert.strictEqual(Object.keys(model.get('/user')).includes('name'), false); }); - it('handles updates to undefined', () => { - model.set('/foo', 'bar'); - let val: unknown = 'initial'; - const sub = model.subscribe('/foo', v => (val = v)); + // --- Array / List Handling (Flutter Parity) --- - model.set('/foo', undefined); - assert.strictEqual(sub.value, undefined); - assert.strictEqual(val, undefined); + it('List: set and get', () => { + model.set('/list/0', 'hello'); + assert.strictEqual(model.get('/list/0'), 'hello'); + assert.ok(Array.isArray(model.get('/list'))); }); - // --- Subscription objects --- + it('List: append and get', () => { + model.set('/list/0', 'hello'); + model.set('/list/1', 'world'); + assert.strictEqual(model.get('/list/0'), 'hello'); + assert.strictEqual(model.get('/list/1'), 'world'); + assert.strictEqual(model.get('/list').length, 2); + }); + + it('List: update existing index', () => { + model.set('/items/1', 'updated'); + assert.strictEqual(model.get('/items/1'), 'updated'); + }); + + it('Nested structures are created automatically', () => { + // Should create nested map and list: { a: { b: [ { c: 123 } ] } } + model.set('/a/b/0/c', 123); + assert.strictEqual(model.get('/a/b/0/c'), 123); + assert.ok(Array.isArray(model.get('/a/b'))); + assert.ok(!Array.isArray(model.get('/a/b/0'))); + + // Should create nested maps + model.set('/x/y/z', 'hello'); + assert.strictEqual(model.get('/x/y/z'), 'hello'); + + // Should create nested lists + model.set('/nestedList/0/0', 'inner'); + assert.strictEqual(model.get('/nestedList/0/0'), 'inner'); + assert.ok(Array.isArray(model.get('/nestedList'))); + assert.ok(Array.isArray(model.get('/nestedList/0'))); + }); + + // --- Subscriptions --- it('returns a subscription object', () => { model.set('/a', 1); - let updatedValue: number | undefined; const sub = model.subscribe('/a', val => (updatedValue = val)); assert.strictEqual(sub.value, 1); + let updatedValue: number | undefined; + model.set('/a', 2); assert.strictEqual(sub.value, 2); assert.strictEqual(updatedValue, 2); @@ -220,11 +151,89 @@ describe('DataModel (JavaScript specific)', () => { assert.strictEqual(updatedValue, 2); }); + it('notifies subscribers on exact match', () => { + let called = false; + model.subscribe('/user/name', val => { + assert.strictEqual(val, 'Charlie'); + called = true; + }); + model.set('/user/name', 'Charlie'); + assert.strictEqual(called, true, 'Callback was never called'); + }); + + it('notifies ancestor subscribers (Container Semantics)', () => { + let called = false; + model.subscribe('/user', (val: any) => { + assert.strictEqual(val.name, 'Dave'); + called = true; + }); + model.set('/user/name', 'Dave'); + assert.strictEqual(called, true, 'Callback was never called'); + }); + + it('notifies descendant subscribers', () => { + let called = false; + model.subscribe('/user/settings/theme', val => { + assert.strictEqual(val, 'light'); + called = true; + }); + + // We update the parent object + model.set('/user/settings', {theme: 'light'}); + assert.strictEqual(called, true, 'Callback was never called'); + }); + + it('notifies root subscriber', () => { + let called = false; + model.subscribe('/', (val: any) => { + assert.strictEqual(val.newProp, 'test'); + called = true; + }); + model.set('/newProp', 'test'); + assert.strictEqual(called, true, 'Callback was never called'); + }); + + it('notifies parent when child updates', () => { + model.set('/parent', {child: 'initial'}); + + let parentValue: any; + model.subscribe('/parent', val => (parentValue = val)); + + model.set('/parent/child', 'updated'); + assert.deepStrictEqual(parentValue, {child: 'updated'}); + }); + + it('stops notifying after dispose', () => { + let count = 0; + model.subscribe('/', () => count++); + + model.dispose(); + model.set('/foo', 'bar'); + assert.strictEqual(count, 0); + }); + + it('supports multiple subscribers to the same path', () => { + let callCount1 = 0; + let callCount2 = 0; + + const sub1 = model.subscribe('/user/name', () => callCount1++); + + const sub2 = model.subscribe('/user/name', () => callCount2++); + + model.set('/user/name', 'Eve'); + + assert.strictEqual(callCount1, 1); + assert.strictEqual(callCount2, 1); + assert.strictEqual(sub1.value, 'Eve'); + assert.strictEqual(sub2.value, 'Eve'); + }); + it('allows unsubscribing individual listeners', () => { let callCount1 = 0; let callCount2 = 0; const sub1 = model.subscribe('/user/name', () => callCount1++); + const sub2 = model.subscribe('/user/name', () => callCount2++); sub1.unsubscribe(); @@ -240,37 +249,119 @@ describe('DataModel (JavaScript specific)', () => { assert.strictEqual(callCount2, 1); // still 1 }); - // --- Null and undefined path arguments --- + it('handles subscription to non-existent path', () => { + let val: any; + const sub = model.subscribe('/non/existent', v => (val = v)); + assert.strictEqual(sub.value, undefined); + + model.set('/non/existent', 'exists now'); + assert.strictEqual(sub.value, 'exists now'); + assert.strictEqual(val, 'exists now'); + }); + + it('handles updates to undefined', () => { + model.set('/foo', 'bar'); + let val: any = 'initial'; + const sub = model.subscribe('/foo', v => (val = v)); + + model.set('/foo', undefined); + assert.strictEqual(sub.value, undefined); + assert.strictEqual(val, undefined); + }); + + it('throws when trying to set nested property through a primitive', () => { + model.set('/user/name', 'not an object'); + assert.strictEqual(model.get('/user/name'), 'not an object'); + + assert.throws(() => { + model.set('/user/name/first', 'Alice'); + }, /Cannot set path/); + }); + + it('throws when using non-numeric segment on an array', () => { + assert.throws(() => { + model.set('/items/foo', 'bar'); + }, /Cannot use non-numeric segment/); + }); + + it('throws when using non-numeric segment on an array (intermediate)', () => { + model.set('/', {items: [1, 2, 3]}); + assert.throws(() => { + model.set('/items/foo/bar', 'value'); + }, /Cannot use non-numeric segment 'foo' on an array/); + }); + + it('normalizes trailing slashes', () => { + let callCount = 0; + model.subscribe('/foo', () => callCount++); + model.set('/foo/', 'bar'); // Trailing slash + assert.strictEqual(model.get('/foo/'), 'bar'); + assert.strictEqual(callCount, 1); + }); + + it('replaces root object on root update', () => { + let callCount = 0; + model.subscribe('/', () => callCount++); + // Just add another sub on a generic path to ensure notifyAllSubscribers loop hits multiple items + model.subscribe('/unrelated', () => {}); + + model.set('/', {newRoot: 'foo'}); + assert.deepStrictEqual(model.get(''), {newRoot: 'foo'}); + assert.strictEqual(callCount, 1); + }); it('throws when path is null or undefined', () => { - assert.throws(() => model.get(null as never), /Path cannot be null or undefined/); - assert.throws(() => model.get(undefined as never), /Path cannot be null or undefined/); - assert.throws(() => model.set(null as never, 'value'), /Path cannot be null or undefined/); - assert.throws(() => model.set(undefined as never, 'value'), /Path cannot be null or undefined/); + assert.throws(() => model.get(null as any), /Path cannot be null or undefined/); + assert.throws(() => model.get(undefined as any), /Path cannot be null or undefined/); + assert.throws(() => model.set(null as any, 'value'), /Path cannot be null or undefined/); + assert.throws(() => model.set(undefined as any, 'value'), /Path cannot be null or undefined/); }); it('calculates descendants against root path', () => { // This explicitly hits an internal method branch where parentPath === "/" - const isDescendant = ( - model as unknown as {isDescendant: (a: string, b: string) => boolean} - ).isDescendant.bind(model); + const isDescendant = (model as any).isDescendant.bind(model); assert.strictEqual(isDescendant('/user', '/'), true); assert.strictEqual(isDescendant('/', '/'), false); }); - // --- Leading-zero list indices --- + describe('JSON Pointer Escaping (RFC 6901)', () => { + it('handles escaped slashes (~1)', () => { + model.set('/user/detailed~1info', 'some info'); + assert.strictEqual(model.get('/user/detailed~1info'), 'some info'); - it('rejects leading-zero array indices (RFC 6901)', () => { - assert.throws(() => { - model.set('/items/01', 'value'); - }, /Cannot use non-numeric segment/); - assert.throws(() => { - model.set('/items/01/nested', 'value'); - }, /Cannot use non-numeric segment/); - assert.strictEqual(model.get('/items/01'), undefined); + // Verify it was actually set as a key with a slash in the underlying object + const user = model.get('/user'); + assert.strictEqual(user['detailed/info'], 'some info'); + assert.strictEqual(user['detailed~1info'], undefined); + }); + + it('handles escaped tildes (~0)', () => { + model.set('/user/profile~0name', 'profile~name'); + assert.strictEqual(model.get('/user/profile~0name'), 'profile~name'); + + const user = model.get('/user'); + assert.strictEqual(user['profile~name'], 'profile~name'); + assert.strictEqual(user['profile~0name'], undefined); + }); + + it('handles mixed escaped characters', () => { + model.set('/user/a~0b~1c', 'value'); + assert.strictEqual(model.get('/user/a~0b~1c'), 'value'); + + const user = model.get('/user'); + assert.strictEqual(user['a~b/c'], 'value'); + }); + + it('handles escaped sequence order correctly (~01)', () => { + model.set('/user/a~01b', 'value'); + assert.strictEqual(model.get('/user/a~01b'), 'value'); + + const user = model.get('/user'); + assert.strictEqual(user['a~1b'], 'value'); + }); }); - // --- Security: prototype pollution protection --- + // --- Security Tests: Prototype Pollution Protection --- it('prevents prototype pollution via __proto__ in set, get, getSignal, subscribe', () => { assert.throws( @@ -286,7 +377,7 @@ describe('DataModel (JavaScript specific)', () => { () => model.subscribe('/__proto__/polluted', () => {}), /Forbidden path segment '__proto__'/, ); - assert.strictEqual(({} as {polluted?: unknown}).polluted, undefined); + assert.strictEqual(({} as any).polluted, undefined); }); it('prevents prototype pollution via constructor in set, get, getSignal, subscribe', () => { @@ -306,7 +397,7 @@ describe('DataModel (JavaScript specific)', () => { () => model.subscribe('/constructor/prototype/polluted', () => {}), /Forbidden path segment 'constructor'/, ); - assert.strictEqual(({} as {polluted?: unknown}).polluted, undefined); + assert.strictEqual(({} as any).polluted, undefined); }); it('prevents prototype pollution via prototype in set, get, getSignal, subscribe', () => { @@ -326,14 +417,7 @@ describe('DataModel (JavaScript specific)', () => { () => model.subscribe('/user/prototype/polluted', () => {}), /Forbidden path segment 'prototype'/, ); - assert.strictEqual(({} as {polluted?: unknown}).polluted, undefined); - }); - - it('allows a large sparse array index', () => { - // Arrays here are sparse, so this allocates nothing. Implementations with - // dense lists reject the same write; see conformance/core/data_model.yaml. - model.set('/items/999999', 'x'); - assert.strictEqual(model.get('/items/999999'), 'x'); + assert.strictEqual(({} as any).polluted, undefined); }); it('does not leak Object.prototype inherited properties on get', () => { @@ -349,4 +433,26 @@ describe('DataModel (JavaScript specific)', () => { model.set('/valueOf/nested', 'custom valueOf'); assert.strictEqual(model.get('/valueOf/nested'), 'custom valueOf'); }); + + it('throws when trying to set nested property through a primitive in an array', () => { + assert.throws(() => { + model.set('/items/0/foo', 'bar'); + }, /Cannot set path/); + }); + + it('returns undefined for out-of-bounds or non-numeric array index in get', () => { + assert.strictEqual(model.get('/items/99'), undefined); + assert.strictEqual(model.get('/items/-1'), undefined); + assert.strictEqual(model.get('/items/invalid'), undefined); + }); + + it('rejects leading-zero array indices (RFC 6901)', () => { + assert.throws(() => { + model.set('/items/01', 'value'); + }, /Cannot use non-numeric segment/); + assert.throws(() => { + model.set('/items/01/nested', 'value'); + }, /Cannot use non-numeric segment/); + assert.strictEqual(model.get('/items/01'), undefined); + }); }); diff --git a/renderers/web_core/tests/conformance/data-model.conformance.test.ts b/renderers/web_core/tests/conformance/data-model.conformance.test.ts new file mode 100644 index 0000000000..20c87cf40e --- /dev/null +++ b/renderers/web_core/tests/conformance/data-model.conformance.test.ts @@ -0,0 +1,155 @@ +/* + * Copyright 2024 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 * as assert from 'node:assert'; +import {describe, it} from 'node:test'; +import {loadConformanceSuite} from './harness.js'; +import {DataModel, type DataSubscription} from '../../src/v0_9/state/data-model.js'; + +/** + * Runs the shared `conformance/core/data_model.yaml` suite against `DataModel`. + * + * One mapping is worth calling out: `op: delete` maps to `set(path, undefined)`, + * because this implementation removes a key when its value becomes `undefined`. + * + * Behaviour that cannot be expressed as a shared, cross-language dataset stays + * in `src/v0_9/state/data-model.test.ts`. + */ + +interface ConformanceStep { + op: 'get' | 'set' | 'delete' | 'dispose'; + path?: string; + value?: unknown; + expect?: unknown; + expect_absent?: boolean; + expect_type?: 'list' | 'object'; + expect_values?: Record; + expect_notified?: string[]; + expect_error?: {category?: string; message?: string} | string; +} + +interface ConformanceCase { + name: string; + initial?: Record; + watch?: string[]; + steps: ConformanceStep[]; +} + +interface Observer { + path: string; + subscription: DataSubscription; + count: number; +} + +function deepCopy(value: T): T { + return value === undefined ? value : (JSON.parse(JSON.stringify(value)) as T); +} + +function errorMessagePattern(expectError: ConformanceStep['expect_error']): RegExp { + const message = typeof expectError === 'string' ? expectError : (expectError?.message ?? ''); + return new RegExp(message); +} + +function applyOp(model: DataModel, step: ConformanceStep, reason: string): void { + switch (step.op) { + case 'get': { + const actual = model.get(step.path!); + if (step.expect_absent === true) { + assert.strictEqual(actual, undefined, reason); + } + if (step.expect_type !== undefined) { + assert.strictEqual(Array.isArray(actual) ? 'list' : 'object', step.expect_type, reason); + } + if ('expect' in step) { + assert.deepStrictEqual(actual, step.expect, reason); + } + break; + } + case 'set': + model.set(step.path!, step.value); + break; + case 'delete': + // This implementation removes a key when its value becomes `undefined`. + model.set(step.path!, undefined); + break; + case 'dispose': + model.dispose(); + break; + default: + throw new Error(`Unknown data_model op: ${String(step.op)}`); + } +} + +function runCase(testCase: ConformanceCase): void { + // The suite is parsed once and shared across cases, so the initial data is + // deep copied before the model mutates it. + const model = new DataModel(deepCopy(testCase.initial) ?? {}); + const observers: Observer[] = (testCase.watch ?? []).map(path => { + const observer: Observer = {path, count: 0, subscription: undefined!}; + observer.subscription = model.subscribe(path, () => observer.count++); + return observer; + }); + + testCase.steps.forEach((step, index) => { + const reason = `${testCase.name} step ${index} (${step.op})`; + for (const observer of observers) { + observer.count = 0; + } + + if (step.expect_error !== undefined) { + assert.throws( + () => applyOp(model, step, reason), + errorMessagePattern(step.expect_error), + reason, + ); + return; + } + + applyOp(model, step, reason); + + if (step.expect_notified !== undefined) { + const notified: string[] = []; + for (const observer of observers) { + for (let i = 0; i < observer.count; i++) { + notified.push(observer.path); + } + } + assert.deepStrictEqual( + notified.sort(), + [...step.expect_notified].sort(), + `${reason}: notified observers`, + ); + } + + for (const [path, expected] of Object.entries(step.expect_values ?? {})) { + const observer = observers.find(o => o.path === path); + assert.ok(observer, `${reason}: ${path} is not watched`); + assert.deepStrictEqual(observer.subscription.value, expected, `${reason}: ${path}`); + } + }); +} + +describe('conformance core/data_model.yaml', () => { + const cases = loadConformanceSuite('core/data_model.yaml'); + + it('suite is not empty', () => { + assert.ok(cases.length > 0); + }); + + for (const testCase of cases) { + it(testCase.name, () => runCase(testCase)); + } +}); diff --git a/renderers/web_core/src/v0_9/conformance/harness.ts b/renderers/web_core/tests/conformance/harness.ts similarity index 100% rename from renderers/web_core/src/v0_9/conformance/harness.ts rename to renderers/web_core/tests/conformance/harness.ts diff --git a/renderers/web_core/src/v0_9/conformance/message-processor.conformance.test.ts b/renderers/web_core/tests/conformance/message-processor.conformance.test.ts similarity index 96% rename from renderers/web_core/src/v0_9/conformance/message-processor.conformance.test.ts rename to renderers/web_core/tests/conformance/message-processor.conformance.test.ts index 02ce5912f0..c085c2338e 100644 --- a/renderers/web_core/src/v0_9/conformance/message-processor.conformance.test.ts +++ b/renderers/web_core/tests/conformance/message-processor.conformance.test.ts @@ -17,9 +17,9 @@ import * as assert from 'node:assert'; import {describe, it} from 'node:test'; import {loadConformanceSuite} from './harness.js'; -import {MessageProcessor} from '../processing/message-processor.js'; -import {Catalog, ComponentApi} from '../catalog/types.js'; -import {SurfaceModel} from '../state/surface-model.js'; +import {MessageProcessor} from '../../src/v0_9/processing/message-processor.js'; +import {Catalog, ComponentApi} from '../../src/v0_9/catalog/types.js'; +import {SurfaceModel} from '../../src/v0_9/state/surface-model.js'; /** * Runs the shared `conformance/core/message_processor.yaml` suite against diff --git a/renderers/web_core/tsconfig.json b/renderers/web_core/tsconfig.json index 26703b1c5a..643c063674 100644 --- a/renderers/web_core/tsconfig.json +++ b/renderers/web_core/tsconfig.json @@ -40,5 +40,5 @@ "strictTemplates": true, "strictPropertyInitialization": false }, - "include": ["src/**/*.ts", "src/**/*.json"] + "include": ["src/**/*.ts", "src/**/*.json", "tests/**/*.ts"] } From 84ef6331b5a8a889332ea8d70245b88aae80c6c8 Mon Sep 17 00:00:00 2001 From: Polina Cherkasova Date: Thu, 27 Aug 2026 16:42:52 -0700 Subject: [PATCH 14/22] add missing implementation to a2ui_core --- dart/a2ui_core/CHANGELOG.md | 26 +- dart/a2ui_core/lib/a2ui_core.dart | 9 + dart/a2ui_core/lib/src/core/messages.dart | 147 +++- .../lib/src/validation/component_graph.dart | 230 ++++++ .../lib/src/validation/component_refs.dart | 317 +++++++++ .../lib/src/validation/schema_resolution.dart | 174 +++++ .../lib/src/validation/validator.dart | 243 ++++++- .../validator_conformance_test.dart | 184 +++++ dart/a2ui_core/test/messages_test.dart | 60 ++ .../test/validator_basic_catalog_test.dart | 262 +++++++ dart/a2ui_core/test/validator_test.dart | 667 ++++++++++++++++-- 11 files changed, 2226 insertions(+), 93 deletions(-) create mode 100644 dart/a2ui_core/lib/src/validation/component_graph.dart create mode 100644 dart/a2ui_core/lib/src/validation/component_refs.dart create mode 100644 dart/a2ui_core/lib/src/validation/schema_resolution.dart create mode 100644 dart/a2ui_core/test/conformance/validator_conformance_test.dart create mode 100644 dart/a2ui_core/test/validator_basic_catalog_test.dart diff --git a/dart/a2ui_core/CHANGELOG.md b/dart/a2ui_core/CHANGELOG.md index d885e794a6..2bbcc7d84e 100644 --- a/dart/a2ui_core/CHANGELOG.md +++ b/dart/a2ui_core/CHANGELOG.md @@ -15,9 +15,29 @@ `$defs/anyFunction` unions narrowed to match. - Added `A2uiRendererCapabilities` and `A2uiVersionCapabilities`, mirroring `client_capabilities.json` and the `web_core` client capability types. -- Added `A2uiValidator`, which parses payload envelopes and gates them on the - supported protocol version. Structural and catalog schema checks are declared - but not implemented yet. +- Added `A2uiValidator`, which validates a payload in three stages: + `parseMessages` gates envelopes on the supported protocol version, + `validateStructure` checks the component graph, and `validateAgainstCatalogs` + checks each component against its catalog's schema. A payload that creates a + surface is treated as a full render, so it must declare a `root` component, + resolve every reference and leave nothing unreachable; a payload that only + updates components is incremental, so it may reference components the client + already holds, while duplicate ids, self-references and cycles still fail. + Which properties reference other components is read from the catalog schema, + through either the `$ref` pointers a catalog document uses or the `REF:` + description pointers a catalog built in Dart carries. +- Added `A2uiValidator.commonTypesSchema`. Catalogs reference + `common_types.json` for their shared definitions; supplying it lets those + definitions be enforced. A reference this SDK cannot resolve is treated as + unconstrained rather than fetched, so validation never performs I/O. +- `A2uiValidator` is exercised by the shared `conformance/core/validator.yaml` + suite. All 20 of its v0.9 cases pass; the 25 v0.8 cases are skipped with a + reason, as this SDK implements v0.9 only. +- **Behaviour change:** `A2uiMessage.fromJson` now throws + `A2uiValidationError` for a message body that is not an object, a missing + required field, or a field of the wrong type. It previously let those fail + as a `TypeError`, which is an `Error` rather than an `Exception` and so was + not catchable as a payload defect. - Added the `A2uiParseError`, `A2uiCompileError`, `A2uiCatalogError`, `A2uiIntegrityError` and `A2uiRecursionError` categories. - Fixed `DataModel.set` silently dropping a write whose parent path resolves to diff --git a/dart/a2ui_core/lib/a2ui_core.dart b/dart/a2ui_core/lib/a2ui_core.dart index 010074ab99..e056a04a0a 100644 --- a/dart/a2ui_core/lib/a2ui_core.dart +++ b/dart/a2ui_core/lib/a2ui_core.dart @@ -47,4 +47,13 @@ export 'src/processing/expressions.dart'; export 'src/processing/processor.dart'; export 'src/rendering/binder.dart'; // Payload validation. +export 'src/validation/component_graph.dart' + show maxComponentDepth, maxFunctionCallDepth, rootComponentId; +export 'src/validation/component_refs.dart' + show + ComponentRefFields, + ComponentReference, + componentReferences, + extractComponentRefFields, + selfDescribingProperties; export 'src/validation/validator.dart'; diff --git a/dart/a2ui_core/lib/src/core/messages.dart b/dart/a2ui_core/lib/src/core/messages.dart index 69e97b4ed8..81d6cefefa 100644 --- a/dart/a2ui_core/lib/src/core/messages.dart +++ b/dart/a2ui_core/lib/src/core/messages.dart @@ -52,42 +52,37 @@ abstract class A2uiMessage { ); } - if (json.containsKey('createSurface')) { - final body = json['createSurface'] as Map; - return CreateSurfaceMessage( - version: version, - surfaceId: body['surfaceId'] as String, - catalogId: body['catalogId'] as String, - theme: body['theme'] as Map?, - sendDataModel: body['sendDataModel'] as bool? ?? false, - ); - } - - if (json.containsKey('updateComponents')) { - final body = json['updateComponents'] as Map; - return UpdateComponentsMessage( - version: version, - surfaceId: body['surfaceId'] as String, - components: (body['components'] as List).cast>(), - ); - } - - if (json.containsKey('updateDataModel')) { - final body = json['updateDataModel'] as Map; - return UpdateDataModelMessage( - version: version, - surfaceId: body['surfaceId'] as String, - path: body['path'] as String?, - value: body['value'], - ); - } - - if (json.containsKey('deleteSurface')) { - final body = json['deleteSurface'] as Map; - return DeleteSurfaceMessage( - version: version, - surfaceId: body['surfaceId'] as String, - ); + for (final key in messageBodyKeys) { + if (!json.containsKey(key)) continue; + final Map body = _body(json, key); + switch (key) { + case 'createSurface': + return CreateSurfaceMessage( + version: version, + surfaceId: _required(body, 'surfaceId', key), + catalogId: _required(body, 'catalogId', key), + theme: _optional>(body, 'theme', key), + sendDataModel: _optional(body, 'sendDataModel', key) ?? false, + ); + case 'updateComponents': + return UpdateComponentsMessage( + version: version, + surfaceId: _required(body, 'surfaceId', key), + components: _components(body, key), + ); + case 'updateDataModel': + return UpdateDataModelMessage( + version: version, + surfaceId: _required(body, 'surfaceId', key), + path: _optional(body, 'path', key), + value: body['value'], + ); + case 'deleteSurface': + return DeleteSurfaceMessage( + version: version, + surfaceId: _required(body, 'surfaceId', key), + ); + } } throw A2uiValidationError( @@ -100,6 +95,86 @@ abstract class A2uiMessage { Map toJson(); } +/// Reads a message body, rejecting one that is not an object. +Map _body(Map json, String key) { + final Object? body = json[key]; + if (body is! Map) { + throw A2uiValidationError( + "Message body '$key' must be an object.", + details: json, + ); + } + return body; +} + +/// Reads a field a message body must declare. +/// +/// A malformed envelope is a payload defect, not a programming error, so it +/// is reported as [A2uiValidationError] rather than left to fail as a cast. +T _required( + Map body, + String field, + String messageType, +) { + final Object? value = body[field]; + if (value == null) { + throw A2uiValidationError( + "Message '$messageType' is missing required field '$field'.", + details: body, + ); + } + if (value is! T) { + throw A2uiValidationError( + "Field '$messageType.$field' must be a $T, got " + '${value.runtimeType}.', + details: body, + ); + } + return value; +} + +/// Reads a field a message body may omit. +T? _optional( + Map body, + String field, + String messageType, +) { + final Object? value = body[field]; + if (value == null) return null; + if (value is! T) { + throw A2uiValidationError( + "Field '$messageType.$field' must be a $T, got " + '${value.runtimeType}.', + details: body, + ); + } + return value; +} + +List> _components( + Map body, + String messageType, +) { + final Object? raw = body['components']; + if (raw is! List) { + throw A2uiValidationError( + "Field '$messageType.components' must be a list.", + details: body, + ); + } + return [ + for (final Object? entry in raw) + if (entry is Map) + entry + else + throw A2uiValidationError( + "Field '$messageType.components' must hold objects, got " + '${entry.runtimeType}.', + details: body, + ), + ]; +} + /// Signals the client to create a new surface. class CreateSurfaceMessage extends A2uiMessage { final String surfaceId; diff --git a/dart/a2ui_core/lib/src/validation/component_graph.dart b/dart/a2ui_core/lib/src/validation/component_graph.dart new file mode 100644 index 0000000000..81f03b36e1 --- /dev/null +++ b/dart/a2ui_core/lib/src/validation/component_graph.dart @@ -0,0 +1,230 @@ +// Copyright 2024 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 '../primitives/errors.dart'; +import 'component_refs.dart'; + +/// The id every surface's component tree is rooted at in v0.9. +const String rootComponentId = 'root'; + +/// The deepest component chain a surface may declare. +const int maxComponentDepth = 50; + +/// The deepest chain of nested function calls a component property may hold. +const int maxFunctionCallDepth = 5; + +/// Matches a JSON Pointer as A2UI writes data-model paths, allowing the +/// leading slash to be omitted. +final RegExp _pathPattern = RegExp( + r'^(?:(?:\/(?:[^~\/]|~[01])*)*|(?:[^~\/]|~[01])+(?:\/(?:[^~\/]|~[01])*)*)$', +); + +/// Checks component ids and references within one surface. +/// +/// [requireRoot] and [allowDangling] distinguish a full render, which must +/// declare every component it names, from an incremental update, which may +/// reference components the client already holds. +/// +/// Throws [A2uiIntegrityError] for duplicate ids, a missing root, or a +/// reference to a component that does not exist. +void checkComponentIntegrity( + List> components, + Map refFields, { + required bool requireRoot, + required bool allowDangling, +}) { + final ids = {}; + for (final component in components) { + final Object? id = component['id']; + if (id is! String) continue; + if (!ids.add(id)) { + throw A2uiIntegrityError( + 'Duplicate component ID: $id', + componentIds: [id], + ); + } + } + + if (allowDangling) return; + + if (requireRoot && !ids.contains(rootComponentId)) { + throw A2uiIntegrityError( + "Missing root component: No component has id='$rootComponentId'", + ); + } + + for (final component in components) { + final String owner = component['id'] as String? ?? 'Unknown'; + for (final ComponentReference reference in _referencesOf( + component, + refFields, + )) { + if (!ids.contains(reference.id)) { + throw A2uiIntegrityError( + "Component '$owner' references non-existent component " + "'${reference.id}' in field '${reference.field}'", + componentIds: [owner, reference.id], + ); + } + } + } +} + +/// Walks the component graph from the root, reporting the ids it reaches. +/// +/// Throws [A2uiRecursionError] for a self-reference, a cycle, or a chain +/// deeper than [maxComponentDepth], and [A2uiIntegrityError] for a component +/// unreachable from the root when [allowOrphans] is false. +Set analyzeComponentTopology( + List> components, + Map refFields, { + required bool requireRoot, + required bool allowOrphans, +}) { + final adjacency = >{}; + final ids = {}; + + for (final component in components) { + final Object? id = component['id']; + if (id is! String) continue; + ids.add(id); + final List edges = adjacency.putIfAbsent(id, () => []); + for (final ComponentReference reference in _referencesOf( + component, + refFields, + )) { + if (reference.id == id) { + throw A2uiRecursionError( + "Self-reference detected: Component '$id' references itself in " + "field '${reference.field}'", + cycle: [id], + ); + } + edges.add(reference.id); + } + } + + final visited = {}; + final onStack = {}; + + void visit(String id, int depth) { + if (depth > maxComponentDepth) { + throw A2uiRecursionError( + 'Global recursion limit exceeded: logical depth > $maxComponentDepth', + cycle: onStack.toList(), + ); + } + visited.add(id); + onStack.add(id); + for (final String next in adjacency[id] ?? const []) { + if (!visited.contains(next)) { + visit(next, depth + 1); + } else if (onStack.contains(next)) { + throw A2uiRecursionError( + "Circular reference detected involving component '$next'", + cycle: [...onStack, next], + ); + } + } + onStack.remove(id); + } + + if (!requireRoot) { + // Without a root there is no single entry point, so every component is + // its own starting point. Cycles still have to be found. + for (final String id in ids.toList()..sort()) { + if (!visited.contains(id)) visit(id, 0); + } + return visited; + } + + if (ids.contains(rootComponentId)) visit(rootComponentId, 0); + + if (!allowOrphans) { + final List orphans = (ids.difference(visited).toList())..sort(); + if (orphans.isNotEmpty) { + throw A2uiIntegrityError( + "Component '${orphans.first}' is not reachable from " + "'$rootComponentId'", + componentIds: orphans, + ); + } + } + return visited; +} + +/// Checks data-model paths and nesting depth anywhere inside a message body. +/// +/// Throws [A2uiValidationError] for a malformed path and [A2uiRecursionError] +/// when nesting or chained function calls run past their caps. +void checkPathsAndRecursion(Object? data) { + void traverse(Object? node, int depth, int callDepth) { + if (depth > maxComponentDepth) { + throw A2uiRecursionError( + 'Global recursion limit exceeded: Depth > $maxComponentDepth', + ); + } + + if (node is List) { + for (final Object? item in node) { + traverse(item, depth + 1, callDepth); + } + return; + } + + if (node is! Map) return; + final Map object = node.cast(); + + final Object? path = object['path']; + if (path is String && !_pathPattern.hasMatch(path)) { + throw A2uiValidationError( + "Invalid path syntax: '$path'", + details: object, + ); + } + + final bool isCall = object.containsKey('call'); + if (isCall) { + if (callDepth >= maxFunctionCallDepth) { + throw A2uiRecursionError( + 'Recursion limit exceeded: functionCall depth > ' + '$maxFunctionCallDepth', + ); + } + for (final MapEntry entry in object.entries) { + traverse( + entry.value, + depth + 1, + entry.key == 'args' ? callDepth + 1 : callDepth, + ); + } + return; + } + + for (final Object? value in object.values) { + traverse(value, depth + 1, callDepth); + } + } + + traverse(data, 0, 0); +} + +Iterable _referencesOf( + Map component, + Map refFields, +) { + final Object? type = component['component']; + if (type is! String) return const []; + return componentReferences(component, refFields[type]); +} diff --git a/dart/a2ui_core/lib/src/validation/component_refs.dart b/dart/a2ui_core/lib/src/validation/component_refs.dart new file mode 100644 index 0000000000..48a997bd99 --- /dev/null +++ b/dart/a2ui_core/lib/src/validation/component_refs.dart @@ -0,0 +1,317 @@ +// Copyright 2024 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 '../core/catalog.dart'; + +/// The JSON Pointer suffix marking a property that holds one component id. +const String componentIdPointer = r'/$defs/ComponentId'; + +/// The JSON Pointer suffix marking a property that holds a `ChildList`. +const String childListPointer = r'/$defs/ChildList'; + +/// Which properties of one component type reference other components. +/// +/// Derived from the component's JSON schema, so a catalog declares its own +/// topology rather than the validator hard-coding property names. +class ComponentRefFields { + /// Properties holding a single component id. + final Set single; + + /// Properties holding a `ChildList`: an id array or a template object. + final Set list; + + /// Properties holding an array of objects whose named keys are ids, such as + /// a tab strip's `items[].child`. Keyed by property name. + final Map> nested; + + const ComponentRefFields({ + this.single = const {}, + this.list = const {}, + this.nested = const {}, + }); + + /// Whether this component type references other components at all. + bool get isEmpty => single.isEmpty && list.isEmpty; +} + +/// Properties that name the component itself rather than another one. +/// +/// `ComponentCommon` declares `id` as a `ComponentId`, so a catalog that +/// inlines it would otherwise read every component's own id as a reference to +/// itself. `component` names the type, never a child. +const Set selfDescribingProperties = {'id', 'component'}; + +/// One reference from a component to another component. +class ComponentReference { + /// The referenced component id. + final String id; + + /// Where the reference sits, for example `children[2]` or `items[0].child`. + final String field; + + const ComponentReference(this.id, this.field); +} + +/// Derives the child-referencing properties of every component in [catalog]. +/// +/// Detection reads the schema, in two equivalent notations: a `$ref` whose +/// pointer ends in `ComponentId` or `ChildList`, as published catalog +/// documents write it, and the `REF:` description pointer that catalogs built +/// in Dart carry (see `CommonSchemas`). Local `$ref`s are followed first +/// against the component's own `$defs`, then against the catalog document. +Map extractComponentRefFields< + C extends ComponentApi, + F extends FunctionApi +>(Catalog catalog) { + final Map document = catalog.catalogSchema; + final result = {}; + + for (final MapEntry entry in catalog.components.entries) { + final Object schema = entry.value.schema.value; + final single = {}; + final list = {}; + final nested = >{}; + _collectFrom(schema, document, single, list, nested); + if (single.isNotEmpty || list.isNotEmpty) { + result[entry.key] = ComponentRefFields( + single: single, + list: list, + nested: nested, + ); + } + } + return result; +} + +/// Walks one component schema, including its `allOf`/`oneOf`/`anyOf` branches, +/// recording every property that references components. +void _collectFrom( + Object? schema, + Map document, + Set single, + Set list, + Map> nested, +) { + if (schema is! Map) return; + final Map node = schema.cast(); + + final Object? properties = node['properties']; + if (properties is Map) { + for (final MapEntry property in properties.entries) { + final name = property.key! as String; + if (selfDescribingProperties.contains(name)) continue; + final Object? resolved = _resolve(property.value, node, document); + if (_marks(resolved, componentIdPointer, node, document)) { + single.add(name); + continue; + } + if (_marks(resolved, childListPointer, node, document)) { + list.add(name); + continue; + } + _collectArrayProperty(name, resolved, node, document, list, nested); + } + } + + for (final combinator in const ['allOf', 'oneOf', 'anyOf']) { + final Object? branches = node[combinator]; + if (branches is! List) continue; + for (final Object? branch in branches) { + _collectFrom( + _resolve(branch, node, document), + document, + single, + list, + nested, + ); + } + } +} + +/// Classifies an array property: an array of ids, or an array of objects with +/// id-bearing keys. +void _collectArrayProperty( + String name, + Object? resolved, + Map owner, + Map document, + Set list, + Map> nested, +) { + if (resolved is! Map) return; + final Map node = resolved.cast(); + if (node['type'] != 'array' || !node.containsKey('items')) return; + + final Object? items = _resolve(node['items'], owner, document); + if (_marks(items, componentIdPointer, owner, document) || + _marks(items, childListPointer, owner, document)) { + list.add(name); + return; + } + if (items is! Map) return; + final Object? itemProperties = items['properties']; + if (itemProperties is! Map) return; + + final keys = {}; + for (final MapEntry property in itemProperties.entries) { + final Object? sub = _resolve(property.value, owner, document); + if (_marks(sub, componentIdPointer, owner, document) || + _marks(sub, childListPointer, owner, document)) { + keys.add(property.key! as String); + } + } + if (keys.isNotEmpty) { + list.add(name); + nested.putIfAbsent(name, () => {}).addAll(keys); + } +} + +/// Whether [schema] carries [pointer], directly or in a combinator branch. +bool _marks( + Object? schema, + String pointer, + Map owner, + Map document, +) { + if (schema is! Map) return false; + final Map node = schema.cast(); + + final Object? ref = node[r'$ref']; + if (ref is String && ref.endsWith(pointer)) return true; + + // Catalogs built in Dart carry the pointer in the description, as + // `REF:` optionally followed by `|`. + final Object? description = node['description']; + if (description is String && description.startsWith('REF:')) { + final String target = description.substring(4).split('|').first; + if (target.endsWith(pointer)) return true; + } + + for (final combinator in const ['oneOf', 'anyOf', 'allOf']) { + final Object? branches = node[combinator]; + if (branches is! List) continue; + for (final Object? branch in branches) { + if (_marks(_resolve(branch, owner, document), pointer, owner, document)) { + return true; + } + } + } + return false; +} + +/// Follows a local `$ref` so detection sees the schema it names. +/// +/// Pointers into `ComponentId` and `ChildList` are left alone: they are the +/// markers being looked for, not indirection to follow. +Object? _resolve( + Object? schema, + Map owner, + Map document, [ + Set? seen, +]) { + if (schema is! Map) return schema; + final Object? ref = schema[r'$ref']; + if (ref is! String || + !ref.startsWith('#/') || + ref.endsWith(componentIdPointer) || + ref.endsWith(childListPointer)) { + return schema; + } + + final Set visited = seen ?? {}; + if (!visited.add(ref)) return schema; + + final List segments = ref.split('/').skip(1).toList(); + final Object? local = _follow(owner, segments); + if (local != null) return _resolve(local, owner, document, visited); + final Object? global = _follow(document, segments); + if (global != null) return _resolve(global, owner, document, visited); + return schema; +} + +/// Walks [segments] through [root], returning null if any step is missing. +Object? _follow(Map root, List segments) { + Object? current = root; + for (final segment in segments) { + if (current is! Map) return null; + if (!current.containsKey(segment)) return null; + current = current[segment]; + } + return identical(current, root) ? null : current; +} + +/// Lists every component [component] references, in declaration order. +/// +/// [fields] describes the component type; a type with no reference properties +/// yields nothing. +Iterable componentReferences( + Map component, + ComponentRefFields? fields, +) sync* { + if (fields == null) return; + for (final MapEntry entry in component.entries) { + if (!fields.single.contains(entry.key) && + !fields.list.contains(entry.key)) { + continue; + } + yield* _pointers(entry.value, entry.key, fields); + } +} + +Iterable _pointers( + Object? value, + String path, + ComponentRefFields fields, +) sync* { + if (value is String) { + yield ComponentReference(value, path); + return; + } + + if (value is List) { + for (var index = 0; index < value.length; index++) { + final Object? item = value[index]; + final itemPath = item is String && !path.contains('[') + ? path + : '$path[$index]'; + yield* _pointers(item, itemPath, fields); + } + return; + } + + if (value is Map) { + final Map node = value.cast(); + // A `ChildList` template names its component through `componentId`. + final Object? templateId = node['componentId']; + if (templateId != null) { + if (templateId is String) { + yield ComponentReference(templateId, '$path.componentId'); + } + return; + } + final String property = path.split('[').first.split('.').first; + final Set? allowed = fields.nested[property]; + if (allowed != null && !path.contains('.')) { + for (final MapEntry entry in node.entries) { + if (allowed.contains(entry.key)) { + yield* _pointers(entry.value, '$path.${entry.key}', fields); + } + } + return; + } + for (final MapEntry entry in node.entries) { + yield* _pointers(entry.value, '$path.${entry.key}', fields); + } + } +} diff --git a/dart/a2ui_core/lib/src/validation/schema_resolution.dart b/dart/a2ui_core/lib/src/validation/schema_resolution.dart new file mode 100644 index 0000000000..f51286f86a --- /dev/null +++ b/dart/a2ui_core/lib/src/validation/schema_resolution.dart @@ -0,0 +1,174 @@ +// Copyright 2024 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. + +/// The file name a catalog refers to for the shared type definitions, however +/// the reference spells the rest of the URL. +const String commonTypesDocument = 'common_types.json'; + +/// The file name `common_types.json` refers back to for the catalog's own +/// definitions, however the reference spells the rest of the URL. +const String catalogDocument = 'catalog.json'; + +/// Rewrites a component schema so it can be validated without any I/O. +/// +/// Every definition the schema reaches — in the catalog [document] and in +/// [commonTypes] — is copied into a single `$defs` block on the result, and +/// each `$ref` is rewritten to point there. Definitions are copied once and +/// shared, so a recursive schema stays recursive: `FunctionCall` reaches +/// `DynamicValue`, which reaches `FunctionCall` again, and the rewritten +/// schema expresses that rather than cutting it short. +/// +/// Local pointers (`#/...`) resolve against the document the subschema +/// carrying them came from. Getting that wrong silently widens a schema, +/// because `DynamicString` and its neighbours reach their alternatives +/// through local pointers. +/// +/// A reference this SDK cannot reach — an unsupplied `common_types.json`, or +/// a document it would have to fetch — is dropped, leaving that subschema +/// unconstrained. The surrounding constraints still apply. Validation +/// therefore never rejects a payload because a schema was unreachable, and +/// never blocks on I/O. +Map resolveSchemaRefs( + Map schema, + Map document, { + Map? commonTypes, +}) { + final resolver = _RefResolver(document, commonTypes); + final Map rewritten = resolver.rewrite( + schema, + _DocumentRef(document, 'catalog'), + ); + if (resolver.defs.isEmpty) return rewritten; + return { + ...rewritten, + r'$defs': { + ...?rewritten[r'$defs'] as Map?, + ...resolver.defs, + }, + }; +} + +/// One of the documents references are resolved against, with a short name +/// used to build collision-free `$defs` keys. +class _DocumentRef { + final Map schema; + final String name; + + const _DocumentRef(this.schema, this.name); +} + +class _RefResolver { + final Map _document; + final Map? _commonTypes; + + /// Definitions hoisted onto the result, keyed by their `$defs` name. + final Map defs = {}; + + /// The `$defs` name already assigned to a document and pointer. + final Map _names = {}; + + _RefResolver(this._document, this._commonTypes); + + Map rewrite(Map node, _DocumentRef base) => + _walk(node, base) as Map; + + Object? _walk(Object? node, _DocumentRef base) { + if (node is List) { + return [for (final Object? item in node) _walk(item, base)]; + } + if (node is! Map) return node; + + final Map object = node.cast(); + final siblings = { + for (final MapEntry entry in object.entries) + if (entry.key != r'$ref') entry.key: _walk(entry.value, base), + }; + + final Object? ref = object[r'$ref']; + if (ref is! String) return siblings; + + final String? name = _hoist(ref, base); + // An unreachable reference leaves the subschema unconstrained. + if (name == null) return siblings; + return {r'$ref': '#/\$defs/$name', ...siblings}; + } + + /// Copies what [ref] names into [defs], returning its `$defs` name. + /// + /// Returns null if this SDK cannot reach the reference. + String? _hoist(String ref, _DocumentRef base) { + final int hash = ref.indexOf('#'); + final String target = hash < 0 ? ref : ref.substring(0, hash); + final String pointer = hash < 0 ? '' : ref.substring(hash + 1); + + final _DocumentRef? source = _documentFor(target, base); + if (source == null || pointer.isEmpty) return null; + + final key = '${source.name}$pointer'; + final String? known = _names[key]; + if (known != null) return known; + + final Object? found = _follow(source.schema, pointer); + if (found is! Map) return null; + + final String name = _defName(key); + // Registered before the copy is walked, so a definition that reaches + // itself points at the name instead of expanding forever. + _names[key] = name; + defs[name] = null; + final copy = Map.of(found.cast()) + // A hoisted definition must not carry its own identity, which would + // move the base every reference below it resolves against. + ..remove(r'$id') + ..remove(r'$schema'); + defs[name] = _walk(copy, source); + return name; + } + + /// The document [target] names, as seen from [base]. + _DocumentRef? _documentFor(String target, _DocumentRef base) { + if (target.isEmpty) return base; + if (target.endsWith(commonTypesDocument)) { + final Map? commonTypes = _commonTypes; + return commonTypes == null + ? null + : _DocumentRef(commonTypes, 'commonTypes'); + } + // `common_types.json` points back at `catalog.json` for the catalog's own + // `anyComponent` and `anyFunction` unions. + if (target.endsWith(catalogDocument)) { + return _DocumentRef(_document, 'catalog'); + } + return null; + } + + Object? _follow(Map root, String pointer) { + Object? current = root; + for (final String raw in pointer.split('/').skip(1)) { + final String segment = raw.replaceAll('~1', '/').replaceAll('~0', '~'); + if (current is! Map || !current.containsKey(segment)) return null; + current = current[segment]; + } + return current; + } + + String _defName(String key) { + final String base = key.replaceAll(RegExp(r'[^A-Za-z0-9]+'), '_'); + if (!defs.containsKey(base)) return base; + for (var i = 2; ; i++) { + final candidate = '$base$i'; + if (!defs.containsKey(candidate)) return candidate; + } + } +} diff --git a/dart/a2ui_core/lib/src/validation/validator.dart b/dart/a2ui_core/lib/src/validation/validator.dart index 2bd8cd0879..15261c6fbb 100644 --- a/dart/a2ui_core/lib/src/validation/validator.dart +++ b/dart/a2ui_core/lib/src/validation/validator.dart @@ -12,10 +12,68 @@ // See the License for the specific language governing permissions and // limitations under the License. +import 'package:json_schema_builder/json_schema_builder.dart'; + import '../core/catalog.dart'; import '../core/messages.dart'; import '../primitives/errors.dart'; import '../primitives/protocol_version.dart'; +import 'component_graph.dart'; +import 'component_refs.dart'; +import 'schema_resolution.dart'; + +/// Everything one surface declares across a payload. +class _SurfacePayload { + /// Whether the payload creates the surface, making it a full render. + bool created = false; + + /// The catalog the payload names for the surface, when it creates it. + String? catalogId; + + /// Components declared for the surface, in the order they arrive. + final List> components = []; + + /// How many `updateComponents` messages the payload sends the surface. + int updates = 0; + + /// Whether the payload declares the whole surface in one message, so every + /// component it declares should be reachable from the root. + /// + /// Once a payload revises the surface across several messages, a component + /// left unreachable is the residue of a replacement rather than a defect: + /// the `31_incremental-dashboard` example in the basic catalog swaps a + /// loading placeholder out for the panel it was standing in for, and the + /// placeholder is meant to be dropped. + bool get isSingleRender => created && updates == 1; + + /// Where each id sits in [components]. + final Map _positions = {}; + + /// Merges one message's components in. + /// + /// A later message that repeats an id replaces that component rather than + /// adding a second one: re-sending a component is how a surface is updated + /// in place, which the `00_incremental` and `31_incremental-dashboard` + /// examples in the basic catalog both do. Repeating an id *within* one + /// message is a contradiction, and is caught before this merge. + void merge(List> incoming) { + updates++; + for (final component in incoming) { + final Object? id = component['id']; + if (id is! String) { + components.add(component); + continue; + } + final int? at = _positions[id]; + if (at == null) { + _positions[id] = components.length; + components.add(component); + } else { + components[at] = component; + } + } + } +} /// Validates A2UI payloads against the protocol schemas and a set of catalogs. /// @@ -23,8 +81,17 @@ import '../primitives/protocol_version.dart'; /// payloads against the same catalogs. Implements v0.9 only: [checkVersion] /// and [parseMessages] reject any other version, or none. /// -/// The deep checks ([validateStructure], [validateAgainstCatalogs]) are not -/// implemented yet and throw [UnimplementedError], as does [validate]. +/// Validation runs in three stages, which [validate] performs in order: +/// [parseMessages] checks envelopes, [validateStructure] checks the component +/// graph, and [validateAgainstCatalogs] checks each component against its +/// catalog's schema. +/// +/// A payload that creates a surface is a full render: it must declare a +/// component with id [rootComponentId], every reference must name a component +/// the payload declares, and every component must be reachable from the root. +/// A payload that only updates components is incremental, so it may reference +/// components the client already holds; duplicate ids, self-references and +/// cycles still fail. class A2uiValidator { /// The catalogs payloads are validated against, keyed by catalog id. final Map> catalogs; @@ -32,8 +99,23 @@ class A2uiValidator { /// The protocol version this validator accepts. final A2uiProtocolVersion protocolVersion; + /// The shared `common_types.json` definitions, when the caller has them. + /// + /// Catalogs reference this document for `ChildList`, `DynamicString` and + /// the other shared types. Supplying it lets [validateAgainstCatalogs] + /// enforce those definitions; without it they are treated as unconstrained, + /// because this SDK never fetches a schema over the network. + final Map? commonTypesSchema; + + /// Child-referencing properties per catalog id, derived on first use. + final Map> _refFields = {}; + + /// Component schemas with their `$ref`s inlined, keyed by catalog id. + final Map> _resolvedComponents = {}; + A2uiValidator({ List> catalogs = const [], + this.commonTypesSchema, this.protocolVersion = A2uiProtocolVersion.v0_9, }) : catalogs = {for (final Catalog c in catalogs) c.id: c}; @@ -44,8 +126,10 @@ class A2uiValidator { factory A2uiValidator.forVersion( Object? version, { List> catalogs = const [], + Map? commonTypesSchema, }) => A2uiValidator( catalogs: catalogs, + commonTypesSchema: commonTypesSchema, protocolVersion: A2uiProtocolVersion.fromJson(version), ); @@ -84,18 +168,78 @@ class A2uiValidator { /// Checks that a message sequence forms a valid component graph: unique /// ids, reachability, no dangling references, no cycles, depth within cap. /// - /// Throws [A2uiIntegrityError] for graph defects and [A2uiRecursionError] - /// for cycles and depth overruns. + /// Throws [A2uiIntegrityError] for graph defects, [A2uiRecursionError] for + /// cycles and depth overruns, and [A2uiValidationError] for a malformed + /// data-model path. void validateStructure(List messages) { - throw UnimplementedError('A2uiValidator.validateStructure'); + for (final message in messages) { + checkPathsAndRecursion(message.toJson()); + // Two components sharing an id in one message contradict each other. + // The same id in a later message updates the component instead, so + // duplicates are looked for per message, before the merge below. + if (message is UpdateComponentsMessage) { + checkComponentIntegrity( + message.components, + const {}, + requireRoot: false, + allowDangling: true, + ); + } + } + + for (final MapEntry entry in _groupBySurface( + messages, + ).entries) { + final _SurfacePayload surface = entry.value; + if (surface.components.isEmpty) continue; + + final Map refFields = _refFieldsFor( + _catalogFor(surface), + ); + checkComponentIntegrity( + surface.components, + refFields, + requireRoot: surface.created, + allowDangling: !surface.created, + ); + analyzeComponentTopology( + surface.components, + refFields, + requireRoot: surface.created, + allowOrphans: !surface.isSingleRender, + ); + } } /// Checks each component and function call against its surface's catalog. /// /// Throws [A2uiCatalogError] if a message names a catalog this validator /// does not hold, and [A2uiValidationError] for schema violations. - Future validateAgainstCatalogs(List messages) { - throw UnimplementedError('A2uiValidator.validateAgainstCatalogs'); + /// + /// A surface the payload does not create carries no catalog id, so its + /// components are checked only when this validator holds exactly one + /// catalog. A validator has no client state to look the surface up in. + Future validateAgainstCatalogs(List messages) async { + final Map surfaces = _groupBySurface(messages); + + for (final _SurfacePayload surface in surfaces.values) { + final String? catalogId = surface.catalogId; + if (catalogId != null && !catalogs.containsKey(catalogId)) { + throw A2uiCatalogError( + "Unknown catalog '$catalogId'. This validator holds: " + '${catalogs.keys.join(', ')}.', + catalogId: catalogId, + ); + } + } + + for (final _SurfacePayload surface in surfaces.values) { + final Catalog? catalog = _catalogFor(surface); + if (catalog == null) continue; + for (final Map component in surface.components) { + await _validateComponent(component, catalog); + } + } } /// Validates a complete payload: envelopes, then structure, then catalog @@ -108,4 +252,89 @@ class A2uiValidator { await validateAgainstCatalogs(messages); return messages; } + + Future _validateComponent( + Map component, + Catalog catalog, + ) async { + final Object? type = component['component']; + if (type is! String) { + throw A2uiValidationError( + "Component '${component['id']}' does not name a component type.", + details: component, + ); + } + final Schema? schema = _resolvedComponentSchemas(catalog)[type]; + if (schema == null) { + throw A2uiValidationError( + "Catalog '${catalog.id}' declares no component named '$type'.", + details: component, + ); + } + + final List errors = await schema.validate(component); + if (errors.isNotEmpty) { + throw A2uiValidationError( + "Component '${component['id']}' does not match the '$type' schema in " + "catalog '${catalog.id}': " + '${errors.map((e) => e.toErrorString()).join('; ')}', + details: component, + ); + } + } + + /// Groups a payload's messages by surface, in arrival order. + Map _groupBySurface(List messages) { + final surfaces = {}; + _SurfacePayload payloadFor(String id) => + surfaces.putIfAbsent(id, _SurfacePayload.new); + + for (final message in messages) { + switch (message) { + case CreateSurfaceMessage(:final surfaceId, :final catalogId): + payloadFor(surfaceId) + ..created = true + ..catalogId = catalogId; + case UpdateComponentsMessage(:final surfaceId, :final components): + payloadFor(surfaceId).merge(components); + case DeleteSurfaceMessage(:final surfaceId): + // A surface deleted within the payload takes its components with + // it, so what came before is not part of the graph any more. + surfaces.remove(surfaceId); + default: + break; + } + } + return surfaces; + } + + /// The catalog a surface's components belong to, when it can be determined. + Catalog? _catalogFor(_SurfacePayload surface) { + final String? catalogId = surface.catalogId; + if (catalogId != null) return catalogs[catalogId]; + return catalogs.length == 1 ? catalogs.values.first : null; + } + + Map _refFieldsFor(Catalog? catalog) { + if (catalog == null) return const {}; + return _refFields.putIfAbsent( + catalog.id, + () => extractComponentRefFields(catalog), + ); + } + + Map _resolvedComponentSchemas(Catalog catalog) => + _resolvedComponents.putIfAbsent(catalog.id, () { + final Map document = catalog.catalogSchema; + return { + for (final MapEntry entry in catalog.components.entries) + entry.key: Schema.fromMap( + resolveSchemaRefs( + entry.value.schema.value, + document, + commonTypes: commonTypesSchema, + ), + ), + }; + }); } diff --git a/dart/a2ui_core/test/conformance/validator_conformance_test.dart b/dart/a2ui_core/test/conformance/validator_conformance_test.dart new file mode 100644 index 0000000000..26823e6805 --- /dev/null +++ b/dart/a2ui_core/test/conformance/validator_conformance_test.dart @@ -0,0 +1,184 @@ +// Copyright 2024 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 'dart:convert'; +import 'dart:io'; + +import 'package:a2ui_core/a2ui_core.dart'; +import 'package:test/test.dart'; + +import 'conformance_harness.dart'; + +/// Runs the shared `conformance/core/validator.yaml` suite against +/// [A2uiValidator]. +/// +/// Cases targeting a protocol version this SDK does not implement are skipped +/// with a reason, so the suite doubles as the implementation checklist. +void main() { + final List> cases = loadConformanceSuite( + 'core/validator.yaml', + ); + + group('conformance core/validator.yaml', () { + test('suite is not empty', () => expect(cases, isNotEmpty)); + + for (final testCase in cases) { + test( + testCase['name']! as String, + () => _runCase(testCase), + skip: _skipReason(testCase), + ); + } + }); +} + +/// Why a case cannot run yet, or null when it can. +String? _skipReason(Map testCase) { + final String? version = caseVersion(testCase); + if (version != null && version != '0.9') { + return 'Targets protocol v$version; this SDK implements v0.9 only.'; + } + return null; +} + +void _runCase(Map testCase) { + final config = testCase['catalog']! as Map; + final Map catalogDocument = _document( + config['catalog_schema'], + ); + final Map? commonTypes = + config.containsKey('common_types_schema') + ? _document(config['common_types_schema']) + : null; + + for (final Map step in _steps(testCase)) { + final List> payload = + (step['payload']! as List).cast>(); + // A fresh validator per step, as the reference Python harness does: each + // step is an independent payload, not a continuation of the previous one. + final A2uiValidator validator = + A2uiValidator( + catalogs: _catalogsFor(catalogDocument, payload), + commonTypesSchema: commonTypes, + ); + + final Object? expectError = + step['expect_error'] ?? testCase['expect_error']; + if (expectError != null) { + expect( + validator.validate(payload), + throwsA(_matchesError(expectError)), + reason: testCase['name'] as String?, + ); + } else { + expect( + validator.validate(payload), + completes, + reason: testCase['name'] as String?, + ); + } + } +} + +/// The steps a case runs, whether it declares one payload or several. +List> _steps(Map testCase) { + final Object? steps = testCase['steps']; + if (steps is List) return steps.cast>(); + return [testCase]; +} + +/// Reads a catalog or common-types document, inline or by path. +Map _document(Object? value) { + if (value is Map) return value; + if (value is String) { + final file = File(resolveConformancePath(value)); + if (!file.existsSync()) { + throw StateError('Conformance schema not found: ${file.path}'); + } + return jsonDecode(file.readAsStringSync()) as Map; + } + throw StateError('Case declares no catalog schema.'); +} + +/// Builds the catalogs a payload needs from the one document a case declares. +/// +/// The suite's fixtures name the catalog `standard` in the document but `std` +/// in the payloads that use it. A validator that indexes catalogs by id would +/// reject those payloads outright, which is not what these cases are testing — +/// they are about the component graph. So the document is registered under +/// every id the payload actually names, and the unknown-catalog check keeps +/// its own coverage in `validator_test.dart`. +List _catalogsFor( + Map document, + List> payload, +) { + final ids = {document['catalogId'] as String? ?? 'standard'}; + for (final envelope in payload) { + final Object? body = envelope['createSurface']; + if (body is Map && body['catalogId'] is String) { + ids.add(body['catalogId']! as String); + } + } + return [ + for (final String id in ids) + Catalog.fromJson({...document, 'catalogId': id}), + ]; +} + +/// Matches the error a case expects, by category and message. +/// +/// `details` is not asserted. It carries the field path and code a Pydantic +/// model reports, which this SDK does not model; the category and message +/// pin the same behaviour. +Matcher _matchesError(Object? expectError) { + if (expectError is String) { + return _messageMatches(expectError); + } + final Map expected = (expectError! as Map) + .cast(); + final Matcher category = _categoryMatches(expected['category'] as String?); + final Object? message = expected['message']; + if (message is! String) return category; + return allOf(category, _messageMatches(message)); +} + +Matcher _categoryMatches(String? category) => switch (category) { + 'ParseError' => isA(), + 'ValidationError' => isA(), + 'CatalogError' => isA(), + 'IntegrityError' => isA(), + 'RecursionError' => isA(), + 'CompileError' => isA(), + 'DataError' => isA(), + 'StateError' => isA(), + _ => isA(), +}; + +Matcher _messageMatches(String pattern) => isA().having( + (e) => e.message, + 'message', + matches(RegExp(_align(pattern))), +); + +/// Widens a case's expected message to the wording this SDK uses. +/// +/// The suite spells some messages the way the Python SDK's JSON Schema +/// library reports them. The reference harness does the same alignment for +/// Pydantic's wording; this is the Dart column of the same table. +String _align(String pattern) { + if (pattern.contains('is not of type')) { + return '($pattern|is not of type)'; + } + return pattern; +} diff --git a/dart/a2ui_core/test/messages_test.dart b/dart/a2ui_core/test/messages_test.dart index 44535446f5..0d22e39ac5 100644 --- a/dart/a2ui_core/test/messages_test.dart +++ b/dart/a2ui_core/test/messages_test.dart @@ -144,6 +144,66 @@ void main() { ); }); + test('throws when a required body field is missing', () { + // Reported as a validation error rather than left to fail as a cast: a + // malformed envelope is a payload defect, not a programming error. + expect( + () => A2uiMessage.fromJson({ + 'version': 'v0.9', + 'createSurface': {'surfaceId': 's1'}, + }), + throwsA(isA()), + ); + expect( + () => A2uiMessage.fromJson({ + 'version': 'v0.9', + 'updateComponents': {'surfaceId': 's1'}, + }), + throwsA(isA()), + ); + }); + + test('throws when a body field has the wrong type', () { + expect( + () => A2uiMessage.fromJson({ + 'version': 'v0.9', + 'createSurface': {'surfaceId': 123, 'catalogId': 'c1'}, + }), + throwsA(isA()), + ); + expect( + () => A2uiMessage.fromJson({ + 'version': 'v0.9', + 'updateComponents': {'surfaceId': 's1', 'components': 'nope'}, + }), + throwsA(isA()), + ); + expect( + () => A2uiMessage.fromJson({ + 'version': 'v0.9', + 'updateComponents': { + 'surfaceId': 's1', + 'components': ['nope'], + }, + }), + throwsA(isA()), + ); + expect( + () => A2uiMessage.fromJson({ + 'version': 'v0.9', + 'updateDataModel': {'surfaceId': 's1', 'path': 7}, + }), + throwsA(isA()), + ); + }); + + test('throws when the message body is not an object', () { + expect( + () => A2uiMessage.fromJson({'version': 'v0.9', 'deleteSurface': 's1'}), + throwsA(isA()), + ); + }); + test('throws when more than one message type is present', () { expect( () => A2uiMessage.fromJson({ diff --git a/dart/a2ui_core/test/validator_basic_catalog_test.dart b/dart/a2ui_core/test/validator_basic_catalog_test.dart new file mode 100644 index 0000000000..94db661091 --- /dev/null +++ b/dart/a2ui_core/test/validator_basic_catalog_test.dart @@ -0,0 +1,262 @@ +// Copyright 2024 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 'dart:convert'; +import 'dart:io'; + +import 'package:a2ui_core/a2ui_core.dart'; +import 'package:test/test.dart'; + +import 'conformance/conformance_harness.dart'; + +/// Exercises `A2uiValidator` against the published basic catalog and the +/// example payloads that ship with it, rather than against a catalog written +/// for the test. Those examples are the specification's own statement of what +/// a valid v0.9 payload looks like, so they are the sharpest available check +/// that validation is neither too strict nor too permissive. + +const String basicCatalogId = + 'https://a2ui.org/specification/v0_9/catalogs/basic/catalog.json'; + +Map _readJson(String relativePath) => + jsonDecode(File(resolveConformancePath(relativePath)).readAsStringSync()) + as Map; + +Map basicCatalogDocument() => + _readJson('../specification/v0_9_1/catalogs/basic/catalog.json'); + +Map commonTypesDocument() => + _readJson('../specification/v0_9/json/common_types.json'); + +A2uiValidator basicValidator() => + A2uiValidator( + catalogs: [Catalog.fromJson(basicCatalogDocument())], + commonTypesSchema: commonTypesDocument(), + ); + +/// A payload declaring one surface against the basic catalog. +List> render(List> components) => [ + { + 'version': 'v0.9', + 'createSurface': {'surfaceId': 's', 'catalogId': basicCatalogId}, + }, + { + 'version': 'v0.9', + 'updateComponents': {'surfaceId': 's', 'components': components}, + }, +]; + +void main() { + group('the basic catalog', () { + test('declares the child references of its layout components', () { + final SchemaCatalog catalog = Catalog.fromJson(basicCatalogDocument()); + final Map refs = extractComponentRefFields( + catalog, + ); + + expect(refs['Card']!.single, {'child'}); + expect(refs['Button']!.single, {'child'}); + expect(refs['Modal']!.single, {'trigger', 'content'}); + expect(refs['Row']!.list, {'children'}); + expect(refs['Column']!.list, {'children'}); + expect(refs['List']!.list, {'children'}); + expect(refs['Tabs']!.list, {'tabs'}); + expect(refs['Tabs']!.nested, { + 'tabs': {'child'}, + }); + // Components that reference nothing are absent, not empty entries. + expect(refs.keys, isNot(contains('Text'))); + expect(refs.keys, isNot(contains('Image'))); + }); + }); + + group('validating the basic catalog examples', () { + final examples = Directory( + resolveConformancePath('../specification/v0_9_1/catalogs/basic/examples'), + ); + final List files = examples.listSync().whereType().where((f) { + return f.path.endsWith('.json'); + }).toList()..sort((a, b) => a.path.compareTo(b.path)); + + test('the examples are present', () { + expect(files, isNotEmpty, reason: examples.path); + }); + + for (final file in files) { + final String name = file.uri.pathSegments.last; + test(name, () async { + final Object? document = jsonDecode(file.readAsStringSync()); + final Object? messages = document is Map + ? document['messages'] + : document; + expect( + messages, + isA>(), + reason: '$name declares no message list', + ); + final List> payload = [ + for (final Object? message in messages! as List) + (message! as Map).cast(), + ]; + + await expectLater(basicValidator().validate(payload), completes); + }); + } + }); + + group('validating against the basic catalog rejects', () { + late A2uiValidator validator; + + setUp(() => validator = basicValidator()); + + test('a component missing a required property', () { + expect( + validator.validate( + render([ + {'id': 'root', 'component': 'Text'}, + ]), + ), + throwsA(isA()), + ); + }); + + test('a value outside a property enum', () { + expect( + validator.validate( + render([ + {'id': 'root', 'component': 'Text', 'text': 'hi', 'variant': 'h9'}, + ]), + ), + throwsA(isA()), + ); + }); + + test('a property the component does not declare', () { + expect( + validator.validate( + render([ + { + 'id': 'root', + 'component': 'Text', + 'text': 'hi', + 'notAProperty': 1, + }, + ]), + ), + throwsA(isA()), + ); + }); + + test('a component type the catalog does not declare', () { + expect( + validator.validate( + render([ + {'id': 'root', 'component': 'Frobnicator'}, + ]), + ), + throwsA(isA()), + ); + }); + + test('a call to a function the catalog does not declare', () { + // `anyFunction` is reached through `common_types.json`, which points + // back at the catalog document, so resolving it in both directions is + // what makes this check possible. + expect( + validator.validate( + render([ + { + 'id': 'root', + 'component': 'Text', + 'text': { + 'call': 'noSuchFunction', + 'args': {}, + 'returnType': 'string', + }, + }, + ]), + ), + throwsA(isA()), + ); + }); + + test('a child reference that names no component', () { + expect( + validator.validate( + render([ + {'id': 'root', 'component': 'Card', 'child': 'missing'}, + ]), + ), + throwsA(isA()), + ); + }); + + test('a malformed child list', () { + expect( + validator.validate( + render([ + { + 'id': 'root', + 'component': 'Column', + // A template needs `path` as well as `componentId`. + 'children': {'componentId': 'a'}, + }, + {'id': 'a', 'component': 'Text', 'text': 'x'}, + ]), + ), + throwsA(isA()), + ); + }); + }); + + group('validating against the basic catalog accepts', () { + late A2uiValidator validator; + + setUp(() => validator = basicValidator()); + + test('a data binding in place of a literal', () { + expect( + validator.validate( + render([ + { + 'id': 'root', + 'component': 'Text', + 'text': {'path': '/greeting'}, + }, + ]), + ), + completes, + ); + }); + + test('a call to a function the catalog declares', () { + expect( + validator.validate( + render([ + { + 'id': 'root', + 'component': 'Text', + 'text': { + 'call': 'formatString', + 'args': {'value': 'x'}, + 'returnType': 'string', + }, + }, + ]), + ), + completes, + ); + }); + }); +} diff --git a/dart/a2ui_core/test/validator_test.dart b/dart/a2ui_core/test/validator_test.dart index 58c93e666d..9cbfd80769 100644 --- a/dart/a2ui_core/test/validator_test.dart +++ b/dart/a2ui_core/test/validator_test.dart @@ -15,12 +15,15 @@ import 'package:a2ui_core/a2ui_core.dart'; import 'package:test/test.dart'; -/// Marks behaviour `A2uiValidator` does not implement yet. -const String pendingValidator = - 'A2uiValidator deep checks are not implemented yet.'; - const String catalogId = 'https://example.com/catalogs/test.json'; +/// The pointers a catalog document uses to mark child references, spelled the +/// way the shared conformance suites spell them. +const String componentIdRef = + 'https://a2ui.org/specification/v0_9/common_types.json#/\$defs/ComponentId'; +const String childListRef = + 'https://a2ui.org/specification/v0_9/common_types.json#/\$defs/ChildList'; + Map createSurface({String version = 'v0.9'}) => { 'version': version, 'createSurface': {'surfaceId': 's1', 'catalogId': catalogId}, @@ -32,6 +35,8 @@ Map updateComponents(List> components) => 'updateComponents': {'surfaceId': 's1', 'components': components}, }; +/// A catalog exercising every way a component can reference another: a single +/// id, a `ChildList`, and an array of objects with id-bearing keys. SchemaCatalog testCatalog() => Catalog.fromJson({ 'catalogId': catalogId, 'components': { @@ -39,8 +44,9 @@ SchemaCatalog testCatalog() => Catalog.fromJson({ 'type': 'object', 'properties': { 'component': {'const': 'Card'}, - 'child': {'type': 'string'}, + 'child': {r'$ref': componentIdRef}, }, + 'required': ['component'], }, 'Text': { 'type': 'object', @@ -48,16 +54,84 @@ SchemaCatalog testCatalog() => Catalog.fromJson({ 'component': {'const': 'Text'}, 'text': {'type': 'string'}, }, - 'required': ['text'], + 'required': ['component', 'text'], + }, + 'Column': { + 'type': 'object', + 'properties': { + 'component': {'const': 'Column'}, + 'children': {r'$ref': childListRef}, + }, + 'required': ['component', 'children'], + }, + 'Tabs': { + 'type': 'object', + 'properties': { + 'component': {'const': 'Tabs'}, + 'items': { + 'type': 'array', + 'items': { + 'type': 'object', + 'properties': { + 'label': {'type': 'string'}, + 'child': {r'$ref': componentIdRef}, + }, + }, + }, + }, + 'required': ['component'], }, }, }); +/// The parts of `common_types.json` this catalog references. +Map commonTypes() => { + r'$defs': { + 'ComponentId': {'type': 'string'}, + 'ChildList': { + 'oneOf': [ + { + 'type': 'array', + 'items': {r'$ref': '#/\$defs/ComponentId'}, + }, + { + 'type': 'object', + 'properties': { + 'componentId': {r'$ref': '#/\$defs/ComponentId'}, + 'path': {'type': 'string'}, + }, + 'required': ['componentId', 'path'], + 'additionalProperties': false, + }, + ], + }, + }, +}; + +A2uiValidator newValidator({ + bool withCommonTypes = false, +}) => A2uiValidator( + catalogs: [testCatalog()], + commonTypesSchema: withCommonTypes ? commonTypes() : null, +); + +Map text(String id, [String value = 'x']) => { + 'id': id, + 'component': 'Text', + 'text': value, +}; + +Map card(String id, String child) => { + 'id': id, + 'component': 'Card', + 'child': child, +}; + void main() { group('A2uiValidator version gating', () { test('accepts payloads declaring the supported version', () { final A2uiValidator validator = - A2uiValidator(catalogs: [testCatalog()]); + newValidator(); expect(validator.checkVersion(createSurface()), A2uiProtocolVersion.v0_9); expect(validator.parseMessages([createSurface()]), hasLength(1)); @@ -69,7 +143,7 @@ void main() { test('rejects payloads declaring another protocol version', () { final A2uiValidator validator = - A2uiValidator(catalogs: [testCatalog()]); + newValidator(); for (final version in ['v0.8', 'v0.9.1', 'v1.0']) { expect( @@ -87,7 +161,7 @@ void main() { test('rejects payloads that omit the version', () { final A2uiValidator validator = - A2uiValidator(catalogs: [testCatalog()]); + newValidator(); final Map> message = { 'createSurface': {'surfaceId': 's1', 'catalogId': catalogId}, }; @@ -104,7 +178,7 @@ void main() { test('rejects an envelope naming no known message body', () { final A2uiValidator validator = - A2uiValidator(catalogs: [testCatalog()]); + newValidator(); expect( () => validator.parseMessages([ @@ -134,7 +208,7 @@ void main() { test('indexes the catalogs it validates against by id', () { final A2uiValidator validator = - A2uiValidator(catalogs: [testCatalog()]); + newValidator(); expect(validator.catalogs.keys, [catalogId]); }); }); @@ -142,86 +216,491 @@ void main() { group('A2uiValidator.validateStructure', () { test('accepts a well formed component graph', () { final A2uiValidator validator = - A2uiValidator(catalogs: [testCatalog()]); + newValidator(); final List messages = validator.parseMessages([ createSurface(), - updateComponents([ - {'id': 'root', 'component': 'Card', 'child': 'label'}, - {'id': 'label', 'component': 'Text', 'text': 'Hello'}, - ]), + updateComponents([card('root', 'label'), text('label', 'Hello')]), ]); expect(() => validator.validateStructure(messages), returnsNormally); - }, skip: pendingValidator); + }); test('rejects duplicate component ids', () { final A2uiValidator validator = - A2uiValidator(catalogs: [testCatalog()]); + newValidator(); + final List messages = validator.parseMessages([ + createSurface(), + updateComponents([text('root', 'a'), text('root', 'b')]), + ]); + + expect( + () => validator.validateStructure(messages), + throwsA( + isA().having( + (e) => e.message, + 'message', + contains('Duplicate component ID: root'), + ), + ), + ); + }); + + test('rejects a child reference that names no component', () { + final A2uiValidator validator = + newValidator(); + final List messages = validator.parseMessages([ + createSurface(), + updateComponents([card('root', 'missing')]), + ]); + + expect( + () => validator.validateStructure(messages), + throwsA( + isA().having( + (e) => e.message, + 'message', + contains('references non-existent component'), + ), + ), + ); + }); + + test('rejects a payload that declares no root component', () { + final A2uiValidator validator = + newValidator(); + final List messages = validator.parseMessages([ + createSurface(), + updateComponents([text('label', 'Hello')]), + ]); + + expect( + () => validator.validateStructure(messages), + throwsA( + isA().having( + (e) => e.message, + 'message', + contains('Missing root component'), + ), + ), + ); + }); + + test('rejects a component unreachable from the root', () { + final A2uiValidator validator = + newValidator(); final List messages = validator.parseMessages([ createSurface(), updateComponents([ - {'id': 'root', 'component': 'Text', 'text': 'a'}, - {'id': 'root', 'component': 'Text', 'text': 'b'}, + card('root', 'label'), + text('label', 'Hello'), + text('orphan', 'Nobody points at me'), ]), ]); expect( () => validator.validateStructure(messages), + throwsA( + isA().having( + (e) => e.message, + 'message', + contains("Component 'orphan' is not reachable"), + ), + ), + ); + }); + + test('rejects a self reference', () { + final A2uiValidator validator = + newValidator(); + final List messages = validator.parseMessages([ + createSurface(), + updateComponents([card('root', 'root')]), + ]); + + expect( + () => validator.validateStructure(messages), + throwsA( + isA() + .having( + (e) => e.message, + 'message', + contains('Self-reference detected'), + ) + .having((e) => e.cycle, 'cycle', ['root']), + ), + ); + }); + + test('rejects a cycle in the component graph', () { + final A2uiValidator validator = + newValidator(); + final List messages = validator.parseMessages([ + createSurface(), + updateComponents([card('root', 'b'), card('b', 'root')]), + ]); + + expect( + () => validator.validateStructure(messages), + throwsA( + isA().having( + (e) => e.message, + 'message', + contains('Circular reference detected'), + ), + ), + ); + }); + + test('rejects a chain deeper than the cap', () { + final A2uiValidator validator = + newValidator(); + final components = >[card('root', 'c0')]; + const int chain = maxComponentDepth + 5; + for (var i = 0; i < chain; i++) { + components.add(card('c$i', 'c${i + 1}')); + } + components.add(text('c$chain', 'leaf')); + + final List messages = validator.parseMessages([ + createSurface(), + updateComponents(components), + ]); + + expect( + () => validator.validateStructure(messages), + throwsA( + isA().having( + (e) => e.message, + 'message', + contains('recursion limit exceeded'), + ), + ), + ); + }); + + test('follows a static child list', () { + final A2uiValidator validator = + newValidator(); + final List valid = validator.parseMessages([ + createSurface(), + updateComponents([ + { + 'id': 'root', + 'component': 'Column', + 'children': ['a', 'b'], + }, + text('a'), + text('b'), + ]), + ]); + expect(() => validator.validateStructure(valid), returnsNormally); + + final List dangling = validator.parseMessages([ + createSurface(), + updateComponents([ + { + 'id': 'root', + 'component': 'Column', + 'children': ['a', 'missing'], + }, + text('a'), + ]), + ]); + expect( + () => validator.validateStructure(dangling), throwsA(isA()), ); - }, skip: pendingValidator); + }); - test('rejects a child reference that names no component', () { + test('follows a child list template', () { final A2uiValidator validator = - A2uiValidator(catalogs: [testCatalog()]); + newValidator(); final List messages = validator.parseMessages([ createSurface(), updateComponents([ - {'id': 'root', 'component': 'Card', 'child': 'missing'}, + { + 'id': 'root', + 'component': 'Column', + 'children': {'componentId': 'row', 'path': '/items'}, + }, + text('row'), ]), ]); + expect(() => validator.validateStructure(messages), returnsNormally); + final List dangling = validator.parseMessages([ + createSurface(), + updateComponents([ + { + 'id': 'root', + 'component': 'Column', + 'children': {'componentId': 'missing', 'path': '/items'}, + }, + ]), + ]); expect( - () => validator.validateStructure(messages), + () => validator.validateStructure(dangling), throwsA(isA()), ); - }, skip: pendingValidator); + }); - test('rejects a cycle in the component graph', () { + test('follows references nested in an array of objects', () { final A2uiValidator validator = - A2uiValidator(catalogs: [testCatalog()]); + newValidator(); final List messages = validator.parseMessages([ createSurface(), updateComponents([ - {'id': 'a', 'component': 'Card', 'child': 'b'}, - {'id': 'b', 'component': 'Card', 'child': 'a'}, + { + 'id': 'root', + 'component': 'Tabs', + 'items': [ + {'label': 'One', 'child': 'a'}, + {'label': 'Two', 'child': 'missing'}, + ], + }, + text('a'), ]), ]); expect( () => validator.validateStructure(messages), - throwsA(isA()), + throwsA( + isA().having( + (e) => e.message, + 'message', + contains("in field 'items[1].child'"), + ), + ), ); - }, skip: pendingValidator); + }); + + test('ignores a property that does not reference components', () { + final A2uiValidator validator = + newValidator(); + // `text` is a plain string, so 'root' inside it is not a reference and + // must not read as a self-reference. + final List messages = validator.parseMessages([ + createSurface(), + updateComponents([text('root', 'root')]), + ]); + + expect(() => validator.validateStructure(messages), returnsNormally); + }); + + test('does not read a component id as a reference to itself', () { + // A catalog that inlines `ComponentCommon` declares `id` as a + // `ComponentId`. That names the component itself, so reading it as a + // child reference would make every component self-referential. + final SchemaCatalog inlined = Catalog.fromJson({ + 'catalogId': catalogId, + 'components': { + 'Card': { + 'type': 'object', + 'allOf': [ + {r'$ref': '#/\$defs/ComponentCommon'}, + { + 'type': 'object', + 'properties': { + 'component': {'const': 'Card'}, + 'child': {r'$ref': componentIdRef}, + }, + }, + ], + }, + }, + r'$defs': { + 'ComponentCommon': { + 'type': 'object', + 'properties': { + 'id': {r'$ref': componentIdRef}, + }, + 'required': ['id'], + }, + }, + }); + + expect(extractComponentRefFields(inlined)['Card']!.single, { + 'child', + }, reason: 'id must not be read as a child reference'); + + final A2uiValidator validator = + A2uiValidator(catalogs: [inlined]); + expect( + () => validator.validateStructure( + validator.parseMessages([ + createSurface(), + updateComponents([ + {'id': 'root', 'component': 'Card', 'child': 'a'}, + {'id': 'a', 'component': 'Card'}, + ]), + ]), + ), + returnsNormally, + ); + }); + + test('rejects a malformed data model path', () { + final A2uiValidator validator = + newValidator(); + final List messages = validator.parseMessages([ + { + 'version': 'v0.9', + 'updateDataModel': {'surfaceId': 's1', 'path': 'a~2b', 'value': 1}, + }, + ]); + + expect( + () => validator.validateStructure(messages), + throwsA( + isA().having( + (e) => e.message, + 'message', + contains('Invalid path syntax'), + ), + ), + ); + }); + + test('rejects function calls nested past the cap', () { + final A2uiValidator validator = + newValidator(); + Map call = {'call': 'f', 'args': {}}; + for (var i = 0; i < maxFunctionCallDepth + 1; i++) { + call = { + 'call': 'f', + 'args': {'inner': call}, + }; + } + final List messages = validator.parseMessages([ + updateComponents([ + {'id': 'root', 'component': 'Text', 'text': call}, + ]), + ]); + + expect( + () => validator.validateStructure(messages), + throwsA( + isA().having( + (e) => e.message, + 'message', + contains('functionCall depth'), + ), + ), + ); + }); + + group('incremental updates', () { + test('allow a missing root and references to existing components', () { + final A2uiValidator validator = + newValidator(); + final List messages = validator.parseMessages([ + updateComponents([card('panel', 'alreadyOnTheClient')]), + ]); + + expect(() => validator.validateStructure(messages), returnsNormally); + }); + + test('still reject duplicate ids', () { + final A2uiValidator validator = + newValidator(); + final List messages = validator.parseMessages([ + updateComponents([text('a', 'one'), text('a', 'two')]), + ]); + + expect( + () => validator.validateStructure(messages), + throwsA(isA()), + ); + }); + + test('still reject a self reference', () { + final A2uiValidator validator = + newValidator(); + final List messages = validator.parseMessages([ + updateComponents([card('a', 'a')]), + ]); + + expect( + () => validator.validateStructure(messages), + throwsA(isA()), + ); + }); + + test('still reject a cycle', () { + final A2uiValidator validator = + newValidator(); + final List messages = validator.parseMessages([ + updateComponents([card('a', 'b'), card('b', 'a')]), + ]); + + expect( + () => validator.validateStructure(messages), + throwsA(isA()), + ); + }); + }); + + test('accumulates components across updates to the same surface', () { + final A2uiValidator validator = + newValidator(); + final List messages = validator.parseMessages([ + createSurface(), + updateComponents([card('root', 'label')]), + updateComponents([text('label', 'Hello')]), + ]); + + expect(() => validator.validateStructure(messages), returnsNormally); + }); + + test('treats an id repeated in a later message as an update', () { + final A2uiValidator validator = + newValidator(); + // The second message replaces `root`, pointing it at `b` instead of + // `a`. That is how the basic catalog's `00_incremental` example swaps + // a placeholder out, so it must not read as a duplicate id — and `a`, + // now unreachable, is the residue of the replacement rather than an + // orphan. A surface declared in one message is still held to full + // reachability, which the test above covers. + final List messages = validator.parseMessages([ + createSurface(), + updateComponents([card('root', 'a'), text('a')]), + updateComponents([card('root', 'b'), text('b')]), + ]); + + expect(() => validator.validateStructure(messages), returnsNormally); + }); + + test('drops the components of a surface deleted in the same payload', () { + final A2uiValidator validator = + newValidator(); + final List messages = validator.parseMessages([ + createSurface(), + updateComponents([card('root', 'missing')]), + { + 'version': 'v0.9', + 'deleteSurface': {'surfaceId': 's1'}, + }, + ]); + + expect(() => validator.validateStructure(messages), returnsNormally); + }); }); group('A2uiValidator.validateAgainstCatalogs', () { test('accepts components that satisfy the catalog schema', () { final A2uiValidator validator = - A2uiValidator(catalogs: [testCatalog()]); + newValidator(); final List messages = validator.parseMessages([ createSurface(), - updateComponents([ - {'id': 'label', 'component': 'Text', 'text': 'Hello'}, - ]), + updateComponents([text('label', 'Hello')]), ]); expect(validator.validateAgainstCatalogs(messages), completes); - }, skip: pendingValidator); + }); test('rejects a component missing a required property', () { final A2uiValidator validator = - A2uiValidator(catalogs: [testCatalog()]); + newValidator(); final List messages = validator.parseMessages([ createSurface(), updateComponents([ @@ -233,11 +712,49 @@ void main() { validator.validateAgainstCatalogs(messages), throwsA(isA()), ); - }, skip: pendingValidator); + }); + + test('rejects a property of the wrong type', () { + final A2uiValidator validator = + newValidator(); + final List messages = validator.parseMessages([ + createSurface(), + updateComponents([ + {'id': 'label', 'component': 'Text', 'text': 42}, + ]), + ]); + + expect( + validator.validateAgainstCatalogs(messages), + throwsA(isA()), + ); + }); + + test('rejects a component the catalog does not declare', () { + final A2uiValidator validator = + newValidator(); + final List messages = validator.parseMessages([ + createSurface(), + updateComponents([ + {'id': 'label', 'component': 'Nonexistent'}, + ]), + ]); + + expect( + validator.validateAgainstCatalogs(messages), + throwsA( + isA().having( + (e) => e.message, + 'message', + contains('declares no component'), + ), + ), + ); + }); test('rejects a surface created against an unregistered catalog', () { final A2uiValidator validator = - A2uiValidator(catalogs: [testCatalog()]); + newValidator(); final List messages = validator.parseMessages([ { 'version': 'v0.9', @@ -252,33 +769,89 @@ void main() { validator.validateAgainstCatalogs(messages), throwsA(isA()), ); - }, skip: pendingValidator); + }); + + test('enforces common_types definitions when they are supplied', () { + final A2uiValidator validator = + newValidator(withCommonTypes: true); + final List messages = validator.parseMessages([ + createSurface(), + updateComponents([ + { + 'id': 'root', + 'component': 'Column', + // Neither a list of ids nor a `{componentId, path}` template. + 'children': {'componentId': 'row'}, + }, + ]), + ]); + + expect( + validator.validateAgainstCatalogs(messages), + throwsA(isA()), + ); + }); + + test('treats an unresolvable reference as unconstrained', () { + // Without `common_types.json`, `ChildList` cannot be resolved. The + // surrounding constraints still apply, but the reference itself is + // skipped rather than failing the payload. + final A2uiValidator validator = + newValidator(); + final List messages = validator.parseMessages([ + createSurface(), + updateComponents([ + { + 'id': 'root', + 'component': 'Column', + 'children': {'componentId': 'row'}, + }, + ]), + ]); + + expect(validator.validateAgainstCatalogs(messages), completes); + }); }); group('A2uiValidator.validate', () { test('returns the parsed messages for a valid payload', () async { final A2uiValidator validator = - A2uiValidator(catalogs: [testCatalog()]); + newValidator(); final List messages = await validator.validate([ createSurface(), - updateComponents([ - {'id': 'label', 'component': 'Text', 'text': 'Hello'}, - ]), + updateComponents([card('root', 'label'), text('label', 'Hello')]), ]); expect(messages, hasLength(2)); expect(messages.first, isA()); - }, skip: pendingValidator); + }); test('rejects an unsupported version before any deep check runs', () { final A2uiValidator validator = - A2uiValidator(catalogs: [testCatalog()]); + newValidator(); expect( validator.validate([createSurface(version: 'v1.0')]), throwsA(isA()), ); }); + + test('reports a structural failure before a catalog failure', () { + final A2uiValidator validator = + newValidator(); + + // `root` is both a dangling reference and missing its required `text`. + // Structure runs first, so the integrity error is what surfaces. + expect( + validator.validate([ + createSurface(), + updateComponents([ + {'id': 'root', 'component': 'Card', 'child': 'missing'}, + ]), + ]), + throwsA(isA()), + ); + }); }); } From 54938ded6ad2b49e6bff11a2c7f0055bab5d0309 Mon Sep 17 00:00:00 2001 From: Polina Cherkasova Date: Fri, 28 Aug 2026 11:08:37 -0700 Subject: [PATCH 15/22] - --- .../tests/conformance/test_conformance.py | 13 +- blueprints/modules/a2ui_agent.blueprint.md | 144 +++++-- blueprints/modules/a2ui_core.blueprint.md | 78 ++++ conformance/README.md | 5 + conformance/agent/catalog_provider.yaml | 33 ++ conformance/agent/catalog_resolver.yaml | 193 +++++++++ conformance/agent/catalog_transformer.yaml | 78 ++++ conformance/agent/inference_format.yaml | 29 -- conformance/conformance_schema.json | 89 ++-- conformance/core/catalog.yaml | 60 --- conformance/core/message_processor.yaml | 392 ------------------ .../lib/src/processor/generator.dart | 30 +- .../conformance/agent_conformance_test.dart | 92 ++++ .../test/conformance/conformance_harness.dart | 29 ++ .../test/e2e/minimal_snippet_test.dart | 10 +- .../test/processor/generator_test.dart | 57 ++- .../message_processor_conformance_test.dart | 252 ----------- .../message-processor.conformance.test.ts | 186 --------- 18 files changed, 753 insertions(+), 1017 deletions(-) create mode 100644 conformance/agent/catalog_provider.yaml create mode 100644 conformance/agent/catalog_resolver.yaml create mode 100644 conformance/agent/catalog_transformer.yaml delete mode 100644 conformance/core/message_processor.yaml delete mode 100644 dart/a2ui_core/test/conformance/message_processor_conformance_test.dart delete mode 100644 renderers/web_core/tests/conformance/message-processor.conformance.test.ts diff --git a/agent_sdks/python/a2ui_agent/tests/conformance/test_conformance.py b/agent_sdks/python/a2ui_agent/tests/conformance/test_conformance.py index f0d57754d1..8cfe61ba7c 100644 --- a/agent_sdks/python/a2ui_agent/tests/conformance/test_conformance.py +++ b/agent_sdks/python/a2ui_agent/tests/conformance/test_conformance.py @@ -276,7 +276,11 @@ def test_validator_conformance(name, test_case): # --- Catalog Conformance --- -cases_catalog = get_conformance_cases("core/catalog.yaml") +# `agent/catalog_transformer.yaml` holds the pruning cases that belong to the +# agent SDK rather than to core; both suites drive the same `prune` action. +cases_catalog = get_conformance_cases("core/catalog.yaml") + get_conformance_cases( + "agent/catalog_transformer.yaml" +) @pytest.mark.parametrize( @@ -329,7 +333,12 @@ def test_catalog_conformance(name, test_case): # --- Schema Manager Conformance --- -cases_schema_manager = get_conformance_cases("agent/inference_format.yaml") +# `agent/catalog_provider.yaml` holds the catalog loading cases, which used to +# sit in `inference_format.yaml`; both suites drive the same `load_catalog` +# action. +cases_schema_manager = get_conformance_cases( + "agent/inference_format.yaml" +) + get_conformance_cases("agent/catalog_provider.yaml") @pytest.mark.parametrize( diff --git a/blueprints/modules/a2ui_agent.blueprint.md b/blueprints/modules/a2ui_agent.blueprint.md index ba9fea059a..3b5a1f0700 100644 --- a/blueprints/modules/a2ui_agent.blueprint.md +++ b/blueprints/modules/a2ui_agent.blueprint.md @@ -49,7 +49,7 @@ graph TD - **Parsers**: Response extraction engines performing tag unwrapping (`unwrap`), streaming chunk processing (`parse_chunk`), syntax compilation (`compile`) and decompilation (`decompile`). - **Validation Layer**: Leverages core `A2uiValidator` capabilities directly from `a2ui_core`, natively supporting protocol version branching (`v0_8`, `v0_9`, `v0_9_1`, `v1_0`). 2. **Encapsulated Application Processor**: - - `CatalogConfig`: Configuration dataclass encapsulating catalog providers (`BundledCatalogProvider`, `FileSystemCatalogProvider`, `InMemoryCatalogProvider`), custom transformers, and examples. + - `CatalogConfig`: Configuration dataclass encapsulating catalog providers (`FileSystemCatalogProvider`, `InMemoryCatalogProvider`), custom transformers, and examples. - `A2uiGenerator`: Agent-level lifecycle manager holding supported `CatalogConfig`s, generating pre-negotiated `A2uiRequestProcessor` instances per renderer capability signature. - `A2uiRequestProcessor`: Central processor facade object unifying multi-catalog capability resolution (`resolve_catalogs`), system prompt snippet rendering, turn-scoped parser creation, and response validation. @@ -154,6 +154,14 @@ class FunctionPruningTransformer(CatalogTransformer): pass ``` +#### Transformer Requirements + +**Transformation is agent-owned.** A `Catalog` from `a2ui_core` is an immutable value object and core never narrows one: a renderer implements every component it advertises, so only an agent has a reason to prompt against a subset. Do not add pruning to `a2ui_core`, and do not file transformer conformance data under `conformance/core/` — it belongs in `agent/catalog_transformer.yaml` (see section 6). + +**Narrow the unions, not just the entries.** A catalog document declares its component and function unions under `$defs/anyComponent` and `$defs/anyFunction`, whose members `$ref` entries in `components` and `functions`. A transformer that drops an entry MUST drop the matching union member in the same pass. Leaving the `$ref` behind is not merely untidy: the pruned document still advertises to the model a component the agent has decided not to accept, and it dangles a pointer that schema resolution will fail on. + +**Transformers never mutate their input.** `transform` returns a new `Catalog`. The pristine catalog registered on the `CatalogConfig` stays intact, because that is the one whose `id` the agent advertises in `A2uiGenerator.agent_capabilities` — narrowing changes what the agent will emit, not which catalog contract it speaks. + --- ### B. Prompt Generation Layer (`a2ui.prompt`) @@ -395,21 +403,6 @@ class CatalogProvider(ABC): """Loads and returns a Catalog definition instance.""" pass -class BundledCatalogProvider(CatalogProvider): - """Loads catalog schemas from bundled package resources for a specified protocol version.""" - - def __init__(self, protocol_version: ProtocolVersion): - """Initializes the bundled provider. - - Args: - protocol_version: Protocol specification version string (e.g. 'v0.9.1', 'v1.0'). - """ - self.protocol_version = protocol_version - - def load(self) -> Catalog[TComponent, TFunction]: - """Loads the bundled package catalog schema for protocol_version.""" - pass - class FileSystemCatalogProvider(CatalogProvider): """Loads a catalog definition from a JSON file on the local filesystem.""" @@ -467,6 +460,10 @@ class InMemoryCatalogProvider(CatalogProvider): pass ``` +There is deliberately no bundled provider. An agent's catalogs come from disk, from memory, or inline from the renderer; a copy shipped inside the SDK package silently forks from `specification//catalogs/`. `BundledCatalogProvider` appears in the legacy Python and Kotlin agent SDKs and in earlier drafts of this document — it is frozen, not a pattern to copy (see [Legacy Surfaces](#legacy-surfaces)). + +A provider is parsing a document it did not write. Raise `A2uiCatalogError` for a missing file, malformed JSON, a non-object document, or a `catalog_id` conflict, and `A2uiValidationError` for an unsupported or conflicting protocol version — never a raw language-level cast failure. + #### `CatalogConfig` ```python @@ -517,7 +514,12 @@ class A2uiGenerator: Attributes: catalogs: Master list of CatalogConfig objects supported by the agent. + These may span protocol versions -- each Catalog carries its own + protocol_version, and the registry is not assumed to be uniform. examples: Optional mapping of few-shot example turns shared across sessions. + accepts_inline_catalogs: Whether the agent will accept catalogs supplied + inline by the renderer. Advertised in agent_capabilities and passed + to resolve_catalogs. factory: Default InferenceFormatFactory used when instantiating processors. """ @@ -525,24 +527,54 @@ class A2uiGenerator: self, catalogs: List[CatalogConfig], examples: Optional[Dict[str, List[AgentToRendererMessage]]] = None, + accepts_inline_catalogs: bool = False, inference_format_factory: Optional[InferenceFormatFactory] = None, ): """Initializes A2uiGenerator with supported catalog configurations and format factory. Args: - catalogs: List of supported CatalogConfig configurations. + catalogs: List of supported CatalogConfig configurations, in agent + preference order. May mix protocol versions. examples: Optional dictionary of prompt examples. + accepts_inline_catalogs: Whether renderer-supplied inline catalogs are accepted. inference_format_factory: Optional default InferenceFormatFactory (defaults to DirectJsonFormatFactory). """ pass + @property + def agent_capabilities(self) -> Dict[str, Any]: + """Returns the capabilities this agent advertises to renderers. + + Mirrors the normative server-side capabilities schema for the version + being implemented (`specification/v0_9_1/json/server_capabilities.json`, + renamed to `agent_capabilities.json` in v1.0). Build the object from + that schema rather than from memory. It is a map keyed by protocol version, with + supported_catalog_ids and accepts_inline_catalogs *inside* each version + entry. A flat object carrying a sibling list of version strings is a + different, invalid shape. + + Group the registered catalogs by the protocol_version each Catalog + declares. Never advertise the whole registry under one version assumed + for all of it: catalogs may span versions, and a renderer that reads + this object decides what to send from it. + + Emit an entry for every version this SDK implements, including a + version with no registered catalog, because the schema requires the + version key to be present. + + List the pristine CatalogConfig.catalog id, not the transformed copy. + """ + pass + def create_processor( self, - renderer_capabilities: Any, + renderer_capabilities: A2uiRendererCapabilities, inference_format_factory: Optional[InferenceFormatFactory] = None, ) -> A2uiRequestProcessor: """Creates an A2uiRequestProcessor bound to the specified renderer capabilities. + Negotiates via resolve_catalogs (section 3.G) and propagates its errors. + Args: renderer_capabilities: A2uiRendererCapabilities object sent by the client renderer. inference_format_factory: Optional override format factory for this processor. @@ -602,16 +634,37 @@ class A2uiRequestProcessor: Negotiates renderer capabilities against a registered sequence of catalogs (`List[CatalogConfig]`) to select matching active schemas for a session. +This is the negotiation entry point. It **supersedes `select_catalog`**, the pre-v1.0 helper that returned a single catalog id; a session may activate several catalogs at once, so negotiation returns a list. `select_catalog` still appears in the shipped Python and Kotlin agent SDKs and in existing conformance data — do not implement it or extend its cases (see [Legacy Surfaces](#legacy-surfaces)). + ```python def resolve_catalogs( catalogs: List[CatalogConfig], renderer_capabilities: A2uiRendererCapabilities, accepts_inline_catalogs: bool = False, ) -> List[Catalog[TComponent, TFunction]]: - """Matches renderer capabilities against registered catalogs and returns active transformed Catalog objects.""" + """Matches renderer capabilities against registered catalogs and returns active transformed Catalog objects. + + Args: + catalogs: The catalogs the agent registered, in agent preference order. + renderer_capabilities: The a2uiClientCapabilities object the renderer sent. + accepts_inline_catalogs: Whether the agent opted in to inline catalogs. + + Returns: + The active catalogs for the session, in agent preference order. + """ pass ``` +**Required behaviour:** + +1. Read the entry for the protocol version this SDK implements out of `renderer_capabilities`. Raise `A2uiValidationError` if the object carries no entry for it — an absent version is a failed negotiation, not an empty catalog list. +2. Keep every registered catalog whose id appears in the renderer's `supportedCatalogIds`, ordered by **the agent's** registration order. The renderer's ordering carries no preference. +3. If the renderer declared no ids at all, fall back to the first registered catalog. +4. Append the renderer's `inlineCatalogs` only when `accepts_inline_catalogs` is true. Ignore them silently otherwise: a renderer cannot know whether the agent opted in until it has read the agent's capabilities, so supplying them is not an error. +5. Raise `A2uiCatalogError` when the result would be empty — the renderer and the agent share no catalog, or the agent registered none. + +Return `CatalogConfig.transformed_catalog` for each match, never the pristine catalog: prompting and validation both run against the narrowed contract. `A2uiGenerator.agent_capabilities` is the one place that advertises pristine ids. + --- ## 4. Inference Format Strategy Implementations @@ -776,27 +829,36 @@ The Express format package under `a2ui/agent/inference_formats/express/` contain ```python # 1. Agent Startup: Initialize long-lived A2uiGenerator with agent catalogs. +# Catalogs are registered in agent preference order and may span protocol +# versions -- each one carries its own protocol_version. # Note: Prompt examples passed here are validated internally during processor creation # (create_processor) against active negotiated catalogs, raising ValueError if any # example uses components or structures not supported by the active catalog. generator = A2uiGenerator( catalogs=[ - CatalogConfig(BasicCatalog("v1.0")), + CatalogConfig.from_path("./catalogs/basic_v0_9.json"), CatalogConfig.from_path("./catalogs/custom_catalog.json"), ], - examples=load_examples("./prompts/examples/**") + examples=load_examples("./prompts/examples/**"), ) -# 2. In Request Handler: Retrieve pre-negotiated A2uiRequestProcessor matching renderer capabilities +# 2. Publish what the agent can do. Version-keyed, per server_capabilities.json; +# each catalog id sits under the version its own catalog declares: +# {"v0.9": {"supportedCatalogIds": [...], "acceptsInlineCatalogs": false}} +agent_card.a2ui_server_capabilities = generator.agent_capabilities + +# 3. In Request Handler: Retrieve pre-negotiated A2uiRequestProcessor matching renderer +# capabilities. Raises A2uiCatalogError if nothing is shared with the renderer, and +# A2uiValidationError if the capabilities declare no entry for a supported version. processor = generator.create_processor(renderer_capabilities) -# 3. Invoke LLM to generate the output +# 4. Invoke LLM to generate the output llm_output_text = myagent.call_llm(processor.prompt_snippet, request_context) -# 4. Parse and validate output using the processor +# 5. Parse and validate output using the processor response_parts = processor.parse_response(llm_output_text) -# 5. Deliver A2UI payloads to the renderer +# 6. Deliver A2UI payloads to the renderer ``` --- @@ -806,3 +868,35 @@ response_parts = processor.parse_response(llm_output_text) To ensure behavioral parity across all SDK implementations (Python, Kotlin, etc.), the project maintains a language-agnostic conformance suite. For complete setup instructions, test harness requirements, suite descriptions, and schema definitions, see [Conformance README](../../conformance/README.md). + +### Where a case belongs + +A suite covers one module of this blueprint, and its path mirrors the module that owns the API under test. Agent-owned behaviour never lands under `conformance/core/`, however catalog-shaped the case looks — `core/` is `a2ui_core`'s, and a case filed there obliges every renderer to implement an agent concern. + +| Blueprint section | API under test | Suite | +| :------------------------ | :---------------------------------------------------------- | :------------------------------- | +| 3.A Catalog Transformers | `ComponentPruningTransformer`, `FunctionPruningTransformer` | `agent/catalog_transformer.yaml` | +| 3.C Common Parser | `Parser.parse_response`, `compile`, `decompile` | `agent/parser.yaml` | +| 4 Streaming | `parse_chunk` | `agent/streaming_parser.yaml` | +| 3.E / 4 Inference Formats | `PromptGenerator.generate` | `agent/inference_format.yaml` | +| 3.F Catalog Providers | `CatalogProvider.load` | `agent/catalog_provider.yaml` | +| 3.G Catalog Resolver | `resolve_catalogs` | `agent/catalog_resolver.yaml` | +| 3.F Processor Facade | one whole agent turn, end to end | `agent/request_processor.yaml` | + +### Rules for adding cases + +1. **Name the `action` after an API in this blueprint.** An action that maps to no documented method means the behaviour has not been specified yet — specify it first. In particular, do not take the action name from a neighbouring case in the file you are editing: the existing suites carry cases for superseded APIs, and copying their `action` quietly re-implements the thing that replaced them. +2. **Look for an existing suite before creating one — on every active spec branch, not just the one you are on.** The repository maintains parallel branches per protocol version (`main`, `v1_0`, …). A suite that already exists elsewhere has an established case shape; a second file with the same name and a different shape is a merge conflict, not extra coverage. Extend the existing one. +3. **Reuse the established case keys.** `catalog`, `action`, `args`, `expect` and `expect_error` are the shared vocabulary defined by `conformance/conformance_schema.json`. When a genuinely new shape is needed, extend the schema in the same change as the suite that uses it. +4. **`expect_error.category` names a class from the core exception hierarchy**, without the `A2ui` prefix. Add a category to the schema enum together with the first suite that asserts it, not in advance. +5. **Reference specification data instead of copying it.** A case that needs a real catalog points at `specification//catalogs//catalog.json` by relative path, so the suite cannot drift from the published document. + +### Legacy Surfaces + +These ship in the Python and Kotlin agent SDKs and appear in existing conformance data. They are frozen. Do not implement them in a new SDK, and do not add cases for them. + +| Legacy surface | Replaced by | +| :----------------------------------------------------- | :---------------------------------------------------------------------------------------------------------- | +| `select_catalog`, returning one negotiated catalog id | `resolve_catalogs`, returning a list (section 3.G) | +| `BundledCatalogProvider` | `FileSystemCatalogProvider`, `InMemoryCatalogProvider`, or a renderer-supplied inline catalog (section 3.F) | +| A flat capabilities object with an `a2uiVersions` list | The version-keyed `server_capabilities.json` shape (`A2uiGenerator.agent_capabilities`, section 3.F) | diff --git a/blueprints/modules/a2ui_core.blueprint.md b/blueprints/modules/a2ui_core.blueprint.md index fd3833e1eb..b5669a3d34 100644 --- a/blueprints/modules/a2ui_core.blueprint.md +++ b/blueprints/modules/a2ui_core.blueprint.md @@ -29,6 +29,17 @@ Its core responsibilities include: 7. **Resolution:** Resolves bound context paths and binds state variables to components for local evaluation. 8. **Multi-Version Protocol Branching:** Supports multiple versions of the protocol. +### Package Boundary & Non-Goals + +`a2ui_agent` depends on `a2ui_core`; the dependency never runs the other way. Core models the protocol and the renderer's state machine, and must stay useful to a renderer that will never load an agent SDK. + +Core does **not** own: + +- **Catalog narrowing.** A `Catalog` is an immutable value object. Pruning components or functions to an allowlist is prompt engineering — it belongs to the catalog transformers in the [a2ui_agent blueprint](./a2ui_agent.blueprint.md). +- **Prompt generation, response parsing, and capability negotiation.** All three are agent concerns, and none of them belongs here even when the type they operate on is a core type. + +When implementing `a2ui_agent` requires a change here — a shared type, a widened generic, a missing error class — make the smallest change that unblocks it, and land it as its own reviewable unit. Core is consumed by every renderer, so a change made for one agent SDK is a change made for all of them. + --- ### A. High-Level Layer Architecture @@ -193,6 +204,12 @@ export interface Catalog/json/client_capabilities.json` | `specification/v1_0/json/renderer_capabilities.json` | +| Agent → renderer | `a2uiServerCapabilities` | `specification//json/server_capabilities.json` | `specification/v1_0/json/agent_capabilities.json` | + +Resolve the filename for the version you are implementing rather than assuming one — v0.8 uses a third spelling (`a2ui_client_capabilities_schema.json`) and publishes no server-side counterpart. + +The top level of each is **keyed by protocol version** — `{"v0.9": { … }}`, `{"v1.0": { … }}` — with `supportedCatalogIds`, `inlineCatalogs` and `acceptsInlineCatalogs` living _inside_ the version entry, and the version key is required. A flat object carrying a sibling list of version strings is a different, invalid shape. + +When parsing one: reject an object that declares no entry for any version this SDK implements, and keep the unrecognised version keys rather than failing on them, so a renderer that also speaks a newer protocol can still be served. + #### Generating Renderer Capabilities and Schema Types To dynamically generate the `A2uiRendererCapabilities` payload (specifically `inlineCatalogs`), the processor must convert internal component schemas into valid JSON Schemas. @@ -751,4 +783,50 @@ export class A2uiRecursionError extends A2uiError { this.name = 'A2uiRecursionError'; } } + +/** Raised when a data model read or write cannot be satisfied at the given path. */ +export class A2uiDataError extends A2uiError { + /* name = 'A2uiDataError' */ +} + +/** Raised when the surface or component state machine is asked for an illegal transition. */ +export class A2uiStateError extends A2uiError { + /* name = 'A2uiStateError' */ +} + +/** Raised when raw model output cannot be extracted or decoded into a payload. */ +export class A2uiParseError extends A2uiError { + /* name = 'A2uiParseError' */ +} + +/** Raised when a source syntax (e.g. the EXPRESS DSL) cannot be compiled to A2UI messages. */ +export class A2uiCompileError extends A2uiError { + /* name = 'A2uiCompileError' */ +} + +/** Raised when a bound expression cannot be evaluated. */ +export class A2uiExpressionError extends A2uiError { + /* name = 'A2uiExpressionError' */ +} ``` + +**Parsing wire JSON raises from this hierarchy, never from the language.** Every entry point that accepts a payload, a capabilities object or a catalog document from outside the process is handling untrusted input: a member may be absent, null, or the wrong type. Check the shape before casting and raise `A2uiValidationError` with the offending value attached. A raw cast failure — `TypeError`, `ClassCastException`, a null dereference — escapes the hierarchy a caller can catch, and turns a malformed message into a crash. + +**These categories are shared with the conformance suite.** `expect_error.category` in `conformance/conformance_schema.json` names these classes without the `A2ui` prefix, so the two must agree. Adding an error class means adding the matching category alongside the first suite that asserts it — not in advance, and not silently under a category that already exists. + +--- + +## 4. Conformance Test Plan + +Behavioural parity across implementations is pinned by a language-agnostic conformance suite. For setup, harness requirements and schema definitions, see [Conformance README](../../conformance/README.md). + +### Where a case belongs + +Suites under `conformance/core/` cover this module: the reactive data model, the message processor's state machine, catalog documents, and the validator. Agent-side behaviour — prompt generation, response parsing, catalog narrowing, capability negotiation — belongs under `conformance/agent/`, **even when the case is about a catalog**. A case filed under `core/` obliges every renderer to implement it, so the directory is a statement about ownership, not about subject matter. Section 6 of the [a2ui_agent blueprint](./a2ui_agent.blueprint.md#6-conformance-test-plan) carries the agent-side map. + +### Rules for adding cases + +1. **Look for an existing suite before creating one — on every active spec branch, not just the one you are on.** The repository maintains parallel branches per protocol version (`main`, `v1_0`, …). A suite that already exists elsewhere has an established case shape; a second file with the same name and a different shape is a merge conflict rather than extra coverage. Extend the existing one. +2. **Reuse the established case keys** defined by `conformance/conformance_schema.json`, and extend the schema in the same change as the suite that needs the new shape. +3. **`expect_error.category` names a class from the exception hierarchy above**, without the `A2ui` prefix. +4. **Migrating an implementation's own tests into the shared suite will surface real disagreements** between implementations. Each one is a decision, not a formatting problem: fix the implementation that is wrong, or record why the case is excluded. Language-level differences (sparse versus dense arrays, `null` versus absent, prototype pollution) stay out of the shared suite and remain in the implementation's own tests. diff --git a/conformance/README.md b/conformance/README.md index e2d40d5a2a..42c7db0f69 100644 --- a/conformance/README.md +++ b/conformance/README.md @@ -11,12 +11,17 @@ Test suites are organized by functional domain: - `core/catalog.yaml`: Contains test cases for catalog operations (prune, render, load). - `core/accessibility.yaml`: Contains test cases for accessibility attributes and checks. - `core/validator.yaml`: Contains test cases for schema and structural validators, verifying structural integrity, cycle detection, and reachability. +- `core/data_model.yaml`: Contains test cases for the reactive data model, verifying JSON Pointer reads and writes, container creation, deletion, and observer notification. ### Agent (`agent/`) - `agent/streaming_parser.yaml`: Contains test cases for streaming parser implementations, verifying chunk buffering, incremental yielding, and edge cases like cut tokens. - `agent/parser.yaml`: Contains test cases for non-streaming parsing and payload fixing. - `agent/inference_format.yaml`: Contains test cases for inference formats and schema managers (select_catalog, load_catalog, generate_prompt). +- `agent/catalog_provider.yaml`: Contains test cases for loading a catalog document into a catalog, whatever backing store it came from. +- `agent/catalog_transformer.yaml`: Contains test cases for narrowing a catalog before prompting, including the `$defs` unions that reference the pruned entries. +- `agent/catalog_resolver.yaml`: Contains test cases for `resolve_catalogs`, which negotiates renderer capabilities against the catalogs an agent registered. It supersedes the legacy single-catalog `select_catalog` helper. +- `agent/request_processor.yaml`: Contains test cases for a whole agent turn: negotiate catalogs, render the prompt snippet, parse the model response. ### Extensions (`extensions/`) diff --git a/conformance/agent/catalog_provider.yaml b/conformance/agent/catalog_provider.yaml new file mode 100644 index 0000000000..03d149580d --- /dev/null +++ b/conformance/agent/catalog_provider.yaml @@ -0,0 +1,33 @@ +# Copyright 2024 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. + +# Behaviour of the catalog providers described by +# `blueprints/modules/a2ui_agent.blueprint.md` section 3.B: turning a catalog +# document into a `Catalog`, whatever backing store it came from. Prompt +# rendering and capability negotiation are covered by `inference_format.yaml` +# and `catalog_resolver.yaml` respectively. +# +# These cases load the published basic catalog schema from +# `specification/v0_9_1/catalogs/basic/catalog.json` rather than any SDK's own +# catalog, so implementations are measured against the same document. Paths are +# resolved relative to the `conformance/` directory. + +- name: test_load_basic_catalog_v0_9 + description: Loads the published v0.9 basic catalog document from disk. + action: load_catalog + catalog_configs: + - name: basic + path: "../specification/v0_9_1/catalogs/basic/catalog.json" + expect: + supported_catalog_ids: ["https://a2ui.org/specification/v0_9/catalogs/basic/catalog.json"] diff --git a/conformance/agent/catalog_resolver.yaml b/conformance/agent/catalog_resolver.yaml new file mode 100644 index 0000000000..08d2fbf889 --- /dev/null +++ b/conformance/agent/catalog_resolver.yaml @@ -0,0 +1,193 @@ +# Copyright 2024 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. + +# Behaviour of `resolve_catalogs`, the capability negotiation helper described +# by `blueprints/modules/a2ui_agent.blueprint.md` section 3.G. It matches an +# `a2uiClientCapabilities` object against the catalogs an agent registered and +# returns the active, transformed catalogs for the session. +# +# This replaces the legacy single-catalog `select_catalog` helper: negotiation +# now yields a list, in agent preference order, and may include catalogs the +# renderer supplied inline. +# +# `args.catalogs` are the registered `CatalogConfig`s in agent preference +# order; each `catalog_schema` is either an inline catalog document or a path +# resolved relative to the `conformance/` directory. `expect_active_catalog_ids` +# asserts the ids of the resolved catalogs, in order. + +- name: test_resolve_catalogs_selects_the_declared_catalog + description: A renderer that declares one registered catalog id negotiates to it. + catalog: + version: "0.9" + action: resolve_catalogs + args: + catalogs: + - catalog_schema: "../specification/v0_9_1/catalogs/basic/catalog.json" + - catalog_schema: + catalogId: "https://example.com/small.json" + components: + Text: {type: object} + renderer_capabilities: + "v0.9": + supportedCatalogIds: ["https://example.com/small.json"] + expect_active_catalog_ids: ["https://example.com/small.json"] + +- name: test_resolve_catalogs_returns_every_shared_catalog_in_agent_order + description: >- + All catalogs the renderer and the agent share are active, ordered by the + agent's preference rather than the renderer's. + catalog: + version: "0.9" + action: resolve_catalogs + args: + catalogs: + - catalog_schema: "../specification/v0_9_1/catalogs/basic/catalog.json" + - catalog_schema: + catalogId: "https://example.com/small.json" + components: + Text: {type: object} + renderer_capabilities: + "v0.9": + supportedCatalogIds: + - "https://example.com/small.json" + - "https://a2ui.org/specification/v0_9/catalogs/basic/catalog.json" + expect_active_catalog_ids: + - "https://a2ui.org/specification/v0_9/catalogs/basic/catalog.json" + - "https://example.com/small.json" + +- name: test_resolve_catalogs_defaults_to_the_first_registered_catalog + description: A renderer that declares no catalog id gets the agent's first. + catalog: + version: "0.9" + action: resolve_catalogs + args: + catalogs: + - catalog_schema: "../specification/v0_9_1/catalogs/basic/catalog.json" + - catalog_schema: + catalogId: "https://example.com/small.json" + components: + Text: {type: object} + renderer_capabilities: + "v0.9": + supportedCatalogIds: [] + expect_active_catalog_ids: + - "https://a2ui.org/specification/v0_9/catalogs/basic/catalog.json" + +- name: test_resolve_catalogs_returns_transformed_catalogs + description: >- + Negotiation returns the catalog with the config's transformers applied, so + a pruned catalog stays pruned for prompting and validation. + catalog: + version: "0.9" + action: resolve_catalogs + args: + catalogs: + - catalog_schema: "../specification/v0_9_1/catalogs/basic/catalog.json" + allowed_components: [Text, Card] + renderer_capabilities: + "v0.9": + supportedCatalogIds: + - "https://a2ui.org/specification/v0_9/catalogs/basic/catalog.json" + expect_active_catalog_ids: + - "https://a2ui.org/specification/v0_9/catalogs/basic/catalog.json" + expect_components: + "https://a2ui.org/specification/v0_9/catalogs/basic/catalog.json": [Card, Text] + +- name: test_resolve_catalogs_ignores_inline_catalogs_by_default + description: >- + An inline catalog is not activated unless the agent advertises + `acceptsInlineCatalogs`. + catalog: + version: "0.9" + action: resolve_catalogs + args: + catalogs: + - catalog_schema: "../specification/v0_9_1/catalogs/basic/catalog.json" + renderer_capabilities: + "v0.9": + supportedCatalogIds: [] + inlineCatalogs: + - catalogId: "https://example.com/inline.json" + components: + Text: {type: object} + expect_active_catalog_ids: + - "https://a2ui.org/specification/v0_9/catalogs/basic/catalog.json" + +- name: test_resolve_catalogs_accepts_inline_catalogs_when_enabled + description: An agent that accepts inline catalogs activates the ones supplied. + catalog: + version: "0.9" + action: resolve_catalogs + args: + catalogs: + - catalog_schema: "../specification/v0_9_1/catalogs/basic/catalog.json" + accepts_inline_catalogs: true + renderer_capabilities: + "v0.9": + supportedCatalogIds: [] + inlineCatalogs: + - catalogId: "https://example.com/inline.json" + components: + Text: {type: object} + expect_active_catalog_ids: + - "https://a2ui.org/specification/v0_9/catalogs/basic/catalog.json" + - "https://example.com/inline.json" + +- name: test_resolve_catalogs_rejects_a_renderer_with_no_shared_catalog + description: A renderer that supports nothing the agent registered cannot be served. + catalog: + version: "0.9" + action: resolve_catalogs + args: + catalogs: + - catalog_schema: "../specification/v0_9_1/catalogs/basic/catalog.json" + renderer_capabilities: + "v0.9": + supportedCatalogIds: ["https://example.com/unknown.json"] + expect_error: + category: "CatalogError" + message: "no matching catalog" + +- name: test_resolve_catalogs_rejects_an_agent_with_no_registered_catalogs + description: An agent that registered no catalog has nothing to negotiate. + catalog: + version: "0.9" + action: resolve_catalogs + args: + catalogs: [] + renderer_capabilities: + "v0.9": + supportedCatalogIds: + - "https://a2ui.org/specification/v0_9/catalogs/basic/catalog.json" + expect_error: + category: "CatalogError" + message: "no matching catalog" + +- name: test_resolve_catalogs_rejects_capabilities_without_a_v0_9_entry + description: >- + Capabilities that declare nothing for the protocol version the SDK + implements are rejected rather than negotiated as empty. + catalog: + version: "0.9" + action: resolve_catalogs + args: + catalogs: + - catalog_schema: "../specification/v0_9_1/catalogs/basic/catalog.json" + renderer_capabilities: + "v1.0": + supportedCatalogIds: + - "https://a2ui.org/specification/v0_9/catalogs/basic/catalog.json" + expect_error: + category: "ValidationError" + message: "v0.9" diff --git a/conformance/agent/catalog_transformer.yaml b/conformance/agent/catalog_transformer.yaml new file mode 100644 index 0000000000..215d5bbfa3 --- /dev/null +++ b/conformance/agent/catalog_transformer.yaml @@ -0,0 +1,78 @@ +# Copyright 2024 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. + +# Behaviour of the catalog transformers described by +# `blueprints/modules/a2ui_agent.blueprint.md` section 3.C. Transformation is an +# agent concern: a renderer implements the whole catalog it advertises, while an +# agent narrows one before prompting. The `prune` cases in +# `conformance/core/catalog.yaml` predate that split and stay where the SDKs +# already read them from. +# +# The `$defs/anyComponent` and `$defs/anyFunction` unions of a catalog document +# must be narrowed alongside the entries they reference, otherwise a pruned +# catalog still advertises components the agent may not emit. The ref shapes +# here match `specification/v0_9_1/catalogs/basic/catalog.json`. + +- name: test_prune_components_narrows_any_component_union + description: Pruning components narrows the anyComponent union to the kept components. + catalog: + version: "0.9" + catalog_schema: + catalogId: "https://a2ui.org/specification/v0_9/catalogs/basic/catalog.json" + components: + Text: {type: object} + Card: {type: object} + Button: {type: object} + Video: {type: object} + $defs: + anyComponent: + oneOf: + - $ref: "#/components/Text" + - $ref: "#/components/Card" + - $ref: "#/components/Button" + - $ref: "#/components/Video" + action: prune + args: + allowed_components: [Text, Card, Button] + expect: + catalog_schema: + catalogId: "https://a2ui.org/specification/v0_9/catalogs/basic/catalog.json" + components: + Text: {type: object} + Card: {type: object} + Button: {type: object} + $defs: + anyComponent: + oneOf: + - $ref: "#/components/Text" + - $ref: "#/components/Card" + - $ref: "#/components/Button" + +- name: test_prune_unknown_component_names_are_ignored + description: Allowlist entries the catalog does not declare are ignored, not errors. + catalog: + version: "0.9" + catalog_schema: + catalogId: basic + components: + Text: {type: object} + Card: {type: object} + action: prune + args: + allowed_components: [Text, NotInCatalog] + expect: + catalog_schema: + catalogId: basic + components: + Text: {type: object} diff --git a/conformance/agent/inference_format.yaml b/conformance/agent/inference_format.yaml index 953a057689..573db42f84 100644 --- a/conformance/agent/inference_format.yaml +++ b/conformance/agent/inference_format.yaml @@ -326,32 +326,3 @@ - "### Catalog Schema:" - '"Text": {' - "---END A2UI JSON SCHEMA---" - -# --- Basic catalog loading and negotiation (v0.9) --- -# -# These cases load the published basic catalog schema from -# `specification/v0_9_1/catalogs/basic/catalog.json` rather than any SDK's own -# catalog, so implementations are measured against the same document. Paths are -# resolved relative to the `conformance/` directory. - -- name: test_load_basic_catalog_v0_9 - description: Loads the published v0.9 basic catalog document from disk. - action: load_catalog - catalog_configs: - - name: basic - path: "../specification/v0_9_1/catalogs/basic/catalog.json" - expect: - supported_catalog_ids: ["https://a2ui.org/specification/v0_9/catalogs/basic/catalog.json"] - -- name: test_select_basic_catalog_by_id_v0_9 - description: A renderer that declares the basic catalog id negotiates to it. - action: select_catalog - args: - supported_catalogs: - - catalogId: "https://a2ui.org/specification/v0_9/catalogs/basic/catalog.json" - components: {} - - catalogId: id_custom - components: {} - client_capabilities: - supportedCatalogIds: ["https://a2ui.org/specification/v0_9/catalogs/basic/catalog.json"] - expect_selected: "https://a2ui.org/specification/v0_9/catalogs/basic/catalog.json" diff --git a/conformance/conformance_schema.json b/conformance/conformance_schema.json index 4094844002..b1b97b49c9 100644 --- a/conformance/conformance_schema.json +++ b/conformance/conformance_schema.json @@ -94,7 +94,7 @@ "accessibility_check", "data_model", "process_request", - "process_messages" + "resolve_catalogs" ] } }, @@ -127,7 +127,7 @@ {"$ref": "#/$defs/AccessibilityCheckTest"}, {"$ref": "#/$defs/DataModelTest"}, {"$ref": "#/$defs/ProcessRequestTest"}, - {"$ref": "#/$defs/ProcessMessagesTest"} + {"$ref": "#/$defs/ResolveCatalogsTest"} ] } ] @@ -594,66 +594,64 @@ }, "required": ["args"] }, - "ProcessMessagesTest": { + "ResolveCatalogsTest": { "type": "object", "properties": { - "action": {"const": "process_messages"}, - "payload": { - "type": "array", - "description": "A2UI messages to process, in order.", - "items": {"type": "object"} - }, - "expect": { + "action": {"const": "resolve_catalogs"}, + "args": { "type": "object", - "description": "Expected state after the payload has been processed.", + "description": "Inputs to one capability negotiation.", "properties": { - "surfaces": { - "type": "object", - "description": "Expectations per open surface, keyed by surface id.", - "additionalProperties": { + "catalogs": { + "type": "array", + "description": "The catalog configurations the agent registered, in preference order.", + "items": { "type": "object", "properties": { - "catalogId": {"type": "string"}, - "sendDataModel": {"type": "boolean"}, - "components": { - "type": "object", - "description": "Expected components, keyed by component id.", - "additionalProperties": { - "type": "object", - "properties": { - "component": {"type": "string"}, - "properties": {"type": "object"} - } - } + "catalog_schema": { + "description": "An inline catalog document, or a path to one relative to the conformance directory." + }, + "allowed_components": { + "type": "array", + "items": {"type": "string"}, + "description": "Component allowlist applied to this catalog before it is returned." }, - "data_model": { - "description": "Expected contents of the surface data model at the root." + "allowed_functions": { + "type": "array", + "items": {"type": "string"}, + "description": "Function allowlist applied to this catalog before it is returned." } - } + }, + "required": ["catalog_schema"] } }, - "absent_surfaces": { - "type": "array", - "items": {"type": "string"}, - "description": "Surface ids that must not be open." - }, - "client_data_model": { + "renderer_capabilities": { "type": "object", - "description": "Expected aggregated data model sent back to the agent." + "description": "The a2uiClientCapabilities object the renderer sent." }, - "client_data_model_absent": { + "accepts_inline_catalogs": { "type": "boolean", - "description": "Whether there is no client data model to send." - }, - "client_capabilities": { - "type": "object", - "description": "Expected capabilities the renderer advertises." + "description": "Whether the agent accepts catalogs supplied inline by the renderer." } + }, + "required": ["catalogs", "renderer_capabilities"] + }, + "expect_active_catalog_ids": { + "type": "array", + "items": {"type": "string"}, + "description": "Ids of the negotiated catalogs, in order." + }, + "expect_components": { + "type": "object", + "description": "Component names each negotiated catalog exposes after transformation, keyed by catalog id and sorted.", + "additionalProperties": { + "type": "array", + "items": {"type": "string"} } }, "expect_error": {"$ref": "#/$defs/ExpectError"} }, - "required": ["payload"] + "required": ["args"] }, "ExpectError": { "oneOf": [ @@ -673,8 +671,7 @@ "IntegrityError", "RecursionError", "CompileError", - "DataError", - "StateError" + "DataError" ] }, "message": { diff --git a/conformance/core/catalog.yaml b/conformance/core/catalog.yaml index 11db44cf42..92f75311fa 100644 --- a/conformance/core/catalog.yaml +++ b/conformance/core/catalog.yaml @@ -475,63 +475,3 @@ action: verify_cuttable_keys expect: custom_cuttable_keys: ["customKey1", "customKey2"] - -# --- Basic catalog pruning (v0.9) --- -# -# The `$defs/anyComponent` and `$defs/anyFunction` unions of a catalog document -# must be narrowed alongside the entries they reference, otherwise a pruned -# catalog still advertises components the agent may not emit. The ref shapes -# here match `specification/v0_9_1/catalogs/basic/catalog.json`. - -- name: test_prune_components_narrows_any_component_union - description: Pruning components narrows the anyComponent union to the kept components. - catalog: - version: "0.9" - catalog_schema: - catalogId: "https://a2ui.org/specification/v0_9/catalogs/basic/catalog.json" - components: - Text: {type: object} - Card: {type: object} - Button: {type: object} - Video: {type: object} - $defs: - anyComponent: - oneOf: - - $ref: "#/components/Text" - - $ref: "#/components/Card" - - $ref: "#/components/Button" - - $ref: "#/components/Video" - action: prune - args: - allowed_components: [Text, Card, Button] - expect: - catalog_schema: - catalogId: "https://a2ui.org/specification/v0_9/catalogs/basic/catalog.json" - components: - Text: {type: object} - Card: {type: object} - Button: {type: object} - $defs: - anyComponent: - oneOf: - - $ref: "#/components/Text" - - $ref: "#/components/Card" - - $ref: "#/components/Button" - -- name: test_prune_unknown_component_names_are_ignored - description: Allowlist entries the catalog does not declare are ignored, not errors. - catalog: - version: "0.9" - catalog_schema: - catalogId: basic - components: - Text: {type: object} - Card: {type: object} - action: prune - args: - allowed_components: [Text, NotInCatalog] - expect: - catalog_schema: - catalogId: basic - components: - Text: {type: object} diff --git a/conformance/core/message_processor.yaml b/conformance/core/message_processor.yaml deleted file mode 100644 index 459418949a..0000000000 --- a/conformance/core/message_processor.yaml +++ /dev/null @@ -1,392 +0,0 @@ -# Copyright 2024 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. - -# Behaviour of the message processor: the surface lifecycle, component graph -# mutation, per surface data model routing, and the capability and data model -# payloads a renderer sends back to the agent. -# -# Cases run against an empty catalog whose id is given by -# `catalog.catalog_schema.catalogId`. Each harness builds that catalog natively -# rather than parsing the schema, because renderers construct catalogs from -# code (Zod in `web_core`, `Schema` in Dart) and neither builds a renderer -# catalog from a JSON Schema document. The suite therefore pins the processor's -# state machine, not catalog loading; component schema validation is out of -# scope here for the same reason. -# -# `payload` is the list of messages to process, in order. `expect` asserts the -# resulting state: -# -# surfaces map of surface id to expectations, any of -# `catalogId`, `sendDataModel`, `components` -# (id to `component` and `properties`), and -# `data_model` (the whole model at `/`) -# absent_surfaces surface ids that must not be open -# client_data_model the aggregated payload sent back to the agent -# client_data_model_absent there is no such payload to send -# client_capabilities the capabilities the renderer advertises -# -# `expect_error` asserts that processing the payload raises instead. Error -# messages differ between implementations, so `message` is matched as a regular -# expression against a substring the implementations share. - -- name: test_processor_creates_surface - description: >- - A createSurface message registers a surface bound to the named catalog. - catalog: - version: "0.9" - catalog_schema: {"catalogId": "conformance-catalog", "components": {}} - action: process_messages - payload: - [{"version": "v0.9", "createSurface": {"surfaceId": "s1", "catalogId": "conformance-catalog"}}] - expect: {"surfaces": {"s1": {"catalogId": "conformance-catalog", "sendDataModel": false}}} - -- name: test_processor_creates_surface_with_send_data_model - description: >- - sendDataModel is carried onto the surface it creates. - catalog: - version: "0.9" - catalog_schema: {"catalogId": "conformance-catalog", "components": {}} - action: process_messages - payload: - [ - { - "version": "v0.9", - "createSurface": - {"surfaceId": "s1", "catalogId": "conformance-catalog", "sendDataModel": true}, - }, - ] - expect: {"surfaces": {"s1": {"sendDataModel": true}}} - -- name: test_processor_deletes_surface - description: >- - A deleteSurface message removes the surface. - catalog: - version: "0.9" - catalog_schema: {"catalogId": "conformance-catalog", "components": {}} - action: process_messages - payload: - [ - {"version": "v0.9", "createSurface": {"surfaceId": "s1", "catalogId": "conformance-catalog"}}, - {"version": "v0.9", "deleteSurface": {"surfaceId": "s1"}}, - ] - expect: {"absent_surfaces": ["s1"]} - -- name: test_processor_adds_components - description: >- - updateComponents adds components to the named surface. - catalog: - version: "0.9" - catalog_schema: {"catalogId": "conformance-catalog", "components": {}} - action: process_messages - payload: - [ - {"version": "v0.9", "createSurface": {"surfaceId": "s1", "catalogId": "conformance-catalog"}}, - { - "version": "v0.9", - "updateComponents": - {"surfaceId": "s1", "components": [{"id": "root", "component": "Text", "text": "Hello"}]}, - }, - ] - expect: - { - "surfaces": - {"s1": {"components": {"root": {"component": "Text", "properties": {"text": "Hello"}}}}}, - } - -- name: test_processor_updates_existing_component_properties - description: >- - Re-sending a component of the same type replaces its properties. - catalog: - version: "0.9" - catalog_schema: {"catalogId": "conformance-catalog", "components": {}} - action: process_messages - payload: - [ - {"version": "v0.9", "createSurface": {"surfaceId": "s1", "catalogId": "conformance-catalog"}}, - { - "version": "v0.9", - "updateComponents": - {"surfaceId": "s1", "components": [{"id": "root", "component": "Text", "text": "Hello"}]}, - }, - { - "version": "v0.9", - "updateComponents": - {"surfaceId": "s1", "components": [{"id": "root", "component": "Text", "text": "World"}]}, - }, - ] - expect: - { - "surfaces": - {"s1": {"components": {"root": {"component": "Text", "properties": {"text": "World"}}}}}, - } - -- name: test_processor_recreates_component_when_type_changes - description: >- - Re-sending a component under a different type replaces the component - rather than merging into it. - catalog: - version: "0.9" - catalog_schema: {"catalogId": "conformance-catalog", "components": {}} - action: process_messages - payload: - [ - {"version": "v0.9", "createSurface": {"surfaceId": "s1", "catalogId": "conformance-catalog"}}, - { - "version": "v0.9", - "updateComponents": - {"surfaceId": "s1", "components": [{"id": "root", "component": "Text", "text": "Hello"}]}, - }, - { - "version": "v0.9", - "updateComponents": - { - "surfaceId": "s1", - "components": [{"id": "root", "component": "Column", "children": ["a"]}], - }, - }, - ] - expect: - { - "surfaces": - { - "s1": - {"components": {"root": {"component": "Column", "properties": {"children": ["a"]}}}}, - }, - } - -- name: test_processor_updates_components_on_the_named_surface_only - description: >- - Components are added to the surface the message names, not to every open - surface. - catalog: - version: "0.9" - catalog_schema: {"catalogId": "conformance-catalog", "components": {}} - action: process_messages - payload: - [ - {"version": "v0.9", "createSurface": {"surfaceId": "s1", "catalogId": "conformance-catalog"}}, - {"version": "v0.9", "createSurface": {"surfaceId": "s2", "catalogId": "conformance-catalog"}}, - { - "version": "v0.9", - "updateComponents": - {"surfaceId": "s2", "components": [{"id": "root", "component": "Text", "text": "Hello"}]}, - }, - ] - expect: - { - "surfaces": - { - "s1": {"components": {}}, - "s2": {"components": {"root": {"component": "Text", "properties": {"text": "Hello"}}}}, - }, - } - -- name: test_processor_routes_data_model_updates - description: >- - updateDataModel writes into the data model of the surface it names. - catalog: - version: "0.9" - catalog_schema: {"catalogId": "conformance-catalog", "components": {}} - action: process_messages - payload: - [ - {"version": "v0.9", "createSurface": {"surfaceId": "s1", "catalogId": "conformance-catalog"}}, - { - "version": "v0.9", - "updateDataModel": {"surfaceId": "s1", "path": "/user/name", "value": "Alice"}, - }, - ] - expect: {"surfaces": {"s1": {"data_model": {"user": {"name": "Alice"}}}}} - -- name: test_processor_routes_data_model_updates_per_surface - description: >- - Each surface has its own data model. - catalog: - version: "0.9" - catalog_schema: {"catalogId": "conformance-catalog", "components": {}} - action: process_messages - payload: - [ - {"version": "v0.9", "createSurface": {"surfaceId": "s1", "catalogId": "conformance-catalog"}}, - {"version": "v0.9", "createSurface": {"surfaceId": "s2", "catalogId": "conformance-catalog"}}, - {"version": "v0.9", "updateDataModel": {"surfaceId": "s1", "path": "/value", "value": "one"}}, - {"version": "v0.9", "updateDataModel": {"surfaceId": "s2", "path": "/value", "value": "two"}}, - ] - expect: - {"surfaces": {"s1": {"data_model": {"value": "one"}}, "s2": {"data_model": {"value": "two"}}}} - -- name: test_processor_client_data_model_includes_opted_in_surfaces_only - description: >- - The aggregated client data model carries only surfaces created with - sendDataModel enabled. - catalog: - version: "0.9" - catalog_schema: {"catalogId": "conformance-catalog", "components": {}} - action: process_messages - payload: - [ - { - "version": "v0.9", - "createSurface": - {"surfaceId": "s1", "catalogId": "conformance-catalog", "sendDataModel": true}, - }, - {"version": "v0.9", "updateDataModel": {"surfaceId": "s1", "path": "/foo", "value": "bar"}}, - { - "version": "v0.9", - "createSurface": - {"surfaceId": "s2", "catalogId": "conformance-catalog", "sendDataModel": false}, - }, - { - "version": "v0.9", - "updateDataModel": {"surfaceId": "s2", "path": "/secret", "value": "baz"}, - }, - ] - expect: {"client_data_model": {"surfaces": {"s1": {"foo": "bar"}}}} - -- name: test_processor_client_data_model_absent_without_opt_in - description: >- - No surface opting in means there is no client data model to send. - catalog: - version: "0.9" - catalog_schema: {"catalogId": "conformance-catalog", "components": {}} - action: process_messages - payload: - [ - {"version": "v0.9", "createSurface": {"surfaceId": "s1", "catalogId": "conformance-catalog"}}, - {"version": "v0.9", "updateDataModel": {"surfaceId": "s1", "path": "/foo", "value": "bar"}}, - ] - expect: {"client_data_model_absent": true} - -- name: test_processor_client_capabilities_list_catalog_ids - description: >- - Client capabilities advertise the ids of the catalogs the renderer holds, - under the protocol version key. - catalog: - version: "0.9" - catalog_schema: {"catalogId": "conformance-catalog", "components": {}} - action: process_messages - payload: [] - expect: {"client_capabilities": {"v0.9": {"supportedCatalogIds": ["conformance-catalog"]}}} - -- name: test_processor_rejects_unknown_catalog - description: >- - A surface cannot be created against a catalog the renderer does not hold. - catalog: - version: "0.9" - catalog_schema: {"catalogId": "conformance-catalog", "components": {}} - action: process_messages - payload: - [ - { - "version": "v0.9", - "createSurface": - {"surfaceId": "s1", "catalogId": "https://example.com/not-registered.json"}, - }, - ] - expect_error: {"category": "StateError", "message": "Catalog not found"} - -- name: test_processor_rejects_duplicate_surface - description: >- - A surface id cannot be created twice. - catalog: - version: "0.9" - catalog_schema: {"catalogId": "conformance-catalog", "components": {}} - action: process_messages - payload: - [ - {"version": "v0.9", "createSurface": {"surfaceId": "s1", "catalogId": "conformance-catalog"}}, - {"version": "v0.9", "createSurface": {"surfaceId": "s1", "catalogId": "conformance-catalog"}}, - ] - expect_error: {"category": "StateError", "message": "already exists"} - -- name: test_processor_rejects_components_for_unknown_surface - description: >- - Components cannot be added to a surface that was never created. - catalog: - version: "0.9" - catalog_schema: {"catalogId": "conformance-catalog", "components": {}} - action: process_messages - payload: - [ - { - "version": "v0.9", - "updateComponents": - {"surfaceId": "s1", "components": [{"id": "root", "component": "Text", "text": "Hello"}]}, - }, - ] - expect_error: {"category": "StateError", "message": "Surface not found"} - -- name: test_processor_rejects_data_model_update_for_unknown_surface - description: >- - The data model of a surface that was never created cannot be written. - catalog: - version: "0.9" - catalog_schema: {"catalogId": "conformance-catalog", "components": {}} - action: process_messages - payload: - [{"version": "v0.9", "updateDataModel": {"surfaceId": "s1", "path": "/foo", "value": "bar"}}] - expect_error: {"category": "StateError", "message": "Surface not found"} - -- name: test_processor_rejects_component_without_id - description: >- - Every component in an updateComponents message needs an id. - catalog: - version: "0.9" - catalog_schema: {"catalogId": "conformance-catalog", "components": {}} - action: process_messages - payload: - [ - {"version": "v0.9", "createSurface": {"surfaceId": "s1", "catalogId": "conformance-catalog"}}, - { - "version": "v0.9", - "updateComponents": - {"surfaceId": "s1", "components": [{"component": "Text", "text": "Hello"}]}, - }, - ] - expect_error: {"category": "ValidationError", "message": "missing an 'id'"} - -- name: test_processor_rejects_new_component_without_type - description: >- - A component that does not exist yet cannot be created without naming its - type. - catalog: - version: "0.9" - catalog_schema: {"catalogId": "conformance-catalog", "components": {}} - action: process_messages - payload: - [ - {"version": "v0.9", "createSurface": {"surfaceId": "s1", "catalogId": "conformance-catalog"}}, - { - "version": "v0.9", - "updateComponents": {"surfaceId": "s1", "components": [{"id": "root", "text": "Hello"}]}, - }, - ] - expect_error: {"category": "ValidationError", "message": "Cannot create component"} - -- name: test_processor_rejects_message_with_multiple_bodies - description: >- - An envelope carrying more than one message body is rejected. - catalog: - version: "0.9" - catalog_schema: {"catalogId": "conformance-catalog", "components": {}} - action: process_messages - payload: - [ - { - "version": "v0.9", - "createSurface": {"surfaceId": "s1", "catalogId": "conformance-catalog"}, - "deleteSurface": {"surfaceId": "s1"}, - }, - ] - expect_error: {"category": "ValidationError"} diff --git a/dart/a2ui_agent/lib/src/processor/generator.dart b/dart/a2ui_agent/lib/src/processor/generator.dart index e5518a800b..bb142a9a3f 100644 --- a/dart/a2ui_agent/lib/src/processor/generator.dart +++ b/dart/a2ui_agent/lib/src/processor/generator.dart @@ -60,11 +60,27 @@ class A2uiGenerator { /// The capabilities this agent advertises, mirroring /// `specification/v0_9_1/json/server_capabilities.json`. - Map get agentCapabilities => { - 'a2uiVersions': [A2uiProtocolVersion.v0_9.jsonValue], - 'supportedCatalogIds': [ - for (final CatalogConfig config in catalogs) config.catalog.id, - ], - 'acceptsInlineCatalogs': acceptsInlineCatalogs, - }; + /// + /// [catalogs] may mix protocol versions, so each catalog is advertised under + /// the version it declares rather than under one version assumed for all of + /// them. Every version this SDK implements gets an entry, even when no + /// catalog is registered for it, because the schema requires one. + Map get agentCapabilities { + final Map> idsByVersion = { + for (final A2uiProtocolVersion version in A2uiProtocolVersion.values) + version.jsonValue: [], + }; + for (final CatalogConfig config in catalogs) { + idsByVersion[config.catalog.protocolVersion.jsonValue]!.add( + config.catalog.id, + ); + } + return { + for (final MapEntry> entry in idsByVersion.entries) + entry.key: { + 'supportedCatalogIds': entry.value, + 'acceptsInlineCatalogs': acceptsInlineCatalogs, + }, + }; + } } diff --git a/dart/a2ui_agent/test/conformance/agent_conformance_test.dart b/dart/a2ui_agent/test/conformance/agent_conformance_test.dart index ddc1e2b699..08b10c1bf2 100644 --- a/dart/a2ui_agent/test/conformance/agent_conformance_test.dart +++ b/dart/a2ui_agent/test/conformance/agent_conformance_test.dart @@ -24,6 +24,9 @@ import 'conformance_harness.dart'; /// reason, so the suite doubles as the implementation checklist. void main() { _runSuite('core/catalog.yaml'); + _runSuite('agent/catalog_provider.yaml'); + _runSuite('agent/catalog_transformer.yaml'); + _runSuite('agent/catalog_resolver.yaml'); _runSuite('agent/inference_format.yaml'); _runSuite('agent/parser.yaml'); _runSuite('agent/streaming_parser.yaml'); @@ -75,6 +78,9 @@ String? _skipReason(Map testCase) { } return null; case 'select_catalog': + return 'select_catalog is the legacy single-catalog helper; this SDK ' + 'negotiates with resolveCatalogs.'; + case 'resolve_catalogs': return 'resolveCatalogs is not implemented yet.'; case 'generate_prompt': return 'DirectJsonPromptGenerator.generate is not implemented yet.'; @@ -99,6 +105,8 @@ void _runCase(Map testCase) { _runPrune(testCase); case 'load_catalog': _runLoadCatalog(testCase); + case 'resolve_catalogs': + _runResolveCatalogs(testCase); default: fail('No agent harness for conformance action "$action".'); } @@ -163,6 +171,90 @@ void _runLoadCatalog(Map testCase) { } } +void _runResolveCatalogs(Map testCase) { + final args = testCase['args']! as Map; + final configs = args['catalogs']! as List; + final bool acceptsInline = + (args['accepts_inline_catalogs'] as bool?) ?? false; + + // Capabilities are parsed inside the closure: a case may expect the + // rejection to come from parsing them rather than from negotiation. + List> resolve() => resolveCatalogs( + [ + for (final Object? entry in configs) + _catalogConfigOf(entry! as Map), + ], + A2uiRendererCapabilities.fromJson( + args['renderer_capabilities']! as Map, + ), + acceptsInlineCatalogs: acceptsInline, + ); + + final Object? expectError = testCase['expect_error']; + if (expectError != null) { + expect( + resolve, + throwsA(matchesConformanceError(expectError as Map)), + reason: testCase['name'] as String?, + ); + return; + } + + final List> active = resolve(); + + final Object? expectedIds = testCase['expect_active_catalog_ids']; + if (expectedIds != null) { + expect( + active.map((c) => c.id).toList(), + equals(expectedIds), + reason: testCase['name'] as String?, + ); + } + + final expectedComponents = + testCase['expect_components'] as Map?; + if (expectedComponents != null) { + for (final MapEntry entry in expectedComponents.entries) { + final Catalog catalog = active + .firstWhere( + (c) => c.id == entry.key, + orElse: () => fail('Catalog "${entry.key}" was not negotiated.'), + ); + expect( + catalog.components.keys.toList()..sort(), + equals(entry.value), + reason: testCase['name'] as String?, + ); + } + } +} + +/// Builds a registered catalog configuration from a `catalogs` entry. +SchemaCatalogConfig _catalogConfigOf(Map entry) { + final Object? schema = entry['catalog_schema']; + final SchemaCatalog catalog = switch (schema) { + final String path => FileSystemCatalogProvider( + resolveConformancePath(path), + ).load(), + final Map inline => InMemoryCatalogProvider(inline).load(), + _ => fail('A catalogs entry needs an inline catalog_schema or a path.'), + }; + + return SchemaCatalogConfig( + catalog, + transformers: [ + if (entry['allowed_components'] != null) + ComponentPruningTransformer( + (entry['allowed_components']! as List).cast(), + ), + if (entry['allowed_functions'] != null) + FunctionPruningTransformer( + (entry['allowed_functions']! as List).cast(), + ), + ], + ); +} + /// The catalog document a case runs against, inline or loaded from a path. Map _catalogSchemaOf(Map testCase) { final Map catalog = diff --git a/dart/a2ui_agent/test/conformance/conformance_harness.dart b/dart/a2ui_agent/test/conformance/conformance_harness.dart index 1ddd7df072..83321c9195 100644 --- a/dart/a2ui_agent/test/conformance/conformance_harness.dart +++ b/dart/a2ui_agent/test/conformance/conformance_harness.dart @@ -15,7 +15,9 @@ import 'dart:convert'; import 'dart:io'; +import 'package:a2ui_core/a2ui_core.dart'; import 'package:path/path.dart' as p; +import 'package:test/test.dart'; import 'package:yaml/yaml.dart'; /// Resolves a case's path against the `conformance/` directory, for example @@ -93,3 +95,30 @@ String? caseVersion(Map testCase) { if (catalog is Map) return catalog['version'] as String?; return null; } + +/// A matcher for a case's `expect_error` block: the error category, and the +/// shared substring of the message, which differs between implementations. +Matcher matchesConformanceError(Map expectError) { + final category = expectError['category'] as String?; + final message = expectError['message'] as String?; + Matcher matcher = switch (category) { + 'DataError' => isA(), + 'ValidationError' => isA(), + 'CatalogError' => isA(), + 'IntegrityError' => isA(), + 'RecursionError' => isA(), + 'ParseError' => isA(), + 'CompileError' => isA(), + _ => isA(), + }; + if (message != null) { + matcher = allOf( + matcher, + predicate( + (Object? e) => RegExp(message).hasMatch(e.toString()), + 'message matching /$message/', + ), + ); + } + return matcher; +} diff --git a/dart/a2ui_agent/test/e2e/minimal_snippet_test.dart b/dart/a2ui_agent/test/e2e/minimal_snippet_test.dart index 9e225c3583..7c444b6b50 100644 --- a/dart/a2ui_agent/test/e2e/minimal_snippet_test.dart +++ b/dart/a2ui_agent/test/e2e/minimal_snippet_test.dart @@ -142,10 +142,12 @@ void main() { final generator = A2uiGenerator( catalogs: [CatalogConfig(basicCatalog())], ); - expect(generator.agentCapabilities['supportedCatalogIds'], [ - basicCatalogId, - ]); - expect(generator.agentCapabilities['a2uiVersions'], ['v0.9']); + expect(generator.agentCapabilities, { + 'v0.9': { + 'supportedCatalogIds': [basicCatalogId], + 'acceptsInlineCatalogs': false, + }, + }); }); test('the example runs against the published basic catalog', () { diff --git a/dart/a2ui_agent/test/processor/generator_test.dart b/dart/a2ui_agent/test/processor/generator_test.dart index 041395e355..46eaca13a6 100644 --- a/dart/a2ui_agent/test/processor/generator_test.dart +++ b/dart/a2ui_agent/test/processor/generator_test.dart @@ -34,6 +34,11 @@ A2uiGenerator generator({ inferenceFormatFactory: factory, ); +/// The `v0.9` entry of what [g] advertises. +Map v0_9Of( + A2uiGenerator g, +) => g.agentCapabilities['v0.9']! as Map; + void main() { group('A2uiGenerator configuration', () { test('holds the catalog configurations it was registered with', () { @@ -73,10 +78,32 @@ void main() { }); group('A2uiGenerator.agentCapabilities', () { - test('advertises only the protocol version this SDK implements', () { - expect(generator().agentCapabilities['a2uiVersions'], ['v0.9']); + test('keys catalogs by the protocol version each one declares', () { + // Registered catalogs may span protocol versions, so the advertised + // object follows `server_capabilities.json` and groups the ids under + // the version key they belong to. + expect(generator().agentCapabilities.keys, ['v0.9']); }); + test( + 'declares every version this SDK implements, even with no catalog', + () { + final Map capabilities = generator( + catalogs: const [], + ).agentCapabilities; + + expect(capabilities.keys, [ + for (final A2uiProtocolVersion version in A2uiProtocolVersion.values) + version.jsonValue, + ]); + expect( + (capabilities['v0.9']! + as Map)['supportedCatalogIds'], + isEmpty, + ); + }, + ); + test('advertises every registered catalog id', () { final A2uiGenerator g = generator( catalogs: [ @@ -85,20 +112,22 @@ void main() { ], ); - expect(g.agentCapabilities['supportedCatalogIds'], [ - basicCatalogId, - 'https://example.com/small.json', - ]); + expect(g.agentCapabilities['v0.9'], { + 'supportedCatalogIds': [ + basicCatalogId, + 'https://example.com/small.json', + ], + 'acceptsInlineCatalogs': false, + }); }); test('advertises whether inline catalogs are accepted', () { - expect(generator().agentCapabilities['acceptsInlineCatalogs'], isFalse); - expect( - generator( - acceptsInlineCatalogs: true, - ).agentCapabilities['acceptsInlineCatalogs'], - isTrue, - ); + Object? acceptsInline( + A2uiGenerator g, + ) => v0_9Of(g)['acceptsInlineCatalogs']; + + expect(acceptsInline(generator()), isFalse); + expect(acceptsInline(generator(acceptsInlineCatalogs: true)), isTrue); }); test('advertises the pristine catalog id, not a transformed copy', () { @@ -113,7 +142,7 @@ void main() { ], ); - expect(g.agentCapabilities['supportedCatalogIds'], [basicCatalogId]); + expect(v0_9Of(g)['supportedCatalogIds'], [basicCatalogId]); }); }); diff --git a/dart/a2ui_core/test/conformance/message_processor_conformance_test.dart b/dart/a2ui_core/test/conformance/message_processor_conformance_test.dart deleted file mode 100644 index e94326e240..0000000000 --- a/dart/a2ui_core/test/conformance/message_processor_conformance_test.dart +++ /dev/null @@ -1,252 +0,0 @@ -// Copyright 2024 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 'package:a2ui_core/a2ui_core.dart'; -import 'package:test/test.dart'; - -import 'conformance_harness.dart'; - -/// Runs the shared `conformance/core/message_processor.yaml` suite against -/// [MessageProcessor]. -/// -/// Cases supply only a catalog id: renderers build catalogs from code, so each -/// harness builds one natively, as the suite header explains. -void main() { - final List> cases = loadConformanceSuite( - 'core/message_processor.yaml', - ); - - group('conformance core/message_processor.yaml', () { - test('suite is not empty', () => expect(cases, isNotEmpty)); - - for (final testCase in cases) { - test(testCase['name']! as String, () => _runCase(testCase)); - } - }); -} - -void _runCase(Map testCase) { - final catalog = _EmptyCatalog(_catalogIdOf(testCase)); - final processor = MessageProcessor(catalogs: [catalog]); - final List> payload = - (testCase['payload']! as List).cast>(); - final name = testCase['name']! as String; - - final Object? expectError = testCase['expect_error']; - if (expectError != null) { - expect( - () => _process(processor, payload), - throwsA(_matchesError(expectError as Map)), - reason: name, - ); - return; - } - - _process(processor, payload); - - final Map expected = - (testCase['expect'] as Map?) ?? const {}; - _checkSurfaces(processor, expected, name); - _checkAbsentSurfaces(processor, expected, name); - _checkClientDataModel(processor, expected, name); - _checkClientCapabilities(processor, expected, name); -} - -/// Converts each envelope and processes it. -/// -/// Conversion counts as processing here: the Dart processor takes typed -/// messages, so [A2uiMessage.fromJson] rejects a malformed envelope first. -void _process( - MessageProcessor processor, - List> payload, -) { - for (final envelope in payload) { - processor.processMessages([ - A2uiMessage.fromJson(Map.from(envelope)), - ]); - } -} - -void _checkSurfaces( - MessageProcessor processor, - Map expected, - String name, -) { - final surfaces = expected['surfaces'] as Map?; - if (surfaces == null) return; - - surfaces.forEach((surfaceId, raw) { - final SurfaceModel? surface = processor.groupModel.getSurface( - surfaceId, - ); - expect(surface, isNotNull, reason: '$name: surface $surfaceId is open'); - - final expectations = raw! as Map; - if (expectations.containsKey('catalogId')) { - expect( - surface!.catalog.id, - expectations['catalogId'], - reason: '$name: $surfaceId catalogId', - ); - } - if (expectations.containsKey('sendDataModel')) { - expect( - surface!.sendDataModel, - expectations['sendDataModel'], - reason: '$name: $surfaceId sendDataModel', - ); - } - if (expectations.containsKey('data_model')) { - expect( - surface!.dataModel.get('/'), - equals(expectations['data_model']), - reason: '$name: $surfaceId data model', - ); - } - final components = expectations['components'] as Map?; - if (components != null) { - _checkComponents(surface!, components, '$name: $surfaceId'); - } - }); -} - -void _checkComponents( - SurfaceModel surface, - Map expected, - String reason, -) { - for (final MapEntry entry in expected.entries) { - final ComponentModel? component = surface.componentsModel.get(entry.key); - expect(component, isNotNull, reason: '$reason: component ${entry.key}'); - - final expectations = entry.value! as Map; - if (expectations.containsKey('component')) { - expect( - component!.type, - expectations['component'], - reason: '$reason: ${entry.key} type', - ); - } - final properties = expectations['properties'] as Map?; - if (properties != null) { - properties.forEach((key, value) { - expect( - component!.properties[key], - equals(value), - reason: '$reason: ${entry.key}.$key', - ); - }); - } - } - if (expected.isEmpty) { - expect( - surface.componentsModel.all, - isEmpty, - reason: '$reason: no components', - ); - } -} - -void _checkAbsentSurfaces( - MessageProcessor processor, - Map expected, - String name, -) { - final absent = expected['absent_surfaces'] as List?; - if (absent == null) return; - for (final Object? surfaceId in absent) { - expect( - processor.groupModel.getSurface(surfaceId! as String), - isNull, - reason: '$name: surface $surfaceId is closed', - ); - } -} - -void _checkClientDataModel( - MessageProcessor processor, - Map expected, - String name, -) { - if (expected['client_data_model_absent'] == true) { - expect(processor.getClientDataModel(), isNull, reason: name); - } - final model = expected['client_data_model'] as Map?; - if (model == null) return; - - final Map? actual = processor.getClientDataModel(); - expect(actual, isNotNull, reason: name); - model.forEach((key, value) { - expect(actual![key], equals(value), reason: '$name: client data $key'); - }); -} - -void _checkClientCapabilities( - MessageProcessor processor, - Map expected, - String name, -) { - final capabilities = expected['client_capabilities'] as Map?; - if (capabilities == null) return; - - final Map actual = processor.getClientCapabilities(); - capabilities.forEach((version, value) { - final expectations = value! as Map; - final actualVersion = actual[version] as Map?; - expect(actualVersion, isNotNull, reason: '$name: capabilities $version'); - expectations.forEach((key, expectedValue) { - expect( - actualVersion![key], - equals(expectedValue), - reason: '$name: capabilities $version.$key', - ); - }); - }); -} - -String _catalogIdOf(Map testCase) { - final Map catalog = - (testCase['catalog'] as Map?) ?? const {}; - final schema = catalog['catalog_schema'] as Map?; - return schema?['catalogId'] as String? ?? 'conformance-catalog'; -} - -Matcher _matchesError(Map expectError) { - final category = expectError['category'] as String?; - final message = expectError['message'] as String?; - Matcher matcher = switch (category) { - 'StateError' => isA(), - 'ValidationError' => isA(), - 'DataError' => isA(), - 'CatalogError' => isA(), - 'IntegrityError' => isA(), - 'RecursionError' => isA(), - _ => isA(), - }; - if (message != null) { - matcher = allOf( - matcher, - predicate( - (e) => RegExp(message).hasMatch(e.toString()), - 'message matching /$message/', - ), - ); - } - return matcher; -} - -/// A catalog with no components, built natively as the suite requires. -class _EmptyCatalog extends Catalog { - _EmptyCatalog(String id) : super(id: id, components: const []); -} diff --git a/renderers/web_core/tests/conformance/message-processor.conformance.test.ts b/renderers/web_core/tests/conformance/message-processor.conformance.test.ts deleted file mode 100644 index c085c2338e..0000000000 --- a/renderers/web_core/tests/conformance/message-processor.conformance.test.ts +++ /dev/null @@ -1,186 +0,0 @@ -/* - * Copyright 2024 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 * as assert from 'node:assert'; -import {describe, it} from 'node:test'; -import {loadConformanceSuite} from './harness.js'; -import {MessageProcessor} from '../../src/v0_9/processing/message-processor.js'; -import {Catalog, ComponentApi} from '../../src/v0_9/catalog/types.js'; -import {SurfaceModel} from '../../src/v0_9/state/surface-model.js'; - -/** - * Runs the shared `conformance/core/message_processor.yaml` suite against - * `MessageProcessor`. - * - * The suite's catalog is built natively rather than parsed from the case, as - * the suite header explains: renderers construct catalogs from code, so the - * case supplies only the catalog id. - * - * This is additive. The hand-written tests in - * `src/v0_9/processing/message-processor.test.ts` still run, and cover - * behaviour that is specific to this implementation, notably Zod-based - * component schema validation and `REF:` handling in inline catalogs. - */ - -interface SurfaceExpectation { - catalogId?: string; - sendDataModel?: boolean; - components?: Record}>; - data_model?: unknown; -} - -interface ConformanceCase { - name: string; - catalog?: {catalog_schema?: {catalogId?: string}}; - payload: Array>; - expect?: { - surfaces?: Record; - absent_surfaces?: string[]; - client_data_model?: Record; - client_data_model_absent?: boolean; - client_capabilities?: Record>; - }; - expect_error?: {category?: string; message?: string} | string; -} - -/** A catalog with no components, built natively as the suite requires. */ -function emptyCatalog(id: string): Catalog { - return new Catalog(id, []); -} - -function catalogIdOf(testCase: ConformanceCase): string { - return testCase.catalog?.catalog_schema?.catalogId ?? 'conformance-catalog'; -} - -function errorPattern(expectError: ConformanceCase['expect_error']): RegExp { - const message = typeof expectError === 'string' ? expectError : (expectError?.message ?? ''); - return new RegExp(message); -} - -function checkComponents( - surface: SurfaceModel, - expected: NonNullable, - reason: string, -): void { - for (const [id, expectation] of Object.entries(expected)) { - const component = surface.componentsModel.get(id); - assert.ok(component, `${reason}: component ${id}`); - - if (expectation.component !== undefined) { - assert.strictEqual(component.type, expectation.component, `${reason}: ${id} type`); - } - for (const [key, value] of Object.entries(expectation.properties ?? {})) { - assert.deepStrictEqual(component.properties[key], value, `${reason}: ${id}.${key}`); - } - } - if (Object.keys(expected).length === 0) { - assert.strictEqual([...surface.componentsModel.entries].length, 0, `${reason}: no components`); - } -} - -function runCase(testCase: ConformanceCase): void { - const processor = new MessageProcessor([emptyCatalog(catalogIdOf(testCase))]); - const name = testCase.name; - - if (testCase.expect_error !== undefined) { - assert.throws( - () => processor.processMessages(testCase.payload as never), - errorPattern(testCase.expect_error), - name, - ); - return; - } - - processor.processMessages(testCase.payload as never); - const expected = testCase.expect ?? {}; - - for (const [surfaceId, expectation] of Object.entries(expected.surfaces ?? {})) { - const surface = processor.model.getSurface(surfaceId); - assert.ok(surface, `${name}: surface ${surfaceId} is open`); - - if (expectation.catalogId !== undefined) { - assert.strictEqual( - surface.catalog.id, - expectation.catalogId, - `${name}: ${surfaceId} catalogId`, - ); - } - if (expectation.sendDataModel !== undefined) { - assert.strictEqual( - surface.sendDataModel, - expectation.sendDataModel, - `${name}: ${surfaceId} sendDataModel`, - ); - } - if ('data_model' in expectation) { - assert.deepStrictEqual( - surface.dataModel.get('/'), - expectation.data_model, - `${name}: ${surfaceId} data model`, - ); - } - if (expectation.components !== undefined) { - checkComponents(surface, expectation.components, `${name}: ${surfaceId}`); - } - } - - for (const surfaceId of expected.absent_surfaces ?? []) { - assert.strictEqual( - processor.model.getSurface(surfaceId), - undefined, - `${name}: surface ${surfaceId} is closed`, - ); - } - - if (expected.client_data_model_absent === true) { - assert.strictEqual(processor.getClientDataModel(), undefined, name); - } - if (expected.client_data_model !== undefined) { - const actual = processor.getClientDataModel() as Record | undefined; - assert.ok(actual, name); - for (const [key, value] of Object.entries(expected.client_data_model)) { - assert.deepStrictEqual(actual[key], value, `${name}: client data ${key}`); - } - } - if (expected.client_capabilities !== undefined) { - const actual = processor.getClientCapabilities() as unknown as Record< - string, - Record - >; - for (const [version, expectations] of Object.entries(expected.client_capabilities)) { - assert.ok(actual[version], `${name}: capabilities ${version}`); - for (const [key, value] of Object.entries(expectations)) { - assert.deepStrictEqual( - actual[version][key], - value, - `${name}: capabilities ${version}.${key}`, - ); - } - } - } -} - -describe('conformance core/message_processor.yaml', () => { - const cases = loadConformanceSuite('core/message_processor.yaml'); - - it('suite is not empty', () => { - assert.ok(cases.length > 0); - }); - - for (const testCase of cases) { - it(testCase.name, () => runCase(testCase)); - } -}); From 079fb2c4292512d8fb9cb588d5ca192e6eb5f090 Mon Sep 17 00:00:00 2001 From: Polina Cherkasova Date: Fri, 28 Aug 2026 11:23:21 -0700 Subject: [PATCH 16/22] Restore message processor conformance in the v1_0 case shape Comment #4 on PR 2408 asked to remove a2ui_core changes; the suite was dropped because v1_0 already carries 51 cases under the same filename with an incompatible case shape. Restored instead as the two cases v1_0 does not cover -- component and data model isolation between surfaces -- written in the v1_0 vocabulary so the files concatenate on merge. Also fixes two suite headers that cited the wrong blueprint section: catalog providers are 3.F and transformers 3.A, not 3.B and 3.C. --- conformance/README.md | 1 + conformance/agent/catalog_provider.yaml | 2 +- conformance/agent/catalog_transformer.yaml | 2 +- conformance/conformance_schema.json | 68 +++++- conformance/core/message_processor.yaml | 110 +++++++++ .../message_processor_conformance_test.dart | 213 ++++++++++++++++++ .../message-processor.conformance.test.ts | 170 ++++++++++++++ 7 files changed, 562 insertions(+), 4 deletions(-) create mode 100644 conformance/core/message_processor.yaml create mode 100644 dart/a2ui_core/test/conformance/message_processor_conformance_test.dart create mode 100644 renderers/web_core/tests/conformance/message-processor.conformance.test.ts diff --git a/conformance/README.md b/conformance/README.md index 42c7db0f69..a58d662add 100644 --- a/conformance/README.md +++ b/conformance/README.md @@ -12,6 +12,7 @@ Test suites are organized by functional domain: - `core/accessibility.yaml`: Contains test cases for accessibility attributes and checks. - `core/validator.yaml`: Contains test cases for schema and structural validators, verifying structural integrity, cycle detection, and reachability. - `core/data_model.yaml`: Contains test cases for the reactive data model, verifying JSON Pointer reads and writes, container creation, deletion, and observer notification. +- `core/message_processor.yaml`: Contains test cases for the message processor's state machine. Written in the case vocabulary of the `v1_0` branch, whose suite of the same name is the primary one, so the two converge rather than conflict. ### Agent (`agent/`) diff --git a/conformance/agent/catalog_provider.yaml b/conformance/agent/catalog_provider.yaml index 03d149580d..b3584e2d90 100644 --- a/conformance/agent/catalog_provider.yaml +++ b/conformance/agent/catalog_provider.yaml @@ -13,7 +13,7 @@ # limitations under the License. # Behaviour of the catalog providers described by -# `blueprints/modules/a2ui_agent.blueprint.md` section 3.B: turning a catalog +# `blueprints/modules/a2ui_agent.blueprint.md` section 3.F: turning a catalog # document into a `Catalog`, whatever backing store it came from. Prompt # rendering and capability negotiation are covered by `inference_format.yaml` # and `catalog_resolver.yaml` respectively. diff --git a/conformance/agent/catalog_transformer.yaml b/conformance/agent/catalog_transformer.yaml index 215d5bbfa3..df52251bb8 100644 --- a/conformance/agent/catalog_transformer.yaml +++ b/conformance/agent/catalog_transformer.yaml @@ -13,7 +13,7 @@ # limitations under the License. # Behaviour of the catalog transformers described by -# `blueprints/modules/a2ui_agent.blueprint.md` section 3.C. Transformation is an +# `blueprints/modules/a2ui_agent.blueprint.md` section 3.A. Transformation is an # agent concern: a renderer implements the whole catalog it advertises, while an # agent narrows one before prompting. The `prune` cases in # `conformance/core/catalog.yaml` predate that split and stay where the SDKs diff --git a/conformance/conformance_schema.json b/conformance/conformance_schema.json index b1b97b49c9..dd0a9712d8 100644 --- a/conformance/conformance_schema.json +++ b/conformance/conformance_schema.json @@ -94,7 +94,8 @@ "accessibility_check", "data_model", "process_request", - "resolve_catalogs" + "resolve_catalogs", + "process_messages" ] } }, @@ -127,7 +128,8 @@ {"$ref": "#/$defs/AccessibilityCheckTest"}, {"$ref": "#/$defs/DataModelTest"}, {"$ref": "#/$defs/ProcessRequestTest"}, - {"$ref": "#/$defs/ResolveCatalogsTest"} + {"$ref": "#/$defs/ResolveCatalogsTest"}, + {"$ref": "#/$defs/ProcessMessagesTest"} ] } ] @@ -653,6 +655,68 @@ }, "required": ["args"] }, + "ProcessMessagesTest": { + "type": "object", + "description": "Behaviour of the message processor. Uses the case vocabulary of the v1_0 branch so the two suites of this name converge rather than conflict: camelCase keys, `messages` rather than `payload`, and an exhaustive `components` list per surface.", + "properties": { + "action": {"const": "process_messages"}, + "catalogPaths": { + "type": "array", + "items": {"type": "string"}, + "description": "Catalog documents the case is written against, relative to the repository root. Harnesses register a native catalog under the id the messages use, because renderers build catalogs from code rather than from a JSON Schema document." + }, + "messages": { + "description": "A2UI messages to process, in order: either the bare list or a {messages: [...]} wrapper.", + "oneOf": [ + {"type": "array", "items": {"type": "object"}}, + { + "type": "object", + "properties": {"messages": {"type": "array", "items": {"type": "object"}}}, + "required": ["messages"] + } + ] + }, + "expect": { + "type": "object", + "description": "Expected state after the messages have been processed.", + "properties": { + "surfaces": { + "type": "object", + "description": "Expectations per surface, keyed by surface id.", + "additionalProperties": { + "type": "object", + "properties": { + "exists": { + "type": "boolean", + "description": "Whether the surface is open. False asserts it is not." + }, + "catalogId": {"type": "string"}, + "sendDataModel": {"type": "boolean"}, + "theme": {"type": "object"}, + "components": { + "type": "array", + "description": "Every component the surface holds, each entry the component's flattened properties. The list is exhaustive, so an empty one asserts the surface holds none.", + "items": { + "type": "object", + "properties": { + "id": {"type": "string"}, + "component": {"type": "string"} + }, + "required": ["id"] + } + }, + "dataModel": { + "description": "Expected contents of the surface data model at the root." + } + } + } + } + } + }, + "expectError": {"$ref": "#/$defs/ExpectError"} + }, + "required": ["messages"] + }, "ExpectError": { "oneOf": [ { diff --git a/conformance/core/message_processor.yaml b/conformance/core/message_processor.yaml new file mode 100644 index 0000000000..031874ffb5 --- /dev/null +++ b/conformance/core/message_processor.yaml @@ -0,0 +1,110 @@ +# Copyright 2024 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. + +# Behaviour of the message processor, written to converge with the suite of the +# same name on the `v1_0` branch rather than to compete with it. +# +# The v1_0 suite is the primary one: 51 cases covering the surface lifecycle, +# component mutation, graph topology, strict schema validation, JSON Pointer +# resolution and the v0.8 / v0.9 / v1.0 protocol vectors. Every case that used +# to live here in a different shape is already covered there, so only the two +# that are not have been kept, written in the v1_0 vocabulary so the two files +# concatenate instead of conflicting when that branch lands: +# +# action `process_messages` +# catalogPaths catalog documents the case runs against, repo-root relative +# messages the messages to process, in order (or a `{messages: [...]}` +# wrapper) +# expect.surfaces per surface id, any of `exists`, `sendDataModel`, `theme`, +# `components` (a list, each entry the component's flattened +# properties) and `dataModel` (the whole model at `/`) +# expectError `category` plus a `message` regular expression, matched +# against a substring the implementations share +# +# Both cases below pin **state isolation between surfaces**, which the v1_0 +# suite opens two surfaces for only twice, and asserts in neither: mutations +# must land on the surface the message names and nowhere else. +# +# Renderers build catalogs from code (Zod in `web_core`, `Schema` in Dart) and +# neither builds a renderer catalog from a JSON Schema document, so each +# harness registers a native catalog under the `catalogId` the messages use. +# `catalogPaths` records which published document the case is written against. + +# --- Surface state isolation --- + +- name: test_update_components_target_named_surface_only + description: Tests that components are added to the surface the message names, not to every open surface. + action: process_messages + catalogPaths: + - "specification/v0_9/catalogs/basic/catalog.json" + messages: + - version: "v0.9" + createSurface: + surfaceId: "s1" + catalogId: "test-catalog" + - version: "v0.9" + createSurface: + surfaceId: "s2" + catalogId: "test-catalog" + - version: "v0.9" + updateComponents: + surfaceId: "s2" + components: + - id: "root" + component: "Text" + text: "Hello" + expect: + surfaces: + s1: + exists: true + components: [] + s2: + exists: true + components: + - id: "root" + component: "Text" + text: "Hello" + +- name: test_update_data_model_isolated_per_surface + description: Tests that each surface owns its data model, so the same path holds a different value per surface. + action: process_messages + catalogPaths: + - "specification/v0_9/catalogs/basic/catalog.json" + messages: + - version: "v0.9" + createSurface: + surfaceId: "s1" + catalogId: "test-catalog" + - version: "v0.9" + createSurface: + surfaceId: "s2" + catalogId: "test-catalog" + - version: "v0.9" + updateDataModel: + surfaceId: "s1" + path: "/value" + value: "one" + - version: "v0.9" + updateDataModel: + surfaceId: "s2" + path: "/value" + value: "two" + expect: + surfaces: + s1: + dataModel: + value: "one" + s2: + dataModel: + value: "two" diff --git a/dart/a2ui_core/test/conformance/message_processor_conformance_test.dart b/dart/a2ui_core/test/conformance/message_processor_conformance_test.dart new file mode 100644 index 0000000000..89e43bbf66 --- /dev/null +++ b/dart/a2ui_core/test/conformance/message_processor_conformance_test.dart @@ -0,0 +1,213 @@ +// Copyright 2024 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 'package:a2ui_core/a2ui_core.dart'; +import 'package:test/test.dart'; + +import 'conformance_harness.dart'; + +/// Runs the shared `conformance/core/message_processor.yaml` suite against +/// [MessageProcessor]. +/// +/// The suite uses the case vocabulary of the `v1_0` branch: `messages`, +/// `catalogPaths`, and `expect.surfaces`. Cases name a catalog rather than +/// declaring one, because renderers build catalogs from code, so this harness +/// registers a native catalog under the id the messages use. +void main() { + final List> cases = loadConformanceSuite( + 'core/message_processor.yaml', + ); + + group('conformance core/message_processor.yaml', () { + test('suite is not empty', () => expect(cases, isNotEmpty)); + + for (final testCase in cases) { + test(testCase['name']! as String, () => _runCase(testCase)); + } + }); +} + +void _runCase(Map testCase) { + final name = testCase['name']! as String; + final catalog = _EmptyCatalog(_catalogIdOf(testCase)); + final processor = MessageProcessor(catalogs: [catalog]); + final List> messages = _messagesOf(testCase); + + final Object? expectError = testCase['expectError']; + if (expectError != null) { + expect( + () => _process(processor, messages), + throwsA(_matchesError(expectError as Map)), + reason: name, + ); + return; + } + + _process(processor, messages); + + final Map expected = + (testCase['expect'] as Map?) ?? const {}; + _checkSurfaces(processor, expected, name); +} + +/// The messages a case processes, accepting both the bare list and the +/// `{messages: [...]}` wrapper the protocol allows. +List> _messagesOf(Map testCase) { + final Object? raw = testCase['messages'] ?? testCase['payload']; + final Object? list = raw is Map ? raw['messages'] : raw; + return (list! as List).cast>(); +} + +/// The catalog id the case's messages bind surfaces to. +String _catalogIdOf(Map testCase) { + for (final Map message in _messagesOf(testCase)) { + final Object? create = message['createSurface']; + if (create is Map && create['catalogId'] is String) { + return create['catalogId']! as String; + } + } + return 'test-catalog'; +} + +/// Converts each envelope and processes it. +/// +/// Conversion counts as processing here: the Dart processor takes typed +/// messages, so [A2uiMessage.fromJson] rejects a malformed envelope first. +void _process( + MessageProcessor processor, + List> messages, +) { + for (final envelope in messages) { + processor.processMessages([ + A2uiMessage.fromJson(Map.from(envelope)), + ]); + } +} + +void _checkSurfaces( + MessageProcessor processor, + Map expected, + String name, +) { + final surfaces = expected['surfaces'] as Map?; + if (surfaces == null) return; + + surfaces.forEach((surfaceId, raw) { + final SurfaceModel? surface = processor.groupModel.getSurface( + surfaceId, + ); + final expectations = raw! as Map; + + if (expectations['exists'] == false) { + expect(surface, isNull, reason: '$name: surface $surfaceId is closed'); + return; + } + expect(surface, isNotNull, reason: '$name: surface $surfaceId is open'); + + if (expectations.containsKey('catalogId')) { + expect( + surface!.catalog.id, + expectations['catalogId'], + reason: '$name: $surfaceId catalogId', + ); + } + if (expectations.containsKey('sendDataModel')) { + expect( + surface!.sendDataModel, + expectations['sendDataModel'], + reason: '$name: $surfaceId sendDataModel', + ); + } + if (expectations.containsKey('dataModel')) { + expect( + surface!.dataModel.get('/'), + equals(expectations['dataModel']), + reason: '$name: $surfaceId data model', + ); + } + if (expectations.containsKey('components')) { + _checkComponents( + surface!, + (expectations['components']! as List) + .cast>(), + '$name: $surfaceId', + ); + } + }); +} + +/// Checks the surface's component graph against the case's expectations. +/// +/// Each entry is the component's flattened properties: `id`, `component`, and +/// whatever else the message set on it. The list is exhaustive, so an empty +/// one asserts the surface holds no components at all. +void _checkComponents( + SurfaceModel surface, + List> expected, + String reason, +) { + expect(surface.componentsModel.all.map((c) => c.id).toSet(), { + for (final Map entry in expected) entry['id'], + }, reason: '$reason: component ids'); + + for (final entry in expected) { + final id = entry['id']! as String; + final ComponentModel? component = surface.componentsModel.get(id); + expect(component, isNotNull, reason: '$reason: component $id'); + + entry.forEach((key, value) { + if (key == 'id') return; + if (key == 'component') { + expect(component!.type, value, reason: '$reason: $id type'); + return; + } + expect( + component!.properties[key], + equals(value), + reason: '$reason: $id.$key', + ); + }); + } +} + +Matcher _matchesError(Map expectError) { + final category = expectError['category'] as String?; + final message = expectError['message'] as String?; + Matcher matcher = switch (category) { + 'DataError' => isA(), + 'ValidationError' => isA(), + 'CatalogError' => isA(), + 'IntegrityError' => isA(), + 'RecursionError' => isA(), + 'StateError' => isA(), + 'ParseError' => isA(), + 'CompileError' => isA(), + _ => isA(), + }; + if (message != null) { + matcher = allOf( + matcher, + predicate( + (Object? e) => RegExp(message).hasMatch(e.toString()), + 'message matching /$message/', + ), + ); + } + return matcher; +} + +/// A catalog with no components, built natively as the suite requires. +class _EmptyCatalog extends Catalog { + _EmptyCatalog(String id) : super(id: id, components: const []); +} diff --git a/renderers/web_core/tests/conformance/message-processor.conformance.test.ts b/renderers/web_core/tests/conformance/message-processor.conformance.test.ts new file mode 100644 index 0000000000..66a52f7626 --- /dev/null +++ b/renderers/web_core/tests/conformance/message-processor.conformance.test.ts @@ -0,0 +1,170 @@ +/** + * @license + * Copyright 2024 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as assert from 'node:assert'; +import {describe, it} from 'node:test'; +import {loadConformanceSuite} from './harness.js'; +import {MessageProcessor} from '../../src/v0_9/processing/message-processor.js'; +import {Catalog, ComponentApi} from '../../src/v0_9/catalog/types.js'; +import {SurfaceModel} from '../../src/v0_9/state/surface-model.js'; + +/** + * Runs the shared `conformance/core/message_processor.yaml` suite against + * `MessageProcessor`. + * + * The suite uses the case vocabulary of the `v1_0` branch: `messages`, + * `catalogPaths`, and `expect.surfaces`. Cases name a catalog rather than + * declaring one, because renderers construct catalogs from code, so this + * harness registers a native catalog under the id the messages use. + * + * This is additive. The hand-written tests in + * `src/v0_9/processing/message-processor.test.ts` still run, and cover + * behaviour that is specific to this implementation, notably Zod-based + * component schema validation and `REF:` handling in inline catalogs. + */ + +/** One component's flattened properties: `id`, `component`, and the rest. */ +type ComponentExpectation = Record & {id: string}; + +interface SurfaceExpectation { + exists?: boolean; + catalogId?: string; + sendDataModel?: boolean; + components?: ComponentExpectation[]; + dataModel?: unknown; +} + +interface ConformanceCase { + name: string; + catalogPaths?: string[]; + messages?: Array> | {messages: Array>}; + payload?: Array> | {messages: Array>}; + expect?: {surfaces?: Record}; + expectError?: {category?: string; message?: string} | string; +} + +/** A catalog with no components, built natively as the suite requires. */ +function emptyCatalog(id: string): Catalog { + return new Catalog(id, []); +} + +/** + * The messages a case processes, accepting both the bare list and the + * `{messages: [...]}` wrapper the protocol allows. + */ +function messagesOf(testCase: ConformanceCase): Array> { + const raw = testCase.messages ?? testCase.payload ?? []; + return Array.isArray(raw) ? raw : raw.messages; +} + +/** The catalog id the case's messages bind surfaces to. */ +function catalogIdOf(testCase: ConformanceCase): string { + for (const message of messagesOf(testCase)) { + const create = message['createSurface'] as {catalogId?: string} | undefined; + if (typeof create?.catalogId === 'string') return create.catalogId; + } + return 'test-catalog'; +} + +function errorPattern(expectError: ConformanceCase['expectError']): RegExp { + const message = typeof expectError === 'string' ? expectError : (expectError?.message ?? ''); + return new RegExp(message); +} + +/** + * Checks the surface's component graph. The expected list is exhaustive, so an + * empty one asserts the surface holds no components at all. + */ +function checkComponents( + surface: SurfaceModel, + expected: ComponentExpectation[], + reason: string, +): void { + assert.deepStrictEqual( + [...surface.componentsModel.entries].map(([id]) => id).sort(), + expected.map((entry) => entry.id).sort(), + `${reason}: component ids`, + ); + + for (const entry of expected) { + const component = surface.componentsModel.get(entry.id); + assert.ok(component, `${reason}: component ${entry.id}`); + + for (const [key, value] of Object.entries(entry)) { + if (key === 'id') continue; + if (key === 'component') { + assert.strictEqual(component.type, value, `${reason}: ${entry.id} type`); + continue; + } + assert.deepStrictEqual(component.properties[key], value, `${reason}: ${entry.id}.${key}`); + } + } +} + +function runCase(testCase: ConformanceCase): void { + const processor = new MessageProcessor([emptyCatalog(catalogIdOf(testCase))]); + const name = testCase.name; + const messages = messagesOf(testCase); + + if (testCase.expectError !== undefined) { + assert.throws( + () => processor.processMessages(messages as never), + errorPattern(testCase.expectError), + name, + ); + return; + } + + processor.processMessages(messages as never); + const expected = testCase.expect ?? {}; + + for (const [surfaceId, expectation] of Object.entries(expected.surfaces ?? {})) { + const surface = processor.model.getSurface(surfaceId); + + if (expectation.exists === false) { + assert.strictEqual(surface, undefined, `${name}: surface ${surfaceId} is closed`); + continue; + } + assert.ok(surface, `${name}: surface ${surfaceId} is open`); + + if (expectation.catalogId !== undefined) { + assert.strictEqual( + surface.catalog.id, + expectation.catalogId, + `${name}: ${surfaceId} catalogId`, + ); + } + if (expectation.sendDataModel !== undefined) { + assert.strictEqual( + surface.sendDataModel, + expectation.sendDataModel, + `${name}: ${surfaceId} sendDataModel`, + ); + } + if (expectation.dataModel !== undefined) { + assert.deepStrictEqual( + surface.dataModel.get('/'), + expectation.dataModel, + `${name}: ${surfaceId} data model`, + ); + } + if (expectation.components !== undefined) { + checkComponents(surface, expectation.components, `${name}: ${surfaceId}`); + } + } +} + +describe('conformance core/message_processor.yaml', () => { + const cases = loadConformanceSuite('core/message_processor.yaml'); + + it('suite is not empty', () => { + assert.ok(cases.length > 0); + }); + + for (const testCase of cases) { + it(testCase.name, () => runCase(testCase)); + } +}); From 18a692c0f114289b889d76b11e6bc1301e78ef19 Mon Sep 17 00:00:00 2001 From: Polina Cherkasova Date: Fri, 28 Aug 2026 11:23:36 -0700 Subject: [PATCH 17/22] - --- conformance/core/message_processor.yaml | 110 +++++++++ .../message_processor_conformance_test.dart | 213 ++++++++++++++++++ .../message-processor.conformance.test.ts | 170 ++++++++++++++ 3 files changed, 493 insertions(+) create mode 100644 conformance/core/message_processor.yaml create mode 100644 dart/a2ui_core/test/conformance/message_processor_conformance_test.dart create mode 100644 renderers/web_core/tests/conformance/message-processor.conformance.test.ts diff --git a/conformance/core/message_processor.yaml b/conformance/core/message_processor.yaml new file mode 100644 index 0000000000..031874ffb5 --- /dev/null +++ b/conformance/core/message_processor.yaml @@ -0,0 +1,110 @@ +# Copyright 2024 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. + +# Behaviour of the message processor, written to converge with the suite of the +# same name on the `v1_0` branch rather than to compete with it. +# +# The v1_0 suite is the primary one: 51 cases covering the surface lifecycle, +# component mutation, graph topology, strict schema validation, JSON Pointer +# resolution and the v0.8 / v0.9 / v1.0 protocol vectors. Every case that used +# to live here in a different shape is already covered there, so only the two +# that are not have been kept, written in the v1_0 vocabulary so the two files +# concatenate instead of conflicting when that branch lands: +# +# action `process_messages` +# catalogPaths catalog documents the case runs against, repo-root relative +# messages the messages to process, in order (or a `{messages: [...]}` +# wrapper) +# expect.surfaces per surface id, any of `exists`, `sendDataModel`, `theme`, +# `components` (a list, each entry the component's flattened +# properties) and `dataModel` (the whole model at `/`) +# expectError `category` plus a `message` regular expression, matched +# against a substring the implementations share +# +# Both cases below pin **state isolation between surfaces**, which the v1_0 +# suite opens two surfaces for only twice, and asserts in neither: mutations +# must land on the surface the message names and nowhere else. +# +# Renderers build catalogs from code (Zod in `web_core`, `Schema` in Dart) and +# neither builds a renderer catalog from a JSON Schema document, so each +# harness registers a native catalog under the `catalogId` the messages use. +# `catalogPaths` records which published document the case is written against. + +# --- Surface state isolation --- + +- name: test_update_components_target_named_surface_only + description: Tests that components are added to the surface the message names, not to every open surface. + action: process_messages + catalogPaths: + - "specification/v0_9/catalogs/basic/catalog.json" + messages: + - version: "v0.9" + createSurface: + surfaceId: "s1" + catalogId: "test-catalog" + - version: "v0.9" + createSurface: + surfaceId: "s2" + catalogId: "test-catalog" + - version: "v0.9" + updateComponents: + surfaceId: "s2" + components: + - id: "root" + component: "Text" + text: "Hello" + expect: + surfaces: + s1: + exists: true + components: [] + s2: + exists: true + components: + - id: "root" + component: "Text" + text: "Hello" + +- name: test_update_data_model_isolated_per_surface + description: Tests that each surface owns its data model, so the same path holds a different value per surface. + action: process_messages + catalogPaths: + - "specification/v0_9/catalogs/basic/catalog.json" + messages: + - version: "v0.9" + createSurface: + surfaceId: "s1" + catalogId: "test-catalog" + - version: "v0.9" + createSurface: + surfaceId: "s2" + catalogId: "test-catalog" + - version: "v0.9" + updateDataModel: + surfaceId: "s1" + path: "/value" + value: "one" + - version: "v0.9" + updateDataModel: + surfaceId: "s2" + path: "/value" + value: "two" + expect: + surfaces: + s1: + dataModel: + value: "one" + s2: + dataModel: + value: "two" diff --git a/dart/a2ui_core/test/conformance/message_processor_conformance_test.dart b/dart/a2ui_core/test/conformance/message_processor_conformance_test.dart new file mode 100644 index 0000000000..89e43bbf66 --- /dev/null +++ b/dart/a2ui_core/test/conformance/message_processor_conformance_test.dart @@ -0,0 +1,213 @@ +// Copyright 2024 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 'package:a2ui_core/a2ui_core.dart'; +import 'package:test/test.dart'; + +import 'conformance_harness.dart'; + +/// Runs the shared `conformance/core/message_processor.yaml` suite against +/// [MessageProcessor]. +/// +/// The suite uses the case vocabulary of the `v1_0` branch: `messages`, +/// `catalogPaths`, and `expect.surfaces`. Cases name a catalog rather than +/// declaring one, because renderers build catalogs from code, so this harness +/// registers a native catalog under the id the messages use. +void main() { + final List> cases = loadConformanceSuite( + 'core/message_processor.yaml', + ); + + group('conformance core/message_processor.yaml', () { + test('suite is not empty', () => expect(cases, isNotEmpty)); + + for (final testCase in cases) { + test(testCase['name']! as String, () => _runCase(testCase)); + } + }); +} + +void _runCase(Map testCase) { + final name = testCase['name']! as String; + final catalog = _EmptyCatalog(_catalogIdOf(testCase)); + final processor = MessageProcessor(catalogs: [catalog]); + final List> messages = _messagesOf(testCase); + + final Object? expectError = testCase['expectError']; + if (expectError != null) { + expect( + () => _process(processor, messages), + throwsA(_matchesError(expectError as Map)), + reason: name, + ); + return; + } + + _process(processor, messages); + + final Map expected = + (testCase['expect'] as Map?) ?? const {}; + _checkSurfaces(processor, expected, name); +} + +/// The messages a case processes, accepting both the bare list and the +/// `{messages: [...]}` wrapper the protocol allows. +List> _messagesOf(Map testCase) { + final Object? raw = testCase['messages'] ?? testCase['payload']; + final Object? list = raw is Map ? raw['messages'] : raw; + return (list! as List).cast>(); +} + +/// The catalog id the case's messages bind surfaces to. +String _catalogIdOf(Map testCase) { + for (final Map message in _messagesOf(testCase)) { + final Object? create = message['createSurface']; + if (create is Map && create['catalogId'] is String) { + return create['catalogId']! as String; + } + } + return 'test-catalog'; +} + +/// Converts each envelope and processes it. +/// +/// Conversion counts as processing here: the Dart processor takes typed +/// messages, so [A2uiMessage.fromJson] rejects a malformed envelope first. +void _process( + MessageProcessor processor, + List> messages, +) { + for (final envelope in messages) { + processor.processMessages([ + A2uiMessage.fromJson(Map.from(envelope)), + ]); + } +} + +void _checkSurfaces( + MessageProcessor processor, + Map expected, + String name, +) { + final surfaces = expected['surfaces'] as Map?; + if (surfaces == null) return; + + surfaces.forEach((surfaceId, raw) { + final SurfaceModel? surface = processor.groupModel.getSurface( + surfaceId, + ); + final expectations = raw! as Map; + + if (expectations['exists'] == false) { + expect(surface, isNull, reason: '$name: surface $surfaceId is closed'); + return; + } + expect(surface, isNotNull, reason: '$name: surface $surfaceId is open'); + + if (expectations.containsKey('catalogId')) { + expect( + surface!.catalog.id, + expectations['catalogId'], + reason: '$name: $surfaceId catalogId', + ); + } + if (expectations.containsKey('sendDataModel')) { + expect( + surface!.sendDataModel, + expectations['sendDataModel'], + reason: '$name: $surfaceId sendDataModel', + ); + } + if (expectations.containsKey('dataModel')) { + expect( + surface!.dataModel.get('/'), + equals(expectations['dataModel']), + reason: '$name: $surfaceId data model', + ); + } + if (expectations.containsKey('components')) { + _checkComponents( + surface!, + (expectations['components']! as List) + .cast>(), + '$name: $surfaceId', + ); + } + }); +} + +/// Checks the surface's component graph against the case's expectations. +/// +/// Each entry is the component's flattened properties: `id`, `component`, and +/// whatever else the message set on it. The list is exhaustive, so an empty +/// one asserts the surface holds no components at all. +void _checkComponents( + SurfaceModel surface, + List> expected, + String reason, +) { + expect(surface.componentsModel.all.map((c) => c.id).toSet(), { + for (final Map entry in expected) entry['id'], + }, reason: '$reason: component ids'); + + for (final entry in expected) { + final id = entry['id']! as String; + final ComponentModel? component = surface.componentsModel.get(id); + expect(component, isNotNull, reason: '$reason: component $id'); + + entry.forEach((key, value) { + if (key == 'id') return; + if (key == 'component') { + expect(component!.type, value, reason: '$reason: $id type'); + return; + } + expect( + component!.properties[key], + equals(value), + reason: '$reason: $id.$key', + ); + }); + } +} + +Matcher _matchesError(Map expectError) { + final category = expectError['category'] as String?; + final message = expectError['message'] as String?; + Matcher matcher = switch (category) { + 'DataError' => isA(), + 'ValidationError' => isA(), + 'CatalogError' => isA(), + 'IntegrityError' => isA(), + 'RecursionError' => isA(), + 'StateError' => isA(), + 'ParseError' => isA(), + 'CompileError' => isA(), + _ => isA(), + }; + if (message != null) { + matcher = allOf( + matcher, + predicate( + (Object? e) => RegExp(message).hasMatch(e.toString()), + 'message matching /$message/', + ), + ); + } + return matcher; +} + +/// A catalog with no components, built natively as the suite requires. +class _EmptyCatalog extends Catalog { + _EmptyCatalog(String id) : super(id: id, components: const []); +} diff --git a/renderers/web_core/tests/conformance/message-processor.conformance.test.ts b/renderers/web_core/tests/conformance/message-processor.conformance.test.ts new file mode 100644 index 0000000000..66a52f7626 --- /dev/null +++ b/renderers/web_core/tests/conformance/message-processor.conformance.test.ts @@ -0,0 +1,170 @@ +/** + * @license + * Copyright 2024 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as assert from 'node:assert'; +import {describe, it} from 'node:test'; +import {loadConformanceSuite} from './harness.js'; +import {MessageProcessor} from '../../src/v0_9/processing/message-processor.js'; +import {Catalog, ComponentApi} from '../../src/v0_9/catalog/types.js'; +import {SurfaceModel} from '../../src/v0_9/state/surface-model.js'; + +/** + * Runs the shared `conformance/core/message_processor.yaml` suite against + * `MessageProcessor`. + * + * The suite uses the case vocabulary of the `v1_0` branch: `messages`, + * `catalogPaths`, and `expect.surfaces`. Cases name a catalog rather than + * declaring one, because renderers construct catalogs from code, so this + * harness registers a native catalog under the id the messages use. + * + * This is additive. The hand-written tests in + * `src/v0_9/processing/message-processor.test.ts` still run, and cover + * behaviour that is specific to this implementation, notably Zod-based + * component schema validation and `REF:` handling in inline catalogs. + */ + +/** One component's flattened properties: `id`, `component`, and the rest. */ +type ComponentExpectation = Record & {id: string}; + +interface SurfaceExpectation { + exists?: boolean; + catalogId?: string; + sendDataModel?: boolean; + components?: ComponentExpectation[]; + dataModel?: unknown; +} + +interface ConformanceCase { + name: string; + catalogPaths?: string[]; + messages?: Array> | {messages: Array>}; + payload?: Array> | {messages: Array>}; + expect?: {surfaces?: Record}; + expectError?: {category?: string; message?: string} | string; +} + +/** A catalog with no components, built natively as the suite requires. */ +function emptyCatalog(id: string): Catalog { + return new Catalog(id, []); +} + +/** + * The messages a case processes, accepting both the bare list and the + * `{messages: [...]}` wrapper the protocol allows. + */ +function messagesOf(testCase: ConformanceCase): Array> { + const raw = testCase.messages ?? testCase.payload ?? []; + return Array.isArray(raw) ? raw : raw.messages; +} + +/** The catalog id the case's messages bind surfaces to. */ +function catalogIdOf(testCase: ConformanceCase): string { + for (const message of messagesOf(testCase)) { + const create = message['createSurface'] as {catalogId?: string} | undefined; + if (typeof create?.catalogId === 'string') return create.catalogId; + } + return 'test-catalog'; +} + +function errorPattern(expectError: ConformanceCase['expectError']): RegExp { + const message = typeof expectError === 'string' ? expectError : (expectError?.message ?? ''); + return new RegExp(message); +} + +/** + * Checks the surface's component graph. The expected list is exhaustive, so an + * empty one asserts the surface holds no components at all. + */ +function checkComponents( + surface: SurfaceModel, + expected: ComponentExpectation[], + reason: string, +): void { + assert.deepStrictEqual( + [...surface.componentsModel.entries].map(([id]) => id).sort(), + expected.map((entry) => entry.id).sort(), + `${reason}: component ids`, + ); + + for (const entry of expected) { + const component = surface.componentsModel.get(entry.id); + assert.ok(component, `${reason}: component ${entry.id}`); + + for (const [key, value] of Object.entries(entry)) { + if (key === 'id') continue; + if (key === 'component') { + assert.strictEqual(component.type, value, `${reason}: ${entry.id} type`); + continue; + } + assert.deepStrictEqual(component.properties[key], value, `${reason}: ${entry.id}.${key}`); + } + } +} + +function runCase(testCase: ConformanceCase): void { + const processor = new MessageProcessor([emptyCatalog(catalogIdOf(testCase))]); + const name = testCase.name; + const messages = messagesOf(testCase); + + if (testCase.expectError !== undefined) { + assert.throws( + () => processor.processMessages(messages as never), + errorPattern(testCase.expectError), + name, + ); + return; + } + + processor.processMessages(messages as never); + const expected = testCase.expect ?? {}; + + for (const [surfaceId, expectation] of Object.entries(expected.surfaces ?? {})) { + const surface = processor.model.getSurface(surfaceId); + + if (expectation.exists === false) { + assert.strictEqual(surface, undefined, `${name}: surface ${surfaceId} is closed`); + continue; + } + assert.ok(surface, `${name}: surface ${surfaceId} is open`); + + if (expectation.catalogId !== undefined) { + assert.strictEqual( + surface.catalog.id, + expectation.catalogId, + `${name}: ${surfaceId} catalogId`, + ); + } + if (expectation.sendDataModel !== undefined) { + assert.strictEqual( + surface.sendDataModel, + expectation.sendDataModel, + `${name}: ${surfaceId} sendDataModel`, + ); + } + if (expectation.dataModel !== undefined) { + assert.deepStrictEqual( + surface.dataModel.get('/'), + expectation.dataModel, + `${name}: ${surfaceId} data model`, + ); + } + if (expectation.components !== undefined) { + checkComponents(surface, expectation.components, `${name}: ${surfaceId}`); + } + } +} + +describe('conformance core/message_processor.yaml', () => { + const cases = loadConformanceSuite('core/message_processor.yaml'); + + it('suite is not empty', () => { + assert.ok(cases.length > 0); + }); + + for (const testCase of cases) { + it(testCase.name, () => runCase(testCase)); + } +}); From 21149cddd8e771e8138d23cd08a73015b216a35e Mon Sep 17 00:00:00 2001 From: Polina Cherkasova Date: Fri, 28 Aug 2026 11:26:11 -0700 Subject: [PATCH 18/22] Extend a2ui_core for agent SDKs, limited to protocol v0.9 Prerequisite for the Dart a2ui_agent API (#2408). Everything here is a change to a2ui_core, or to a consumer of it, split out so the agent PR reviews as agent work only. Catalog and capabilities - `Catalog` (breaking, 0.1.1 -> 0.2.0). Agents parameterise with `CatalogFunction` (signature only), renderers with `FunctionImplementation`. `SchemaCatalog` aliases the agent shape. - `Catalog.fromJson` / `catalogSchema` / `copyWith`, plus schema-only `CatalogComponent` and `CatalogFunction`, so catalog documents round trip through core and a narrowed catalog renders a narrowed document with `$defs/anyComponent` and `$defs/anyFunction` narrowed to match. - `A2uiRendererCapabilities`, mirroring `client_capabilities.json` and web_core's `A2uiClientCapabilities`. - `A2uiProtocolVersion`, and the `A2uiParseError` / `A2uiCompileError` / `A2uiCatalogError` / `A2uiIntegrityError` / `A2uiRecursionError` categories. - `A2uiValidator`, with the v0.9 version gate implemented and the structural and catalog-schema checks declared but stubbed. One bug fixed - `DataModel.set` silently dropped a write whose parent path resolved to a primitive (`/user/name/first` where `/user/name` is a string). It now throws `A2uiDataError`, matching web_core. The shared dataset surfaced this. Notification on an unchanged value is fixed the same way: the signal is handed a copy of a container rather than bypassing the equality check. Shared conformance data - `core/data_model.yaml` (new, `data_model` action): 37 cases migrated from `renderers/web_core/src/v0_9/state/data-model.test.ts`. - `core/message_processor.yaml` (new, `process_messages` action): the two surface-isolation cases the v1_0 branch's suite of the same name does not cover, written in that branch's case vocabulary so the two files concatenate rather than conflict when it lands. - `conformance_schema.json` gains the two actions and the `DataError` category; the file's existing formatting is preserved. web_core consumes the shared data - `tests/conformance/harness.ts` locates `conformance/` by walking up. - `data-model.conformance.test.ts` and `message-processor.conformance.test.ts` run the two suites. Both are additive: the hand-written `data-model.test.ts` and `message-processor.test.ts` are untouched. Also - `blueprints/modules/a2ui_core.blueprint.md`: package boundary and non-goals, catalog immutability and per-catalog protocol version, the version-keyed capabilities objects and their normative schemas, the complete exception hierarchy plus the rule that parsing wire JSON raises from it, and a conformance section (there was none). - `flutter_packages_test.yml` discovers packages under `dart/` as well as `samples/`. The `dart/` packages were not built, analyzed or tested by any workflow before this. - `dart/a2ui_agent` gets the two changes that exposure requires: its `a2ui_core` constraint follows the major bump, and the unnecessary `library;` directive its stub carried is removed. --- .github/workflows/flutter_packages_test.yml | 2 +- blueprints/modules/a2ui_core.blueprint.md | 78 ++ conformance/README.md | 2 + conformance/conformance_schema.json | 130 ++- conformance/core/data_model.yaml | 654 +++++++++++++ dart/a2ui_agent/lib/a2ui_agent.dart | 2 - dart/a2ui_agent/pubspec.yaml | 2 +- dart/a2ui_core/CHANGELOG.md | 51 ++ dart/a2ui_core/lib/a2ui_core.dart | 18 + dart/a2ui_core/lib/src/core/catalog.dart | 335 ++++++- dart/a2ui_core/lib/src/core/contexts.dart | 3 +- dart/a2ui_core/lib/src/core/data_model.dart | 20 +- dart/a2ui_core/lib/src/core/messages.dart | 174 +++- .../lib/src/core/minimal_catalog.dart | 2 +- .../lib/src/core/renderer_capabilities.dart | 147 +++ .../a2ui_core/lib/src/core/surface_model.dart | 2 +- dart/a2ui_core/lib/src/primitives/errors.dart | 52 ++ .../lib/src/primitives/protocol_version.dart | 63 ++ .../lib/src/primitives/reactivity.dart | 1 + .../lib/src/processing/processor.dart | 8 +- .../lib/src/validation/component_graph.dart | 230 +++++ .../lib/src/validation/component_refs.dart | 317 +++++++ .../lib/src/validation/schema_resolution.dart | 174 ++++ .../lib/src/validation/validator.dart | 340 +++++++ dart/a2ui_core/pubspec.yaml | 4 +- dart/a2ui_core/test/catalog_json_test.dart | 258 ++++++ .../test/conformance/conformance_harness.dart | 85 ++ .../data_model_conformance_test.dart | 205 +++++ .../validator_conformance_test.dart | 184 ++++ dart/a2ui_core/test/messages_test.dart | 60 ++ .../a2ui_core/test/protocol_version_test.dart | 83 ++ .../test/renderer_capabilities_test.dart | 154 ++++ .../test/validator_basic_catalog_test.dart | 262 ++++++ dart/a2ui_core/test/validator_test.dart | 857 ++++++++++++++++++ renderers/web_core/package.json | 6 +- .../data-model.conformance.test.ts | 155 ++++ .../web_core/tests/conformance/harness.ts | 63 ++ renderers/web_core/tsconfig.json | 2 +- yarn.lock | 1 + 39 files changed, 5110 insertions(+), 76 deletions(-) create mode 100644 conformance/core/data_model.yaml create mode 100644 dart/a2ui_core/lib/src/core/renderer_capabilities.dart create mode 100644 dart/a2ui_core/lib/src/primitives/protocol_version.dart create mode 100644 dart/a2ui_core/lib/src/validation/component_graph.dart create mode 100644 dart/a2ui_core/lib/src/validation/component_refs.dart create mode 100644 dart/a2ui_core/lib/src/validation/schema_resolution.dart create mode 100644 dart/a2ui_core/lib/src/validation/validator.dart create mode 100644 dart/a2ui_core/test/catalog_json_test.dart create mode 100644 dart/a2ui_core/test/conformance/conformance_harness.dart create mode 100644 dart/a2ui_core/test/conformance/data_model_conformance_test.dart create mode 100644 dart/a2ui_core/test/conformance/validator_conformance_test.dart create mode 100644 dart/a2ui_core/test/protocol_version_test.dart create mode 100644 dart/a2ui_core/test/renderer_capabilities_test.dart create mode 100644 dart/a2ui_core/test/validator_basic_catalog_test.dart create mode 100644 dart/a2ui_core/test/validator_test.dart create mode 100644 renderers/web_core/tests/conformance/data-model.conformance.test.ts create mode 100644 renderers/web_core/tests/conformance/harness.ts diff --git a/.github/workflows/flutter_packages_test.yml b/.github/workflows/flutter_packages_test.yml index 379fb01657..4ecafe53e9 100644 --- a/.github/workflows/flutter_packages_test.yml +++ b/.github/workflows/flutter_packages_test.yml @@ -46,7 +46,7 @@ jobs: - name: Generate testing matrix id: generate_matrix run: | - DIRS_TO_TEST=$(find samples -name pubspec.yaml -not -path "*/.dart_tool/*" -not -path "*/e2e_test/*" -exec dirname {} \;) + DIRS_TO_TEST=$(find samples dart -name pubspec.yaml -not -path "*/.dart_tool/*" -not -path "*/e2e_test/*" -exec dirname {} \;) JSON_MATRIX="[" FIRST=true diff --git a/blueprints/modules/a2ui_core.blueprint.md b/blueprints/modules/a2ui_core.blueprint.md index fd3833e1eb..b5669a3d34 100644 --- a/blueprints/modules/a2ui_core.blueprint.md +++ b/blueprints/modules/a2ui_core.blueprint.md @@ -29,6 +29,17 @@ Its core responsibilities include: 7. **Resolution:** Resolves bound context paths and binds state variables to components for local evaluation. 8. **Multi-Version Protocol Branching:** Supports multiple versions of the protocol. +### Package Boundary & Non-Goals + +`a2ui_agent` depends on `a2ui_core`; the dependency never runs the other way. Core models the protocol and the renderer's state machine, and must stay useful to a renderer that will never load an agent SDK. + +Core does **not** own: + +- **Catalog narrowing.** A `Catalog` is an immutable value object. Pruning components or functions to an allowlist is prompt engineering — it belongs to the catalog transformers in the [a2ui_agent blueprint](./a2ui_agent.blueprint.md). +- **Prompt generation, response parsing, and capability negotiation.** All three are agent concerns, and none of them belongs here even when the type they operate on is a core type. + +When implementing `a2ui_agent` requires a change here — a shared type, a widened generic, a missing error class — make the smallest change that unblocks it, and land it as its own reviewable unit. Core is consumed by every renderer, so a change made for one agent SDK is a change made for all of them. + --- ### A. High-Level Layer Architecture @@ -193,6 +204,12 @@ export interface Catalog/json/client_capabilities.json` | `specification/v1_0/json/renderer_capabilities.json` | +| Agent → renderer | `a2uiServerCapabilities` | `specification//json/server_capabilities.json` | `specification/v1_0/json/agent_capabilities.json` | + +Resolve the filename for the version you are implementing rather than assuming one — v0.8 uses a third spelling (`a2ui_client_capabilities_schema.json`) and publishes no server-side counterpart. + +The top level of each is **keyed by protocol version** — `{"v0.9": { … }}`, `{"v1.0": { … }}` — with `supportedCatalogIds`, `inlineCatalogs` and `acceptsInlineCatalogs` living _inside_ the version entry, and the version key is required. A flat object carrying a sibling list of version strings is a different, invalid shape. + +When parsing one: reject an object that declares no entry for any version this SDK implements, and keep the unrecognised version keys rather than failing on them, so a renderer that also speaks a newer protocol can still be served. + #### Generating Renderer Capabilities and Schema Types To dynamically generate the `A2uiRendererCapabilities` payload (specifically `inlineCatalogs`), the processor must convert internal component schemas into valid JSON Schemas. @@ -751,4 +783,50 @@ export class A2uiRecursionError extends A2uiError { this.name = 'A2uiRecursionError'; } } + +/** Raised when a data model read or write cannot be satisfied at the given path. */ +export class A2uiDataError extends A2uiError { + /* name = 'A2uiDataError' */ +} + +/** Raised when the surface or component state machine is asked for an illegal transition. */ +export class A2uiStateError extends A2uiError { + /* name = 'A2uiStateError' */ +} + +/** Raised when raw model output cannot be extracted or decoded into a payload. */ +export class A2uiParseError extends A2uiError { + /* name = 'A2uiParseError' */ +} + +/** Raised when a source syntax (e.g. the EXPRESS DSL) cannot be compiled to A2UI messages. */ +export class A2uiCompileError extends A2uiError { + /* name = 'A2uiCompileError' */ +} + +/** Raised when a bound expression cannot be evaluated. */ +export class A2uiExpressionError extends A2uiError { + /* name = 'A2uiExpressionError' */ +} ``` + +**Parsing wire JSON raises from this hierarchy, never from the language.** Every entry point that accepts a payload, a capabilities object or a catalog document from outside the process is handling untrusted input: a member may be absent, null, or the wrong type. Check the shape before casting and raise `A2uiValidationError` with the offending value attached. A raw cast failure — `TypeError`, `ClassCastException`, a null dereference — escapes the hierarchy a caller can catch, and turns a malformed message into a crash. + +**These categories are shared with the conformance suite.** `expect_error.category` in `conformance/conformance_schema.json` names these classes without the `A2ui` prefix, so the two must agree. Adding an error class means adding the matching category alongside the first suite that asserts it — not in advance, and not silently under a category that already exists. + +--- + +## 4. Conformance Test Plan + +Behavioural parity across implementations is pinned by a language-agnostic conformance suite. For setup, harness requirements and schema definitions, see [Conformance README](../../conformance/README.md). + +### Where a case belongs + +Suites under `conformance/core/` cover this module: the reactive data model, the message processor's state machine, catalog documents, and the validator. Agent-side behaviour — prompt generation, response parsing, catalog narrowing, capability negotiation — belongs under `conformance/agent/`, **even when the case is about a catalog**. A case filed under `core/` obliges every renderer to implement it, so the directory is a statement about ownership, not about subject matter. Section 6 of the [a2ui_agent blueprint](./a2ui_agent.blueprint.md#6-conformance-test-plan) carries the agent-side map. + +### Rules for adding cases + +1. **Look for an existing suite before creating one — on every active spec branch, not just the one you are on.** The repository maintains parallel branches per protocol version (`main`, `v1_0`, …). A suite that already exists elsewhere has an established case shape; a second file with the same name and a different shape is a merge conflict rather than extra coverage. Extend the existing one. +2. **Reuse the established case keys** defined by `conformance/conformance_schema.json`, and extend the schema in the same change as the suite that needs the new shape. +3. **`expect_error.category` names a class from the exception hierarchy above**, without the `A2ui` prefix. +4. **Migrating an implementation's own tests into the shared suite will surface real disagreements** between implementations. Each one is a decision, not a formatting problem: fix the implementation that is wrong, or record why the case is excluded. Language-level differences (sparse versus dense arrays, `null` versus absent, prototype pollution) stay out of the shared suite and remain in the implementation's own tests. diff --git a/conformance/README.md b/conformance/README.md index e2d40d5a2a..2ad8a57d04 100644 --- a/conformance/README.md +++ b/conformance/README.md @@ -11,6 +11,8 @@ Test suites are organized by functional domain: - `core/catalog.yaml`: Contains test cases for catalog operations (prune, render, load). - `core/accessibility.yaml`: Contains test cases for accessibility attributes and checks. - `core/validator.yaml`: Contains test cases for schema and structural validators, verifying structural integrity, cycle detection, and reachability. +- `core/data_model.yaml`: Contains test cases for the reactive data model, verifying JSON Pointer reads and writes, container creation, deletion, and observer notification. +- `core/message_processor.yaml`: Contains test cases for the message processor's state machine. Written in the case vocabulary of the `v1_0` branch, whose suite of the same name is the primary one, so the two converge rather than conflict. ### Agent (`agent/`) diff --git a/conformance/conformance_schema.json b/conformance/conformance_schema.json index 1663fb6e4f..acda0f33c3 100644 --- a/conformance/conformance_schema.json +++ b/conformance/conformance_schema.json @@ -91,7 +91,9 @@ "try_activate", "select_newest", "verify_cuttable_keys", - "accessibility_check" + "accessibility_check", + "data_model", + "process_messages" ] } }, @@ -121,7 +123,9 @@ {"$ref": "#/$defs/TryActivateTest"}, {"$ref": "#/$defs/SelectNewestTest"}, {"$ref": "#/$defs/VerifyCuttableKeysTest"}, - {"$ref": "#/$defs/AccessibilityCheckTest"} + {"$ref": "#/$defs/AccessibilityCheckTest"}, + {"$ref": "#/$defs/DataModelTest"}, + {"$ref": "#/$defs/ProcessMessagesTest"} ] } ] @@ -483,6 +487,125 @@ }, "required": ["action", "surface", "assertions"] }, + "DataModelTest": { + "type": "object", + "properties": { + "action": {"const": "data_model"}, + "initial": { + "description": "Initial contents of the data model. Defaults to an empty object." + }, + "watch": { + "type": "array", + "description": "Paths observed for the duration of the case. A path may repeat to attach more than one observer.", + "items": {"type": "string"} + }, + "steps": { + "type": "array", + "description": "Operations applied to the model, in order.", + "items": { + "type": "object", + "properties": { + "op": { + "type": "string", + "description": "The operation to apply.", + "enum": ["get", "set", "delete", "dispose"] + }, + "path": { + "type": "string", + "description": "JSON Pointer the operation targets. Required for every op except dispose." + }, + "value": {"description": "Value to write. Only meaningful for set."}, + "expect": { + "description": "Value the path is expected to hold. Only meaningful for get." + }, + "expect_absent": { + "type": "boolean", + "description": "Whether the path is expected to hold no value. Only meaningful for get." + }, + "expect_type": { + "type": "string", + "enum": ["list", "object"], + "description": "Structural type the path is expected to hold. Only meaningful for get." + }, + "expect_values": { + "type": "object", + "description": "Value each watched path holds after this step, keyed by path." + }, + "expect_notified": { + "type": "array", + "items": {"type": "string"}, + "description": "Watched paths whose observers fired for this step, one entry per observer that fired." + }, + "expect_error": {"$ref": "#/$defs/ExpectError"} + }, + "required": ["op"] + } + } + }, + "required": ["steps"] + }, + "ProcessMessagesTest": { + "type": "object", + "description": "Behaviour of the message processor. Uses the case vocabulary of the v1_0 branch so the two suites of this name converge rather than conflict: camelCase keys, `messages` rather than `payload`, and an exhaustive `components` list per surface.", + "properties": { + "action": {"const": "process_messages"}, + "catalogPaths": { + "type": "array", + "items": {"type": "string"}, + "description": "Catalog documents the case is written against, relative to the repository root. Harnesses register a native catalog under the id the messages use, because renderers build catalogs from code rather than from a JSON Schema document." + }, + "messages": { + "description": "A2UI messages to process, in order: either the bare list or a {messages: [...]} wrapper.", + "oneOf": [ + {"type": "array", "items": {"type": "object"}}, + { + "type": "object", + "properties": {"messages": {"type": "array", "items": {"type": "object"}}}, + "required": ["messages"] + } + ] + }, + "expect": { + "type": "object", + "description": "Expected state after the messages have been processed.", + "properties": { + "surfaces": { + "type": "object", + "description": "Expectations per surface, keyed by surface id.", + "additionalProperties": { + "type": "object", + "properties": { + "exists": { + "type": "boolean", + "description": "Whether the surface is open. False asserts it is not." + }, + "catalogId": {"type": "string"}, + "sendDataModel": {"type": "boolean"}, + "theme": {"type": "object"}, + "components": { + "type": "array", + "description": "Every component the surface holds, each entry the component's flattened properties. The list is exhaustive, so an empty one asserts the surface holds none.", + "items": { + "type": "object", + "properties": { + "id": {"type": "string"}, + "component": {"type": "string"} + }, + "required": ["id"] + } + }, + "dataModel": { + "description": "Expected contents of the surface data model at the root." + } + } + } + } + } + }, + "expectError": {"$ref": "#/$defs/ExpectError"} + }, + "required": ["messages"] + }, "ExpectError": { "oneOf": [ { @@ -500,7 +623,8 @@ "CatalogError", "IntegrityError", "RecursionError", - "CompileError" + "CompileError", + "DataError" ] }, "message": { diff --git a/conformance/core/data_model.yaml b/conformance/core/data_model.yaml new file mode 100644 index 0000000000..7ff5a7937b --- /dev/null +++ b/conformance/core/data_model.yaml @@ -0,0 +1,654 @@ +# Copyright 2024 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. + +# Behaviour of the reactive data model: JSON Pointer resolution, structural +# auto-vivification, and observer notification routing. +# +# Migrated from `renderers/web_core/src/v0_9/state/data-model.test.ts` so that +# every implementation of the data model is measured against one dataset. +# +# Each case builds a model from `initial`, attaches an observer to every path in +# `watch`, then runs `steps` in order. A step is one of: +# +# op: get read `path`; assert `expect`, `expect_absent` or `expect_type` +# (`list` or `object`) +# op: set write `value` at `path` +# op: delete remove the value at `path` from its parent container +# op: dispose detach every observer +# +# A `set`, `delete` or `dispose` step may assert `expect_notified` (the watched +# paths whose observers fired for that step, with one entry per observer) and +# `expect_values` (the value each watched path holds afterwards). +# +# Deliberately left out of this suite, because the behaviour does not currently +# hold across implementations. Each is covered by a package local test instead. +# +# * JavaScript prototype pollution guards (`__proto__`, `constructor`, +# `prototype`) and `Object.prototype` property leakage, which exist only +# because JavaScript objects have a prototype chain. +# * `null` and `undefined` path arguments, which languages with non-nullable +# string types cannot express. +# * The distinction between storing `undefined` and removing a key, which +# languages without `undefined` cannot express. Removal is expressed here as +# an explicit `delete` step. +# * Rejection of leading zero list indices, which `web_core` enforces and the +# Dart implementation does not. +# * A cap on auto-vivified list indices. Dart lists are dense, so writing +# `/items/999999999` must be rejected to avoid allocating the whole list; +# JavaScript arrays are sparse, so the same write is cheap and is allowed. + +- name: test_data_model_initializes_empty + description: >- + A model created without data starts as an empty object. + catalog: + version: "0.9" + action: data_model + steps: + - op: "get" + path: "/" + expect: {} + +- name: test_data_model_retrieves_root + description: >- + The root path returns the whole data model. + catalog: + version: "0.9" + action: data_model + initial: {"user": {"name": "Alice", "settings": {"theme": "dark"}}, "items": ["a", "b", "c"]} + steps: + - op: "get" + path: "/" + expect: {"user": {"name": "Alice", "settings": {"theme": "dark"}}, "items": ["a", "b", "c"]} + +- name: test_data_model_retrieves_nested_path + description: >- + Nested object paths resolve segment by segment. + catalog: + version: "0.9" + action: data_model + initial: {"user": {"name": "Alice", "settings": {"theme": "dark"}}, "items": ["a", "b", "c"]} + steps: + - op: "get" + path: "/user/name" + expect: "Alice" + - op: "get" + path: "/user/settings/theme" + expect: "dark" + +- name: test_data_model_retrieves_list_items + description: >- + List elements resolve by numeric segment. + catalog: + version: "0.9" + action: data_model + initial: {"user": {"name": "Alice", "settings": {"theme": "dark"}}, "items": ["a", "b", "c"]} + steps: + - op: "get" + path: "/items/0" + expect: "a" + - op: "get" + path: "/items/1" + expect: "b" + +- name: test_data_model_absent_for_missing_paths + description: >- + Paths that do not exist resolve to no value. + catalog: + version: "0.9" + action: data_model + initial: {"user": {"name": "Alice", "settings": {"theme": "dark"}}, "items": ["a", "b", "c"]} + steps: + - op: "get" + path: "/user/age" + expect_absent: true + - op: "get" + path: "/unknown/path" + expect_absent: true + +- name: test_data_model_absent_through_empty_segment + description: >- + Traversing through a segment that holds no value yields no value. + catalog: + version: "0.9" + action: data_model + initial: {"user": {"name": "Alice", "settings": {"theme": "dark"}}, "items": ["a", "b", "c"]} + steps: + - op: "set" + path: "/nullable/inner" + value: 1 + - op: "delete" + path: "/nullable/inner" + - op: "get" + path: "/nullable/deep/path" + expect_absent: true + +- name: test_data_model_sets_existing_path + description: >- + Setting an existing path replaces its value. + catalog: + version: "0.9" + action: data_model + initial: {"user": {"name": "Alice", "settings": {"theme": "dark"}}, "items": ["a", "b", "c"]} + steps: + - op: "set" + path: "/user/name" + value: "Bob" + - op: "get" + path: "/user/name" + expect: "Bob" + +- name: test_data_model_sets_new_path + description: >- + Setting a new key on an existing object adds it. + catalog: + version: "0.9" + action: data_model + initial: {"user": {"name": "Alice", "settings": {"theme": "dark"}}, "items": ["a", "b", "c"]} + steps: + - op: "set" + path: "/user/age" + value: 30 + - op: "get" + path: "/user/age" + expect: 30 + +- name: test_data_model_creates_intermediate_objects + description: >- + Missing intermediate objects are created on write. + catalog: + version: "0.9" + action: data_model + initial: {"user": {"name": "Alice", "settings": {"theme": "dark"}}, "items": ["a", "b", "c"]} + steps: + - op: "set" + path: "/a/b/c" + value: "foo" + - op: "get" + path: "/a/b/c" + expect: "foo" + - op: "get" + path: "/a/b" + expect_type: "object" + +- name: test_data_model_delete_removes_key + description: >- + Deleting a path removes the key from its parent object. + catalog: + version: "0.9" + action: data_model + initial: {"user": {"name": "Alice", "settings": {"theme": "dark"}}, "items": ["a", "b", "c"]} + steps: + - op: "delete" + path: "/user/name" + - op: "get" + path: "/user/name" + expect_absent: true + - op: "get" + path: "/user" + expect: {"settings": {"theme": "dark"}} + +- name: test_data_model_list_set_and_get + description: >- + Writing a numeric segment into a missing path creates a list. + catalog: + version: "0.9" + action: data_model + initial: {"user": {"name": "Alice", "settings": {"theme": "dark"}}, "items": ["a", "b", "c"]} + steps: + - op: "set" + path: "/list/0" + value: "hello" + - op: "get" + path: "/list/0" + expect: "hello" + - op: "get" + path: "/list" + expect_type: "list" + +- name: test_data_model_list_append + description: >- + Writing successive indices appends to a list. + catalog: + version: "0.9" + action: data_model + initial: {"user": {"name": "Alice", "settings": {"theme": "dark"}}, "items": ["a", "b", "c"]} + steps: + - op: "set" + path: "/list/0" + value: "hello" + - op: "set" + path: "/list/1" + value: "world" + - op: "get" + path: "/list" + expect: ["hello", "world"] + +- name: test_data_model_list_update_index + description: >- + Writing an existing index replaces that element. + catalog: + version: "0.9" + action: data_model + initial: {"user": {"name": "Alice", "settings": {"theme": "dark"}}, "items": ["a", "b", "c"]} + steps: + - op: "set" + path: "/items/1" + value: "updated" + - op: "get" + path: "/items" + expect: ["a", "updated", "c"] + +- name: test_data_model_creates_nested_structures + description: >- + Auto-vivification picks a list or an object based on the next segment. + catalog: + version: "0.9" + action: data_model + initial: {"user": {"name": "Alice", "settings": {"theme": "dark"}}, "items": ["a", "b", "c"]} + steps: + - op: "set" + path: "/a/b/0/c" + value: 123 + - op: "get" + path: "/a/b/0/c" + expect: 123 + - op: "get" + path: "/a/b" + expect_type: "list" + - op: "get" + path: "/a/b/0" + expect_type: "object" + - op: "set" + path: "/x/y/z" + value: "hello" + - op: "get" + path: "/x/y/z" + expect: "hello" + - op: "set" + path: "/nestedList/0/0" + value: "inner" + - op: "get" + path: "/nestedList" + expect_type: "list" + - op: "get" + path: "/nestedList/0" + expect_type: "list" + +- name: test_data_model_get_out_of_range_index + description: >- + Out of range and non numeric list indices resolve to no value on read. + catalog: + version: "0.9" + action: data_model + initial: {"user": {"name": "Alice", "settings": {"theme": "dark"}}, "items": ["a", "b", "c"]} + steps: + - op: "get" + path: "/items/99" + expect_absent: true + - op: "get" + path: "/items/-1" + expect_absent: true + - op: "get" + path: "/items/invalid" + expect_absent: true + +- name: test_data_model_normalizes_trailing_slash + description: >- + A trailing slash is not a distinct path. + catalog: + version: "0.9" + action: data_model + initial: {"user": {"name": "Alice", "settings": {"theme": "dark"}}, "items": ["a", "b", "c"]} + watch: ["/foo"] + steps: + - op: "set" + path: "/foo/" + value: "bar" + expect_notified: ["/foo"] + - op: "get" + path: "/foo/" + expect: "bar" + - op: "get" + path: "/foo" + expect: "bar" + +- name: test_data_model_replaces_root + description: >- + Setting the root path replaces the whole model. + catalog: + version: "0.9" + action: data_model + initial: {"user": {"name": "Alice", "settings": {"theme": "dark"}}, "items": ["a", "b", "c"]} + watch: ["/", "/unrelated"] + steps: + - op: "set" + path: "/" + value: {"newRoot": "foo"} + expect_notified: ["/"] + expect_values: {"/": {"newRoot": "foo"}} + - op: "get" + path: "" + expect: {"newRoot": "foo"} + +- name: test_data_model_escaped_slash + description: >- + A ~1 escape addresses a key containing a slash (RFC 6901). + catalog: + version: "0.9" + action: data_model + initial: {"user": {"name": "Alice", "settings": {"theme": "dark"}}, "items": ["a", "b", "c"]} + steps: + - op: "set" + path: "/user/detailed~1info" + value: "some info" + - op: "get" + path: "/user/detailed~1info" + expect: "some info" + - op: "get" + path: "/user" + expect: {"name": "Alice", "settings": {"theme": "dark"}, "detailed/info": "some info"} + +- name: test_data_model_escaped_tilde + description: >- + A ~0 escape addresses a key containing a tilde (RFC 6901). + catalog: + version: "0.9" + action: data_model + initial: {"user": {"name": "Alice", "settings": {"theme": "dark"}}, "items": ["a", "b", "c"]} + steps: + - op: "set" + path: "/user/profile~0name" + value: "profile~name" + - op: "get" + path: "/user/profile~0name" + expect: "profile~name" + - op: "get" + path: "/user" + expect: {"name": "Alice", "settings": {"theme": "dark"}, "profile~name": "profile~name"} + +- name: test_data_model_escaped_mixed + description: >- + Both escapes may appear in one segment. + catalog: + version: "0.9" + action: data_model + initial: {"user": {"name": "Alice", "settings": {"theme": "dark"}}, "items": ["a", "b", "c"]} + steps: + - op: "set" + path: "/user/a~0b~1c" + value: "value" + - op: "get" + path: "/user/a~0b~1c" + expect: "value" + - op: "get" + path: "/user" + expect: {"name": "Alice", "settings": {"theme": "dark"}, "a~b/c": "value"} + +- name: test_data_model_escape_order + description: >- + A ~01 sequence decodes to ~1, not to a slash. + catalog: + version: "0.9" + action: data_model + initial: {"user": {"name": "Alice", "settings": {"theme": "dark"}}, "items": ["a", "b", "c"]} + steps: + - op: "set" + path: "/user/a~01b" + value: "value" + - op: "get" + path: "/user/a~01b" + expect: "value" + - op: "get" + path: "/user" + expect: {"name": "Alice", "settings": {"theme": "dark"}, "a~1b": "value"} + +- name: test_data_model_rejects_write_through_primitive + description: >- + Writing through a primitive intermediate segment is an error. + catalog: + version: "0.9" + action: data_model + initial: {"user": {"name": "Alice", "settings": {"theme": "dark"}}, "items": ["a", "b", "c"]} + steps: + - op: "set" + path: "/user/name" + value: "not an object" + - op: "set" + path: "/user/name/first" + value: "Alice" + expect_error: {"category": "DataError", "message": "Cannot set path"} + +- name: test_data_model_rejects_write_through_list_primitive + description: >- + Writing through a primitive list element is an error. + catalog: + version: "0.9" + action: data_model + initial: {"user": {"name": "Alice", "settings": {"theme": "dark"}}, "items": ["a", "b", "c"]} + steps: + - op: "set" + path: "/items/0/foo" + value: "bar" + expect_error: {"category": "DataError", "message": "Cannot set path"} + +- name: test_data_model_rejects_non_numeric_list_segment + description: >- + A non numeric segment cannot address a list element on write. + catalog: + version: "0.9" + action: data_model + initial: {"user": {"name": "Alice", "settings": {"theme": "dark"}}, "items": ["a", "b", "c"]} + steps: + - op: "set" + path: "/items/foo" + value: "bar" + expect_error: {"category": "DataError", "message": "non-numeric segment"} + +- name: test_data_model_rejects_non_numeric_list_segment_intermediate + description: >- + A non numeric intermediate segment cannot address a list element. + catalog: + version: "0.9" + action: data_model + initial: {"user": {"name": "Alice", "settings": {"theme": "dark"}}, "items": ["a", "b", "c"]} + steps: + - op: "set" + path: "/items/foo/bar" + value: "value" + expect_error: {"category": "DataError", "message": "non-numeric segment"} + +- name: test_data_model_notifies_exact_path + description: >- + An observer fires when its own path is written. + catalog: + version: "0.9" + action: data_model + initial: {"user": {"name": "Alice", "settings": {"theme": "dark"}}, "items": ["a", "b", "c"]} + watch: ["/user/name"] + steps: + - op: "set" + path: "/user/name" + value: "Charlie" + expect_values: {"/user/name": "Charlie"} + expect_notified: ["/user/name"] + +- name: test_data_model_notifies_ancestor + description: >- + An observer on a container fires when a descendant is written. + catalog: + version: "0.9" + action: data_model + initial: {"user": {"name": "Alice", "settings": {"theme": "dark"}}, "items": ["a", "b", "c"]} + watch: ["/user"] + steps: + - op: "set" + path: "/user/name" + value: "Dave" + expect_values: {"/user": {"name": "Dave", "settings": {"theme": "dark"}}} + expect_notified: ["/user"] + +- name: test_data_model_notifies_descendant + description: >- + An observer on a leaf fires when an ancestor is replaced. + catalog: + version: "0.9" + action: data_model + initial: {"user": {"name": "Alice", "settings": {"theme": "dark"}}, "items": ["a", "b", "c"]} + watch: ["/user/settings/theme"] + steps: + - op: "set" + path: "/user/settings" + value: {"theme": "light"} + expect_values: {"/user/settings/theme": "light"} + expect_notified: ["/user/settings/theme"] + +- name: test_data_model_notifies_root_observer + description: >- + A root observer fires on any write. + catalog: + version: "0.9" + action: data_model + initial: {"user": {"name": "Alice", "settings": {"theme": "dark"}}, "items": ["a", "b", "c"]} + watch: ["/"] + steps: + - op: "set" + path: "/newProp" + value: "test" + expect_notified: ["/"] + +- name: test_data_model_notifies_parent_on_child_write + description: >- + A container observer sees the updated container value. + catalog: + version: "0.9" + action: data_model + initial: {"user": {"name": "Alice", "settings": {"theme": "dark"}}, "items": ["a", "b", "c"]} + watch: ["/parent"] + steps: + - op: "set" + path: "/parent" + value: {"child": "initial"} + - op: "set" + path: "/parent/child" + value: "updated" + expect_values: {"/parent": {"child": "updated"}} + +- name: test_data_model_does_not_notify_unrelated + description: >- + An observer does not fire for an unrelated path. + catalog: + version: "0.9" + action: data_model + initial: {"a": 1, "b": 2} + watch: ["/b"] + steps: + - op: "set" + path: "/a" + value: 99 + expect_notified: [] + +- name: test_data_model_does_not_notify_prefix_sibling + description: >- + A sibling whose name merely shares a prefix is not a descendant. + catalog: + version: "0.9" + action: data_model + initial: {"foo": 1, "foobar": 2} + watch: ["/foo", "/foobar"] + steps: + - op: "set" + path: "/foo" + value: 99 + expect_notified: ["/foo"] + +- name: test_data_model_multiple_observers_same_path + description: >- + Every observer on a path is notified. + catalog: + version: "0.9" + action: data_model + initial: {"user": {"name": "Alice", "settings": {"theme": "dark"}}, "items": ["a", "b", "c"]} + watch: ["/user/name", "/user/name"] + steps: + - op: "set" + path: "/user/name" + value: "Eve" + expect_values: {"/user/name": "Eve"} + expect_notified: ["/user/name", "/user/name"] + +- name: test_data_model_observe_absent_path + description: >- + Observing a path that does not exist yet starts empty and fires on + creation. + catalog: + version: "0.9" + action: data_model + initial: {"user": {"name": "Alice", "settings": {"theme": "dark"}}, "items": ["a", "b", "c"]} + watch: ["/non/existent"] + steps: + - op: "get" + path: "/non/existent" + expect_absent: true + - op: "set" + path: "/non/existent" + value: "exists now" + expect_values: {"/non/existent": "exists now"} + expect_notified: ["/non/existent"] + +- name: test_data_model_stops_notifying_after_dispose + description: >- + Disposing the model detaches every observer. + catalog: + version: "0.9" + action: data_model + initial: {"user": {"name": "Alice", "settings": {"theme": "dark"}}, "items": ["a", "b", "c"]} + watch: ["/"] + steps: + - op: "dispose" + - op: "set" + path: "/foo" + value: "bar" + expect_notified: [] + +- name: test_data_model_does_not_notify_unchanged_descendant + description: >- + An observer on a descendant path does not fire when an ancestor is + rewritten but the observer's own value is still absent. + catalog: + version: "0.9" + action: data_model + watch: ["/a/b"] + steps: + - op: "set" + path: "/a" + value: {"c": 1} + expect_notified: [] + +- name: test_data_model_does_not_notify_same_value_rewrite + description: >- + Writing a path the value it already holds does not fire its observers. + catalog: + version: "0.9" + action: data_model + initial: {"k": "v"} + watch: ["/k"] + steps: + - op: "set" + path: "/k" + value: "v" + expect_notified: [] + - op: "set" + path: "/k" + value: "w" + expect_notified: ["/k"] diff --git a/dart/a2ui_agent/lib/a2ui_agent.dart b/dart/a2ui_agent/lib/a2ui_agent.dart index b8aace0387..0f55bf00ee 100644 --- a/dart/a2ui_agent/lib/a2ui_agent.dart +++ b/dart/a2ui_agent/lib/a2ui_agent.dart @@ -12,8 +12,6 @@ // See the License for the specific language governing permissions and // limitations under the License. -library; - export 'src/a2ui_agent_base.dart'; // TODO: Export any libraries intended for clients of this package. diff --git a/dart/a2ui_agent/pubspec.yaml b/dart/a2ui_agent/pubspec.yaml index da83b2055f..d1f51d85e1 100644 --- a/dart/a2ui_agent/pubspec.yaml +++ b/dart/a2ui_agent/pubspec.yaml @@ -23,7 +23,7 @@ environment: sdk: ">=3.10.0 <4.0.0" dependencies: - a2ui_core: ^0.1.1 + a2ui_core: ^0.2.0 dev_dependencies: test: ^1.26.2 diff --git a/dart/a2ui_core/CHANGELOG.md b/dart/a2ui_core/CHANGELOG.md index 8220d87893..2bbcc7d84e 100644 --- a/dart/a2ui_core/CHANGELOG.md +++ b/dart/a2ui_core/CHANGELOG.md @@ -1,5 +1,56 @@ # [a2ui_core](https://pub.dev/packages/a2ui_core) Changelog +## 0.2.0 + +- **Breaking:** `Catalog` now takes two type parameters, + `Catalog`, so that agents can + hold catalogs whose functions declare a signature without an implementation. + Renderers use `Catalog`. +- Added `A2uiProtocolVersion`, which gates every entry point on protocol v0.9 + and rejects payloads that declare another version or omit it. +- Added `Catalog.fromJson`, `Catalog.catalogSchema` and `Catalog.copyWith`, plus + the schema-only `CatalogComponent` and `CatalogFunction` and the + `SchemaCatalog` alias, so catalog documents round trip through the core layer. + A pruned catalog renders a pruned document, with the `$defs/anyComponent` and + `$defs/anyFunction` unions narrowed to match. +- Added `A2uiRendererCapabilities` and `A2uiVersionCapabilities`, mirroring + `client_capabilities.json` and the `web_core` client capability types. +- Added `A2uiValidator`, which validates a payload in three stages: + `parseMessages` gates envelopes on the supported protocol version, + `validateStructure` checks the component graph, and `validateAgainstCatalogs` + checks each component against its catalog's schema. A payload that creates a + surface is treated as a full render, so it must declare a `root` component, + resolve every reference and leave nothing unreachable; a payload that only + updates components is incremental, so it may reference components the client + already holds, while duplicate ids, self-references and cycles still fail. + Which properties reference other components is read from the catalog schema, + through either the `$ref` pointers a catalog document uses or the `REF:` + description pointers a catalog built in Dart carries. +- Added `A2uiValidator.commonTypesSchema`. Catalogs reference + `common_types.json` for their shared definitions; supplying it lets those + definitions be enforced. A reference this SDK cannot resolve is treated as + unconstrained rather than fetched, so validation never performs I/O. +- `A2uiValidator` is exercised by the shared `conformance/core/validator.yaml` + suite. All 20 of its v0.9 cases pass; the 25 v0.8 cases are skipped with a + reason, as this SDK implements v0.9 only. +- **Behaviour change:** `A2uiMessage.fromJson` now throws + `A2uiValidationError` for a message body that is not an object, a missing + required field, or a field of the wrong type. It previously let those fail + as a `TypeError`, which is an `Error` rather than an `Exception` and so was + not catchable as a payload defect. +- Added the `A2uiParseError`, `A2uiCompileError`, `A2uiCatalogError`, + `A2uiIntegrityError` and `A2uiRecursionError` categories. +- Fixed `DataModel.set` silently dropping a write whose parent path resolves to + a primitive; it now throws `A2uiDataError`. +- **Behaviour change:** `DataModel` observers no longer fire when a write leaves + their own value unchanged. Notifications previously bypassed the signal's + equality check, so an observer on a path merely related to the write was woken + even when nothing it observes had changed. Containers are now handed to the + signal as a copy, so a container mutated in place still compares unequal and + still notifies, while unchanged primitive and absent values no longer do. This + matches the `web_core` renderer, and the shared behaviour is pinned by + `conformance/core/data_model.yaml`. + ## 0.1.1 - The source code is moved from genui repo to a2ui repo. diff --git a/dart/a2ui_core/lib/a2ui_core.dart b/dart/a2ui_core/lib/a2ui_core.dart index 9a1b0d435a..e056a04a0a 100644 --- a/dart/a2ui_core/lib/a2ui_core.dart +++ b/dart/a2ui_core/lib/a2ui_core.dart @@ -12,6 +12,10 @@ // See the License for the specific language governing permissions and // limitations under the License. +/// The A2UI core SDK: protocol messages, catalogs, reactive state models and +/// payload validation, shared by renderers and agents. +/// +/// Implements protocol v0.9. library; // Protocol models. @@ -25,6 +29,7 @@ export 'src/core/contexts.dart'; export 'src/core/data_model.dart'; export 'src/core/messages.dart'; export 'src/core/minimal_catalog.dart'; +export 'src/core/renderer_capabilities.dart'; export 'src/core/surface_group_model.dart'; export 'src/core/surface_model.dart'; export 'src/primitives/cancellation.dart'; @@ -32,6 +37,8 @@ export 'src/primitives/data_path.dart'; export 'src/primitives/errors.dart'; // Event notifications for discrete lifecycle events. export 'src/primitives/event_notifier.dart'; +// Protocol version gating (v0.9 only). +export 'src/primitives/protocol_version.dart'; // Reactivity (re-exports preact_signals primitives). export 'src/primitives/reactivity.dart'; export 'src/processing/basic_functions.dart'; @@ -39,3 +46,14 @@ export 'src/processing/expressions.dart'; // Processing & expressions. export 'src/processing/processor.dart'; export 'src/rendering/binder.dart'; +// Payload validation. +export 'src/validation/component_graph.dart' + show maxComponentDepth, maxFunctionCallDepth, rootComponentId; +export 'src/validation/component_refs.dart' + show + ComponentRefFields, + ComponentReference, + componentReferences, + extractComponentRefFields, + selfDescribingProperties; +export 'src/validation/validator.dart'; diff --git a/dart/a2ui_core/lib/src/core/catalog.dart b/dart/a2ui_core/lib/src/core/catalog.dart index e3682bb2aa..d1c6871fd5 100644 --- a/dart/a2ui_core/lib/src/core/catalog.dart +++ b/dart/a2ui_core/lib/src/core/catalog.dart @@ -14,6 +14,8 @@ import 'package:json_schema_builder/json_schema_builder.dart'; import '../primitives/cancellation.dart'; +import '../primitives/errors.dart'; +import '../primitives/protocol_version.dart'; import '../primitives/reactivity.dart'; import 'contexts.dart'; @@ -44,6 +46,9 @@ enum A2uiReturnType { } /// A definition of a UI function's API. +/// +/// Declares a signature only. Renderers that also evaluate the function supply +/// a [FunctionImplementation] instead. abstract class FunctionApi { String get name; A2uiReturnType get returnType; @@ -60,18 +65,334 @@ abstract class FunctionImplementation extends FunctionApi { ]); } +/// A [ComponentApi] backed by a catalog document's JSON schema. +/// +/// Produced by [Catalog.fromJson]; carries no rendering behaviour, so it is +/// the agent-side representation. +class CatalogComponent implements ComponentApi { + @override + final String name; + + @override + final Schema schema; + + CatalogComponent({required this.name, required this.schema}); +} + +/// A [FunctionApi] backed by a catalog document's JSON schema. +/// +/// Produced by [Catalog.fromJson]; declares a signature but cannot be +/// evaluated. See [FunctionImplementation] for the renderer-side counterpart. +class CatalogFunction implements FunctionApi { + @override + final String name; + + @override + final A2uiReturnType returnType; + + @override + final Schema argumentSchema; + + /// The function's description, when the catalog declares one. + final String? description; + + CatalogFunction({ + required this.name, + required this.argumentSchema, + this.returnType = A2uiReturnType.any, + this.description, + }); +} + +/// A catalog whose components and functions carry schemas only. +/// +/// What [Catalog.fromJson] produces, and what agents work with: they prompt +/// and validate against signatures but never evaluate a function. +typedef SchemaCatalog = Catalog; + /// A collection of available components and functions. -class Catalog { +/// +/// [C] is the component representation and [F] the function representation: +/// renderers use [FunctionImplementation], agents [CatalogFunction]. +class Catalog { + /// The catalog id, from the document's `catalogId` field. final String id; - final Map components; - final Map functions; + + /// The protocol version this catalog conforms to. + final A2uiProtocolVersion protocolVersion; + + final Map components; + final Map functions; final Schema? themeSchema; + /// The document this catalog was parsed from, if any. + final Map? _sourceSchema; + Catalog({ required this.id, - required List components, - List functions = const [], + required List components, + List functions = const [], this.themeSchema, - }) : components = {for (var c in components) c.name: c}, - functions = {for (var f in functions) f.name: f}; + this.protocolVersion = A2uiProtocolVersion.v0_9, + Map? sourceSchema, + }) : components = {for (final c in components) c.name: c}, + functions = {for (final f in functions) f.name: f}, + _sourceSchema = sourceSchema; + + /// Parses a catalog document into a schema-only [Catalog]. + /// + /// Accepts both forms of `functions`: the map of name to JSON schema used by + /// published catalog documents, and the list of definitions used by inline + /// catalogs in renderer capabilities. + /// + /// Throws [A2uiCatalogError] if the document is malformed or conflicts with + /// [expectedCatalogId], and [A2uiValidationError] if its version is + /// unsupported or conflicts with [expectedProtocolVersion]. + static SchemaCatalog fromJson( + Map json, { + A2uiProtocolVersion? expectedProtocolVersion, + String? expectedCatalogId, + }) { + final Object? rawId = json['catalogId']; + if (rawId is! String || rawId.isEmpty) { + throw A2uiCatalogError( + "Catalog document must declare a non-empty string 'catalogId'.", + ); + } + if (expectedCatalogId != null && expectedCatalogId != rawId) { + throw A2uiCatalogError( + "Catalog id mismatch: expected '$expectedCatalogId' but the document " + "declares '$rawId'.", + catalogId: rawId, + ); + } + + // Catalog documents before v1.0 do not declare `protocolVersion`, so an + // absent value means the version this SDK implements. + final A2uiProtocolVersion version = json.containsKey('protocolVersion') + ? A2uiProtocolVersion.fromJson(json['protocolVersion'], details: json) + : A2uiProtocolVersion.v0_9; + if (expectedProtocolVersion != null && expectedProtocolVersion != version) { + throw A2uiValidationError( + 'Catalog protocol version mismatch: expected ' + "'${expectedProtocolVersion.jsonValue}' but the document declares " + "'${version.jsonValue}'.", + details: json, + ); + } + + return SchemaCatalog( + id: rawId, + protocolVersion: version, + components: _parseComponents(json['components'], rawId), + functions: _parseFunctions(json['functions'], rawId), + themeSchema: _parseTheme(json), + sourceSchema: json, + ); + } + + static List _parseComponents( + Object? raw, + String catalogId, + ) { + if (raw == null) return const []; + if (raw is! Map) { + throw A2uiCatalogError( + "Catalog 'components' must be an object mapping names to schemas.", + catalogId: catalogId, + ); + } + return [ + for (final MapEntry entry in raw.entries) + CatalogComponent( + name: entry.key! as String, + schema: Schema.fromMap(_asSchemaMap(entry.value)), + ), + ]; + } + + static List _parseFunctions(Object? raw, String catalogId) { + if (raw == null) return const []; + + // Inline form: {name, description, parameters, returnType} definitions. + if (raw is List) { + return [ + for (final Object? entry in raw) + if (entry is Map) + CatalogFunction( + name: entry['name']! as String, + description: entry['description'] as String?, + argumentSchema: Schema.fromMap( + _asSchemaMap(entry['parameters'] ?? const {}), + ), + returnType: A2uiReturnType.fromJson( + entry['returnType'] as String? ?? 'any', + ), + ), + ]; + } + + // Document form: name to JSON schema, with arguments under + // `properties/args` and the return type under + // `properties/returnType/const`. + if (raw is! Map) { + throw A2uiCatalogError( + "Catalog 'functions' must be an object or a list of definitions.", + catalogId: catalogId, + ); + } + final functions = []; + for (final MapEntry entry in raw.entries) { + final Map schema = _asSchemaMap(entry.value); + final Map properties = + schema['properties'] as Map? ?? + const {}; + final Object? args = properties['args']; + final Object? returnType = properties['returnType']; + functions.add( + CatalogFunction( + name: entry.key! as String, + description: schema['description'] as String?, + argumentSchema: Schema.fromMap( + _asSchemaMap(args ?? const {}), + ), + returnType: A2uiReturnType.fromJson( + (returnType is Map ? returnType[r'const'] as String? : null) ?? + 'any', + ), + ), + ); + } + return functions; + } + + static Schema? _parseTheme(Map json) { + final Object? defs = json[r'$defs']; + final Object? theme = json['theme'] ?? (defs is Map ? defs['theme'] : null); + if (theme == null) return null; + return Schema.fromMap(_asSchemaMap(theme)); + } + + static Map _asSchemaMap(Object? value) { + if (value is Map) return value.cast(); + throw A2uiCatalogError('Expected a JSON schema object, got $value.'); + } + + /// The catalog document for this catalog, as JSON. + /// + /// A parsed catalog returns its source document with `components`, + /// `functions` and `$defs` narrowed to what it still holds, so a pruned + /// catalog renders a pruned document. Otherwise the document is + /// synthesised from the schemas. + Map get catalogSchema { + final Map? source = _sourceSchema; + if (source == null) return _synthesizeSchema(); + + final Map document = _deepCopy(source); + document['catalogId'] = id; + final Object? sourceComponents = source['components']; + if (sourceComponents is Map) { + document['components'] = { + for (final String name in components.keys) + if (sourceComponents.containsKey(name)) + name: _deepCopyValue(sourceComponents[name]), + }; + } + final Object? sourceFunctions = source['functions']; + if (sourceFunctions is Map) { + document['functions'] = { + for (final String name in functions.keys) + if (sourceFunctions.containsKey(name)) + name: _deepCopyValue(sourceFunctions[name]), + }; + } else if (sourceFunctions is List) { + document['functions'] = [ + for (final Object? entry in sourceFunctions) + if (entry is Map && functions.containsKey(entry['name'])) + _deepCopyValue(entry), + ]; + } + _narrowAnyOneOf(document, 'anyComponent', '#/components/', components.keys); + _narrowAnyOneOf(document, 'anyFunction', '#/functions/', functions.keys); + return document; + } + + /// Drops `$defs//oneOf` entries whose `$ref` names a pruned entry. + static void _narrowAnyOneOf( + Map document, + String defName, + String refPrefix, + Iterable kept, + ) { + final Object? defs = document[r'$defs']; + if (defs is! Map) return; + final Object? any = defs[defName]; + if (any is! Map) return; + final Object? oneOf = any['oneOf']; + if (oneOf is! List) return; + final Set keptRefs = { + for (final String name in kept) '$refPrefix$name', + }; + any['oneOf'] = [ + for (final Object? entry in oneOf) + if (entry is! Map || + entry[r'$ref'] is! String || + !(entry[r'$ref']! as String).startsWith(refPrefix) || + keptRefs.contains(entry[r'$ref'])) + entry, + ]; + } + + Map _synthesizeSchema() => { + 'catalogId': id, + 'components': { + for (final MapEntry entry in components.entries) + entry.key: entry.value.schema.value, + }, + if (functions.isNotEmpty) + 'functions': [ + for (final F function in functions.values) + { + 'name': function.name, + 'returnType': function.returnType.jsonValue, + 'parameters': function.argumentSchema.value, + }, + ], + if (themeSchema != null) r'$defs': {'theme': themeSchema!.value}, + }; + + /// A copy of this catalog with the given components and functions. + /// + /// Used by catalog transformers to narrow a catalog before prompting or + /// validation. + Catalog copyWith({ + Iterable? components, + Iterable? functions, + Schema? themeSchema, + }) => Catalog( + id: id, + protocolVersion: protocolVersion, + components: (components ?? this.components.values).toList(), + functions: (functions ?? this.functions.values).toList(), + themeSchema: themeSchema ?? this.themeSchema, + sourceSchema: _sourceSchema, + ); + + static Map _deepCopy(Map map) => { + for (final MapEntry entry in map.entries) + entry.key: _deepCopyValue(entry.value), + }; + + static Object? _deepCopyValue(Object? value) { + if (value is Map) { + return { + for (final MapEntry entry in value.entries) + entry.key! as String: _deepCopyValue(entry.value), + }; + } + if (value is List) { + return [for (final Object? item in value) _deepCopyValue(item)]; + } + return value; + } } diff --git a/dart/a2ui_core/lib/src/core/contexts.dart b/dart/a2ui_core/lib/src/core/contexts.dart index 45fc6fc35f..eec81d54d0 100644 --- a/dart/a2ui_core/lib/src/core/contexts.dart +++ b/dart/a2ui_core/lib/src/core/contexts.dart @@ -148,7 +148,8 @@ class ComponentContext { } } -extension CatalogInvokerExtension on Catalog { +extension CatalogInvokerExtension + on Catalog { /// Invokes a catalog function by name with the given arguments. Object? invoke(String name, Map args, DataContext context) { final FunctionImplementation? fn = functions[name]; diff --git a/dart/a2ui_core/lib/src/core/data_model.dart b/dart/a2ui_core/lib/src/core/data_model.dart index e0a88c1b91..ef121ba523 100644 --- a/dart/a2ui_core/lib/src/core/data_model.dart +++ b/dart/a2ui_core/lib/src/core/data_model.dart @@ -132,6 +132,14 @@ class DataModel { current.add(null); } current[index] = value; + } else { + // The parent resolved to a primitive, so there is nothing to + // write into. Dropping the write would hide a malformed path. + throw A2uiDataError( + "Cannot set path '$path': '$lastSegment' is a property of a " + 'primitive value.', + path: path, + ); } } @@ -182,9 +190,15 @@ class DataModel { } final Object? newValue = get(path); - // Force notification even if the value is the same reference, because - // mutable containers (Maps/Lists) may have changed in place. - sig.set(newValue, force: true); + // A container mutated in place keeps its identity, so the live object + // would compare equal and suppress the notification. Hand over a copy, + // and let the signal's equality check suppress genuinely unchanged + // values; notifying unconditionally would wake unaffected observers. + sig.set(switch (newValue) { + final Map map => Map.of(map), + final List list => List.of(list), + _ => newValue, + }); } void _pruneSignals() { diff --git a/dart/a2ui_core/lib/src/core/messages.dart b/dart/a2ui_core/lib/src/core/messages.dart index e8c7ae16cc..81d6cefefa 100644 --- a/dart/a2ui_core/lib/src/core/messages.dart +++ b/dart/a2ui_core/lib/src/core/messages.dart @@ -13,28 +13,27 @@ // limitations under the License. import '../primitives/errors.dart'; +import '../primitives/protocol_version.dart'; /// Base class for all A2UI messages. abstract class A2uiMessage { + /// The declared protocol version, as it appears on the wire. final String version; + A2uiMessage({this.version = 'v0.9'}); + /// The declared protocol version, parsed. + A2uiProtocolVersion get protocolVersion => + A2uiProtocolVersion.fromJson(version); + /// Deserializes a JSON envelope into a typed [A2uiMessage]. + /// + /// Throws [A2uiValidationError] if `version` is missing or unsupported. factory A2uiMessage.fromJson(Map json) { - final Object? rawVersion = json['version']; - if (rawVersion is! String) { - throw A2uiValidationError( - "A2UI message must have a string 'version' field.", - details: json, - ); - } - if (rawVersion != 'v0.9') { - throw A2uiValidationError( - "A2UI message must have version 'v0.9' (got '$rawVersion').", - details: json, - ); - } - final String version = rawVersion; + final String version = A2uiProtocolVersion.fromJson( + json['version'], + details: json, + ).jsonValue; const messageBodyKeys = { 'createSurface', @@ -53,42 +52,37 @@ abstract class A2uiMessage { ); } - if (json.containsKey('createSurface')) { - final body = json['createSurface'] as Map; - return CreateSurfaceMessage( - version: version, - surfaceId: body['surfaceId'] as String, - catalogId: body['catalogId'] as String, - theme: body['theme'] as Map?, - sendDataModel: body['sendDataModel'] as bool? ?? false, - ); - } - - if (json.containsKey('updateComponents')) { - final body = json['updateComponents'] as Map; - return UpdateComponentsMessage( - version: version, - surfaceId: body['surfaceId'] as String, - components: (body['components'] as List).cast>(), - ); - } - - if (json.containsKey('updateDataModel')) { - final body = json['updateDataModel'] as Map; - return UpdateDataModelMessage( - version: version, - surfaceId: body['surfaceId'] as String, - path: body['path'] as String?, - value: body['value'], - ); - } - - if (json.containsKey('deleteSurface')) { - final body = json['deleteSurface'] as Map; - return DeleteSurfaceMessage( - version: version, - surfaceId: body['surfaceId'] as String, - ); + for (final key in messageBodyKeys) { + if (!json.containsKey(key)) continue; + final Map body = _body(json, key); + switch (key) { + case 'createSurface': + return CreateSurfaceMessage( + version: version, + surfaceId: _required(body, 'surfaceId', key), + catalogId: _required(body, 'catalogId', key), + theme: _optional>(body, 'theme', key), + sendDataModel: _optional(body, 'sendDataModel', key) ?? false, + ); + case 'updateComponents': + return UpdateComponentsMessage( + version: version, + surfaceId: _required(body, 'surfaceId', key), + components: _components(body, key), + ); + case 'updateDataModel': + return UpdateDataModelMessage( + version: version, + surfaceId: _required(body, 'surfaceId', key), + path: _optional(body, 'path', key), + value: body['value'], + ); + case 'deleteSurface': + return DeleteSurfaceMessage( + version: version, + surfaceId: _required(body, 'surfaceId', key), + ); + } } throw A2uiValidationError( @@ -101,6 +95,86 @@ abstract class A2uiMessage { Map toJson(); } +/// Reads a message body, rejecting one that is not an object. +Map _body(Map json, String key) { + final Object? body = json[key]; + if (body is! Map) { + throw A2uiValidationError( + "Message body '$key' must be an object.", + details: json, + ); + } + return body; +} + +/// Reads a field a message body must declare. +/// +/// A malformed envelope is a payload defect, not a programming error, so it +/// is reported as [A2uiValidationError] rather than left to fail as a cast. +T _required( + Map body, + String field, + String messageType, +) { + final Object? value = body[field]; + if (value == null) { + throw A2uiValidationError( + "Message '$messageType' is missing required field '$field'.", + details: body, + ); + } + if (value is! T) { + throw A2uiValidationError( + "Field '$messageType.$field' must be a $T, got " + '${value.runtimeType}.', + details: body, + ); + } + return value; +} + +/// Reads a field a message body may omit. +T? _optional( + Map body, + String field, + String messageType, +) { + final Object? value = body[field]; + if (value == null) return null; + if (value is! T) { + throw A2uiValidationError( + "Field '$messageType.$field' must be a $T, got " + '${value.runtimeType}.', + details: body, + ); + } + return value; +} + +List> _components( + Map body, + String messageType, +) { + final Object? raw = body['components']; + if (raw is! List) { + throw A2uiValidationError( + "Field '$messageType.components' must be a list.", + details: body, + ); + } + return [ + for (final Object? entry in raw) + if (entry is Map) + entry + else + throw A2uiValidationError( + "Field '$messageType.components' must hold objects, got " + '${entry.runtimeType}.', + details: body, + ), + ]; +} + /// Signals the client to create a new surface. class CreateSurfaceMessage extends A2uiMessage { final String surfaceId; diff --git a/dart/a2ui_core/lib/src/core/minimal_catalog.dart b/dart/a2ui_core/lib/src/core/minimal_catalog.dart index 69d4186ebc..033de2f1a5 100644 --- a/dart/a2ui_core/lib/src/core/minimal_catalog.dart +++ b/dart/a2ui_core/lib/src/core/minimal_catalog.dart @@ -153,7 +153,7 @@ class CapitalizeFunction extends FunctionImplementation { } } -class MinimalCatalog extends Catalog { +class MinimalCatalog extends Catalog { MinimalCatalog() : super( id: 'https://a2ui.org/specification/v0_9/catalogs/minimal/minimal_catalog.json', diff --git a/dart/a2ui_core/lib/src/core/renderer_capabilities.dart b/dart/a2ui_core/lib/src/core/renderer_capabilities.dart new file mode 100644 index 0000000000..2f4699d636 --- /dev/null +++ b/dart/a2ui_core/lib/src/core/renderer_capabilities.dart @@ -0,0 +1,147 @@ +// Copyright 2024 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 '../primitives/errors.dart'; +import '../primitives/protocol_version.dart'; +import 'catalog.dart'; + +/// The catalogs a renderer can render for one protocol version, mirroring +/// `A2uiVersionCapabilities` in `client_capabilities.json`. +class A2uiVersionCapabilities { + /// Ids of the catalogs the renderer supports. + final List supportedCatalogIds; + + /// Catalogs supplied inline, meaningful only when the agent advertises + /// `acceptsInlineCatalogs`. + final List inlineCatalogs; + + A2uiVersionCapabilities({ + required this.supportedCatalogIds, + this.inlineCatalogs = const [], + }); + + /// Parses a version capabilities object. + /// + /// Throws [A2uiValidationError] unless `supportedCatalogIds` is a list of + /// strings and `inlineCatalogs`, if present, holds catalog objects. + factory A2uiVersionCapabilities.fromJson(Map json) { + final Object? rawIds = json['supportedCatalogIds']; + if (rawIds is! List) { + throw A2uiValidationError( + "Renderer capabilities must declare a 'supportedCatalogIds' array.", + details: json, + ); + } + final Object? rawInline = json['inlineCatalogs']; + return A2uiVersionCapabilities( + supportedCatalogIds: [ + for (final Object? id in rawIds) + if (id is String) + id + else + throw A2uiValidationError( + "'supportedCatalogIds' must contain only strings.", + details: json, + ), + ], + inlineCatalogs: [ + if (rawInline is List) + for (final Object? catalog in rawInline) + if (catalog is Map) + Catalog.fromJson(catalog.cast()) + else + throw A2uiValidationError( + "'inlineCatalogs' must contain only catalog objects (got " + '${catalog.runtimeType}).', + details: json, + ), + ], + ); + } + + Map toJson() => { + 'supportedCatalogIds': supportedCatalogIds, + if (inlineCatalogs.isNotEmpty) + 'inlineCatalogs': [ + for (final SchemaCatalog catalog in inlineCatalogs) + catalog.catalogSchema, + ], + }; +} + +/// The rendering capabilities a renderer advertises, mirroring +/// `a2uiClientCapabilities` in `client_capabilities.json` and web_core's +/// `A2uiClientCapabilities`. +/// +/// A capabilities object without a `v0.9` entry is rejected. Other versions +/// are kept in [unsupportedVersions] but never negotiated against. +class A2uiRendererCapabilities { + /// The capabilities declared for v0.9. + final A2uiVersionCapabilities v0_9; + + /// Version keys in the source object that this SDK does not implement. + final List unsupportedVersions; + + A2uiRendererCapabilities({ + required this.v0_9, + this.unsupportedVersions = const [], + }); + + /// A renderer that supports catalogs by id only. + factory A2uiRendererCapabilities.forCatalogIds( + List supportedCatalogIds, { + List inlineCatalogs = const [], + }) => A2uiRendererCapabilities( + v0_9: A2uiVersionCapabilities( + supportedCatalogIds: supportedCatalogIds, + inlineCatalogs: inlineCatalogs, + ), + ); + + /// Parses an `a2uiClientCapabilities` object. + /// + /// Throws [A2uiValidationError] if the object carries no `v0.9` entry. + factory A2uiRendererCapabilities.fromJson(Map json) { + final Object? v09 = json[A2uiProtocolVersion.v0_9.jsonValue]; + if (v09 is! Map) { + throw A2uiValidationError( + 'Renderer capabilities must declare a ' + "'${A2uiProtocolVersion.v0_9.jsonValue}' entry; this SDK supports " + 'only ${A2uiProtocolVersion.supportedVersions}.', + details: json, + ); + } + return A2uiRendererCapabilities( + v0_9: A2uiVersionCapabilities.fromJson(v09.cast()), + unsupportedVersions: [ + for (final String key in json.keys) + if (key != A2uiProtocolVersion.v0_9.jsonValue) key, + ], + ); + } + + /// The capabilities declared for [version]. + /// + /// Throws [A2uiValidationError] for any version this SDK does not implement. + A2uiVersionCapabilities forVersion(A2uiProtocolVersion version) { + switch (version) { + case A2uiProtocolVersion.v0_9: + return v0_9; + } + } + + Map toJson() => { + A2uiProtocolVersion.v0_9.jsonValue: v0_9.toJson(), + }; +} diff --git a/dart/a2ui_core/lib/src/core/surface_model.dart b/dart/a2ui_core/lib/src/core/surface_model.dart index e6586bfaa4..448b43856e 100644 --- a/dart/a2ui_core/lib/src/core/surface_model.dart +++ b/dart/a2ui_core/lib/src/core/surface_model.dart @@ -25,7 +25,7 @@ import 'messages.dart'; /// The state model for a single UI surface. class SurfaceModel { final String id; - final Catalog catalog; + final Catalog catalog; final Map theme; final bool sendDataModel; diff --git a/dart/a2ui_core/lib/src/primitives/errors.dart b/dart/a2ui_core/lib/src/primitives/errors.dart index 77014d81fd..f602600493 100644 --- a/dart/a2ui_core/lib/src/primitives/errors.dart +++ b/dart/a2ui_core/lib/src/primitives/errors.dart @@ -51,3 +51,55 @@ class A2uiExpressionError extends A2uiError { class A2uiStateError extends A2uiError { A2uiStateError(String message) : super(message, 'STATE_ERROR'); } + +/// Thrown when an LLM response cannot be tokenized into A2UI parts. +class A2uiParseError extends A2uiError { + /// The raw content that could not be parsed. + final String? rawContent; + + A2uiParseError(String message, {this.rawContent}) + : super(message, 'PARSE_ERROR'); +} + +/// Thrown when a raw format payload cannot be compiled into A2UI messages. +class A2uiCompileError extends A2uiError { + /// The raw content that could not be compiled. + final String? rawContent; + + /// Parts that were compiled successfully before the failure. + final List partialResults; + + A2uiCompileError( + String message, { + this.rawContent, + this.partialResults = const [], + }) : super(message, 'COMPILE_ERROR'); +} + +/// Thrown when a catalog cannot be loaded, parsed, or negotiated. +class A2uiCatalogError extends A2uiError { + /// The catalog id involved, when known. + final String? catalogId; + + A2uiCatalogError(String message, {this.catalogId}) + : super(message, 'CATALOG_ERROR'); +} + +/// Thrown for a structurally invalid component graph: unreachable roots, +/// duplicate ids, dangling references. +class A2uiIntegrityError extends A2uiError { + /// The component ids involved, when known. + final List componentIds; + + A2uiIntegrityError(String message, {this.componentIds = const []}) + : super(message, 'INTEGRITY_ERROR'); +} + +/// Thrown when a component graph cycles or exceeds the depth cap. +class A2uiRecursionError extends A2uiError { + /// The chain of component ids that produced the cycle, when known. + final List cycle; + + A2uiRecursionError(String message, {this.cycle = const []}) + : super(message, 'RECURSION_ERROR'); +} diff --git a/dart/a2ui_core/lib/src/primitives/protocol_version.dart b/dart/a2ui_core/lib/src/primitives/protocol_version.dart new file mode 100644 index 0000000000..d3407ab662 --- /dev/null +++ b/dart/a2ui_core/lib/src/primitives/protocol_version.dart @@ -0,0 +1,63 @@ +// Copyright 2024 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 'errors.dart'; + +/// A version of the A2UI protocol. +/// +/// This SDK implements v0.9 only; [fromJson] rejects anything else, and a +/// payload that omits the version. +enum A2uiProtocolVersion { + /// Version 0.9, and the schema-compatible v0.9.1, which shares its wire + /// value. + v0_9('v0.9'); + + const A2uiProtocolVersion(this.jsonValue); + + /// The value used for the `version` field on the wire. + final String jsonValue; + + /// Parses the `version` field of an A2UI payload. + /// + /// Throws [A2uiValidationError] if [value] is absent, is not a string, or + /// names a version this SDK does not implement. + static A2uiProtocolVersion fromJson(Object? value, {Object? details}) { + if (value == null) { + throw A2uiValidationError( + "A2UI payloads must declare a 'version' field; this SDK supports " + 'only $supportedVersions.', + details: details, + ); + } + if (value is! String) { + throw A2uiValidationError( + "A2UI payloads must have a string 'version' field (got " + '${value.runtimeType}).', + details: details, + ); + } + for (final A2uiProtocolVersion version in values) { + if (version.jsonValue == value) return version; + } + throw A2uiValidationError( + "Unsupported A2UI protocol version '$value'; this SDK supports only " + '$supportedVersions.', + details: details, + ); + } + + /// The versions this SDK implements, for error messages. + static String get supportedVersions => + values.map((v) => "'${v.jsonValue}'").join(', '); +} diff --git a/dart/a2ui_core/lib/src/primitives/reactivity.dart b/dart/a2ui_core/lib/src/primitives/reactivity.dart index 593993a1d9..0f1d590d86 100644 --- a/dart/a2ui_core/lib/src/primitives/reactivity.dart +++ b/dart/a2ui_core/lib/src/primitives/reactivity.dart @@ -12,6 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. +/// Reactive primitives, re-exported from `package:preact_signals`. library; export 'package:preact_signals/preact_signals.dart' diff --git a/dart/a2ui_core/lib/src/processing/processor.dart b/dart/a2ui_core/lib/src/processing/processor.dart index 3990e584e8..ddb49d036f 100644 --- a/dart/a2ui_core/lib/src/processing/processor.dart +++ b/dart/a2ui_core/lib/src/processing/processor.dart @@ -24,7 +24,7 @@ import '../primitives/errors.dart'; /// The central processor for A2UI messages. class MessageProcessor { final SurfaceGroupModel groupModel; - final List> catalogs; + final List> catalogs; MessageProcessor({ required this.catalogs, @@ -55,7 +55,7 @@ class MessageProcessor { } void _processCreateSurface(CreateSurfaceMessage message) { - final Catalog catalog = catalogs.firstWhere( + final Catalog catalog = catalogs.firstWhere( (c) => c.id == message.catalogId, orElse: () => throw A2uiStateError('Catalog not found: ${message.catalogId}'), @@ -140,7 +140,9 @@ class MessageProcessor { return {'v0.9': v09}; } - Map _generateInlineCatalog(Catalog catalog) { + Map _generateInlineCatalog( + Catalog catalog, + ) { final components = {}; for (final MapEntry entry in catalog.components.entries) { final Map jsonSchema = entry.value.schema.toJsonMap(); diff --git a/dart/a2ui_core/lib/src/validation/component_graph.dart b/dart/a2ui_core/lib/src/validation/component_graph.dart new file mode 100644 index 0000000000..81f03b36e1 --- /dev/null +++ b/dart/a2ui_core/lib/src/validation/component_graph.dart @@ -0,0 +1,230 @@ +// Copyright 2024 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 '../primitives/errors.dart'; +import 'component_refs.dart'; + +/// The id every surface's component tree is rooted at in v0.9. +const String rootComponentId = 'root'; + +/// The deepest component chain a surface may declare. +const int maxComponentDepth = 50; + +/// The deepest chain of nested function calls a component property may hold. +const int maxFunctionCallDepth = 5; + +/// Matches a JSON Pointer as A2UI writes data-model paths, allowing the +/// leading slash to be omitted. +final RegExp _pathPattern = RegExp( + r'^(?:(?:\/(?:[^~\/]|~[01])*)*|(?:[^~\/]|~[01])+(?:\/(?:[^~\/]|~[01])*)*)$', +); + +/// Checks component ids and references within one surface. +/// +/// [requireRoot] and [allowDangling] distinguish a full render, which must +/// declare every component it names, from an incremental update, which may +/// reference components the client already holds. +/// +/// Throws [A2uiIntegrityError] for duplicate ids, a missing root, or a +/// reference to a component that does not exist. +void checkComponentIntegrity( + List> components, + Map refFields, { + required bool requireRoot, + required bool allowDangling, +}) { + final ids = {}; + for (final component in components) { + final Object? id = component['id']; + if (id is! String) continue; + if (!ids.add(id)) { + throw A2uiIntegrityError( + 'Duplicate component ID: $id', + componentIds: [id], + ); + } + } + + if (allowDangling) return; + + if (requireRoot && !ids.contains(rootComponentId)) { + throw A2uiIntegrityError( + "Missing root component: No component has id='$rootComponentId'", + ); + } + + for (final component in components) { + final String owner = component['id'] as String? ?? 'Unknown'; + for (final ComponentReference reference in _referencesOf( + component, + refFields, + )) { + if (!ids.contains(reference.id)) { + throw A2uiIntegrityError( + "Component '$owner' references non-existent component " + "'${reference.id}' in field '${reference.field}'", + componentIds: [owner, reference.id], + ); + } + } + } +} + +/// Walks the component graph from the root, reporting the ids it reaches. +/// +/// Throws [A2uiRecursionError] for a self-reference, a cycle, or a chain +/// deeper than [maxComponentDepth], and [A2uiIntegrityError] for a component +/// unreachable from the root when [allowOrphans] is false. +Set analyzeComponentTopology( + List> components, + Map refFields, { + required bool requireRoot, + required bool allowOrphans, +}) { + final adjacency = >{}; + final ids = {}; + + for (final component in components) { + final Object? id = component['id']; + if (id is! String) continue; + ids.add(id); + final List edges = adjacency.putIfAbsent(id, () => []); + for (final ComponentReference reference in _referencesOf( + component, + refFields, + )) { + if (reference.id == id) { + throw A2uiRecursionError( + "Self-reference detected: Component '$id' references itself in " + "field '${reference.field}'", + cycle: [id], + ); + } + edges.add(reference.id); + } + } + + final visited = {}; + final onStack = {}; + + void visit(String id, int depth) { + if (depth > maxComponentDepth) { + throw A2uiRecursionError( + 'Global recursion limit exceeded: logical depth > $maxComponentDepth', + cycle: onStack.toList(), + ); + } + visited.add(id); + onStack.add(id); + for (final String next in adjacency[id] ?? const []) { + if (!visited.contains(next)) { + visit(next, depth + 1); + } else if (onStack.contains(next)) { + throw A2uiRecursionError( + "Circular reference detected involving component '$next'", + cycle: [...onStack, next], + ); + } + } + onStack.remove(id); + } + + if (!requireRoot) { + // Without a root there is no single entry point, so every component is + // its own starting point. Cycles still have to be found. + for (final String id in ids.toList()..sort()) { + if (!visited.contains(id)) visit(id, 0); + } + return visited; + } + + if (ids.contains(rootComponentId)) visit(rootComponentId, 0); + + if (!allowOrphans) { + final List orphans = (ids.difference(visited).toList())..sort(); + if (orphans.isNotEmpty) { + throw A2uiIntegrityError( + "Component '${orphans.first}' is not reachable from " + "'$rootComponentId'", + componentIds: orphans, + ); + } + } + return visited; +} + +/// Checks data-model paths and nesting depth anywhere inside a message body. +/// +/// Throws [A2uiValidationError] for a malformed path and [A2uiRecursionError] +/// when nesting or chained function calls run past their caps. +void checkPathsAndRecursion(Object? data) { + void traverse(Object? node, int depth, int callDepth) { + if (depth > maxComponentDepth) { + throw A2uiRecursionError( + 'Global recursion limit exceeded: Depth > $maxComponentDepth', + ); + } + + if (node is List) { + for (final Object? item in node) { + traverse(item, depth + 1, callDepth); + } + return; + } + + if (node is! Map) return; + final Map object = node.cast(); + + final Object? path = object['path']; + if (path is String && !_pathPattern.hasMatch(path)) { + throw A2uiValidationError( + "Invalid path syntax: '$path'", + details: object, + ); + } + + final bool isCall = object.containsKey('call'); + if (isCall) { + if (callDepth >= maxFunctionCallDepth) { + throw A2uiRecursionError( + 'Recursion limit exceeded: functionCall depth > ' + '$maxFunctionCallDepth', + ); + } + for (final MapEntry entry in object.entries) { + traverse( + entry.value, + depth + 1, + entry.key == 'args' ? callDepth + 1 : callDepth, + ); + } + return; + } + + for (final Object? value in object.values) { + traverse(value, depth + 1, callDepth); + } + } + + traverse(data, 0, 0); +} + +Iterable _referencesOf( + Map component, + Map refFields, +) { + final Object? type = component['component']; + if (type is! String) return const []; + return componentReferences(component, refFields[type]); +} diff --git a/dart/a2ui_core/lib/src/validation/component_refs.dart b/dart/a2ui_core/lib/src/validation/component_refs.dart new file mode 100644 index 0000000000..48a997bd99 --- /dev/null +++ b/dart/a2ui_core/lib/src/validation/component_refs.dart @@ -0,0 +1,317 @@ +// Copyright 2024 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 '../core/catalog.dart'; + +/// The JSON Pointer suffix marking a property that holds one component id. +const String componentIdPointer = r'/$defs/ComponentId'; + +/// The JSON Pointer suffix marking a property that holds a `ChildList`. +const String childListPointer = r'/$defs/ChildList'; + +/// Which properties of one component type reference other components. +/// +/// Derived from the component's JSON schema, so a catalog declares its own +/// topology rather than the validator hard-coding property names. +class ComponentRefFields { + /// Properties holding a single component id. + final Set single; + + /// Properties holding a `ChildList`: an id array or a template object. + final Set list; + + /// Properties holding an array of objects whose named keys are ids, such as + /// a tab strip's `items[].child`. Keyed by property name. + final Map> nested; + + const ComponentRefFields({ + this.single = const {}, + this.list = const {}, + this.nested = const {}, + }); + + /// Whether this component type references other components at all. + bool get isEmpty => single.isEmpty && list.isEmpty; +} + +/// Properties that name the component itself rather than another one. +/// +/// `ComponentCommon` declares `id` as a `ComponentId`, so a catalog that +/// inlines it would otherwise read every component's own id as a reference to +/// itself. `component` names the type, never a child. +const Set selfDescribingProperties = {'id', 'component'}; + +/// One reference from a component to another component. +class ComponentReference { + /// The referenced component id. + final String id; + + /// Where the reference sits, for example `children[2]` or `items[0].child`. + final String field; + + const ComponentReference(this.id, this.field); +} + +/// Derives the child-referencing properties of every component in [catalog]. +/// +/// Detection reads the schema, in two equivalent notations: a `$ref` whose +/// pointer ends in `ComponentId` or `ChildList`, as published catalog +/// documents write it, and the `REF:` description pointer that catalogs built +/// in Dart carry (see `CommonSchemas`). Local `$ref`s are followed first +/// against the component's own `$defs`, then against the catalog document. +Map extractComponentRefFields< + C extends ComponentApi, + F extends FunctionApi +>(Catalog catalog) { + final Map document = catalog.catalogSchema; + final result = {}; + + for (final MapEntry entry in catalog.components.entries) { + final Object schema = entry.value.schema.value; + final single = {}; + final list = {}; + final nested = >{}; + _collectFrom(schema, document, single, list, nested); + if (single.isNotEmpty || list.isNotEmpty) { + result[entry.key] = ComponentRefFields( + single: single, + list: list, + nested: nested, + ); + } + } + return result; +} + +/// Walks one component schema, including its `allOf`/`oneOf`/`anyOf` branches, +/// recording every property that references components. +void _collectFrom( + Object? schema, + Map document, + Set single, + Set list, + Map> nested, +) { + if (schema is! Map) return; + final Map node = schema.cast(); + + final Object? properties = node['properties']; + if (properties is Map) { + for (final MapEntry property in properties.entries) { + final name = property.key! as String; + if (selfDescribingProperties.contains(name)) continue; + final Object? resolved = _resolve(property.value, node, document); + if (_marks(resolved, componentIdPointer, node, document)) { + single.add(name); + continue; + } + if (_marks(resolved, childListPointer, node, document)) { + list.add(name); + continue; + } + _collectArrayProperty(name, resolved, node, document, list, nested); + } + } + + for (final combinator in const ['allOf', 'oneOf', 'anyOf']) { + final Object? branches = node[combinator]; + if (branches is! List) continue; + for (final Object? branch in branches) { + _collectFrom( + _resolve(branch, node, document), + document, + single, + list, + nested, + ); + } + } +} + +/// Classifies an array property: an array of ids, or an array of objects with +/// id-bearing keys. +void _collectArrayProperty( + String name, + Object? resolved, + Map owner, + Map document, + Set list, + Map> nested, +) { + if (resolved is! Map) return; + final Map node = resolved.cast(); + if (node['type'] != 'array' || !node.containsKey('items')) return; + + final Object? items = _resolve(node['items'], owner, document); + if (_marks(items, componentIdPointer, owner, document) || + _marks(items, childListPointer, owner, document)) { + list.add(name); + return; + } + if (items is! Map) return; + final Object? itemProperties = items['properties']; + if (itemProperties is! Map) return; + + final keys = {}; + for (final MapEntry property in itemProperties.entries) { + final Object? sub = _resolve(property.value, owner, document); + if (_marks(sub, componentIdPointer, owner, document) || + _marks(sub, childListPointer, owner, document)) { + keys.add(property.key! as String); + } + } + if (keys.isNotEmpty) { + list.add(name); + nested.putIfAbsent(name, () => {}).addAll(keys); + } +} + +/// Whether [schema] carries [pointer], directly or in a combinator branch. +bool _marks( + Object? schema, + String pointer, + Map owner, + Map document, +) { + if (schema is! Map) return false; + final Map node = schema.cast(); + + final Object? ref = node[r'$ref']; + if (ref is String && ref.endsWith(pointer)) return true; + + // Catalogs built in Dart carry the pointer in the description, as + // `REF:` optionally followed by `|`. + final Object? description = node['description']; + if (description is String && description.startsWith('REF:')) { + final String target = description.substring(4).split('|').first; + if (target.endsWith(pointer)) return true; + } + + for (final combinator in const ['oneOf', 'anyOf', 'allOf']) { + final Object? branches = node[combinator]; + if (branches is! List) continue; + for (final Object? branch in branches) { + if (_marks(_resolve(branch, owner, document), pointer, owner, document)) { + return true; + } + } + } + return false; +} + +/// Follows a local `$ref` so detection sees the schema it names. +/// +/// Pointers into `ComponentId` and `ChildList` are left alone: they are the +/// markers being looked for, not indirection to follow. +Object? _resolve( + Object? schema, + Map owner, + Map document, [ + Set? seen, +]) { + if (schema is! Map) return schema; + final Object? ref = schema[r'$ref']; + if (ref is! String || + !ref.startsWith('#/') || + ref.endsWith(componentIdPointer) || + ref.endsWith(childListPointer)) { + return schema; + } + + final Set visited = seen ?? {}; + if (!visited.add(ref)) return schema; + + final List segments = ref.split('/').skip(1).toList(); + final Object? local = _follow(owner, segments); + if (local != null) return _resolve(local, owner, document, visited); + final Object? global = _follow(document, segments); + if (global != null) return _resolve(global, owner, document, visited); + return schema; +} + +/// Walks [segments] through [root], returning null if any step is missing. +Object? _follow(Map root, List segments) { + Object? current = root; + for (final segment in segments) { + if (current is! Map) return null; + if (!current.containsKey(segment)) return null; + current = current[segment]; + } + return identical(current, root) ? null : current; +} + +/// Lists every component [component] references, in declaration order. +/// +/// [fields] describes the component type; a type with no reference properties +/// yields nothing. +Iterable componentReferences( + Map component, + ComponentRefFields? fields, +) sync* { + if (fields == null) return; + for (final MapEntry entry in component.entries) { + if (!fields.single.contains(entry.key) && + !fields.list.contains(entry.key)) { + continue; + } + yield* _pointers(entry.value, entry.key, fields); + } +} + +Iterable _pointers( + Object? value, + String path, + ComponentRefFields fields, +) sync* { + if (value is String) { + yield ComponentReference(value, path); + return; + } + + if (value is List) { + for (var index = 0; index < value.length; index++) { + final Object? item = value[index]; + final itemPath = item is String && !path.contains('[') + ? path + : '$path[$index]'; + yield* _pointers(item, itemPath, fields); + } + return; + } + + if (value is Map) { + final Map node = value.cast(); + // A `ChildList` template names its component through `componentId`. + final Object? templateId = node['componentId']; + if (templateId != null) { + if (templateId is String) { + yield ComponentReference(templateId, '$path.componentId'); + } + return; + } + final String property = path.split('[').first.split('.').first; + final Set? allowed = fields.nested[property]; + if (allowed != null && !path.contains('.')) { + for (final MapEntry entry in node.entries) { + if (allowed.contains(entry.key)) { + yield* _pointers(entry.value, '$path.${entry.key}', fields); + } + } + return; + } + for (final MapEntry entry in node.entries) { + yield* _pointers(entry.value, '$path.${entry.key}', fields); + } + } +} diff --git a/dart/a2ui_core/lib/src/validation/schema_resolution.dart b/dart/a2ui_core/lib/src/validation/schema_resolution.dart new file mode 100644 index 0000000000..f51286f86a --- /dev/null +++ b/dart/a2ui_core/lib/src/validation/schema_resolution.dart @@ -0,0 +1,174 @@ +// Copyright 2024 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. + +/// The file name a catalog refers to for the shared type definitions, however +/// the reference spells the rest of the URL. +const String commonTypesDocument = 'common_types.json'; + +/// The file name `common_types.json` refers back to for the catalog's own +/// definitions, however the reference spells the rest of the URL. +const String catalogDocument = 'catalog.json'; + +/// Rewrites a component schema so it can be validated without any I/O. +/// +/// Every definition the schema reaches — in the catalog [document] and in +/// [commonTypes] — is copied into a single `$defs` block on the result, and +/// each `$ref` is rewritten to point there. Definitions are copied once and +/// shared, so a recursive schema stays recursive: `FunctionCall` reaches +/// `DynamicValue`, which reaches `FunctionCall` again, and the rewritten +/// schema expresses that rather than cutting it short. +/// +/// Local pointers (`#/...`) resolve against the document the subschema +/// carrying them came from. Getting that wrong silently widens a schema, +/// because `DynamicString` and its neighbours reach their alternatives +/// through local pointers. +/// +/// A reference this SDK cannot reach — an unsupplied `common_types.json`, or +/// a document it would have to fetch — is dropped, leaving that subschema +/// unconstrained. The surrounding constraints still apply. Validation +/// therefore never rejects a payload because a schema was unreachable, and +/// never blocks on I/O. +Map resolveSchemaRefs( + Map schema, + Map document, { + Map? commonTypes, +}) { + final resolver = _RefResolver(document, commonTypes); + final Map rewritten = resolver.rewrite( + schema, + _DocumentRef(document, 'catalog'), + ); + if (resolver.defs.isEmpty) return rewritten; + return { + ...rewritten, + r'$defs': { + ...?rewritten[r'$defs'] as Map?, + ...resolver.defs, + }, + }; +} + +/// One of the documents references are resolved against, with a short name +/// used to build collision-free `$defs` keys. +class _DocumentRef { + final Map schema; + final String name; + + const _DocumentRef(this.schema, this.name); +} + +class _RefResolver { + final Map _document; + final Map? _commonTypes; + + /// Definitions hoisted onto the result, keyed by their `$defs` name. + final Map defs = {}; + + /// The `$defs` name already assigned to a document and pointer. + final Map _names = {}; + + _RefResolver(this._document, this._commonTypes); + + Map rewrite(Map node, _DocumentRef base) => + _walk(node, base) as Map; + + Object? _walk(Object? node, _DocumentRef base) { + if (node is List) { + return [for (final Object? item in node) _walk(item, base)]; + } + if (node is! Map) return node; + + final Map object = node.cast(); + final siblings = { + for (final MapEntry entry in object.entries) + if (entry.key != r'$ref') entry.key: _walk(entry.value, base), + }; + + final Object? ref = object[r'$ref']; + if (ref is! String) return siblings; + + final String? name = _hoist(ref, base); + // An unreachable reference leaves the subschema unconstrained. + if (name == null) return siblings; + return {r'$ref': '#/\$defs/$name', ...siblings}; + } + + /// Copies what [ref] names into [defs], returning its `$defs` name. + /// + /// Returns null if this SDK cannot reach the reference. + String? _hoist(String ref, _DocumentRef base) { + final int hash = ref.indexOf('#'); + final String target = hash < 0 ? ref : ref.substring(0, hash); + final String pointer = hash < 0 ? '' : ref.substring(hash + 1); + + final _DocumentRef? source = _documentFor(target, base); + if (source == null || pointer.isEmpty) return null; + + final key = '${source.name}$pointer'; + final String? known = _names[key]; + if (known != null) return known; + + final Object? found = _follow(source.schema, pointer); + if (found is! Map) return null; + + final String name = _defName(key); + // Registered before the copy is walked, so a definition that reaches + // itself points at the name instead of expanding forever. + _names[key] = name; + defs[name] = null; + final copy = Map.of(found.cast()) + // A hoisted definition must not carry its own identity, which would + // move the base every reference below it resolves against. + ..remove(r'$id') + ..remove(r'$schema'); + defs[name] = _walk(copy, source); + return name; + } + + /// The document [target] names, as seen from [base]. + _DocumentRef? _documentFor(String target, _DocumentRef base) { + if (target.isEmpty) return base; + if (target.endsWith(commonTypesDocument)) { + final Map? commonTypes = _commonTypes; + return commonTypes == null + ? null + : _DocumentRef(commonTypes, 'commonTypes'); + } + // `common_types.json` points back at `catalog.json` for the catalog's own + // `anyComponent` and `anyFunction` unions. + if (target.endsWith(catalogDocument)) { + return _DocumentRef(_document, 'catalog'); + } + return null; + } + + Object? _follow(Map root, String pointer) { + Object? current = root; + for (final String raw in pointer.split('/').skip(1)) { + final String segment = raw.replaceAll('~1', '/').replaceAll('~0', '~'); + if (current is! Map || !current.containsKey(segment)) return null; + current = current[segment]; + } + return current; + } + + String _defName(String key) { + final String base = key.replaceAll(RegExp(r'[^A-Za-z0-9]+'), '_'); + if (!defs.containsKey(base)) return base; + for (var i = 2; ; i++) { + final candidate = '$base$i'; + if (!defs.containsKey(candidate)) return candidate; + } + } +} diff --git a/dart/a2ui_core/lib/src/validation/validator.dart b/dart/a2ui_core/lib/src/validation/validator.dart new file mode 100644 index 0000000000..15261c6fbb --- /dev/null +++ b/dart/a2ui_core/lib/src/validation/validator.dart @@ -0,0 +1,340 @@ +// Copyright 2024 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 'package:json_schema_builder/json_schema_builder.dart'; + +import '../core/catalog.dart'; +import '../core/messages.dart'; +import '../primitives/errors.dart'; +import '../primitives/protocol_version.dart'; +import 'component_graph.dart'; +import 'component_refs.dart'; +import 'schema_resolution.dart'; + +/// Everything one surface declares across a payload. +class _SurfacePayload { + /// Whether the payload creates the surface, making it a full render. + bool created = false; + + /// The catalog the payload names for the surface, when it creates it. + String? catalogId; + + /// Components declared for the surface, in the order they arrive. + final List> components = []; + + /// How many `updateComponents` messages the payload sends the surface. + int updates = 0; + + /// Whether the payload declares the whole surface in one message, so every + /// component it declares should be reachable from the root. + /// + /// Once a payload revises the surface across several messages, a component + /// left unreachable is the residue of a replacement rather than a defect: + /// the `31_incremental-dashboard` example in the basic catalog swaps a + /// loading placeholder out for the panel it was standing in for, and the + /// placeholder is meant to be dropped. + bool get isSingleRender => created && updates == 1; + + /// Where each id sits in [components]. + final Map _positions = {}; + + /// Merges one message's components in. + /// + /// A later message that repeats an id replaces that component rather than + /// adding a second one: re-sending a component is how a surface is updated + /// in place, which the `00_incremental` and `31_incremental-dashboard` + /// examples in the basic catalog both do. Repeating an id *within* one + /// message is a contradiction, and is caught before this merge. + void merge(List> incoming) { + updates++; + for (final component in incoming) { + final Object? id = component['id']; + if (id is! String) { + components.add(component); + continue; + } + final int? at = _positions[id]; + if (at == null) { + _positions[id] = components.length; + components.add(component); + } else { + components[at] = component; + } + } + } +} + +/// Validates A2UI payloads against the protocol schemas and a set of catalogs. +/// +/// Lives in `a2ui_core` because renderers and agents validate the same +/// payloads against the same catalogs. Implements v0.9 only: [checkVersion] +/// and [parseMessages] reject any other version, or none. +/// +/// Validation runs in three stages, which [validate] performs in order: +/// [parseMessages] checks envelopes, [validateStructure] checks the component +/// graph, and [validateAgainstCatalogs] checks each component against its +/// catalog's schema. +/// +/// A payload that creates a surface is a full render: it must declare a +/// component with id [rootComponentId], every reference must name a component +/// the payload declares, and every component must be reachable from the root. +/// A payload that only updates components is incremental, so it may reference +/// components the client already holds; duplicate ids, self-references and +/// cycles still fail. +class A2uiValidator { + /// The catalogs payloads are validated against, keyed by catalog id. + final Map> catalogs; + + /// The protocol version this validator accepts. + final A2uiProtocolVersion protocolVersion; + + /// The shared `common_types.json` definitions, when the caller has them. + /// + /// Catalogs reference this document for `ChildList`, `DynamicString` and + /// the other shared types. Supplying it lets [validateAgainstCatalogs] + /// enforce those definitions; without it they are treated as unconstrained, + /// because this SDK never fetches a schema over the network. + final Map? commonTypesSchema; + + /// Child-referencing properties per catalog id, derived on first use. + final Map> _refFields = {}; + + /// Component schemas with their `$ref`s inlined, keyed by catalog id. + final Map> _resolvedComponents = {}; + + A2uiValidator({ + List> catalogs = const [], + this.commonTypesSchema, + this.protocolVersion = A2uiProtocolVersion.v0_9, + }) : catalogs = {for (final Catalog c in catalogs) c.id: c}; + + /// Creates a validator for [version]. + /// + /// Throws [A2uiValidationError] for any version this SDK does not + /// implement. + factory A2uiValidator.forVersion( + Object? version, { + List> catalogs = const [], + Map? commonTypesSchema, + }) => A2uiValidator( + catalogs: catalogs, + commonTypesSchema: commonTypesSchema, + protocolVersion: A2uiProtocolVersion.fromJson(version), + ); + + /// Checks the `version` field of one payload envelope. + /// + /// Throws [A2uiValidationError] if it is missing or is not the version this + /// validator accepts. + A2uiProtocolVersion checkVersion(Map envelope) { + final A2uiProtocolVersion version = A2uiProtocolVersion.fromJson( + envelope['version'], + details: envelope, + ); + if (version != protocolVersion) { + throw A2uiValidationError( + "Payload declares version '${version.jsonValue}' but this validator " + "accepts only '${protocolVersion.jsonValue}'.", + details: envelope, + ); + } + return version; + } + + /// Parses payload envelopes into typed messages. + /// + /// Throws [A2uiValidationError] for any envelope that is not a well-formed + /// message of the accepted version. + List parseMessages(List> payload) { + final messages = []; + for (final envelope in payload) { + checkVersion(envelope); + messages.add(A2uiMessage.fromJson(Map.from(envelope))); + } + return messages; + } + + /// Checks that a message sequence forms a valid component graph: unique + /// ids, reachability, no dangling references, no cycles, depth within cap. + /// + /// Throws [A2uiIntegrityError] for graph defects, [A2uiRecursionError] for + /// cycles and depth overruns, and [A2uiValidationError] for a malformed + /// data-model path. + void validateStructure(List messages) { + for (final message in messages) { + checkPathsAndRecursion(message.toJson()); + // Two components sharing an id in one message contradict each other. + // The same id in a later message updates the component instead, so + // duplicates are looked for per message, before the merge below. + if (message is UpdateComponentsMessage) { + checkComponentIntegrity( + message.components, + const {}, + requireRoot: false, + allowDangling: true, + ); + } + } + + for (final MapEntry entry in _groupBySurface( + messages, + ).entries) { + final _SurfacePayload surface = entry.value; + if (surface.components.isEmpty) continue; + + final Map refFields = _refFieldsFor( + _catalogFor(surface), + ); + checkComponentIntegrity( + surface.components, + refFields, + requireRoot: surface.created, + allowDangling: !surface.created, + ); + analyzeComponentTopology( + surface.components, + refFields, + requireRoot: surface.created, + allowOrphans: !surface.isSingleRender, + ); + } + } + + /// Checks each component and function call against its surface's catalog. + /// + /// Throws [A2uiCatalogError] if a message names a catalog this validator + /// does not hold, and [A2uiValidationError] for schema violations. + /// + /// A surface the payload does not create carries no catalog id, so its + /// components are checked only when this validator holds exactly one + /// catalog. A validator has no client state to look the surface up in. + Future validateAgainstCatalogs(List messages) async { + final Map surfaces = _groupBySurface(messages); + + for (final _SurfacePayload surface in surfaces.values) { + final String? catalogId = surface.catalogId; + if (catalogId != null && !catalogs.containsKey(catalogId)) { + throw A2uiCatalogError( + "Unknown catalog '$catalogId'. This validator holds: " + '${catalogs.keys.join(', ')}.', + catalogId: catalogId, + ); + } + } + + for (final _SurfacePayload surface in surfaces.values) { + final Catalog? catalog = _catalogFor(surface); + if (catalog == null) continue; + for (final Map component in surface.components) { + await _validateComponent(component, catalog); + } + } + } + + /// Validates a complete payload: envelopes, then structure, then catalog + /// schemas. + /// + /// Returns the parsed messages, and throws as the individual steps do. + Future> validate(List> payload) async { + final List messages = parseMessages(payload); + validateStructure(messages); + await validateAgainstCatalogs(messages); + return messages; + } + + Future _validateComponent( + Map component, + Catalog catalog, + ) async { + final Object? type = component['component']; + if (type is! String) { + throw A2uiValidationError( + "Component '${component['id']}' does not name a component type.", + details: component, + ); + } + final Schema? schema = _resolvedComponentSchemas(catalog)[type]; + if (schema == null) { + throw A2uiValidationError( + "Catalog '${catalog.id}' declares no component named '$type'.", + details: component, + ); + } + + final List errors = await schema.validate(component); + if (errors.isNotEmpty) { + throw A2uiValidationError( + "Component '${component['id']}' does not match the '$type' schema in " + "catalog '${catalog.id}': " + '${errors.map((e) => e.toErrorString()).join('; ')}', + details: component, + ); + } + } + + /// Groups a payload's messages by surface, in arrival order. + Map _groupBySurface(List messages) { + final surfaces = {}; + _SurfacePayload payloadFor(String id) => + surfaces.putIfAbsent(id, _SurfacePayload.new); + + for (final message in messages) { + switch (message) { + case CreateSurfaceMessage(:final surfaceId, :final catalogId): + payloadFor(surfaceId) + ..created = true + ..catalogId = catalogId; + case UpdateComponentsMessage(:final surfaceId, :final components): + payloadFor(surfaceId).merge(components); + case DeleteSurfaceMessage(:final surfaceId): + // A surface deleted within the payload takes its components with + // it, so what came before is not part of the graph any more. + surfaces.remove(surfaceId); + default: + break; + } + } + return surfaces; + } + + /// The catalog a surface's components belong to, when it can be determined. + Catalog? _catalogFor(_SurfacePayload surface) { + final String? catalogId = surface.catalogId; + if (catalogId != null) return catalogs[catalogId]; + return catalogs.length == 1 ? catalogs.values.first : null; + } + + Map _refFieldsFor(Catalog? catalog) { + if (catalog == null) return const {}; + return _refFields.putIfAbsent( + catalog.id, + () => extractComponentRefFields(catalog), + ); + } + + Map _resolvedComponentSchemas(Catalog catalog) => + _resolvedComponents.putIfAbsent(catalog.id, () { + final Map document = catalog.catalogSchema; + return { + for (final MapEntry entry in catalog.components.entries) + entry.key: Schema.fromMap( + resolveSchemaRefs( + entry.value.schema.value, + document, + commonTypes: commonTypesSchema, + ), + ), + }; + }); +} diff --git a/dart/a2ui_core/pubspec.yaml b/dart/a2ui_core/pubspec.yaml index b8ece956b1..1bd7625f4d 100644 --- a/dart/a2ui_core/pubspec.yaml +++ b/dart/a2ui_core/pubspec.yaml @@ -15,7 +15,7 @@ name: a2ui_core description: Core package for A2UI protocol. repository: https://github.com/a2ui-project/a2ui/tree/main/dart/a2ui_core -version: 0.1.1 +version: 0.2.0 resolution: workspace @@ -29,4 +29,6 @@ dependencies: preact_signals: ^1.9.4 dev_dependencies: + path: ^1.9.0 test: ^1.26.2 + yaml: ^3.1.2 diff --git a/dart/a2ui_core/test/catalog_json_test.dart b/dart/a2ui_core/test/catalog_json_test.dart new file mode 100644 index 0000000000..dc94d99402 --- /dev/null +++ b/dart/a2ui_core/test/catalog_json_test.dart @@ -0,0 +1,258 @@ +// Copyright 2024 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 'dart:convert'; +import 'dart:io'; + +import 'package:a2ui_core/a2ui_core.dart'; +import 'package:test/test.dart'; + +import 'conformance/conformance_harness.dart'; + +/// The published basic catalog, which agent-side tests are measured against. +const String basicCatalogPath = + '../specification/v0_9_1/catalogs/basic/catalog.json'; + +const String basicCatalogId = + 'https://a2ui.org/specification/v0_9/catalogs/basic/catalog.json'; + +Map loadBasicCatalogJson() => + jsonDecode( + File(resolveConformancePath(basicCatalogPath)).readAsStringSync(), + ) + as Map; + +void main() { + group('Catalog.fromJson', () { + test('parses the published basic catalog document', () { + final SchemaCatalog catalog = Catalog.fromJson(loadBasicCatalogJson()); + + expect(catalog.id, basicCatalogId); + expect(catalog.protocolVersion, A2uiProtocolVersion.v0_9); + expect( + catalog.components.keys, + containsAll(['Text', 'Card', 'Column', 'Button', 'TextField']), + ); + expect( + catalog.functions.keys, + containsAll(['required', 'email', 'formatNumber', 'openUrl']), + ); + expect(catalog.themeSchema, isNotNull); + }); + + test('reads a function argument schema and return type', () { + final SchemaCatalog catalog = Catalog.fromJson(loadBasicCatalogJson()); + + final CatalogFunction required = catalog.functions['required']!; + expect(required.name, 'required'); + expect(required.returnType, A2uiReturnType.boolean); + expect( + (required.argumentSchema.value['required']! as List).cast(), + ['value'], + ); + + expect( + catalog.functions['formatNumber']!.returnType, + A2uiReturnType.string, + ); + }); + + test('parses the inline catalog form used by renderer capabilities', () { + final SchemaCatalog catalog = Catalog.fromJson({ + 'catalogId': 'inline', + 'components': { + 'Text': {'type': 'object'}, + }, + 'functions': [ + { + 'name': 'greet', + 'description': 'Says hello.', + 'parameters': {'type': 'object'}, + 'returnType': 'string', + }, + ], + }); + + expect(catalog.id, 'inline'); + expect(catalog.components.keys, ['Text']); + expect(catalog.functions['greet']!.returnType, A2uiReturnType.string); + expect(catalog.functions['greet']!.description, 'Says hello.'); + }); + + test('defaults an undeclared function return type to any', () { + final SchemaCatalog catalog = Catalog.fromJson({ + 'catalogId': 'c', + 'functions': { + 'mystery': {'type': 'object', 'properties': {}}, + }, + }); + + expect(catalog.functions['mystery']!.returnType, A2uiReturnType.any); + }); + + test('rejects a document without a catalog id', () { + expect( + () => Catalog.fromJson({'components': {}}), + throwsA(isA()), + ); + expect( + () => Catalog.fromJson({'catalogId': ''}), + throwsA(isA()), + ); + }); + + test('rejects a catalog id that conflicts with the expected id', () { + expect( + () => Catalog.fromJson({ + 'catalogId': 'actual', + }, expectedCatalogId: 'expected'), + throwsA( + isA().having( + (e) => e.catalogId, + 'catalogId', + 'actual', + ), + ), + ); + }); + + test('accepts a catalog id that matches the expected id', () { + expect( + Catalog.fromJson({'catalogId': 'same'}, expectedCatalogId: 'same').id, + 'same', + ); + }); + + test('rejects a document declaring an unsupported protocol version', () { + expect( + () => Catalog.fromJson({'catalogId': 'c', 'protocolVersion': 'v1.0'}), + throwsA(isA()), + ); + }); + + test('treats an undeclared protocol version as v0.9', () { + // Catalog documents do not carry a protocol version before v1.0. + expect( + Catalog.fromJson({'catalogId': 'c'}).protocolVersion, + A2uiProtocolVersion.v0_9, + ); + }); + + test('rejects malformed components and functions', () { + expect( + () => Catalog.fromJson({'catalogId': 'c', 'components': 'nope'}), + throwsA(isA()), + ); + expect( + () => Catalog.fromJson({'catalogId': 'c', 'functions': 'nope'}), + throwsA(isA()), + ); + }); + }); + + group('Catalog.catalogSchema', () { + test('round trips the source document', () { + final Map source = loadBasicCatalogJson(); + final Map rendered = Catalog.fromJson( + source, + ).catalogSchema; + + expect(rendered['catalogId'], source['catalogId']); + expect( + (rendered['components']! as Map).keys.toSet(), + (source['components']! as Map).keys.toSet(), + ); + expect( + (rendered['functions']! as Map).keys.toSet(), + (source['functions']! as Map).keys.toSet(), + ); + }); + + test('does not alias the source document', () { + final Map source = loadBasicCatalogJson(); + final Map rendered = Catalog.fromJson( + source, + ).catalogSchema; + + (rendered['components']! as Map).remove('Text'); + expect((source['components']! as Map).containsKey('Text'), isTrue); + }); + + test('reflects a pruned catalog and narrows the anyComponent union', () { + final SchemaCatalog catalog = Catalog.fromJson(loadBasicCatalogJson()); + final SchemaCatalog pruned = catalog.copyWith( + components: [catalog.components['Text']!, catalog.components['Card']!], + ); + + final Map rendered = pruned.catalogSchema; + expect((rendered['components']! as Map).keys.toSet(), {'Text', 'Card'}); + + final oneOf = + ((rendered[r'$defs']! as Map)['anyComponent']! as Map)['oneOf']! + as List; + expect(oneOf.map((e) => (e! as Map)[r'$ref']).toSet(), { + '#/components/Text', + '#/components/Card', + }); + }); + + test('reflects pruned functions and narrows the anyFunction union', () { + final SchemaCatalog catalog = Catalog.fromJson(loadBasicCatalogJson()); + final SchemaCatalog pruned = catalog.copyWith( + functions: [catalog.functions['required']!], + ); + + final Map rendered = pruned.catalogSchema; + expect((rendered['functions']! as Map).keys.toSet(), {'required'}); + + final oneOf = + ((rendered[r'$defs']! as Map)['anyFunction']! as Map)['oneOf']! + as List; + expect(oneOf.map((e) => (e! as Map)[r'$ref']).toSet(), { + '#/functions/required', + }); + }); + + test('synthesises a document for a code defined catalog', () { + final Map rendered = MinimalCatalog().catalogSchema; + + expect(rendered['catalogId'], MinimalCatalog().id); + expect((rendered['components']! as Map).keys, contains('Text')); + }); + }); + + group('Catalog generics', () { + test('separates function signatures from function implementations', () { + // Agents hold schema-only functions; renderers hold implementations. + final SchemaCatalog agentCatalog = Catalog.fromJson( + loadBasicCatalogJson(), + ); + expect( + agentCatalog.functions.values, + everyElement(isA()), + ); + expect( + agentCatalog.functions.values, + isNot(anyElement(isA())), + ); + + final Catalog rendererCatalog = + MinimalCatalog(); + expect( + rendererCatalog.functions.values, + everyElement(isA()), + ); + }); + }); +} diff --git a/dart/a2ui_core/test/conformance/conformance_harness.dart b/dart/a2ui_core/test/conformance/conformance_harness.dart new file mode 100644 index 0000000000..d01f13de0a --- /dev/null +++ b/dart/a2ui_core/test/conformance/conformance_harness.dart @@ -0,0 +1,85 @@ +// Copyright 2024 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 'dart:io'; + +import 'package:path/path.dart' as p; +import 'package:yaml/yaml.dart'; + +/// Resolves a case's path against the `conformance/` directory, for example +/// `../specification/v0_9_1/catalogs/basic/catalog.json`. +String resolveConformancePath(String relativePath) => + p.normalize(p.join(_conformanceRoot(), relativePath)); + +String _conformanceRoot() { + // Walk up, so the harness works from the package directory and the + // workspace root. + Directory dir = Directory.current; + while (true) { + final candidate = Directory(p.join(dir.path, 'conformance')); + if (candidate.existsSync() && + File(p.join(candidate.path, 'conformance_schema.json')).existsSync()) { + return candidate.path; + } + final Directory parent = dir.parent; + if (parent.path == dir.path) { + throw StateError( + 'Could not locate the conformance/ directory above ' + '${Directory.current.path}.', + ); + } + dir = parent; + } +} + +/// Loads a conformance suite, for example `core/data_model.yaml`. +List> loadConformanceSuite(String suite) { + final file = File(resolveConformancePath(suite)); + if (!file.existsSync()) { + throw StateError('Conformance suite not found: ${file.path}'); + } + final Object? parsed = loadYaml(file.readAsStringSync()); + if (parsed is! YamlList) { + throw StateError('Conformance suite $suite must be a list of cases.'); + } + return [ + for (final Object? node in parsed) + normalizeYaml(node)! as Map, + ]; +} + +/// Converts YAML nodes into plain Dart maps, lists and scalars, which +/// `YamlMap` and `YamlList` are not. +Object? normalizeYaml(Object? node) { + if (node is YamlMap || node is Map) { + return { + for (final MapEntry entry + in (node as Map).cast().entries) + entry.key.toString(): normalizeYaml(entry.value), + }; + } + if (node is YamlList || node is List) { + return [ + for (final Object? item in node as List) normalizeYaml(item), + ]; + } + return node; +} + +/// The protocol version a case targets, or null when it declares none. +String? caseVersion(Map testCase) { + final Object? catalog = testCase['catalog']; + if (catalog is Map) return catalog['version'] as String?; + return null; +} diff --git a/dart/a2ui_core/test/conformance/data_model_conformance_test.dart b/dart/a2ui_core/test/conformance/data_model_conformance_test.dart new file mode 100644 index 0000000000..312ad60253 --- /dev/null +++ b/dart/a2ui_core/test/conformance/data_model_conformance_test.dart @@ -0,0 +1,205 @@ +// Copyright 2024 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 'package:a2ui_core/a2ui_core.dart'; +import 'package:test/test.dart'; + +import 'conformance_harness.dart'; + +/// Runs the shared `conformance/core/data_model.yaml` suite against +/// [DataModel]. +/// +/// Two mappings, both documented in the suite header: `op: delete` maps to +/// `set(path, null)`, since Dart has no `undefined`; `watch` attaches one +/// observer per entry, so a repeated path attaches a second. +void main() { + final List> cases = loadConformanceSuite( + 'core/data_model.yaml', + ); + + group('conformance core/data_model.yaml', () { + test('suite is not empty', () => expect(cases, isNotEmpty)); + + for (final testCase in cases) { + test(testCase['name']! as String, () { + _runCase(testCase); + }); + } + }); +} + +void _runCase(Map testCase) { + // The suite is shared across cases, so deep copy before mutating. + final model = DataModel( + _deepCopy(testCase['initial']) ?? {}, + ); + addTearDown(model.dispose); + + final watched = <_Observer>[]; + final List watchPaths = + (testCase['watch'] as List?) ?? const []; + for (final path in watchPaths) { + watched.add(_Observer(model, path! as String)); + } + + final steps = testCase['steps']! as List; + for (var i = 0; i < steps.length; i++) { + final step = steps[i]! as Map; + final reason = '${testCase['name']} step $i (${step['op']})'; + for (final observer in watched) { + observer.resetCount(); + } + + final Object? expectError = step['expect_error']; + if (expectError != null) { + expect( + () => _applyOp(model, step), + throwsA(_matchesError(expectError as Map)), + reason: reason, + ); + continue; + } + + _applyOp(model, step, observers: watched, reason: reason); + _checkNotifications(step, watched, reason); + _checkWatchedValues(step, watched, reason); + } +} + +void _applyOp( + DataModel model, + Map step, { + List<_Observer> observers = const [], + String reason = '', +}) { + final op = step['op']! as String; + switch (op) { + case 'get': + final Object? actual = model.get(step['path']! as String); + if (step['expect_absent'] == true) { + expect(actual, isNull, reason: reason); + } + if (step.containsKey('expect_type')) { + expect( + actual, + step['expect_type'] == 'list' + ? isA>() + : isA>(), + reason: reason, + ); + } + if (step.containsKey('expect')) { + expect(actual, equals(step['expect']), reason: reason); + } + case 'set': + model.set(step['path']! as String, step['value']); + case 'delete': + // Dart has no `undefined`; writing null removes the key. + model.set(step['path']! as String, null); + case 'dispose': + model.dispose(); + default: + fail('Unknown data_model op: $op'); + } +} + +void _checkNotifications( + Map step, + List<_Observer> observers, + String reason, +) { + final Object? expected = step['expect_notified']; + if (expected == null) return; + final List expectedPaths = (expected as List).cast(); + final notified = [ + for (final _Observer observer in observers) + for (var i = 0; i < observer.changeCount; i++) observer.path, + ]; + expect( + notified..sort(), + equals([...expectedPaths]..sort()), + reason: '$reason: notified observers', + ); +} + +void _checkWatchedValues( + Map step, + List<_Observer> observers, + String reason, +) { + final Object? expected = step['expect_values']; + if (expected == null) return; + (expected as Map).forEach((path, value) { + final _Observer observer = observers.firstWhere( + (o) => o.path == path, + orElse: () => fail('$reason: $path is not watched'), + ); + expect(observer.signal.value, equals(value), reason: '$reason: $path'); + }); +} + +Matcher _matchesError(Map expectError) { + final category = expectError['category'] as String?; + final message = expectError['message'] as String?; + Matcher matcher = switch (category) { + 'DataError' => isA(), + 'ValidationError' => isA(), + 'CatalogError' => isA(), + 'IntegrityError' => isA(), + 'RecursionError' => isA(), + 'ParseError' => isA(), + 'CompileError' => isA(), + _ => isA(), + }; + if (message != null) { + matcher = allOf( + matcher, + predicate((Object? e) { + return RegExp(message).hasMatch(e.toString()); + }, 'message matching /$message/'), + ); + } + return matcher; +} + +/// Deep copies plain maps, lists and scalars parsed from a conformance suite. +Object? _deepCopy(Object? value) { + if (value is Map) { + return { + for (final MapEntry entry in value.entries) + entry.key.toString(): _deepCopy(entry.value), + }; + } + if (value is List) { + return [for (final Object? item in value) _deepCopy(item)]; + } + return value; +} + +/// A single observer attached to one path of a [DataModel]. +class _Observer { + final String path; + final ReadonlySignal signal; + int _count = 0; + + _Observer(DataModel model, this.path) : signal = model.watch(path) { + signal.subscribe((_) => _count++); + // preact_signals calls back on subscribe; that is not a change. + _count = 0; + } + + int get changeCount => _count; + + void resetCount() => _count = 0; +} diff --git a/dart/a2ui_core/test/conformance/validator_conformance_test.dart b/dart/a2ui_core/test/conformance/validator_conformance_test.dart new file mode 100644 index 0000000000..26823e6805 --- /dev/null +++ b/dart/a2ui_core/test/conformance/validator_conformance_test.dart @@ -0,0 +1,184 @@ +// Copyright 2024 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 'dart:convert'; +import 'dart:io'; + +import 'package:a2ui_core/a2ui_core.dart'; +import 'package:test/test.dart'; + +import 'conformance_harness.dart'; + +/// Runs the shared `conformance/core/validator.yaml` suite against +/// [A2uiValidator]. +/// +/// Cases targeting a protocol version this SDK does not implement are skipped +/// with a reason, so the suite doubles as the implementation checklist. +void main() { + final List> cases = loadConformanceSuite( + 'core/validator.yaml', + ); + + group('conformance core/validator.yaml', () { + test('suite is not empty', () => expect(cases, isNotEmpty)); + + for (final testCase in cases) { + test( + testCase['name']! as String, + () => _runCase(testCase), + skip: _skipReason(testCase), + ); + } + }); +} + +/// Why a case cannot run yet, or null when it can. +String? _skipReason(Map testCase) { + final String? version = caseVersion(testCase); + if (version != null && version != '0.9') { + return 'Targets protocol v$version; this SDK implements v0.9 only.'; + } + return null; +} + +void _runCase(Map testCase) { + final config = testCase['catalog']! as Map; + final Map catalogDocument = _document( + config['catalog_schema'], + ); + final Map? commonTypes = + config.containsKey('common_types_schema') + ? _document(config['common_types_schema']) + : null; + + for (final Map step in _steps(testCase)) { + final List> payload = + (step['payload']! as List).cast>(); + // A fresh validator per step, as the reference Python harness does: each + // step is an independent payload, not a continuation of the previous one. + final A2uiValidator validator = + A2uiValidator( + catalogs: _catalogsFor(catalogDocument, payload), + commonTypesSchema: commonTypes, + ); + + final Object? expectError = + step['expect_error'] ?? testCase['expect_error']; + if (expectError != null) { + expect( + validator.validate(payload), + throwsA(_matchesError(expectError)), + reason: testCase['name'] as String?, + ); + } else { + expect( + validator.validate(payload), + completes, + reason: testCase['name'] as String?, + ); + } + } +} + +/// The steps a case runs, whether it declares one payload or several. +List> _steps(Map testCase) { + final Object? steps = testCase['steps']; + if (steps is List) return steps.cast>(); + return [testCase]; +} + +/// Reads a catalog or common-types document, inline or by path. +Map _document(Object? value) { + if (value is Map) return value; + if (value is String) { + final file = File(resolveConformancePath(value)); + if (!file.existsSync()) { + throw StateError('Conformance schema not found: ${file.path}'); + } + return jsonDecode(file.readAsStringSync()) as Map; + } + throw StateError('Case declares no catalog schema.'); +} + +/// Builds the catalogs a payload needs from the one document a case declares. +/// +/// The suite's fixtures name the catalog `standard` in the document but `std` +/// in the payloads that use it. A validator that indexes catalogs by id would +/// reject those payloads outright, which is not what these cases are testing — +/// they are about the component graph. So the document is registered under +/// every id the payload actually names, and the unknown-catalog check keeps +/// its own coverage in `validator_test.dart`. +List _catalogsFor( + Map document, + List> payload, +) { + final ids = {document['catalogId'] as String? ?? 'standard'}; + for (final envelope in payload) { + final Object? body = envelope['createSurface']; + if (body is Map && body['catalogId'] is String) { + ids.add(body['catalogId']! as String); + } + } + return [ + for (final String id in ids) + Catalog.fromJson({...document, 'catalogId': id}), + ]; +} + +/// Matches the error a case expects, by category and message. +/// +/// `details` is not asserted. It carries the field path and code a Pydantic +/// model reports, which this SDK does not model; the category and message +/// pin the same behaviour. +Matcher _matchesError(Object? expectError) { + if (expectError is String) { + return _messageMatches(expectError); + } + final Map expected = (expectError! as Map) + .cast(); + final Matcher category = _categoryMatches(expected['category'] as String?); + final Object? message = expected['message']; + if (message is! String) return category; + return allOf(category, _messageMatches(message)); +} + +Matcher _categoryMatches(String? category) => switch (category) { + 'ParseError' => isA(), + 'ValidationError' => isA(), + 'CatalogError' => isA(), + 'IntegrityError' => isA(), + 'RecursionError' => isA(), + 'CompileError' => isA(), + 'DataError' => isA(), + 'StateError' => isA(), + _ => isA(), +}; + +Matcher _messageMatches(String pattern) => isA().having( + (e) => e.message, + 'message', + matches(RegExp(_align(pattern))), +); + +/// Widens a case's expected message to the wording this SDK uses. +/// +/// The suite spells some messages the way the Python SDK's JSON Schema +/// library reports them. The reference harness does the same alignment for +/// Pydantic's wording; this is the Dart column of the same table. +String _align(String pattern) { + if (pattern.contains('is not of type')) { + return '($pattern|is not of type)'; + } + return pattern; +} diff --git a/dart/a2ui_core/test/messages_test.dart b/dart/a2ui_core/test/messages_test.dart index 44535446f5..0d22e39ac5 100644 --- a/dart/a2ui_core/test/messages_test.dart +++ b/dart/a2ui_core/test/messages_test.dart @@ -144,6 +144,66 @@ void main() { ); }); + test('throws when a required body field is missing', () { + // Reported as a validation error rather than left to fail as a cast: a + // malformed envelope is a payload defect, not a programming error. + expect( + () => A2uiMessage.fromJson({ + 'version': 'v0.9', + 'createSurface': {'surfaceId': 's1'}, + }), + throwsA(isA()), + ); + expect( + () => A2uiMessage.fromJson({ + 'version': 'v0.9', + 'updateComponents': {'surfaceId': 's1'}, + }), + throwsA(isA()), + ); + }); + + test('throws when a body field has the wrong type', () { + expect( + () => A2uiMessage.fromJson({ + 'version': 'v0.9', + 'createSurface': {'surfaceId': 123, 'catalogId': 'c1'}, + }), + throwsA(isA()), + ); + expect( + () => A2uiMessage.fromJson({ + 'version': 'v0.9', + 'updateComponents': {'surfaceId': 's1', 'components': 'nope'}, + }), + throwsA(isA()), + ); + expect( + () => A2uiMessage.fromJson({ + 'version': 'v0.9', + 'updateComponents': { + 'surfaceId': 's1', + 'components': ['nope'], + }, + }), + throwsA(isA()), + ); + expect( + () => A2uiMessage.fromJson({ + 'version': 'v0.9', + 'updateDataModel': {'surfaceId': 's1', 'path': 7}, + }), + throwsA(isA()), + ); + }); + + test('throws when the message body is not an object', () { + expect( + () => A2uiMessage.fromJson({'version': 'v0.9', 'deleteSurface': 's1'}), + throwsA(isA()), + ); + }); + test('throws when more than one message type is present', () { expect( () => A2uiMessage.fromJson({ diff --git a/dart/a2ui_core/test/protocol_version_test.dart b/dart/a2ui_core/test/protocol_version_test.dart new file mode 100644 index 0000000000..635531114b --- /dev/null +++ b/dart/a2ui_core/test/protocol_version_test.dart @@ -0,0 +1,83 @@ +// Copyright 2024 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 'package:a2ui_core/a2ui_core.dart'; +import 'package:test/test.dart'; + +void main() { + group('A2uiProtocolVersion', () { + test('exposes v0.9 as its wire value', () { + expect(A2uiProtocolVersion.v0_9.jsonValue, 'v0.9'); + }); + + test('implements exactly one version', () { + expect(A2uiProtocolVersion.values, [A2uiProtocolVersion.v0_9]); + expect(A2uiProtocolVersion.supportedVersions, "'v0.9'"); + }); + + test('parses the supported version', () { + expect(A2uiProtocolVersion.fromJson('v0.9'), A2uiProtocolVersion.v0_9); + }); + + test('rejects an unspecified version', () { + expect( + () => A2uiProtocolVersion.fromJson(null), + throwsA( + isA().having( + (e) => e.message, + 'message', + contains("must declare a 'version' field"), + ), + ), + ); + }); + + test('rejects a version that is not a string', () { + expect( + () => A2uiProtocolVersion.fromJson(123), + throwsA(isA()), + ); + }); + + test('rejects earlier and later protocol versions', () { + for (final version in ['v0.8', 'v1.0', 'v0.9.1', '0.9', '']) { + expect( + () => A2uiProtocolVersion.fromJson(version), + throwsA( + isA().having( + (e) => e.message, + 'message', + contains('Unsupported A2UI protocol version'), + ), + ), + reason: version, + ); + } + }); + + test('carries the offending payload as error details', () { + final payload = {'version': 'v1.0'}; + expect( + () => A2uiProtocolVersion.fromJson('v1.0', details: payload), + throwsA( + isA().having( + (e) => e.details, + 'details', + same(payload), + ), + ), + ); + }); + }); +} diff --git a/dart/a2ui_core/test/renderer_capabilities_test.dart b/dart/a2ui_core/test/renderer_capabilities_test.dart new file mode 100644 index 0000000000..3b9774487e --- /dev/null +++ b/dart/a2ui_core/test/renderer_capabilities_test.dart @@ -0,0 +1,154 @@ +// Copyright 2024 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 'package:a2ui_core/a2ui_core.dart'; +import 'package:test/test.dart'; + +void main() { + group('A2uiVersionCapabilities', () { + test('parses supported catalog ids', () { + final caps = A2uiVersionCapabilities.fromJson({ + 'supportedCatalogIds': ['a', 'b'], + }); + + expect(caps.supportedCatalogIds, ['a', 'b']); + expect(caps.inlineCatalogs, isEmpty); + }); + + test('parses inline catalogs into schema catalogs', () { + final caps = A2uiVersionCapabilities.fromJson({ + 'supportedCatalogIds': [], + 'inlineCatalogs': [ + { + 'catalogId': 'inline', + 'components': { + 'Gauge': {'type': 'object'}, + }, + }, + ], + }); + + expect(caps.inlineCatalogs, hasLength(1)); + expect(caps.inlineCatalogs.single.id, 'inline'); + expect(caps.inlineCatalogs.single.components.keys, ['Gauge']); + }); + + test('rejects an inline catalog that is not an object', () { + for (final malformed in [null, 'nope', 42, []]) { + expect( + () => A2uiVersionCapabilities.fromJson({ + 'supportedCatalogIds': [], + 'inlineCatalogs': [malformed], + }), + throwsA( + isA().having( + (e) => e.message, + 'message', + contains('inlineCatalogs'), + ), + ), + reason: '$malformed', + ); + } + }); + + test('rejects missing or malformed supportedCatalogIds', () { + expect( + () => A2uiVersionCapabilities.fromJson({}), + throwsA(isA()), + ); + expect( + () => A2uiVersionCapabilities.fromJson({ + 'supportedCatalogIds': [1, 2], + }), + throwsA(isA()), + ); + }); + + test('round trips through JSON', () { + final caps = A2uiVersionCapabilities(supportedCatalogIds: ['a']); + expect(caps.toJson(), { + 'supportedCatalogIds': ['a'], + }); + }); + }); + + group('A2uiRendererCapabilities', () { + test('parses a v0.9 capabilities object', () { + final caps = A2uiRendererCapabilities.fromJson({ + 'v0.9': { + 'supportedCatalogIds': ['basic'], + }, + }); + + expect(caps.v0_9.supportedCatalogIds, ['basic']); + expect(caps.unsupportedVersions, isEmpty); + }); + + test('records version entries this SDK does not implement', () { + final caps = A2uiRendererCapabilities.fromJson({ + 'v0.9': { + 'supportedCatalogIds': ['basic'], + }, + 'v1.0': { + 'supportedCatalogIds': ['basic'], + }, + }); + + expect(caps.unsupportedVersions, ['v1.0']); + }); + + test('rejects capabilities carrying no v0.9 entry', () { + expect( + () => A2uiRendererCapabilities.fromJson({ + 'v1.0': { + 'supportedCatalogIds': ['basic'], + }, + }), + throwsA( + isA().having( + (e) => e.message, + 'message', + contains('v0.9'), + ), + ), + ); + expect( + () => A2uiRendererCapabilities.fromJson({}), + throwsA(isA()), + ); + }); + + test('builds capabilities from a list of catalog ids', () { + final caps = A2uiRendererCapabilities.forCatalogIds(['basic']); + expect(caps.v0_9.supportedCatalogIds, ['basic']); + }); + + test('resolves capabilities for a supported version', () { + final caps = A2uiRendererCapabilities.forCatalogIds(['basic']); + expect(caps.forVersion(A2uiProtocolVersion.v0_9).supportedCatalogIds, [ + 'basic', + ]); + }); + + test('round trips through JSON', () { + final json = { + 'v0.9': { + 'supportedCatalogIds': ['basic'], + }, + }; + expect(A2uiRendererCapabilities.fromJson(json).toJson(), json); + }); + }); +} diff --git a/dart/a2ui_core/test/validator_basic_catalog_test.dart b/dart/a2ui_core/test/validator_basic_catalog_test.dart new file mode 100644 index 0000000000..94db661091 --- /dev/null +++ b/dart/a2ui_core/test/validator_basic_catalog_test.dart @@ -0,0 +1,262 @@ +// Copyright 2024 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 'dart:convert'; +import 'dart:io'; + +import 'package:a2ui_core/a2ui_core.dart'; +import 'package:test/test.dart'; + +import 'conformance/conformance_harness.dart'; + +/// Exercises `A2uiValidator` against the published basic catalog and the +/// example payloads that ship with it, rather than against a catalog written +/// for the test. Those examples are the specification's own statement of what +/// a valid v0.9 payload looks like, so they are the sharpest available check +/// that validation is neither too strict nor too permissive. + +const String basicCatalogId = + 'https://a2ui.org/specification/v0_9/catalogs/basic/catalog.json'; + +Map _readJson(String relativePath) => + jsonDecode(File(resolveConformancePath(relativePath)).readAsStringSync()) + as Map; + +Map basicCatalogDocument() => + _readJson('../specification/v0_9_1/catalogs/basic/catalog.json'); + +Map commonTypesDocument() => + _readJson('../specification/v0_9/json/common_types.json'); + +A2uiValidator basicValidator() => + A2uiValidator( + catalogs: [Catalog.fromJson(basicCatalogDocument())], + commonTypesSchema: commonTypesDocument(), + ); + +/// A payload declaring one surface against the basic catalog. +List> render(List> components) => [ + { + 'version': 'v0.9', + 'createSurface': {'surfaceId': 's', 'catalogId': basicCatalogId}, + }, + { + 'version': 'v0.9', + 'updateComponents': {'surfaceId': 's', 'components': components}, + }, +]; + +void main() { + group('the basic catalog', () { + test('declares the child references of its layout components', () { + final SchemaCatalog catalog = Catalog.fromJson(basicCatalogDocument()); + final Map refs = extractComponentRefFields( + catalog, + ); + + expect(refs['Card']!.single, {'child'}); + expect(refs['Button']!.single, {'child'}); + expect(refs['Modal']!.single, {'trigger', 'content'}); + expect(refs['Row']!.list, {'children'}); + expect(refs['Column']!.list, {'children'}); + expect(refs['List']!.list, {'children'}); + expect(refs['Tabs']!.list, {'tabs'}); + expect(refs['Tabs']!.nested, { + 'tabs': {'child'}, + }); + // Components that reference nothing are absent, not empty entries. + expect(refs.keys, isNot(contains('Text'))); + expect(refs.keys, isNot(contains('Image'))); + }); + }); + + group('validating the basic catalog examples', () { + final examples = Directory( + resolveConformancePath('../specification/v0_9_1/catalogs/basic/examples'), + ); + final List files = examples.listSync().whereType().where((f) { + return f.path.endsWith('.json'); + }).toList()..sort((a, b) => a.path.compareTo(b.path)); + + test('the examples are present', () { + expect(files, isNotEmpty, reason: examples.path); + }); + + for (final file in files) { + final String name = file.uri.pathSegments.last; + test(name, () async { + final Object? document = jsonDecode(file.readAsStringSync()); + final Object? messages = document is Map + ? document['messages'] + : document; + expect( + messages, + isA>(), + reason: '$name declares no message list', + ); + final List> payload = [ + for (final Object? message in messages! as List) + (message! as Map).cast(), + ]; + + await expectLater(basicValidator().validate(payload), completes); + }); + } + }); + + group('validating against the basic catalog rejects', () { + late A2uiValidator validator; + + setUp(() => validator = basicValidator()); + + test('a component missing a required property', () { + expect( + validator.validate( + render([ + {'id': 'root', 'component': 'Text'}, + ]), + ), + throwsA(isA()), + ); + }); + + test('a value outside a property enum', () { + expect( + validator.validate( + render([ + {'id': 'root', 'component': 'Text', 'text': 'hi', 'variant': 'h9'}, + ]), + ), + throwsA(isA()), + ); + }); + + test('a property the component does not declare', () { + expect( + validator.validate( + render([ + { + 'id': 'root', + 'component': 'Text', + 'text': 'hi', + 'notAProperty': 1, + }, + ]), + ), + throwsA(isA()), + ); + }); + + test('a component type the catalog does not declare', () { + expect( + validator.validate( + render([ + {'id': 'root', 'component': 'Frobnicator'}, + ]), + ), + throwsA(isA()), + ); + }); + + test('a call to a function the catalog does not declare', () { + // `anyFunction` is reached through `common_types.json`, which points + // back at the catalog document, so resolving it in both directions is + // what makes this check possible. + expect( + validator.validate( + render([ + { + 'id': 'root', + 'component': 'Text', + 'text': { + 'call': 'noSuchFunction', + 'args': {}, + 'returnType': 'string', + }, + }, + ]), + ), + throwsA(isA()), + ); + }); + + test('a child reference that names no component', () { + expect( + validator.validate( + render([ + {'id': 'root', 'component': 'Card', 'child': 'missing'}, + ]), + ), + throwsA(isA()), + ); + }); + + test('a malformed child list', () { + expect( + validator.validate( + render([ + { + 'id': 'root', + 'component': 'Column', + // A template needs `path` as well as `componentId`. + 'children': {'componentId': 'a'}, + }, + {'id': 'a', 'component': 'Text', 'text': 'x'}, + ]), + ), + throwsA(isA()), + ); + }); + }); + + group('validating against the basic catalog accepts', () { + late A2uiValidator validator; + + setUp(() => validator = basicValidator()); + + test('a data binding in place of a literal', () { + expect( + validator.validate( + render([ + { + 'id': 'root', + 'component': 'Text', + 'text': {'path': '/greeting'}, + }, + ]), + ), + completes, + ); + }); + + test('a call to a function the catalog declares', () { + expect( + validator.validate( + render([ + { + 'id': 'root', + 'component': 'Text', + 'text': { + 'call': 'formatString', + 'args': {'value': 'x'}, + 'returnType': 'string', + }, + }, + ]), + ), + completes, + ); + }); + }); +} diff --git a/dart/a2ui_core/test/validator_test.dart b/dart/a2ui_core/test/validator_test.dart new file mode 100644 index 0000000000..9cbfd80769 --- /dev/null +++ b/dart/a2ui_core/test/validator_test.dart @@ -0,0 +1,857 @@ +// Copyright 2024 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 'package:a2ui_core/a2ui_core.dart'; +import 'package:test/test.dart'; + +const String catalogId = 'https://example.com/catalogs/test.json'; + +/// The pointers a catalog document uses to mark child references, spelled the +/// way the shared conformance suites spell them. +const String componentIdRef = + 'https://a2ui.org/specification/v0_9/common_types.json#/\$defs/ComponentId'; +const String childListRef = + 'https://a2ui.org/specification/v0_9/common_types.json#/\$defs/ChildList'; + +Map createSurface({String version = 'v0.9'}) => { + 'version': version, + 'createSurface': {'surfaceId': 's1', 'catalogId': catalogId}, +}; + +Map updateComponents(List> components) => + { + 'version': 'v0.9', + 'updateComponents': {'surfaceId': 's1', 'components': components}, + }; + +/// A catalog exercising every way a component can reference another: a single +/// id, a `ChildList`, and an array of objects with id-bearing keys. +SchemaCatalog testCatalog() => Catalog.fromJson({ + 'catalogId': catalogId, + 'components': { + 'Card': { + 'type': 'object', + 'properties': { + 'component': {'const': 'Card'}, + 'child': {r'$ref': componentIdRef}, + }, + 'required': ['component'], + }, + 'Text': { + 'type': 'object', + 'properties': { + 'component': {'const': 'Text'}, + 'text': {'type': 'string'}, + }, + 'required': ['component', 'text'], + }, + 'Column': { + 'type': 'object', + 'properties': { + 'component': {'const': 'Column'}, + 'children': {r'$ref': childListRef}, + }, + 'required': ['component', 'children'], + }, + 'Tabs': { + 'type': 'object', + 'properties': { + 'component': {'const': 'Tabs'}, + 'items': { + 'type': 'array', + 'items': { + 'type': 'object', + 'properties': { + 'label': {'type': 'string'}, + 'child': {r'$ref': componentIdRef}, + }, + }, + }, + }, + 'required': ['component'], + }, + }, +}); + +/// The parts of `common_types.json` this catalog references. +Map commonTypes() => { + r'$defs': { + 'ComponentId': {'type': 'string'}, + 'ChildList': { + 'oneOf': [ + { + 'type': 'array', + 'items': {r'$ref': '#/\$defs/ComponentId'}, + }, + { + 'type': 'object', + 'properties': { + 'componentId': {r'$ref': '#/\$defs/ComponentId'}, + 'path': {'type': 'string'}, + }, + 'required': ['componentId', 'path'], + 'additionalProperties': false, + }, + ], + }, + }, +}; + +A2uiValidator newValidator({ + bool withCommonTypes = false, +}) => A2uiValidator( + catalogs: [testCatalog()], + commonTypesSchema: withCommonTypes ? commonTypes() : null, +); + +Map text(String id, [String value = 'x']) => { + 'id': id, + 'component': 'Text', + 'text': value, +}; + +Map card(String id, String child) => { + 'id': id, + 'component': 'Card', + 'child': child, +}; + +void main() { + group('A2uiValidator version gating', () { + test('accepts payloads declaring the supported version', () { + final A2uiValidator validator = + newValidator(); + + expect(validator.checkVersion(createSurface()), A2uiProtocolVersion.v0_9); + expect(validator.parseMessages([createSurface()]), hasLength(1)); + expect( + validator.parseMessages([createSurface()]).single, + isA(), + ); + }); + + test('rejects payloads declaring another protocol version', () { + final A2uiValidator validator = + newValidator(); + + for (final version in ['v0.8', 'v0.9.1', 'v1.0']) { + expect( + () => validator.checkVersion(createSurface(version: version)), + throwsA(isA()), + reason: version, + ); + expect( + () => validator.parseMessages([createSurface(version: version)]), + throwsA(isA()), + reason: version, + ); + } + }); + + test('rejects payloads that omit the version', () { + final A2uiValidator validator = + newValidator(); + final Map> message = { + 'createSurface': {'surfaceId': 's1', 'catalogId': catalogId}, + }; + + expect( + () => validator.checkVersion(message), + throwsA(isA()), + ); + expect( + () => validator.parseMessages([message]), + throwsA(isA()), + ); + }); + + test('rejects an envelope naming no known message body', () { + final A2uiValidator validator = + newValidator(); + + expect( + () => validator.parseMessages([ + {'version': 'v0.9', 'notAMessage': {}}, + ]), + throwsA(isA()), + ); + }); + + test('is constructed for a supported version by name', () { + expect( + A2uiValidator.forVersion('v0.9').protocolVersion, + A2uiProtocolVersion.v0_9, + ); + }); + + test('cannot be constructed for an unsupported version', () { + expect( + () => A2uiValidator.forVersion('v1.0'), + throwsA(isA()), + ); + expect( + () => A2uiValidator.forVersion(null), + throwsA(isA()), + ); + }); + + test('indexes the catalogs it validates against by id', () { + final A2uiValidator validator = + newValidator(); + expect(validator.catalogs.keys, [catalogId]); + }); + }); + + group('A2uiValidator.validateStructure', () { + test('accepts a well formed component graph', () { + final A2uiValidator validator = + newValidator(); + final List messages = validator.parseMessages([ + createSurface(), + updateComponents([card('root', 'label'), text('label', 'Hello')]), + ]); + + expect(() => validator.validateStructure(messages), returnsNormally); + }); + + test('rejects duplicate component ids', () { + final A2uiValidator validator = + newValidator(); + final List messages = validator.parseMessages([ + createSurface(), + updateComponents([text('root', 'a'), text('root', 'b')]), + ]); + + expect( + () => validator.validateStructure(messages), + throwsA( + isA().having( + (e) => e.message, + 'message', + contains('Duplicate component ID: root'), + ), + ), + ); + }); + + test('rejects a child reference that names no component', () { + final A2uiValidator validator = + newValidator(); + final List messages = validator.parseMessages([ + createSurface(), + updateComponents([card('root', 'missing')]), + ]); + + expect( + () => validator.validateStructure(messages), + throwsA( + isA().having( + (e) => e.message, + 'message', + contains('references non-existent component'), + ), + ), + ); + }); + + test('rejects a payload that declares no root component', () { + final A2uiValidator validator = + newValidator(); + final List messages = validator.parseMessages([ + createSurface(), + updateComponents([text('label', 'Hello')]), + ]); + + expect( + () => validator.validateStructure(messages), + throwsA( + isA().having( + (e) => e.message, + 'message', + contains('Missing root component'), + ), + ), + ); + }); + + test('rejects a component unreachable from the root', () { + final A2uiValidator validator = + newValidator(); + final List messages = validator.parseMessages([ + createSurface(), + updateComponents([ + card('root', 'label'), + text('label', 'Hello'), + text('orphan', 'Nobody points at me'), + ]), + ]); + + expect( + () => validator.validateStructure(messages), + throwsA( + isA().having( + (e) => e.message, + 'message', + contains("Component 'orphan' is not reachable"), + ), + ), + ); + }); + + test('rejects a self reference', () { + final A2uiValidator validator = + newValidator(); + final List messages = validator.parseMessages([ + createSurface(), + updateComponents([card('root', 'root')]), + ]); + + expect( + () => validator.validateStructure(messages), + throwsA( + isA() + .having( + (e) => e.message, + 'message', + contains('Self-reference detected'), + ) + .having((e) => e.cycle, 'cycle', ['root']), + ), + ); + }); + + test('rejects a cycle in the component graph', () { + final A2uiValidator validator = + newValidator(); + final List messages = validator.parseMessages([ + createSurface(), + updateComponents([card('root', 'b'), card('b', 'root')]), + ]); + + expect( + () => validator.validateStructure(messages), + throwsA( + isA().having( + (e) => e.message, + 'message', + contains('Circular reference detected'), + ), + ), + ); + }); + + test('rejects a chain deeper than the cap', () { + final A2uiValidator validator = + newValidator(); + final components = >[card('root', 'c0')]; + const int chain = maxComponentDepth + 5; + for (var i = 0; i < chain; i++) { + components.add(card('c$i', 'c${i + 1}')); + } + components.add(text('c$chain', 'leaf')); + + final List messages = validator.parseMessages([ + createSurface(), + updateComponents(components), + ]); + + expect( + () => validator.validateStructure(messages), + throwsA( + isA().having( + (e) => e.message, + 'message', + contains('recursion limit exceeded'), + ), + ), + ); + }); + + test('follows a static child list', () { + final A2uiValidator validator = + newValidator(); + final List valid = validator.parseMessages([ + createSurface(), + updateComponents([ + { + 'id': 'root', + 'component': 'Column', + 'children': ['a', 'b'], + }, + text('a'), + text('b'), + ]), + ]); + expect(() => validator.validateStructure(valid), returnsNormally); + + final List dangling = validator.parseMessages([ + createSurface(), + updateComponents([ + { + 'id': 'root', + 'component': 'Column', + 'children': ['a', 'missing'], + }, + text('a'), + ]), + ]); + expect( + () => validator.validateStructure(dangling), + throwsA(isA()), + ); + }); + + test('follows a child list template', () { + final A2uiValidator validator = + newValidator(); + final List messages = validator.parseMessages([ + createSurface(), + updateComponents([ + { + 'id': 'root', + 'component': 'Column', + 'children': {'componentId': 'row', 'path': '/items'}, + }, + text('row'), + ]), + ]); + expect(() => validator.validateStructure(messages), returnsNormally); + + final List dangling = validator.parseMessages([ + createSurface(), + updateComponents([ + { + 'id': 'root', + 'component': 'Column', + 'children': {'componentId': 'missing', 'path': '/items'}, + }, + ]), + ]); + expect( + () => validator.validateStructure(dangling), + throwsA(isA()), + ); + }); + + test('follows references nested in an array of objects', () { + final A2uiValidator validator = + newValidator(); + final List messages = validator.parseMessages([ + createSurface(), + updateComponents([ + { + 'id': 'root', + 'component': 'Tabs', + 'items': [ + {'label': 'One', 'child': 'a'}, + {'label': 'Two', 'child': 'missing'}, + ], + }, + text('a'), + ]), + ]); + + expect( + () => validator.validateStructure(messages), + throwsA( + isA().having( + (e) => e.message, + 'message', + contains("in field 'items[1].child'"), + ), + ), + ); + }); + + test('ignores a property that does not reference components', () { + final A2uiValidator validator = + newValidator(); + // `text` is a plain string, so 'root' inside it is not a reference and + // must not read as a self-reference. + final List messages = validator.parseMessages([ + createSurface(), + updateComponents([text('root', 'root')]), + ]); + + expect(() => validator.validateStructure(messages), returnsNormally); + }); + + test('does not read a component id as a reference to itself', () { + // A catalog that inlines `ComponentCommon` declares `id` as a + // `ComponentId`. That names the component itself, so reading it as a + // child reference would make every component self-referential. + final SchemaCatalog inlined = Catalog.fromJson({ + 'catalogId': catalogId, + 'components': { + 'Card': { + 'type': 'object', + 'allOf': [ + {r'$ref': '#/\$defs/ComponentCommon'}, + { + 'type': 'object', + 'properties': { + 'component': {'const': 'Card'}, + 'child': {r'$ref': componentIdRef}, + }, + }, + ], + }, + }, + r'$defs': { + 'ComponentCommon': { + 'type': 'object', + 'properties': { + 'id': {r'$ref': componentIdRef}, + }, + 'required': ['id'], + }, + }, + }); + + expect(extractComponentRefFields(inlined)['Card']!.single, { + 'child', + }, reason: 'id must not be read as a child reference'); + + final A2uiValidator validator = + A2uiValidator(catalogs: [inlined]); + expect( + () => validator.validateStructure( + validator.parseMessages([ + createSurface(), + updateComponents([ + {'id': 'root', 'component': 'Card', 'child': 'a'}, + {'id': 'a', 'component': 'Card'}, + ]), + ]), + ), + returnsNormally, + ); + }); + + test('rejects a malformed data model path', () { + final A2uiValidator validator = + newValidator(); + final List messages = validator.parseMessages([ + { + 'version': 'v0.9', + 'updateDataModel': {'surfaceId': 's1', 'path': 'a~2b', 'value': 1}, + }, + ]); + + expect( + () => validator.validateStructure(messages), + throwsA( + isA().having( + (e) => e.message, + 'message', + contains('Invalid path syntax'), + ), + ), + ); + }); + + test('rejects function calls nested past the cap', () { + final A2uiValidator validator = + newValidator(); + Map call = {'call': 'f', 'args': {}}; + for (var i = 0; i < maxFunctionCallDepth + 1; i++) { + call = { + 'call': 'f', + 'args': {'inner': call}, + }; + } + final List messages = validator.parseMessages([ + updateComponents([ + {'id': 'root', 'component': 'Text', 'text': call}, + ]), + ]); + + expect( + () => validator.validateStructure(messages), + throwsA( + isA().having( + (e) => e.message, + 'message', + contains('functionCall depth'), + ), + ), + ); + }); + + group('incremental updates', () { + test('allow a missing root and references to existing components', () { + final A2uiValidator validator = + newValidator(); + final List messages = validator.parseMessages([ + updateComponents([card('panel', 'alreadyOnTheClient')]), + ]); + + expect(() => validator.validateStructure(messages), returnsNormally); + }); + + test('still reject duplicate ids', () { + final A2uiValidator validator = + newValidator(); + final List messages = validator.parseMessages([ + updateComponents([text('a', 'one'), text('a', 'two')]), + ]); + + expect( + () => validator.validateStructure(messages), + throwsA(isA()), + ); + }); + + test('still reject a self reference', () { + final A2uiValidator validator = + newValidator(); + final List messages = validator.parseMessages([ + updateComponents([card('a', 'a')]), + ]); + + expect( + () => validator.validateStructure(messages), + throwsA(isA()), + ); + }); + + test('still reject a cycle', () { + final A2uiValidator validator = + newValidator(); + final List messages = validator.parseMessages([ + updateComponents([card('a', 'b'), card('b', 'a')]), + ]); + + expect( + () => validator.validateStructure(messages), + throwsA(isA()), + ); + }); + }); + + test('accumulates components across updates to the same surface', () { + final A2uiValidator validator = + newValidator(); + final List messages = validator.parseMessages([ + createSurface(), + updateComponents([card('root', 'label')]), + updateComponents([text('label', 'Hello')]), + ]); + + expect(() => validator.validateStructure(messages), returnsNormally); + }); + + test('treats an id repeated in a later message as an update', () { + final A2uiValidator validator = + newValidator(); + // The second message replaces `root`, pointing it at `b` instead of + // `a`. That is how the basic catalog's `00_incremental` example swaps + // a placeholder out, so it must not read as a duplicate id — and `a`, + // now unreachable, is the residue of the replacement rather than an + // orphan. A surface declared in one message is still held to full + // reachability, which the test above covers. + final List messages = validator.parseMessages([ + createSurface(), + updateComponents([card('root', 'a'), text('a')]), + updateComponents([card('root', 'b'), text('b')]), + ]); + + expect(() => validator.validateStructure(messages), returnsNormally); + }); + + test('drops the components of a surface deleted in the same payload', () { + final A2uiValidator validator = + newValidator(); + final List messages = validator.parseMessages([ + createSurface(), + updateComponents([card('root', 'missing')]), + { + 'version': 'v0.9', + 'deleteSurface': {'surfaceId': 's1'}, + }, + ]); + + expect(() => validator.validateStructure(messages), returnsNormally); + }); + }); + + group('A2uiValidator.validateAgainstCatalogs', () { + test('accepts components that satisfy the catalog schema', () { + final A2uiValidator validator = + newValidator(); + final List messages = validator.parseMessages([ + createSurface(), + updateComponents([text('label', 'Hello')]), + ]); + + expect(validator.validateAgainstCatalogs(messages), completes); + }); + + test('rejects a component missing a required property', () { + final A2uiValidator validator = + newValidator(); + final List messages = validator.parseMessages([ + createSurface(), + updateComponents([ + {'id': 'label', 'component': 'Text'}, + ]), + ]); + + expect( + validator.validateAgainstCatalogs(messages), + throwsA(isA()), + ); + }); + + test('rejects a property of the wrong type', () { + final A2uiValidator validator = + newValidator(); + final List messages = validator.parseMessages([ + createSurface(), + updateComponents([ + {'id': 'label', 'component': 'Text', 'text': 42}, + ]), + ]); + + expect( + validator.validateAgainstCatalogs(messages), + throwsA(isA()), + ); + }); + + test('rejects a component the catalog does not declare', () { + final A2uiValidator validator = + newValidator(); + final List messages = validator.parseMessages([ + createSurface(), + updateComponents([ + {'id': 'label', 'component': 'Nonexistent'}, + ]), + ]); + + expect( + validator.validateAgainstCatalogs(messages), + throwsA( + isA().having( + (e) => e.message, + 'message', + contains('declares no component'), + ), + ), + ); + }); + + test('rejects a surface created against an unregistered catalog', () { + final A2uiValidator validator = + newValidator(); + final List messages = validator.parseMessages([ + { + 'version': 'v0.9', + 'createSurface': { + 'surfaceId': 's1', + 'catalogId': 'https://example.com/catalogs/other.json', + }, + }, + ]); + + expect( + validator.validateAgainstCatalogs(messages), + throwsA(isA()), + ); + }); + + test('enforces common_types definitions when they are supplied', () { + final A2uiValidator validator = + newValidator(withCommonTypes: true); + final List messages = validator.parseMessages([ + createSurface(), + updateComponents([ + { + 'id': 'root', + 'component': 'Column', + // Neither a list of ids nor a `{componentId, path}` template. + 'children': {'componentId': 'row'}, + }, + ]), + ]); + + expect( + validator.validateAgainstCatalogs(messages), + throwsA(isA()), + ); + }); + + test('treats an unresolvable reference as unconstrained', () { + // Without `common_types.json`, `ChildList` cannot be resolved. The + // surrounding constraints still apply, but the reference itself is + // skipped rather than failing the payload. + final A2uiValidator validator = + newValidator(); + final List messages = validator.parseMessages([ + createSurface(), + updateComponents([ + { + 'id': 'root', + 'component': 'Column', + 'children': {'componentId': 'row'}, + }, + ]), + ]); + + expect(validator.validateAgainstCatalogs(messages), completes); + }); + }); + + group('A2uiValidator.validate', () { + test('returns the parsed messages for a valid payload', () async { + final A2uiValidator validator = + newValidator(); + + final List messages = await validator.validate([ + createSurface(), + updateComponents([card('root', 'label'), text('label', 'Hello')]), + ]); + + expect(messages, hasLength(2)); + expect(messages.first, isA()); + }); + + test('rejects an unsupported version before any deep check runs', () { + final A2uiValidator validator = + newValidator(); + + expect( + validator.validate([createSurface(version: 'v1.0')]), + throwsA(isA()), + ); + }); + + test('reports a structural failure before a catalog failure', () { + final A2uiValidator validator = + newValidator(); + + // `root` is both a dangling reference and missing its required `text`. + // Structure runs first, so the integrity error is what surfaces. + expect( + validator.validate([ + createSurface(), + updateComponents([ + {'id': 'root', 'component': 'Card', 'child': 'missing'}, + ]), + ]), + throwsA(isA()), + ); + }); + }); +} diff --git a/renderers/web_core/package.json b/renderers/web_core/package.json index 99c0c62cfe..bc4dfc17e6 100644 --- a/renderers/web_core/package.json +++ b/renderers/web_core/package.json @@ -105,6 +105,7 @@ "files": [ "src/**/*.ts", "src/**/*.json", + "tests/**/*.ts", "tsconfig.json" ], "output": [ @@ -127,6 +128,7 @@ "files": [ "src/**/*.ts", "src/**/*.json", + "tests/**/*.ts", "scripts/**/*.js", "package.json", "tsconfig.json" @@ -137,6 +139,7 @@ "files": [ "src/**/*.ts", "src/**/*.json", + "tests/**/*.ts", "scripts/**/*.js", "package.json", "tsconfig.json" @@ -154,7 +157,8 @@ "gts": "^7.0.0", "rxjs": "^7.8.2", "typescript": "5.9.3", - "wireit": "^0.15.0-pre.2" + "wireit": "^0.15.0-pre.2", + "yaml": "^2.8.1" }, "dependencies": { "@preact/signals-core": "^1.14.2", diff --git a/renderers/web_core/tests/conformance/data-model.conformance.test.ts b/renderers/web_core/tests/conformance/data-model.conformance.test.ts new file mode 100644 index 0000000000..20c87cf40e --- /dev/null +++ b/renderers/web_core/tests/conformance/data-model.conformance.test.ts @@ -0,0 +1,155 @@ +/* + * Copyright 2024 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 * as assert from 'node:assert'; +import {describe, it} from 'node:test'; +import {loadConformanceSuite} from './harness.js'; +import {DataModel, type DataSubscription} from '../../src/v0_9/state/data-model.js'; + +/** + * Runs the shared `conformance/core/data_model.yaml` suite against `DataModel`. + * + * One mapping is worth calling out: `op: delete` maps to `set(path, undefined)`, + * because this implementation removes a key when its value becomes `undefined`. + * + * Behaviour that cannot be expressed as a shared, cross-language dataset stays + * in `src/v0_9/state/data-model.test.ts`. + */ + +interface ConformanceStep { + op: 'get' | 'set' | 'delete' | 'dispose'; + path?: string; + value?: unknown; + expect?: unknown; + expect_absent?: boolean; + expect_type?: 'list' | 'object'; + expect_values?: Record; + expect_notified?: string[]; + expect_error?: {category?: string; message?: string} | string; +} + +interface ConformanceCase { + name: string; + initial?: Record; + watch?: string[]; + steps: ConformanceStep[]; +} + +interface Observer { + path: string; + subscription: DataSubscription; + count: number; +} + +function deepCopy(value: T): T { + return value === undefined ? value : (JSON.parse(JSON.stringify(value)) as T); +} + +function errorMessagePattern(expectError: ConformanceStep['expect_error']): RegExp { + const message = typeof expectError === 'string' ? expectError : (expectError?.message ?? ''); + return new RegExp(message); +} + +function applyOp(model: DataModel, step: ConformanceStep, reason: string): void { + switch (step.op) { + case 'get': { + const actual = model.get(step.path!); + if (step.expect_absent === true) { + assert.strictEqual(actual, undefined, reason); + } + if (step.expect_type !== undefined) { + assert.strictEqual(Array.isArray(actual) ? 'list' : 'object', step.expect_type, reason); + } + if ('expect' in step) { + assert.deepStrictEqual(actual, step.expect, reason); + } + break; + } + case 'set': + model.set(step.path!, step.value); + break; + case 'delete': + // This implementation removes a key when its value becomes `undefined`. + model.set(step.path!, undefined); + break; + case 'dispose': + model.dispose(); + break; + default: + throw new Error(`Unknown data_model op: ${String(step.op)}`); + } +} + +function runCase(testCase: ConformanceCase): void { + // The suite is parsed once and shared across cases, so the initial data is + // deep copied before the model mutates it. + const model = new DataModel(deepCopy(testCase.initial) ?? {}); + const observers: Observer[] = (testCase.watch ?? []).map(path => { + const observer: Observer = {path, count: 0, subscription: undefined!}; + observer.subscription = model.subscribe(path, () => observer.count++); + return observer; + }); + + testCase.steps.forEach((step, index) => { + const reason = `${testCase.name} step ${index} (${step.op})`; + for (const observer of observers) { + observer.count = 0; + } + + if (step.expect_error !== undefined) { + assert.throws( + () => applyOp(model, step, reason), + errorMessagePattern(step.expect_error), + reason, + ); + return; + } + + applyOp(model, step, reason); + + if (step.expect_notified !== undefined) { + const notified: string[] = []; + for (const observer of observers) { + for (let i = 0; i < observer.count; i++) { + notified.push(observer.path); + } + } + assert.deepStrictEqual( + notified.sort(), + [...step.expect_notified].sort(), + `${reason}: notified observers`, + ); + } + + for (const [path, expected] of Object.entries(step.expect_values ?? {})) { + const observer = observers.find(o => o.path === path); + assert.ok(observer, `${reason}: ${path} is not watched`); + assert.deepStrictEqual(observer.subscription.value, expected, `${reason}: ${path}`); + } + }); +} + +describe('conformance core/data_model.yaml', () => { + const cases = loadConformanceSuite('core/data_model.yaml'); + + it('suite is not empty', () => { + assert.ok(cases.length > 0); + }); + + for (const testCase of cases) { + it(testCase.name, () => runCase(testCase)); + } +}); diff --git a/renderers/web_core/tests/conformance/harness.ts b/renderers/web_core/tests/conformance/harness.ts new file mode 100644 index 0000000000..f222f7d370 --- /dev/null +++ b/renderers/web_core/tests/conformance/harness.ts @@ -0,0 +1,63 @@ +/* + * Copyright 2024 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 {existsSync, readFileSync} from 'node:fs'; +import * as path from 'node:path'; +import {fileURLToPath} from 'node:url'; +import {parse as parseYaml} from 'yaml'; + +/** + * Locates the repository's `conformance/` directory. + * + * Walks up from this module so the suites load whether the tests run from + * `src/` or from the compiled output in `dist/`. + */ +function conformanceRoot(): string { + let dir = path.dirname(fileURLToPath(import.meta.url)); + for (;;) { + const candidate = path.join(dir, 'conformance'); + if (existsSync(path.join(candidate, 'conformance_schema.json'))) { + return candidate; + } + const parent = path.dirname(dir); + if (parent === dir) { + throw new Error(`Could not locate the conformance/ directory above ${dir}.`); + } + dir = parent; + } +} + +/** + * Resolves a path from a conformance case against the `conformance/` directory. + */ +export function resolveConformancePath(relativePath: string): string { + return path.join(conformanceRoot(), relativePath); +} + +/** + * Loads a conformance suite, for example `core/data_model.yaml`. + */ +export function loadConformanceSuite>(suite: string): T[] { + const file = resolveConformancePath(suite); + if (!existsSync(file)) { + throw new Error(`Conformance suite not found: ${file}`); + } + const parsed = parseYaml(readFileSync(file, 'utf8')); + if (!Array.isArray(parsed)) { + throw new Error(`Conformance suite ${suite} must be a list of cases.`); + } + return parsed as T[]; +} diff --git a/renderers/web_core/tsconfig.json b/renderers/web_core/tsconfig.json index 26703b1c5a..643c063674 100644 --- a/renderers/web_core/tsconfig.json +++ b/renderers/web_core/tsconfig.json @@ -40,5 +40,5 @@ "strictTemplates": true, "strictPropertyInitialization": false }, - "include": ["src/**/*.ts", "src/**/*.json"] + "include": ["src/**/*.ts", "src/**/*.json", "tests/**/*.ts"] } diff --git a/yarn.lock b/yarn.lock index 5f247863fe..d8de83208c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -469,6 +469,7 @@ __metadata: rxjs: "npm:^7.8.2" typescript: "npm:5.9.3" wireit: "npm:^0.15.0-pre.2" + yaml: "npm:^2.8.1" zod: "npm:^3.25.76" zod-to-json-schema: "npm:^3.25.2" languageName: unknown From 1d7c91ee1cb4230009ed737b1a42d31a2e18d52f Mon Sep 17 00:00:00 2001 From: Polina Cherkasova Date: Fri, 28 Aug 2026 11:55:57 -0700 Subject: [PATCH 19/22] Correct the claim that only agents narrow catalogs A renderer can need a smaller catalog for a given use case and derives one the same way. What is agent-owned is the named transformer rules and the config pipeline that applies them, not narrowing itself -- which is the actual reason their conformance data belongs under agent/. --- blueprints/modules/a2ui_core.blueprint.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/blueprints/modules/a2ui_core.blueprint.md b/blueprints/modules/a2ui_core.blueprint.md index b5669a3d34..4c1a6746df 100644 --- a/blueprints/modules/a2ui_core.blueprint.md +++ b/blueprints/modules/a2ui_core.blueprint.md @@ -35,7 +35,7 @@ Its core responsibilities include: Core does **not** own: -- **Catalog narrowing.** A `Catalog` is an immutable value object. Pruning components or functions to an allowlist is prompt engineering — it belongs to the catalog transformers in the [a2ui_agent blueprint](./a2ui_agent.blueprint.md). +- **Named catalog transformers.** Core keeps `Catalog` immutable and offers the derivation any caller needs to build a narrower one. The transformer *rules* that drive it — `CatalogTransformer`, `ComponentPruningTransformer`, `FunctionPruningTransformer` — and the config pipeline that applies them are specified in the [a2ui_agent blueprint](./a2ui_agent.blueprint.md), so their conformance data belongs under `conformance/agent/`. Deriving a narrowed catalog is not itself agent-only: a renderer may need a smaller catalog for a given use case, and uses the same immutable derivation to get one. - **Prompt generation, response parsing, and capability negotiation.** All three are agent concerns, and none of them belongs here even when the type they operate on is a core type. When implementing `a2ui_agent` requires a change here — a shared type, a widened generic, a missing error class — make the smallest change that unblocks it, and land it as its own reviewable unit. Core is consumed by every renderer, so a change made for one agent SDK is a change made for all of them. @@ -204,9 +204,9 @@ export interface Catalog Date: Fri, 28 Aug 2026 11:56:28 -0700 Subject: [PATCH 20/22] Drop a vague qualifier and fix formatting 'a capabilities payload above all' said nothing precise; it is just an example, so name it as one. Also applies prettier, which the previous commit skipped. --- blueprints/modules/a2ui_core.blueprint.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/blueprints/modules/a2ui_core.blueprint.md b/blueprints/modules/a2ui_core.blueprint.md index 4c1a6746df..f4822f8a62 100644 --- a/blueprints/modules/a2ui_core.blueprint.md +++ b/blueprints/modules/a2ui_core.blueprint.md @@ -35,7 +35,7 @@ Its core responsibilities include: Core does **not** own: -- **Named catalog transformers.** Core keeps `Catalog` immutable and offers the derivation any caller needs to build a narrower one. The transformer *rules* that drive it — `CatalogTransformer`, `ComponentPruningTransformer`, `FunctionPruningTransformer` — and the config pipeline that applies them are specified in the [a2ui_agent blueprint](./a2ui_agent.blueprint.md), so their conformance data belongs under `conformance/agent/`. Deriving a narrowed catalog is not itself agent-only: a renderer may need a smaller catalog for a given use case, and uses the same immutable derivation to get one. +- **Named catalog transformers.** Core keeps `Catalog` immutable and offers the derivation any caller needs to build a narrower one. The transformer _rules_ that drive it — `CatalogTransformer`, `ComponentPruningTransformer`, `FunctionPruningTransformer` — and the config pipeline that applies them are specified in the [a2ui_agent blueprint](./a2ui_agent.blueprint.md), so their conformance data belongs under `conformance/agent/`. Deriving a narrowed catalog is not itself agent-only: a renderer may need a smaller catalog for a given use case, and uses the same immutable derivation to get one. - **Prompt generation, response parsing, and capability negotiation.** All three are agent concerns, and none of them belongs here even when the type they operate on is a core type. When implementing `a2ui_agent` requires a change here — a shared type, a widened generic, a missing error class — make the smallest change that unblocks it, and land it as its own reviewable unit. Core is consumed by every renderer, so a change made for one agent SDK is a change made for all of them. @@ -206,7 +206,7 @@ export interface Catalog Date: Fri, 28 Aug 2026 11:58:28 -0700 Subject: [PATCH 21/22] Correct the claim that only agents narrow catalogs (agent side) Follows 562329b7 on dart-a2ui-core. A renderer can need a smaller catalog for a given use case and derives one the same way. What the agent SDK owns is the named transformer rules and the CatalogConfig pipeline, which is the actual reason their cases live under agent/. --- blueprints/modules/a2ui_agent.blueprint.md | 2 +- conformance/agent/catalog_transformer.yaml | 12 +++++++----- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/blueprints/modules/a2ui_agent.blueprint.md b/blueprints/modules/a2ui_agent.blueprint.md index 3b5a1f0700..161bd7ed66 100644 --- a/blueprints/modules/a2ui_agent.blueprint.md +++ b/blueprints/modules/a2ui_agent.blueprint.md @@ -156,7 +156,7 @@ class FunctionPruningTransformer(CatalogTransformer): #### Transformer Requirements -**Transformation is agent-owned.** A `Catalog` from `a2ui_core` is an immutable value object and core never narrows one: a renderer implements every component it advertises, so only an agent has a reason to prompt against a subset. Do not add pruning to `a2ui_core`, and do not file transformer conformance data under `conformance/core/` — it belongs in `agent/catalog_transformer.yaml` (see section 6). +**These transformer rules are agent SDK surface.** A `Catalog` from `a2ui_core` is immutable, and core offers the derivation any caller needs to build a narrower one: deriving a smaller catalog is not agent-only, since a renderer may need one for a given use case. What this blueprint owns is the named rules above and the `CatalogConfig` pipeline that applies them, so their conformance data belongs in `agent/catalog_transformer.yaml` (see section 6) rather than under `conformance/core/`. **Narrow the unions, not just the entries.** A catalog document declares its component and function unions under `$defs/anyComponent` and `$defs/anyFunction`, whose members `$ref` entries in `components` and `functions`. A transformer that drops an entry MUST drop the matching union member in the same pass. Leaving the `$ref` behind is not merely untidy: the pruned document still advertises to the model a component the agent has decided not to accept, and it dangles a pointer that schema resolution will fail on. diff --git a/conformance/agent/catalog_transformer.yaml b/conformance/agent/catalog_transformer.yaml index df52251bb8..89cec8f884 100644 --- a/conformance/agent/catalog_transformer.yaml +++ b/conformance/agent/catalog_transformer.yaml @@ -13,11 +13,13 @@ # limitations under the License. # Behaviour of the catalog transformers described by -# `blueprints/modules/a2ui_agent.blueprint.md` section 3.A. Transformation is an -# agent concern: a renderer implements the whole catalog it advertises, while an -# agent narrows one before prompting. The `prune` cases in -# `conformance/core/catalog.yaml` predate that split and stay where the SDKs -# already read them from. +# `blueprints/modules/a2ui_agent.blueprint.md` section 3.A. Deriving a narrower +# catalog is not agent-only -- a renderer may need a smaller one for a given +# use case, and uses the same immutable derivation from `a2ui_core` to get it. +# What lives in the agent SDK is the named transformer rules and the +# `CatalogConfig` pipeline that applies them, so their cases belong here. The +# `prune` cases in `conformance/core/catalog.yaml` predate that split and stay +# where the SDKs already read them from. # # The `$defs/anyComponent` and `$defs/anyFunction` unions of a catalog document # must be narrowed alongside the entries they reference, otherwise a pruned From 01bb54535eb84a535dbb3f0fbfe1f66d21cd557b Mon Sep 17 00:00:00 2001 From: Polina Cherkasova Date: Fri, 28 Aug 2026 12:01:31 -0700 Subject: [PATCH 22/22] Itemise the core non-goals instead of explaining them The per-item prose restated what the a2ui_agent blueprint already documents. A list of what is not in core, with one pointer to where it is specified, carries the same boundary without the duplication. --- blueprints/modules/a2ui_core.blueprint.md | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/blueprints/modules/a2ui_core.blueprint.md b/blueprints/modules/a2ui_core.blueprint.md index f4822f8a62..fb40cc7584 100644 --- a/blueprints/modules/a2ui_core.blueprint.md +++ b/blueprints/modules/a2ui_core.blueprint.md @@ -31,14 +31,14 @@ Its core responsibilities include: ### Package Boundary & Non-Goals -`a2ui_agent` depends on `a2ui_core`; the dependency never runs the other way. Core models the protocol and the renderer's state machine, and must stay useful to a renderer that will never load an agent SDK. +`a2ui_agent` depends on `a2ui_core`; the dependency never runs the other way. A change here made to unblock an agent SDK should be the smallest one that works, and land as its own reviewable unit. -Core does **not** own: +Not in core — specified in the [a2ui_agent blueprint](./a2ui_agent.blueprint.md), with conformance data under `conformance/agent/`: -- **Named catalog transformers.** Core keeps `Catalog` immutable and offers the derivation any caller needs to build a narrower one. The transformer _rules_ that drive it — `CatalogTransformer`, `ComponentPruningTransformer`, `FunctionPruningTransformer` — and the config pipeline that applies them are specified in the [a2ui_agent blueprint](./a2ui_agent.blueprint.md), so their conformance data belongs under `conformance/agent/`. Deriving a narrowed catalog is not itself agent-only: a renderer may need a smaller catalog for a given use case, and uses the same immutable derivation to get one. -- **Prompt generation, response parsing, and capability negotiation.** All three are agent concerns, and none of them belongs here even when the type they operate on is a core type. - -When implementing `a2ui_agent` requires a change here — a shared type, a widened generic, a missing error class — make the smallest change that unblocks it, and land it as its own reviewable unit. Core is consumed by every renderer, so a change made for one agent SDK is a change made for all of them. +- `CatalogTransformer`, `ComponentPruningTransformer`, `FunctionPruningTransformer`, and the `CatalogConfig` pipeline that applies them +- Prompt generation +- Response parsing +- Capability negotiation ---