From f7624ca47e5ab5ea989f23f59b2e948e363bd469 Mon Sep 17 00:00:00 2001 From: David Iglesias Teixeira Date: Mon, 26 Oct 2026 10:47:09 -0700 Subject: [PATCH 1/5] fix(dart-agent): compile Event userMessage and nested action slots in Express `Event("reply", userMessage="...")` silently dropped `userMessage`, and a function call used as the action of a list item (for example `Links([{label: "Docs", action: openUrl("...")}])`) compiled to a bare `{"@call": ...}` that the processor rejects. - `Event` now accepts `userMessage=` and `context=` keyword arguments. `userMessage` compiles to the v1.0 `event.userMessage` field and throws `A2uiValidationError` before v1.0; any other keyword on `Event` is rejected rather than dropped. - `_Context` carries the catalog schema at the current position, so an `Action`-typed sub-schema inside array items or object properties wraps function calls in `functionCall` like a top-level action slot does. `CatalogSchemaHelper.resolveSubschema` descends into `items` and properties through `$ref`s and `allOf`/`oneOf`/`anyOf`. - `ExpressDecompiler` writes `userMessage=` back so events round-trip, and writes a `functionCall` nested in a list item as a plain call. - The Express conformance harness runs the cases that carry `userMessage` as written in v1.0 only, since the v0.9 Action has no such field. Fixes points 1 and 2 of #3598 for the Dart SDK. --- dart/a2ui_agent/CHANGELOG.md | 9 + .../inference_formats/express/compiler.dart | 113 +++++++-- .../inference_formats/express/decompiler.dart | 30 ++- .../express/schema_helper.dart | 47 ++++ .../conformance/express_conformance_test.dart | 8 + .../test/express_decompiler_test.dart | 154 ++++++++++++ dart/a2ui_agent/test/express_test.dart | 227 ++++++++++++++++++ 7 files changed, 560 insertions(+), 28 deletions(-) diff --git a/dart/a2ui_agent/CHANGELOG.md b/dart/a2ui_agent/CHANGELOG.md index b95a7ba338..9b0929d1c8 100644 --- a/dart/a2ui_agent/CHANGELOG.md +++ b/dart/a2ui_agent/CHANGELOG.md @@ -59,6 +59,15 @@ 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. +- `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), + [#3636](https://github.com/a2ui-project/a2ui/pull/3636)). - `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..efb7d40558 100644 --- a/dart/a2ui_agent/lib/src/inference_formats/express/compiler.dart +++ b/dart/a2ui_agent/lib/src/inference_formats/express/compiler.dart @@ -30,6 +30,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,6 +314,25 @@ 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 an array item, when [key] is `items`, or of the entry [key] + /// of a map, or null when the schema declares none at that position. + _Slot? sub(String key) => switch (helper.resolveSubschema(schema, key)) { + final Map found => _Slot(found, helper), + null => null, + }; +} + /// Where a value is being compiled. class _Context { _Context({ @@ -318,6 +340,7 @@ class _Context { required this.hoisted, this.isAction = false, this.valueBinding, + this.slot, }); /// The id a component written inline here takes. @@ -332,11 +355,30 @@ class _Context { /// 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 an array item, when [key] is `items`, or of the map + /// entry [key], at the sub-schema of [slot] at that position. + _Context sub(Object suffix, String key) { + final _Slot? sub = slot?.sub(key); + return _Context( + idPrefix: '${idPrefix}_$suffix', + hoisted: hoisted, + isAction: sub?.holdsAction ?? false, + valueBinding: valueBinding, + slot: sub, + ); + } } class _SurfaceCompiler { @@ -749,6 +791,7 @@ class _SurfaceCompiler { idPrefix: '${id}_$property', hoisted: hoisted, isAction: isAction(schema), + slot: schema == null ? null : _Slot(schema, helper), ), ); if (value is List && expectsOptionObjects(schema)) { @@ -899,11 +942,11 @@ class _SurfaceCompiler { ), ArrayNode(:final List items) => [ for (final (int index, ExpressNode item) in items.indexed) - _value(item, context.child(index)), + _value(item, context.sub(index, 'items')), ], 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.sub(entry.key, entry.key)), }, VariableNode(:final String name) => _variable(name, context), CheckNode() => _check(node, context).$1, @@ -1052,20 +1095,63 @@ class _SurfaceCompiler { }; } - /// `Event(name, context?)`: an action sent to the agent. + /// `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 parameter '$key'. Its parameters are: name, " + '${_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'), + ); + if (userMessage is! String && !_isBinding(userMessage)) { + throw A2uiValidationError( + 'The userMessage of Event must be a string or a data path, such as ' + 'Event("reply", userMessage="Replying now").', + ); + } + 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 +1161,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 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..fa0acd2c82 100644 --- a/dart/a2ui_agent/lib/src/inference_formats/express/decompiler.dart +++ b/dart/a2ui_agent/lib/src/inference_formats/express/decompiler.dart @@ -515,7 +515,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 +524,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)) { @@ -560,21 +556,29 @@ 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)) && + (userMessage == null || + userMessage is String || + _binding(_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: 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..502493f062 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,53 @@ class CatalogSchemaHelper { return check(schema); } + /// The schema [schema] declares for an array item, when [key] is `items`, + /// or for the property named [key] of an object, 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? resolveSubschema( + Map? schema, + String key, + ) { + 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 (key == 'items') { + if (s['items'] case final Map items) { + return items.cast(); + } + } else { + if (_properties(s)[key] case final Map property) { + return property.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..6589f8b5b3 100644 --- a/dart/a2ui_agent/test/conformance/express_conformance_test.dart +++ b/dart/a2ui_agent/test/conformance/express_conformance_test.dart @@ -65,7 +65,15 @@ const String _reservedPlainKeys = /// 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 String _noUserMessage = + 'The v0.9 Action has no userMessage field, so an event carrying one runs ' + 'as written in v1.0 only.'; + 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_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..e917fcf2ba 100644 --- a/dart/a2ui_agent/test/express_decompiler_test.dart +++ b/dart/a2ui_agent/test/express_decompiler_test.dart @@ -537,6 +537,160 @@ 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('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..2fb3db51aa 100644 --- a/dart/a2ui_agent/test/express_test.dart +++ b/dart/a2ui_agent/test/express_test.dart @@ -831,4 +831,231 @@ 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'], + }, + }, + }); + + /// 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 parameter 'message'"), + ), + ), + ); + }); + + test('rejects a userMessage that is neither a string nor a path', () { + expect( + () => action('root = Button(label, Event("go", userMessage=42))'), + throwsError(), + ); + }); + + 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], + ); + }); + + 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'}, + }, + }, + ], + ); + }); + }); } From 8267af7f32fcdcb91b1eabe6a995f60169149a40 Mon Sep 17 00:00:00 2001 From: David Iglesias Teixeira Date: Mon, 26 Oct 2026 13:16:26 -0700 Subject: [PATCH 2/5] fix(dart-agent): name only the keyword parameters in the Event error The name is positional, so listing it among the parameters an unknown keyword could have been contradicted the error itself. --- .../lib/src/inference_formats/express/compiler.dart | 4 ++-- dart/a2ui_agent/test/express_test.dart | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) 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 efb7d40558..6b1edf5754 100644 --- a/dart/a2ui_agent/lib/src/inference_formats/express/compiler.dart +++ b/dart/a2ui_agent/lib/src/inference_formats/express/compiler.dart @@ -1111,8 +1111,8 @@ class _SurfaceCompiler { for (final String key in call.kwargs.keys) { if (!_eventKeywords.contains(key)) { throw A2uiValidationError( - "Event has no parameter '$key'. Its parameters are: name, " - '${_eventKeywords.join(', ')}.', + "Event has no keyword parameter '$key'. Its keyword parameters " + 'are: ${_eventKeywords.join(', ')}.', ); } } diff --git a/dart/a2ui_agent/test/express_test.dart b/dart/a2ui_agent/test/express_test.dart index 2fb3db51aa..9bf759fc00 100644 --- a/dart/a2ui_agent/test/express_test.dart +++ b/dart/a2ui_agent/test/express_test.dart @@ -916,7 +916,7 @@ surface("s1") isA().having( (e) => e.message, 'message', - contains("Event has no parameter 'message'"), + contains("Event has no keyword parameter 'message'"), ), ), ); From e01f694d33995efd7d7614cbd92ad9a0f1e390fb Mon Sep 17 00:00:00 2001 From: David Iglesias Teixeira Date: Mon, 26 Oct 2026 16:36:18 -0700 Subject: [PATCH 3/5] fix(dart-agent): accept a function call as an Event userMessage Action.event.userMessage is a DynamicString: a string, a data binding or a function call. The compiler accepted only the first two, and the decompiler wrote such an event as a map literal where TypeScript and Python write Event(..., userMessage=fn(...)). Both now take a call. The error for any other value names what the model wrote. Record the removal of Event(name=...) as a breaking change. --- dart/a2ui_agent/CHANGELOG.md | 7 ++- .../inference_formats/express/compiler.dart | 30 +++++++++- .../inference_formats/express/decompiler.dart | 3 +- .../test/express_decompiler_test.dart | 26 +++++++++ dart/a2ui_agent/test/express_test.dart | 56 ++++++++++++++++++- 5 files changed, 114 insertions(+), 8 deletions(-) diff --git a/dart/a2ui_agent/CHANGELOG.md b/dart/a2ui_agent/CHANGELOG.md index 9b0929d1c8..48013a373a 100644 --- a/dart/a2ui_agent/CHANGELOG.md +++ b/dart/a2ui_agent/CHANGELOG.md @@ -59,6 +59,10 @@ 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 @@ -66,8 +70,7 @@ 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), - [#3636](https://github.com/a2ui-project/a2ui/pull/3636)). + ([#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 6b1edf5754..69373e05c6 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'; @@ -1140,10 +1141,14 @@ class _SurfaceCompiler { userMessageNode, context.child('userMessage'), ); - if (userMessage is! String && !_isBinding(userMessage)) { + // `userMessage` is a DynamicString: a string, a data binding or a + // function call. + if (userMessage is! String && + !_isBinding(userMessage) && + !_isCall(userMessage)) { throw A2uiValidationError( - 'The userMessage of Event must be a string or a data path, such as ' - 'Event("reply", userMessage="Replying now").', + 'Event userMessage must be a string, a \$path data binding or a ' + 'function call, got ${_describe(userMessageNode)}.', ); } event['userMessage'] = userMessage; @@ -1185,6 +1190,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 fa0acd2c82..7405fd03f9 100644 --- a/dart/a2ui_agent/lib/src/inference_formats/express/decompiler.dart +++ b/dart/a2ui_agent/lib/src/inference_formats/express/decompiler.dart @@ -571,7 +571,8 @@ class _Component { (context == null || (context is Map && context.isNotEmpty)) && (userMessage == null || userMessage is String || - _binding(_asMap(userMessage)) != null); + _binding(_asMap(userMessage)) != null || + _call(_asMap(userMessage)) != null); if (isEvent) { final arguments = [ _string(name), diff --git a/dart/a2ui_agent/test/express_decompiler_test.dart b/dart/a2ui_agent/test/express_decompiler_test.dart index e917fcf2ba..a5f41d64f4 100644 --- a/dart/a2ui_agent/test/express_decompiler_test.dart +++ b/dart/a2ui_agent/test/express_decompiler_test.dart @@ -658,6 +658,32 @@ void main() { ); }); + 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 a list item action slot as a plain call', () { expect( roundTripV1([ diff --git a/dart/a2ui_agent/test/express_test.dart b/dart/a2ui_agent/test/express_test.dart index 9bf759fc00..d765e785cf 100644 --- a/dart/a2ui_agent/test/express_test.dart +++ b/dart/a2ui_agent/test/express_test.dart @@ -855,6 +855,23 @@ surface("s1") '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. @@ -922,10 +939,45 @@ surface("s1") ); }); - test('rejects a userMessage that is neither a string nor a path', () { + 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))'), - throwsError(), + 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.'), + ), + ), ); }); From a74e9d8ea3130f79ce986dd89218b57168fed582 Mon Sep 17 00:00:00 2001 From: David Iglesias Teixeira Date: Mon, 26 Oct 2026 16:37:20 -0700 Subject: [PATCH 4/5] test(dart-agent): run the function-call userMessage cases as written in v1.0 only --- dart/a2ui_agent/test/conformance/express_conformance_test.dart | 3 +++ 1 file changed, 3 insertions(+) diff --git a/dart/a2ui_agent/test/conformance/express_conformance_test.dart b/dart/a2ui_agent/test/conformance/express_conformance_test.dart index 6589f8b5b3..95a2fd24c4 100644 --- a/dart/a2ui_agent/test/conformance/express_conformance_test.dart +++ b/dart/a2ui_agent/test/conformance/express_conformance_test.dart @@ -74,6 +74,9 @@ const Map _v1OnlyCases = { '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, From 60fac99373a0793f10b13be718bc326280bec664 Mon Sep 17 00:00:00 2001 From: David Iglesias Teixeira Date: Mon, 26 Oct 2026 18:32:26 -0700 Subject: [PATCH 5/5] fix(dart-agent): keep a property named items apart from array items resolveSubschema took the key 'items' to mean the array items, so an object property named items was never looked up among the properties. It is now resolveItemSchema and resolvePropertySchema, the item slot is resolved once per array, and _Context derives isAction from its slot so the two cannot be set apart. The decompiler reaches nested schemas through the same helpers, so an item schema written as a $ref writes its action slots as calls, as the compiler reads them. Before v1.0 it writes an event carrying a userMessage as a map literal, since that version's compiler rejects userMessage= and the literal compiles back as written. --- .../inference_formats/express/compiler.dart | 63 +++--- .../inference_formats/express/decompiler.dart | 40 ++-- .../express/schema_helper.dart | 31 ++- .../conformance/express_conformance_test.dart | 6 +- .../test/express_decompiler_test.dart | 121 +++++++++++ dart/a2ui_agent/test/express_test.dart | 197 ++++++++++++++++++ 6 files changed, 396 insertions(+), 62 deletions(-) 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 69373e05c6..deb927edcf 100644 --- a/dart/a2ui_agent/lib/src/inference_formats/express/compiler.dart +++ b/dart/a2ui_agent/lib/src/inference_formats/express/compiler.dart @@ -326,12 +326,17 @@ class _Slot { /// Whether the slot takes an action. bool get holdsAction => isAction(schema); - /// The slot of an array item, when [key] is `items`, or of the entry [key] - /// of a map, or null when the schema declares none at that position. - _Slot? sub(String key) => switch (helper.resolveSubschema(schema, key)) { - final Map found => _Slot(found, helper), - null => null, - }; + /// 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. @@ -339,7 +344,6 @@ class _Context { _Context({ required this.idPrefix, required this.hoisted, - this.isAction = false, this.valueBinding, this.slot, }); @@ -350,8 +354,8 @@ 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; @@ -368,18 +372,14 @@ class _Context { valueBinding: valueBinding, ); - /// The context of an array item, when [key] is `items`, or of the map - /// entry [key], at the sub-schema of [slot] at that position. - _Context sub(Object suffix, String key) { - final _Slot? sub = slot?.sub(key); - return _Context( - idPrefix: '${idPrefix}_$suffix', - hoisted: hoisted, - isAction: sub?.holdsAction ?? false, - valueBinding: valueBinding, - slot: sub, - ); - } + /// 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 { @@ -791,7 +791,6 @@ class _SurfaceCompiler { _Context( idPrefix: '${id}_$property', hoisted: hoisted, - isAction: isAction(schema), slot: schema == null ? null : _Slot(schema, helper), ), ); @@ -941,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.sub(index, 'items')), - ], + ArrayNode(:final List items) => _list(items, context), MapNode(:final Map entries) => { for (final MapEntry entry in entries.entries) - entry.key: _value(entry.value, context.sub(entry.key, 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, @@ -1096,6 +1095,16 @@ class _SurfaceCompiler { }; } + /// 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. /// 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 7405fd03f9..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; @@ -545,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), }; } @@ -569,10 +577,13 @@ class _Component { name is String && 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 || - userMessage is String || - _binding(_asMap(userMessage)) != null || - _call(_asMap(userMessage)) != null); + (_v1 && + (userMessage is String || + _binding(_asMap(userMessage)) != null || + _call(_asMap(userMessage)) != null))); if (isEvent) { final arguments = [ _string(name), @@ -861,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 502493f062..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,17 +116,32 @@ class CatalogSchemaHelper { return check(schema); } - /// The schema [schema] declares for an array item, when [key] is `items`, - /// or for the property named [key] of an object, or null if it declares - /// none. + /// 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? resolveSubschema( + 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 key, + String? property, ) { final Object? defsObj = catalog.validationSchema[r'$defs']; final Map? defs = defsObj is Map ? defsObj : null; @@ -135,13 +150,13 @@ class CatalogSchemaHelper { Map? resolve(Map? s) { if (s == null) return null; - if (key == 'items') { + if (property == null) { if (s['items'] case final Map items) { return items.cast(); } } else { - if (_properties(s)[key] case final Map property) { - return property.cast(); + if (_properties(s)[property] case final Map found) { + return found.cast(); } if (s['additionalProperties'] case final Map more) { return more.cast(); diff --git a/dart/a2ui_agent/test/conformance/express_conformance_test.dart b/dart/a2ui_agent/test/conformance/express_conformance_test.dart index 95a2fd24c4..ad50ce7102 100644 --- a/dart/a2ui_agent/test/conformance/express_conformance_test.dart +++ b/dart/a2ui_agent/test/conformance/express_conformance_test.dart @@ -62,13 +62,13 @@ const String _reservedPlainKeys = 'function calls, so a map literal that uses them 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 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': diff --git a/dart/a2ui_agent/test/express_decompiler_test.dart b/dart/a2ui_agent/test/express_decompiler_test.dart index a5f41d64f4..1b65761615 100644 --- a/dart/a2ui_agent/test/express_decompiler_test.dart +++ b/dart/a2ui_agent/test/express_decompiler_test.dart @@ -684,6 +684,127 @@ void main() { ); }); + 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([ diff --git a/dart/a2ui_agent/test/express_test.dart b/dart/a2ui_agent/test/express_test.dart index d765e785cf..536be2701b 100644 --- a/dart/a2ui_agent/test/express_test.dart +++ b/dart/a2ui_agent/test/express_test.dart @@ -1090,6 +1090,203 @@ surface("s1") ); }); + /// 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(