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..465fecc81 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,108 @@ 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(), + 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 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, + 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..accf66ee2 --- /dev/null +++ b/packages/genui/test/facade/catalog_context_test.dart @@ -0,0 +1,187 @@ +// 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('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-----