diff --git a/python/a2ui_agent/CHANGELOG.md b/python/a2ui_agent/CHANGELOG.md index 49c7245767..e4793f0e3e 100644 --- a/python/a2ui_agent/CHANGELOG.md +++ b/python/a2ui_agent/CHANGELOG.md @@ -42,6 +42,7 @@ standalone. [#3232](https://github.com/a2ui-project/a2ui/issues/3232), [#3575](https://github.com/a2ui-project/a2ui/pull/3575) +- The Express compiler accepts `userMessage=` and `context=` keyword arguments on `Event(...)`, compiling `userMessage` to the v1.0 `event.userMessage` field, and raises `ExpressValidationError` 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)). - The Express compiler keeps a map literal as written, quoted keys or not, when one of its keys shares a name with an Express construct (`variable`, `path`, `call`, `check`, `skipped`); before, such a map was read as a variable diff --git a/python/a2ui_agent/src/a2ui/inference_formats/_shared/__init__.py b/python/a2ui_agent/src/a2ui/inference_formats/_shared/__init__.py index 11e05ed1f6..a13d1a9fb1 100644 --- a/python/a2ui_agent/src/a2ui/inference_formats/_shared/__init__.py +++ b/python/a2ui_agent/src/a2ui/inference_formats/_shared/__init__.py @@ -33,7 +33,7 @@ surface_catalog_id, ) from .coalesce import CoalescedMessage, coalesce_surface_messages -from .schema_helper import CatalogSchemaHelper +from .schema_helper import CatalogSchemaHelper, is_action_slot __all__ = [ "CatalogSchemaHelper", @@ -44,6 +44,7 @@ "check_catalogs", "check_mixed_catalogs", "coalesce_surface_messages", + "is_action_slot", "normalize_prompt_example_messages", "surface_catalog_id", "to_message_dicts", diff --git a/python/a2ui_agent/src/a2ui/inference_formats/_shared/schema_helper.py b/python/a2ui_agent/src/a2ui/inference_formats/_shared/schema_helper.py index 8e55114831..ee73175c6e 100644 --- a/python/a2ui_agent/src/a2ui/inference_formats/_shared/schema_helper.py +++ b/python/a2ui_agent/src/a2ui/inference_formats/_shared/schema_helper.py @@ -35,6 +35,37 @@ _COMMON_DEF_RE = re.compile(r"(?:^|/)common_types\.json#/(?:\$defs|definitions)/(\w+)$") +def _common_def_name(ref: Any) -> str | None: + """Returns the common type a `$ref` names, or None if it is not one.""" + if not isinstance(ref, str): + return None + m = _COMMON_DEF_RE.search(ref) + return m.group(1) if m else None + + +def is_action_slot(schema: Any) -> bool: + """Checks whether a property schema is an Action slot. + + A property is an action slot when its schema references the common `Action` + def, directly or through `oneOf`/`anyOf`/`allOf`. + + Args: + schema: The property JSON schema to inspect. + + Returns: + True if the schema references the common `Action` type. + """ + if not isinstance(schema, dict): + return False + if _common_def_name(schema.get("$ref")) == "Action": + return True + for key in ("allOf", "oneOf", "anyOf"): + subs = schema.get(key) + if isinstance(subs, list) and any(is_action_slot(sub) for sub in subs): + return True + return False + + def _find_enum(s: Any) -> list[Any] | None: """Returns the first `enum` list found in a schema or its combinators.""" if isinstance(s, dict): @@ -462,3 +493,89 @@ def _check(s: Any, in_common_types: bool) -> bool: return False return _check(schema, False) + + def resolve_item_schema(self, schema: Any) -> dict[str, Any] | None: + """Resolves the schema of an array's items. + + Follows `$ref` (into both the common types and the catalog document), + `allOf`, `oneOf` and `anyOf` to find the schema declared at that + position. + + Args: + schema: The array's JSON schema. + + Returns: + The resolved item schema, or None if none is declared. + """ + return self._resolve_at(schema, None) + + def resolve_property_schema(self, schema: Any, name: str) -> dict[str, Any] | None: + """Resolves the schema of an object's property. + + Follows `$ref` (into both the common types and the catalog document), + `allOf`, `oneOf` and `anyOf` to find the schema declared at that + position, and falls back to `additionalProperties` when the property + is not declared. A property named `items` is looked up as a property, + not as the array items. + + Args: + schema: The object's JSON schema. + name: The property name. + + Returns: + The resolved property schema, or None if none is declared. + """ + return self._resolve_at(schema, name) + + def _resolve_at(self, schema: Any, prop: str | None) -> dict[str, Any] | None: + """Resolves the schema at a property, or at the array items when `prop` is None.""" + visited: set[int] = set() + + def _resolve(s: Any, in_common_types: bool) -> dict[str, Any] | None: + if not isinstance(s, dict) or id(s) in visited: + return None + visited.add(id(s)) + + if prop is None: + items = s.get("items") + if isinstance(items, dict): + return items + else: + props = s.get("properties") + declared = props.get(prop) if isinstance(props, dict) else None + if isinstance(declared, dict): + return declared + additional = s.get("additionalProperties") + if isinstance(additional, dict): + return additional + + ref = s.get("$ref") + if isinstance(ref, str): + def_name = _common_def_name(ref) + if def_name: + found = _resolve(self._lookup_common_def(def_name), True) + elif in_common_types and ( + ref.startswith("#/$defs/") or ref.startswith("#/definitions/") + ): + name = ref[8:] if ref.startswith("#/$defs/") else ref[14:] + found = _resolve(self._lookup_common_def(name), True) + elif ref.startswith("#/"): + found = _resolve( + self._resolve_json_pointer(self.catalog, ref), False + ) + else: + found = None + if found: + return found + + for combinator in ("allOf", "oneOf", "anyOf"): + subs = s.get(combinator) + if isinstance(subs, list): + for sub in subs: + found = _resolve(sub, in_common_types) + if found: + return found + + return None + + return _resolve(schema, False) diff --git a/python/a2ui_agent/src/a2ui/inference_formats/express/compiler.py b/python/a2ui_agent/src/a2ui/inference_formats/express/compiler.py index 873305a5bf..23b02a1953 100644 --- a/python/a2ui_agent/src/a2ui/inference_formats/express/compiler.py +++ b/python/a2ui_agent/src/a2ui/inference_formats/express/compiler.py @@ -23,6 +23,7 @@ from collections.abc import Sequence from dataclasses import dataclass, field, replace +import json import re from typing import Any, Literal @@ -37,6 +38,7 @@ catalogs_defining, catalogs_protocol_version, check_mixed_catalogs, + is_action_slot, surface_catalog_id, to_message_models, ) @@ -70,6 +72,41 @@ _SEND_DATA_MODEL_KEY = "sendDataModel" _UPDATE_KEY = "update" +# Keyword arguments accepted by the reserved `Event(...)` signature. +_EVENT_KEYWORDS = ("userMessage", "context") + + +@dataclass(frozen=True) +class _ValueSlot: + """The catalog schema declared at a value's position, with the helper that owns it.""" + + schema: dict[str, Any] + helper: CatalogSchemaHelper + + +def _item_slot(slot: _ValueSlot | None) -> _ValueSlot | None: + """Descends a value slot into the items of an array. + + Returns: + The item slot, or None when the schema is unknown at that position. + """ + if slot is None: + return None + schema = slot.helper.resolve_item_schema(slot.schema) + return _ValueSlot(schema, slot.helper) if schema else None + + +def _property_slot(slot: _ValueSlot | None, name: str) -> _ValueSlot | None: + """Descends a value slot into a property of an object, whatever its name. + + Returns: + The property's slot, or None when the schema is unknown at that position. + """ + if slot is None: + return None + schema = slot.helper.resolve_property_schema(slot.schema, name) + return _ValueSlot(schema, slot.helper) if schema else None + def _component_id(var_name: str, ast: Any, *, at_least_v10: bool = False) -> str: """Returns the ID of the component a variable holds. @@ -230,6 +267,38 @@ def _schema_allows_databinding(schema: Any) -> bool: return False +def _describe_express_value(val: Any) -> str: + """Names a parsed value for an error message in the terms the model wrote it in. + + Returns: + A short phrase such as `a map literal` or `the number 42`, rather than + the node's repr. + """ + if isinstance(val, CallNode): + return f"a call to {val.name}(...)" + if isinstance(val, CheckNode): + return f"a check (?{val.name})" + if isinstance(val, VariableNode): + return f"the variable {val.name}" + if isinstance(val, PathNode): + return f"the data binding ${val.path}" + if isinstance(val, SkippedNode): + return "'_'" + if isinstance(val, dict): + return "a map literal" + if isinstance(val, list): + return "a list" + if val is None: + return "null" + if isinstance(val, bool): + return f"the boolean {str(val).lower()}" + if isinstance(val, (int, float)): + return f"the number {json.dumps(val)}" + if isinstance(val, str): + return f"the string {json.dumps(val)}" + return f"a {type(val).__name__}" + + def _binding_path(v: Any, path_key: str) -> str | None: """Returns the path of a compiled data binding, or None if `v` is not one. @@ -1216,15 +1285,16 @@ def _compile_ast_node( comp_dict[prop_name] = None continue + prop_schema = comp_helper.get_property_schema(comp_name, prop_name) mapped_val = self._compile_value( arg, raw_symbols, ctx, - is_action=(prop_name in ["action", "submitAction"]), + is_action=is_action_slot(prop_schema), parent_id=comp_id, parent_prop=prop_name, + slot=_ValueSlot(prop_schema, comp_helper) if prop_schema else None, ) - prop_schema = comp_helper.get_property_schema(comp_name, prop_name) if prop_schema and not _schema_allows_databinding(prop_schema): if _has_databinding(arg, raw_symbols): raise ExpressForbiddenDatabindingError(comp_name, prop_name) @@ -1368,6 +1438,91 @@ def _compile_check( condition["args"] = compiled_args return condition, message_val + def _compile_event_call( + self, + val: CallNode, + raw_symbols: dict[str, Any], + ctx: _CompileContext, + ) -> dict[str, Any]: + """Compiles the reserved `Event(name, context?, userMessage=?, context=?)` signature. + + `userMessage` is a v1.0 Action field. It is rejected when targeting an + earlier protocol version, and so is any other keyword the signature does + not define, rather than being dropped from the compiled action. + + The event's name, message and context entries are `DynamicValue`s, not + actions, so a function call among them stays a plain call whatever slot + the event itself sits in. + + Raises: + ExpressValidationError: If there are more than two positional + arguments, `userMessage` is used before v1.0, or `userMessage` + is neither a string nor a `$path` data binding. + ExpressInvalidParamError: If a keyword other than `userMessage` or + `context` is given. + ExpressDuplicateParamError: If the context is given both + positionally and by keyword. + """ + fn_args = val.args + fn_kwargs = val.kwargs + + if len(fn_args) > 2: + raise ExpressValidationError( + "Event accepts at most 2 positional arguments (name, context)," + f" got {len(fn_args)}." + ) + for key in fn_kwargs: + if key not in _EVENT_KEYWORDS: + raise ExpressInvalidParamError("Event", key, list(_EVENT_KEYWORDS)) + if len(fn_args) == 2 and "context" in fn_kwargs: + raise ExpressDuplicateParamError("Event", "context") + + compiled_event_name = ( + self._compile_value(fn_args[0], raw_symbols, ctx) if fn_args else "" + ) + event_dict: dict[str, Any] = {"name": compiled_event_name} + + if "userMessage" in fn_kwargs: + if not ctx.at_least_v10: + raise ExpressValidationError( + "Event userMessage requires protocol v1.0 or later; the" + f" target version {ctx.version} has no userMessage field." + ) + # `userMessage` is a DynamicString: a string, a data binding or a + # function call. + user_message = self._compile_value( + fn_kwargs["userMessage"], raw_symbols, ctx + ) + is_binding_or_call = isinstance(user_message, dict) and ( + ctx.path_key in user_message or ctx.call_key in user_message + ) + if not isinstance(user_message, str) and not is_binding_or_call: + raise ExpressValidationError( + "Event userMessage must be a string, a $path data binding or" + " a function call, got" + f" {_describe_express_value(fn_kwargs['userMessage'])}." + ) + event_dict["userMessage"] = user_message + + if len(fn_args) == 2: + context_node = fn_args[1] + elif "context" in fn_kwargs: + context_node = fn_kwargs["context"] + else: + return {"event": event_dict} + + raw_context = self._compile_value(context_node, raw_symbols, ctx) + # A context written in the DSL is kept even when it is empty. + compiled_context: dict[str, Any] = {} + if isinstance(raw_context, dict): + compiled_context.update(raw_context) + elif isinstance(raw_context, list): + for item in raw_context: + if isinstance(item, dict): + compiled_context.update(item) + event_dict["context"] = compiled_context + return {"event": event_dict} + def _compile_value( self, val: Any, @@ -1377,6 +1532,7 @@ def _compile_value( parent_id: str | None = None, parent_prop: str | None = None, list_index: int | None = None, + slot: _ValueSlot | None = None, ) -> Any: """Compiles an individual AST node value into valid A2UI equivalents. @@ -1388,6 +1544,10 @@ def _compile_value( parent_id: The parent component ID if compiling a component property. parent_prop: The parent component property name. list_index: The index within an array property if applicable. + slot: The catalog 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 (e.g. + `items: [{label, action}]`) is compiled as an action too. Returns: The semantically correct A2UI JSON structure. @@ -1420,6 +1580,7 @@ def _compile_value( raw_symbols, ctx, is_action, + slot=slot, ) finally: ctx.resolving.discard(ref_name) @@ -1441,21 +1602,33 @@ def _compile_value( # A dict is always a map literal, whatever its keys are. if isinstance(val, dict): - return { - k: self._compile_value(v, raw_symbols, ctx, is_action) - for k, v in val.items() - } + dict_res: dict[str, Any] = {} + for k, v in val.items(): + entry_slot = _property_slot(slot, k) + entry_is_action = is_action or ( + entry_slot is not None and is_action_slot(entry_slot.schema) + ) + dict_res[k] = self._compile_value( + v, raw_symbols, ctx, entry_is_action, slot=entry_slot + ) + return dict_res if isinstance(val, list): + # Resolved once for the list, since every item shares the schema. + item_slot = _item_slot(slot) + item_is_action = is_action or ( + item_slot is not None and is_action_slot(item_slot.schema) + ) return [ self._compile_value( item, raw_symbols, ctx, - is_action, + item_is_action, parent_id=parent_id, parent_prop=parent_prop, list_index=idx, + slot=item_slot, ) for idx, item in enumerate(val) ] @@ -1495,38 +1668,7 @@ def _compile_call( return {"path": template_path, "componentId": comp_id_val} if fn_name == "Event": - compiled_event_name = ( - self._compile_value( - fn_args[0], - raw_symbols, - ctx, - is_action, - ) - if len(fn_args) > 0 - else "" - ) - raw_context = ( - self._compile_value( - fn_args[1], - raw_symbols, - ctx, - is_action, - ) - if len(fn_args) > 1 - else {} - ) - compiled_context = {} - if isinstance(raw_context, dict): - compiled_context.update(raw_context) - elif isinstance(raw_context, list): - for item in raw_context: - if isinstance(item, dict): - compiled_context.update(item) - event_dict: dict[str, Any] = {"name": compiled_event_name} - # A context written in the DSL is kept even when it is empty. - if compiled_context or len(fn_args) > 1: - event_dict["context"] = compiled_context - return {"event": event_dict} + return self._compile_event_call(val, raw_symbols, ctx) call_cat_id = raw_kwargs.pop("catalogId", None) if self._defines_component(fn_name, call_cat_id): diff --git a/python/a2ui_agent/src/a2ui/inference_formats/express/decompiler.py b/python/a2ui_agent/src/a2ui/inference_formats/express/decompiler.py index edf8beb83d..6511c6176f 100644 --- a/python/a2ui_agent/src/a2ui/inference_formats/express/decompiler.py +++ b/python/a2ui_agent/src/a2ui/inference_formats/express/decompiler.py @@ -833,19 +833,28 @@ def _decompile_value( if isinstance(bound_path, str) and len(val) == 1: return _decompile_path(bound_path) - if isinstance(val.get("event"), dict): + # Before v1.0 an event has no userMessage, and the compiler rejects + # `userMessage=`, so such an object is written as a map literal that + # compiles back as written. + if isinstance(val.get("event"), dict) and ( + self._v1 or val["event"].get("userMessage") is None + ): evt = val["event"] - name = _decompile_string(evt.get("name", "")) - ctx = evt.get("context", {}) - ctx_reprs = [ - f"{_decompile_key(k)}:" - f" {self._decompile_value(v, var_names, False, surface_cat_id)}" - for k, v in ctx.items() - ] + event_args = [_decompile_string(evt.get("name", ""))] # An empty context is written out so that it compiles back. if "context" in evt: - return f"Event({name}, {{{', '.join(ctx_reprs)}}})" - return f"Event({name})" + ctx_reprs = [ + f"{_decompile_key(k)}:" + f" {self._decompile_value(v, var_names, False, surface_cat_id)}" + for k, v in evt.get("context", {}).items() + ] + event_args.append(f"{{{', '.join(ctx_reprs)}}}") + if evt.get("userMessage") is not None: + user_message = self._decompile_value( + evt["userMessage"], var_names, False, surface_cat_id + ) + event_args.append(f"userMessage={user_message}") + return f"Event({', '.join(event_args)})" if isinstance(val.get("functionCall"), dict): return self._decompile_call( diff --git a/python/a2ui_agent/tests/express/test_catalogs_and_updates.py b/python/a2ui_agent/tests/express/test_catalogs_and_updates.py index 8ad4769598..cb19ad8561 100644 --- a/python/a2ui_agent/tests/express/test_catalogs_and_updates.py +++ b/python/a2ui_agent/tests/express/test_catalogs_and_updates.py @@ -76,7 +76,12 @@ def _primary(version: str = "v1.0") -> Catalog: "Input": { "allOf": [ _CHECKABLE, - {"properties": {"value": _DYNAMIC_STRING, "action": {}}}, + { + "properties": { + "value": _DYNAMIC_STRING, + "action": {"$ref": "common_types.json#/$defs/Action"}, + } + }, ] }, }, @@ -99,7 +104,7 @@ def _secondary(version: str = "v1.0") -> Catalog: "properties": { "headline": _DYNAMIC_STRING, "content": {"$ref": "common_types.json#/$defs/ComponentId"}, - "action": {}, + "action": {"$ref": "common_types.json#/$defs/Action"}, } }, ] diff --git a/python/a2ui_agent/tests/express/test_compiler.py b/python/a2ui_agent/tests/express/test_compiler.py index 0bcd42696c..ecd0c52cce 100644 --- a/python/a2ui_agent/tests/express/test_compiler.py +++ b/python/a2ui_agent/tests/express/test_compiler.py @@ -42,6 +42,7 @@ ExpressParser, ExpressUndefinedRootError, ExpressUnknownPropertyError, + ExpressValidationError, ) from ..repo_paths import find_repo_root @@ -321,5 +322,323 @@ def test_number_literal_parse_errors(self): compiler.compile('root = Column([t], weight=.)\nt = Text("Hi")') +def _links_catalog(protocol_version: str) -> Catalog: + """Builds a catalog whose `Links` component nests an action inside each list item. + + `items[].action` and `primary` are `Action` slots, `tags[]` is a plain + string list, and `openUrl`/`concat` are catalog functions. + """ + call_key = "@call" if protocol_version == "1.0" else "call" + return Catalog.from_json( + { + "catalogId": "test/links", + "components": { + "Links": { + "type": "object", + "properties": { + "component": {"const": "Links"}, + "items": { + "type": "array", + "items": { + "type": "object", + "properties": { + "label": {"type": "string"}, + "action": { + "$ref": "common_types.json#/$defs/Action" + }, + }, + "required": ["label", "action"], + }, + }, + "primary": {"$ref": "common_types.json#/$defs/Action"}, + "tags": {"type": "array", "items": {"type": "string"}}, + }, + "required": ["component", "items"], + }, + }, + "functions": { + "openUrl": { + "type": "object", + "returnType": "void", + "properties": { + call_key: {"const": "openUrl"}, + "args": { + "type": "object", + "properties": {"url": {"type": "string"}}, + "required": ["url"], + }, + }, + "required": [call_key, "args"], + }, + "concat": { + "type": "object", + "returnType": "string", + "properties": { + call_key: {"const": "concat"}, + "args": { + "type": "object", + "properties": { + "a": {"type": "string"}, + "b": {"type": "string"}, + }, + "required": ["a", "b"], + }, + }, + "required": [call_key, "args"], + }, + }, + }, + protocol_version=protocol_version, + ) + + +class TestExpressEventAndNestedActionSlots(unittest.TestCase): + """Covers the `Event(...)` keyword signature and action slots nested in list items.""" + + def setUp(self): + self.catalog = _links_catalog("1.0") + self.compiler = ExpressCompiler([self.catalog], version="v1.0") + + def _root(self, dsl: str) -> dict: + messages = to_message_dicts(self.compiler.compile(dsl)) + comps = {c["id"]: c for c in messages[0]["createSurface"]["components"]} + return comps["root"] + + def test_event_user_message_compiles_to_v1_field(self): + root = self._root( + 'root = Links([{label: "Reply", action: Event("reply",' + ' userMessage="Now")}])' + ) + self.assertEqual( + root["items"][0]["action"], + {"event": {"name": "reply", "userMessage": "Now"}}, + ) + + def test_event_context_keeps_a_function_call_a_plain_call(self): + root = self._root( + 'root = Links([{label: "Go", action: Event("go",' + ' {label: concat("a", "b")})}])' + ) + self.assertEqual( + root["items"][0]["action"]["event"]["context"]["label"], + {"@call": "concat", "args": {"a": "a", "b": "b"}}, + ) + + def test_event_user_message_binding_with_positional_context(self): + root = self._root( + 'root = Links([{label: "Reply", action: Event("reply", {threadId: "t1"},' + " userMessage=$/draft/message)}])" + ) + self.assertEqual( + root["items"][0]["action"], + { + "event": { + "name": "reply", + "userMessage": {"@path": "/draft/message"}, + "context": {"threadId": "t1"}, + } + }, + ) + + def test_event_context_keyword(self): + root = self._root( + 'root = Links([{label: "Go", action: Event("submit", context={itemId:' + ' "42"})}])' + ) + self.assertEqual( + root["items"][0]["action"], + {"event": {"name": "submit", "context": {"itemId": "42"}}}, + ) + + def test_event_duplicate_context_is_a_validation_error(self): + with self.assertRaises(ExpressDuplicateParamError) as ctx: + self.compiler.compile( + 'root = Links([{label: "Go", action: Event("submit", {a: 1},' + " context={b: 2})}])" + ) + self.assertIsInstance(ctx.exception, ExpressValidationError) + + def test_event_unknown_keyword_is_a_validation_error(self): + with self.assertRaises(ExpressInvalidParamError) as ctx: + self.compiler.compile( + 'root = Links([{label: "Reply", action: Event("reply",' + ' message="Now")}])' + ) + self.assertIsInstance(ctx.exception, ExpressValidationError) + self.assertIn("message", str(ctx.exception)) + + def test_event_user_message_accepts_a_function_call(self): + root = self._root( + 'root = Links([{label: "Reply", action: Event("reply",' + ' userMessage=concat("a", "b"))}])' + ) + self.assertEqual( + root["items"][0]["action"], + { + "event": { + "name": "reply", + "userMessage": { + "@call": "concat", + "args": {"a": "a", "b": "b"}, + }, + } + }, + ) + + def test_event_user_message_that_is_not_a_dynamic_string_is_an_error(self): + with self.assertRaises(ExpressValidationError) as ctx: + self.compiler.compile( + 'root = Links([{label: "Reply", action: Event("reply",' + " userMessage=42)}])" + ) + self.assertEqual( + str(ctx.exception), + "Event userMessage must be a string, a $path data binding or a" + " function call, got the number 42.", + ) + with self.assertRaises(ExpressValidationError) as ctx: + self.compiler.compile( + 'root = Links([{label: "Reply", action: Event("reply",' + " userMessage={a: 1})}])" + ) + self.assertIn("got a map literal.", str(ctx.exception)) + + def test_property_named_items_is_a_property_not_the_array_items(self): + """A map key `items` is an object property; its item schema is reached through a local $ref.""" + catalog = Catalog.from_json( + { + "catalogId": "test/menu", + "components": { + "Menu": { + "type": "object", + "properties": { + "component": {"const": "Menu"}, + "menu": { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": {"$ref": "#/$defs/MenuItem"}, + } + }, + }, + }, + "required": ["component", "menu"], + } + }, + "functions": { + "openUrl": { + "type": "object", + "returnType": "void", + "properties": { + "@call": {"const": "openUrl"}, + "args": { + "type": "object", + "properties": {"url": {"type": "string"}}, + "required": ["url"], + }, + }, + "required": ["@call", "args"], + } + }, + "$defs": { + "MenuItem": { + "type": "object", + "properties": { + "label": {"type": "string"}, + "action": {"$ref": "common_types.json#/$defs/Action"}, + }, + "required": ["label", "action"], + } + }, + }, + protocol_version="1.0", + ) + compiler = ExpressCompiler([catalog], version="v1.0") + messages = to_message_dicts( + compiler.compile( + 'root = Menu({items: [{label: "Docs", action:' + ' openUrl("https://a2ui.org")}]})' + ) + ) + root = messages[0]["createSurface"]["components"][0] + self.assertEqual( + root["menu"]["items"], + [{ + "label": "Docs", + "action": { + "functionCall": { + "@call": "openUrl", + "args": {"url": "https://a2ui.org"}, + } + }, + }], + ) + + def test_event_too_many_positional_args_is_a_validation_error(self): + with self.assertRaises(ExpressValidationError): + self.compiler.compile( + 'root = Links([{label: "Reply", action: Event("reply", {}, "extra")}])' + ) + + def test_event_user_message_before_v1_is_a_validation_error(self): + compiler = ExpressCompiler([_links_catalog("0.9")], version="v0.9") + with self.assertRaises(ExpressValidationError) as ctx: + compiler.compile( + 'root = Links([{label: "Reply", action: Event("reply",' + ' userMessage="Now")}])' + ) + self.assertIn("v1.0", str(ctx.exception)) + + def test_function_call_in_list_item_action_slot_is_wrapped(self): + root = self._root( + 'root = Links([{label: "Docs", action: openUrl("https://a2ui.org")}],' + ' primary=openUrl("https://a2ui.org/start"))' + ) + self.assertEqual( + root["items"][0]["action"], + { + "functionCall": { + "@call": "openUrl", + "args": {"url": "https://a2ui.org"}, + } + }, + ) + self.assertEqual( + root["primary"], + { + "functionCall": { + "@call": "openUrl", + "args": {"url": "https://a2ui.org/start"}, + } + }, + ) + + def test_function_call_in_list_item_action_slot_through_variable(self): + root = self._root( + 'docs = {label: "Docs", action: openUrl("https://a2ui.org")}\n' + "root = Links([docs])" + ) + self.assertEqual( + root["items"][0]["action"], + { + "functionCall": { + "@call": "openUrl", + "args": {"url": "https://a2ui.org"}, + } + }, + ) + + def test_function_call_in_non_action_nested_slot_stays_a_plain_call(self): + root = self._root( + 'root = Links([{label: "Go", action: Event("go")}], tags=[concat("a",' + ' "b")])' + ) + self.assertEqual( + root["tags"], + [{"@call": "concat", "args": {"a": "a", "b": "b"}}], + ) + + if __name__ == "__main__": unittest.main() diff --git a/python/a2ui_agent/tests/express/test_parser_decompile.py b/python/a2ui_agent/tests/express/test_parser_decompile.py index b3b0068df5..a3e9318c7c 100644 --- a/python/a2ui_agent/tests/express/test_parser_decompile.py +++ b/python/a2ui_agent/tests/express/test_parser_decompile.py @@ -258,5 +258,132 @@ def test_decompiled_numbers_round_trip(self): self.assertEqual(comps["root"]["weight"], 1e-7) +class TestExpressDecompileEventAndNestedActions(unittest.TestCase): + """Covers writing `Event` keyword arguments and nested action slots back out.""" + + def setUp(self): + self.catalog = Catalog.from_json( + { + "catalogId": "test/links", + "components": { + "Links": { + "type": "object", + "properties": { + "component": {"const": "Links"}, + "items": { + "type": "array", + "items": { + "type": "object", + "properties": { + "label": {"type": "string"}, + "action": { + "$ref": "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"], + }, + }, + }, + protocol_version="1.0", + ) + self.compiler = ExpressCompiler([self.catalog], version="v1.0") + self.decompiler = ExpressParser([self.catalog]) + + def _round_trip(self, dsl: str) -> str: + """Decompiles the compiled DSL and checks the text compiles back the same.""" + compiled = self.compiler.compile(dsl) + decompiled = self.decompiler.decompile(compiled) + self.assertEqual( + to_message_dicts(self.compiler.compile(decompiled)), + to_message_dicts(compiled), + ) + return decompiled + + def test_event_user_message_is_written_as_keyword(self): + decompiled = self._round_trip( + 'root = Links([{label: "Reply", action: Event("reply",' + ' userMessage="Now")}])' + ) + self.assertIn('Event("reply", userMessage="Now")', decompiled) + + def test_event_user_message_binding_follows_context(self): + decompiled = self._round_trip( + 'root = Links([{label: "Reply", action: Event("reply", {threadId: "t1"},' + " userMessage=$/draft/message)}])" + ) + self.assertIn( + 'Event("reply", {threadId: "t1"}, userMessage=$/draft/message)', decompiled + ) + + def test_event_user_message_function_call_round_trips(self): + decompiled = self._round_trip( + 'root = Links([{label: "Reply", action: Event("reply",' + ' userMessage=openUrl("https://a2ui.org"))}])' + ) + self.assertIn( + 'Event("reply", userMessage=openUrl("https://a2ui.org"))', decompiled + ) + + def test_event_with_user_message_is_a_map_literal_before_v1(self): + """v0.9 has no userMessage and its compiler rejects `userMessage=`, so the object stays data.""" + catalog = Catalog.from_json( + { + "catalogId": "test/v09", + "components": { + "Card": { + "type": "object", + "properties": { + "component": {"const": "Card"}, + "child": {"type": "object"}, + }, + "required": ["component", "child"], + } + }, + }, + protocol_version="0.9", + ) + decompiled = ExpressParser([catalog], version="v0.9").decompile([{ + "version": "v0.9", + "updateComponents": { + "surfaceId": "s1", + "components": [{ + "id": "root", + "component": "Card", + "child": {"event": {"name": "go", "userMessage": "hi"}}, + }], + }, + }]) + self.assertNotIn("Event(", decompiled) + self.assertIn("{event: {name:", decompiled) + + def test_function_call_in_list_item_action_slot_is_written_as_a_call(self): + decompiled = self._round_trip( + 'root = Links([{label: "Docs", action: openUrl("https://a2ui.org")}])' + ) + self.assertIn( + 'root = Links([{label: "Docs", action: openUrl("https://a2ui.org")}])', + decompiled, + ) + + if __name__ == "__main__": unittest.main() diff --git a/python/a2ui_agent/tests/test_multi_catalog_inference_formats.py b/python/a2ui_agent/tests/test_multi_catalog_inference_formats.py index e483c4acc5..6a0bab714c 100644 --- a/python/a2ui_agent/tests/test_multi_catalog_inference_formats.py +++ b/python/a2ui_agent/tests/test_multi_catalog_inference_formats.py @@ -48,7 +48,7 @@ def primary_catalog() -> Catalog: { "properties": { "primaryArg": {"type": "string"}, - "action": {"type": "object"}, + "action": {"$ref": "common_types.json#/$defs/Action"}, } }, ] @@ -104,7 +104,7 @@ def secondary_catalog() -> Catalog: { "properties": { "secondaryArg": {"type": "string"}, - "action": {"type": "object"}, + "action": {"$ref": "common_types.json#/$defs/Action"}, } }, ]