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/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..161bd7ed66 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 + +**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. + +**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..fb40cc7584 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. A change here made to unblock an agent SDK should be the smallest one that works, and land as its own reviewable unit. + +Not in core — specified in the [a2ui_agent blueprint](./a2ui_agent.blueprint.md), with conformance data under `conformance/agent/`: + +- `CatalogTransformer`, `ComponentPruningTransformer`, `FunctionPruningTransformer`, and the `CatalogConfig` pipeline that applies them +- Prompt generation +- Response parsing +- Capability negotiation + --- ### 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..a58d662add 100644 --- a/conformance/README.md +++ b/conformance/README.md @@ -11,12 +11,18 @@ 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/`) - `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..b3584e2d90 --- /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.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. +# +# 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..89cec8f884 --- /dev/null +++ b/conformance/agent/catalog_transformer.yaml @@ -0,0 +1,80 @@ +# 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.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 +# 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/parser.yaml b/conformance/agent/parser.yaml index ff2ea5bbea..3c4133be26 100644 --- a/conformance/agent/parser.yaml +++ b/conformance/agent/parser.yaml @@ -147,3 +147,23 @@ 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" 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..dd0a9712d8 100644 --- a/conformance/conformance_schema.json +++ b/conformance/conformance_schema.json @@ -91,7 +91,11 @@ "try_activate", "select_newest", "verify_cuttable_keys", - "accessibility_check" + "accessibility_check", + "data_model", + "process_request", + "resolve_catalogs", + "process_messages" ] } }, @@ -121,7 +125,11 @@ {"$ref": "#/$defs/TryActivateTest"}, {"$ref": "#/$defs/SelectNewestTest"}, {"$ref": "#/$defs/VerifyCuttableKeysTest"}, - {"$ref": "#/$defs/AccessibilityCheckTest"} + {"$ref": "#/$defs/AccessibilityCheckTest"}, + {"$ref": "#/$defs/DataModelTest"}, + {"$ref": "#/$defs/ProcessRequestTest"}, + {"$ref": "#/$defs/ResolveCatalogsTest"}, + {"$ref": "#/$defs/ProcessMessagesTest"} ] } ] @@ -483,6 +491,232 @@ }, "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"] + }, + "ResolveCatalogsTest": { + "type": "object", + "properties": { + "action": {"const": "resolve_catalogs"}, + "args": { + "type": "object", + "description": "Inputs to one capability negotiation.", + "properties": { + "catalogs": { + "type": "array", + "description": "The catalog configurations the agent registered, in preference order.", + "items": { + "type": "object", + "properties": { + "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." + }, + "allowed_functions": { + "type": "array", + "items": {"type": "string"}, + "description": "Function allowlist applied to this catalog before it is returned." + } + }, + "required": ["catalog_schema"] + } + }, + "renderer_capabilities": { + "type": "object", + "description": "The a2uiClientCapabilities object the renderer sent." + }, + "accepts_inline_catalogs": { + "type": "boolean", + "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": ["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": [ { @@ -500,7 +734,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/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_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..62d9ffd464 100644 --- a/dart/a2ui_agent/example/a2ui_agent_example.dart +++ b/dart/a2ui_agent/example/a2ui_agent_example.dart @@ -13,8 +13,76 @@ // 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. +/// +/// Shows the intended shape of an integration. Most of the SDK is still +/// stubbed, so running this throws [UnimplementedError]. void main() { - var awesome = Awesome(); - awesome.toString(); + // 1. Agent startup. Register every catalog the agent supports, narrowed to + // the components and functions it 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 against what the renderer says it can render. + // `a2uiClientCapabilities` arrives in transport 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 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, 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. + 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..42835ae342 100644 --- a/dart/a2ui_agent/lib/a2ui_agent.dart +++ b/dart/a2ui_agent/lib/a2ui_agent.dart @@ -12,8 +12,39 @@ // See the License for the specific language governing permissions and // limitations under the License. +/// The A2UI agent SDK: catalogs, capability negotiation, prompting, response +/// parsing and payload validation for agents that generate A2UI. +/// +/// Implements protocol v0.9 only; any other version, or none, is 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 59% rename from dart/a2ui_agent/test/a2ui_agent_test.dart rename to dart/a2ui_agent/lib/src/catalog_transformers/base.dart index fea717f80d..dee7f15f3c 100644 --- a/dart/a2ui_agent/test/a2ui_agent_test.dart +++ b/dart/a2ui_agent/lib/src/catalog_transformers/base.dart @@ -12,19 +12,17 @@ // 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 rule applied to a catalog before prompting or validation. +/// +/// Transformers narrow a 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); - }); - }); + /// Narrows [catalog], preserving its 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..ee68ae74e5 --- /dev/null +++ b/dart/a2ui_agent/lib/src/catalog_transformers/pruning.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 'base.dart'; + +/// Prunes catalog component definitions to an allowlist. +/// +/// Keeps only the components named in both the allowlist and catalog. +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. +/// +/// Keeps only the functions named in the allowlist and catalog. +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..f4479b7322 --- /dev/null +++ b/dart/a2ui_agent/lib/src/inference_format.dart @@ -0,0 +1,43 @@ +// 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 with a parser for one wire format. +abstract class InferenceFormat { + const InferenceFormat(); + + /// The prompt generator for this format. + PromptGenerator get promptGenerator; + + /// Creates a turn-scoped parser bound to this format's catalogs. + Parser createParser(); +} + +/// Constructs [InferenceFormat]s bound to a set of active catalogs. +abstract class InferenceFormatFactory< + C extends ComponentApi, + F extends FunctionApi +> { + const InferenceFormatFactory(); + + /// 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 new file mode 100644 index 0000000000..4837b9a93e --- /dev/null +++ b/dart/a2ui_agent/lib/src/inference_formats/direct_json/constants.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. + +/// 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 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', + '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..7000d96d71 --- /dev/null +++ b/dart/a2ui_agent/lib/src/inference_formats/direct_json/parser.dart @@ -0,0 +1,81 @@ +// 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 per turn: [parseChunk] state must not be shared. +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 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..b0302cbcc3 --- /dev/null +++ b/dart/a2ui_agent/lib/src/inference_formats/direct_json/prompt_generator.dart @@ -0,0 +1,39 @@ +// 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. +/// +/// Embeds the catalog schemas in `` tags and asks the model for +/// payloads in `` tags. +class DirectJsonPromptGenerator + extends PromptGenerator { + /// The envelope names the model may emit; null allows every envelope of + /// the active protocol version. + 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..c531820588 --- /dev/null +++ b/dart/a2ui_agent/lib/src/inference_formats/direct_json/streaming.dart @@ -0,0 +1,55 @@ +// 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 [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; + + /// 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 buffered content at the end of a stream. + /// + /// Throws [A2uiParseError] if a payload block is still unterminated. + List finish() { + throw UnimplementedError('DirectJsonStreamProcessor.finish'); + } + + /// 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 new file mode 100644 index 0000000000..bb8307cc5f --- /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 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/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..087888f8b0 --- /dev/null +++ b/dart/a2ui_agent/lib/src/inference_formats/express/parser.dart @@ -0,0 +1,68 @@ +// 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. +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..e459ec4875 --- /dev/null +++ b/dart/a2ui_agent/lib/src/inference_formats/express/prompt_generator.dart @@ -0,0 +1,31 @@ +// 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 components and functions as positional signatures, far cheaper +/// in tokens than DIRECT_JSON's schemas. +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..768a0ef28e --- /dev/null +++ b/dart/a2ui_agent/lib/src/parser/parser.dart @@ -0,0 +1,74 @@ +// 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 and compiles it into A2UI messages. +/// +/// 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; + + /// Renders raw parts back to one string, re-adding the format's tags. + String wrap(List blocks); + + /// Tokenizes a response into raw parts, in the order the model emitted + /// them. + /// + /// Throws [A2uiParseError] if the response holds no well-formed content for + /// this format. + List unwrap(String content); + + /// Compiles raw format content into validated A2UI messages. + /// + /// 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 response, preserving emission order. + /// + /// 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 = []; + 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 A2uiPart(): + // 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 one chunk of a streamed response. + /// + /// 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 new file mode 100644 index 0000000000..52b81ecf1a --- /dev/null +++ b/dart/a2ui_agent/lib/src/parser/response_part.dart @@ -0,0 +1,116 @@ +// 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: [TextPart] and [A2uiPart] once parsed, +/// [RawResponsePart] before compilation. +sealed class ResponsePart { + const ResponsePart(); +} + +/// Conversational text from an LLM response. +final class TextPart extends ResponsePart { + /// The text 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 block from an LLM response. +final class RawA2uiPart extends ResponsePart { + /// The uncompiled format content (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 messages, ready for a renderer. +final class A2uiPart extends ResponsePart { + /// The validated messages. + 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. +/// +/// Throws [ArgumentError] unless [part] is a [TextPart] or a [RawA2uiPart]. +class RawResponsePart { + /// The content: a [TextPart] or a [RawA2uiPart]. + final ResponsePart part; + + /// Whether this part is complete, 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..fa1ef350b3 --- /dev/null +++ b/dart/a2ui_agent/lib/src/processor/catalog_config.dart @@ -0,0 +1,61 @@ +// 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, the shape agents use: an +/// agent never evaluates a catalog function. +typedef SchemaCatalogConfig = CatalogConfig; + +/// Pairs a catalog with the transformers applied before 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 with every transformer applied, 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..ef5fad953d --- /dev/null +++ b/dart/a2ui_agent/lib/src/processor/catalog_providers.dart @@ -0,0 +1,108 @@ +// 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: an agent's catalogs come from +/// disk, from memory, or inline from 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, 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); + 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 conflicts with + /// [catalogId], and [A2uiValidationError] if its version is unsupported or + /// conflicts 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..bb142a9a3f --- /dev/null +++ b/dart/a2ui_agent/lib/src/processor/generator.dart @@ -0,0 +1,86 @@ +// 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 long-lived entry point to the agent SDK. +/// +/// 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 turns, 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, 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, + }) { + throw UnimplementedError('A2uiGenerator.createProcessor'); + } + + /// The capabilities this agent advertises, mirroring + /// `specification/v0_9_1/json/server_capabilities.json`. + /// + /// [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/lib/src/processor/processor.dart b/dart/a2ui_agent/lib/src/processor/processor.dart new file mode 100644 index 0000000000..dfe7c84b91 --- /dev/null +++ b/dart/a2ui_agent/lib/src/processor/processor.dart @@ -0,0 +1,74 @@ +// 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: negotiated catalogs, prompt snippet, parsers and +/// validation for one renderer. +/// +/// Usually obtained from `A2uiGenerator.createProcessor`. +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 prompt snippet; the agent prepends its own + /// 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 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 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 new file mode 100644 index 0000000000..d96348aee3 --- /dev/null +++ b/dart/a2ui_agent/lib/src/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'; + +/// Builds the format-specific portion of an agent's system instructions. +/// +/// The agent owns the surrounding preamble and suffix. +abstract class PromptGenerator { + /// The catalogs to describe. + final List> catalogs; + + /// 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 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..ffbb1356ca --- /dev/null +++ b/dart/a2ui_agent/lib/src/utils/catalog_resolver.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'; + +import '../processor/catalog_config.dart'; + +/// Negotiates renderer capabilities against the catalogs an agent supports. +/// +/// 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, and +/// [A2uiValidationError] if [rendererCapabilities] declares nothing 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..08b10c1bf2 --- /dev/null +++ b/dart/a2ui_agent/test/conformance/agent_conformance_test.dart @@ -0,0 +1,268 @@ +// 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 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'); + _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'); + _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 '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.'; + 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); + case 'resolve_catalogs': + _runResolveCatalogs(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?, + ); + } +} + +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 = + (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..83321c9195 --- /dev/null +++ b/dart/a2ui_agent/test/conformance/conformance_harness.dart @@ -0,0 +1,124 @@ +// 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: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 +/// `../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; +} + +/// 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; +} + +/// 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.dart b/dart/a2ui_agent/test/e2e/minimal_snippet.dart new file mode 100644 index 0000000000..5cf96f6a95 --- /dev/null +++ b/dart/a2ui_agent/test/e2e/minimal_snippet.dart @@ -0,0 +1,93 @@ +// 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, step by step. +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 preamble to. + final String promptSnippet; + + /// Step 3: what the model returned. + final String llmOutput; + + /// Step 4: the parsed response, in the order the model emitted it. + final List responseParts; + + /// Step 5: the messages for the renderer, flattened from [responseParts]. + 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. +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. 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 for + // 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..7c444b6b50 --- /dev/null +++ b/dart/a2ui_agent/test/e2e/minimal_snippet_test.dart @@ -0,0 +1,163 @@ +// 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 assertions needing negotiation, prompting and response parsing. +const String pendingSnippet = + 'The blueprint snippet cannot run end to end yet.'; + +/// Exercises [userSnippet], the "Code Example" section of +/// `blueprints/modules/a2ui_agent.blueprint.md`. +/// +/// 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', + ); + 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 renderer declared the basic catalog, so it is + // negotiated. + expect(result.processor.activeCatalogs.map((c) => c.id), [ + basicCatalogId, + ]); + + // 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: 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++) { + 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: the renderer gets 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 stands on its own: the generator advertises its catalog + // before anything is negotiated. + final generator = A2uiGenerator( + catalogs: [CatalogConfig(basicCatalog())], + ); + expect(generator.agentCapabilities, { + 'v0.9': { + 'supportedCatalogIds': [basicCatalogId], + 'acceptsInlineCatalogs': false, + }, + }); + }); + + 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']), + ); + }); + }); +} 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..127a8e9f1a --- /dev/null +++ b/dart/a2ui_agent/test/e2e/primary_use_case_test.dart @@ -0,0 +1,238 @@ +// 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 assertions needing negotiation, prompting and response parsing. +const String pendingEndToEnd = + 'The agent turn is not implemented end to end yet.'; + +/// The agent turn from section 5 of +/// `blueprints/modules/a2ui_agent.blueprint.md`. +/// +/// 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', + ); + + 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, 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 goes 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 payload must reconstruct into live surface state, which is what + // the renderer 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..0a81e9a136 --- /dev/null +++ b/dart/a2ui_agent/test/inference_formats/direct_json_format_test.dart @@ -0,0 +1,214 @@ +// 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 behaviour the DIRECT_JSON prompt generator does not implement yet. +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..1fff4126e8 --- /dev/null +++ b/dart/a2ui_agent/test/inference_formats/direct_json_parser_test.dart @@ -0,0 +1,241 @@ +// 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 behaviour the DIRECT_JSON parser does not implement yet. +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..9de499fbfb --- /dev/null +++ b/dart/a2ui_agent/test/inference_formats/direct_json_streaming_test.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 'package:a2ui_agent/a2ui_agent.dart'; +import 'package:a2ui_core/a2ui_core.dart'; +import 'package:test/test.dart'; + +import '../test_catalogs.dart'; + +/// Marks behaviour the DIRECT_JSON stream processor does not implement yet. +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..8b49669f08 --- /dev/null +++ b/dart/a2ui_agent/test/inference_formats/express_test.dart @@ -0,0 +1,209 @@ +// 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 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")'; + +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..9e523a6a7e --- /dev/null +++ b/dart/a2ui_agent/test/parser/parser_test.dart @@ -0,0 +1,129 @@ +// 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, exercising +/// `Parser.parseResponse` — the one behaviour it does not delegate. +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..46eaca13a6 --- /dev/null +++ b/dart/a2ui_agent/test/processor/generator_test.dart @@ -0,0 +1,281 @@ +// 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 behaviour capability negotiation does not implement yet. +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, +); + +/// 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', () { + 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('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: [ + CatalogConfig(basicCatalog()), + CatalogConfig(smallCatalog()), + ], + ); + + expect(g.agentCapabilities['v0.9'], { + 'supportedCatalogIds': [ + basicCatalogId, + 'https://example.com/small.json', + ], + 'acceptsInlineCatalogs': false, + }); + }); + + test('advertises whether inline catalogs are accepted', () { + 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', () { + final A2uiGenerator g = generator( + catalogs: [ + CatalogConfig( + basicCatalog(), + transformers: [ + ComponentPruningTransformer(['Text']), + ], + ), + ], + ); + + expect(v0_9Of(g)['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..fea27fd888 --- /dev/null +++ b/dart/a2ui_agent/test/processor/processor_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 behaviour the request processor does not implement yet. +const String pendingProcessor = + 'A2uiRequestProcessor.parseResponse is not implemented yet.'; + +/// Marks prompt rendering, which the DIRECT_JSON generator does not do 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..1b6d6e8bfa --- /dev/null +++ b/dart/a2ui_agent/test/test_catalogs.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: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 measure the SDK against the specification's catalog, not one of its +/// own. +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, where the 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..e1dd3d7c39 --- /dev/null +++ b/dart/a2ui_agent/test/utils/catalog_resolver_test.dart @@ -0,0 +1,162 @@ +// 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 behaviour `resolveCatalogs` does not implement yet. +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..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/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/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/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)); + } +}); 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