From 5264ce5d58fef8753d5f6ea06b7020ee4b369487 Mon Sep 17 00:00:00 2001 From: Nan Yu Date: Mon, 26 Jan 2026 23:15:43 +0000 Subject: [PATCH] feat: Add a A2uiValidator to validate loaded examples --- a2a_agents/python/a2ui_agent/pyproject.toml | 2 +- .../src/a2ui/inference/inference_strategy.py | 2 + .../src/a2ui/inference/schema/catalog.py | 26 +- .../src/a2ui/inference/schema/constants.py | 1 + .../src/a2ui/inference/schema/manager.py | 9 +- .../src/a2ui/inference/schema/validator.py | 179 ++++++ .../src/a2ui/inference/template/manager.py | 1 + .../tests/inference/test_catalog.py | 12 +- .../tests/inference/test_schema_manager.py | 2 +- .../tests/inference/test_validator.py | 538 ++++++++++++++++ .../tests/integration/verify_load_real.py | 589 +++++++++++++++++- 11 files changed, 1339 insertions(+), 22 deletions(-) create mode 100644 a2a_agents/python/a2ui_agent/src/a2ui/inference/schema/validator.py create mode 100644 a2a_agents/python/a2ui_agent/tests/inference/test_validator.py diff --git a/a2a_agents/python/a2ui_agent/pyproject.toml b/a2a_agents/python/a2ui_agent/pyproject.toml index f8e3c9fd5..960a031dd 100644 --- a/a2a_agents/python/a2ui_agent/pyproject.toml +++ b/a2a_agents/python/a2ui_agent/pyproject.toml @@ -12,7 +12,7 @@ dependencies = [ ] [build-system] -requires = ["hatchling"] +requires = ["hatchling", "jsonschema"] build-backend = "hatchling.build" [tool.hatch.build.targets.wheel] diff --git a/a2a_agents/python/a2ui_agent/src/a2ui/inference/inference_strategy.py b/a2a_agents/python/a2ui_agent/src/a2ui/inference/inference_strategy.py index ed1b0789f..f74db8585 100644 --- a/a2a_agents/python/a2ui_agent/src/a2ui/inference/inference_strategy.py +++ b/a2a_agents/python/a2ui_agent/src/a2ui/inference/inference_strategy.py @@ -28,6 +28,7 @@ def generate_system_prompt( allowed_components: List[str] = [], include_schema: bool = False, include_examples: bool = False, + validate_examples: bool = False, ) -> str: """ Generates a system prompt for all LLM requests. @@ -40,6 +41,7 @@ def generate_system_prompt( allowed_components: List of allowed components. include_schema: Whether to include the schema. include_examples: Whether to include examples. + validate_examples: Whether to validate examples. Returns: The system prompt. diff --git a/a2a_agents/python/a2ui_agent/src/a2ui/inference/schema/catalog.py b/a2a_agents/python/a2ui_agent/src/a2ui/inference/schema/catalog.py index 0a53f1a1a..6dbe5d7df 100644 --- a/a2a_agents/python/a2ui_agent/src/a2ui/inference/schema/catalog.py +++ b/a2a_agents/python/a2ui_agent/src/a2ui/inference/schema/catalog.py @@ -16,12 +16,15 @@ import json import logging import os -from dataclasses import dataclass, replace -from typing import Any, Dict, List, Optional +from dataclasses import dataclass, field, replace +from typing import Any, Dict, List, Optional, TYPE_CHECKING from .constants import CATALOG_COMPONENTS_KEY, CATALOG_ID_KEY from referencing import Registry, Resource +if TYPE_CHECKING: + from .validator import A2uiValidator + @dataclass class CustomCatalogConfig: @@ -56,6 +59,12 @@ def catalog_id(self) -> str: raise ValueError(f"Catalog '{self.name}' missing catalogId") return self.catalog_schema[CATALOG_ID_KEY] + @property + def validator(self) -> "A2uiValidator": + from .validator import A2uiValidator + + return A2uiValidator(self) + def with_pruned_components(self, allowed_components: List[str]) -> "A2uiCatalog": """Returns a new catalog with only allowed components. @@ -123,7 +132,7 @@ def render_as_llm_instructions(self) -> str: return "\n\n".join(all_schemas) - def load_examples(self, path: Optional[str]) -> str: + def load_examples(self, path: Optional[str], validate: bool = False) -> str: """Loads and validates examples from a directory.""" if not path or not os.path.isdir(path): if path: @@ -138,6 +147,8 @@ def load_examples(self, path: Optional[str]) -> str: try: with open(full_path, "r", encoding="utf-8") as f: content = f.read() + if validate and not self._validate_example(full_path, basename, content): + continue merged_examples.append( f"---BEGIN {basename}---\n{content}\n---END {basename}---" ) @@ -253,3 +264,12 @@ def merge_into(target: Dict[str, Any], source: Dict[str, Any]): target["oneOf"] = new_one_of return result + + def _validate_example(self, full_path: str, basename: str, content: str) -> bool: + try: + json_data = json.loads(content) + self.validator.validate(json_data) + except Exception as e: + logging.warning(f"Failed to validate example {full_path}: {e}") + return False + return True diff --git a/a2a_agents/python/a2ui_agent/src/a2ui/inference/schema/constants.py b/a2a_agents/python/a2ui_agent/src/a2ui/inference/schema/constants.py index 8a8682090..e3def1e13 100644 --- a/a2a_agents/python/a2ui_agent/src/a2ui/inference/schema/constants.py +++ b/a2a_agents/python/a2ui_agent/src/a2ui/inference/schema/constants.py @@ -20,6 +20,7 @@ CATALOG_SCHEMA_KEY = "catalog" CATALOG_COMPONENTS_KEY = "components" CATALOG_ID_KEY = "catalogId" +CATALOG_STYLES_KEY = "styles" BASE_SCHEMA_URL = "https://a2ui.org/" BASIC_CATALOG_NAME = "basic" diff --git a/a2a_agents/python/a2ui_agent/src/a2ui/inference/schema/manager.py b/a2a_agents/python/a2ui_agent/src/a2ui/inference/schema/manager.py index 106590f00..e9e00cf9e 100644 --- a/a2a_agents/python/a2ui_agent/src/a2ui/inference/schema/manager.py +++ b/a2a_agents/python/a2ui_agent/src/a2ui/inference/schema/manager.py @@ -283,10 +283,12 @@ def get_effective_catalog( pruned_catalog = catalog.with_pruned_components(allowed_components) return pruned_catalog - def load_examples(self, catalog: A2uiCatalog) -> str: + def load_examples(self, catalog: A2uiCatalog, validate: bool = False) -> str: """Loads examples for a catalog.""" if catalog.catalog_id in self._catalog_example_paths: - return catalog.load_examples(self._catalog_example_paths[catalog.catalog_id]) + return catalog.load_examples( + self._catalog_example_paths[catalog.catalog_id], validate=validate + ) return "" def generate_system_prompt( @@ -298,6 +300,7 @@ def generate_system_prompt( allowed_components: List[str] = [], include_schema: bool = False, include_examples: bool = False, + validate_examples: bool = False, ) -> str: """Assembles the final system instruction for the LLM.""" parts = [role_description] @@ -314,7 +317,7 @@ def generate_system_prompt( parts.append(final_catalog.render_as_llm_instructions()) if include_examples: - examples_str = self.load_examples(final_catalog) + examples_str = self.load_examples(final_catalog, validate=validate_examples) if examples_str: parts.append(f"### Examples:\n{examples_str}") diff --git a/a2a_agents/python/a2ui_agent/src/a2ui/inference/schema/validator.py b/a2a_agents/python/a2ui_agent/src/a2ui/inference/schema/validator.py new file mode 100644 index 000000000..a42b2052e --- /dev/null +++ b/a2a_agents/python/a2ui_agent/src/a2ui/inference/schema/validator.py @@ -0,0 +1,179 @@ +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import copy +from typing import TYPE_CHECKING, Any, Dict, List, Optional, Set, Tuple + +from jsonschema import Draft202012Validator + +if TYPE_CHECKING: + from .catalog import A2uiCatalog + +from .constants import ( + BASE_SCHEMA_URL, + CATALOG_COMPONENTS_KEY, + CATALOG_ID_KEY, + CATALOG_STYLES_KEY, +) + + +def _inject_additional_properties( + schema: Dict[str, Any], + source_properties: Dict[str, Any], + mapping: Dict[str, str] = None, +) -> Tuple[Dict[str, Any], Set[str]]: + """ + Recursively injects properties from source_properties into nodes with additionalProperties=True and sets additionalProperties=False. + + Args: + schema: The target schema to traverse and patch. + source_properties: A dictionary of top-level property groups (e.g., "components", "styles") from the source schema. + + Returns: + A tuple containing: + - The patched schema. + - A set of keys from source_properties that were injected. + """ + injected_keys = set() + + def recursive_inject(obj): + if isinstance(obj, dict): + new_obj = {} + for k, v in obj.items(): + # If this node has additionalProperties=True, we inject the source properties + if isinstance(v, dict) and v.get("additionalProperties") is True: + if k in source_properties: + injected_keys.add(k) + new_node = dict(v) + new_node["additionalProperties"] = False + new_node["properties"] = { + **new_node.get("properties", {}), + **source_properties[k], + } + new_obj[k] = new_node + else: # No matching source group, keep as is but recurse children + new_obj[k] = recursive_inject(v) + else: # Not a node with additionalProperties, recurse children + new_obj[k] = recursive_inject(v) + return new_obj + elif isinstance(obj, list): + return [recursive_inject(i) for i in obj] + return obj + + return recursive_inject(schema), injected_keys + + +# LLM is instructed to generate a list of messages, so we wrap the bundled schema in an array. +def _wrap_main_schema(schema: Dict[str, Any]) -> Dict[str, Any]: + return {"type": "array", "items": schema} + + +class A2uiValidator: + """Validator for A2UI messages.""" + + def __init__(self, catalog: "A2uiCatalog"): + self._catalog = catalog + self._validator = self._build_validator() + + def _build_validator(self) -> Draft202012Validator: + """Builds a validator for the A2UI schema.""" + + if self._catalog.version == "0.8": + return self._build_0_8_validator() + return self._build_0_9_validator() + + def _bundle_0_8_schemas(self) -> Dict[str, Any]: + if not self._catalog.s2c_schema: + return {} + + bundled = copy.deepcopy(self._catalog.s2c_schema) + + # Prepare catalog components and styles for injection + source_properties = {} + catalog_schema = self._catalog.catalog_schema + if catalog_schema: + if CATALOG_COMPONENTS_KEY in catalog_schema: + # Special mapping for v0.8: "components" -> "component" + source_properties["component"] = catalog_schema[CATALOG_COMPONENTS_KEY] + if CATALOG_STYLES_KEY in catalog_schema: + source_properties[CATALOG_STYLES_KEY] = catalog_schema[CATALOG_STYLES_KEY] + + bundled, _ = _inject_additional_properties(bundled, source_properties) + return bundled + + def _build_0_8_validator(self) -> Draft202012Validator: + """Builds a validator for the A2UI schema version 0.8.""" + bundled_schema = self._bundle_0_8_schemas() + full_schema = _wrap_main_schema(bundled_schema) + return Draft202012Validator(full_schema) + + def _build_0_9_validator(self) -> Draft202012Validator: + """Builds a validator for the A2UI schema version 0.9+.""" + full_schema = _wrap_main_schema(self._catalog.s2c_schema) + + from referencing import Registry, Resource + + # v0.9 schemas (e.g. server_to_client.json) use relative references like + # 'catalog.json#/$defs/anyComponent'. Since server_to_client.json has + # $id: https://a2ui.org/specification/v0_9/server_to_client.json, + # these resolve to https://a2ui.org/specification/v0_9/catalog.json. + # We must register them using these absolute URIs. + base_uri = self._catalog.s2c_schema.get("$id", BASE_SCHEMA_URL) + import os + + def get_sibling_uri(uri, filename): + return os.path.join(os.path.dirname(uri), filename) + + catalog_uri = get_sibling_uri(base_uri, "catalog.json") + common_types_uri = get_sibling_uri(base_uri, "common_types.json") + + resources = [ + ( + common_types_uri, + Resource.from_contents(self._catalog.common_types_schema), + ), + ( + catalog_uri, + Resource.from_contents(self._catalog.catalog_schema), + ), + # Fallbacks for robustness + ("catalog.json", Resource.from_contents(self._catalog.catalog_schema)), + ( + "common_types.json", + Resource.from_contents(self._catalog.common_types_schema), + ), + ] + # Also register the catalog ID if it's different from the catalog URI + if self._catalog.catalog_id and self._catalog.catalog_id != catalog_uri: + resources.append(( + self._catalog.catalog_id, + Resource.from_contents(self._catalog.catalog_schema), + )) + + registry = Registry().with_resources(resources) + validator_schema = copy.deepcopy(full_schema) + validator_schema["$schema"] = "https://json-schema.org/draft/2020-12/schema" + + return Draft202012Validator(validator_schema, registry=registry) + + def validate(self, message: Dict[str, Any]) -> None: + """Validates an A2UI message against the schema.""" + error = next(self._validator.iter_errors(message), None) + if error is not None: + msg = f"Validation failed: {error.message}" + if error.context: + msg += "\nContext failures:" + for sub_error in error.context: + msg += f"\n - {sub_error.message}" + raise ValueError(msg) diff --git a/a2a_agents/python/a2ui_agent/src/a2ui/inference/template/manager.py b/a2a_agents/python/a2ui_agent/src/a2ui/inference/template/manager.py index 41f5a8ec9..235eb1880 100644 --- a/a2a_agents/python/a2ui_agent/src/a2ui/inference/template/manager.py +++ b/a2a_agents/python/a2ui_agent/src/a2ui/inference/template/manager.py @@ -27,6 +27,7 @@ def generate_system_prompt( allowed_components: List[str] = [], include_schema: bool = False, include_examples: bool = False, + validate_examples: bool = False, ) -> str: # TODO: Implementation logic for Template Manager raise NotImplementedError("This method is not yet implemented.") diff --git a/a2a_agents/python/a2ui_agent/tests/inference/test_catalog.py b/a2a_agents/python/a2ui_agent/tests/inference/test_catalog.py index 89874db82..f0cf9a7fa 100644 --- a/a2a_agents/python/a2ui_agent/tests/inference/test_catalog.py +++ b/a2a_agents/python/a2ui_agent/tests/inference/test_catalog.py @@ -49,8 +49,12 @@ def test_catalog_id_missing_raises_error(): def test_load_examples(tmp_path): example_dir = tmp_path / "examples" example_dir.mkdir() - (example_dir / "example1.json").write_text('{"foo": "bar"}') - (example_dir / "example2.json").write_text('{"baz": "qux"}') + (example_dir / "example1.json").write_text( + '[{"beginRendering": {"surfaceId": "id"}}]' + ) + (example_dir / "example2.json").write_text( + '[{"beginRendering": {"surfaceId": "id"}}]' + ) (example_dir / "ignored.txt").write_text("should not be loaded") catalog = A2uiCatalog( @@ -63,9 +67,9 @@ def test_load_examples(tmp_path): examples_str = catalog.load_examples(str(example_dir)) assert "---BEGIN example1---" in examples_str - assert '{"foo": "bar"}' in examples_str + assert '[{"beginRendering": {"surfaceId": "id"}}]' in examples_str assert "---BEGIN example2---" in examples_str - assert '{"baz": "qux"}' in examples_str + assert '[{"beginRendering": {"surfaceId": "id"}}]' in examples_str assert "ignored" not in examples_str diff --git a/a2a_agents/python/a2ui_agent/tests/inference/test_schema_manager.py b/a2a_agents/python/a2ui_agent/tests/inference/test_schema_manager.py index 354286290..03b1545d3 100644 --- a/a2a_agents/python/a2ui_agent/tests/inference/test_schema_manager.py +++ b/a2a_agents/python/a2ui_agent/tests/inference/test_schema_manager.py @@ -218,7 +218,7 @@ def joinpath_side_effect(path): return_value="---BEGIN example1---\n{}\n---END example1---", ): prompt = manager.generate_system_prompt("Role description", include_examples=True) - assert "### Examples:" in prompt + assert "### Examples" in prompt assert "example1" in prompt # Test without examples diff --git a/a2a_agents/python/a2ui_agent/tests/inference/test_validator.py b/a2a_agents/python/a2ui_agent/tests/inference/test_validator.py new file mode 100644 index 000000000..72f56425d --- /dev/null +++ b/a2a_agents/python/a2ui_agent/tests/inference/test_validator.py @@ -0,0 +1,538 @@ +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import json +import copy +import pytest +from unittest.mock import MagicMock +from a2ui.inference.schema.manager import A2uiSchemaManager, A2uiCatalog, CustomCatalogConfig + + +class TestValidator: + + @pytest.fixture + def catalog_0_9(self): + s2c_schema = { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://a2ui.org/specification/v0_9/server_to_client.json", + "title": "A2UI Message Schema", + "oneOf": [ + {"$ref": "#/$defs/CreateSurfaceMessage"}, + {"$ref": "#/$defs/UpdateComponentsMessage"}, + ], + "$defs": { + "CreateSurfaceMessage": { + "type": "object", + "properties": { + "version": {"const": "v0.9"}, + "createSurface": { + "type": "object", + "properties": { + "surfaceId": { + "type": "string", + }, + "catalogId": { + "type": "string", + }, + "theme": {"type": "object", "additionalProperties": True}, + }, + "required": ["surfaceId", "catalogId"], + "additionalProperties": False, + }, + }, + "required": ["version", "createSurface"], + "additionalProperties": False, + }, + "UpdateComponentsMessage": { + "type": "object", + "properties": { + "version": {"const": "v0.9"}, + "updateComponents": { + "type": "object", + "properties": { + "surfaceId": { + "type": "string", + }, + "components": { + "type": "array", + "minItems": 1, + "items": {"$ref": "catalog.json#/$defs/anyComponent"}, + }, + }, + "required": ["surfaceId", "components"], + "additionalProperties": False, + }, + }, + "required": ["version", "updateComponents"], + "additionalProperties": False, + }, + "UpdateDataModelMessage": { + "type": "object", + "properties": { + "version": {"const": "v0.9"}, + "updateDataModel": { + "type": "object", + "properties": { + "surfaceId": { + "type": "string", + }, + "value": {"additionalProperties": True}, + }, + "required": ["surfaceId"], + "additionalProperties": False, + }, + }, + "required": ["version", "updateDataModel"], + "additionalProperties": False, + }, + }, + } + catalog_schema = { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://a2ui.org/specification/v0_9/standard_catalog.json", + "title": "A2UI Standard Catalog", + "catalogId": "https://a2ui.dev/specification/v0_9/standard_catalog.json", + "components": { + "Text": { + "type": "object", + "allOf": [ + {"$ref": "common_types.json#/$defs/ComponentCommon"}, + {"$ref": "#/$defs/CatalogComponentCommon"}, + { + "type": "object", + "properties": { + "component": {"const": "Text"}, + "text": {"$ref": "common_types.json#/$defs/DynamicString"}, + "variant": { + "type": "string", + "enum": [ + "h1", + "h2", + "h3", + "h4", + "h5", + "caption", + "body", + ], + }, + }, + "required": ["component", "text"], + }, + ], + }, + "Image": {}, + "Icon": {}, + }, + "theme": {"primaryColor": {"type": "string"}, "iconUrl": {"type": "string"}}, + "$defs": { + "CatalogComponentCommon": { + "type": "object", + "properties": {"weight": {"type": "number"}}, + }, + "anyComponent": { + "oneOf": [ + {"$ref": "#/components/Text"}, + {"$ref": "#/components/Image"}, + {"$ref": "#/components/Icon"}, + ], + "discriminator": {"propertyName": "component"}, + }, + }, + } + common_types_schema = { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://a2ui.org/specification/v0_9/common_types.json", + "title": "A2UI Common Types", + "$defs": { + "ComponentId": { + "type": "string", + }, + "AccessibilityAttributes": { + "type": "object", + "properties": { + "label": { + "$ref": "#/$defs/DynamicString", + } + }, + }, + "ComponentCommon": { + "type": "object", + "properties": {"id": {"$ref": "#/$defs/ComponentId"}}, + "required": ["id"], + }, + "DataBinding": {"type": "object"}, + "DynamicString": { + "anyOf": [{"type": "string"}, {"$ref": "#/$defs/DataBinding"}] + }, + "DynamicValue": { + "anyOf": [ + {"type": "object"}, + {"type": "array"}, + {"$ref": "#/$defs/DataBinding"}, + ] + }, + "DynamicNumber": { + "anyOf": [{"type": "number"}, {"$ref": "#/$defs/DataBinding"}] + }, + "ChildList": { + "oneOf": [ + {"type": "array", "items": {"type": "string"}}, + {"$ref": "#/$defs/DataBinding"}, + ] + }, + }, + } + return A2uiCatalog( + version="0.9", + name="standard", + catalog_schema=catalog_schema, + s2c_schema=s2c_schema, + common_types_schema=common_types_schema, + ) + + @pytest.fixture + def catalog_0_8(self): + s2c_schema = { + "title": "A2UI Message Schema", + "description": "Describes a JSON payload for an A2UI message.", + "type": "object", + "additionalProperties": False, + "properties": { + "beginRendering": { + "type": "object", + "additionalProperties": False, + "properties": { + "surfaceId": {"type": "string"}, + "styles": { + "type": "object", + "description": "Styling information for the UI.", + "additionalProperties": True, + }, + }, + "required": ["surfaceId"], + }, + "surfaceUpdate": { + "type": "object", + "additionalProperties": False, + "properties": { + "surfaceId": { + "type": "string", + }, + "components": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": False, + "properties": { + "id": { + "type": "string", + }, + "component": { + "type": "object", + "description": "A wrapper object.", + "additionalProperties": True, + }, + }, + "required": ["id", "component"], + }, + }, + }, + }, + "required": ["surfaceId", "components"], + }, + } + catalog_schema = { + "catalogId": ( + "https://a2ui.org/specification/v0_8/json/standard_catalog_definition.json" + ), + "components": {"Text": {"type": "object"}, "Button": {"type": "object"}}, + "styles": {"font": {"type": "string"}, "primaryColor": {"type": "string"}}, + } + return A2uiCatalog( + version="0.8", + name="standard", + catalog_schema=catalog_schema, + s2c_schema=s2c_schema, + common_types_schema=None, + ) + + def test_validator_0_9(self, catalog_0_9): + # v0.9+ uses Registry and referencing, not monolithic bundling. + # We test by validating a sample message. + message = [{ + "version": "v0.9", + "createSurface": { + "surfaceId": "test-id", + "catalogId": "standard", + "theme": {"primaryColor": "blue", "iconUrl": "http://img"}, + }, + }] + # Should not raise exception + catalog_0_9.validator.validate(message) + + # Test failure: version is missing + invalid_message = [{"createSurface": {"surfaceId": "123", "catalogId": "standard"}}] + # Note: version is missing in the message object + with pytest.raises(ValueError) as excinfo: + catalog_0_9.validator.validate(invalid_message) + assert "'version' is a required property" in str(excinfo.value) + + # Test failure: wrong version const + invalid_message = [{ + "version": "0.9", + "createSurface": {"surfaceId": "123", "catalogId": "standard"}, + }] + with pytest.raises(ValueError) as excinfo: + catalog_0_9.validator.validate(invalid_message) + assert "'v0.9' was expected" in str(excinfo.value) + + # Test failure: surfaceId must be string + invalid_message = [{ + "version": "v0.9", + "createSurface": {"surfaceId": 123, "catalogId": "standard"}, + }] + with pytest.raises(ValueError) as excinfo: + catalog_0_9.validator.validate(invalid_message) + assert "123 is not of type 'string'" in str(excinfo.value) + + # Test failure: catalogId is missing + invalid_message = [{"version": "v0.9", "createSurface": {"surfaceId": "123"}}] + with pytest.raises(ValueError) as excinfo: + catalog_0_9.validator.validate(invalid_message) + assert "'catalogId' is a required property" in str(excinfo.value) + + def test_validator_0_8(self, catalog_0_8): + # v0.8 uses monolithic bundling for validation + message = [{ + "beginRendering": { + "surfaceId": "test-id", + "styles": {"primaryColor": "#ff0000"}, + } + }] + # Should not raise exception + catalog_0_8.validator.validate(message) + + # Test failure: surfaceId must be string + invalid_message = [{"beginRendering": {"surfaceId": 123}}] + with pytest.raises(ValueError) as excinfo: + catalog_0_8.validator.validate(invalid_message) + assert "123 is not of type 'string'" in str(excinfo.value) + + # Test failure: styles must be object + invalid_message = [ + {"beginRendering": {"surfaceId": "id", "styles": "not-an-object"}} + ] + with pytest.raises(ValueError) as excinfo: + catalog_0_8.validator.validate(invalid_message) + assert "'not-an-object' is not of type 'object'" in str(excinfo.value) + + def test_custom_catalog_0_8(self, catalog_0_8): + """Tests validation with a custom catalog in v0.8.""" + custom_components = { + "Canvas": { + "type": "object", + "properties": { + "children": { + "type": "object", + "properties": { + "explicitList": {"type": "array", "items": {"type": "string"}} + }, + "required": ["explicitList"], + } + }, + "required": ["children"], + }, + "Chart": { + "type": "object", + "properties": { + "type": {"type": "string", "enum": ["doughnut", "pie"]}, + "title": { + "type": "object", + "properties": { + "literalString": {"type": "string"}, + "path": {"type": "string"}, + }, + }, + "chartData": { + "type": "object", + "properties": { + "literalArray": {"type": "array"}, + "path": {"type": "string"}, + }, + }, + }, + "required": ["type", "chartData"], + }, + "GoogleMap": { + "type": "object", + "properties": { + "center": { + "type": "object", + "properties": { + "literalObject": {"type": "object"}, + "path": {"type": "string"}, + }, + }, + "zoom": { + "type": "object", + "properties": { + "literalNumber": {"type": "number"}, + "path": {"type": "string"}, + }, + }, + }, + "required": ["center", "zoom"], + }, + } + + # Create a new catalog with these components + catalog_schema = copy.deepcopy(catalog_0_8.catalog_schema) + catalog_schema["components"] = custom_components + + custom_catalog = A2uiCatalog( + version="0.8", + name="custom", + catalog_schema=catalog_schema, + s2c_schema=catalog_0_8.s2c_schema, + common_types_schema=None, + ) + + # Valid message + message = [{ + "surfaceUpdate": { + "surfaceId": "id1", + "components": [ + { + "id": "c1", + "component": {"Canvas": {"children": {"explicitList": ["item1"]}}}, + }, + { + "id": "c2", + "component": { + "Chart": {"type": "pie", "chartData": {"path": "/data"}} + }, + }, + ], + } + }] + custom_catalog.validator.validate(message) + + def test_custom_catalog_0_9(self, catalog_0_9): + """Tests validation with a custom catalog in v0.9.""" + # Use the existing catalog_0_9 fixture but override its catalog_schema + # to include the custom components. + custom_components = { + "Canvas": { + "type": "object", + "allOf": [ + {"$ref": "common_types.json#/$defs/ComponentCommon"}, + {"$ref": "#/$defs/CatalogComponentCommon"}, + { + "type": "object", + "properties": { + "component": {"const": "Canvas"}, + "children": {"$ref": "common_types.json#/$defs/ChildList"}, + }, + "required": ["component", "children"], + }, + ], + }, + "Chart": { + "type": "object", + "allOf": [ + {"$ref": "common_types.json#/$defs/ComponentCommon"}, + {"$ref": "#/$defs/CatalogComponentCommon"}, + { + "type": "object", + "properties": { + "component": {"const": "Chart"}, + "chartType": {"enum": ["doughnut", "pie"]}, + "title": {"$ref": "common_types.json#/$defs/DynamicString"}, + "chartData": {"$ref": "common_types.json#/$defs/DynamicValue"}, + }, + "required": ["component", "chartType", "chartData"], + }, + ], + }, + "GoogleMap": { + "type": "object", + "allOf": [ + {"$ref": "common_types.json#/$defs/ComponentCommon"}, + {"$ref": "#/$defs/CatalogComponentCommon"}, + { + "type": "object", + "properties": { + "component": {"const": "GoogleMap"}, + "center": {"$ref": "common_types.json#/$defs/DynamicValue"}, + "zoom": {"$ref": "common_types.json#/$defs/DynamicNumber"}, + "pins": {"$ref": "common_types.json#/$defs/DynamicValue"}, + }, + "required": ["component", "center", "zoom"], + }, + ], + }, + } + + # Create a new catalog with these components + catalog_schema = copy.deepcopy(catalog_0_9.catalog_schema) + catalog_schema["components"] = custom_components + # Update anyComponent to include them + catalog_schema["$defs"]["anyComponent"]["oneOf"] = [ + {"$ref": "#/components/Canvas"}, + {"$ref": "#/components/Chart"}, + {"$ref": "#/components/GoogleMap"}, + ] + + custom_catalog = A2uiCatalog( + version="0.9", + name="custom", + catalog_schema=catalog_schema, + s2c_schema=catalog_0_9.s2c_schema, + common_types_schema=catalog_0_9.common_types_schema, + ) + + # Valid message + message = [{ + "version": "v0.9", + "updateComponents": { + "surfaceId": "s1", + "components": [ + {"id": "c1", "component": "Canvas", "children": ["child1"]}, + { + "id": "c2", + "component": "Chart", + "chartType": "doughnut", + "chartData": {"path": "/data"}, + }, + ], + }, + }] + custom_catalog.validator.validate(message) + + def test_bundle_0_8(self, catalog_0_8): + bundled = catalog_0_8.validator._bundle_0_8_schemas() + + # Verify styles injection + styles_node = bundled["properties"]["beginRendering"]["properties"]["styles"] + assert styles_node["additionalProperties"] is False + assert "font" in styles_node["properties"] + assert "primaryColor" in styles_node["properties"] + + # Verify component injection + component_node = bundled["properties"]["surfaceUpdate"]["properties"]["components"][ + "items" + ]["properties"]["component"] + assert component_node["additionalProperties"] is False + assert "Text" in component_node["properties"] + assert "Button" in component_node["properties"] diff --git a/a2a_agents/python/a2ui_agent/tests/integration/verify_load_real.py b/a2a_agents/python/a2ui_agent/tests/integration/verify_load_real.py index 9b039bd43..eecc7d6b7 100644 --- a/a2a_agents/python/a2ui_agent/tests/integration/verify_load_real.py +++ b/a2a_agents/python/a2ui_agent/tests/integration/verify_load_real.py @@ -19,27 +19,596 @@ def verify(): - print("Verifying A2uiSchemaManager...") + print('Verifying A2uiSchemaManager...') try: - manager = A2uiSchemaManager("0.8") + manager = A2uiSchemaManager('0.8') catalog = manager.get_effective_catalog() catalog_components = catalog.catalog_schema[CATALOG_COMPONENTS_KEY] - print(f"Successfully loaded 0.8: {len(catalog_components)} components") - print(f"Components found: {list(catalog_components.keys())[:5]}...") + print(f'Successfully loaded 0.8: {len(catalog_components)} components') + print(f'Components found: {list(catalog_components.keys())[:5]}...') + + a2ui_message = [ + {'beginRendering': {'surfaceId': 'contact-card', 'root': 'main_card'}}, + { + 'surfaceUpdate': { + 'surfaceId': 'contact-card', + 'components': [ + { + 'id': 'profile_image', + 'component': { + 'Image': { + 'url': {'path': 'imageUrl'}, + 'usageHint': 'avatar', + 'fit': 'cover', + } + }, + }, + { + 'id': 'user_heading', + 'weight': 1, + 'component': { + 'Text': {'text': {'path': 'name'}, 'usageHint': 'h2'} + }, + }, + { + 'id': 'description_text_1', + 'component': {'Text': {'text': {'path': 'title'}}}, + }, + { + 'id': 'description_text_2', + 'component': {'Text': {'text': {'path': 'team'}}}, + }, + { + 'id': 'description_column', + 'component': { + 'Column': { + 'children': { + 'explicitList': [ + 'user_heading', + 'description_text_1', + 'description_text_2', + ] + }, + 'alignment': 'center', + } + }, + }, + { + 'id': 'calendar_icon', + 'component': { + 'Icon': {'name': {'literalString': 'calendarToday'}} + }, + }, + { + 'id': 'calendar_primary_text', + 'component': { + 'Text': {'usageHint': 'h5', 'text': {'path': 'calendar'}} + }, + }, + { + 'id': 'calendar_secondary_text', + 'component': {'Text': {'text': {'literalString': 'Calendar'}}}, + }, + { + 'id': 'calendar_text_column', + 'component': { + 'Column': { + 'children': { + 'explicitList': [ + 'calendar_primary_text', + 'calendar_secondary_text', + ] + }, + 'distribution': 'start', + 'alignment': 'start', + } + }, + }, + { + 'id': 'info_row_1', + 'component': { + 'Row': { + 'children': { + 'explicitList': [ + 'calendar_icon', + 'calendar_text_column', + ] + }, + 'distribution': 'start', + 'alignment': 'start', + } + }, + }, + { + 'id': 'location_icon', + 'component': { + 'Icon': {'name': {'literalString': 'locationOn'}} + }, + }, + { + 'id': 'location_primary_text', + 'component': { + 'Text': {'usageHint': 'h5', 'text': {'path': 'location'}} + }, + }, + { + 'id': 'location_secondary_text', + 'component': {'Text': {'text': {'literalString': 'Location'}}}, + }, + { + 'id': 'location_text_column', + 'component': { + 'Column': { + 'children': { + 'explicitList': [ + 'location_primary_text', + 'location_secondary_text', + ] + }, + 'distribution': 'start', + 'alignment': 'start', + } + }, + }, + { + 'id': 'info_row_2', + 'component': { + 'Row': { + 'children': { + 'explicitList': [ + 'location_icon', + 'location_text_column', + ] + }, + 'distribution': 'start', + 'alignment': 'start', + } + }, + }, + { + 'id': 'mail_icon', + 'component': {'Icon': {'name': {'literalString': 'mail'}}}, + }, + { + 'id': 'mail_primary_text', + 'component': { + 'Text': {'usageHint': 'h5', 'text': {'path': 'email'}} + }, + }, + { + 'id': 'mail_secondary_text', + 'component': {'Text': {'text': {'literalString': 'Email'}}}, + }, + { + 'id': 'mail_text_column', + 'component': { + 'Column': { + 'children': { + 'explicitList': [ + 'mail_primary_text', + 'mail_secondary_text', + ] + }, + 'distribution': 'start', + 'alignment': 'start', + } + }, + }, + { + 'id': 'info_row_3', + 'component': { + 'Row': { + 'children': { + 'explicitList': ['mail_icon', 'mail_text_column'] + }, + 'distribution': 'start', + 'alignment': 'start', + } + }, + }, + {'id': 'div', 'component': {'Divider': {}}}, + { + 'id': 'call_icon', + 'component': {'Icon': {'name': {'literalString': 'call'}}}, + }, + { + 'id': 'call_primary_text', + 'component': { + 'Text': {'usageHint': 'h5', 'text': {'path': 'mobile'}} + }, + }, + { + 'id': 'call_secondary_text', + 'component': {'Text': {'text': {'literalString': 'Mobile'}}}, + }, + { + 'id': 'call_text_column', + 'component': { + 'Column': { + 'children': { + 'explicitList': [ + 'call_primary_text', + 'call_secondary_text', + ] + }, + 'distribution': 'start', + 'alignment': 'start', + } + }, + }, + { + 'id': 'info_row_4', + 'component': { + 'Row': { + 'children': { + 'explicitList': ['call_icon', 'call_text_column'] + }, + 'distribution': 'start', + 'alignment': 'start', + } + }, + }, + { + 'id': 'info_rows_column', + 'weight': 1, + 'component': { + 'Column': { + 'children': { + 'explicitList': [ + 'info_row_1', + 'info_row_2', + 'info_row_3', + 'info_row_4', + ] + }, + 'alignment': 'stretch', + } + }, + }, + { + 'id': 'button_1_text', + 'component': {'Text': {'text': {'literalString': 'Follow'}}}, + }, + { + 'id': 'button_1', + 'component': { + 'Button': { + 'child': 'button_1_text', + 'primary': True, + 'action': {'name': 'follow_contact'}, + } + }, + }, + { + 'id': 'button_2_text', + 'component': {'Text': {'text': {'literalString': 'Message'}}}, + }, + { + 'id': 'button_2', + 'component': { + 'Button': { + 'child': 'button_2_text', + 'primary': False, + 'action': {'name': 'send_message'}, + } + }, + }, + { + 'id': 'action_buttons_row', + 'component': { + 'Row': { + 'children': {'explicitList': ['button_1', 'button_2']}, + 'distribution': 'center', + 'alignment': 'center', + } + }, + }, + { + 'id': 'link_text', + 'component': { + 'Text': { + 'text': { + 'literalString': '[View Full Profile](/profile)' + } + } + }, + }, + { + 'id': 'link_text_wrapper', + 'component': { + 'Row': { + 'children': {'explicitList': ['link_text']}, + 'distribution': 'center', + 'alignment': 'center', + } + }, + }, + { + 'id': 'main_column', + 'component': { + 'Column': { + 'children': { + 'explicitList': [ + 'profile_image', + 'description_column', + 'div', + 'info_rows_column', + 'action_buttons_row', + 'link_text_wrapper', + ] + }, + 'alignment': 'stretch', + } + }, + }, + { + 'id': 'main_card', + 'component': {'Card': {'child': 'main_column'}}, + }, + ], + } + }, + { + 'dataModelUpdate': { + 'surfaceId': 'contact-card', + 'path': '/', + 'contents': [ + {'key': 'name', 'valueString': 'Casey Smith'}, + {'key': 'title', 'valueString': 'Digital Marketing Specialist'}, + {'key': 'team', 'valueString': 'Growth Team'}, + {'key': 'location', 'valueString': 'New York'}, + {'key': 'email', 'valueString': 'casey.smith@example.com'}, + {'key': 'mobile', 'valueString': '+1 (415) 222-3333'}, + {'key': 'calendar', 'valueString': 'In a meeting'}, + { + 'key': 'imageUrl', + 'valueString': 'http://localhost:10003/static/profile2.png', + }, + ], + } + }, + ] + catalog.validator.validate(a2ui_message) + print('Validation successful') except Exception as e: - print(f"Failed to load 0.8: {e}") + print(f'Failed to load 0.8: {e}') sys.exit(1) try: - manager = A2uiSchemaManager("0.9") + manager = A2uiSchemaManager('0.9') catalog = manager.get_effective_catalog() catalog_components = catalog.catalog_schema[CATALOG_COMPONENTS_KEY] - print(f"Successfully loaded 0.9: {len(catalog_components)} components") - print(f"Components found: {list(catalog_components.keys())}...") + print(f'Successfully loaded 0.9: {len(catalog_components)} components') + print(f'Components found: {list(catalog_components.keys())}...') + + a2ui_message = [ + { + 'version': 'v0.9', + 'createSurface': { + 'surfaceId': 'contact_form_1', + 'catalogId': ( + 'https://a2ui.dev/specification/v0_9/standard_catalog.json' + ), + }, + }, + { + 'version': 'v0.9', + 'updateComponents': { + 'surfaceId': 'contact_form_1', + 'components': [ + {'id': 'root', 'component': 'Card', 'child': 'form_container'}, + { + 'id': 'form_container', + 'component': 'Column', + 'children': [ + 'header_row', + 'name_row', + 'email_group', + 'phone_group', + 'pref_group', + 'divider_1', + 'newsletter_checkbox', + 'submit_button', + ], + 'justify': 'start', + 'align': 'stretch', + }, + { + 'id': 'header_row', + 'component': 'Row', + 'children': ['header_icon', 'header_text'], + 'align': 'center', + }, + {'id': 'header_icon', 'component': 'Icon', 'name': 'mail'}, + { + 'id': 'header_text', + 'component': 'Text', + 'text': '# Contact Us', + 'variant': 'h2', + }, + { + 'id': 'name_row', + 'component': 'Row', + 'children': ['first_name_group', 'last_name_group'], + 'justify': 'spaceBetween', + }, + { + 'id': 'first_name_group', + 'component': 'Column', + 'children': ['first_name_label', 'first_name_field'], + 'weight': 1, + }, + { + 'id': 'first_name_label', + 'component': 'Text', + 'text': 'First Name', + 'variant': 'caption', + }, + { + 'id': 'first_name_field', + 'component': 'TextField', + 'label': 'First Name', + 'value': {'path': '/contact/firstName'}, + 'variant': 'shortText', + }, + { + 'id': 'last_name_group', + 'component': 'Column', + 'children': ['last_name_label', 'last_name_field'], + 'weight': 1, + }, + { + 'id': 'last_name_label', + 'component': 'Text', + 'text': 'Last Name', + 'variant': 'caption', + }, + { + 'id': 'last_name_field', + 'component': 'TextField', + 'label': 'Last Name', + 'value': {'path': '/contact/lastName'}, + 'variant': 'shortText', + }, + { + 'id': 'email_group', + 'component': 'Column', + 'children': ['email_label', 'email_field'], + }, + { + 'id': 'email_label', + 'component': 'Text', + 'text': 'Email Address', + 'variant': 'caption', + }, + { + 'id': 'email_field', + 'component': 'TextField', + 'label': 'Email', + 'value': {'path': '/contact/email'}, + 'variant': 'shortText', + 'checks': [ + { + 'condition': { + 'call': 'required', + 'args': {'value': {'path': '/contact/email'}}, + }, + 'message': 'Email is required.', + }, + { + 'condition': { + 'call': 'email', + 'args': {'value': {'path': '/contact/email'}}, + }, + 'message': 'Please enter a valid email address.', + }, + ], + }, + { + 'id': 'phone_group', + 'component': 'Column', + 'children': ['phone_label', 'phone_field'], + }, + { + 'id': 'phone_label', + 'component': 'Text', + 'text': 'Phone Number', + 'variant': 'caption', + }, + { + 'id': 'phone_field', + 'component': 'TextField', + 'label': 'Phone', + 'value': {'path': '/contact/phone'}, + 'variant': 'shortText', + 'checks': [{ + 'condition': { + 'call': 'regex', + 'args': { + 'value': {'path': '/contact/phone'}, + 'pattern': '^\\d{10}$', + }, + }, + 'message': 'Phone number must be 10 digits.', + }], + }, + { + 'id': 'pref_group', + 'component': 'Column', + 'children': ['pref_label', 'pref_picker'], + }, + { + 'id': 'pref_label', + 'component': 'Text', + 'text': 'Preferred Contact Method', + 'variant': 'caption', + }, + { + 'id': 'pref_picker', + 'component': 'ChoicePicker', + 'variant': 'mutuallyExclusive', + 'options': [ + {'label': 'Email', 'value': 'email'}, + {'label': 'Phone', 'value': 'phone'}, + {'label': 'SMS', 'value': 'sms'}, + ], + 'value': {'path': '/contact/preference'}, + }, + {'id': 'divider_1', 'component': 'Divider', 'axis': 'horizontal'}, + { + 'id': 'newsletter_checkbox', + 'component': 'CheckBox', + 'label': 'Subscribe to our newsletter', + 'value': {'path': '/contact/subscribe'}, + }, + { + 'id': 'submit_button_label', + 'component': 'Text', + 'text': 'Send Message', + }, + { + 'id': 'submit_button', + 'component': 'Button', + 'child': 'submit_button_label', + 'variant': 'primary', + 'action': { + 'event': { + 'name': 'submitContactForm', + 'context': { + 'formId': 'contact_form_1', + 'isNewsletterSubscribed': { + 'path': '/contact/subscribe' + }, + }, + } + }, + }, + ], + }, + }, + { + 'version': 'v0.9', + 'updateDataModel': { + 'surfaceId': 'contact_form_1', + 'path': '/contact', + 'value': { + 'firstName': 'John', + 'lastName': 'Doe', + 'email': 'john.doe@example.com', + 'phone': '1234567890', + 'preference': ['email'], + 'subscribe': True, + }, + }, + }, + {'version': 'v0.9', 'deleteSurface': {'surfaceId': 'contact_form_1'}}, + ] + catalog.validator.validate(a2ui_message) + print('Validation successful') except Exception as e: - print(f"Failed to load 0.9: {e}") + print(f'Failed to load 0.9: {e}') sys.exit(1) -if __name__ == "__main__": +if __name__ == '__main__': verify()