From 08a3d38d29a492c73dbcef70a5563ba6d32ff1f3 Mon Sep 17 00:00:00 2001 From: Leo Farias Date: Fri, 29 May 2026 16:12:55 -0400 Subject: [PATCH 1/2] feat(genui): add incremental catalog loading via CatalogContext Introduces CatalogContext to support incremental catalog building in the facade, and updates PromptBuilder to consume it. Includes tests and golden output for the new incremental loading behavior. --- packages/genui/lib/src/facade.dart | 1 + .../genui/lib/src/facade/catalog_context.dart | 252 ++++++++++++++++++ .../genui/lib/src/facade/prompt_builder.dart | 145 +++++++++- .../test/facade/catalog_context_test.dart | 198 ++++++++++++++ .../test/facade/prompt_builder_test.dart | 146 ++++++++++ .../incremental_test_catalog.txt | 133 +++++++++ 6 files changed, 866 insertions(+), 9 deletions(-) create mode 100644 packages/genui/lib/src/facade/catalog_context.dart create mode 100644 packages/genui/test/facade/catalog_context_test.dart create mode 100644 packages/genui/test/facade/prompt_builder_test.golden/incremental_test_catalog.txt 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..29f1ad2f4 --- /dev/null +++ b/packages/genui/lib/src/facade/catalog_context.dart @@ -0,0 +1,252 @@ +// 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/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 ' + '(e.g. {"items": ["Card", "Text"]}); 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 [], + ); + + return { + ...itemSchema, + 'properties': { + ...itemProperties, + 'id': { + 'type': 'string', + 'description': + 'Unique component id. Use "root" for the root component.', + }, + }, + 'required': ['id', ...itemRequired.where((value) => value != 'id')], + }; + } + + /// Decodes each of the item's example builders as structured JSON. + static List _examplesFor(CatalogItem item) { + return [ + for (final buildExample in item.exampleData) jsonDecode(buildExample()), + ]; + } +} diff --git a/packages/genui/lib/src/facade/prompt_builder.dart b/packages/genui/lib/src/facade/prompt_builder.dart index 77ecada7c..fcc3b2437 100644 --- a/packages/genui/lib/src/facade/prompt_builder.dart +++ b/packages/genui/lib/src/facade/prompt_builder.dart @@ -9,6 +9,7 @@ import 'package:flutter/material.dart'; import '../model/a2ui_message.dart'; import '../model/catalog.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. @@ -62,6 +63,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. @@ -83,6 +115,7 @@ abstract class PromptBuilder { Iterable systemPromptFragments = const [], String importancePrefix = defaultImportancePrefix, JsonMap? clientDataModel, + CatalogPromptMode catalogPromptMode = CatalogPromptMode.fullSchema, }) { return _BasicPromptBuilder( catalog: catalog, @@ -91,6 +124,7 @@ abstract class PromptBuilder { importancePrefix: importancePrefix, clientDataModel: clientDataModel, technicalPossibilities: const TechnicalPossibilities(), + catalogPromptMode: catalogPromptMode, ); } @@ -102,6 +136,7 @@ abstract class PromptBuilder { TechnicalPossibilities technicalPossibilities = const TechnicalPossibilities(), JsonMap? clientDataModel, + CatalogPromptMode catalogPromptMode = CatalogPromptMode.fullSchema, }) { return _BasicPromptBuilder( catalog: catalog, @@ -110,6 +145,7 @@ abstract class PromptBuilder { importancePrefix: importancePrefix, clientDataModel: clientDataModel, technicalPossibilities: technicalPossibilities, + catalogPromptMode: catalogPromptMode, ); } @@ -212,7 +248,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) { @@ -221,13 +263,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.', @@ -332,6 +374,7 @@ final class _BasicPromptBuilder extends PromptBuilder { required this.importancePrefix, required this.clientDataModel, required this.technicalPossibilities, + required this.catalogPromptMode, }) : super._(); final Catalog catalog; @@ -352,28 +395,112 @@ final class _BasicPromptBuilder extends PromptBuilder { final JsonMap? clientDataModel; - Iterable _fragmentsToPrompt(Iterable fragments) => - fragments.map((e) => e.trim()); - final TechnicalPossibilities technicalPossibilities; + final CatalogPromptMode catalogPromptMode; + @override Iterable systemPrompt() { + if (catalogPromptMode == CatalogPromptMode.incremental) { + return _incrementalSystemPrompt(); + } final String a2uiSchema = A2uiMessage.a2uiMessageSchema( catalog, ).toJson(indent: ' '); + return _assembleSystemPrompt( + afterTechnical: const [], + catalogSection: _fenced(a2uiSchema, sectionName: 'A2UI JSON SCHEMA'), + restrictUiTools: true, + ); + } + + /// 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.', + ); + } + return _assembleSystemPrompt( + afterTechnical: [ + PromptFragments.incrementalCatalogToolPolicy(prefix: importancePrefix), + ], + catalogSection: _incrementalCatalogPrompt(), + // The catalog presentation is a manifest, not the full schema, so the + // model must call loadCatalogItems; suppress the blanket "no tools for + // UI generation" lines that would otherwise contradict the carve-out. + restrictUiTools: false, + ); + } + + /// 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 + /// [catalogSection] (full schema 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 intentionally inlined in + /// both modes. They carry operation and usage guidance, not full per-item + /// schemas, so they do not contradict the manifest's "use the loaded + /// schemas" instruction. + Iterable _assembleSystemPrompt({ + required Iterable afterTechnical, + required String catalogSection, + required bool restrictUiTools, + }) { final fragments = [ ...systemPromptFragments, 'Use the provided tools to respond to user using rich UI elements.', - ...technicalPossibilities.systemPromptFragment(), + ...technicalPossibilities.systemPromptFragment( + includeToolRestrictions: restrictUiTools, + ), + ...afterTechnical, ...catalog.systemPromptFragments, ...allowedOperations.systemPromptFragments, - _fenced(a2uiSchema, sectionName: 'A2UI JSON SCHEMA'), + catalogSection, ?_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; + + 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 (input shape +{"items": ["Card", "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: +$encodedManifest +''', sectionName: 'A2UI CATALOG MANIFEST'); } static String? _encodedDataModel(JsonMap? clientDataModel) { 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..8a2c2a9cf --- /dev/null +++ b/packages/genui/test/facade/catalog_context_test.dart @@ -0,0 +1,198 @@ +// 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)); + } + }); + + test('does not include full schemas or examples', () { + final CatalogManifest manifest = CatalogContext.manifest(catalog); + + for (final CatalogManifestItem item in manifest.items) { + final Iterable keys = item.toJson().keys; + expect(keys, isNot(contains('schema'))); + expect(keys, isNot(contains('examples'))); + expect(keys, isNot(contains('properties'))); + } + }); + }); + + 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('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('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); + + 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 dc573fdc5..d268dfd6f 100644 --- a/packages/genui/test/facade/prompt_builder_test.dart +++ b/packages/genui/test/facade/prompt_builder_test.dart @@ -37,6 +37,152 @@ void main() { ); expect(chatBuilder.systemPrompt(), customBuilder.systemPrompt()); }); + + test('defaults to full-schema catalog prompt mode', () { + final String prompt = PromptBuilder.chat( + catalog: testCatalog, + ).systemPromptJoined(); + + expect(prompt, contains('A2UI_JSON_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('A2UI_JSON_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('A2UI_CATALOG_MANIFEST')); + expect(prompt, isNot(contains('A2UI_JSON_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('A2UI_JSON_SCHEMA'))); + }); + + test( + 'instructs the model to call loadCatalogItems with canonical input', + () { + final String prompt = incrementalPromptFor(testCatalog); + + expect(prompt, contains('loadCatalogItems')); + expect(prompt, contains('{"items": ["Card", "Text"]}')); + }, + ); + + 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..4ac15d850 --- /dev/null +++ b/packages/genui/test/facade/prompt_builder_test.golden/incremental_test_catalog.txt @@ -0,0 +1,133 @@ +Use the provided tools to respond to user using rich UI elements. + +------------------------------------- + +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 catalog ID 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 catalog ID 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----- + +------------------------------------- + +-----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 (input shape +{"items": ["Card", "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----- From 26f468dc5ea6589ee15fe17a36923e8ebff2aca0 Mon Sep 17 00:00:00 2001 From: Leo Farias Date: Mon, 1 Jun 2026 11:31:54 -0400 Subject: [PATCH 2/2] refactor(genui): drop redundant manifest test and tidy prompt comments The removed test asserted the manifest excludes schema/examples/properties keys, which is already guaranteed by the adjacent test pinning the keys to exactly name+description. Also trims comment noise in the incremental catalog prompt assembly. --- packages/genui/lib/src/facade/prompt_builder.dart | 10 +++------- packages/genui/test/facade/catalog_context_test.dart | 11 ----------- 2 files changed, 3 insertions(+), 18 deletions(-) diff --git a/packages/genui/lib/src/facade/prompt_builder.dart b/packages/genui/lib/src/facade/prompt_builder.dart index fcc3b2437..465fecc81 100644 --- a/packages/genui/lib/src/facade/prompt_builder.dart +++ b/packages/genui/lib/src/facade/prompt_builder.dart @@ -432,9 +432,6 @@ final class _BasicPromptBuilder extends PromptBuilder { PromptFragments.incrementalCatalogToolPolicy(prefix: importancePrefix), ], catalogSection: _incrementalCatalogPrompt(), - // The catalog presentation is a manifest, not the full schema, so the - // model must call loadCatalogItems; suppress the blanket "no tools for - // UI generation" lines that would otherwise contradict the carve-out. restrictUiTools: false, ); } @@ -448,10 +445,9 @@ final class _BasicPromptBuilder extends PromptBuilder { /// in one place avoids the two modes silently drifting apart. /// /// Note: [Catalog.systemPromptFragments] and - /// [SurfaceOperations.systemPromptFragments] are intentionally inlined in - /// both modes. They carry operation and usage guidance, not full per-item - /// schemas, so they do not contradict the manifest's "use the loaded - /// schemas" instruction. + /// [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 String catalogSection, diff --git a/packages/genui/test/facade/catalog_context_test.dart b/packages/genui/test/facade/catalog_context_test.dart index 8a2c2a9cf..accf66ee2 100644 --- a/packages/genui/test/facade/catalog_context_test.dart +++ b/packages/genui/test/facade/catalog_context_test.dart @@ -62,17 +62,6 @@ void main() { expect(keys, hasLength(2)); } }); - - test('does not include full schemas or examples', () { - final CatalogManifest manifest = CatalogContext.manifest(catalog); - - for (final CatalogManifestItem item in manifest.items) { - final Iterable keys = item.toJson().keys; - expect(keys, isNot(contains('schema'))); - expect(keys, isNot(contains('examples'))); - expect(keys, isNot(contains('properties'))); - } - }); }); group('CatalogContext.loadItems', () {