From 47fa86fc3826b2ac02ae8c842cf6ae9eb83fff6a Mon Sep 17 00:00:00 2001 From: Greg Spencer Date: Fri, 10 Jul 2026 11:30:05 -0700 Subject: [PATCH 01/10] feat(genui): support ref schema resolution and validation via SchemaRegistry - Changed `SurfaceDefinition.validate` and validation APIs to be asynchronous to support resolving `$ref` schemas. - Integrated `SchemaRegistry` to validate component schemas using local assets or filesystem. - Exported `SchemaRegistry` in `json_schema_builder`. - Updated CHANGELOG.md for the breaking change. - Fixed a button widget stream test issue where manual subscription cancellation caused test runner hangs. --- packages/genui/CHANGELOG.md | 1 + .../genui/assets/schemas/common_types.json | 305 ++++++++++++++++++ .../assets/schemas/server_to_client.json | 132 ++++++++ .../catalog/basic_catalog_widgets/button.dart | 25 +- .../lib/src/engine/surface_controller.dart | 81 ++++- .../lib/src/model/schema_validation.dart | 91 +----- packages/genui/lib/src/model/ui_models.dart | 5 +- .../genui/lib/src/primitives/constants.dart | 17 + packages/genui/pubspec.yaml | 5 + .../catalog/core_widgets/button_test.dart | 34 +- .../test/engine/surface_controller_test.dart | 2 +- packages/genui/test/model/ui_models_test.dart | 10 +- .../lib/json_schema_builder.dart | 1 + .../test/test_suite_test.dart | 1 - 14 files changed, 580 insertions(+), 130 deletions(-) create mode 100644 packages/genui/assets/schemas/common_types.json create mode 100644 packages/genui/assets/schemas/server_to_client.json diff --git a/packages/genui/CHANGELOG.md b/packages/genui/CHANGELOG.md index 6b1b33213..49a2fa869 100644 --- a/packages/genui/CHANGELOG.md +++ b/packages/genui/CHANGELOG.md @@ -2,6 +2,7 @@ ## 0.10.0 (in progress) +- **BREAKING**: Changed `SurfaceDefinition.validate` to be asynchronous to support resolving `$ref` schemas via `SchemaRegistry`. - **Refactor**: genui now runs on `package:a2ui_core`. See [the migration guide](../../docs/usage/migration/migration_0.9.1_to_0.10.0.md). - **BREAKING**: A2UI message types are now `package:a2ui_core` types. The GenUI diff --git a/packages/genui/assets/schemas/common_types.json b/packages/genui/assets/schemas/common_types.json new file mode 100644 index 000000000..51c5b036b --- /dev/null +++ b/packages/genui/assets/schemas/common_types.json @@ -0,0 +1,305 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://a2ui.org/specification/v0_9/common_types.json", + "title": "A2UI Common Types", + "description": "Common type definitions used across A2UI schemas.", + "$defs": { + "ComponentId": { + "type": "string", + "description": "The unique identifier for a component, used for both definitions and references within the same surface." + }, + "AccessibilityAttributes": { + "type": "object", + "description": "Attributes to enhance accessibility when using assistive technologies like screen readers.", + "properties": { + "label": { + "$ref": "#/$defs/DynamicString", + "description": "A short string, typically 1 to 3 words, used by assistive technologies to convey the purpose or intent of an element. For example, an input field might have an accessible label of 'User ID' or a button might be labeled 'Submit'." + }, + "description": { + "$ref": "#/$defs/DynamicString", + "description": "Additional information provided by assistive technologies about an element such as instructions, format requirements, or result of an action. For example, a mute button might have a label of 'Mute' and a description of 'Silences notifications about this conversation'." + } + } + }, + "ComponentCommon": { + "type": "object", + "properties": { + "id": { + "$ref": "#/$defs/ComponentId" + }, + "accessibility": { + "$ref": "#/$defs/AccessibilityAttributes" + } + }, + "required": ["id"] + }, + "ChildList": { + "oneOf": [ + { + "type": "array", + "items": { + "$ref": "#/$defs/ComponentId" + }, + "description": "A static list of child component IDs." + }, + { + "type": "object", + "description": "A template for generating a dynamic list of children from a data model list. The `componentId` is the component to use as a template.", + "properties": { + "componentId": { + "$ref": "#/$defs/ComponentId" + }, + "path": { + "type": "string", + "description": "The path to the list of component property objects in the data model." + } + }, + "required": ["componentId", "path"], + "additionalProperties": false + } + ] + }, + "DataBinding": { + "type": "object", + "properties": { + "path": { + "type": "string", + "description": "A JSON Pointer path to a value in the data model." + } + }, + "required": ["path"], + "additionalProperties": false + }, + "DynamicValue": { + "description": "A value that can be a literal, a path, or a function call returning any type.", + "oneOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "array" + }, + { + "$ref": "#/$defs/DataBinding" + }, + { + "$ref": "#/$defs/FunctionCall" + } + ] + }, + "DynamicString": { + "description": "Represents a string", + "oneOf": [ + { + "type": "string" + }, + { + "$ref": "#/$defs/DataBinding" + }, + { + "allOf": [ + { + "$ref": "#/$defs/FunctionCall" + }, + { + "properties": { + "returnType": { + "const": "string" + } + } + } + ] + } + ] + }, + "DynamicNumber": { + "description": "Represents a value that can be either a literal number, a path to a number in the data model, or a function call returning a number.", + "oneOf": [ + { + "type": "number" + }, + { + "$ref": "#/$defs/DataBinding" + }, + { + "allOf": [ + { + "$ref": "#/$defs/FunctionCall" + }, + { + "properties": { + "returnType": { + "const": "number" + } + } + } + ] + } + ] + }, + "DynamicBoolean": { + "description": "A boolean value that can be a literal, a path, or a function call returning a boolean.", + "oneOf": [ + { + "type": "boolean" + }, + { + "$ref": "#/$defs/DataBinding" + }, + { + "allOf": [ + { + "$ref": "#/$defs/FunctionCall" + }, + { + "properties": { + "returnType": { + "const": "boolean" + } + } + } + ] + } + ] + }, + "DynamicStringList": { + "description": "Represents a value that can be either a literal array of strings, a path to a string array in the data model, or a function call returning a string array.", + "oneOf": [ + { + "type": "array", + "items": { + "type": "string" + } + }, + { + "$ref": "#/$defs/DataBinding" + }, + { + "allOf": [ + { + "$ref": "#/$defs/FunctionCall" + }, + { + "properties": { + "returnType": { + "const": "array" + } + } + } + ] + } + ] + }, + "FunctionCall": { + "type": "object", + "description": "Invokes a named function on the client.", + "properties": { + "call": { + "type": "string", + "description": "The name of the function to call." + }, + "args": { + "type": "object", + "description": "Arguments passed to the function.", + "additionalProperties": { + "anyOf": [ + { + "$ref": "#/$defs/DynamicValue" + }, + { + "type": "object", + "description": "A literal object argument (e.g. configuration)." + } + ] + } + }, + "returnType": { + "type": "string", + "description": "The expected return type of the function call.", + "enum": ["string", "number", "boolean", "array", "object", "any", "void"], + "default": "boolean" + } + }, + "required": ["call"], + "oneOf": [{"$ref": "catalog.json#/$defs/anyFunction"}] + }, + "CheckRule": { + "type": "object", + "description": "A single validation rule applied to an input component.", + "properties": { + "condition": { + "$ref": "#/$defs/DynamicBoolean" + }, + "message": { + "type": "string", + "description": "The error message to display if the check fails." + } + }, + "required": ["condition", "message"], + "additionalProperties": false + }, + "Checkable": { + "description": "Properties for components that support client-side checks.", + "type": "object", + "properties": { + "checks": { + "type": "array", + "description": "A list of checks to perform. These are function calls that must return a boolean indicating validity.", + "items": { + "$ref": "#/$defs/CheckRule" + } + } + } + }, + "Action": { + "description": "Defines an interaction handler that can either trigger a server-side event or execute a local client-side function.", + "oneOf": [ + { + "type": "object", + "description": "Triggers a server-side event.", + "properties": { + "event": { + "type": "object", + "description": "The event to dispatch to the server.", + "properties": { + "name": { + "type": "string", + "description": "The name of the action to be dispatched to the server." + }, + "context": { + "type": "object", + "description": "A JSON object containing the key-value pairs for the action context. Values can be literals or paths. Use literal values unless the value must be dynamically bound to the data model. Do NOT use paths for static IDs.", + "additionalProperties": { + "$ref": "#/$defs/DynamicValue" + } + } + }, + "required": ["name"], + "additionalProperties": false + } + }, + "required": ["event"], + "additionalProperties": false + }, + { + "type": "object", + "description": "Executes a local client-side function.", + "properties": { + "functionCall": { + "$ref": "#/$defs/FunctionCall" + } + }, + "required": ["functionCall"], + "additionalProperties": false + } + ] + } + } +} diff --git a/packages/genui/assets/schemas/server_to_client.json b/packages/genui/assets/schemas/server_to_client.json new file mode 100644 index 000000000..005980642 --- /dev/null +++ b/packages/genui/assets/schemas/server_to_client.json @@ -0,0 +1,132 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://a2ui.org/specification/v0_9/server_to_client.json", + "title": "A2UI Message Schema", + "description": "Describes a JSON payload for an A2UI (Agent to UI) message, which is used to dynamically construct and update user interfaces.", + "type": "object", + "oneOf": [ + {"$ref": "#/$defs/CreateSurfaceMessage"}, + {"$ref": "#/$defs/UpdateComponentsMessage"}, + {"$ref": "#/$defs/UpdateDataModelMessage"}, + {"$ref": "#/$defs/DeleteSurfaceMessage"} + ], + "$defs": { + "CreateSurfaceMessage": { + "type": "object", + "properties": { + "version": { + "const": "v0.9" + }, + "createSurface": { + "type": "object", + "description": "Signals the client to create a new surface and begin rendering it. It is an error to send 'createSurface' for a surfaceId that already exists without first deleting it. When this message is sent, the client will expect 'updateComponents' and/or 'updateDataModel' messages for the same surfaceId that define the component tree.", + "properties": { + "surfaceId": { + "type": "string", + "description": "The unique identifier for the UI surface to be rendered." + }, + "catalogId": { + "description": "A string that uniquely identifies this catalog. It is recommended to prefix this with an internet domain that you own, to avoid conflicts e.g. mycompany.com:somecatalog'.", + "type": "string" + }, + "theme": { + "$ref": "catalog.json#/$defs/theme", + "description": "Theme parameters for the surface (e.g., {'primaryColor': '#FF0000'}). These must validate against the 'theme' schema defined in the catalog." + }, + "sendDataModel": { + "type": "boolean", + "description": "If true, the client will send the full data model of this surface in the metadata of every A2A message sent to the server that created the surface. Defaults to false." + } + }, + "required": ["surfaceId", "catalogId"], + "additionalProperties": false + } + }, + "required": ["createSurface", "version"], + "additionalProperties": false + }, + "UpdateComponentsMessage": { + "type": "object", + "properties": { + "version": { + "const": "v0.9" + }, + "updateComponents": { + "type": "object", + "description": "Updates a surface with a new set of components. This message can be sent multiple times to update the component tree of an existing surface. One of the components in one of the components lists MUST have an 'id' of 'root' to serve as the root of the component tree. The createSurface message MUST have been previously sent with the 'catalogId' that is in this message.", + "properties": { + "surfaceId": { + "type": "string", + "description": "The unique identifier for the UI surface to be updated." + }, + + "components": { + "type": "array", + "description": "A list containing all UI components for the surface.", + "minItems": 1, + "items": { + "$ref": "catalog.json#/$defs/anyComponent" + } + } + }, + "required": ["surfaceId", "components"], + "additionalProperties": false + } + }, + "required": ["updateComponents", "version"], + "additionalProperties": false + }, + "UpdateDataModelMessage": { + "type": "object", + "properties": { + "version": { + "const": "v0.9" + }, + "updateDataModel": { + "type": "object", + "description": "Updates the data model for an existing surface. This message can be sent multiple times to update the data model. The createSurface message MUST have been previously sent with the 'catalogId' that is in this message.", + "properties": { + "surfaceId": { + "type": "string", + "description": "The unique identifier for the UI surface this data model update applies to." + }, + "path": { + "type": "string", + "description": "An optional path to a location within the data model (e.g., '/user/name'). If omitted, or set to '/', refers to the entire data model." + }, + "value": { + "description": "The data to be updated in the data model. If present, the value at 'path' is replaced (or created). If omitted, the key at 'path' is removed.", + "additionalProperties": true + } + }, + "required": ["surfaceId"], + "additionalProperties": false + } + }, + "required": ["updateDataModel", "version"], + "additionalProperties": false + }, + "DeleteSurfaceMessage": { + "type": "object", + "properties": { + "version": { + "const": "v0.9" + }, + "deleteSurface": { + "type": "object", + "description": "Signals the client to delete the surface identified by 'surfaceId'. The createSurface message MUST have been previously sent with the 'catalogId' that is in this message.", + "properties": { + "surfaceId": { + "type": "string", + "description": "The unique identifier for the UI surface to be deleted." + } + }, + "required": ["surfaceId"], + "additionalProperties": false + } + }, + "required": ["deleteSurface", "version"], + "additionalProperties": false + } + } +} diff --git a/packages/genui/lib/src/catalog/basic_catalog_widgets/button.dart b/packages/genui/lib/src/catalog/basic_catalog_widgets/button.dart index 9c50c80b2..dbc96e186 100644 --- a/packages/genui/lib/src/catalog/basic_catalog_widgets/button.dart +++ b/packages/genui/lib/src/catalog/basic_catalog_widgets/button.dart @@ -2,6 +2,8 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. +import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:json_schema_builder/json_schema_builder.dart'; @@ -230,11 +232,24 @@ Future _handlePress( final Stream resultStream = itemContext.dataContext.resolve( funcMap, ); - try { - await resultStream.first; - } catch (exception, stackTrace) { - itemContext.reportError(exception, stackTrace); - } + final completer = Completer(); + StreamSubscription? subscription; + subscription = resultStream.listen( + (val) { + subscription?.cancel(); + if (!completer.isCompleted) completer.complete(); + }, + onError: (Object exception, StackTrace stackTrace) { + subscription?.cancel(); + itemContext.reportError(exception, stackTrace); + if (!completer.isCompleted) completer.complete(); + }, + onDone: () { + subscription?.cancel(); + if (!completer.isCompleted) completer.complete(); + }, + ); + await completer.future; } else { genUiLogger.warning( 'Button action missing event or functionCall: $actionData', diff --git a/packages/genui/lib/src/engine/surface_controller.dart b/packages/genui/lib/src/engine/surface_controller.dart index 0f1ebb28d..a4111a5dd 100644 --- a/packages/genui/lib/src/engine/surface_controller.dart +++ b/packages/genui/lib/src/engine/surface_controller.dart @@ -4,10 +4,13 @@ import 'dart:async'; import 'dart:convert'; +import 'dart:io'; import 'package:a2ui_core/a2ui_core.dart' as core; import 'package:collection/collection.dart'; import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; +import 'package:json_schema_builder/json_schema_builder.dart'; import '../interfaces/a2ui_message_sink.dart'; import '../interfaces/surface_context.dart'; @@ -19,6 +22,7 @@ import '../model/data_model.dart'; import '../model/schema_validation.dart' as schema_validation; import '../model/ui_models.dart'; import '../primitives/a2ui_validation_exception.dart'; +import '../primitives/constants.dart'; import '../primitives/logging.dart'; import 'surface_registry.dart' as surface_reg; @@ -54,6 +58,40 @@ interface class SurfaceController implements SurfaceHost, A2uiMessageSink { late final surface_reg.SurfaceRegistry _registry = surface_reg.SurfaceRegistry(); final Map _liveDataModels = {}; + SchemaRegistry? _schemaRegistry; + + Future _getSchemaRegistry() async { + if (_schemaRegistry != null) return _schemaRegistry!; + String content; + final isTest = Platform.environment['FLUTTER_TEST'] == 'true'; + if (isTest) { + var file = File(commonTypesLocalPath); + if (!file.existsSync()) { + file = File('../../$commonTypesLocalPath'); + } + if (file.existsSync()) { + content = file.readAsStringSync(); + } else { + throw FileSystemException( + 'Schema file not found in test environment', + file.path, + ); + } + } else { + try { + content = await rootBundle.loadString(commonTypesAssetKey); + } catch (e) { + rethrow; + } + } + final registry = SchemaRegistry(); + registry.addSchema( + Uri.parse(commonTypesSchemaId), + Schema.fromMap(jsonDecode(content) as Map), + ); + _schemaRegistry = registry; + return registry; + } final _onSubmit = StreamController.broadcast(); final _pendingUpdates = >{}; @@ -207,18 +245,29 @@ interface class SurfaceController implements SurfaceHost, A2uiMessageSink { _registry.notifyUpdated(surface); // Validation does not roll back the mutation; we surface the error // and let the caller decide. - try { - final Catalog? genuiCatalog = catalogs.firstWhereOrNull( - (c) => c.effectiveCatalogId == surface.catalog.id, - ); - if (genuiCatalog != null) { - _validateComponents(coreMessage.surfaceId, surface, genuiCatalog); - } - } on A2uiValidationException catch (e) { - genUiLogger.warning( - 'Schema validation failed for surface ${e.surfaceId}: $e', - ); - reportError(e, StackTrace.current); + final Catalog? genuiCatalog = catalogs.firstWhereOrNull( + (c) => c.effectiveCatalogId == surface.catalog.id, + ); + if (genuiCatalog != null) { + _validateComponents( + coreMessage.surfaceId, + surface, + genuiCatalog, + ).catchError((Object error, StackTrace stack) { + if (error is A2uiValidationException) { + genUiLogger.warning( + 'Schema validation failed for surface ' + '${error.surfaceId}: $error', + ); + reportError(error, stack); + } else { + genUiLogger.severe( + 'Unexpected error during surface validation', + error, + stack, + ); + } + }); } } } @@ -331,17 +380,19 @@ interface class SurfaceController implements SurfaceHost, A2uiMessageSink { /// Validates the components currently in [surface] against [catalog]'s /// schema. Throws [A2uiValidationException] on the first failing component. - void _validateComponents( + Future _validateComponents( String surfaceId, core.SurfaceModel surface, Catalog catalog, - ) { - schema_validation.validateComponents( + ) async { + final SchemaRegistry registry = await _getSchemaRegistry(); + await schema_validation.validateComponents( surfaceId: surfaceId, components: surface.componentsModel.all.map( (c) => (id: c.id, type: c.type, json: c.toJson()), ), schema: catalog.definition, + registry: registry, ); } diff --git a/packages/genui/lib/src/model/schema_validation.dart b/packages/genui/lib/src/model/schema_validation.dart index cc742931d..b14ec268d 100644 --- a/packages/genui/lib/src/model/schema_validation.dart +++ b/packages/genui/lib/src/model/schema_validation.dart @@ -11,11 +11,12 @@ import '../primitives/simple_items.dart'; /// /// Throws [A2uiValidationException] on the first component that fails. State /// is not rolled back. -void validateComponents({ +Future validateComponents({ required String surfaceId, required Iterable<({String id, String type, JsonMap json})> components, required Schema schema, -}) { + required SchemaRegistry registry, +}) async { final List> allowedSchemas = _extractAllowedSchemas( schema.value, ); @@ -28,12 +29,16 @@ void validateComponents({ for (final s in allowedSchemas) { if (_schemaMatchesType(s, component.type)) { try { - _validateInstance( - component.json, + final List validationErrors = await Schema.fromMap( s, - '/components/${component.id}', - surfaceId, - ); + ).validate(component.json, schemaRegistry: registry); + if (validationErrors.isNotEmpty) { + throw A2uiValidationException( + validationErrors.join('; '), + surfaceId: surfaceId, + path: '/components/${component.id}', + ); + } matched = true; break; } catch (e) { @@ -98,75 +103,3 @@ bool _schemaMatchesType(Map schema, String type) { } return false; } - -void _validateInstance( - Object? instance, - Map schema, - String path, - String surfaceId, -) { - if (instance == null) return; - - if (schema case {'const': Object? constVal} when instance != constVal) { - throw A2uiValidationException( - 'Value mismatch. Expected $constVal, got $instance', - surfaceId: surfaceId, - path: path, - ); - } - if (schema case { - 'enum': List enums, - } when !enums.contains(instance)) { - throw A2uiValidationException( - 'Value not in enum: $instance', - surfaceId: surfaceId, - path: path, - ); - } - if (schema case {'required': List required} when instance is Map) { - for (final String key in required.cast()) { - if (!instance.containsKey(key)) { - throw A2uiValidationException( - 'Missing required property: $key', - surfaceId: surfaceId, - path: path, - ); - } - } - } - if (schema case { - 'properties': Map props, - } when instance is Map) { - for (final MapEntry entry in props.entries) { - final String key = entry.key; - final propSchema = entry.value as Map; - if (instance.containsKey(key)) { - _validateInstance(instance[key], propSchema, '$path/$key', surfaceId); - } - } - } - if (schema case { - 'items': Map itemsSchema, - } when instance is List) { - for (var i = 0; i < instance.length; i++) { - _validateInstance(instance[i], itemsSchema, '$path/$i', surfaceId); - } - } - if (schema case {'oneOf': List oneOfs}) { - var oneMatched = false; - for (final Map s in oneOfs.cast>()) { - try { - _validateInstance(instance, s, path, surfaceId); - oneMatched = true; - break; - } catch (_) {} - } - if (!oneMatched) { - throw A2uiValidationException( - 'Value did not match any oneOf schema', - surfaceId: surfaceId, - path: path, - ); - } - } -} diff --git a/packages/genui/lib/src/model/ui_models.dart b/packages/genui/lib/src/model/ui_models.dart index 8f9a2fa4e..5a5b58849 100644 --- a/packages/genui/lib/src/model/ui_models.dart +++ b/packages/genui/lib/src/model/ui_models.dart @@ -168,13 +168,14 @@ class SurfaceDefinition { } /// Validates the UI definition against a schema. - void validate(Schema schema) { - schema_validation.validateComponents( + Future validate(Schema schema, {SchemaRegistry? registry}) async { + await schema_validation.validateComponents( surfaceId: surfaceId, components: components.values.map( (c) => (id: c.id, type: c.type, json: c.toJson()), ), schema: schema, + registry: registry ?? SchemaRegistry(), ); } } diff --git a/packages/genui/lib/src/primitives/constants.dart b/packages/genui/lib/src/primitives/constants.dart index 9203a0046..1a9d1114e 100644 --- a/packages/genui/lib/src/primitives/constants.dart +++ b/packages/genui/lib/src/primitives/constants.dart @@ -5,3 +5,20 @@ /// The catalog ID for the basic catalog. const String basicCatalogId = 'https://a2ui.org/specification/v0_9/basic_catalog.json'; + +/// The schema URI for common A2UI types. +const String commonTypesSchemaId = + 'https://a2ui.org/specification/v0_9/common_types.json'; + +/// Asset path for common A2UI types schema. +const String commonTypesAssetKey = + 'packages/genui/assets/schemas/common_types.json'; + +/// Asset path for server-to-client message envelope schema. +const String serverToClientAssetKey = + 'packages/genui/assets/schemas/server_to_client.json'; + +/// Local filesystem path to common A2UI types schema (for test and development +/// utilities). +const String commonTypesLocalPath = + 'submodules/a2ui/specification/v0_9/json/common_types.json'; diff --git a/packages/genui/pubspec.yaml b/packages/genui/pubspec.yaml index 71506f532..8b3805f13 100644 --- a/packages/genui/pubspec.yaml +++ b/packages/genui/pubspec.yaml @@ -39,3 +39,8 @@ dev_dependencies: sdk: flutter network_image_mock: ^2.1.1 test: ^1.26.2 + +flutter: + assets: + - assets/schemas/common_types.json + - assets/schemas/server_to_client.json diff --git a/packages/genui/test/catalog/core_widgets/button_test.dart b/packages/genui/test/catalog/core_widgets/button_test.dart index 8743a9691..caf0a42b6 100644 --- a/packages/genui/test/catalog/core_widgets/button_test.dart +++ b/packages/genui/test/catalog/core_widgets/button_test.dart @@ -76,13 +76,15 @@ void main() { testWidgets('Button widget handles stream errors gracefully', ( WidgetTester tester, ) async { - ChatMessage? message; - // Create a stream controller that we can use to emit errors - final streamController = StreamController.broadcast(); - final mockFunction = MockFunction( name: 'throwError', - onExecute: (args, context) => streamController.stream, + onExecute: (args, context) { + final controller = StreamController(sync: true); + controller.addError(Exception('Stream error')); + // ignore: unawaited_futures + controller.close(); + return controller.stream; + }, ); final surfaceController = SurfaceController( @@ -94,6 +96,7 @@ void main() { ), ], ); + ChatMessage? message; surfaceController.onSubmit.listen((event) => message = event); const surfaceId = 'testSurface'; @@ -103,7 +106,9 @@ void main() { type: 'Button', properties: { 'child': 'button_text', - 'action': {'call': 'throwError'}, + 'action': { + 'functionCall': {'call': 'throwError'}, + }, }, ), component( @@ -133,25 +138,10 @@ void main() { // Tap the button to trigger the function call await tester.tap(find.byType(ElevatedButton)); - - // Emit an error from the stream - streamController.addError(Exception('Stream error')); - - // Pump to process the error - await tester.pump(); - - // Wait for the message to be received, pumping the widget tree - var retries = 0; - while (message == null && retries < 50) { - await tester.pump(const Duration(milliseconds: 10)); - retries++; - } + await tester.pumpAndSettle(); // Verify error was reported expect(message, isNotNull); - - // The test passes if no unhandled exception crashes the test. - await streamController.close(); surfaceController.dispose(); }); diff --git a/packages/genui/test/engine/surface_controller_test.dart b/packages/genui/test/engine/surface_controller_test.dart index cac77d9b2..81ed665fd 100644 --- a/packages/genui/test/engine/surface_controller_test.dart +++ b/packages/genui/test/engine/surface_controller_test.dart @@ -454,7 +454,7 @@ void main() { final errorObj = errorJson['error'] as Map; expect(errorObj['code'], 'VALIDATION_FAILED'); - expect(errorObj['message'], contains('Missing required property')); + expect(errorObj['message'], contains('Required property')); }, ); }); diff --git a/packages/genui/test/model/ui_models_test.dart b/packages/genui/test/model/ui_models_test.dart index 3d3a963f4..33f6529b9 100644 --- a/packages/genui/test/model/ui_models_test.dart +++ b/packages/genui/test/model/ui_models_test.dart @@ -65,7 +65,7 @@ void main() { }); group('SurfaceDefinition', () { - test('validate throws exception on mismatch', () { + test('validate throws exception on mismatch', () async { final component = const Component( id: 'test', type: 'Text', @@ -87,13 +87,13 @@ void main() { }, ); - expect( - () => surfaceDefinition.validate(schema), + await expectLater( + surfaceDefinition.validate(schema), throwsA(isA()), ); }); - test('validate passes on correct match', () { + test('validate passes on correct match', () async { final component = const Component( id: 'test', type: 'Text', @@ -117,7 +117,7 @@ void main() { }, ); - surfaceDefinition.validate(schema); // Should not throw + await surfaceDefinition.validate(schema); // Should not throw }); }); diff --git a/packages/json_schema_builder/lib/json_schema_builder.dart b/packages/json_schema_builder/lib/json_schema_builder.dart index efff3323e..f5ee31640 100644 --- a/packages/json_schema_builder/lib/json_schema_builder.dart +++ b/packages/json_schema_builder/lib/json_schema_builder.dart @@ -11,6 +11,7 @@ export 'src/schema/number_schema.dart'; export 'src/schema/object_schema.dart'; export 'src/schema/schema.dart'; export 'src/schema/string_schema.dart'; +export 'src/schema_registry.dart'; export 'src/schema_validation.dart'; export 'src/validation_error.dart'; export 'src/validation_result.dart'; diff --git a/packages/json_schema_builder/test/test_suite_test.dart b/packages/json_schema_builder/test/test_suite_test.dart index a534737fa..63c399028 100644 --- a/packages/json_schema_builder/test/test_suite_test.dart +++ b/packages/json_schema_builder/test/test_suite_test.dart @@ -7,7 +7,6 @@ import 'dart:io'; import 'package:json_schema_builder/json_schema_builder.dart'; import 'package:json_schema_builder/src/logging_context.dart'; -import 'package:json_schema_builder/src/schema_registry.dart'; import 'package:test/test.dart'; void main() { From c41a0d8840c33c1050243a2a1f2b3e44d472f3f3 Mon Sep 17 00:00:00 2001 From: Greg Spencer Date: Fri, 10 Jul 2026 11:45:35 -0700 Subject: [PATCH 02/10] feat(genui): embed schemas as static constants instead of local assets - Created `packages/genui/tool/generate_embedded_schemas.dart` script to compile schema JSONs to Dart string constants. - Generated `packages/genui/lib/src/primitives/embedded_schemas.dart` containing embedded A2UI schemas. - Updated `SurfaceController._getSchemaRegistry` to load the common types schema synchronously from the embedded string literal. - Unregistered the assets from `packages/genui/pubspec.yaml` and deleted the local asset files. - Removed unused assets-related constants from `packages/genui/lib/src/primitives/constants.dart`. --- .../assets/schemas/server_to_client.json | 132 ---------------- .../lib/src/engine/surface_controller.dart | 27 +--- .../genui/lib/src/primitives/constants.dart | 13 -- .../src/primitives/embedded_schemas.dart} | 146 ++++++++++++++++++ packages/genui/pubspec.yaml | 5 +- .../genui/tool/generate_embedded_schemas.dart | 67 ++++++++ 6 files changed, 216 insertions(+), 174 deletions(-) delete mode 100644 packages/genui/assets/schemas/server_to_client.json rename packages/genui/{assets/schemas/common_types.json => lib/src/primitives/embedded_schemas.dart} (59%) create mode 100644 packages/genui/tool/generate_embedded_schemas.dart diff --git a/packages/genui/assets/schemas/server_to_client.json b/packages/genui/assets/schemas/server_to_client.json deleted file mode 100644 index 005980642..000000000 --- a/packages/genui/assets/schemas/server_to_client.json +++ /dev/null @@ -1,132 +0,0 @@ -{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://a2ui.org/specification/v0_9/server_to_client.json", - "title": "A2UI Message Schema", - "description": "Describes a JSON payload for an A2UI (Agent to UI) message, which is used to dynamically construct and update user interfaces.", - "type": "object", - "oneOf": [ - {"$ref": "#/$defs/CreateSurfaceMessage"}, - {"$ref": "#/$defs/UpdateComponentsMessage"}, - {"$ref": "#/$defs/UpdateDataModelMessage"}, - {"$ref": "#/$defs/DeleteSurfaceMessage"} - ], - "$defs": { - "CreateSurfaceMessage": { - "type": "object", - "properties": { - "version": { - "const": "v0.9" - }, - "createSurface": { - "type": "object", - "description": "Signals the client to create a new surface and begin rendering it. It is an error to send 'createSurface' for a surfaceId that already exists without first deleting it. When this message is sent, the client will expect 'updateComponents' and/or 'updateDataModel' messages for the same surfaceId that define the component tree.", - "properties": { - "surfaceId": { - "type": "string", - "description": "The unique identifier for the UI surface to be rendered." - }, - "catalogId": { - "description": "A string that uniquely identifies this catalog. It is recommended to prefix this with an internet domain that you own, to avoid conflicts e.g. mycompany.com:somecatalog'.", - "type": "string" - }, - "theme": { - "$ref": "catalog.json#/$defs/theme", - "description": "Theme parameters for the surface (e.g., {'primaryColor': '#FF0000'}). These must validate against the 'theme' schema defined in the catalog." - }, - "sendDataModel": { - "type": "boolean", - "description": "If true, the client will send the full data model of this surface in the metadata of every A2A message sent to the server that created the surface. Defaults to false." - } - }, - "required": ["surfaceId", "catalogId"], - "additionalProperties": false - } - }, - "required": ["createSurface", "version"], - "additionalProperties": false - }, - "UpdateComponentsMessage": { - "type": "object", - "properties": { - "version": { - "const": "v0.9" - }, - "updateComponents": { - "type": "object", - "description": "Updates a surface with a new set of components. This message can be sent multiple times to update the component tree of an existing surface. One of the components in one of the components lists MUST have an 'id' of 'root' to serve as the root of the component tree. The createSurface message MUST have been previously sent with the 'catalogId' that is in this message.", - "properties": { - "surfaceId": { - "type": "string", - "description": "The unique identifier for the UI surface to be updated." - }, - - "components": { - "type": "array", - "description": "A list containing all UI components for the surface.", - "minItems": 1, - "items": { - "$ref": "catalog.json#/$defs/anyComponent" - } - } - }, - "required": ["surfaceId", "components"], - "additionalProperties": false - } - }, - "required": ["updateComponents", "version"], - "additionalProperties": false - }, - "UpdateDataModelMessage": { - "type": "object", - "properties": { - "version": { - "const": "v0.9" - }, - "updateDataModel": { - "type": "object", - "description": "Updates the data model for an existing surface. This message can be sent multiple times to update the data model. The createSurface message MUST have been previously sent with the 'catalogId' that is in this message.", - "properties": { - "surfaceId": { - "type": "string", - "description": "The unique identifier for the UI surface this data model update applies to." - }, - "path": { - "type": "string", - "description": "An optional path to a location within the data model (e.g., '/user/name'). If omitted, or set to '/', refers to the entire data model." - }, - "value": { - "description": "The data to be updated in the data model. If present, the value at 'path' is replaced (or created). If omitted, the key at 'path' is removed.", - "additionalProperties": true - } - }, - "required": ["surfaceId"], - "additionalProperties": false - } - }, - "required": ["updateDataModel", "version"], - "additionalProperties": false - }, - "DeleteSurfaceMessage": { - "type": "object", - "properties": { - "version": { - "const": "v0.9" - }, - "deleteSurface": { - "type": "object", - "description": "Signals the client to delete the surface identified by 'surfaceId'. The createSurface message MUST have been previously sent with the 'catalogId' that is in this message.", - "properties": { - "surfaceId": { - "type": "string", - "description": "The unique identifier for the UI surface to be deleted." - } - }, - "required": ["surfaceId"], - "additionalProperties": false - } - }, - "required": ["deleteSurface", "version"], - "additionalProperties": false - } - } -} diff --git a/packages/genui/lib/src/engine/surface_controller.dart b/packages/genui/lib/src/engine/surface_controller.dart index a4111a5dd..e81a968a2 100644 --- a/packages/genui/lib/src/engine/surface_controller.dart +++ b/packages/genui/lib/src/engine/surface_controller.dart @@ -4,12 +4,10 @@ import 'dart:async'; import 'dart:convert'; -import 'dart:io'; import 'package:a2ui_core/a2ui_core.dart' as core; import 'package:collection/collection.dart'; import 'package:flutter/foundation.dart'; -import 'package:flutter/services.dart'; import 'package:json_schema_builder/json_schema_builder.dart'; import '../interfaces/a2ui_message_sink.dart'; @@ -23,6 +21,7 @@ import '../model/schema_validation.dart' as schema_validation; import '../model/ui_models.dart'; import '../primitives/a2ui_validation_exception.dart'; import '../primitives/constants.dart'; +import '../primitives/embedded_schemas.dart'; import '../primitives/logging.dart'; import 'surface_registry.dart' as surface_reg; @@ -62,32 +61,10 @@ interface class SurfaceController implements SurfaceHost, A2uiMessageSink { Future _getSchemaRegistry() async { if (_schemaRegistry != null) return _schemaRegistry!; - String content; - final isTest = Platform.environment['FLUTTER_TEST'] == 'true'; - if (isTest) { - var file = File(commonTypesLocalPath); - if (!file.existsSync()) { - file = File('../../$commonTypesLocalPath'); - } - if (file.existsSync()) { - content = file.readAsStringSync(); - } else { - throw FileSystemException( - 'Schema file not found in test environment', - file.path, - ); - } - } else { - try { - content = await rootBundle.loadString(commonTypesAssetKey); - } catch (e) { - rethrow; - } - } final registry = SchemaRegistry(); registry.addSchema( Uri.parse(commonTypesSchemaId), - Schema.fromMap(jsonDecode(content) as Map), + Schema.fromMap(jsonDecode(commonTypesSchemaJson) as Map), ); _schemaRegistry = registry; return registry; diff --git a/packages/genui/lib/src/primitives/constants.dart b/packages/genui/lib/src/primitives/constants.dart index 1a9d1114e..8bea68e33 100644 --- a/packages/genui/lib/src/primitives/constants.dart +++ b/packages/genui/lib/src/primitives/constants.dart @@ -9,16 +9,3 @@ const String basicCatalogId = /// The schema URI for common A2UI types. const String commonTypesSchemaId = 'https://a2ui.org/specification/v0_9/common_types.json'; - -/// Asset path for common A2UI types schema. -const String commonTypesAssetKey = - 'packages/genui/assets/schemas/common_types.json'; - -/// Asset path for server-to-client message envelope schema. -const String serverToClientAssetKey = - 'packages/genui/assets/schemas/server_to_client.json'; - -/// Local filesystem path to common A2UI types schema (for test and development -/// utilities). -const String commonTypesLocalPath = - 'submodules/a2ui/specification/v0_9/json/common_types.json'; diff --git a/packages/genui/assets/schemas/common_types.json b/packages/genui/lib/src/primitives/embedded_schemas.dart similarity index 59% rename from packages/genui/assets/schemas/common_types.json rename to packages/genui/lib/src/primitives/embedded_schemas.dart index 51c5b036b..9d5b8a827 100644 --- a/packages/genui/assets/schemas/common_types.json +++ b/packages/genui/lib/src/primitives/embedded_schemas.dart @@ -1,3 +1,12 @@ +// Copyright 2025 The Flutter Authors. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +// GENERATED FILE. DO NOT EDIT MANUALLY. +// To regenerate, run: dart run packages/genui/tool/generate_embedded_schemas.dart + +/// Embedded schema contents of 'common_types.json'. +const String commonTypesSchemaJson = r''' { "$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "https://a2ui.org/specification/v0_9/common_types.json", @@ -303,3 +312,140 @@ } } } +'''; + +/// Embedded schema contents of 'server_to_client.json'. +const String serverToClientSchemaJson = r''' +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://a2ui.org/specification/v0_9/server_to_client.json", + "title": "A2UI Message Schema", + "description": "Describes a JSON payload for an A2UI (Agent to UI) message, which is used to dynamically construct and update user interfaces.", + "type": "object", + "oneOf": [ + {"$ref": "#/$defs/CreateSurfaceMessage"}, + {"$ref": "#/$defs/UpdateComponentsMessage"}, + {"$ref": "#/$defs/UpdateDataModelMessage"}, + {"$ref": "#/$defs/DeleteSurfaceMessage"} + ], + "$defs": { + "CreateSurfaceMessage": { + "type": "object", + "properties": { + "version": { + "const": "v0.9" + }, + "createSurface": { + "type": "object", + "description": "Signals the client to create a new surface and begin rendering it. It is an error to send 'createSurface' for a surfaceId that already exists without first deleting it. When this message is sent, the client will expect 'updateComponents' and/or 'updateDataModel' messages for the same surfaceId that define the component tree.", + "properties": { + "surfaceId": { + "type": "string", + "description": "The unique identifier for the UI surface to be rendered." + }, + "catalogId": { + "description": "A string that uniquely identifies this catalog. It is recommended to prefix this with an internet domain that you own, to avoid conflicts e.g. mycompany.com:somecatalog'.", + "type": "string" + }, + "theme": { + "$ref": "catalog.json#/$defs/theme", + "description": "Theme parameters for the surface (e.g., {'primaryColor': '#FF0000'}). These must validate against the 'theme' schema defined in the catalog." + }, + "sendDataModel": { + "type": "boolean", + "description": "If true, the client will send the full data model of this surface in the metadata of every A2A message sent to the server that created the surface. Defaults to false." + } + }, + "required": ["surfaceId", "catalogId"], + "additionalProperties": false + } + }, + "required": ["createSurface", "version"], + "additionalProperties": false + }, + "UpdateComponentsMessage": { + "type": "object", + "properties": { + "version": { + "const": "v0.9" + }, + "updateComponents": { + "type": "object", + "description": "Updates a surface with a new set of components. This message can be sent multiple times to update the component tree of an existing surface. One of the components in one of the components lists MUST have an 'id' of 'root' to serve as the root of the component tree. The createSurface message MUST have been previously sent with the 'catalogId' that is in this message.", + "properties": { + "surfaceId": { + "type": "string", + "description": "The unique identifier for the UI surface to be updated." + }, + + "components": { + "type": "array", + "description": "A list containing all UI components for the surface.", + "minItems": 1, + "items": { + "$ref": "catalog.json#/$defs/anyComponent" + } + } + }, + "required": ["surfaceId", "components"], + "additionalProperties": false + } + }, + "required": ["updateComponents", "version"], + "additionalProperties": false + }, + "UpdateDataModelMessage": { + "type": "object", + "properties": { + "version": { + "const": "v0.9" + }, + "updateDataModel": { + "type": "object", + "description": "Updates the data model for an existing surface. This message can be sent multiple times to update the data model. The createSurface message MUST have been previously sent with the 'catalogId' that is in this message.", + "properties": { + "surfaceId": { + "type": "string", + "description": "The unique identifier for the UI surface this data model update applies to." + }, + "path": { + "type": "string", + "description": "An optional path to a location within the data model (e.g., '/user/name'). If omitted, or set to '/', refers to the entire data model." + }, + "value": { + "description": "The data to be updated in the data model. If present, the value at 'path' is replaced (or created). If omitted, the key at 'path' is removed.", + "additionalProperties": true + } + }, + "required": ["surfaceId"], + "additionalProperties": false + } + }, + "required": ["updateDataModel", "version"], + "additionalProperties": false + }, + "DeleteSurfaceMessage": { + "type": "object", + "properties": { + "version": { + "const": "v0.9" + }, + "deleteSurface": { + "type": "object", + "description": "Signals the client to delete the surface identified by 'surfaceId'. The createSurface message MUST have been previously sent with the 'catalogId' that is in this message.", + "properties": { + "surfaceId": { + "type": "string", + "description": "The unique identifier for the UI surface to be deleted." + } + }, + "required": ["surfaceId"], + "additionalProperties": false + } + }, + "required": ["deleteSurface", "version"], + "additionalProperties": false + } + } +} +'''; diff --git a/packages/genui/pubspec.yaml b/packages/genui/pubspec.yaml index 8b3805f13..8d27e5533 100644 --- a/packages/genui/pubspec.yaml +++ b/packages/genui/pubspec.yaml @@ -40,7 +40,4 @@ dev_dependencies: network_image_mock: ^2.1.1 test: ^1.26.2 -flutter: - assets: - - assets/schemas/common_types.json - - assets/schemas/server_to_client.json + diff --git a/packages/genui/tool/generate_embedded_schemas.dart b/packages/genui/tool/generate_embedded_schemas.dart new file mode 100644 index 000000000..ed4fae9ae --- /dev/null +++ b/packages/genui/tool/generate_embedded_schemas.dart @@ -0,0 +1,67 @@ +// Copyright 2025 The Flutter Authors. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +// ignore_for_file: specify_nonobvious_local_variable_types, avoid_print + +import 'dart:io'; + +void main() { + final scriptFile = File(Platform.script.toFilePath()); + final packageDir = scriptFile.parent.parent; + final repoRoot = packageDir.parent.parent; + + final sourceDir = Directory( + '${repoRoot.path}/submodules/a2ui/specification/v0_9/json', + ); + final targetFile = File( + '${packageDir.path}/lib/src/primitives/embedded_schemas.dart', + ); + + if (!sourceDir.existsSync()) { + stderr.writeln( + 'Error: Source directory ${sourceDir.path} does not exist.\n' + 'Make sure git submodules are checked out and up to date.', + ); + exit(1); + } + + final files = { + 'common_types.json': 'commonTypesSchemaJson', + 'server_to_client.json': 'serverToClientSchemaJson', + }; + + final buffer = StringBuffer(); + buffer.writeln('// Copyright 2025 The Flutter Authors.'); + buffer.writeln( + '// Use of this source code is governed by a BSD-style license that can be', + ); + buffer.writeln('// found in the LICENSE file.'); + buffer.writeln(); + buffer.writeln('// GENERATED FILE. DO NOT EDIT MANUALLY.'); + buffer.writeln( + '// To regenerate, run: dart run packages/genui/tool/generate_embedded_schemas.dart', + ); + buffer.writeln(); + + for (final entry in files.entries) { + final filename = entry.key; + final variableName = entry.value; + final sourceFile = File('${sourceDir.path}/$filename'); + + if (!sourceFile.existsSync()) { + stderr.writeln('Error: Source file ${sourceFile.path} not found.'); + exit(1); + } + + final content = sourceFile.readAsStringSync().trim(); + buffer.writeln('/// Embedded schema contents of \'$filename\'.'); + buffer.writeln('const String $variableName = r\'\'\''); + buffer.writeln(content); + buffer.writeln('\'\'\';'); + buffer.writeln(); + } + + targetFile.writeAsStringSync(buffer.toString()); + print('Successfully generated ${targetFile.path}'); +} From 380f0505e61bdb2ffd788c17b32a01bae8f038f3 Mon Sep 17 00:00:00 2001 From: Greg Spencer Date: Fri, 10 Jul 2026 11:50:54 -0700 Subject: [PATCH 03/10] feat(genui): configure schema embedding as build_runner builder - Integrated schema compilation into the standard Dart `build_runner` framework. - Created `packages/genui/tool/schema_builder.dart` implementing the `Builder` interface to copy schemas from submodules at build time. - Configured `packages/genui/build.yaml` to trigger `schema_builder` on `pubspec.yaml` changes. - Added `build` and `build_runner` as dev_dependencies in `packages/genui/pubspec.yaml`. - Deleted the temporary standalone `generate_embedded_schemas.dart` script. --- packages/genui/build.yaml | 13 +++ .../lib/src/primitives/embedded_schemas.dart | 2 +- packages/genui/pubspec.yaml | 2 + .../genui/tool/generate_embedded_schemas.dart | 67 ---------------- packages/genui/tool/schema_builder.dart | 80 +++++++++++++++++++ 5 files changed, 96 insertions(+), 68 deletions(-) create mode 100644 packages/genui/build.yaml delete mode 100644 packages/genui/tool/generate_embedded_schemas.dart create mode 100644 packages/genui/tool/schema_builder.dart diff --git a/packages/genui/build.yaml b/packages/genui/build.yaml new file mode 100644 index 000000000..126faddae --- /dev/null +++ b/packages/genui/build.yaml @@ -0,0 +1,13 @@ +targets: + $default: + builders: + genui|schema_builder: + enabled: true + +builders: + schema_builder: + import: "tool/schema_builder.dart" + builder_factories: ["schemaBuilder"] + build_extensions: {"pubspec.yaml": ["lib/src/primitives/embedded_schemas.dart"]} + auto_apply: dependents + build_to: source diff --git a/packages/genui/lib/src/primitives/embedded_schemas.dart b/packages/genui/lib/src/primitives/embedded_schemas.dart index 9d5b8a827..00f331a4d 100644 --- a/packages/genui/lib/src/primitives/embedded_schemas.dart +++ b/packages/genui/lib/src/primitives/embedded_schemas.dart @@ -3,7 +3,7 @@ // found in the LICENSE file. // GENERATED FILE. DO NOT EDIT MANUALLY. -// To regenerate, run: dart run packages/genui/tool/generate_embedded_schemas.dart +// To regenerate, run: dart run build_runner build /// Embedded schema contents of 'common_types.json'. const String commonTypesSchemaJson = r''' diff --git a/packages/genui/pubspec.yaml b/packages/genui/pubspec.yaml index 8d27e5533..dbd0e54d0 100644 --- a/packages/genui/pubspec.yaml +++ b/packages/genui/pubspec.yaml @@ -35,6 +35,8 @@ dependencies: dev_dependencies: async: ^2.13.0 + build: ^4.0.7 + build_runner: ^2.15.1 flutter_test: sdk: flutter network_image_mock: ^2.1.1 diff --git a/packages/genui/tool/generate_embedded_schemas.dart b/packages/genui/tool/generate_embedded_schemas.dart deleted file mode 100644 index ed4fae9ae..000000000 --- a/packages/genui/tool/generate_embedded_schemas.dart +++ /dev/null @@ -1,67 +0,0 @@ -// Copyright 2025 The Flutter Authors. -// Use of this source code is governed by a BSD-style license that can be -// found in the LICENSE file. - -// ignore_for_file: specify_nonobvious_local_variable_types, avoid_print - -import 'dart:io'; - -void main() { - final scriptFile = File(Platform.script.toFilePath()); - final packageDir = scriptFile.parent.parent; - final repoRoot = packageDir.parent.parent; - - final sourceDir = Directory( - '${repoRoot.path}/submodules/a2ui/specification/v0_9/json', - ); - final targetFile = File( - '${packageDir.path}/lib/src/primitives/embedded_schemas.dart', - ); - - if (!sourceDir.existsSync()) { - stderr.writeln( - 'Error: Source directory ${sourceDir.path} does not exist.\n' - 'Make sure git submodules are checked out and up to date.', - ); - exit(1); - } - - final files = { - 'common_types.json': 'commonTypesSchemaJson', - 'server_to_client.json': 'serverToClientSchemaJson', - }; - - final buffer = StringBuffer(); - buffer.writeln('// Copyright 2025 The Flutter Authors.'); - buffer.writeln( - '// Use of this source code is governed by a BSD-style license that can be', - ); - buffer.writeln('// found in the LICENSE file.'); - buffer.writeln(); - buffer.writeln('// GENERATED FILE. DO NOT EDIT MANUALLY.'); - buffer.writeln( - '// To regenerate, run: dart run packages/genui/tool/generate_embedded_schemas.dart', - ); - buffer.writeln(); - - for (final entry in files.entries) { - final filename = entry.key; - final variableName = entry.value; - final sourceFile = File('${sourceDir.path}/$filename'); - - if (!sourceFile.existsSync()) { - stderr.writeln('Error: Source file ${sourceFile.path} not found.'); - exit(1); - } - - final content = sourceFile.readAsStringSync().trim(); - buffer.writeln('/// Embedded schema contents of \'$filename\'.'); - buffer.writeln('const String $variableName = r\'\'\''); - buffer.writeln(content); - buffer.writeln('\'\'\';'); - buffer.writeln(); - } - - targetFile.writeAsStringSync(buffer.toString()); - print('Successfully generated ${targetFile.path}'); -} diff --git a/packages/genui/tool/schema_builder.dart b/packages/genui/tool/schema_builder.dart new file mode 100644 index 000000000..94a8b8113 --- /dev/null +++ b/packages/genui/tool/schema_builder.dart @@ -0,0 +1,80 @@ +// Copyright 2025 The Flutter Authors. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. +// ignore_for_file: specify_nonobvious_local_variable_types + +import 'dart:io'; +import 'package:build/build.dart'; + +Builder schemaBuilder(BuilderOptions options) => SchemaBuilder(); + +class SchemaBuilder implements Builder { + @override + Map> get buildExtensions => const { + 'pubspec.yaml': ['lib/src/primitives/embedded_schemas.dart'], + }; + + @override + Future build(BuildStep buildStep) async { + // Find repository root by traversing upwards until we find .gitmodules. + Directory dir = Directory.current; + while (dir.path != dir.parent.path) { + if (File('${dir.path}/.gitmodules').existsSync()) { + break; + } + dir = dir.parent; + } + final repoRoot = dir; + final sourceDir = Directory( + '${repoRoot.path}/submodules/a2ui/specification/v0_9/json', + ); + + if (!sourceDir.existsSync()) { + log.warning( + 'Source directory ${sourceDir.path} does not exist. ' + 'Make sure git submodules are checked out.', + ); + return; + } + + final files = { + 'common_types.json': 'commonTypesSchemaJson', + 'server_to_client.json': 'serverToClientSchemaJson', + }; + + final buffer = StringBuffer(); + buffer.writeln('// Copyright 2025 The Flutter Authors.'); + buffer.writeln( + '// Use of this source code is governed by a BSD-style license that can be', + ); + buffer.writeln('// found in the LICENSE file.'); + buffer.writeln(); + buffer.writeln('// GENERATED FILE. DO NOT EDIT MANUALLY.'); + buffer.writeln('// To regenerate, run: dart run build_runner build'); + buffer.writeln(); + + for (final entry in files.entries) { + final filename = entry.key; + final variableName = entry.value; + final sourceFile = File('${sourceDir.path}/$filename'); + + if (!sourceFile.existsSync()) { + log.warning('Source file ${sourceFile.path} not found.'); + return; + } + + final content = sourceFile.readAsStringSync().trim(); + buffer.writeln('/// Embedded schema contents of \'$filename\'.'); + buffer.writeln('const String $variableName = r\'\'\''); + buffer.writeln(content); + buffer.writeln('\'\'\';'); + buffer.writeln(); + } + + final outputId = AssetId( + buildStep.inputId.package, + 'lib/src/primitives/embedded_schemas.dart', + ); + await buildStep.writeAsString(outputId, buffer.toString()); + } +} From 2c43d3627b9f451d2bb9155861b6fda0798f1611 Mon Sep 17 00:00:00 2001 From: Greg Spencer Date: Fri, 10 Jul 2026 11:54:56 -0700 Subject: [PATCH 04/10] chore(genui): rename generated schemas file to embedded_schemas.g.dart - Renamed generated schemas file extension to `.g.dart` to follow Dart builder conventions. - Updated SchemaBuilder implementation and build.yaml configurations to output to `.g.dart`. - Updated import references in surface_controller.dart. --- dev_tools/catalog_gallery/integration_test/app_test.dart | 4 +--- dev_tools/catalog_gallery/lib/sample_source.dart | 4 +--- packages/genui/build.yaml | 6 +++++- packages/genui/lib/src/engine/surface_controller.dart | 2 +- .../{embedded_schemas.dart => embedded_schemas.g.dart} | 0 packages/genui/tool/schema_builder.dart | 5 +++-- 6 files changed, 11 insertions(+), 10 deletions(-) rename packages/genui/lib/src/primitives/{embedded_schemas.dart => embedded_schemas.g.dart} (100%) diff --git a/dev_tools/catalog_gallery/integration_test/app_test.dart b/dev_tools/catalog_gallery/integration_test/app_test.dart index 790696213..0be2c442c 100644 --- a/dev_tools/catalog_gallery/integration_test/app_test.dart +++ b/dev_tools/catalog_gallery/integration_test/app_test.dart @@ -58,9 +58,7 @@ void main() { testWidgets('catalog_gallery smoke test - verify initial state', ( tester, ) async { - await tester.pumpWidget( - CatalogGalleryApp(sampleSource: sampleSource), - ); + await tester.pumpWidget(CatalogGalleryApp(sampleSource: sampleSource)); await tester.pumpAndSettle(); expect(find.text('Catalog Gallery'), findsOneWidget); diff --git a/dev_tools/catalog_gallery/lib/sample_source.dart b/dev_tools/catalog_gallery/lib/sample_source.dart index a1aaba8f0..0861dc706 100644 --- a/dev_tools/catalog_gallery/lib/sample_source.dart +++ b/dev_tools/catalog_gallery/lib/sample_source.dart @@ -86,9 +86,7 @@ class DirectorySampleSource implements SampleSource { final List refs = files .map( (file) => SampleRef( - name: directory.fileSystem.path.basenameWithoutExtension( - file.path, - ), + name: directory.fileSystem.path.basenameWithoutExtension(file.path), load: file.readAsString, ), ) diff --git a/packages/genui/build.yaml b/packages/genui/build.yaml index 126faddae..a5b19c0ca 100644 --- a/packages/genui/build.yaml +++ b/packages/genui/build.yaml @@ -1,3 +1,7 @@ +# Copyright 2025 The Flutter Authors. +# Use of this source code is governed by a BSD-style license that can be +# found in the LICENSE file. + targets: $default: builders: @@ -8,6 +12,6 @@ builders: schema_builder: import: "tool/schema_builder.dart" builder_factories: ["schemaBuilder"] - build_extensions: {"pubspec.yaml": ["lib/src/primitives/embedded_schemas.dart"]} + build_extensions: {"pubspec.yaml": ["lib/src/primitives/embedded_schemas.g.dart"]} auto_apply: dependents build_to: source diff --git a/packages/genui/lib/src/engine/surface_controller.dart b/packages/genui/lib/src/engine/surface_controller.dart index e81a968a2..e9cb9573e 100644 --- a/packages/genui/lib/src/engine/surface_controller.dart +++ b/packages/genui/lib/src/engine/surface_controller.dart @@ -21,7 +21,7 @@ import '../model/schema_validation.dart' as schema_validation; import '../model/ui_models.dart'; import '../primitives/a2ui_validation_exception.dart'; import '../primitives/constants.dart'; -import '../primitives/embedded_schemas.dart'; +import '../primitives/embedded_schemas.g.dart'; import '../primitives/logging.dart'; import 'surface_registry.dart' as surface_reg; diff --git a/packages/genui/lib/src/primitives/embedded_schemas.dart b/packages/genui/lib/src/primitives/embedded_schemas.g.dart similarity index 100% rename from packages/genui/lib/src/primitives/embedded_schemas.dart rename to packages/genui/lib/src/primitives/embedded_schemas.g.dart diff --git a/packages/genui/tool/schema_builder.dart b/packages/genui/tool/schema_builder.dart index 94a8b8113..ae958fe98 100644 --- a/packages/genui/tool/schema_builder.dart +++ b/packages/genui/tool/schema_builder.dart @@ -1,6 +1,7 @@ // Copyright 2025 The Flutter Authors. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. + // ignore_for_file: specify_nonobvious_local_variable_types import 'dart:io'; @@ -11,7 +12,7 @@ Builder schemaBuilder(BuilderOptions options) => SchemaBuilder(); class SchemaBuilder implements Builder { @override Map> get buildExtensions => const { - 'pubspec.yaml': ['lib/src/primitives/embedded_schemas.dart'], + 'pubspec.yaml': ['lib/src/primitives/embedded_schemas.g.dart'], }; @override @@ -73,7 +74,7 @@ class SchemaBuilder implements Builder { final outputId = AssetId( buildStep.inputId.package, - 'lib/src/primitives/embedded_schemas.dart', + 'lib/src/primitives/embedded_schemas.g.dart', ); await buildStep.writeAsString(outputId, buffer.toString()); } From ea8546dddbaf2a018eeee1deafe13055e66fb59b Mon Sep 17 00:00:00 2001 From: Greg Spencer Date: Fri, 10 Jul 2026 12:06:24 -0700 Subject: [PATCH 05/10] refactor(genui): address PR review feedback for schema reference validation - Updated button event handler to use StreamIterator to prevent synchronous race conditions. - Configured schema builder to throw a descriptive StateError if the A2UI submodule is not checked out. - Simplified button test mock stream to use Stream.error and wrapped test execution inside tester.runAsync. - Optimized _getSchemaRegistry() in SurfaceController to be synchronous and removed redundant await calls. - Bumped json_schema_builder version to 0.1.6 and documented public SchemaRegistry export in CHANGELOG.md. --- .../catalog/basic_catalog_widgets/button.dart | 26 ++++++------------- .../lib/src/engine/surface_controller.dart | 4 +-- .../catalog/core_widgets/button_test.dart | 15 +++++------ packages/genui/tool/schema_builder.dart | 9 ++++--- packages/json_schema_builder/CHANGELOG.md | 4 +++ packages/json_schema_builder/pubspec.yaml | 2 +- 6 files changed, 26 insertions(+), 34 deletions(-) diff --git a/packages/genui/lib/src/catalog/basic_catalog_widgets/button.dart b/packages/genui/lib/src/catalog/basic_catalog_widgets/button.dart index dbc96e186..052dee4ee 100644 --- a/packages/genui/lib/src/catalog/basic_catalog_widgets/button.dart +++ b/packages/genui/lib/src/catalog/basic_catalog_widgets/button.dart @@ -232,24 +232,14 @@ Future _handlePress( final Stream resultStream = itemContext.dataContext.resolve( funcMap, ); - final completer = Completer(); - StreamSubscription? subscription; - subscription = resultStream.listen( - (val) { - subscription?.cancel(); - if (!completer.isCompleted) completer.complete(); - }, - onError: (Object exception, StackTrace stackTrace) { - subscription?.cancel(); - itemContext.reportError(exception, stackTrace); - if (!completer.isCompleted) completer.complete(); - }, - onDone: () { - subscription?.cancel(); - if (!completer.isCompleted) completer.complete(); - }, - ); - await completer.future; + final iterator = StreamIterator(resultStream); + try { + await iterator.moveNext(); + } catch (exception, stackTrace) { + itemContext.reportError(exception, stackTrace); + } finally { + await iterator.cancel(); + } } else { genUiLogger.warning( 'Button action missing event or functionCall: $actionData', diff --git a/packages/genui/lib/src/engine/surface_controller.dart b/packages/genui/lib/src/engine/surface_controller.dart index e9cb9573e..28cbf836c 100644 --- a/packages/genui/lib/src/engine/surface_controller.dart +++ b/packages/genui/lib/src/engine/surface_controller.dart @@ -59,7 +59,7 @@ interface class SurfaceController implements SurfaceHost, A2uiMessageSink { final Map _liveDataModels = {}; SchemaRegistry? _schemaRegistry; - Future _getSchemaRegistry() async { + SchemaRegistry _getSchemaRegistry() { if (_schemaRegistry != null) return _schemaRegistry!; final registry = SchemaRegistry(); registry.addSchema( @@ -362,7 +362,7 @@ interface class SurfaceController implements SurfaceHost, A2uiMessageSink { core.SurfaceModel surface, Catalog catalog, ) async { - final SchemaRegistry registry = await _getSchemaRegistry(); + final SchemaRegistry registry = _getSchemaRegistry(); await schema_validation.validateComponents( surfaceId: surfaceId, components: surface.componentsModel.all.map( diff --git a/packages/genui/test/catalog/core_widgets/button_test.dart b/packages/genui/test/catalog/core_widgets/button_test.dart index caf0a42b6..7ac3888f9 100644 --- a/packages/genui/test/catalog/core_widgets/button_test.dart +++ b/packages/genui/test/catalog/core_widgets/button_test.dart @@ -78,13 +78,8 @@ void main() { ) async { final mockFunction = MockFunction( name: 'throwError', - onExecute: (args, context) { - final controller = StreamController(sync: true); - controller.addError(Exception('Stream error')); - // ignore: unawaited_futures - controller.close(); - return controller.stream; - }, + onExecute: (args, context) => + Stream.error(Exception('Stream error')), ); final surfaceController = SurfaceController( @@ -137,8 +132,10 @@ void main() { await tester.pumpAndSettle(); // Tap the button to trigger the function call - await tester.tap(find.byType(ElevatedButton)); - await tester.pumpAndSettle(); + await tester.runAsync(() async { + await tester.tap(find.byType(ElevatedButton)); + await Future.delayed(const Duration(milliseconds: 10)); + }); // Verify error was reported expect(message, isNotNull); diff --git a/packages/genui/tool/schema_builder.dart b/packages/genui/tool/schema_builder.dart index ae958fe98..dd01020ca 100644 --- a/packages/genui/tool/schema_builder.dart +++ b/packages/genui/tool/schema_builder.dart @@ -31,11 +31,12 @@ class SchemaBuilder implements Builder { ); if (!sourceDir.existsSync()) { - log.warning( - 'Source directory ${sourceDir.path} does not exist. ' - 'Make sure git submodules are checked out.', + throw StateError( + 'A2UI specification submodule not found at ${sourceDir.path}.\n' + 'Please initialize git submodules by running:\n' + ' git submodule update --init --recursive\n' + 'and then rebuild.', ); - return; } final files = { diff --git a/packages/json_schema_builder/CHANGELOG.md b/packages/json_schema_builder/CHANGELOG.md index 4ff81ea36..0970b4547 100644 --- a/packages/json_schema_builder/CHANGELOG.md +++ b/packages/json_schema_builder/CHANGELOG.md @@ -1,5 +1,9 @@ # `json_schema_builder` Change Log +## 0.1.6 (in progress) + +- **Feature**: Export `SchemaRegistry` to support managing schema references during component validation. + ## 0.1.5 - **Internal**: publishing workflow automation. diff --git a/packages/json_schema_builder/pubspec.yaml b/packages/json_schema_builder/pubspec.yaml index a910d9bc6..d273bd9ed 100644 --- a/packages/json_schema_builder/pubspec.yaml +++ b/packages/json_schema_builder/pubspec.yaml @@ -4,7 +4,7 @@ name: json_schema_builder description: A full-featured package used to build and validate JSON schemas in Dart. -version: 0.1.5 +version: 0.1.6 homepage: https://github.com/flutter/genui/tree/main/packages/json_schema_builder license: BSD-3-Clause issue_tracker: https://github.com/flutter/genui/issues From e51cde6465f41ac8ef5814a5e5289539e045c35f Mon Sep 17 00:00:00 2001 From: Greg Spencer Date: Fri, 10 Jul 2026 12:13:31 -0700 Subject: [PATCH 06/10] chore: update monorepo coverage baseline values --- coverage_baseline.yaml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/coverage_baseline.yaml b/coverage_baseline.yaml index e31fa51df..515fbe85d 100644 --- a/coverage_baseline.yaml +++ b/coverage_baseline.yaml @@ -3,11 +3,11 @@ # found in the LICENSE file. # Monorepo Coverage High-Water Marks (Auto-generated by test_and_fix --update-baseline) -dev_tools/catalog_gallery: 51.66 -dev_tools/composer: 20.28 -packages/a2ui_core: 76.30 +dev_tools/catalog_gallery: 51.43 +dev_tools/composer: 20.29 +packages/a2ui_core: 76.54 packages/genai_primitives: 100.00 -packages/genui: 79.38 +packages/genui: 79.66 packages/genui_a2a: 91.37 packages/json_schema_builder: 79.09 tool/e2e: 100.00 From 52d1363f275511dc5faa8504be4bcd5b76b3a479 Mon Sep 17 00:00:00 2001 From: Greg Spencer Date: Fri, 10 Jul 2026 12:17:08 -0700 Subject: [PATCH 07/10] refactor(genui): address second round of PR review feedback for schema builder - Changed schema builder's auto_apply policy to none in build.yaml to prevent breaking dependents. - Added check during directory traversal in schema_builder.dart to throw a descriptive StateError if .gitmodules is not found. - Updated missing schema source files handling to throw a StateError directly instead of failing silently. --- packages/genui/build.yaml | 2 +- packages/genui/tool/schema_builder.dart | 12 ++++++++++-- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/packages/genui/build.yaml b/packages/genui/build.yaml index a5b19c0ca..981088287 100644 --- a/packages/genui/build.yaml +++ b/packages/genui/build.yaml @@ -13,5 +13,5 @@ builders: import: "tool/schema_builder.dart" builder_factories: ["schemaBuilder"] build_extensions: {"pubspec.yaml": ["lib/src/primitives/embedded_schemas.g.dart"]} - auto_apply: dependents + auto_apply: none build_to: source diff --git a/packages/genui/tool/schema_builder.dart b/packages/genui/tool/schema_builder.dart index dd01020ca..d7bf12fe8 100644 --- a/packages/genui/tool/schema_builder.dart +++ b/packages/genui/tool/schema_builder.dart @@ -18,13 +18,22 @@ class SchemaBuilder implements Builder { @override Future build(BuildStep buildStep) async { // Find repository root by traversing upwards until we find .gitmodules. + var foundGitmodules = false; Directory dir = Directory.current; while (dir.path != dir.parent.path) { if (File('${dir.path}/.gitmodules').existsSync()) { + foundGitmodules = true; break; } dir = dir.parent; } + if (!foundGitmodules) { + throw StateError( + 'Could not find repository root containing .gitmodules starting from ' + '${Directory.current.path}. Please ensure you are running from within ' + 'the git repository and that submodules are initialized.', + ); + } final repoRoot = dir; final sourceDir = Directory( '${repoRoot.path}/submodules/a2ui/specification/v0_9/json', @@ -61,8 +70,7 @@ class SchemaBuilder implements Builder { final sourceFile = File('${sourceDir.path}/$filename'); if (!sourceFile.existsSync()) { - log.warning('Source file ${sourceFile.path} not found.'); - return; + throw StateError('Source file ${sourceFile.path} not found.'); } final content = sourceFile.readAsStringSync().trim(); From d6ebe90d08c5b3c1714f51394a6e8aee92d8c77c Mon Sep 17 00:00:00 2001 From: Greg Spencer Date: Fri, 10 Jul 2026 12:22:03 -0700 Subject: [PATCH 08/10] Update packages/genui/test/catalog/core_widgets/button_test.dart Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- packages/genui/test/catalog/core_widgets/button_test.dart | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/genui/test/catalog/core_widgets/button_test.dart b/packages/genui/test/catalog/core_widgets/button_test.dart index 7ac3888f9..ab98c42f0 100644 --- a/packages/genui/test/catalog/core_widgets/button_test.dart +++ b/packages/genui/test/catalog/core_widgets/button_test.dart @@ -132,9 +132,10 @@ void main() { await tester.pumpAndSettle(); // Tap the button to trigger the function call + final Future onSubmitFuture = surfaceController.onSubmit.first; await tester.runAsync(() async { await tester.tap(find.byType(ElevatedButton)); - await Future.delayed(const Duration(milliseconds: 10)); + await onSubmitFuture; }); // Verify error was reported From 1b3124531aca8430a37ae0fdcba0579a7ba09cb7 Mon Sep 17 00:00:00 2001 From: Greg Spencer Date: Fri, 10 Jul 2026 12:22:14 -0700 Subject: [PATCH 09/10] Update packages/genui/lib/src/engine/surface_controller.dart Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- packages/genui/lib/src/engine/surface_controller.dart | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/genui/lib/src/engine/surface_controller.dart b/packages/genui/lib/src/engine/surface_controller.dart index 28cbf836c..594222605 100644 --- a/packages/genui/lib/src/engine/surface_controller.dart +++ b/packages/genui/lib/src/engine/surface_controller.dart @@ -231,6 +231,7 @@ interface class SurfaceController implements SurfaceHost, A2uiMessageSink { surface, genuiCatalog, ).catchError((Object error, StackTrace stack) { + if (_onSubmit.isClosed) return; if (error is A2uiValidationException) { genUiLogger.warning( 'Schema validation failed for surface ' From 585f5436e066682ab35da214b2caed93808d64f3 Mon Sep 17 00:00:00 2001 From: Andrew Kolos Date: Mon, 13 Jul 2026 06:28:59 -0700 Subject: [PATCH 10/10] test(genui): fix zone deadlock in button stream-error test The onSubmit.first subscription was created in the fake-async test zone but awaited inside tester.runAsync. Stream events are delivered in the zone that subscribed, so the future could only complete on a fake-zone microtask that nothing pumps while the test is suspended inside runAsync. The test hung to the suite timeout and left the binding wedged, taking the other two button tests down with it. Subscribe inside runAsync so delivery uses real microtasks, and pump once afterwards so the fake-zone listener that records the message runs before the assertion. --- packages/genui/test/catalog/core_widgets/button_test.dart | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/genui/test/catalog/core_widgets/button_test.dart b/packages/genui/test/catalog/core_widgets/button_test.dart index ab98c42f0..b5845649b 100644 --- a/packages/genui/test/catalog/core_widgets/button_test.dart +++ b/packages/genui/test/catalog/core_widgets/button_test.dart @@ -132,11 +132,13 @@ void main() { await tester.pumpAndSettle(); // Tap the button to trigger the function call - final Future onSubmitFuture = surfaceController.onSubmit.first; await tester.runAsync(() async { + final Future onSubmitFuture = + surfaceController.onSubmit.first; await tester.tap(find.byType(ElevatedButton)); await onSubmitFuture; }); + await tester.pump(); // Verify error was reported expect(message, isNotNull);