diff --git a/.github/workflows/pub_publish.yaml b/.github/workflows/pub_publish.yaml index 81f6ff8cd..c29f437d2 100644 --- a/.github/workflows/pub_publish.yaml +++ b/.github/workflows/pub_publish.yaml @@ -23,7 +23,7 @@ jobs: sdk: beta # version of dart sdk to use for publishing use-flutter: true write-comments: false - checkout_submodules: false + checkout_submodules: true permissions: id-token: write pull-requests: write 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/dev_tools/catalog_gallery/pubspec.yaml b/dev_tools/catalog_gallery/pubspec.yaml index e9a66fe68..0d837f223 100644 --- a/dev_tools/catalog_gallery/pubspec.yaml +++ b/dev_tools/catalog_gallery/pubspec.yaml @@ -18,7 +18,7 @@ dependencies: file: ^7.0.1 flutter: sdk: flutter - genui: ^0.9.0 + genui: ^0.10.0 json_schema_builder: ^0.1.3 yaml: ^3.1.3 diff --git a/dev_tools/catalog_gallery/test/sample_source_test.dart b/dev_tools/catalog_gallery/test/sample_source_test.dart new file mode 100644 index 000000000..ddeb39bdb --- /dev/null +++ b/dev_tools/catalog_gallery/test/sample_source_test.dart @@ -0,0 +1,66 @@ +// 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. + +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:catalog_gallery/sample_source.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + group('AssetSampleSource', () { + test('listSamples loads and parses samples from asset bundle', () async { + final binaryMessenger = + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger; + + // Mock AssetManifest.bin + final manifestData = { + 'samples/hello.sample': [ + {'asset': 'samples/hello.sample'}, + ], + 'samples/world.sample': [ + {'asset': 'samples/world.sample'}, + ], + 'other/file.txt': [ + {'asset': 'other/file.txt'}, + ], + }; + + final ByteData manifestByteData = const StandardMessageCodec() + .encodeMessage(manifestData)!; + + binaryMessenger.setMockMessageHandler('flutter/assets', ( + ByteData? message, + ) async { + final String key = utf8.decode(message!.buffer.asUint8List()); + if (key == 'AssetManifest.bin' || key == 'AssetManifest.json') { + return manifestByteData; + } + if (key == 'samples/hello.sample') { + return ByteData.view(utf8.encode('hello content').buffer); + } + if (key == 'samples/world.sample') { + return ByteData.view(utf8.encode('world content').buffer); + } + return null; + }); + + addTearDown(() { + binaryMessenger.setMockMessageHandler('flutter/assets', null); + }); + + const source = AssetSampleSource(); + final samples = await source.listSamples(); + + expect(samples, hasLength(2)); + expect(samples[0].name, 'hello'); + expect(await samples[0].load(), 'hello content'); + expect(samples[1].name, 'world'); + expect(await samples[1].load(), 'world content'); + }); + }); +} diff --git a/dev_tools/composer/lib/create_tab.dart b/dev_tools/composer/lib/create_tab.dart index a82a7434b..5ca53e5f2 100644 --- a/dev_tools/composer/lib/create_tab.dart +++ b/dev_tools/composer/lib/create_tab.dart @@ -78,7 +78,7 @@ class _CreateTabState extends State { transport: transport, ); - final promptBuilder = PromptBuilder.chat( + final promptBuilder = await PromptBuilder.createChat( catalog: catalog, systemPromptFragments: [ 'You are a UI generator. The user will describe a UI they want. ' diff --git a/dev_tools/composer/pubspec.yaml b/dev_tools/composer/pubspec.yaml index df3a8b6f3..b2d6e4e4a 100644 --- a/dev_tools/composer/pubspec.yaml +++ b/dev_tools/composer/pubspec.yaml @@ -18,7 +18,7 @@ dependencies: sdk: flutter flutter_code_editor: ^0.3.5 flutter_highlight: ^0.7.0 - genui: ^0.9.0 + genui: ^0.10.0 highlight: ^0.7.0 logging: ^1.3.0 window_manager: ^0.5.1 diff --git a/examples/simple_chat/lib/chat_session.dart b/examples/simple_chat/lib/chat_session.dart index 59e6ed858..f06e64a82 100644 --- a/examples/simple_chat/lib/chat_session.dart +++ b/examples/simple_chat/lib/chat_session.dart @@ -64,19 +64,20 @@ final Catalog _customCatalog = _basicCatalog.copyWith( newItems: [climbingLocationItem], ); -PromptBuilder _promptBuilderFor(Catalog catalog) => PromptBuilder.chat( - catalog: catalog, - systemPromptFragments: [ - Prompts.summary, - PromptFragments.acknowledgeUser(), - PromptFragments.requireAtLeastOneSubmitElement( - prefix: PromptBuilder.defaultImportancePrefix, - ), - PromptFragments.uiGenerationRestriction( - prefix: PromptBuilder.defaultImportancePrefix, - ), - ], -); +Future _promptBuilderFor(Catalog catalog) async => + await PromptBuilder.createChat( + catalog: catalog, + systemPromptFragments: [ + Prompts.summary, + PromptFragments.acknowledgeUser(), + PromptFragments.requireAtLeastOneSubmitElement( + prefix: PromptBuilder.defaultImportancePrefix, + ), + PromptFragments.uiGenerationRestriction( + prefix: PromptBuilder.defaultImportancePrefix, + ), + ], + ); sealed class ChatSession extends ChangeNotifier { ChatSession._(); @@ -196,7 +197,7 @@ class A2uiChatSession extends ChatSession { late final StreamSubscription _submitSub; late final StreamSubscription _surfaceSub; - void _init() { + Future _init() async { _messageSub = _transport.incomingMessages.listen( _surfaceController.handleMessage, ); @@ -206,9 +207,8 @@ class A2uiChatSession extends ChatSession { ); _surfaceSub = _surfaceController.surfaceUpdates.listen(_onSurfaceUpdate); - _transport.addSystemMessage( - _promptBuilderFor(_catalog).systemPromptJoined(), - ); + final PromptBuilder pb = await _promptBuilderFor(_catalog); + _transport.addSystemMessage(pb.systemPromptJoined()); } void _onSurfaceUpdate(SurfaceUpdate update) { diff --git a/examples/simple_chat/pubspec.yaml b/examples/simple_chat/pubspec.yaml index 1a60e7c93..3a0456692 100644 --- a/examples/simple_chat/pubspec.yaml +++ b/examples/simple_chat/pubspec.yaml @@ -17,7 +17,7 @@ dependencies: flutter: sdk: flutter flutter_markdown_plus: ^1.0.7 - genui: ^0.9.0 + genui: ^0.10.0 json_schema_builder: ^0.1.3 logging: ^1.3.0 diff --git a/examples/verdure/client/pubspec.yaml b/examples/verdure/client/pubspec.yaml index 89c178257..cb4d78390 100644 --- a/examples/verdure/client/pubspec.yaml +++ b/examples/verdure/client/pubspec.yaml @@ -20,7 +20,7 @@ dependencies: sdk: flutter flutter_riverpod: ^3.1.0 flutter_svg: ^2.2.2 - genui: ^0.9.0 + genui: ^0.10.0 genui_a2a: ^0.9.0 go_router: ^17.0.0 image_picker: ^1.2.0 diff --git a/packages/genui/CHANGELOG.md b/packages/genui/CHANGELOG.md index cd8f84d5c..059aab902 100644 --- a/packages/genui/CHANGELOG.md +++ b/packages/genui/CHANGELOG.md @@ -1,8 +1,8 @@ # `genui` Changelog -## 0.10.0 (in progress) +## 0.10.0 -- **Refactor**: genui now runs on `package:a2ui_core`. See +- **BREAKING**: 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 message classes (`A2uiMessage`, `CreateSurface`, `UpdateComponents`, @@ -14,13 +14,21 @@ surface's data model via `SurfaceController.contextFor(id).dataModel`. - **BREAKING**: `SurfaceRegistry.updateSurface(...)` is removed; drive surfaces through `SurfaceController.handleMessage`. +- **BREAKING**: Changed `PromptBuilder.chat` and `PromptBuilder.custom` from synchronous factory constructors to asynchronous static methods (`createChat` and `createCustom`) to support asynchronous asset loading. +- **BREAKING**: Changed `_loadSchemas` return type to a named record structure. +- **BREAKING**: Restricted public API surface of low-level `primitives` exports. Only `CancellationException`, `CancellationSignal`, `JsonMap`, `basicCatalogId`, `configureLogging`, `genUiLogger`, and `generateId` are now exported from `package:genui/genui.dart`. - **Behavior**: `DataModel` writes are stricter; some writes that previously did nothing now throw, and sparse list writes fill skipped entries with `null`. - **Behavior**: A duplicate `createSurface` for an already-active surface id is now an error. - The catalog-widget authoring API is unchanged; `SurfaceDefinition` and `Component` remain genui types. +- **Refactor**: Extracted exception mapping logic to a private helper `_errorToMap` in `SurfaceController`. +- **Refactor**: Centralized and shared common schema registry initialization helper. +- **Refactor**: Extracted mock binary messenger asset setup to a shared helper for test reuse. +- **Fix**: Sanitized raw error messages exposed from `ArgumentError` in `Button` press handlers. +## 0.9.2 ## 0.9.1 - **Feature**: Updated example/README.md. diff --git a/packages/genui/assets/schemas/common_types.json b/packages/genui/assets/schemas/common_types.json new file mode 120000 index 000000000..20becfbf4 --- /dev/null +++ b/packages/genui/assets/schemas/common_types.json @@ -0,0 +1 @@ +../../../../submodules/a2ui/specification/v0_9/json/common_types.json \ No newline at end of file diff --git a/packages/genui/assets/schemas/server_to_client.json b/packages/genui/assets/schemas/server_to_client.json new file mode 120000 index 000000000..db30fcf89 --- /dev/null +++ b/packages/genui/assets/schemas/server_to_client.json @@ -0,0 +1 @@ +../../../../submodules/a2ui/specification/v0_9/json/server_to_client.json \ No newline at end of file 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..24e8a34ec 100644 --- a/packages/genui/lib/src/catalog/basic_catalog_widgets/button.dart +++ b/packages/genui/lib/src/catalog/basic_catalog_widgets/button.dart @@ -2,9 +2,12 @@ // 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'; +import '../../model/a2ui_exceptions.dart'; import '../../model/a2ui_schemas.dart'; import '../../model/catalog_item.dart'; import '../../model/data_model.dart'; @@ -231,9 +234,40 @@ Future _handlePress( funcMap, ); try { - await resultStream.first; + await resultStream.first.timeout( + const Duration(seconds: 10), + onTimeout: () => throw TimeoutException( + 'Function execution for $callName timed out', + ), + ); } catch (exception, stackTrace) { - itemContext.reportError(exception, stackTrace); + genUiLogger.severe( + 'Error executing function call "$callName" on button press', + exception, + stackTrace, + ); + + if (exception is A2uiFunctionException) { + itemContext.reportError(exception, stackTrace); + } else if (exception is TimeoutException) { + itemContext.reportError( + A2uiFunctionException( + 'Function execution timed out.', + functionName: callName, + cause: exception, + ), + stackTrace, + ); + } else { + itemContext.reportError( + A2uiFunctionException( + 'Function execution failed. Please check arguments and try again.', + functionName: callName, + cause: exception, + ), + stackTrace, + ); + } } } else { genUiLogger.warning( diff --git a/packages/genui/lib/src/catalog/basic_catalog_widgets/date_time_input.dart b/packages/genui/lib/src/catalog/basic_catalog_widgets/date_time_input.dart index be314b50d..b1b81b418 100644 --- a/packages/genui/lib/src/catalog/basic_catalog_widgets/date_time_input.dart +++ b/packages/genui/lib/src/catalog/basic_catalog_widgets/date_time_input.dart @@ -340,9 +340,7 @@ final dateTimeInput = CatalogItem( { "id": "root", "component": "DateTimeInput", - "value": { - "path": "/myDateTime" - } + "value": "2026-05-15" } ] ''', @@ -354,7 +352,7 @@ final dateTimeInput = CatalogItem( "value": { "path": "/myDate" }, - "enableTime": false + "variant": "date" } ] ''', @@ -366,7 +364,7 @@ final dateTimeInput = CatalogItem( "value": { "path": "/myTime" }, - "enableDate": false + "variant": "time" } ] ''', diff --git a/packages/genui/lib/src/catalog/basic_catalog_widgets/image.dart b/packages/genui/lib/src/catalog/basic_catalog_widgets/image.dart index 7e165b578..da9829cdd 100644 --- a/packages/genui/lib/src/catalog/basic_catalog_widgets/image.dart +++ b/packages/genui/lib/src/catalog/basic_catalog_widgets/image.dart @@ -78,7 +78,9 @@ final CatalogItem image = CatalogItem( { "id": "root", "component": "Image", - "url": "https://storage.googleapis.com/cms-storage-bucket/lockup_flutter_horizontal.c823e53b3a1a7b0d36a9.png", + "url": { + "path": "/imageUrl" + }, "variant": "mediumFeature" } ] diff --git a/packages/genui/lib/src/catalog/basic_catalog_widgets/text.dart b/packages/genui/lib/src/catalog/basic_catalog_widgets/text.dart index 34e17f9c9..f4b2b7899 100644 --- a/packages/genui/lib/src/catalog/basic_catalog_widgets/text.dart +++ b/packages/genui/lib/src/catalog/basic_catalog_widgets/text.dart @@ -52,8 +52,7 @@ final text = CatalogItem( { "id": "root", "component": "Text", - "text": "Hello World", - "variant": "h1" + "text": "Hello World" } ] ''', diff --git a/packages/genui/lib/src/engine/surface_controller.dart b/packages/genui/lib/src/engine/surface_controller.dart index 0f1ebb28d..2013cb764 100644 --- a/packages/genui/lib/src/engine/surface_controller.dart +++ b/packages/genui/lib/src/engine/surface_controller.dart @@ -13,6 +13,7 @@ import '../interfaces/a2ui_message_sink.dart'; import '../interfaces/surface_context.dart'; import '../interfaces/surface_host.dart'; import '../model/a2ui_client_capabilities.dart'; +import '../model/a2ui_exceptions.dart'; import '../model/catalog.dart'; import '../model/chat_message.dart'; import '../model/data_model.dart'; @@ -265,33 +266,45 @@ interface class SurfaceController implements SurfaceHost, A2uiMessageSink { /// Reports an error to the AI service. void reportError(Object error, StackTrace? stack) { - var errorCode = 'RUNTIME_ERROR'; - var message = error.toString(); + final Map errorMsg = { + 'version': 'v0.9', + 'error': _errorToMap(error), + }; + if (!_onSubmit.isClosed) { + _onSubmit.add( + ChatMessage.user( + '', + parts: [UiInteractionPart.create(jsonEncode(errorMsg))], + ), + ); + } + } + + Map _errorToMap(Object error) { + var errorCode = 'INTERNAL_ERROR'; + var message = 'An unexpected system error occurred.'; String? surfaceId; String? path; + String? functionName; if (error is A2uiValidationException) { errorCode = 'VALIDATION_FAILED'; message = error.message; surfaceId = error.surfaceId; path = error.path; + } else if (error is A2uiFunctionException) { + errorCode = 'FUNCTION_EXECUTION_FAILED'; + message = error.message; + functionName = error.functionName; } - final Map errorMsg = { - 'version': 'v0.9', - 'error': { - 'code': errorCode, - 'surfaceId': ?surfaceId, - 'path': ?path, - 'message': message, - }, + return { + 'code': errorCode, + 'surfaceId': ?surfaceId, + 'path': ?path, + 'functionName': ?functionName, + 'message': message, }; - _onSubmit.add( - ChatMessage.user( - '', - parts: [UiInteractionPart.create(jsonEncode(errorMsg))], - ), - ); } void _bufferMessage(String surfaceId, core.A2uiMessage message) { diff --git a/packages/genui/lib/src/facade/prompt_builder.dart b/packages/genui/lib/src/facade/prompt_builder.dart index e275e6c3f..f5d59e386 100644 --- a/packages/genui/lib/src/facade/prompt_builder.dart +++ b/packages/genui/lib/src/facade/prompt_builder.dart @@ -5,9 +5,10 @@ import 'dart:convert'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; -import '../model/a2ui_message.dart'; import '../model/catalog.dart'; +import '../primitives/constants.dart'; import '../primitives/simple_items.dart'; /// Common fragments for prompts, to explain agent behavior. @@ -78,12 +79,14 @@ abstract class PromptBuilder { /// The builder will generate a prompt for a chat session, /// that instructs to create new surfaces for each response /// and restrict surface deletion and updates. - factory PromptBuilder.chat({ + static Future createChat({ required Catalog catalog, Iterable systemPromptFragments = const [], String importancePrefix = defaultImportancePrefix, JsonMap? clientDataModel, - }) { + }) async { + final ({String commonTypes, String serverToClient}) schemas = + await _loadSchemas(); return _BasicPromptBuilder( catalog: catalog, systemPromptFragments: systemPromptFragments, @@ -91,10 +94,12 @@ abstract class PromptBuilder { importancePrefix: importancePrefix, clientDataModel: clientDataModel, technicalPossibilities: const TechnicalPossibilities(), + commonTypesSchema: schemas.commonTypes, + serverToClientSchema: schemas.serverToClient, ); } - factory PromptBuilder.custom({ + static Future createCustom({ required Catalog catalog, required SurfaceOperations allowedOperations, Iterable systemPromptFragments = const [], @@ -102,7 +107,9 @@ abstract class PromptBuilder { TechnicalPossibilities technicalPossibilities = const TechnicalPossibilities(), JsonMap? clientDataModel, - }) { + }) async { + final ({String commonTypes, String serverToClient}) schemas = + await _loadSchemas(); return _BasicPromptBuilder( catalog: catalog, systemPromptFragments: systemPromptFragments, @@ -110,9 +117,20 @@ abstract class PromptBuilder { importancePrefix: importancePrefix, clientDataModel: clientDataModel, technicalPossibilities: technicalPossibilities, + commonTypesSchema: schemas.commonTypes, + serverToClientSchema: schemas.serverToClient, ); } + static Future<({String commonTypes, String serverToClient})> + _loadSchemas() async { + final String commonTypes = await rootBundle.loadString(commonTypesAssetKey); + final String serverToClient = await rootBundle.loadString( + serverToClientAssetKey, + ); + return (commonTypes: commonTypes, serverToClient: serverToClient); + } + Iterable systemPrompt(); /// Returns the system prompt as a single string. @@ -332,9 +350,13 @@ final class _BasicPromptBuilder extends PromptBuilder { required this.importancePrefix, required this.clientDataModel, required this.technicalPossibilities, + required this.commonTypesSchema, + required this.serverToClientSchema, }) : super._(); final Catalog catalog; + final String commonTypesSchema; + final String serverToClientSchema; final SurfaceOperations allowedOperations; @@ -352,14 +374,23 @@ final class _BasicPromptBuilder extends PromptBuilder { final JsonMap? clientDataModel; + final TechnicalPossibilities technicalPossibilities; + Iterable _fragmentsToPrompt(Iterable fragments) => fragments.map((e) => e.trim()); - final TechnicalPossibilities technicalPossibilities; - @override Iterable systemPrompt() { - final String a2uiSchema = a2uiMessageSchema(catalog).toJson(indent: ' '); + final String catalogSchema = _generateCatalogSchema(catalog); + + final String cleanCommonTypes = commonTypesSchema.replaceAll( + commonTypesSchemaId, + 'common_types.json', + ); + final String cleanServerToClient = serverToClientSchema.replaceAll( + commonTypesSchemaId, + 'common_types.json', + ); final String? activeCatalogId = catalog.catalogId; @@ -372,13 +403,123 @@ final class _BasicPromptBuilder extends PromptBuilder { ...technicalPossibilities.systemPromptFragment(), ...catalog.systemPromptFragments, ...allowedOperations.systemPromptFragments, - _fenced(a2uiSchema, sectionName: 'A2UI JSON SCHEMA'), + _fenced(cleanCommonTypes, sectionName: 'COMMON TYPES'), + _fenced(catalogSchema, sectionName: 'CATALOG SCHEMA'), + _fenced(cleanServerToClient, sectionName: 'MESSAGE SCHEMA'), ?_encodedDataModel(clientDataModel), ]; return _fragmentsToPrompt(fragments); } + String _generateCatalogSchema(Catalog catalog) { + final Map components = { + for (final item in catalog.items) + item.name: { + 'type': 'object', + 'allOf': [ + {r'$ref': r'common_types.json#/$defs/ComponentCommon'}, + {r'$ref': r'#/$defs/CatalogComponentCommon'}, + { + 'type': 'object', + 'properties': { + 'component': {'const': item.name}, + ...item.dataSchema.value['properties'] as Map, + }, + 'required': { + 'component', + if (item.dataSchema.value['required'] is List) + ...(item.dataSchema.value['required'] as List), + }.toList(), + }, + ], + 'unevaluatedProperties': false, + }, + }; + + final Map functions = { + for (final func in catalog.functions) + func.name: { + 'description': func.description, + 'parameters': func.argumentSchema.value, + 'returnType': func.returnType.value, + }, + }; + + final Map catalogJson = { + r'$schema': 'https://json-schema.org/draft/2020-12/schema', + r'$id': 'https://a2ui.org/specification/v0_9/catalog.json', + 'title': 'A2UI Catalog', + 'description': 'Custom catalog of A2UI components and functions.', + if (catalog.catalogId != null) 'catalogId': catalog.catalogId, + 'components': components, + if (functions.isNotEmpty) 'functions': functions, + r'$defs': { + 'CatalogComponentCommon': { + 'type': 'object', + 'properties': { + 'id': { + 'type': 'string', + 'description': + 'A unique identifier for this component instance within ' + 'the surface. This ID is used to refer to the component ' + 'in layout children arrays or event handlers.', + }, + }, + 'required': ['id'], + }, + 'theme': { + 'type': 'object', + 'properties': { + 'primaryColor': { + 'type': 'string', + 'description': + 'The primary brand color used for highlights (e.g., ' + 'primary buttons, active borders). Renderers may generate ' + 'variants of this color for different contexts. Format: ' + "Hexadecimal code (e.g., '#00BFFF').", + 'pattern': r'^#[0-9a-fA-F]{6}$', + }, + 'iconUrl': { + 'type': 'string', + 'format': 'uri', + 'description': + 'A URL for an image that identifies the agent or tool ' + 'associated with the surface.', + }, + 'agentDisplayName': { + 'type': 'string', + 'description': + 'Text to be displayed next to the surface to identify ' + 'the agent or tool that created it.', + }, + }, + 'additionalProperties': true, + }, + 'anyComponent': components.isEmpty + ? {'not': {}} + : { + 'oneOf': [ + for (final name in components.keys) + {r'$ref': '#/components/$name'}, + ], + 'discriminator': {'propertyName': 'component'}, + }, + 'anyFunction': functions.isEmpty + ? {'not': {}} + : { + 'oneOf': [ + for (final name in functions.keys) + {r'$ref': '#/functions/$name'}, + ], + }, + }, + }; + + final String json = const JsonEncoder.withIndent(' ').convert(catalogJson); + return json.replaceAll(commonTypesSchemaId, 'common_types.json'); + } + static String? _encodedDataModel(JsonMap? clientDataModel) { if (clientDataModel == null) return null; final String encodedModel = const JsonEncoder.withIndent( diff --git a/packages/genui/lib/src/functions/format_string.dart b/packages/genui/lib/src/functions/format_string.dart index c966ec01f..ecadc855f 100644 --- a/packages/genui/lib/src/functions/format_string.dart +++ b/packages/genui/lib/src/functions/format_string.dart @@ -4,7 +4,6 @@ import 'package:json_schema_builder/json_schema_builder.dart'; import 'package:meta/meta.dart'; -import 'package:stream_transform/stream_transform.dart'; import '../model/client_function.dart'; import '../model/data_model.dart'; diff --git a/packages/genui/lib/src/model.dart b/packages/genui/lib/src/model.dart index 1c968a435..43e4d66dc 100644 --- a/packages/genui/lib/src/model.dart +++ b/packages/genui/lib/src/model.dart @@ -6,6 +6,7 @@ library; export 'model/a2ui_client_capabilities.dart'; +export 'model/a2ui_exceptions.dart'; export 'model/a2ui_message.dart'; export 'model/a2ui_schemas.dart'; export 'model/catalog.dart'; diff --git a/packages/genui/lib/src/model/a2ui_exceptions.dart b/packages/genui/lib/src/model/a2ui_exceptions.dart new file mode 100644 index 000000000..0f7c51cf8 --- /dev/null +++ b/packages/genui/lib/src/model/a2ui_exceptions.dart @@ -0,0 +1,38 @@ +// 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. + +/// Exception thrown when client function execution fails. +class A2uiFunctionException implements Exception { + /// Creates a [A2uiFunctionException]. + A2uiFunctionException( + this.message, { + required this.functionName, + this.argumentKey, + this.cause, + }); + + /// The sanitized diagnostic message. + final String message; + + /// The name of the function that failed. + final String functionName; + + /// The specific argument key that caused the error, if any. + final String? argumentKey; + + /// The underlying cause of the error, if any. + final Object? cause; + + @override + String toString() { + var result = 'A2uiFunctionException inside $functionName: $message'; + if (argumentKey != null) { + result += ' (argument: $argumentKey)'; + } + if (cause != null) { + result += '\nCause: $cause'; + } + return result; + } +} diff --git a/packages/genui/lib/src/model/a2ui_schemas.dart b/packages/genui/lib/src/model/a2ui_schemas.dart index 3e92022c9..47631f640 100644 --- a/packages/genui/lib/src/model/a2ui_schemas.dart +++ b/packages/genui/lib/src/model/a2ui_schemas.dart @@ -4,12 +4,15 @@ import 'package:json_schema_builder/json_schema_builder.dart'; +import '../primitives/constants.dart'; import '../primitives/simple_items.dart'; import 'catalog.dart'; /// Provides a set of pre-defined, reusable schema objects for common /// A2UI patterns, simplifying the creation of CatalogItem definitions. abstract final class A2uiSchemas { + static String get _commonTypesUri => commonTypesSchemaId; + /// Defines the usage of the function registry. static Schema clientFunctions() { return S.list( @@ -280,17 +283,9 @@ abstract final class A2uiSchemas { /// Schema for a validation check, including logic and an error message. static Schema validationCheck({String? description}) { - return S.object( + return S.combined( + $ref: '$_commonTypesUri#/\$defs/CheckRule', description: description, - properties: { - 'message': S.string(description: 'Error message if validation fails.'), - 'condition': S.any( - description: - 'DynamicBoolean condition (FunctionCall, DataBinding, or ' - 'literal).', - ), - }, - required: ['message', 'condition'], ); } @@ -300,16 +295,22 @@ abstract final class A2uiSchemas { String? description, List? enumValues, }) { - final literal = S.string( - description: 'A literal string value.', - enumValues: enumValues, - ); - final Schema binding = dataBindingSchema( - description: 'A path to a string.', - ); - final Schema function = functionCall(); + if (enumValues != null) { + return S.combined( + allOf: [ + S.combined($ref: '$_commonTypesUri#/\$defs/DynamicString'), + S.combined( + anyOf: [ + S.string(enumValues: enumValues), + S.object(), + ], + ), + ], + description: description, + ); + } return S.combined( - oneOf: [literal, binding, function], + $ref: '$_commonTypesUri#/\$defs/DynamicString', description: description, ); } @@ -317,13 +318,8 @@ abstract final class A2uiSchemas { /// Schema for a value that can be either a literal number or a /// data-bound path to a number in the DataModel. static Schema numberReference({String? description}) { - final literal = S.number(description: 'A literal number value.'); - final Schema binding = dataBindingSchema( - description: 'A path to a number.', - ); - final Schema function = functionCall(); return S.combined( - oneOf: [literal, binding, function], + $ref: '$_commonTypesUri#/\$defs/DynamicNumber', description: description, ); } @@ -331,13 +327,8 @@ abstract final class A2uiSchemas { /// Schema for a value that can be either a literal boolean or a /// data-bound path to a boolean in the DataModel. static Schema booleanReference({String? description}) { - final literal = S.boolean(description: 'A literal boolean value.'); - final Schema binding = dataBindingSchema( - description: 'A path to a boolean.', - ); - final Schema function = functionCall(); return S.combined( - oneOf: [literal, binding, function], + $ref: '$_commonTypesUri#/\$defs/DynamicBoolean', description: description, ); } @@ -383,46 +374,17 @@ abstract final class A2uiSchemas { /// /// Can be either a server-side event or a client-side function call. static Schema action({String? description}) { - final eventSchema = S.object( - properties: { - 'event': S.object( - properties: { - 'name': S.string( - description: - 'The name of the action to be dispatched to the server.', - ), - 'context': S.object( - description: 'Arbitrary context data to send with the action.', - additionalProperties: true, - ), - }, - required: ['name'], - ), - }, - required: ['event'], - ); - - final functionCallSchema = S.object( - properties: {'functionCall': functionCall()}, - required: ['functionCall'], - ); - return S.combined( + $ref: '$_commonTypesUri#/\$defs/Action', description: description, - oneOf: [eventSchema, functionCallSchema], ); } /// Schema for a value that can be either a literal array of strings or a /// data-bound path to an array of strings. static Schema stringArrayReference({String? description}) { - final literal = S.list(items: S.string()); - final Schema binding = dataBindingSchema( - description: 'A path to a string list.', - ); - final Schema function = functionCall(); return S.combined( - oneOf: [literal, binding, function], + $ref: '$_commonTypesUri#/\$defs/DynamicStringList', description: description, ); } diff --git a/packages/genui/lib/src/model/catalog_item.dart b/packages/genui/lib/src/model/catalog_item.dart index 715f335eb..6d5c7b40a 100644 --- a/packages/genui/lib/src/model/catalog_item.dart +++ b/packages/genui/lib/src/model/catalog_item.dart @@ -110,7 +110,7 @@ final class CatalogItem { final List requiredProps = originalMap['required'] as List? ?? []; - return ObjectSchema.fromMap({ + final schema = ObjectSchema.fromMap({ ...originalMap, 'properties': { ...properties, @@ -120,7 +120,9 @@ final class CatalogItem { }, }, 'required': ['component', ...requiredProps], + 'additionalProperties': true, }); + return schema; } /// The builder for this widget. diff --git a/packages/genui/lib/src/model/data_model.dart b/packages/genui/lib/src/model/data_model.dart index 9f9e88f61..982b2bace 100644 --- a/packages/genui/lib/src/model/data_model.dart +++ b/packages/genui/lib/src/model/data_model.dart @@ -6,7 +6,6 @@ import 'dart:async'; import 'package:a2ui_core/a2ui_core.dart' as core; import 'package:flutter/foundation.dart'; -import 'package:stream_transform/stream_transform.dart'; import '../primitives/logging.dart'; import '../primitives/simple_items.dart'; 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/lib/src/utils/stream_extensions.dart b/packages/genui/lib/src/utils/stream_extensions.dart index 87e85d8e8..7e6af0f9f 100644 --- a/packages/genui/lib/src/utils/stream_extensions.dart +++ b/packages/genui/lib/src/utils/stream_extensions.dart @@ -2,6 +2,7 @@ // 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:stream_transform/stream_transform.dart'; /// Extensions for [Iterable] of [Stream]s. @@ -17,3 +18,57 @@ extension CombineLatestAll on Iterable> { return first.combineLatestAll(skip(1)); } } + +/// Extensions for [Stream]. +extension SwitchMapExtension on Stream { + /// Maps each event to a new stream, and switches to emitting events from + /// the most recent inner stream. + Stream switchMap(Stream Function(T) convert) { + late StreamController controller; + StreamSubscription? outerSubscription; + StreamSubscription? innerSubscription; + + void cancelInner() { + innerSubscription?.cancel(); + innerSubscription = null; + } + + controller = StreamController( + sync: true, + onListen: () { + outerSubscription = listen( + (event) { + cancelInner(); + final Stream innerStream = convert(event); + innerSubscription = innerStream.listen( + (innerEvent) => controller.add(innerEvent), + onError: (Object error, StackTrace? stackTrace) => + controller.addError(error, stackTrace), + onDone: () { + innerSubscription = null; + if (outerSubscription == null) { + controller.close(); + } + }, + ); + }, + onError: (Object error, StackTrace? stackTrace) => + controller.addError(error, stackTrace), + onDone: () { + outerSubscription = null; + if (innerSubscription == null) { + controller.close(); + } + }, + ); + }, + onCancel: () { + cancelInner(); + outerSubscription?.cancel(); + outerSubscription = null; + }, + ); + + return controller.stream; + } +} diff --git a/packages/genui/lib/test/validation.dart b/packages/genui/lib/test/validation.dart index 24947a154..1cdd010f4 100644 --- a/packages/genui/lib/test/validation.dart +++ b/packages/genui/lib/test/validation.dart @@ -3,12 +3,17 @@ // found in the LICENSE file. import 'dart:convert'; +import 'dart:io'; import 'package:json_schema_builder/json_schema_builder.dart'; +// ignore: implementation_imports +import 'package:json_schema_builder/src/schema_registry.dart'; import '../src/model/a2ui_schemas.dart'; import '../src/model/catalog.dart'; import '../src/model/catalog_item.dart' show CatalogItem; + +import '../src/primitives/constants.dart'; import '../src/primitives/simple_items.dart'; /// A class to represent a validation error in a catalog item example. @@ -75,10 +80,17 @@ Future> validateCatalogItemExamples( ); } - final List validationErrors = await schema.validate({ + final Map surfaceUpdate = { surfaceIdKey: 'test-surface', 'components': components, - }); + }; + + final SchemaRegistry registry = createSchemaRegistryWithCommonTypes(); + + final List validationErrors = await schema.validate( + surfaceUpdate, + schemaRegistry: registry, + ); if (validationErrors.isNotEmpty) { errors.add( ExampleValidationError( @@ -91,3 +103,18 @@ Future> validateCatalogItemExamples( } return errors; } + +/// Creates a [SchemaRegistry] pre-populated with the common types schema. +SchemaRegistry createSchemaRegistryWithCommonTypes() { + var file = File(commonTypesLocalPath); + if (!file.existsSync()) { + file = File('../../$commonTypesLocalPath'); + } + final String commonTypesContent = file.readAsStringSync(); + final commonTypesSchema = Schema.fromMap( + jsonDecode(commonTypesContent) as Map, + ); + final registry = SchemaRegistry(); + registry.addSchema(Uri.parse(commonTypesSchemaId), commonTypesSchema); + return registry; +} diff --git a/packages/genui/pubspec.yaml b/packages/genui/pubspec.yaml index 71506f532..8ad18ce2b 100644 --- a/packages/genui/pubspec.yaml +++ b/packages/genui/pubspec.yaml @@ -4,7 +4,7 @@ name: genui description: Generates and displays generative user interfaces (GenUI) in Flutter using AI. -version: 0.9.2 +version: 0.10.0 homepage: https://github.com/flutter/genui/tree/main/packages/genui license: BSD-3-Clause issue_tracker: https://github.com/flutter/genui/issues @@ -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..06a77a047 100644 --- a/packages/genui/test/catalog/core_widgets/button_test.dart +++ b/packages/genui/test/catalog/core_widgets/button_test.dart @@ -9,10 +9,26 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:genui/genui.dart'; import 'package:json_schema_builder/json_schema_builder.dart'; +import 'package:logging/logging.dart'; import '../../test_infra/message_builders.dart'; void main() { + setUpAll(() { + Logger.root.level = Level.ALL; + Logger.root.onRecord.listen((record) { + debugPrint( + '[${record.level.name}] ${record.loggerName}: ${record.message}', + ); + if (record.error != null) { + debugPrint('Error: ${record.error}'); + } + if (record.stackTrace != null) { + debugPrint('StackTrace:\n${record.stackTrace}'); + } + }); + }); + testWidgets('Button widget renders and handles taps', ( WidgetTester tester, ) async { @@ -76,13 +92,11 @@ 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) { + return Stream.error(Exception('Stream error')); + }, ); final surfaceController = SurfaceController( @@ -94,7 +108,8 @@ void main() { ), ], ); - surfaceController.onSubmit.listen((event) => message = event); + + final Future onSubmitFuture = surfaceController.onSubmit.first; const surfaceId = 'testSurface'; final List components = [ @@ -103,7 +118,9 @@ void main() { type: 'Button', properties: { 'child': 'button_text', - 'action': {'call': 'throwError'}, + 'action': { + 'functionCall': {'call': 'throwError'}, + }, }, ), component( @@ -134,24 +151,21 @@ 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 + // Pump to process the tap and invoke the function which throws 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++; - } + // Advance fake time to process stream error propagation and + // error reporting. + await tester.pump(const Duration(seconds: 1)); - // Verify error was reported + // Verify the error was caught and reported + final ChatMessage message = await onSubmitFuture; expect(message, isNotNull); + expect( + message.parts.first.asUiInteractionPart!.interaction, + contains('throwError'), + ); - // The test passes if no unhandled exception crashes the test. - await streamController.close(); surfaceController.dispose(); }); diff --git a/packages/genui/test/catalog/functions_rendering_test.dart b/packages/genui/test/catalog/functions_rendering_test.dart new file mode 100644 index 000000000..b55c4b28c --- /dev/null +++ b/packages/genui/test/catalog/functions_rendering_test.dart @@ -0,0 +1,89 @@ +// 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. + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:genui/genui.dart'; +import '../test_infra/message_builders.dart'; + +void main() { + late SurfaceController controller; + final testCatalog = Catalog( + [BasicCatalogItems.text, BasicCatalogItems.column], + functions: BasicFunctions.all, + catalogId: 'test_catalog', + ); + + setUp(() { + controller = SurfaceController(catalogs: [testCatalog]); + }); + + tearDown(() { + controller.dispose(); + }); + + testWidgets('Surface renders function output correctly', ( + WidgetTester tester, + ) async { + const surfaceId = 'testSurface'; + + // 1. Create surface + controller.handleMessage( + createSurface(surfaceId: surfaceId, catalogId: 'test_catalog'), + ); + + // 2. Update data model + controller.handleMessage( + updateDataModel( + surfaceId: surfaceId, + path: DataPath.root, + value: {'count': 2}, + ), + ); + + // 3. Update components with a function call + final components = [ + const Component( + id: 'root', + type: 'Column', + properties: { + 'children': ['cartSummaryText'], + }, + ), + const Component( + id: 'cartSummaryText', + type: 'Text', + properties: { + 'text': { + 'call': 'pluralize', + 'args': { + 'count': {'path': '/count'}, + 'zero': 'No items', + 'one': 'One item', + 'other': 'Multiple items', + }, + 'returnType': 'string', + }, + }, + ), + ]; + + controller.handleMessage( + updateComponents( + surfaceId: surfaceId, + components: components.map((c) => c.toJson()).toList(), + ), + ); + + await tester.pumpWidget( + MaterialApp( + home: Surface(surfaceContext: controller.contextFor(surfaceId)), + ), + ); + await tester.pumpAndSettle(); + + // We expect "Multiple items" because count is 2. + expect(find.text('Multiple items'), findsOneWidget); + }); +} diff --git a/packages/genui/test/core_catalog_validation_test.dart b/packages/genui/test/core_catalog_validation_test.dart index 3c0010a51..8e2237683 100644 --- a/packages/genui/test/core_catalog_validation_test.dart +++ b/packages/genui/test/core_catalog_validation_test.dart @@ -1,10 +1,8 @@ -// 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. - +import 'package:flutter/widgets.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:genui/genui.dart'; import 'package:genui/test.dart'; +import 'package:json_schema_builder/json_schema_builder.dart'; void main() { group('Basic Catalog Validation', () { @@ -18,4 +16,62 @@ void main() { }); } }); + + group('Catalog Validation Error Paths', () { + test('ExampleValidationError toString', () { + final err1 = ExampleValidationError(1, 'some error'); + expect(err1.toString(), 'Validation error in example 1: some error'); + + final err2 = ExampleValidationError(2, 'some error', cause: 'some cause'); + expect( + err2.toString(), + 'Validation error in example 2: some error\nCause: some cause', + ); + }); + + test('validateCatalogItemExamples invalid JSON', () async { + final item = CatalogItem( + name: 'TestItem', + dataSchema: ObjectSchema.fromMap(const {}), + exampleData: [() => '{invalid json'], + widgetBuilder: (_) => const SizedBox(), + ); + final catalog = const Catalog([]); + final errors = await validateCatalogItemExamples(item, catalog); + expect(errors, hasLength(1)); + expect(errors[0].message, 'Failed to parse as a JSON list'); + expect(errors[0].cause, isA()); + }); + + test('validateCatalogItemExamples missing root component', () async { + final item = CatalogItem( + name: 'TestItem', + dataSchema: ObjectSchema.fromMap(const {}), + exampleData: [ + () => + '[{"id": "not-root", "component": {"Text": {"text": "hello"}}}]', + ], + widgetBuilder: (_) => const SizedBox(), + ); + final catalog = const Catalog([]); + final errors = await validateCatalogItemExamples(item, catalog); + expect(errors, hasLength(1)); + expect(errors[0].message, 'Example must have a component with id "root"'); + }); + + test('validateCatalogItemExamples schema validation failure', () async { + final item = CatalogItem( + name: 'Text', + dataSchema: ObjectSchema.fromMap(const {}), + // Text widget missing the text property (which is required by the schema) + exampleData: [() => '[{"id": "root", "component": {"Text": {}}}]'], + widgetBuilder: (_) => const SizedBox(), + ); + final catalog = BasicCatalogItems.asCatalog(); + final errors = await validateCatalogItemExamples(item, catalog); + expect(errors, hasLength(1)); + expect(errors[0].message, 'Schema validation failed'); + expect(errors[0].cause, isNotEmpty); + }); + }); } diff --git a/packages/genui/test/error_boundary_test.dart b/packages/genui/test/error_boundary_test.dart new file mode 100644 index 000000000..64ca0e50a --- /dev/null +++ b/packages/genui/test/error_boundary_test.dart @@ -0,0 +1,226 @@ +// 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. + +import 'dart:async'; +import 'dart:convert'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:genui/genui.dart'; +import 'package:json_schema_builder/json_schema_builder.dart'; +import 'package:logging/logging.dart'; +import 'test_infra/message_builders.dart'; + +void main() { + group('Secure Error Boundary Tests', () { + setUp(() { + hierarchicalLoggingEnabled = true; + Logger.root.level = Level.ALL; + Logger.root.onRecord.listen((record) { + // ignore: avoid_print + print('[${record.level.name}] ${record.message}'); + if (record.error != null) { + // ignore: avoid_print + print(' Error: ${record.error}'); + } + }); + }); + test( + 'A2uiValidationException is reported cleanly as VALIDATION_FAILED', + () async { + final surfaceController = SurfaceController(catalogs: []); + final Completer errorCompleter = Completer(); + + surfaceController.onSubmit.listen((event) { + final String interaction = + event.parts.first.asUiInteractionPart!.interaction; + final data = jsonDecode(interaction) as JsonMap; + errorCompleter.complete(data); + }); + + surfaceController.reportError( + A2uiValidationException( + 'Invalid component properties', + surfaceId: 'test-surface', + path: '/components/0', + ), + StackTrace.current, + ); + + final JsonMap result = await errorCompleter.future; + expect(result['version'], equals('v0.9')); + final error = result['error'] as JsonMap; + expect(error['code'], equals('VALIDATION_FAILED')); + expect(error['message'], equals('Invalid component properties')); + expect(error['surfaceId'], equals('test-surface')); + expect(error['path'], equals('/components/0')); + expect(error.containsKey('stackTrace'), isFalse); + }, + ); + + test( + 'A2uiFunctionException is reported as FUNCTION_EXECUTION_FAILED', + () async { + final surfaceController = SurfaceController(catalogs: []); + final Completer errorCompleter = Completer(); + + surfaceController.onSubmit.listen((event) { + final String interaction = + event.parts.first.asUiInteractionPart!.interaction; + final data = jsonDecode(interaction) as JsonMap; + errorCompleter.complete(data); + }); + + surfaceController.reportError( + A2uiFunctionException( + 'Custom rule validation failed', + functionName: 'validateEmail', + argumentKey: 'email', + ), + StackTrace.current, + ); + + final JsonMap result = await errorCompleter.future; + expect(result['version'], equals('v0.9')); + final error = result['error'] as JsonMap; + expect(error['code'], equals('FUNCTION_EXECUTION_FAILED')); + expect(error['message'], equals('Custom rule validation failed')); + expect(error['functionName'], equals('validateEmail')); + expect(error.containsKey('stackTrace'), isFalse); + }, + ); + + test('Raw VM exceptions are completely masked as INTERNAL_ERROR', () async { + final surfaceController = SurfaceController(catalogs: []); + final Completer errorCompleter = Completer(); + + surfaceController.onSubmit.listen((event) { + final String interaction = + event.parts.first.asUiInteractionPart!.interaction; + final data = jsonDecode(interaction) as JsonMap; + errorCompleter.complete(data); + }); + + // Simulate a VM/internal crash + surfaceController.reportError(TypeError(), StackTrace.current); + + final JsonMap result = await errorCompleter.future; + expect(result['version'], equals('v0.9')); + final error = result['error'] as JsonMap; + expect(error['code'], equals('INTERNAL_ERROR')); + expect(error['message'], equals('An unexpected system error occurred.')); + expect(error.containsKey('surfaceId'), isFalse); + expect(error.containsKey('path'), isFalse); + expect(error.containsKey('stackTrace'), isFalse); + }); + + testWidgets('Button widget handles action VM throws by wrapping in ' + 'A2uiFunctionException', (WidgetTester tester) async { + final mockFunction = MockFunction( + name: 'crashFunc', + onExecute: (args, context) => throw TypeError(), + ); + + final surfaceController = SurfaceController( + catalogs: [ + Catalog( + [BasicCatalogItems.button, BasicCatalogItems.text], + catalogId: 'test_catalog', + functions: [mockFunction], + ), + ], + ); + + final List messages = []; + surfaceController.onSubmit.listen(messages.add); + + const surfaceId = 'testSurface'; + final components = [ + const Component( + id: 'root', + type: 'Button', + properties: { + 'child': 'button_text', + 'action': { + 'functionCall': {'call': 'crashFunc'}, + }, + }, + ), + const Component( + id: 'button_text', + type: 'Text', + properties: {'text': 'Click Me'}, + ), + ]; + + surfaceController.handleMessage( + updateComponents( + surfaceId: surfaceId, + components: components.map((c) => c.toJson()).toList(), + ), + ); + surfaceController.handleMessage( + createSurface(surfaceId: surfaceId, catalogId: 'test_catalog'), + ); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Surface( + surfaceContext: surfaceController.contextFor(surfaceId), + ), + ), + ), + ); + + await tester.pumpAndSettle(); + + expect(find.byType(ElevatedButton), findsOneWidget); + final ElevatedButton button = tester.widget( + find.byType(ElevatedButton), + ); + expect(button.onPressed, isNotNull); + await tester.runAsync(() async { + await tester.tap(find.byType(ElevatedButton)); + await Future.delayed(const Duration(milliseconds: 100)); + }); + + expect(messages, isNotEmpty); + final String interaction = + messages.first.parts.first.asUiInteractionPart!.interaction; + final result = jsonDecode(interaction) as JsonMap; + expect(result['version'], equals('v0.9')); + final error = result['error'] as JsonMap; + expect(error['code'], equals('FUNCTION_EXECUTION_FAILED')); + expect(error['message'], contains('Function execution failed')); + expect(error['functionName'], equals('crashFunc')); + expect(error.containsKey('stackTrace'), isFalse); + + surfaceController.dispose(); + }); + }); +} + +class MockFunction extends SynchronousClientFunction { + MockFunction({required this.name, required this.onExecute}); + + @override + final String name; + + final Object? Function(JsonMap, ExecutionContext) onExecute; + + @override + String get description => 'Mock function for testing.'; + + @override + ClientFunctionReturnType get returnType => ClientFunctionReturnType.empty; + + @override + Schema get argumentSchema => S.object(); + + @override + Object? executeSync(JsonMap args, ExecutionContext context) { + return onExecute(args, context); + } +} diff --git a/packages/genui/test/facade/prompt_builder_test.dart b/packages/genui/test/facade/prompt_builder_test.dart index cbb04f1ef..5a5cfd2dd 100644 --- a/packages/genui/test/facade/prompt_builder_test.dart +++ b/packages/genui/test/facade/prompt_builder_test.dart @@ -2,12 +2,19 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. +import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:genui/genui.dart'; +import 'package:json_schema_builder/json_schema_builder.dart'; import '../test_infra/golden_texts.dart'; +import '../test_infra/mock_assets.dart'; void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + setUpAll(setUpMockPackageAssets); + final testCatalog = Catalog( [BasicCatalogItems.text], catalogId: 'test_catalog', @@ -21,22 +28,25 @@ void main() { ); group('Chat prompt', () { - test('is equivalent to custom prompt with create only operations', () { - final systemPromptFragments = [ - 'You are a chat assistant.', - 'You sometimes tell jokes to the user', - ]; - final chatBuilder = PromptBuilder.chat( - catalog: testCatalog, - systemPromptFragments: systemPromptFragments, - ); - final customBuilder = PromptBuilder.custom( - catalog: testCatalog, - allowedOperations: SurfaceOperations.createOnly(dataModel: false), - systemPromptFragments: systemPromptFragments, - ); - expect(chatBuilder.systemPrompt(), customBuilder.systemPrompt()); - }); + test( + 'is equivalent to custom prompt with create only operations', + () async { + final systemPromptFragments = [ + 'You are a chat assistant.', + 'You sometimes tell jokes to the user', + ]; + final PromptBuilder chatBuilder = await PromptBuilder.createChat( + catalog: testCatalog, + systemPromptFragments: systemPromptFragments, + ); + final PromptBuilder customBuilder = await PromptBuilder.createCustom( + catalog: testCatalog, + allowedOperations: SurfaceOperations.createOnly(dataModel: false), + systemPromptFragments: systemPromptFragments, + ); + expect(chatBuilder.systemPrompt(), customBuilder.systemPrompt()); + }, + ); }); group('Custom prompt', () { @@ -62,14 +72,14 @@ void main() { for (MapEntry b in operationsUnderTheTest.entries) { - test(b.key, () { + test(b.key, () async { final SurfaceOperations operations = b.value; - final String prompt = PromptBuilder.custom( + final String prompt = (await PromptBuilder.createCustom( catalog: testCatalog, allowedOperations: operations, systemPromptFragments: systemPromptFragments, - ).systemPromptJoined(); + )).systemPromptJoined(); for (final fragment in systemPromptFragments) { expect(prompt, contains(fragment)); @@ -124,19 +134,72 @@ void main() { } }); + group('Prompt with functions', () { + test('includes functions when catalog has functions', () async { + final catalogWithFunctions = Catalog( + [BasicCatalogItems.text], + functions: [BasicFunctions.pluralizeFunction], + catalogId: 'test_catalog', + ); + + final String prompt = (await PromptBuilder.createChat( + catalog: catalogWithFunctions, + )).systemPromptJoined(); + + expect(prompt, contains('pluralize')); + expect( + prompt, + contains( + 'Returns a localized string based on the Common Locale Data ' + 'Repository', + ), + ); + }); + }); + + group('Prompt with custom components', () { + test('includes custom component schema in prompt', () async { + final customItem = CatalogItem( + name: 'CustomCard', + dataSchema: S.object( + properties: { + 'title': A2uiSchemas.stringReference(), + 'elevation': S.number(description: 'Card elevation.'), + }, + required: ['title'], + ), + widgetBuilder: (ctx) => const SizedBox(), // Dummy builder + ); + + final customCatalog = Catalog([customItem], catalogId: 'custom_catalog'); + + final String prompt = (await PromptBuilder.createChat( + catalog: customCatalog, + )).systemPromptJoined(); + + expect(prompt, contains('CustomCard')); + expect(prompt, contains('Card elevation.')); + expect(prompt, contains('"title"')); + }); + }); + group('Catalog ID', () { - test('is surfaced in system prompt when provided', () { + test('is surfaced in system prompt when provided', () async { final catalog = Catalog([ BasicCatalogItems.text, ], catalogId: 'my_custom_catalog'); - final builder = PromptBuilder.chat(catalog: catalog); + final PromptBuilder builder = await PromptBuilder.createChat( + catalog: catalog, + ); final String prompt = builder.systemPromptJoined(); expect(prompt, contains('The active catalog ID is: "my_custom_catalog"')); }); - test('is not surfaced in system prompt when not provided', () { + test('is not surfaced in system prompt when not provided', () async { final catalog = Catalog([BasicCatalogItems.text]); - final builder = PromptBuilder.chat(catalog: catalog); + final PromptBuilder builder = await PromptBuilder.createChat( + catalog: catalog, + ); final String prompt = builder.systemPromptJoined(); expect(prompt, isNot(contains('The active catalog ID is:'))); }); diff --git a/packages/genui/test/facade/prompt_builder_test.golden/all_operations_with_dataModel_false.txt b/packages/genui/test/facade/prompt_builder_test.golden/all_operations_with_dataModel_false.txt index 3d27339c3..cf2921f97 100644 --- a/packages/genui/test/facade/prompt_builder_test.golden/all_operations_with_dataModel_false.txt +++ b/packages/genui/test/facade/prompt_builder_test.golden/all_operations_with_dataModel_false.txt @@ -129,57 +129,473 @@ When constructing UI, you must output a VALID A2UI JSON object representing one ------------------------------------- ------A2UI_JSON_SCHEMA_START----- +-----COMMON_TYPES_START----- { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "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 + } + ] + } + } +} +-----COMMON_TYPES_END----- + +------------------------------------- + +-----CATALOG_SCHEMA_START----- +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://a2ui.org/specification/v0_9/catalog.json", + "title": "A2UI Catalog", + "description": "Custom catalog of A2UI components and functions.", + "catalogId": "test_catalog", + "components": { + "Text": { + "type": "object", + "allOf": [ + { + "$ref": "common_types.json#/$defs/ComponentCommon" + }, + { + "$ref": "#/$defs/CatalogComponentCommon" + }, + { + "type": "object", + "properties": { + "component": { + "type": "string", + "enum": [ + "Text" + ] + }, + "text": { + "description": "While simple Markdown is supported (without HTML or image references), utilizing dedicated UI components is generally preferred for a richer and more structured presentation.", + "$ref": "common_types.json#/$defs/DynamicString" + }, + "variant": { + "type": "string", + "description": "A hint for the base text style.", + "enum": [ + "h1", + "h2", + "h3", + "h4", + "h5", + "caption", + "body" + ] + } + }, + "required": [ + "component", + "text" + ] + } + ], + "unevaluatedProperties": false + } + }, + "$defs": { + "CatalogComponentCommon": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "A unique identifier for this component instance within the surface. This ID is used to refer to the component in layout children arrays or event handlers." + } + }, + "required": [ + "id" + ] + }, + "theme": { + "type": "object", + "properties": { + "primaryColor": { + "type": "string", + "description": "The primary brand color used for highlights (e.g., primary buttons, active borders). Renderers may generate variants of this color for different contexts. Format: Hexadecimal code (e.g., '#00BFFF').", + "pattern": "^#[0-9a-fA-F]{6}$" + }, + "iconUrl": { + "type": "string", + "format": "uri", + "description": "A URL for an image that identifies the agent or tool associated with the surface." + }, + "agentDisplayName": { + "type": "string", + "description": "Text to be displayed next to the surface to identify the agent or tool that created it." + } + }, + "additionalProperties": true + }, + "anyComponent": { + "oneOf": [ + { + "$ref": "#/components/Text" + } + ], + "discriminator": { + "propertyName": "component" + } + }, + "anyFunction": { + "not": {} + } + } +} +-----CATALOG_SCHEMA_END----- + +------------------------------------- + +-----MESSAGE_SCHEMA_START----- +{ + "$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": { - "type": "string", "const": "v0.9" }, "createSurface": { "type": "object", - "description": "Signals the client to create a new surface and begin rendering it. When this message is sent, the client will expect 'updateComponents' and/or 'updateDataModel' messages for the same surfaceId that define the component tree.", + "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 ID for the surface." + "description": "The unique identifier for the UI surface to be rendered." }, "catalogId": { - "type": "string", - "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'." + "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": { - "type": "object", - "description": "Theme parameters for the surface.", - "additionalProperties": true + "$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": "Whether to send the data model to every client request." + "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" - ] + "required": ["surfaceId", "catalogId"], + "additionalProperties": false } }, - "required": [ - "version", - "createSurface" - ], + "required": ["createSurface", "version"], "additionalProperties": false }, - { + "UpdateComponentsMessage": { "type": "object", "properties": { "version": { - "type": "string", "const": "v0.9" }, "updateComponents": { @@ -188,104 +604,29 @@ When constructing UI, you must output a VALID A2UI JSON object representing one "properties": { "surfaceId": { "type": "string", - "description": "The unique identifier for the UI surface." + "description": "The unique identifier for the UI surface to be updated." }, + "components": { "type": "array", - "description": "A flat list of component definitions.", + "description": "A list containing all UI components for the surface.", + "minItems": 1, "items": { - "description": "Must match one of the component definitions in the catalog.", - "oneOf": [ - { - "type": "object", - "description": "A block of styled text.", - "properties": { - "text": { - "description": "While simple Markdown is supported (without HTML or image references), utilizing dedicated UI components is generally preferred for a richer and more structured presentation.", - "oneOf": [ - { - "type": "string", - "description": "A literal string value." - }, - { - "type": "object", - "description": "A path to a string.", - "properties": { - "path": { - "type": "string", - "description": "A relative or absolute path in the data model." - } - }, - "required": [ - "path" - ] - }, - { - "type": "object", - "properties": { - "call": { - "type": "string", - "description": "The name of the function to call." - }, - "args": { - "type": "object", - "description": "Arguments to pass to the function.", - "additionalProperties": true - } - }, - "required": [ - "call" - ] - } - ] - }, - "variant": { - "type": "string", - "description": "A hint for the base text style.", - "enum": [ - "h1", - "h2", - "h3", - "h4", - "h5", - "caption", - "body" - ] - }, - "component": { - "type": "string", - "enum": [ - "Text" - ] - } - }, - "required": [ - "component", - "text" - ] - } - ] - }, - "minItems": 1 + "$ref": "catalog.json#/$defs/anyComponent" + } } }, - "required": [ - "surfaceId", - "components" - ] + "required": ["surfaceId", "components"], + "additionalProperties": false } }, - "required": [ - "version", - "updateComponents" - ], + "required": ["updateComponents", "version"], "additionalProperties": false }, - { + "UpdateDataModelMessage": { "type": "object", "properties": { "version": { - "type": "string", "const": "v0.9" }, "updateDataModel": { @@ -293,32 +634,29 @@ When constructing UI, you must output a VALID A2UI JSON object representing one "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" + "type": "string", + "description": "The unique identifier for the UI surface this data model update applies to." }, "path": { "type": "string", - "default": "/" + "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 new value to write to the data model. If null/omitted, the key is removed." + "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" - ] + "required": ["surfaceId"], + "additionalProperties": false } }, - "required": [ - "version", - "updateDataModel" - ], + "required": ["updateDataModel", "version"], "additionalProperties": false }, - { + "DeleteSurfaceMessage": { "type": "object", "properties": { "version": { - "type": "string", "const": "v0.9" }, "deleteSurface": { @@ -326,20 +664,17 @@ When constructing UI, you must output a VALID A2UI JSON object representing one "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" + "type": "string", + "description": "The unique identifier for the UI surface to be deleted." } }, - "required": [ - "surfaceId" - ] + "required": ["surfaceId"], + "additionalProperties": false } }, - "required": [ - "version", - "deleteSurface" - ], + "required": ["deleteSurface", "version"], "additionalProperties": false } - ] + } } ------A2UI_JSON_SCHEMA_END----- +-----MESSAGE_SCHEMA_END----- diff --git a/packages/genui/test/facade/prompt_builder_test.golden/all_operations_with_dataModel_true.txt b/packages/genui/test/facade/prompt_builder_test.golden/all_operations_with_dataModel_true.txt index 7661b3a97..1dbf4957a 100644 --- a/packages/genui/test/facade/prompt_builder_test.golden/all_operations_with_dataModel_true.txt +++ b/packages/genui/test/facade/prompt_builder_test.golden/all_operations_with_dataModel_true.txt @@ -131,57 +131,473 @@ When constructing UI, you must output a VALID A2UI JSON object representing one ------------------------------------- ------A2UI_JSON_SCHEMA_START----- +-----COMMON_TYPES_START----- { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "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 + } + ] + } + } +} +-----COMMON_TYPES_END----- + +------------------------------------- + +-----CATALOG_SCHEMA_START----- +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://a2ui.org/specification/v0_9/catalog.json", + "title": "A2UI Catalog", + "description": "Custom catalog of A2UI components and functions.", + "catalogId": "test_catalog", + "components": { + "Text": { + "type": "object", + "allOf": [ + { + "$ref": "common_types.json#/$defs/ComponentCommon" + }, + { + "$ref": "#/$defs/CatalogComponentCommon" + }, + { + "type": "object", + "properties": { + "component": { + "type": "string", + "enum": [ + "Text" + ] + }, + "text": { + "description": "While simple Markdown is supported (without HTML or image references), utilizing dedicated UI components is generally preferred for a richer and more structured presentation.", + "$ref": "common_types.json#/$defs/DynamicString" + }, + "variant": { + "type": "string", + "description": "A hint for the base text style.", + "enum": [ + "h1", + "h2", + "h3", + "h4", + "h5", + "caption", + "body" + ] + } + }, + "required": [ + "component", + "text" + ] + } + ], + "unevaluatedProperties": false + } + }, + "$defs": { + "CatalogComponentCommon": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "A unique identifier for this component instance within the surface. This ID is used to refer to the component in layout children arrays or event handlers." + } + }, + "required": [ + "id" + ] + }, + "theme": { + "type": "object", + "properties": { + "primaryColor": { + "type": "string", + "description": "The primary brand color used for highlights (e.g., primary buttons, active borders). Renderers may generate variants of this color for different contexts. Format: Hexadecimal code (e.g., '#00BFFF').", + "pattern": "^#[0-9a-fA-F]{6}$" + }, + "iconUrl": { + "type": "string", + "format": "uri", + "description": "A URL for an image that identifies the agent or tool associated with the surface." + }, + "agentDisplayName": { + "type": "string", + "description": "Text to be displayed next to the surface to identify the agent or tool that created it." + } + }, + "additionalProperties": true + }, + "anyComponent": { + "oneOf": [ + { + "$ref": "#/components/Text" + } + ], + "discriminator": { + "propertyName": "component" + } + }, + "anyFunction": { + "not": {} + } + } +} +-----CATALOG_SCHEMA_END----- + +------------------------------------- + +-----MESSAGE_SCHEMA_START----- +{ + "$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": { - "type": "string", "const": "v0.9" }, "createSurface": { "type": "object", - "description": "Signals the client to create a new surface and begin rendering it. When this message is sent, the client will expect 'updateComponents' and/or 'updateDataModel' messages for the same surfaceId that define the component tree.", + "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 ID for the surface." + "description": "The unique identifier for the UI surface to be rendered." }, "catalogId": { - "type": "string", - "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'." + "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": { - "type": "object", - "description": "Theme parameters for the surface.", - "additionalProperties": true + "$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": "Whether to send the data model to every client request." + "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" - ] + "required": ["surfaceId", "catalogId"], + "additionalProperties": false } }, - "required": [ - "version", - "createSurface" - ], + "required": ["createSurface", "version"], "additionalProperties": false }, - { + "UpdateComponentsMessage": { "type": "object", "properties": { "version": { - "type": "string", "const": "v0.9" }, "updateComponents": { @@ -190,104 +606,29 @@ When constructing UI, you must output a VALID A2UI JSON object representing one "properties": { "surfaceId": { "type": "string", - "description": "The unique identifier for the UI surface." + "description": "The unique identifier for the UI surface to be updated." }, + "components": { "type": "array", - "description": "A flat list of component definitions.", + "description": "A list containing all UI components for the surface.", + "minItems": 1, "items": { - "description": "Must match one of the component definitions in the catalog.", - "oneOf": [ - { - "type": "object", - "description": "A block of styled text.", - "properties": { - "text": { - "description": "While simple Markdown is supported (without HTML or image references), utilizing dedicated UI components is generally preferred for a richer and more structured presentation.", - "oneOf": [ - { - "type": "string", - "description": "A literal string value." - }, - { - "type": "object", - "description": "A path to a string.", - "properties": { - "path": { - "type": "string", - "description": "A relative or absolute path in the data model." - } - }, - "required": [ - "path" - ] - }, - { - "type": "object", - "properties": { - "call": { - "type": "string", - "description": "The name of the function to call." - }, - "args": { - "type": "object", - "description": "Arguments to pass to the function.", - "additionalProperties": true - } - }, - "required": [ - "call" - ] - } - ] - }, - "variant": { - "type": "string", - "description": "A hint for the base text style.", - "enum": [ - "h1", - "h2", - "h3", - "h4", - "h5", - "caption", - "body" - ] - }, - "component": { - "type": "string", - "enum": [ - "Text" - ] - } - }, - "required": [ - "component", - "text" - ] - } - ] - }, - "minItems": 1 + "$ref": "catalog.json#/$defs/anyComponent" + } } }, - "required": [ - "surfaceId", - "components" - ] + "required": ["surfaceId", "components"], + "additionalProperties": false } }, - "required": [ - "version", - "updateComponents" - ], + "required": ["updateComponents", "version"], "additionalProperties": false }, - { + "UpdateDataModelMessage": { "type": "object", "properties": { "version": { - "type": "string", "const": "v0.9" }, "updateDataModel": { @@ -295,32 +636,29 @@ When constructing UI, you must output a VALID A2UI JSON object representing one "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" + "type": "string", + "description": "The unique identifier for the UI surface this data model update applies to." }, "path": { "type": "string", - "default": "/" + "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 new value to write to the data model. If null/omitted, the key is removed." + "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" - ] + "required": ["surfaceId"], + "additionalProperties": false } }, - "required": [ - "version", - "updateDataModel" - ], + "required": ["updateDataModel", "version"], "additionalProperties": false }, - { + "DeleteSurfaceMessage": { "type": "object", "properties": { "version": { - "type": "string", "const": "v0.9" }, "deleteSurface": { @@ -328,20 +666,17 @@ When constructing UI, you must output a VALID A2UI JSON object representing one "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" + "type": "string", + "description": "The unique identifier for the UI surface to be deleted." } }, - "required": [ - "surfaceId" - ] + "required": ["surfaceId"], + "additionalProperties": false } }, - "required": [ - "version", - "deleteSurface" - ], + "required": ["deleteSurface", "version"], "additionalProperties": false } - ] + } } ------A2UI_JSON_SCHEMA_END----- +-----MESSAGE_SCHEMA_END----- diff --git a/packages/genui/test/facade/prompt_builder_test.golden/create_and_update_with_dataModel_false.txt b/packages/genui/test/facade/prompt_builder_test.golden/create_and_update_with_dataModel_false.txt index dd3adc395..e620bf8bf 100644 --- a/packages/genui/test/facade/prompt_builder_test.golden/create_and_update_with_dataModel_false.txt +++ b/packages/genui/test/facade/prompt_builder_test.golden/create_and_update_with_dataModel_false.txt @@ -127,57 +127,473 @@ When constructing UI, you must output a VALID A2UI JSON object representing one ------------------------------------- ------A2UI_JSON_SCHEMA_START----- +-----COMMON_TYPES_START----- { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "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 + } + ] + } + } +} +-----COMMON_TYPES_END----- + +------------------------------------- + +-----CATALOG_SCHEMA_START----- +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://a2ui.org/specification/v0_9/catalog.json", + "title": "A2UI Catalog", + "description": "Custom catalog of A2UI components and functions.", + "catalogId": "test_catalog", + "components": { + "Text": { + "type": "object", + "allOf": [ + { + "$ref": "common_types.json#/$defs/ComponentCommon" + }, + { + "$ref": "#/$defs/CatalogComponentCommon" + }, + { + "type": "object", + "properties": { + "component": { + "type": "string", + "enum": [ + "Text" + ] + }, + "text": { + "description": "While simple Markdown is supported (without HTML or image references), utilizing dedicated UI components is generally preferred for a richer and more structured presentation.", + "$ref": "common_types.json#/$defs/DynamicString" + }, + "variant": { + "type": "string", + "description": "A hint for the base text style.", + "enum": [ + "h1", + "h2", + "h3", + "h4", + "h5", + "caption", + "body" + ] + } + }, + "required": [ + "component", + "text" + ] + } + ], + "unevaluatedProperties": false + } + }, + "$defs": { + "CatalogComponentCommon": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "A unique identifier for this component instance within the surface. This ID is used to refer to the component in layout children arrays or event handlers." + } + }, + "required": [ + "id" + ] + }, + "theme": { + "type": "object", + "properties": { + "primaryColor": { + "type": "string", + "description": "The primary brand color used for highlights (e.g., primary buttons, active borders). Renderers may generate variants of this color for different contexts. Format: Hexadecimal code (e.g., '#00BFFF').", + "pattern": "^#[0-9a-fA-F]{6}$" + }, + "iconUrl": { + "type": "string", + "format": "uri", + "description": "A URL for an image that identifies the agent or tool associated with the surface." + }, + "agentDisplayName": { + "type": "string", + "description": "Text to be displayed next to the surface to identify the agent or tool that created it." + } + }, + "additionalProperties": true + }, + "anyComponent": { + "oneOf": [ + { + "$ref": "#/components/Text" + } + ], + "discriminator": { + "propertyName": "component" + } + }, + "anyFunction": { + "not": {} + } + } +} +-----CATALOG_SCHEMA_END----- + +------------------------------------- + +-----MESSAGE_SCHEMA_START----- +{ + "$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": { - "type": "string", "const": "v0.9" }, "createSurface": { "type": "object", - "description": "Signals the client to create a new surface and begin rendering it. When this message is sent, the client will expect 'updateComponents' and/or 'updateDataModel' messages for the same surfaceId that define the component tree.", + "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 ID for the surface." + "description": "The unique identifier for the UI surface to be rendered." }, "catalogId": { - "type": "string", - "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'." + "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": { - "type": "object", - "description": "Theme parameters for the surface.", - "additionalProperties": true + "$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": "Whether to send the data model to every client request." + "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" - ] + "required": ["surfaceId", "catalogId"], + "additionalProperties": false } }, - "required": [ - "version", - "createSurface" - ], + "required": ["createSurface", "version"], "additionalProperties": false }, - { + "UpdateComponentsMessage": { "type": "object", "properties": { "version": { - "type": "string", "const": "v0.9" }, "updateComponents": { @@ -186,104 +602,29 @@ When constructing UI, you must output a VALID A2UI JSON object representing one "properties": { "surfaceId": { "type": "string", - "description": "The unique identifier for the UI surface." + "description": "The unique identifier for the UI surface to be updated." }, + "components": { "type": "array", - "description": "A flat list of component definitions.", + "description": "A list containing all UI components for the surface.", + "minItems": 1, "items": { - "description": "Must match one of the component definitions in the catalog.", - "oneOf": [ - { - "type": "object", - "description": "A block of styled text.", - "properties": { - "text": { - "description": "While simple Markdown is supported (without HTML or image references), utilizing dedicated UI components is generally preferred for a richer and more structured presentation.", - "oneOf": [ - { - "type": "string", - "description": "A literal string value." - }, - { - "type": "object", - "description": "A path to a string.", - "properties": { - "path": { - "type": "string", - "description": "A relative or absolute path in the data model." - } - }, - "required": [ - "path" - ] - }, - { - "type": "object", - "properties": { - "call": { - "type": "string", - "description": "The name of the function to call." - }, - "args": { - "type": "object", - "description": "Arguments to pass to the function.", - "additionalProperties": true - } - }, - "required": [ - "call" - ] - } - ] - }, - "variant": { - "type": "string", - "description": "A hint for the base text style.", - "enum": [ - "h1", - "h2", - "h3", - "h4", - "h5", - "caption", - "body" - ] - }, - "component": { - "type": "string", - "enum": [ - "Text" - ] - } - }, - "required": [ - "component", - "text" - ] - } - ] - }, - "minItems": 1 + "$ref": "catalog.json#/$defs/anyComponent" + } } }, - "required": [ - "surfaceId", - "components" - ] + "required": ["surfaceId", "components"], + "additionalProperties": false } }, - "required": [ - "version", - "updateComponents" - ], + "required": ["updateComponents", "version"], "additionalProperties": false }, - { + "UpdateDataModelMessage": { "type": "object", "properties": { "version": { - "type": "string", "const": "v0.9" }, "updateDataModel": { @@ -291,32 +632,29 @@ When constructing UI, you must output a VALID A2UI JSON object representing one "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" + "type": "string", + "description": "The unique identifier for the UI surface this data model update applies to." }, "path": { "type": "string", - "default": "/" + "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 new value to write to the data model. If null/omitted, the key is removed." + "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" - ] + "required": ["surfaceId"], + "additionalProperties": false } }, - "required": [ - "version", - "updateDataModel" - ], + "required": ["updateDataModel", "version"], "additionalProperties": false }, - { + "DeleteSurfaceMessage": { "type": "object", "properties": { "version": { - "type": "string", "const": "v0.9" }, "deleteSurface": { @@ -324,20 +662,17 @@ When constructing UI, you must output a VALID A2UI JSON object representing one "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" + "type": "string", + "description": "The unique identifier for the UI surface to be deleted." } }, - "required": [ - "surfaceId" - ] + "required": ["surfaceId"], + "additionalProperties": false } }, - "required": [ - "version", - "deleteSurface" - ], + "required": ["deleteSurface", "version"], "additionalProperties": false } - ] + } } ------A2UI_JSON_SCHEMA_END----- +-----MESSAGE_SCHEMA_END----- diff --git a/packages/genui/test/facade/prompt_builder_test.golden/create_and_update_with_dataModel_true.txt b/packages/genui/test/facade/prompt_builder_test.golden/create_and_update_with_dataModel_true.txt index c23b0e23b..b6125b4a9 100644 --- a/packages/genui/test/facade/prompt_builder_test.golden/create_and_update_with_dataModel_true.txt +++ b/packages/genui/test/facade/prompt_builder_test.golden/create_and_update_with_dataModel_true.txt @@ -129,57 +129,473 @@ When constructing UI, you must output a VALID A2UI JSON object representing one ------------------------------------- ------A2UI_JSON_SCHEMA_START----- +-----COMMON_TYPES_START----- { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "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 + } + ] + } + } +} +-----COMMON_TYPES_END----- + +------------------------------------- + +-----CATALOG_SCHEMA_START----- +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://a2ui.org/specification/v0_9/catalog.json", + "title": "A2UI Catalog", + "description": "Custom catalog of A2UI components and functions.", + "catalogId": "test_catalog", + "components": { + "Text": { + "type": "object", + "allOf": [ + { + "$ref": "common_types.json#/$defs/ComponentCommon" + }, + { + "$ref": "#/$defs/CatalogComponentCommon" + }, + { + "type": "object", + "properties": { + "component": { + "type": "string", + "enum": [ + "Text" + ] + }, + "text": { + "description": "While simple Markdown is supported (without HTML or image references), utilizing dedicated UI components is generally preferred for a richer and more structured presentation.", + "$ref": "common_types.json#/$defs/DynamicString" + }, + "variant": { + "type": "string", + "description": "A hint for the base text style.", + "enum": [ + "h1", + "h2", + "h3", + "h4", + "h5", + "caption", + "body" + ] + } + }, + "required": [ + "component", + "text" + ] + } + ], + "unevaluatedProperties": false + } + }, + "$defs": { + "CatalogComponentCommon": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "A unique identifier for this component instance within the surface. This ID is used to refer to the component in layout children arrays or event handlers." + } + }, + "required": [ + "id" + ] + }, + "theme": { + "type": "object", + "properties": { + "primaryColor": { + "type": "string", + "description": "The primary brand color used for highlights (e.g., primary buttons, active borders). Renderers may generate variants of this color for different contexts. Format: Hexadecimal code (e.g., '#00BFFF').", + "pattern": "^#[0-9a-fA-F]{6}$" + }, + "iconUrl": { + "type": "string", + "format": "uri", + "description": "A URL for an image that identifies the agent or tool associated with the surface." + }, + "agentDisplayName": { + "type": "string", + "description": "Text to be displayed next to the surface to identify the agent or tool that created it." + } + }, + "additionalProperties": true + }, + "anyComponent": { + "oneOf": [ + { + "$ref": "#/components/Text" + } + ], + "discriminator": { + "propertyName": "component" + } + }, + "anyFunction": { + "not": {} + } + } +} +-----CATALOG_SCHEMA_END----- + +------------------------------------- + +-----MESSAGE_SCHEMA_START----- +{ + "$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": { - "type": "string", "const": "v0.9" }, "createSurface": { "type": "object", - "description": "Signals the client to create a new surface and begin rendering it. When this message is sent, the client will expect 'updateComponents' and/or 'updateDataModel' messages for the same surfaceId that define the component tree.", + "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 ID for the surface." + "description": "The unique identifier for the UI surface to be rendered." }, "catalogId": { - "type": "string", - "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'." + "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": { - "type": "object", - "description": "Theme parameters for the surface.", - "additionalProperties": true + "$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": "Whether to send the data model to every client request." + "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" - ] + "required": ["surfaceId", "catalogId"], + "additionalProperties": false } }, - "required": [ - "version", - "createSurface" - ], + "required": ["createSurface", "version"], "additionalProperties": false }, - { + "UpdateComponentsMessage": { "type": "object", "properties": { "version": { - "type": "string", "const": "v0.9" }, "updateComponents": { @@ -188,104 +604,29 @@ When constructing UI, you must output a VALID A2UI JSON object representing one "properties": { "surfaceId": { "type": "string", - "description": "The unique identifier for the UI surface." + "description": "The unique identifier for the UI surface to be updated." }, + "components": { "type": "array", - "description": "A flat list of component definitions.", + "description": "A list containing all UI components for the surface.", + "minItems": 1, "items": { - "description": "Must match one of the component definitions in the catalog.", - "oneOf": [ - { - "type": "object", - "description": "A block of styled text.", - "properties": { - "text": { - "description": "While simple Markdown is supported (without HTML or image references), utilizing dedicated UI components is generally preferred for a richer and more structured presentation.", - "oneOf": [ - { - "type": "string", - "description": "A literal string value." - }, - { - "type": "object", - "description": "A path to a string.", - "properties": { - "path": { - "type": "string", - "description": "A relative or absolute path in the data model." - } - }, - "required": [ - "path" - ] - }, - { - "type": "object", - "properties": { - "call": { - "type": "string", - "description": "The name of the function to call." - }, - "args": { - "type": "object", - "description": "Arguments to pass to the function.", - "additionalProperties": true - } - }, - "required": [ - "call" - ] - } - ] - }, - "variant": { - "type": "string", - "description": "A hint for the base text style.", - "enum": [ - "h1", - "h2", - "h3", - "h4", - "h5", - "caption", - "body" - ] - }, - "component": { - "type": "string", - "enum": [ - "Text" - ] - } - }, - "required": [ - "component", - "text" - ] - } - ] - }, - "minItems": 1 + "$ref": "catalog.json#/$defs/anyComponent" + } } }, - "required": [ - "surfaceId", - "components" - ] + "required": ["surfaceId", "components"], + "additionalProperties": false } }, - "required": [ - "version", - "updateComponents" - ], + "required": ["updateComponents", "version"], "additionalProperties": false }, - { + "UpdateDataModelMessage": { "type": "object", "properties": { "version": { - "type": "string", "const": "v0.9" }, "updateDataModel": { @@ -293,32 +634,29 @@ When constructing UI, you must output a VALID A2UI JSON object representing one "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" + "type": "string", + "description": "The unique identifier for the UI surface this data model update applies to." }, "path": { "type": "string", - "default": "/" + "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 new value to write to the data model. If null/omitted, the key is removed." + "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" - ] + "required": ["surfaceId"], + "additionalProperties": false } }, - "required": [ - "version", - "updateDataModel" - ], + "required": ["updateDataModel", "version"], "additionalProperties": false }, - { + "DeleteSurfaceMessage": { "type": "object", "properties": { "version": { - "type": "string", "const": "v0.9" }, "deleteSurface": { @@ -326,20 +664,17 @@ When constructing UI, you must output a VALID A2UI JSON object representing one "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" + "type": "string", + "description": "The unique identifier for the UI surface to be deleted." } }, - "required": [ - "surfaceId" - ] + "required": ["surfaceId"], + "additionalProperties": false } }, - "required": [ - "version", - "deleteSurface" - ], + "required": ["deleteSurface", "version"], "additionalProperties": false } - ] + } } ------A2UI_JSON_SCHEMA_END----- +-----MESSAGE_SCHEMA_END----- diff --git a/packages/genui/test/facade/prompt_builder_test.golden/create_only_with_dataModel_false.txt b/packages/genui/test/facade/prompt_builder_test.golden/create_only_with_dataModel_false.txt index d35089698..c44ba7e85 100644 --- a/packages/genui/test/facade/prompt_builder_test.golden/create_only_with_dataModel_false.txt +++ b/packages/genui/test/facade/prompt_builder_test.golden/create_only_with_dataModel_false.txt @@ -126,57 +126,473 @@ When constructing UI, you must output a VALID A2UI JSON object representing one ------------------------------------- ------A2UI_JSON_SCHEMA_START----- +-----COMMON_TYPES_START----- { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "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 + } + ] + } + } +} +-----COMMON_TYPES_END----- + +------------------------------------- + +-----CATALOG_SCHEMA_START----- +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://a2ui.org/specification/v0_9/catalog.json", + "title": "A2UI Catalog", + "description": "Custom catalog of A2UI components and functions.", + "catalogId": "test_catalog", + "components": { + "Text": { + "type": "object", + "allOf": [ + { + "$ref": "common_types.json#/$defs/ComponentCommon" + }, + { + "$ref": "#/$defs/CatalogComponentCommon" + }, + { + "type": "object", + "properties": { + "component": { + "type": "string", + "enum": [ + "Text" + ] + }, + "text": { + "description": "While simple Markdown is supported (without HTML or image references), utilizing dedicated UI components is generally preferred for a richer and more structured presentation.", + "$ref": "common_types.json#/$defs/DynamicString" + }, + "variant": { + "type": "string", + "description": "A hint for the base text style.", + "enum": [ + "h1", + "h2", + "h3", + "h4", + "h5", + "caption", + "body" + ] + } + }, + "required": [ + "component", + "text" + ] + } + ], + "unevaluatedProperties": false + } + }, + "$defs": { + "CatalogComponentCommon": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "A unique identifier for this component instance within the surface. This ID is used to refer to the component in layout children arrays or event handlers." + } + }, + "required": [ + "id" + ] + }, + "theme": { + "type": "object", + "properties": { + "primaryColor": { + "type": "string", + "description": "The primary brand color used for highlights (e.g., primary buttons, active borders). Renderers may generate variants of this color for different contexts. Format: Hexadecimal code (e.g., '#00BFFF').", + "pattern": "^#[0-9a-fA-F]{6}$" + }, + "iconUrl": { + "type": "string", + "format": "uri", + "description": "A URL for an image that identifies the agent or tool associated with the surface." + }, + "agentDisplayName": { + "type": "string", + "description": "Text to be displayed next to the surface to identify the agent or tool that created it." + } + }, + "additionalProperties": true + }, + "anyComponent": { + "oneOf": [ + { + "$ref": "#/components/Text" + } + ], + "discriminator": { + "propertyName": "component" + } + }, + "anyFunction": { + "not": {} + } + } +} +-----CATALOG_SCHEMA_END----- + +------------------------------------- + +-----MESSAGE_SCHEMA_START----- +{ + "$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": { - "type": "string", "const": "v0.9" }, "createSurface": { "type": "object", - "description": "Signals the client to create a new surface and begin rendering it. When this message is sent, the client will expect 'updateComponents' and/or 'updateDataModel' messages for the same surfaceId that define the component tree.", + "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 ID for the surface." + "description": "The unique identifier for the UI surface to be rendered." }, "catalogId": { - "type": "string", - "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'." + "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": { - "type": "object", - "description": "Theme parameters for the surface.", - "additionalProperties": true + "$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": "Whether to send the data model to every client request." + "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" - ] + "required": ["surfaceId", "catalogId"], + "additionalProperties": false } }, - "required": [ - "version", - "createSurface" - ], + "required": ["createSurface", "version"], "additionalProperties": false }, - { + "UpdateComponentsMessage": { "type": "object", "properties": { "version": { - "type": "string", "const": "v0.9" }, "updateComponents": { @@ -185,104 +601,29 @@ When constructing UI, you must output a VALID A2UI JSON object representing one "properties": { "surfaceId": { "type": "string", - "description": "The unique identifier for the UI surface." + "description": "The unique identifier for the UI surface to be updated." }, + "components": { "type": "array", - "description": "A flat list of component definitions.", + "description": "A list containing all UI components for the surface.", + "minItems": 1, "items": { - "description": "Must match one of the component definitions in the catalog.", - "oneOf": [ - { - "type": "object", - "description": "A block of styled text.", - "properties": { - "text": { - "description": "While simple Markdown is supported (without HTML or image references), utilizing dedicated UI components is generally preferred for a richer and more structured presentation.", - "oneOf": [ - { - "type": "string", - "description": "A literal string value." - }, - { - "type": "object", - "description": "A path to a string.", - "properties": { - "path": { - "type": "string", - "description": "A relative or absolute path in the data model." - } - }, - "required": [ - "path" - ] - }, - { - "type": "object", - "properties": { - "call": { - "type": "string", - "description": "The name of the function to call." - }, - "args": { - "type": "object", - "description": "Arguments to pass to the function.", - "additionalProperties": true - } - }, - "required": [ - "call" - ] - } - ] - }, - "variant": { - "type": "string", - "description": "A hint for the base text style.", - "enum": [ - "h1", - "h2", - "h3", - "h4", - "h5", - "caption", - "body" - ] - }, - "component": { - "type": "string", - "enum": [ - "Text" - ] - } - }, - "required": [ - "component", - "text" - ] - } - ] - }, - "minItems": 1 + "$ref": "catalog.json#/$defs/anyComponent" + } } }, - "required": [ - "surfaceId", - "components" - ] + "required": ["surfaceId", "components"], + "additionalProperties": false } }, - "required": [ - "version", - "updateComponents" - ], + "required": ["updateComponents", "version"], "additionalProperties": false }, - { + "UpdateDataModelMessage": { "type": "object", "properties": { "version": { - "type": "string", "const": "v0.9" }, "updateDataModel": { @@ -290,32 +631,29 @@ When constructing UI, you must output a VALID A2UI JSON object representing one "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" + "type": "string", + "description": "The unique identifier for the UI surface this data model update applies to." }, "path": { "type": "string", - "default": "/" + "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 new value to write to the data model. If null/omitted, the key is removed." + "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" - ] + "required": ["surfaceId"], + "additionalProperties": false } }, - "required": [ - "version", - "updateDataModel" - ], + "required": ["updateDataModel", "version"], "additionalProperties": false }, - { + "DeleteSurfaceMessage": { "type": "object", "properties": { "version": { - "type": "string", "const": "v0.9" }, "deleteSurface": { @@ -323,20 +661,17 @@ When constructing UI, you must output a VALID A2UI JSON object representing one "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" + "type": "string", + "description": "The unique identifier for the UI surface to be deleted." } }, - "required": [ - "surfaceId" - ] + "required": ["surfaceId"], + "additionalProperties": false } }, - "required": [ - "version", - "deleteSurface" - ], + "required": ["deleteSurface", "version"], "additionalProperties": false } - ] + } } ------A2UI_JSON_SCHEMA_END----- +-----MESSAGE_SCHEMA_END----- diff --git a/packages/genui/test/facade/prompt_builder_test.golden/create_only_with_dataModel_true.txt b/packages/genui/test/facade/prompt_builder_test.golden/create_only_with_dataModel_true.txt index 0ae7dfeed..321bc0e98 100644 --- a/packages/genui/test/facade/prompt_builder_test.golden/create_only_with_dataModel_true.txt +++ b/packages/genui/test/facade/prompt_builder_test.golden/create_only_with_dataModel_true.txt @@ -128,57 +128,473 @@ When constructing UI, you must output a VALID A2UI JSON object representing one ------------------------------------- ------A2UI_JSON_SCHEMA_START----- +-----COMMON_TYPES_START----- { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "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 + } + ] + } + } +} +-----COMMON_TYPES_END----- + +------------------------------------- + +-----CATALOG_SCHEMA_START----- +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://a2ui.org/specification/v0_9/catalog.json", + "title": "A2UI Catalog", + "description": "Custom catalog of A2UI components and functions.", + "catalogId": "test_catalog", + "components": { + "Text": { + "type": "object", + "allOf": [ + { + "$ref": "common_types.json#/$defs/ComponentCommon" + }, + { + "$ref": "#/$defs/CatalogComponentCommon" + }, + { + "type": "object", + "properties": { + "component": { + "type": "string", + "enum": [ + "Text" + ] + }, + "text": { + "description": "While simple Markdown is supported (without HTML or image references), utilizing dedicated UI components is generally preferred for a richer and more structured presentation.", + "$ref": "common_types.json#/$defs/DynamicString" + }, + "variant": { + "type": "string", + "description": "A hint for the base text style.", + "enum": [ + "h1", + "h2", + "h3", + "h4", + "h5", + "caption", + "body" + ] + } + }, + "required": [ + "component", + "text" + ] + } + ], + "unevaluatedProperties": false + } + }, + "$defs": { + "CatalogComponentCommon": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "A unique identifier for this component instance within the surface. This ID is used to refer to the component in layout children arrays or event handlers." + } + }, + "required": [ + "id" + ] + }, + "theme": { + "type": "object", + "properties": { + "primaryColor": { + "type": "string", + "description": "The primary brand color used for highlights (e.g., primary buttons, active borders). Renderers may generate variants of this color for different contexts. Format: Hexadecimal code (e.g., '#00BFFF').", + "pattern": "^#[0-9a-fA-F]{6}$" + }, + "iconUrl": { + "type": "string", + "format": "uri", + "description": "A URL for an image that identifies the agent or tool associated with the surface." + }, + "agentDisplayName": { + "type": "string", + "description": "Text to be displayed next to the surface to identify the agent or tool that created it." + } + }, + "additionalProperties": true + }, + "anyComponent": { + "oneOf": [ + { + "$ref": "#/components/Text" + } + ], + "discriminator": { + "propertyName": "component" + } + }, + "anyFunction": { + "not": {} + } + } +} +-----CATALOG_SCHEMA_END----- + +------------------------------------- + +-----MESSAGE_SCHEMA_START----- +{ + "$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": { - "type": "string", "const": "v0.9" }, "createSurface": { "type": "object", - "description": "Signals the client to create a new surface and begin rendering it. When this message is sent, the client will expect 'updateComponents' and/or 'updateDataModel' messages for the same surfaceId that define the component tree.", + "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 ID for the surface." + "description": "The unique identifier for the UI surface to be rendered." }, "catalogId": { - "type": "string", - "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'." + "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": { - "type": "object", - "description": "Theme parameters for the surface.", - "additionalProperties": true + "$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": "Whether to send the data model to every client request." + "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" - ] + "required": ["surfaceId", "catalogId"], + "additionalProperties": false } }, - "required": [ - "version", - "createSurface" - ], + "required": ["createSurface", "version"], "additionalProperties": false }, - { + "UpdateComponentsMessage": { "type": "object", "properties": { "version": { - "type": "string", "const": "v0.9" }, "updateComponents": { @@ -187,104 +603,29 @@ When constructing UI, you must output a VALID A2UI JSON object representing one "properties": { "surfaceId": { "type": "string", - "description": "The unique identifier for the UI surface." + "description": "The unique identifier for the UI surface to be updated." }, + "components": { "type": "array", - "description": "A flat list of component definitions.", + "description": "A list containing all UI components for the surface.", + "minItems": 1, "items": { - "description": "Must match one of the component definitions in the catalog.", - "oneOf": [ - { - "type": "object", - "description": "A block of styled text.", - "properties": { - "text": { - "description": "While simple Markdown is supported (without HTML or image references), utilizing dedicated UI components is generally preferred for a richer and more structured presentation.", - "oneOf": [ - { - "type": "string", - "description": "A literal string value." - }, - { - "type": "object", - "description": "A path to a string.", - "properties": { - "path": { - "type": "string", - "description": "A relative or absolute path in the data model." - } - }, - "required": [ - "path" - ] - }, - { - "type": "object", - "properties": { - "call": { - "type": "string", - "description": "The name of the function to call." - }, - "args": { - "type": "object", - "description": "Arguments to pass to the function.", - "additionalProperties": true - } - }, - "required": [ - "call" - ] - } - ] - }, - "variant": { - "type": "string", - "description": "A hint for the base text style.", - "enum": [ - "h1", - "h2", - "h3", - "h4", - "h5", - "caption", - "body" - ] - }, - "component": { - "type": "string", - "enum": [ - "Text" - ] - } - }, - "required": [ - "component", - "text" - ] - } - ] - }, - "minItems": 1 + "$ref": "catalog.json#/$defs/anyComponent" + } } }, - "required": [ - "surfaceId", - "components" - ] + "required": ["surfaceId", "components"], + "additionalProperties": false } }, - "required": [ - "version", - "updateComponents" - ], + "required": ["updateComponents", "version"], "additionalProperties": false }, - { + "UpdateDataModelMessage": { "type": "object", "properties": { "version": { - "type": "string", "const": "v0.9" }, "updateDataModel": { @@ -292,32 +633,29 @@ When constructing UI, you must output a VALID A2UI JSON object representing one "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" + "type": "string", + "description": "The unique identifier for the UI surface this data model update applies to." }, "path": { "type": "string", - "default": "/" + "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 new value to write to the data model. If null/omitted, the key is removed." + "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" - ] + "required": ["surfaceId"], + "additionalProperties": false } }, - "required": [ - "version", - "updateDataModel" - ], + "required": ["updateDataModel", "version"], "additionalProperties": false }, - { + "DeleteSurfaceMessage": { "type": "object", "properties": { "version": { - "type": "string", "const": "v0.9" }, "deleteSurface": { @@ -325,20 +663,17 @@ When constructing UI, you must output a VALID A2UI JSON object representing one "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" + "type": "string", + "description": "The unique identifier for the UI surface to be deleted." } }, - "required": [ - "surfaceId" - ] + "required": ["surfaceId"], + "additionalProperties": false } }, - "required": [ - "version", - "deleteSurface" - ], + "required": ["deleteSurface", "version"], "additionalProperties": false } - ] + } } ------A2UI_JSON_SCHEMA_END----- +-----MESSAGE_SCHEMA_END----- diff --git a/packages/genui/test/facade/prompt_builder_test.golden/update_only_with_dataModel_false.txt b/packages/genui/test/facade/prompt_builder_test.golden/update_only_with_dataModel_false.txt index 6f63a173a..311dba834 100644 --- a/packages/genui/test/facade/prompt_builder_test.golden/update_only_with_dataModel_false.txt +++ b/packages/genui/test/facade/prompt_builder_test.golden/update_only_with_dataModel_false.txt @@ -119,57 +119,473 @@ When constructing UI, you must output a VALID A2UI JSON object representing one ------------------------------------- ------A2UI_JSON_SCHEMA_START----- +-----COMMON_TYPES_START----- { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "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 + } + ] + } + } +} +-----COMMON_TYPES_END----- + +------------------------------------- + +-----CATALOG_SCHEMA_START----- +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://a2ui.org/specification/v0_9/catalog.json", + "title": "A2UI Catalog", + "description": "Custom catalog of A2UI components and functions.", + "catalogId": "test_catalog", + "components": { + "Text": { + "type": "object", + "allOf": [ + { + "$ref": "common_types.json#/$defs/ComponentCommon" + }, + { + "$ref": "#/$defs/CatalogComponentCommon" + }, + { + "type": "object", + "properties": { + "component": { + "type": "string", + "enum": [ + "Text" + ] + }, + "text": { + "description": "While simple Markdown is supported (without HTML or image references), utilizing dedicated UI components is generally preferred for a richer and more structured presentation.", + "$ref": "common_types.json#/$defs/DynamicString" + }, + "variant": { + "type": "string", + "description": "A hint for the base text style.", + "enum": [ + "h1", + "h2", + "h3", + "h4", + "h5", + "caption", + "body" + ] + } + }, + "required": [ + "component", + "text" + ] + } + ], + "unevaluatedProperties": false + } + }, + "$defs": { + "CatalogComponentCommon": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "A unique identifier for this component instance within the surface. This ID is used to refer to the component in layout children arrays or event handlers." + } + }, + "required": [ + "id" + ] + }, + "theme": { + "type": "object", + "properties": { + "primaryColor": { + "type": "string", + "description": "The primary brand color used for highlights (e.g., primary buttons, active borders). Renderers may generate variants of this color for different contexts. Format: Hexadecimal code (e.g., '#00BFFF').", + "pattern": "^#[0-9a-fA-F]{6}$" + }, + "iconUrl": { + "type": "string", + "format": "uri", + "description": "A URL for an image that identifies the agent or tool associated with the surface." + }, + "agentDisplayName": { + "type": "string", + "description": "Text to be displayed next to the surface to identify the agent or tool that created it." + } + }, + "additionalProperties": true + }, + "anyComponent": { + "oneOf": [ + { + "$ref": "#/components/Text" + } + ], + "discriminator": { + "propertyName": "component" + } + }, + "anyFunction": { + "not": {} + } + } +} +-----CATALOG_SCHEMA_END----- + +------------------------------------- + +-----MESSAGE_SCHEMA_START----- +{ + "$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": { - "type": "string", "const": "v0.9" }, "createSurface": { "type": "object", - "description": "Signals the client to create a new surface and begin rendering it. When this message is sent, the client will expect 'updateComponents' and/or 'updateDataModel' messages for the same surfaceId that define the component tree.", + "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 ID for the surface." + "description": "The unique identifier for the UI surface to be rendered." }, "catalogId": { - "type": "string", - "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'." + "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": { - "type": "object", - "description": "Theme parameters for the surface.", - "additionalProperties": true + "$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": "Whether to send the data model to every client request." + "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" - ] + "required": ["surfaceId", "catalogId"], + "additionalProperties": false } }, - "required": [ - "version", - "createSurface" - ], + "required": ["createSurface", "version"], "additionalProperties": false }, - { + "UpdateComponentsMessage": { "type": "object", "properties": { "version": { - "type": "string", "const": "v0.9" }, "updateComponents": { @@ -178,104 +594,29 @@ When constructing UI, you must output a VALID A2UI JSON object representing one "properties": { "surfaceId": { "type": "string", - "description": "The unique identifier for the UI surface." + "description": "The unique identifier for the UI surface to be updated." }, + "components": { "type": "array", - "description": "A flat list of component definitions.", + "description": "A list containing all UI components for the surface.", + "minItems": 1, "items": { - "description": "Must match one of the component definitions in the catalog.", - "oneOf": [ - { - "type": "object", - "description": "A block of styled text.", - "properties": { - "text": { - "description": "While simple Markdown is supported (without HTML or image references), utilizing dedicated UI components is generally preferred for a richer and more structured presentation.", - "oneOf": [ - { - "type": "string", - "description": "A literal string value." - }, - { - "type": "object", - "description": "A path to a string.", - "properties": { - "path": { - "type": "string", - "description": "A relative or absolute path in the data model." - } - }, - "required": [ - "path" - ] - }, - { - "type": "object", - "properties": { - "call": { - "type": "string", - "description": "The name of the function to call." - }, - "args": { - "type": "object", - "description": "Arguments to pass to the function.", - "additionalProperties": true - } - }, - "required": [ - "call" - ] - } - ] - }, - "variant": { - "type": "string", - "description": "A hint for the base text style.", - "enum": [ - "h1", - "h2", - "h3", - "h4", - "h5", - "caption", - "body" - ] - }, - "component": { - "type": "string", - "enum": [ - "Text" - ] - } - }, - "required": [ - "component", - "text" - ] - } - ] - }, - "minItems": 1 + "$ref": "catalog.json#/$defs/anyComponent" + } } }, - "required": [ - "surfaceId", - "components" - ] + "required": ["surfaceId", "components"], + "additionalProperties": false } }, - "required": [ - "version", - "updateComponents" - ], + "required": ["updateComponents", "version"], "additionalProperties": false }, - { + "UpdateDataModelMessage": { "type": "object", "properties": { "version": { - "type": "string", "const": "v0.9" }, "updateDataModel": { @@ -283,32 +624,29 @@ When constructing UI, you must output a VALID A2UI JSON object representing one "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" + "type": "string", + "description": "The unique identifier for the UI surface this data model update applies to." }, "path": { "type": "string", - "default": "/" + "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 new value to write to the data model. If null/omitted, the key is removed." + "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" - ] + "required": ["surfaceId"], + "additionalProperties": false } }, - "required": [ - "version", - "updateDataModel" - ], + "required": ["updateDataModel", "version"], "additionalProperties": false }, - { + "DeleteSurfaceMessage": { "type": "object", "properties": { "version": { - "type": "string", "const": "v0.9" }, "deleteSurface": { @@ -316,20 +654,17 @@ When constructing UI, you must output a VALID A2UI JSON object representing one "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" + "type": "string", + "description": "The unique identifier for the UI surface to be deleted." } }, - "required": [ - "surfaceId" - ] + "required": ["surfaceId"], + "additionalProperties": false } }, - "required": [ - "version", - "deleteSurface" - ], + "required": ["deleteSurface", "version"], "additionalProperties": false } - ] + } } ------A2UI_JSON_SCHEMA_END----- +-----MESSAGE_SCHEMA_END----- diff --git a/packages/genui/test/facade/prompt_builder_test.golden/update_only_with_dataModel_true.txt b/packages/genui/test/facade/prompt_builder_test.golden/update_only_with_dataModel_true.txt index d85d3f55f..0b8f9f482 100644 --- a/packages/genui/test/facade/prompt_builder_test.golden/update_only_with_dataModel_true.txt +++ b/packages/genui/test/facade/prompt_builder_test.golden/update_only_with_dataModel_true.txt @@ -121,57 +121,473 @@ When constructing UI, you must output a VALID A2UI JSON object representing one ------------------------------------- ------A2UI_JSON_SCHEMA_START----- +-----COMMON_TYPES_START----- { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "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 + } + ] + } + } +} +-----COMMON_TYPES_END----- + +------------------------------------- + +-----CATALOG_SCHEMA_START----- +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://a2ui.org/specification/v0_9/catalog.json", + "title": "A2UI Catalog", + "description": "Custom catalog of A2UI components and functions.", + "catalogId": "test_catalog", + "components": { + "Text": { + "type": "object", + "allOf": [ + { + "$ref": "common_types.json#/$defs/ComponentCommon" + }, + { + "$ref": "#/$defs/CatalogComponentCommon" + }, + { + "type": "object", + "properties": { + "component": { + "type": "string", + "enum": [ + "Text" + ] + }, + "text": { + "description": "While simple Markdown is supported (without HTML or image references), utilizing dedicated UI components is generally preferred for a richer and more structured presentation.", + "$ref": "common_types.json#/$defs/DynamicString" + }, + "variant": { + "type": "string", + "description": "A hint for the base text style.", + "enum": [ + "h1", + "h2", + "h3", + "h4", + "h5", + "caption", + "body" + ] + } + }, + "required": [ + "component", + "text" + ] + } + ], + "unevaluatedProperties": false + } + }, + "$defs": { + "CatalogComponentCommon": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "A unique identifier for this component instance within the surface. This ID is used to refer to the component in layout children arrays or event handlers." + } + }, + "required": [ + "id" + ] + }, + "theme": { + "type": "object", + "properties": { + "primaryColor": { + "type": "string", + "description": "The primary brand color used for highlights (e.g., primary buttons, active borders). Renderers may generate variants of this color for different contexts. Format: Hexadecimal code (e.g., '#00BFFF').", + "pattern": "^#[0-9a-fA-F]{6}$" + }, + "iconUrl": { + "type": "string", + "format": "uri", + "description": "A URL for an image that identifies the agent or tool associated with the surface." + }, + "agentDisplayName": { + "type": "string", + "description": "Text to be displayed next to the surface to identify the agent or tool that created it." + } + }, + "additionalProperties": true + }, + "anyComponent": { + "oneOf": [ + { + "$ref": "#/components/Text" + } + ], + "discriminator": { + "propertyName": "component" + } + }, + "anyFunction": { + "not": {} + } + } +} +-----CATALOG_SCHEMA_END----- + +------------------------------------- + +-----MESSAGE_SCHEMA_START----- +{ + "$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": { - "type": "string", "const": "v0.9" }, "createSurface": { "type": "object", - "description": "Signals the client to create a new surface and begin rendering it. When this message is sent, the client will expect 'updateComponents' and/or 'updateDataModel' messages for the same surfaceId that define the component tree.", + "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 ID for the surface." + "description": "The unique identifier for the UI surface to be rendered." }, "catalogId": { - "type": "string", - "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'." + "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": { - "type": "object", - "description": "Theme parameters for the surface.", - "additionalProperties": true + "$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": "Whether to send the data model to every client request." + "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" - ] + "required": ["surfaceId", "catalogId"], + "additionalProperties": false } }, - "required": [ - "version", - "createSurface" - ], + "required": ["createSurface", "version"], "additionalProperties": false }, - { + "UpdateComponentsMessage": { "type": "object", "properties": { "version": { - "type": "string", "const": "v0.9" }, "updateComponents": { @@ -180,104 +596,29 @@ When constructing UI, you must output a VALID A2UI JSON object representing one "properties": { "surfaceId": { "type": "string", - "description": "The unique identifier for the UI surface." + "description": "The unique identifier for the UI surface to be updated." }, + "components": { "type": "array", - "description": "A flat list of component definitions.", + "description": "A list containing all UI components for the surface.", + "minItems": 1, "items": { - "description": "Must match one of the component definitions in the catalog.", - "oneOf": [ - { - "type": "object", - "description": "A block of styled text.", - "properties": { - "text": { - "description": "While simple Markdown is supported (without HTML or image references), utilizing dedicated UI components is generally preferred for a richer and more structured presentation.", - "oneOf": [ - { - "type": "string", - "description": "A literal string value." - }, - { - "type": "object", - "description": "A path to a string.", - "properties": { - "path": { - "type": "string", - "description": "A relative or absolute path in the data model." - } - }, - "required": [ - "path" - ] - }, - { - "type": "object", - "properties": { - "call": { - "type": "string", - "description": "The name of the function to call." - }, - "args": { - "type": "object", - "description": "Arguments to pass to the function.", - "additionalProperties": true - } - }, - "required": [ - "call" - ] - } - ] - }, - "variant": { - "type": "string", - "description": "A hint for the base text style.", - "enum": [ - "h1", - "h2", - "h3", - "h4", - "h5", - "caption", - "body" - ] - }, - "component": { - "type": "string", - "enum": [ - "Text" - ] - } - }, - "required": [ - "component", - "text" - ] - } - ] - }, - "minItems": 1 + "$ref": "catalog.json#/$defs/anyComponent" + } } }, - "required": [ - "surfaceId", - "components" - ] + "required": ["surfaceId", "components"], + "additionalProperties": false } }, - "required": [ - "version", - "updateComponents" - ], + "required": ["updateComponents", "version"], "additionalProperties": false }, - { + "UpdateDataModelMessage": { "type": "object", "properties": { "version": { - "type": "string", "const": "v0.9" }, "updateDataModel": { @@ -285,32 +626,29 @@ When constructing UI, you must output a VALID A2UI JSON object representing one "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" + "type": "string", + "description": "The unique identifier for the UI surface this data model update applies to." }, "path": { "type": "string", - "default": "/" + "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 new value to write to the data model. If null/omitted, the key is removed." + "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" - ] + "required": ["surfaceId"], + "additionalProperties": false } }, - "required": [ - "version", - "updateDataModel" - ], + "required": ["updateDataModel", "version"], "additionalProperties": false }, - { + "DeleteSurfaceMessage": { "type": "object", "properties": { "version": { - "type": "string", "const": "v0.9" }, "deleteSurface": { @@ -318,20 +656,17 @@ When constructing UI, you must output a VALID A2UI JSON object representing one "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" + "type": "string", + "description": "The unique identifier for the UI surface to be deleted." } }, - "required": [ - "surfaceId" - ] + "required": ["surfaceId"], + "additionalProperties": false } }, - "required": [ - "version", - "deleteSurface" - ], + "required": ["deleteSurface", "version"], "additionalProperties": false } - ] + } } ------A2UI_JSON_SCHEMA_END----- +-----MESSAGE_SCHEMA_END----- diff --git a/packages/genui/test/model/catalog_exception_test.dart b/packages/genui/test/model/catalog_exception_test.dart index 98b982639..b80dd3157 100644 --- a/packages/genui/test/model/catalog_exception_test.dart +++ b/packages/genui/test/model/catalog_exception_test.dart @@ -97,5 +97,47 @@ void main() { ); }, ); + + test('A2uiFunctionException toString formatting', () { + final exc1 = A2uiFunctionException( + 'some message', + functionName: 'myFunc', + ); + expect( + exc1.toString(), + 'A2uiFunctionException inside myFunc: some message', + ); + + final exc2 = A2uiFunctionException( + 'some message', + functionName: 'myFunc', + argumentKey: 'myArg', + ); + expect( + exc2.toString(), + 'A2uiFunctionException inside myFunc: some message (argument: myArg)', + ); + + final exc3 = A2uiFunctionException( + 'some message', + functionName: 'myFunc', + cause: 'underlying error', + ); + expect( + exc3.toString(), + 'A2uiFunctionException inside myFunc: some message\nCause: underlying error', + ); + + final exc4 = A2uiFunctionException( + 'some message', + functionName: 'myFunc', + argumentKey: 'myArg', + cause: 'underlying error', + ); + expect( + exc4.toString(), + 'A2uiFunctionException inside myFunc: some message (argument: myArg)\nCause: underlying error', + ); + }); }); } diff --git a/packages/genui/test/test_infra/mock_assets.dart b/packages/genui/test/test_infra/mock_assets.dart new file mode 100644 index 000000000..15c28c796 --- /dev/null +++ b/packages/genui/test/test_infra/mock_assets.dart @@ -0,0 +1,42 @@ +// 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. + +import 'dart:convert'; +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; + +/// Configures a mock handler for the 'flutter/assets' channel to load assets +/// directly from the local file system. +/// +/// This is necessary because PromptBuilder loads schemas from assets, +/// and Flutter tests do not load package assets automatically. +/// It automatically handles running from the package root or example directory. +void setUpMockPackageAssets() { + final String cwd = Directory.current.path; + String packageRoot; + if (cwd.endsWith('packages/genui')) { + packageRoot = cwd; + } else if (cwd.contains('examples/')) { + packageRoot = '${cwd.substring(0, cwd.indexOf('examples/'))}packages/genui'; + } else { + packageRoot = '$cwd/packages/genui'; + } + + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMessageHandler('flutter/assets', (ByteData? message) async { + final String key = utf8.decode(message!.buffer.asUint8List()); + var relativePath = key; + if (key.startsWith('packages/genui/')) { + relativePath = key.substring('packages/genui/'.length); + } + final file = File('$packageRoot/$relativePath'); + if (file.existsSync()) { + return ByteData.view(utf8.encode(file.readAsStringSync()).buffer); + } + return null; + }); +} diff --git a/packages/genui/test/test_infra/validation_test_utils.dart b/packages/genui/test/test_infra/validation_test_utils.dart index 3d8a4031b..02226a302 100644 --- a/packages/genui/test/test_infra/validation_test_utils.dart +++ b/packages/genui/test/test_infra/validation_test_utils.dart @@ -11,7 +11,10 @@ import 'package:genui/src/model/catalog.dart'; import 'package:genui/src/model/catalog_item.dart'; import 'package:genui/src/model/ui_models.dart'; import 'package:genui/src/primitives/simple_items.dart'; +import 'package:genui/test/validation.dart'; import 'package:json_schema_builder/json_schema_builder.dart'; +// ignore: implementation_imports +import 'package:json_schema_builder/src/schema_registry.dart'; import 'message_builders.dart'; @@ -55,8 +58,11 @@ void validateCatalogExamples( components: components.map((c) => c.toJson()).toList(), ); + final SchemaRegistry registry = createSchemaRegistryWithCommonTypes(); + final List validationErrors = await schema.validate( surfaceUpdate.toJson(), + schemaRegistry: registry, ); expect(validationErrors, isEmpty); }); diff --git a/packages/genui/test/utils/stream_extensions_test.dart b/packages/genui/test/utils/stream_extensions_test.dart new file mode 100644 index 000000000..e094db67b --- /dev/null +++ b/packages/genui/test/utils/stream_extensions_test.dart @@ -0,0 +1,96 @@ +// 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. + +import 'dart:async'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:genui/src/utils/stream_extensions.dart'; + +void main() { + group('SwitchMapExtension', () { + test('switchMap maps and switches streams', () async { + final controller = StreamController(); + final innerControllers = >[]; + + final resultStream = controller.stream.switchMap((val) { + final inner = StreamController(); + innerControllers.add(inner); + return inner.stream; + }); + + final emitted = []; + final subscription = resultStream.listen(emitted.add); + + controller.add(1); + await Future.value(); + innerControllers[0].add('a'); + await Future.value(); + expect(emitted, ['a']); + + controller.add(2); + await Future.value(); + innerControllers[0].add('b'); + innerControllers[1].add('c'); + await Future.value(); + expect(emitted, ['a', 'c']); + + await subscription.cancel(); + await controller.close(); + }); + + test('outer stream error is forwarded', () async { + final controller = StreamController(); + final resultStream = controller.stream.switchMap( + (val) => Stream.value('a'), + ); + + final errors = []; + resultStream.listen((_) {}, onError: errors.add); + + controller.addError('outer error'); + await Future.value(); + expect(errors, ['outer error']); + await controller.close(); + }); + + test( + 'outer stream done closes controller when no inner stream is active', + () async { + final controller = StreamController(); + final resultStream = controller.stream.switchMap( + (val) => const Stream.empty(), + ); + + final completer = Completer(); + resultStream.listen((_) {}, onDone: completer.complete); + + await controller.close(); + await completer.future; + }, + ); + + test( + 'inner stream done closes controller if outer stream is already done', + () async { + final controller = StreamController(); + final innerController = StreamController(); + final resultStream = controller.stream.switchMap( + (val) => innerController.stream, + ); + + final completer = Completer(); + resultStream.listen((_) {}, onDone: completer.complete); + + controller.add(1); + await Future.value(); + + await controller.close(); + expect(completer.isCompleted, isFalse); + + await innerController.close(); + await completer.future; + }, + ); + }); +} diff --git a/packages/genui_a2a/CHANGELOG.md b/packages/genui_a2a/CHANGELOG.md index 8198638f0..78d662af8 100644 --- a/packages/genui_a2a/CHANGELOG.md +++ b/packages/genui_a2a/CHANGELOG.md @@ -4,6 +4,7 @@ - **BREAKING**: `A2uiAgentConnector.stream` now emits `package:a2ui_core` message types. Depend on `a2ui_core` directly to consume them. +- Bumped dependent genui version to 0.10.0 ## 0.9.0 diff --git a/packages/genui_a2a/pubspec.yaml b/packages/genui_a2a/pubspec.yaml index 259dfd9f3..b42cb9f8a 100644 --- a/packages/genui_a2a/pubspec.yaml +++ b/packages/genui_a2a/pubspec.yaml @@ -20,7 +20,7 @@ dependencies: collection: ^1.19.1 flutter: sdk: flutter - genui: ^0.9.0 + genui: ^0.10.0 http: ^1.2.1 json_annotation: ^4.9.0 json_schema_builder: ^0.1.3 diff --git a/tool/e2e/pubspec.yaml b/tool/e2e/pubspec.yaml index c5a8be6bb..b761efe29 100644 --- a/tool/e2e/pubspec.yaml +++ b/tool/e2e/pubspec.yaml @@ -15,7 +15,7 @@ dependencies: dartantic_ai: ^3.1.0 flutter: sdk: flutter - genui: ^0.9.0 + genui: ^0.10.0 simple_chat: path: ../../examples/simple_chat