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'},
+ },
+ },
+ ],
+ );
+ });
+ });
}