diff --git a/packages/genui/lib/src/facade.dart b/packages/genui/lib/src/facade.dart index 7695c6238..25a3e51e5 100644 --- a/packages/genui/lib/src/facade.dart +++ b/packages/genui/lib/src/facade.dart @@ -8,6 +8,7 @@ /// for building chat-centric generative applications. library; +export 'facade/catalog_context.dart'; export 'facade/conversation.dart'; export 'facade/prompt_builder.dart'; export 'facade/widgets/chat_primitives.dart'; diff --git a/packages/genui/lib/src/facade/catalog_context.dart b/packages/genui/lib/src/facade/catalog_context.dart new file mode 100644 index 000000000..b21bce76b --- /dev/null +++ b/packages/genui/lib/src/facade/catalog_context.dart @@ -0,0 +1,273 @@ +// 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 'package:genai_primitives/genai_primitives.dart'; +import 'package:json_schema_builder/json_schema_builder.dart'; + +import '../model/catalog.dart'; +import '../model/catalog_item.dart'; +import '../primitives/constants.dart'; +import '../primitives/simple_items.dart'; + +/// A compact summary of a single catalog item. +/// +/// Used in a [CatalogManifest] to give the model a lightweight index of the +/// available components without inlining their full schemas. +final class CatalogManifestItem { + /// Creates a [CatalogManifestItem]. + const CatalogManifestItem({required this.name, required this.description}); + + /// The catalog item name, e.g. `Card`. + final String name; + + /// A short, human-readable description of the component. + /// + /// Derived from the component's schema description. + final String description; + + /// Returns a JSON-serializable representation. + JsonMap toJson() => {'name': name, 'description': description}; +} + +/// A compact index of a catalog, suitable for an initial system prompt. +/// +/// The manifest contains only [CatalogManifestItem] descriptions. Full schemas +/// and examples are loaded on demand through [CatalogContext.loadItems]. +final class CatalogManifest { + /// Creates a [CatalogManifest]. + const CatalogManifest({required this.catalogId, required this.items}); + + /// The id of the catalog this manifest describes, if any. + final String? catalogId; + + /// The compact descriptions for every item in the catalog. + final List items; + + /// Returns a JSON-serializable representation. + JsonMap toJson() => { + if (catalogId != null) 'catalogId': catalogId, + 'items': items.map((item) => item.toJson()).toList(), + }; +} + +/// The full, model-facing detail for a single catalog item. +/// +/// Returned inside a [LoadCatalogItemsResult] when the model asks to load a +/// component. This is the on-demand "body" for a component: the complete +/// [schema] and [examples]. The [schema] is the full component-envelope +/// schema (including `id` and `component`, plus per-property descriptions) +/// for use inside `updateComponents.components`. +final class CatalogItemDetails { + /// Creates a [CatalogItemDetails]. + const CatalogItemDetails({ + required this.name, + required this.description, + required this.schema, + required this.examples, + }); + + /// The catalog item name, e.g. `Card`. + final String name; + + /// A short, human-readable description of the component. + final String description; + + /// The complete component-envelope JSON schema, including `id` and + /// `component`. + final JsonMap schema; + + /// Example component payloads decoded from the item's JSON examples. + final List examples; + + /// Returns a JSON-serializable representation. + JsonMap toJson() => { + 'name': name, + 'description': description, + 'schema': schema, + 'examples': examples, + }; +} + +/// The result of a [CatalogContext.loadItems] call. +/// +/// Wraps the loaded item [items] with the [catalogId] they were loaded from. +/// The set of loaded names is `items.map((e) => e.name)`; unknown names cause +/// the call to throw rather than producing a partial result. +final class LoadCatalogItemsResult { + /// Creates a [LoadCatalogItemsResult]. + const LoadCatalogItemsResult({required this.catalogId, required this.items}); + + /// The id of the catalog the items were loaded from, if any. + final String? catalogId; + + /// The loaded item details, in request order (de-duplicated). + final List items; + + /// Returns a JSON-serializable representation. + JsonMap toJson() => { + if (catalogId != null) 'catalogId': catalogId, + 'items': items.map((item) => item.toJson()).toList(), + }; +} + +/// Resolves catalog context for incremental catalog prompt mode. +/// +/// Pure functions over the in-process [Catalog]; testable without any LLM +/// provider. Integrations register [loadCatalogItemsTool] with their tool +/// framework and forward calls to [loadItems]. +abstract final class CatalogContext { + CatalogContext._(); + + /// The canonical `loadCatalogItems` tool definition for incremental mode. + /// + /// Register this tool's `name`, `description` and `inputSchema` with the + /// LLM provider's tool framework, and forward the parsed input to + /// [loadItems]. The prompt builder names the same tool, so registering this + /// definition keeps the prompt and the registered tool in sync. + static final ToolDefinition> loadCatalogItemsTool = + ToolDefinition( + name: 'loadCatalogItems', + description: + 'Loads the A2UI schemas and examples for the named catalog items. ' + 'Pass all the components you need for this turn in one call, using ' + 'exact item names from the catalog manifest. Returns each ' + 'component\'s schema and examples so you can emit valid ' + 'updateComponents.', + inputSchema: S.object( + properties: { + 'items': S.list( + items: S.string( + description: 'A catalog item name from the manifest.', + ), + description: + 'The catalog item names to load: all the components ' + 'you need for this turn.', + ), + }, + required: ['items'], + ), + ); + + /// Builds a compact manifest of [catalog]. + /// + /// The manifest contains only names and descriptions; it never includes full + /// schemas or examples. + static CatalogManifest manifest(Catalog catalog) { + return CatalogManifest( + catalogId: catalog.catalogId, + items: [ + for (final item in catalog.items) + CatalogManifestItem( + name: item.name, + description: _descriptionFor(item), + ), + ], + ); + } + + /// Loads exact details for the requested item [names] from [catalog]. + /// + /// Behavior: + /// - Unknown item name: throws [CatalogItemNotFoundException]. + /// - Duplicate names: returned once, preserving first-seen order. + /// - Empty request: returns an empty [LoadCatalogItemsResult.items] list. + static LoadCatalogItemsResult loadItems( + Catalog catalog, + Iterable names, + ) { + final Map byName = { + for (final item in catalog.items) item.name: item, + }; + + final seen = {}; + final details = []; + for (final name in names) { + if (!seen.add(name)) continue; + final CatalogItem? item = byName[name]; + if (item == null) { + throw CatalogItemNotFoundException(name, catalogId: catalog.catalogId); + } + details.add( + CatalogItemDetails( + name: item.name, + description: _descriptionFor(item), + schema: _componentEnvelopeSchema(item), + examples: _examplesFor(item), + ), + ); + } + + return LoadCatalogItemsResult(catalogId: catalog.catalogId, items: details); + } + + /// Resolves a compact description from the item's schema description, falling + /// back to a generic label when the schema has none. + static String _descriptionFor(CatalogItem item) { + final Map value = item.dataSchema.value; + final Object? description = value['description']; + if (description is String && description.trim().isNotEmpty) { + return description.trim(); + } + return 'A2UI component named ${item.name}.'; + } + + /// Builds the full component-envelope schema for [item]. + /// + /// [CatalogItem.dataSchema] already injects the `component` discriminator and + /// marks it required, but does not include `id`. This adds `id` to both + /// `properties` and `required` so the schema describes the complete object + /// expected inside `updateComponents.components`. + static JsonMap _componentEnvelopeSchema(CatalogItem item) { + final itemSchema = Map.from(item.dataSchema.value); + + final itemProperties = Map.from( + itemSchema['properties'] as Map? ?? const {}, + )..remove('id'); + + final itemRequired = List.from( + itemSchema['required'] as List? ?? const [], + ); + + final JsonMap envelope = { + ...itemSchema, + 'additionalProperties': false, + 'properties': { + ...itemProperties, + 'id': { + 'type': 'string', + 'description': + 'Unique component id. Use "root" for the root component.', + }, + }, + 'required': ['id', ...itemRequired.where((value) => value != 'id')], + }; + return jsonDecode( + jsonEncode( + envelope, + ).replaceAll(commonTypesSchemaId, 'common_types.json'), + ) + as JsonMap; + } + + /// Decodes each of the item's example builders as structured JSON. + static List _examplesFor(CatalogItem item) { + final examples = []; + for (var index = 0; index < item.exampleData.length; index++) { + final String json = item.exampleData[index](); + try { + examples.add(jsonDecode(json)); + } on FormatException catch (error) { + throw FormatException( + 'Failed to parse example $index for catalog item "${item.name}": ' + '${error.message}', + error.source, + error.offset, + ); + } + } + return examples; + } +} diff --git a/packages/genui/lib/src/facade/prompt_builder.dart b/packages/genui/lib/src/facade/prompt_builder.dart index 7d1e1205a..d9f8f7dd8 100644 --- a/packages/genui/lib/src/facade/prompt_builder.dart +++ b/packages/genui/lib/src/facade/prompt_builder.dart @@ -10,6 +10,7 @@ import '../model/catalog.dart'; import '../primitives/constants.dart'; import '../primitives/embedded_schemas.g.dart'; import '../primitives/simple_items.dart'; +import 'catalog_context.dart'; /// Common fragments for prompts, to explain agent behavior. // This class should not contain technical details. @@ -63,6 +64,37 @@ the user can indicate that they are done providing information. '${prefix}Do not use tools or function calls for UI generation. ' 'Use JSON text blocks.\n' 'Ensure all JSON is valid and fenced with ```json ... ```.'; + + /// Carve-out from the no-tools-for-UI rule for the `loadCatalogItems` + /// tool used by [CatalogPromptMode.incremental]. + /// + /// Auto-injected by the prompt builder in [CatalogPromptMode.incremental]; + /// callers do not need to add it manually. + /// + /// [prefix] is a prefix to be added to the prompt. + /// Is useful when you want to emphasize the importance of this fragment. + static String incrementalCatalogToolPolicy({String prefix = ''}) => + '$prefix${CatalogContext.loadCatalogItemsTool.name} is available to load ' + 'A2UI catalog item schemas and examples. Calling it is context loading, ' + 'not UI generation. You may also call any other provided tools; when a ' + 'response needs both schemas and other tools, call them together in the ' + 'same turn rather than across separate turns.'; +} + +/// How the catalog is presented to the model in the system prompt. +enum CatalogPromptMode { + /// Inline the full A2UI schema, including every catalog item schema in the + /// `updateComponents` `oneOf`. + fullSchema, + + /// Show a compact catalog manifest up front and let the model load exact + /// component schemas and examples on demand via the `loadCatalogItems` + /// tool. + /// + /// Callers MUST register that tool (wired to [CatalogContext.loadItems]) + /// before selecting this mode, or the model will be instructed to call a + /// tool the host has not registered. + incremental, } /// A builder for a prompt to generate UI. @@ -84,6 +116,7 @@ abstract class PromptBuilder { Iterable systemPromptFragments = const [], String importancePrefix = defaultImportancePrefix, JsonMap? clientDataModel, + CatalogPromptMode catalogPromptMode = CatalogPromptMode.fullSchema, }) { final ({String commonTypes, String serverToClient}) schemas = _loadSchemas(); @@ -94,6 +127,7 @@ abstract class PromptBuilder { importancePrefix: importancePrefix, clientDataModel: clientDataModel, technicalPossibilities: const TechnicalPossibilities(), + catalogPromptMode: catalogPromptMode, commonTypesSchema: schemas.commonTypes, serverToClientSchema: schemas.serverToClient, ); @@ -107,6 +141,7 @@ abstract class PromptBuilder { TechnicalPossibilities technicalPossibilities = const TechnicalPossibilities(), JsonMap? clientDataModel, + CatalogPromptMode catalogPromptMode = CatalogPromptMode.fullSchema, }) { final ({String commonTypes, String serverToClient}) schemas = _loadSchemas(); @@ -117,6 +152,7 @@ abstract class PromptBuilder { importancePrefix: importancePrefix, clientDataModel: clientDataModel, technicalPossibilities: technicalPossibilities, + catalogPromptMode: catalogPromptMode, commonTypesSchema: schemas.commonTypes, serverToClientSchema: schemas.serverToClient, ); @@ -228,7 +264,13 @@ final class TechnicalPossibilities { /// /// This fragment should be added to the system prompt and should be used to /// instruct the model on how to use the surface operations. - Iterable systemPromptFragment() { + /// + /// Set [includeToolRestrictions] to `false` to omit the "no tools / no + /// function calls for UI generation" lines. [CatalogPromptMode.incremental] + /// does this because it legitimately exposes the `loadCatalogItems` tool, and + /// the carve-out ([PromptFragments.incrementalCatalogToolPolicy]) would + /// otherwise have to fight these blanket prohibitions. + Iterable systemPromptFragment({bool includeToolRestrictions = true}) { final result = []; if (!codeExecution) { @@ -237,13 +279,13 @@ final class TechnicalPossibilities { 'If you need to perform calculations, do them yourself.', ); } - if (!toolCall) { + if (includeToolRestrictions && !toolCall) { result.add( '${importancePrefix}You do not have the ability ' 'to use tools for UI generation.', ); } - if (!functionCall) { + if (includeToolRestrictions && !functionCall) { result.add( '${importancePrefix}You do not have the ability ' 'to use function calls for UI generation.', @@ -348,6 +390,7 @@ final class _BasicPromptBuilder extends PromptBuilder { required this.importancePrefix, required this.clientDataModel, required this.technicalPossibilities, + required this.catalogPromptMode, required this.commonTypesSchema, required this.serverToClientSchema, }) : super._(); @@ -374,40 +417,140 @@ final class _BasicPromptBuilder extends PromptBuilder { final TechnicalPossibilities technicalPossibilities; - Iterable _fragmentsToPrompt(Iterable fragments) => - fragments.map((e) => e.trim()); + final CatalogPromptMode catalogPromptMode; @override Iterable systemPrompt() { + if (catalogPromptMode == CatalogPromptMode.incremental) { + return _incrementalSystemPrompt(); + } final String catalogSchema = _generateCatalogSchema(catalog); + final ({String commonTypes, String serverToClient}) cleanSchemas = + _cleanSchemas(); + + return _assembleSystemPrompt( + afterTechnical: const [], + schemaSections: [ + _fenced(cleanSchemas.commonTypes, sectionName: 'COMMON TYPES'), + _fenced(catalogSchema, sectionName: 'CATALOG SCHEMA'), + _fenced(cleanSchemas.serverToClient, sectionName: 'MESSAGE SCHEMA'), + ], + restrictUiTools: true, + ); + } - final String cleanCommonTypes = commonTypesSchema.replaceAll( + /// Builds the system prompt in incremental mode: the default fragment + /// chain plus the `loadCatalogItems` carve-out, with a compact catalog + /// manifest in place of the full A2UI schema. + Iterable _incrementalSystemPrompt() { + // createSurface requires a non-null catalogId; incremental mode is + // out of scope for anonymous inline catalogs. + if (allowedOperations.create && catalog.catalogId == null) { + throw StateError( + 'CatalogPromptMode.incremental requires a non-null catalogId when ' + 'createSurface is enabled.', + ); + } + final ({String commonTypes, String serverToClient}) cleanSchemas = + _cleanSchemas(); + return _assembleSystemPrompt( + afterTechnical: [ + PromptFragments.incrementalCatalogToolPolicy(prefix: importancePrefix), + ], + schemaSections: [ + _fenced(cleanSchemas.commonTypes, sectionName: 'COMMON TYPES'), + _incrementalCatalogPrompt(), + _fenced( + _encodeCatalogFunctions(catalog), + sectionName: 'CATALOG FUNCTIONS', + ), + _fenced(cleanSchemas.serverToClient, sectionName: 'MESSAGE SCHEMA'), + ], + restrictUiTools: false, + ); + } + + ({String commonTypes, String serverToClient}) _cleanSchemas() => ( + commonTypes: commonTypesSchema.replaceAll( commonTypesSchemaId, 'common_types.json', - ); - final String cleanServerToClient = serverToClientSchema.replaceAll( + ), + serverToClient: serverToClientSchema.replaceAll( commonTypesSchemaId, 'common_types.json', - ); + ), + ); + /// Assembles the shared system-prompt fragment chain. + /// + /// Both catalog prompt modes share this skeleton; they differ only in + /// [afterTechnical] (the incremental tool-policy carve-out), the + /// [schemaSections] (full schemas vs. compact manifest), and whether the + /// tool-restriction lines are emitted ([restrictUiTools]). Keeping the order + /// in one place avoids the two modes silently drifting apart. + /// + /// Note: [Catalog.systemPromptFragments] and + /// [SurfaceOperations.systemPromptFragments] are inlined in both modes: they + /// carry guidance, not per-item schemas, so they do not contradict the + /// manifest's "use the loaded schemas" instruction. + Iterable _assembleSystemPrompt({ + required Iterable afterTechnical, + required Iterable schemaSections, + required bool restrictUiTools, + }) { final String? activeCatalogId = catalog.catalogId; - final fragments = [ ...systemPromptFragments, 'Use the provided tools to respond to user using rich UI elements.', if (activeCatalogId != null) 'The active catalog ID is: "$activeCatalogId". ' 'You must use this catalog ID when creating surfaces.', - ...technicalPossibilities.systemPromptFragment(), + ...technicalPossibilities.systemPromptFragment( + includeToolRestrictions: restrictUiTools, + ), + ...afterTechnical, ...catalog.systemPromptFragments, ...allowedOperations.systemPromptFragments, - _fenced(cleanCommonTypes, sectionName: 'COMMON TYPES'), - _fenced(catalogSchema, sectionName: 'CATALOG SCHEMA'), - _fenced(cleanServerToClient, sectionName: 'MESSAGE SCHEMA'), + ...schemaSections, ?_encodedDataModel(clientDataModel), ]; - return _fragmentsToPrompt(fragments); + return fragments.map((fragment) => fragment.trim()); + } + + /// A compact catalog manifest plus instructions to load item details on + /// demand through the `loadCatalogItems` tool. + String _incrementalCatalogPrompt() { + final CatalogManifest manifest = CatalogContext.manifest(catalog); + final String encodedManifest = const JsonEncoder.withIndent( + ' ', + ).convert(manifest.toJson()); + final String toolName = CatalogContext.loadCatalogItemsTool.name; + final String exampleItemNames = manifest.items + .take(2) + .map((item) => jsonEncode(item.name)) + .join(', '); + final loadItemsInputExample = '{"items": [$exampleItemNames]}'; + + return _fenced(''' +The active A2UI catalog is available as a compact manifest below. It lists the +available components and a short description of each, but NOT their full schemas. + +Before emitting any A2UI, call the $toolName tool (for example, +$loadItemsInputExample) to load the exact schema and examples for the components +you need. + +In updateComponents.components, each component is an object with: +- id: a unique component id. Use "root" for the root component. +- component: the catalog item name. +- additional properties defined by the loaded catalog item schema. + +Do not invent component properties; build valid A2UI JSON from the loaded +schemas and examples. + +Catalog manifest: +$encodedManifest +''', sectionName: 'A2UI CATALOG MANIFEST'); } String _generateCatalogSchema(Catalog catalog) { @@ -415,36 +558,58 @@ final class _BasicPromptBuilder extends PromptBuilder { catalog.fullSchema.value as Map, ); - final Map functions = { - for (final func in catalog.functions) - func.name: { - 'description': func.description, - 'parameters': func.argumentSchema.value, - 'returnType': func.returnType.value, - }, - }; + final ({Map functions, Map anyFunction}) + functionSchemas = _catalogFunctionSchemas(catalog); final defs = Map.from( catalogJson[r'$defs'] as Map, ); catalogJson[r'$defs'] = defs; - if (functions.isNotEmpty) { - catalogJson['functions'] = functions; - defs['anyFunction'] = { - 'oneOf': [ - for (final name in functions.keys) {r'$ref': '#/functions/$name'}, - ], - }; + if (functionSchemas.functions.isNotEmpty) { + catalogJson['functions'] = functionSchemas.functions; + defs['anyFunction'] = functionSchemas.anyFunction; } else { catalogJson.remove('functions'); - defs['anyFunction'] = {'not': {}}; + defs['anyFunction'] = functionSchemas.anyFunction; } final String json = const JsonEncoder.withIndent(' ').convert(catalogJson); return json.replaceAll(commonTypesSchemaId, 'common_types.json'); } + String _encodeCatalogFunctions(Catalog catalog) { + final ({Map functions, Map anyFunction}) + functionSchemas = _catalogFunctionSchemas(catalog); + final JsonMap catalogFunctions = { + 'functions': functionSchemas.functions, + r'$defs': {'anyFunction': functionSchemas.anyFunction}, + }; + return const JsonEncoder.withIndent(' ') + .convert(catalogFunctions) + .replaceAll(commonTypesSchemaId, 'common_types.json'); + } + + ({Map functions, Map anyFunction}) + _catalogFunctionSchemas(Catalog catalog) { + final Map functions = { + for (final func in catalog.functions) + func.name: { + 'description': func.description, + 'parameters': func.argumentSchema.value, + 'returnType': func.returnType.value, + }, + }; + final Map anyFunction = functions.isEmpty + ? {'not': {}} + : { + 'oneOf': [ + for (final name in functions.keys) {r'$ref': '#/functions/$name'}, + ], + }; + return (functions: functions, anyFunction: anyFunction); + } + static String? _encodedDataModel(JsonMap? clientDataModel) { if (clientDataModel == null) return null; final String encodedModel = const JsonEncoder.withIndent( diff --git a/packages/genui/test/facade/catalog_context_test.dart b/packages/genui/test/facade/catalog_context_test.dart new file mode 100644 index 000000000..1bbd61c1a --- /dev/null +++ b/packages/genui/test/facade/catalog_context_test.dart @@ -0,0 +1,267 @@ +// 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:json_schema_builder/json_schema_builder.dart'; + +void main() { + final catalog = Catalog([ + BasicCatalogItems.card, + BasicCatalogItems.text, + BasicCatalogItems.button, + ], catalogId: 'test_catalog'); + + // A custom item with its own schema description, used to verify the manifest + // derives its description from the schema (single source of truth). + final fancyItem = CatalogItem( + name: 'Fancy', + dataSchema: S.object( + description: 'A fancy component for decorative content.', + properties: {'label': S.string()}, + required: ['label'], + ), + widgetBuilder: (_) => const SizedBox.shrink(), + ); + final metaCatalog = Catalog([fancyItem], catalogId: 'meta_catalog'); + + group('CatalogContext.manifest', () { + test('includes catalog item names and descriptions', () { + final CatalogManifest manifest = CatalogContext.manifest(catalog); + + expect( + manifest.items.map((CatalogManifestItem e) => e.name), + containsAll(['Card', 'Text', 'Button']), + ); + + final CatalogManifestItem card = manifest.items.firstWhere( + (CatalogManifestItem e) => e.name == 'Card', + ); + // Don't pin the exact wording (it lives in BasicCatalogItems and may + // change). The "derives from schema description" test below pins the + // wiring with a controlled item. + expect(card.description, isNotEmpty); + expect(manifest.catalogId, 'test_catalog'); + }); + + test('derives the description from the schema description', () { + final CatalogManifest manifest = CatalogContext.manifest(metaCatalog); + final CatalogManifestItem item = manifest.items.single; + + expect(item.description, 'A fancy component for decorative content.'); + }); + + test('manifest items only carry name and description', () { + final CatalogManifest manifest = CatalogContext.manifest(catalog); + + for (final CatalogManifestItem item in manifest.items) { + final Iterable keys = item.toJson().keys; + expect(keys, containsAll(['name', 'description'])); + expect(keys, hasLength(2)); + } + }); + }); + + group('CatalogContext.loadItems', () { + test('returns details for the requested items', () { + final LoadCatalogItemsResult result = CatalogContext.loadItems( + catalog, + ['Card', 'Text'], + ); + + expect(result.items.map((CatalogItemDetails e) => e.name), [ + 'Card', + 'Text', + ]); + expect(result.catalogId, 'test_catalog'); + }); + + test('loaded schema includes id and component', () { + final LoadCatalogItemsResult result = CatalogContext.loadItems( + catalog, + ['Card'], + ); + final properties = + result.items.single.schema['properties'] as Map; + + expect( + properties.keys, + containsAll(['id', 'component', 'child']), + ); + }); + + test('loaded schema marks id and component as required', () { + final LoadCatalogItemsResult result = CatalogContext.loadItems( + catalog, + ['Card'], + ); + final required = result.items.single.schema['required'] as List; + + expect(required, containsAll(['id', 'component'])); + }); + + test('loaded Card schema keeps child as required', () { + final LoadCatalogItemsResult result = CatalogContext.loadItems( + catalog, + ['Card'], + ); + final required = result.items.single.schema['required'] as List; + + expect(required, contains('child')); + }); + + test('loaded schema rejects additional properties', () { + final LoadCatalogItemsResult result = CatalogContext.loadItems( + catalog, + ['Card'], + ); + + expect(result.items.single.schema['additionalProperties'], isFalse); + }); + + test('rewrites common-types refs in loaded schemas', () { + final referencedItem = CatalogItem( + name: 'Referenced', + dataSchema: ObjectSchema.fromMap({ + 'type': 'object', + 'properties': { + 'action': { + r'$ref': '$commonTypesSchemaId#/\$defs/Action', + }, + }, + }), + widgetBuilder: (_) => const SizedBox.shrink(), + ); + final referencedCatalog = Catalog([ + referencedItem, + ], catalogId: 'referenced_catalog'); + + final LoadCatalogItemsResult result = CatalogContext.loadItems( + referencedCatalog, + ['Referenced'], + ); + final encodedSchema = result.items.single.schema.toString(); + + expect(encodedSchema, contains(r'common_types.json#/$defs/Action')); + expect(encodedSchema, isNot(contains('https://a2ui.org'))); + }); + + test('parses example JSON when valid', () { + final LoadCatalogItemsResult result = CatalogContext.loadItems( + catalog, + ['Card'], + ); + + expect(result.items.single.examples, hasLength(1)); + expect(result.items.single.examples.first, isA>()); + }); + + test('identifies the item and example when example JSON is invalid', () { + final invalidExampleItem = CatalogItem( + name: 'BrokenCard', + dataSchema: S.object(), + widgetBuilder: (_) => const SizedBox.shrink(), + exampleData: [ + () => '[]', + () => '{invalid json', + ], + ); + final invalidExampleCatalog = Catalog([ + invalidExampleItem, + ], catalogId: 'invalid_example_catalog'); + + expect( + () => CatalogContext.loadItems(invalidExampleCatalog, [ + 'BrokenCard', + ]), + throwsA( + isA() + .having( + (FormatException error) => error.message, + 'message', + allOf(contains('BrokenCard'), contains('example 1')), + ) + .having( + (FormatException error) => error.source, + 'source', + '{invalid json', + ) + .having( + (FormatException error) => error.offset, + 'offset', + isNotNull, + ), + ), + ); + }); + + test('preserves request order and removes duplicates', () { + final LoadCatalogItemsResult result = CatalogContext.loadItems( + catalog, + ['Button', 'Card', 'Button'], + ); + + expect(result.items.map((CatalogItemDetails e) => e.name), [ + 'Button', + 'Card', + ]); + }); + + test('throws for unknown catalog item', () { + expect( + () => CatalogContext.loadItems(catalog, ['NotAComponent']), + throwsA( + isA() + .having((e) => e.widgetType, 'widgetType', 'NotAComponent') + .having((e) => e.catalogId, 'catalogId', 'test_catalog'), + ), + ); + }); + + test('accepts an empty request', () { + final LoadCatalogItemsResult result = CatalogContext.loadItems( + catalog, + const [], + ); + + expect(result.items, isEmpty); + }); + + test('details carry only name, description, schema, and examples', () { + final LoadCatalogItemsResult result = CatalogContext.loadItems( + catalog, + ['Card'], + ); + final Iterable keys = result.items.single.toJson().keys; + + expect( + keys, + containsAll(['name', 'description', 'schema', 'examples']), + ); + expect(keys, hasLength(4)); + }); + }); + + group('CatalogContext.loadCatalogItemsTool', () { + test('exposes a canonical ToolDefinition for incremental mode', () { + final ToolDefinition> tool = + CatalogContext.loadCatalogItemsTool; + + expect(tool.name, 'loadCatalogItems'); + expect(tool.description, isNotEmpty); + expect( + tool.description, + contains('exact item names from the catalog manifest'), + ); + expect(tool.description, isNot(contains('"Card"'))); + + final Map schema = tool.inputSchema.value; + expect(schema['type'], 'object'); + final properties = schema['properties'] as Map; + expect(properties.keys, contains('items')); + expect(schema['required'], contains('items')); + }); + }); +} diff --git a/packages/genui/test/facade/prompt_builder_test.dart b/packages/genui/test/facade/prompt_builder_test.dart index 8bb692830..80fa92ce2 100644 --- a/packages/genui/test/facade/prompt_builder_test.dart +++ b/packages/genui/test/facade/prompt_builder_test.dart @@ -28,25 +28,211 @@ void main() { ); group('Chat prompt', () { - 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 = PromptBuilder.chat( - catalog: testCatalog, - systemPromptFragments: systemPromptFragments, - ); - final PromptBuilder 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', () { + final systemPromptFragments = [ + 'You are a chat assistant.', + 'You sometimes tell jokes to the user', + ]; + final PromptBuilder chatBuilder = PromptBuilder.chat( + catalog: testCatalog, + systemPromptFragments: systemPromptFragments, + ); + final PromptBuilder customBuilder = PromptBuilder.custom( + catalog: testCatalog, + allowedOperations: SurfaceOperations.createOnly(dataModel: false), + systemPromptFragments: systemPromptFragments, + ); + expect(chatBuilder.systemPrompt(), customBuilder.systemPrompt()); + }); + + test('defaults to full-schema catalog prompt mode', () { + final String prompt = PromptBuilder.chat( + catalog: testCatalog, + ).systemPromptJoined(); + + expect(prompt, contains('COMMON_TYPES')); + expect(prompt, contains('CATALOG_SCHEMA')); + expect(prompt, contains('MESSAGE_SCHEMA')); + expect(prompt, isNot(contains('A2UI_CATALOG_MANIFEST'))); + }); + + test('custom prompts also default to full-schema catalog prompt mode', () { + final String prompt = PromptBuilder.custom( + catalog: testCatalog, + allowedOperations: SurfaceOperations.createOnly(dataModel: false), + ).systemPromptJoined(); + + expect(prompt, contains('COMMON_TYPES')); + expect(prompt, contains('CATALOG_SCHEMA')); + expect(prompt, contains('MESSAGE_SCHEMA')); + expect(prompt, isNot(contains('A2UI_CATALOG_MANIFEST'))); + }); + }); + + group('Incremental catalog prompt mode', () { + final systemPromptFragments = ['You are a chat assistant.']; + + String incrementalPromptFor(Catalog catalog) => PromptBuilder.chat( + catalog: catalog, + systemPromptFragments: systemPromptFragments, + catalogPromptMode: CatalogPromptMode.incremental, + ).systemPromptJoined(); + + test('includes a catalog manifest section and omits the full schema', () { + final String prompt = incrementalPromptFor(testCatalog); + + expect(prompt, contains('COMMON_TYPES')); + expect(prompt, contains('A2UI_CATALOG_MANIFEST')); + expect(prompt, contains('CATALOG_FUNCTIONS')); + expect(prompt, contains('MESSAGE_SCHEMA')); + expect(prompt, isNot(contains('CATALOG_SCHEMA'))); + }); + + test('custom prompts can use incremental catalog prompt mode', () { + final String prompt = PromptBuilder.custom( + catalog: testCatalog, + allowedOperations: SurfaceOperations.createOnly(dataModel: false), + catalogPromptMode: CatalogPromptMode.incremental, + ).systemPromptJoined(); + + expect(prompt, contains('A2UI_CATALOG_MANIFEST')); + expect(prompt, contains('loadCatalogItems')); + expect(prompt, isNot(contains('CATALOG_SCHEMA'))); + }); + + test('describes the required A2UI message envelope', () { + final catalogWithoutPromptFragments = Catalog([ + BasicCatalogItems.text, + ], catalogId: 'minimal_catalog'); + final String prompt = PromptBuilder.chat( + catalog: catalogWithoutPromptFragments, + catalogPromptMode: CatalogPromptMode.incremental, + ).systemPromptJoined(); + + expect(prompt, contains('MESSAGE_SCHEMA')); + expect(prompt, contains('"const": "v0.9"')); + }); + + test('uses active manifest names in the loadCatalogItems example', () { + final textOnlyCatalog = Catalog([ + BasicCatalogItems.text, + ], catalogId: 'text_only_catalog'); + final String prompt = incrementalPromptFor(textOnlyCatalog); + + expect(prompt, contains('loadCatalogItems')); + expect(prompt, contains('{"items": ["Text"]}')); + expect(prompt, isNot(contains('{"items": ["Card", "Text"]}'))); + }); + + test('includes catalog functions and anyFunction', () { + final functionsCatalog = Catalog( + [BasicCatalogItems.text], + functions: [BasicFunctions.requiredFunction], + catalogId: 'functions_catalog', + ); + final String prompt = incrementalPromptFor(functionsCatalog); + + expect(prompt, contains('CATALOG_FUNCTIONS')); + expect(prompt, contains('"required"')); + expect(prompt, contains('"anyFunction"')); + expect(prompt, contains(r'"$ref": "#/functions/required"')); + }); + + test('includes an impossible anyFunction when functions are empty', () { + final String prompt = incrementalPromptFor(testCatalog); + + expect(prompt, contains('"functions": {}')); + expect(prompt, contains('"anyFunction"')); + expect(prompt, contains('"not": {}')); + }); + + test('describes the component envelope (id and component)', () { + final String prompt = incrementalPromptFor(testCatalog); + + expect(prompt, contains('id:')); + expect(prompt, contains('component:')); + expect(prompt, contains('"root"')); + }); + + test('auto-injects the loadCatalogItems carve-out policy', () { + final String prompt = incrementalPromptFor(testCatalog); + + expect(prompt, contains(PromptFragments.incrementalCatalogToolPolicy())); + expect(prompt, contains('context loading, not UI generation')); + }); + + test('omits the blanket no-tools restriction', () { + final String prompt = incrementalPromptFor(testCatalog); + + expect( + prompt, + isNot(contains('do not have the ability to use tools for UI')), + ); + expect( + prompt, + isNot(contains('do not have the ability to use function calls')), + ); + }); + + test('keeps unrelated technical restrictions', () { + final String prompt = incrementalPromptFor(testCatalog); + + expect(prompt, contains('do not have the ability to execute code')); + }); + + test('full-schema mode still keeps the no-tools restriction', () { + final String prompt = PromptBuilder.chat( + catalog: testCatalog, + ).systemPromptJoined(); + + expect(prompt, contains('do not have the ability to use tools for UI')); + }); + + test('still includes surface operation instructions', () { + final String prompt = incrementalPromptFor(testCatalog); + + expect(prompt, contains(ProtocolMessages.createSurface.name)); + expect(prompt, contains(ProtocolMessages.updateComponents.name)); + }); + + test('preserves caller-provided system prompt fragments', () { + final String prompt = incrementalPromptFor(testCatalog); + + for (final fragment in systemPromptFragments) { + expect(prompt, contains(fragment)); + } + }); + + test('includes the client data model when provided', () { + final String prompt = PromptBuilder.chat( + catalog: testCatalog, + catalogPromptMode: CatalogPromptMode.incremental, + clientDataModel: {'foo': 'bar'}, + ).systemPromptJoined(); + + expect(prompt, contains('Client Data Model:')); + expect(prompt, contains('"foo": "bar"')); + }); + + test('throws when incremental + create has no catalogId', () { + final anonymousCatalog = Catalog([BasicCatalogItems.text]); + + expect( + () => PromptBuilder.chat( + catalog: anonymousCatalog, + catalogPromptMode: CatalogPromptMode.incremental, + ).systemPrompt(), + throwsStateError, + ); + }); + + test('matches the golden for the test catalog', () { + final String prompt = PromptBuilder.chat( + catalog: testCatalog, + catalogPromptMode: CatalogPromptMode.incremental, + ).systemPromptJoined(); + verifyGoldenText(prompt, 'incremental_test_catalog.txt'); + }); }); group('Custom prompt', () { diff --git a/packages/genui/test/facade/prompt_builder_test.golden/incremental_test_catalog.txt b/packages/genui/test/facade/prompt_builder_test.golden/incremental_test_catalog.txt new file mode 100644 index 000000000..4d7b5827c --- /dev/null +++ b/packages/genui/test/facade/prompt_builder_test.golden/incremental_test_catalog.txt @@ -0,0 +1,597 @@ +Use the provided tools to respond to user using rich UI elements. + +------------------------------------- + +The active catalog ID is: "test_catalog". You must use this catalog ID when creating surfaces. + +------------------------------------- + +IMPORTANT: You do not have the ability to execute code. If you need to perform calculations, do them yourself. + +------------------------------------- + +IMPORTANT: loadCatalogItems is available to load A2UI catalog item schemas and examples. Calling it is context loading, not UI generation. You may also call any other provided tools; when a response needs both schemas and other tools, call them together in the same turn rather than across separate turns. + +------------------------------------- + +**REQUIRED PROPERTIES:** You MUST include ALL required properties for every component, even if they are inside a template or will be bound to data. +- For 'Text', you MUST provide 'text'. If dynamic, use { "path": "..." }. +- For 'Image', you MUST provide 'url'. If dynamic, use { "path": "..." }. +- For 'Button', you MUST provide 'action'. +- For 'TextField', 'CheckBox', etc., you MUST provide 'label'. + +**EXAMPLES:** + +1. Create a surface: +```json +{ + "version": "v0.9", + "createSurface": { + "surfaceId": "main", + "catalogId": "https://a2ui.org/specification/v0_9/basic_catalog.json", + "sendDataModel": true + } +} +``` + +2. Update components: +```json +{ + "version": "v0.9", + "updateComponents": { + "surfaceId": "main", + "components": [ + { + // The root component MUST have id "root" + "id": "root", + "component": "Column", + "justify": "start", + "children": [ + "headerText", + "content" + ] + } + ] + } +} +``` + +**IMPORTANT:** +- One of the components sent in one of the `updateComponents` MUST have id "root", or nothing will be displayed. +- Do NOT nest `components` inside `createSurface`. Use `updateComponents` to add components to a surface. +- `createSurface` ONLY sets up the surface (ID and catalog). It does NOT take content. +- To show a UI, you typically send a `createSurface` message (if the surface doesn't exist), followed by an `updateComponents` message. + +------------------------------------- + +Your responses should contain acknowledgment of the user message. + +------------------------------------- + +IMPORTANT: When you are asking for information from the user, you should always include +at least one submit button of some kind or another submitting element so that +the user can indicate that they are done providing information. + +------------------------------------- + +-----CONTROLLING_THE_UI_START----- +You can control the UI by outputting valid A2UI JSON messages wrapped in markdown code blocks. + +Supported messages are: `createSurface`, `updateComponents`. + +- `createSurface`: Creates a new surface. +- `updateComponents`: Updates components in a surface. + +Properties: + +- `createSurface`: Requires `surfaceId` (you must always use a unique ID for each created surface), +`catalogId` (use the active catalog ID if provided in system instructions), +and `sendDataModel: true`. +- `updateComponents`: Requires `surfaceId` and a list of `components`. +One component MUST have `id: "root"`. + +To create a new UI: +1. Output a `createSurface` message with a unique `surfaceId` and `catalogId` (use the active catalog ID if provided in system instructions). +2. Output an `updateComponents` message with the `surfaceId` and the component definitions. + +IMPORTANT: DO NOT update or modify surfaces created in previous turns. If the UI needs to change, you MUST create a NEW surface with a new unique `surfaceId`. You may only use `updateComponents` to populate the components of a freshly created surface. +-----CONTROLLING_THE_UI_END----- + +------------------------------------- + +-----OUTPUT_FORMAT_START----- +When constructing UI, you must output a VALID A2UI JSON object representing one of the A2UI message types (`createSurface`, `updateComponents`). +- You can treat the A2UI schema as a specification for the JSON you typically output. +- The JSON block must be valid and complete. +- Ensure your JSON is fenced with ```json and ```. +-----OUTPUT_FORMAT_END----- + +------------------------------------- + +-----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----- + +------------------------------------- + +-----A2UI_CATALOG_MANIFEST_START----- +The active A2UI catalog is available as a compact manifest below. It lists the +available components and a short description of each, but NOT their full schemas. + +Before emitting any A2UI, call the loadCatalogItems tool (for example, +{"items": ["Text"]}) to load the exact schema and examples for the components +you need. + +In updateComponents.components, each component is an object with: +- id: a unique component id. Use "root" for the root component. +- component: the catalog item name. +- additional properties defined by the loaded catalog item schema. + +Do not invent component properties; build valid A2UI JSON from the loaded +schemas and examples. + +Catalog manifest: +{ + "catalogId": "test_catalog", + "items": [ + { + "name": "Text", + "description": "A block of styled text." + } + ] +} +-----A2UI_CATALOG_MANIFEST_END----- + +------------------------------------- + +-----CATALOG_FUNCTIONS_START----- +{ + "functions": {}, + "$defs": { + "anyFunction": { + "not": {} + } + } +} +-----CATALOG_FUNCTIONS_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": { + "const": "v0.9" + }, + "createSurface": { + "type": "object", + "description": "Signals the client to create a new surface and begin rendering it. It is an error to send 'createSurface' for a surfaceId that already exists without first deleting it. When this message is sent, the client will expect 'updateComponents' and/or 'updateDataModel' messages for the same surfaceId that define the component tree.", + "properties": { + "surfaceId": { + "type": "string", + "description": "The unique identifier for the UI surface to be rendered." + }, + "catalogId": { + "description": "A string that uniquely identifies this catalog. It is recommended to prefix this with an internet domain that you own, to avoid conflicts e.g. mycompany.com:somecatalog'.", + "type": "string" + }, + "theme": { + "$ref": "catalog.json#/$defs/theme", + "description": "Theme parameters for the surface (e.g., {'primaryColor': '#FF0000'}). These must validate against the 'theme' schema defined in the catalog." + }, + "sendDataModel": { + "type": "boolean", + "description": "If true, the client will send the full data model of this surface in the metadata of every A2A message sent to the server that created the surface. Defaults to false." + } + }, + "required": ["surfaceId", "catalogId"], + "additionalProperties": false + } + }, + "required": ["createSurface", "version"], + "additionalProperties": false + }, + "UpdateComponentsMessage": { + "type": "object", + "properties": { + "version": { + "const": "v0.9" + }, + "updateComponents": { + "type": "object", + "description": "Updates a surface with a new set of components. This message can be sent multiple times to update the component tree of an existing surface. One of the components in one of the components lists MUST have an 'id' of 'root' to serve as the root of the component tree. The createSurface message MUST have been previously sent with the 'catalogId' that is in this message.", + "properties": { + "surfaceId": { + "type": "string", + "description": "The unique identifier for the UI surface to be updated." + }, + + "components": { + "type": "array", + "description": "A list containing all UI components for the surface.", + "minItems": 1, + "items": { + "$ref": "catalog.json#/$defs/anyComponent" + } + } + }, + "required": ["surfaceId", "components"], + "additionalProperties": false + } + }, + "required": ["updateComponents", "version"], + "additionalProperties": false + }, + "UpdateDataModelMessage": { + "type": "object", + "properties": { + "version": { + "const": "v0.9" + }, + "updateDataModel": { + "type": "object", + "description": "Updates the data model for an existing surface. This message can be sent multiple times to update the data model. The createSurface message MUST have been previously sent with the 'catalogId' that is in this message.", + "properties": { + "surfaceId": { + "type": "string", + "description": "The unique identifier for the UI surface this data model update applies to." + }, + "path": { + "type": "string", + "description": "An optional path to a location within the data model (e.g., '/user/name'). If omitted, or set to '/', refers to the entire data model." + }, + "value": { + "description": "The data to be updated in the data model. If present, the value at 'path' is replaced (or created). If omitted, the key at 'path' is removed.", + "additionalProperties": true + } + }, + "required": ["surfaceId"], + "additionalProperties": false + } + }, + "required": ["updateDataModel", "version"], + "additionalProperties": false + }, + "DeleteSurfaceMessage": { + "type": "object", + "properties": { + "version": { + "const": "v0.9" + }, + "deleteSurface": { + "type": "object", + "description": "Signals the client to delete the surface identified by 'surfaceId'. The createSurface message MUST have been previously sent with the 'catalogId' that is in this message.", + "properties": { + "surfaceId": { + "type": "string", + "description": "The unique identifier for the UI surface to be deleted." + } + }, + "required": ["surfaceId"], + "additionalProperties": false + } + }, + "required": ["deleteSurface", "version"], + "additionalProperties": false + } + } +} +-----MESSAGE_SCHEMA_END-----