diff --git a/dart/a2ui_agent/CHANGELOG.md b/dart/a2ui_agent/CHANGELOG.md index b95a7ba338..48013a373a 100644 --- a/dart/a2ui_agent/CHANGELOG.md +++ b/dart/a2ui_agent/CHANGELOG.md @@ -59,6 +59,18 @@ which `RendererState.of` uses. The release that publishes this version raises the `a2ui_core` constraint along with that `a2ui_core` release; until then the workspace resolves `a2ui_core` from this repository. +- Breaking: `Event(...)` in Express no longer accepts the event name as a + `name=` keyword argument; pass it positionally, as the TypeScript and Python + compilers require. A keyword `Event` does not define now throws + `A2uiValidationError`. +- `ExpressCompiler` accepts `userMessage=` and `context=` keyword arguments on + `Event(...)`, compiling `userMessage` to the v1.0 `event.userMessage` field, + and throws `A2uiValidationError` for `userMessage` before v1.0 and for any + keyword `Event` does not define, where it used to drop them. A function call + in an action slot nested inside a list item or object property (such as + `items: [{label, action: openUrl(...)}]`) is now compiled as a + `functionCall` action. `ExpressDecompiler` writes `userMessage=` back + ([#3598](https://github.com/a2ui-project/a2ui/issues/3598)). - `DirectJsonParser.unwrap`, `DirectJsonResponseStream`, and `ExpressParser.unwrap` remove the backticks left at the edges of a block when a model writes the tags as inline code (`` `` ... `` `` or diff --git a/dart/a2ui_agent/lib/src/inference_formats/express/compiler.dart b/dart/a2ui_agent/lib/src/inference_formats/express/compiler.dart index 118d378511..deb927edcf 100644 --- a/dart/a2ui_agent/lib/src/inference_formats/express/compiler.dart +++ b/dart/a2ui_agent/lib/src/inference_formats/express/compiler.dart @@ -12,6 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. +import 'dart:convert'; import 'package:a2ui_core/a2ui_core.dart'; import '../../utils/catalogs.dart'; @@ -30,6 +31,9 @@ String defaultCheckMessage(String check) => /// The helpers that belong to no catalog. const Set _reservedHelpers = {'Event', '_template'}; +/// The keyword arguments the reserved `Event(...)` signature accepts. +const List _eventKeywords = ['userMessage', 'context']; + /// Compiles Express blocks into A2UI messages of the protocol version the /// catalogs target. /// @@ -311,13 +315,37 @@ class _StandaloneCall { final _Surface? surface; } +/// The catalog schema declared at a value's position, with the helper whose +/// catalog declares it. +class _Slot { + const _Slot(this.schema, this.helper); + + final Map schema; + final CatalogSchemaHelper helper; + + /// Whether the slot takes an action. + bool get holdsAction => isAction(schema); + + /// The slot of the items of an array, or null when the schema declares + /// none. + _Slot? get item => _of(helper.resolveItemSchema(schema)); + + /// The slot of the property [name] of an object, whatever the property is + /// named, or null when the schema declares none. + _Slot? property(String name) => + _of(helper.resolvePropertySchema(schema, name)); + + _Slot? _of(Map? found) => + found == null ? null : _Slot(found, helper); +} + /// Where a value is being compiled. class _Context { _Context({ required this.idPrefix, required this.hoisted, - this.isAction = false, this.valueBinding, + this.slot, }); /// The id a component written inline here takes. @@ -326,17 +354,32 @@ class _Context { /// Components written inline, to emit after their parent. final List> hoisted; - /// Whether the value fills an action slot. - final bool isAction; + /// Whether the value fills an action slot, which its schema says. + bool get isAction => slot?.holdsAction ?? false; /// The binding of the enclosing component's `value`, which a check tests. final Map? valueBinding; + /// The schema declared at this position, when known. Array items and map + /// entries resolve their own sub-schema from it, so an action slot nested + /// inside a list item (such as `items: [{label, action}]`) is compiled as + /// an action too. + final _Slot? slot; + _Context child(Object suffix) => _Context( idPrefix: '${idPrefix}_$suffix', hoisted: hoisted, valueBinding: valueBinding, ); + + /// The context of the value at [suffix] inside this one, in [slot]: an + /// array item's or a map entry's. + _Context at(Object suffix, _Slot? slot) => _Context( + idPrefix: '${idPrefix}_$suffix', + hoisted: hoisted, + valueBinding: valueBinding, + slot: slot, + ); } class _SurfaceCompiler { @@ -748,7 +791,7 @@ class _SurfaceCompiler { _Context( idPrefix: '${id}_$property', hoisted: hoisted, - isAction: isAction(schema), + slot: schema == null ? null : _Slot(schema, helper), ), ); if (value is List && expectsOptionObjects(schema)) { @@ -897,13 +940,13 @@ class _SurfaceCompiler { SkippedNode() => throw A2uiValidationError( "'_' can only stand in for an argument.", ), - ArrayNode(:final List items) => [ - for (final (int index, ExpressNode item) in items.indexed) - _value(item, context.child(index)), - ], + ArrayNode(:final List items) => _list(items, context), MapNode(:final Map entries) => { for (final MapEntry entry in entries.entries) - entry.key: _value(entry.value, context.child(entry.key)), + entry.key: _value( + entry.value, + context.at(entry.key, context.slot?.property(entry.key)), + ), }, VariableNode(:final String name) => _variable(name, context), CheckNode() => _check(node, context).$1, @@ -1052,20 +1095,77 @@ class _SurfaceCompiler { }; } - /// `Event(name, context?)`: an action sent to the agent. + /// The values of an array, each in the slot of the array's items, which is + /// resolved once since every item shares it. + List _list(List items, _Context context) { + final _Slot? itemSlot = context.slot?.item; + return [ + for (final (int index, ExpressNode item) in items.indexed) + _value(item, context.at(index, itemSlot)), + ]; + } + + /// `Event(name, context?, userMessage=?, context=?)`: an action sent to + /// the agent. + /// + /// `userMessage` is a v1.0 field of the Action, so before v1.0 it is + /// rejected, as is any keyword the signature does not define, rather than + /// dropped from the compiled action. Map _event(CallNode call, _Context context) { - ExpressNode? argument(int index, String name) => - call.kwargs[name] ?? - (index < call.args.length ? call.args[index] : null); + if (call.args.length > 2) { + throw A2uiValidationError( + 'Event takes at most 2 positional arguments (name, context), but ' + '${call.args.length} are given.', + ); + } + for (final String key in call.kwargs.keys) { + if (!_eventKeywords.contains(key)) { + throw A2uiValidationError( + "Event has no keyword parameter '$key'. Its keyword parameters " + 'are: ${_eventKeywords.join(', ')}.', + ); + } + } + if (call.args.length == 2 && call.kwargs.containsKey('context')) { + throw A2uiValidationError("Parameter 'context' of Event is given twice."); + } - final ExpressNode? nameNode = argument(0, 'name'); + final ExpressNode? nameNode = call.args.firstOrNull; final Object? eventName = nameNode == null ? null : _value(nameNode, context.child('name')); if (eventName is! String) { throw A2uiValidationError('Event needs a name, such as Event("submit").'); } - final ExpressNode? contextNode = argument(1, 'context'); + final event = {'name': eventName}; + + if (call.kwargs['userMessage'] case final ExpressNode userMessageNode) { + if (!_v1) { + throw A2uiValidationError( + 'Event userMessage requires protocol v1.0 or later; the target ' + 'version ${compiler._version.jsonValue} has no userMessage field.', + ); + } + final Object? userMessage = _value( + userMessageNode, + context.child('userMessage'), + ); + // `userMessage` is a DynamicString: a string, a data binding or a + // function call. + if (userMessage is! String && + !_isBinding(userMessage) && + !_isCall(userMessage)) { + throw A2uiValidationError( + 'Event userMessage must be a string, a \$path data binding or a ' + 'function call, got ${_describe(userMessageNode)}.', + ); + } + event['userMessage'] = userMessage; + } + + final ExpressNode? contextNode = call.args.length == 2 + ? call.args[1] + : call.kwargs['context']; final Object? eventContext = contextNode == null ? null : _value(contextNode, context.child('context')); @@ -1075,13 +1175,10 @@ class _SurfaceCompiler { 'Event("submit", {id: \$/item/id}).', ); } - return { - 'event': { - 'name': eventName, - if (eventContext is Map && eventContext.isNotEmpty) - 'context': eventContext, - }, - }; + if (eventContext is Map && eventContext.isNotEmpty) { + event['context'] = eventContext; + } + return {'event': event}; } /// [id], the id an inline component takes from where it is written, such @@ -1102,6 +1199,25 @@ class _SurfaceCompiler { bool _isBinding(Object? value) => value is Map && value.length == 1 && value[_pathKey] is String; + + bool _isCall(Object? value) => value is Map && value[_callKey] is String; + + /// Names a parsed value for an error message in the terms the model wrote + /// it in, rather than as the node's type. + String _describe(ExpressNode node) => switch (node) { + CallNode(:final String name) => 'a call to $name(...)', + CheckNode(:final String name) => 'a check (?$name)', + VariableNode(:final String name) => 'the variable $name', + PathNode(:final String path) => 'the data binding \$$path', + SkippedNode() => "'_'", + MapNode() => 'a map literal', + ArrayNode() => 'a list', + LiteralNode(value: null) => 'null', + LiteralNode(value: final bool value) => 'the boolean $value', + LiteralNode(value: final num value) => 'the number ${jsonEncode(value)}', + LiteralNode(value: final String value) => 'the string ${jsonEncode(value)}', + LiteralNode(:final Object? value) => 'a ${value.runtimeType}', + }; } /// Marks a context in which a component cannot be written. diff --git a/dart/a2ui_agent/lib/src/inference_formats/express/decompiler.dart b/dart/a2ui_agent/lib/src/inference_formats/express/decompiler.dart index 4a48fd8840..1335ebd9a2 100644 --- a/dart/a2ui_agent/lib/src/inference_formats/express/decompiler.dart +++ b/dart/a2ui_agent/lib/src/inference_formats/express/decompiler.dart @@ -472,6 +472,10 @@ class _Component { String get _id => '${json['id']}'; + /// The helper of the component's catalog, set once [write] resolves it, + /// through which nested schemas are reached. + CatalogSchemaHelper? _helper; + String write() { final id = json['id']! as String; final String varName = variables[id]!; @@ -486,6 +490,7 @@ class _Component { component: true, subject: "Component '$id'", ); + _helper = helper; final List properties = helper.properties(type); for (final String key in json.keys) { if (key == 'id' || key == 'component' || key == 'checks') continue; @@ -515,7 +520,7 @@ class _Component { } arguments ..addAll(List.filled(skipped, '_')) - ..add(_property(value, helper.propertySchema(type, property))); + ..add(_value(value, helper.propertySchema(type, property))); skipped = 0; } if (json['checks'] case final Object checks) arguments.add(_checks(checks)); @@ -524,12 +529,8 @@ class _Component { return '$varName = $type(${arguments.join(', ')})'; } - String _property(Object value, Map? schema) { - if (isAction(schema)) return _action(value); - return _value(value, schema); - } - String _value(Object? value, Map? schema) { + if (isAction(schema) && value != null) return _action(value); if (isComponentId(schema) && value is String) return _reference(value); if (isChildList(schema)) { if (value is List && value.every((Object? v) => v is String)) { @@ -549,8 +550,11 @@ class _Component { Map() => _binding(value) ?? _call(value) ?? - _mapLiteral(value, (key) => _propertySchema(schema, key)), - List() => _list(value, _itemSchema(schema)), + _mapLiteral( + value, + (key) => _helper?.resolvePropertySchema(schema, key), + ), + List() => _list(value, _helper?.resolveItemSchema(schema)), _ => _literal(value), }; } @@ -560,21 +564,33 @@ class _Component { /// as written. String _reference(String id) => variables[id] ?? _string(id); - /// `Event(name, context)` for an event, the call itself for a local - /// function, and the map literal otherwise. + /// `Event(name, context?, userMessage=?)` for an event, the call itself + /// for a local function, and the map literal otherwise. String _action(Object value) { switch (value) { case {'event': final Map event} when value.length == 1: final Object? name = event['name']; final Object? context = event['context']; + final Object? userMessage = event['userMessage']; + const keys = {'name', 'context', 'userMessage'}; final bool isEvent = name is String && - event.keys.every((k) => k == 'name' || k == 'context') && - (context == null || (context is Map && context.isNotEmpty)); + event.keys.every(keys.contains) && + (context == null || (context is Map && context.isNotEmpty)) && + // Before v1.0 an event has no userMessage, and the compiler + // rejects `userMessage=`, so the object stays a map literal. + (userMessage == null || + (_v1 && + (userMessage is String || + _binding(_asMap(userMessage)) != null || + _call(_asMap(userMessage)) != null))); if (isEvent) { - return context == null - ? 'Event(${_string(name)})' - : 'Event(${_string(name)}, ${_value(context, null)})'; + final arguments = [ + _string(name), + if (context != null) _value(context, null), + if (userMessage != null) 'userMessage=${_value(userMessage, null)}', + ]; + return 'Event(${arguments.join(', ')})'; } case {'functionCall': final Map call} when value.length == 1: @@ -856,24 +872,5 @@ bool _equal(Object? a, Object? b) => switch ((a, b)) { _ => a == b, }; -Map? _propertySchema( - Map? schema, - String key, -) { - if (schema == null) return null; - if (schema['properties'] case final Map properties) { - if (properties[key] case final Map property) { - return property.cast(); - } - } - return null; -} - -Map? _itemSchema(Map? schema) => - switch (schema?['items']) { - final Map items => items.cast(), - _ => null, - }; - A2uiValidationError _noNotation(String what) => A2uiValidationError('$what has no Express notation.'); diff --git a/dart/a2ui_agent/lib/src/inference_formats/express/schema_helper.dart b/dart/a2ui_agent/lib/src/inference_formats/express/schema_helper.dart index 28dc192445..77cdb37ea1 100644 --- a/dart/a2ui_agent/lib/src/inference_formats/express/schema_helper.dart +++ b/dart/a2ui_agent/lib/src/inference_formats/express/schema_helper.dart @@ -116,6 +116,68 @@ class CatalogSchemaHelper { return check(schema); } + /// The schema the catalog declares for the items of an array, or null if + /// it declares none. + /// + /// Follows `$ref`s into the definitions of [catalog]'s validation schema, + /// which bundles the common types, and the members of `allOf`, `oneOf` and + /// `anyOf`, so that a nested slot such as `items: [{label, action}]` is + /// found whichever way the catalog writes it. + Map? resolveItemSchema(Map? schema) => + _resolveAt(schema, null); + + /// The schema the catalog declares for the property [name] of an object, + /// or `additionalProperties` when it declares no such property, or null. + /// + /// Follows `$ref`s and the members of `allOf`, `oneOf` and `anyOf` as + /// [resolveItemSchema] does. A property named `items` is looked up as a + /// property, not as the array items. + Map? resolvePropertySchema( + Map? schema, + String name, + ) => _resolveAt(schema, name); + + /// The schema at the property [property], or at the array items when + /// [property] is null. + Map? _resolveAt( + Map? schema, + String? property, + ) { + final Object? defsObj = catalog.validationSchema[r'$defs']; + final Map? defs = defsObj is Map ? defsObj : null; + // A definition may refer to itself, so each is followed once. + final visitedRefs = {}; + + Map? resolve(Map? s) { + if (s == null) return null; + if (property == null) { + if (s['items'] case final Map items) { + return items.cast(); + } + } else { + if (_properties(s)[property] case final Map found) { + return found.cast(); + } + if (s['additionalProperties'] case final Map more) { + return more.cast(); + } + } + if (_refName(s) case final String ref when visitedRefs.add(ref)) { + if (defs?[ref] case final Map target) { + if (resolve(target.cast()) case final found?) { + return found; + } + } + } + for (final Map alt in _alternatives(s)) { + if (resolve(alt) case final found?) return found; + } + return null; + } + + return resolve(schema); + } + /// The component's own description, if its schema has one. String? componentDescription(String component) { final Map schema = catalog diff --git a/dart/a2ui_agent/test/conformance/express_conformance_test.dart b/dart/a2ui_agent/test/conformance/express_conformance_test.dart index 68fbc31d54..ad50ce7102 100644 --- a/dart/a2ui_agent/test/conformance/express_conformance_test.dart +++ b/dart/a2ui_agent/test/conformance/express_conformance_test.dart @@ -62,10 +62,21 @@ const String _reservedPlainKeys = 'function calls, so a map literal that uses them runs as written in v1.0 ' 'only.'; +const String _noUserMessage = + 'The v0.9 Action has no userMessage field, so an event carrying one runs ' + 'as written in v1.0 only.'; + /// Cases that state what v0.9 cannot, or that are already written against a /// v0.9 catalog, so they run in [SuiteVersion.v1_0] (as written) only, with /// the reason for each. const Map _v1OnlyCases = { + 'test_compile_express_event_user_message': _noUserMessage, + 'test_compile_express_event_user_message_binding_with_context': + _noUserMessage, + 'test_decompile_express_event_action_with_user_message': _noUserMessage, + 'test_compile_express_event_user_message_function_call': _noUserMessage, + 'test_decompile_express_event_action_with_function_call_user_message': + _noUserMessage, 'test_compile_express_surface_targeting_names_a_catalog': _oneCatalogPerSurface, 'test_decompile_express_two_surfaces_in_two_catalogs': _oneCatalogPerSurface, diff --git a/dart/a2ui_agent/test/express_decompiler_test.dart b/dart/a2ui_agent/test/express_decompiler_test.dart index 8b203982c0..1b65761615 100644 --- a/dart/a2ui_agent/test/express_decompiler_test.dart +++ b/dart/a2ui_agent/test/express_decompiler_test.dart @@ -537,6 +537,307 @@ void main() { }); }); + group('writes v1.0 actions', () { + final CatalogApi links = Catalog.fromJson({ + 'catalogId': 'https://example.com/links.json', + 'protocolVersion': '1.0', + 'components': { + 'Text': { + 'type': 'object', + 'properties': { + 'component': {'const': 'Text'}, + 'text': {r'$ref': r'common_types.json#/$defs/DynamicString'}, + }, + 'required': ['component', 'text'], + }, + 'Button': { + 'type': 'object', + 'properties': { + 'component': {'const': 'Button'}, + 'child': {r'$ref': r'common_types.json#/$defs/Child'}, + 'action': {r'$ref': r'common_types.json#/$defs/Action'}, + }, + 'required': ['component', 'child', 'action'], + }, + 'Links': { + 'type': 'object', + 'properties': { + 'component': {'const': 'Links'}, + 'items': { + 'type': 'array', + 'items': { + 'type': 'object', + 'properties': { + 'label': {'type': 'string'}, + 'action': {r'$ref': r'common_types.json#/$defs/Action'}, + }, + 'required': ['label', 'action'], + }, + }, + }, + 'required': ['component', 'items'], + }, + }, + 'functions': { + 'openUrl': { + 'type': 'object', + 'returnType': 'void', + 'properties': { + '@call': {'const': 'openUrl'}, + 'args': { + 'type': 'object', + 'properties': { + 'url': {'type': 'string'}, + }, + 'required': ['url'], + }, + }, + 'required': ['@call', 'args'], + }, + }, + }); + final Parser v1Parser = const ExpressFormatFactory().createFormat([ + links, + ]).createParser(); + + /// Writes a v1.0 surface of [components] in Express, checks that + /// compiling the result returns it, and returns what was written. + String roundTripV1(List> components) { + final List original = + AgentToRendererMessage.parseAll([ + { + 'version': 'v1.0', + 'createSurface': { + 'surfaceId': 's', + 'catalogId': links.id, + 'components': components, + }, + }, + ], protocolVersion: A2uiProtocolVersion.v1_0).messages; + final String written = v1Parser.decompile(original); + expect( + v1Parser.compile(written).map((m) => m.toJson()), + original.map((m) => m.toJson()), + reason: 'Express written:\n$written', + ); + return written; + } + + test('an event with userMessage=, after its context', () { + expect( + roundTripV1([ + { + 'id': 'root', + 'component': 'Button', + 'child': 'label', + 'action': { + 'event': {'name': 'reply', 'userMessage': 'Replying now'}, + }, + }, + {'id': 'label', 'component': 'Text', 'text': 'Reply'}, + ]), + contains('Event("reply", userMessage="Replying now")'), + ); + expect( + roundTripV1([ + { + 'id': 'root', + 'component': 'Button', + 'child': 'label', + 'action': { + 'event': { + 'name': 'reply', + 'userMessage': {'@path': '/draft'}, + 'context': {'id': 1}, + }, + }, + }, + {'id': 'label', 'component': 'Text', 'text': 'Reply'}, + ]), + contains(r'Event("reply", {id: 1}, userMessage=$/draft)'), + ); + }); + + test('an event with a function call userMessage', () { + expect( + roundTripV1([ + { + 'id': 'root', + 'component': 'Links', + 'items': [ + { + 'label': 'Reply', + 'action': { + 'event': { + 'name': 'reply', + 'userMessage': { + '@call': 'openUrl', + 'args': {'url': 'https://a2ui.org'}, + }, + }, + }, + }, + ], + }, + ]), + contains('Event("reply", userMessage=openUrl("https://a2ui.org"))'), + ); + }); + + test('a functionCall in an item slot reached through a \$ref', () { + final CatalogApi menu = Catalog.fromJson({ + 'catalogId': 'https://example.com/menu.json', + 'protocolVersion': '1.0', + 'components': { + 'Menu': { + 'type': 'object', + 'properties': { + 'component': {'const': 'Menu'}, + 'items': { + 'type': 'array', + 'items': {r'$ref': r'#/$defs/MenuItem'}, + }, + }, + 'required': ['component', 'items'], + }, + }, + 'functions': { + 'openUrl': { + 'type': 'object', + 'returnType': 'void', + 'properties': { + '@call': {'const': 'openUrl'}, + 'args': { + 'type': 'object', + 'properties': { + 'url': {'type': 'string'}, + }, + 'required': ['url'], + }, + }, + 'required': ['@call', 'args'], + }, + }, + r'$defs': { + 'MenuItem': { + 'type': 'object', + 'properties': { + 'label': {'type': 'string'}, + 'action': {r'$ref': r'common_types.json#/$defs/Action'}, + }, + 'required': ['label', 'action'], + }, + }, + }); + final String written = ExpressDecompiler([menu]).decompile([ + AgentToRendererMessage.fromJson({ + 'version': 'v1.0', + 'createSurface': { + 'surfaceId': 's1', + 'catalogId': menu.id, + 'components': [ + { + 'id': 'root', + 'component': 'Menu', + 'items': [ + { + 'label': 'Docs', + 'action': { + 'functionCall': { + '@call': 'openUrl', + 'args': {'url': 'https://a2ui.org'}, + }, + }, + }, + ], + }, + ], + }, + }), + ]); + expect( + written, + contains( + 'root = Menu([{label: "Docs", action: openUrl("https://a2ui.org")}])', + ), + ); + }); + + test('an event with a userMessage as a map literal before v1.0', () { + // v0.9 has no userMessage and its compiler rejects `userMessage=`, so + // the object stays data that compiles back as written. + final CatalogApi v09 = Catalog.fromJson({ + 'catalogId': 'https://example.com/v09.json', + 'protocolVersion': '0.9', + 'components': { + 'Card': { + 'type': 'object', + 'properties': { + 'component': {'const': 'Card'}, + 'child': {'type': 'object'}, + }, + 'required': ['component', 'child'], + }, + }, + }); + final String written = ExpressDecompiler([v09]).decompile([ + AgentToRendererMessage.fromJson({ + 'version': 'v0.9', + 'createSurface': {'surfaceId': 's1', 'catalogId': v09.id}, + }), + AgentToRendererMessage.fromJson({ + 'version': 'v0.9', + 'updateComponents': { + 'surfaceId': 's1', + 'components': [ + { + 'id': 'root', + 'component': 'Card', + 'child': { + 'event': {'name': 'go', 'userMessage': 'hi'}, + }, + }, + ], + }, + }), + ]); + expect(written, isNot(contains('Event('))); + expect(written, contains('{event: {name:')); + }); + + test('a functionCall in a list item action slot as a plain call', () { + expect( + roundTripV1([ + { + 'id': 'root', + 'component': 'Links', + 'items': [ + { + 'label': 'Docs', + 'action': { + 'functionCall': { + '@call': 'openUrl', + 'args': {'url': 'https://a2ui.org'}, + }, + }, + }, + { + 'label': 'Go', + 'action': { + 'event': {'name': 'go'}, + }, + }, + ], + }, + ]), + contains( + 'root = Links([{label: "Docs", action: openUrl("https://a2ui.org")}, ' + '{label: "Go", action: Event("go")}])', + ), + ); + }); + }); + group('examples', () { final List greeting = messages([ create('greeting'), diff --git a/dart/a2ui_agent/test/express_test.dart b/dart/a2ui_agent/test/express_test.dart index 2fe54e2eff..536be2701b 100644 --- a/dart/a2ui_agent/test/express_test.dart +++ b/dart/a2ui_agent/test/express_test.dart @@ -831,4 +831,480 @@ surface("s1") ); }); }); + + group('Event arguments', () { + final CatalogApi v1 = Catalog.fromJson({ + 'catalogId': 'https://example.com/events.json', + 'protocolVersion': '1.0', + 'components': { + 'Text': { + 'type': 'object', + 'properties': { + 'component': {'const': 'Text'}, + 'text': {r'$ref': r'common_types.json#/$defs/DynamicString'}, + }, + 'required': ['component', 'text'], + }, + 'Button': { + 'type': 'object', + 'properties': { + 'component': {'const': 'Button'}, + 'child': {r'$ref': r'common_types.json#/$defs/Child'}, + 'action': {r'$ref': r'common_types.json#/$defs/Action'}, + }, + 'required': ['component', 'child', 'action'], + }, + }, + 'functions': { + 'openUrl': { + 'type': 'object', + 'returnType': 'void', + 'properties': { + '@call': {'const': 'openUrl'}, + 'args': { + 'type': 'object', + 'properties': { + 'url': {'type': 'string'}, + }, + 'required': ['url'], + }, + }, + 'required': ['@call', 'args'], + }, + }, + }); + + /// The action of the `root` Button [block] compiles to. + Object? action(String block) { + final List messages = ExpressCompiler([ + v1, + ]).compile('$block\nlabel = Text("Go")'); + final components = + (messages.single.toJson()['createSurface']! as Map)['components']! + as List; + return (components.first as Map)['action']; + } + + test('compiles userMessage to the v1.0 event field', () { + expect( + action('root = Button(label, Event("reply", userMessage="Replying"))'), + { + 'event': {'name': 'reply', 'userMessage': 'Replying'}, + }, + ); + }); + + test('compiles a \$path userMessage beside a positional context', () { + expect( + action( + r'root = Button(label, Event("go", {id: 1}, userMessage=$/draft))', + ), + { + 'event': { + 'name': 'go', + 'userMessage': {'@path': '/draft'}, + 'context': {'id': 1}, + }, + }, + ); + }); + + test('accepts context= as a keyword argument', () { + expect(action('root = Button(label, Event("go", context={id: 1}))'), { + 'event': { + 'name': 'go', + 'context': {'id': 1}, + }, + }); + }); + + test('rejects a context given both positionally and by keyword', () { + expect( + () => + action('root = Button(label, Event("go", {a: 1}, context={b: 2}))'), + throwsError(), + ); + }); + + test('rejects an unknown keyword instead of dropping it', () { + expect( + () => action('root = Button(label, Event("go", message="hi"))'), + throwsA( + isA().having( + (e) => e.message, + 'message', + contains("Event has no keyword parameter 'message'"), + ), + ), + ); + }); + + test('compiles a function call userMessage, as DynamicString allows', () { + expect( + action( + 'root = Button(label, Event("go", ' + 'userMessage=openUrl("https://a2ui.org")))', + ), + { + 'event': { + 'name': 'go', + 'userMessage': { + '@call': 'openUrl', + 'args': {'url': 'https://a2ui.org'}, + }, + }, + }, + ); + }); + + test('rejects a userMessage that is not a DynamicString, naming it', () { + expect( + () => action('root = Button(label, Event("go", userMessage=42))'), + throwsA( + isA().having( + (e) => e.message, + 'message', + 'Event userMessage must be a string, a \$path data binding or a ' + 'function call, got the number 42.', + ), + ), + ); + expect( + () => action('root = Button(label, Event("go", userMessage={a: 1}))'), + throwsA( + isA().having( + (e) => e.message, + 'message', + contains('got a map literal.'), + ), + ), + ); + }); + + test('rejects userMessage before v1.0, whose Action has no such field', () { + expect( + () => compile( + 'root = Button(label, Event("go", userMessage="hi"))\n' + 'label = Text("Go")', + ), + throwsA( + isA().having( + (e) => e.message, + 'message', + contains('userMessage requires protocol v1.0'), + ), + ), + ); + }); + }); + + group('action slots nested in list items', () { + final CatalogApi links = Catalog.fromJson({ + 'catalogId': 'https://example.com/links.json', + 'protocolVersion': '1.0', + 'components': { + 'Links': { + 'type': 'object', + 'properties': { + 'component': {'const': 'Links'}, + 'items': { + 'type': 'array', + 'items': { + 'type': 'object', + 'properties': { + 'label': {r'$ref': r'common_types.json#/$defs/DynamicString'}, + 'action': {r'$ref': r'common_types.json#/$defs/Action'}, + }, + 'required': ['label', 'action'], + }, + }, + }, + 'required': ['component', 'items'], + }, + }, + 'functions': { + 'openUrl': { + 'type': 'object', + 'returnType': 'void', + 'properties': { + '@call': {'const': 'openUrl'}, + 'args': { + 'type': 'object', + 'properties': { + 'url': {'type': 'string'}, + }, + 'required': ['url'], + }, + }, + 'required': ['@call', 'args'], + }, + }, + }); + + /// The items of the `root` Links [block] compiles to. + Object? items(String block) { + final List messages = ExpressCompiler([ + links, + ]).compile(block); + final components = + (messages.single.toJson()['createSurface']! as Map)['components']! + as List; + return (components.single as Map)['items']; + } + + const Map docs = { + 'label': 'Docs', + 'action': { + 'functionCall': { + '@call': 'openUrl', + 'args': {'url': 'https://a2ui.org'}, + }, + }, + }; + + test('wraps a function call in functionCall inside a list item', () { + expect( + items( + 'root = Links([{label: "Docs", action: openUrl("https://a2ui.org")}, ' + '{label: "Go", action: Event("go")}])', + ), + [ + docs, + { + 'label': 'Go', + 'action': { + 'event': {'name': 'go'}, + }, + }, + ], + ); + }); + + test('wraps a function call reached through a variable', () { + expect( + items( + 'docs = {label: "Docs", action: openUrl("https://a2ui.org")}\n' + 'root = Links([docs])', + ), + [docs], + ); + }); + + /// The `root` component [block] compiles to, against [catalog]. + Map rootOf(CatalogApi catalog, String block) { + final List messages = ExpressCompiler([ + catalog, + ]).compile(block); + final components = + (messages.single.toJson()['createSurface']! as Map)['components']! + as List; + return (components.single as Map).cast(); + } + + const Map openUrlFunction = { + 'type': 'object', + 'returnType': 'void', + 'properties': { + '@call': {'const': 'openUrl'}, + 'args': { + 'type': 'object', + 'properties': { + 'url': {'type': 'string'}, + }, + 'required': ['url'], + }, + }, + 'required': ['@call', 'args'], + }; + const Map wrappedOpenUrl = { + 'functionCall': { + '@call': 'openUrl', + 'args': {'url': 'https://a2ui.org'}, + }, + }; + + test( + 'resolves a property named items as a property, not the array items', + () { + // The item schema is reached through a local $ref too. + final CatalogApi menu = Catalog.fromJson({ + 'catalogId': 'https://example.com/menu.json', + 'protocolVersion': '1.0', + 'components': { + 'Menu': { + 'type': 'object', + 'properties': { + 'component': {'const': 'Menu'}, + 'menu': { + 'type': 'object', + 'properties': { + 'items': { + 'type': 'array', + 'items': {r'$ref': r'#/$defs/MenuItem'}, + }, + }, + }, + }, + 'required': ['component', 'menu'], + }, + }, + 'functions': {'openUrl': openUrlFunction}, + r'$defs': { + 'MenuItem': { + 'type': 'object', + 'properties': { + 'label': {'type': 'string'}, + 'action': {r'$ref': r'common_types.json#/$defs/Action'}, + }, + 'required': ['label', 'action'], + }, + }, + }); + expect( + rootOf( + menu, + 'root = Menu({items: [{label: "Docs", ' + 'action: openUrl("https://a2ui.org")}]})', + )['menu'], + { + 'items': [ + {'label': 'Docs', 'action': wrappedOpenUrl}, + ], + }, + ); + }, + ); + + test('finds action slots through allOf, anyOf, additionalProperties', () { + final CatalogApi mixins = Catalog.fromJson({ + 'catalogId': 'https://example.com/mixins.json', + 'protocolVersion': '1.0', + 'components': { + 'Board': { + 'type': 'object', + 'properties': { + 'component': {'const': 'Board'}, + // The item schema is an allOf of two mixins, one of which holds + // the action under anyOf. + 'cards': { + 'type': 'array', + 'items': { + 'allOf': [ + {r'$ref': r'#/$defs/Labelled'}, + { + 'anyOf': [ + { + 'type': 'object', + 'properties': { + 'action': { + r'$ref': r'common_types.json#/$defs/Action', + }, + }, + }, + ], + }, + ], + }, + }, + // Every entry of the map is an action. + 'shortcuts': { + 'type': 'object', + 'additionalProperties': { + r'$ref': r'common_types.json#/$defs/Action', + }, + }, + }, + 'required': ['component', 'cards'], + }, + }, + 'functions': {'openUrl': openUrlFunction}, + r'$defs': { + 'Labelled': { + 'type': 'object', + 'properties': { + 'label': {'type': 'string'}, + }, + }, + }, + }); + final Map root = rootOf( + mixins, + 'root = Board([{label: "Docs", action: openUrl("https://a2ui.org")}], ' + 'shortcuts={help: openUrl("https://a2ui.org")})', + ); + expect(root['cards'], [ + {'label': 'Docs', 'action': wrappedOpenUrl}, + ]); + expect(root['shortcuts'], {'help': wrappedOpenUrl}); + }); + + test('follows a definition that refers to itself without looping', () { + final CatalogApi tree = Catalog.fromJson({ + 'catalogId': 'https://example.com/tree.json', + 'protocolVersion': '1.0', + 'components': { + 'Tree': { + 'type': 'object', + 'properties': { + 'component': {'const': 'Tree'}, + 'nodes': { + 'type': 'array', + 'items': {r'$ref': r'#/$defs/Node'}, + }, + }, + 'required': ['component', 'nodes'], + }, + }, + 'functions': {'openUrl': openUrlFunction}, + r'$defs': { + 'Node': { + 'type': 'object', + 'properties': { + 'label': {'type': 'string'}, + 'action': {r'$ref': r'common_types.json#/$defs/Action'}, + 'children': { + 'type': 'array', + 'items': {r'$ref': r'#/$defs/Node'}, + }, + }, + }, + }, + }); + expect( + rootOf( + tree, + 'root = Tree([{label: "a", children: [{label: "b", ' + 'action: openUrl("https://a2ui.org")}]}])', + )['nodes'], + [ + { + 'label': 'a', + 'children': [ + {'label': 'b', 'action': wrappedOpenUrl}, + ], + }, + ], + ); + }); + + test('keeps a function call outside an action slot as a plain call', () { + expect( + items( + 'root = Links([{label: openUrl("https://a2ui.org"), ' + 'action: Event("go")}])', + ), + [ + { + 'label': { + '@call': 'openUrl', + 'args': {'url': 'https://a2ui.org'}, + }, + 'action': { + 'event': {'name': 'go'}, + }, + }, + ], + ); + }); + }); }