Define the Dart a2ui_agent API and its tests, limited to protocol v0.9 - #2408
Define the Dart a2ui_agent API and its tests, limited to protocol v0.9#2408polina-c wants to merge 2 commits into
Conversation
Implements the API surface described by blueprints/modules/a2ui_agent.blueprint.md for the Dart agent SDK, moves the pieces that belong to a2ui_core into a2ui_core, and moves shared test data into conformance/ so every SDK is measured against one dataset. Most of the agent API throws UnimplementedError; the mechanical parts are implemented. Tests describe the intended behaviour of everything still stubbed and are marked skip: with the reason, so `dart test` doubles as the implementation checklist. a2ui_core - Catalog now takes two type parameters, Catalog<C extends ComponentApi, F extends FunctionApi>, so agents can hold schema-only functions. Breaking. - Adds A2uiProtocolVersion, Catalog.fromJson/catalogSchema/copyWith, A2uiRendererCapabilities, A2uiValidator and the conformance error categories. - Fixes DataModel.set silently dropping a write through a primitive. conformance/ - New core/data_model.yaml, migrated from web_core's data-model.test.ts. - New agent/request_processor.yaml for the blueprint's primary use case. - Basic-catalog cases added to core/catalog.yaml, agent/parser.yaml and agent/inference_format.yaml, referencing the published spec catalog by path. renderers/web_core - data-model.test.ts becomes a harness over the shared dataset; JavaScript specific behaviour stays local and is documented on both sides. CI - Adds a dart_packages job so the Dart packages are formatted, analyzed with --fatal-infos, and tested.
There was a problem hiding this comment.
Code Review
This pull request introduces a shared conformance test suite for the reactive data model and request processor, alongside updates to the Dart a2ui_agent and a2ui_core packages to support protocol version gating (v0.9), schema-only catalogs, and payload validation. It also fixes a bug in DataModel.set to throw an error when writing through a primitive parent path. The review feedback highlights opportunities to improve Dart idiomaticity by leveraging compile-time exhaustiveness checking for sealed classes, enhancing runtime safety during inline catalog parsing, and optimizing repeated map lookups in catalog schema generation.
| switch (raw.part) { | ||
| case TextPart(:final String text): | ||
| parts.add(TextPart(text)); | ||
| case RawA2uiPart(:final String a2uiRaw): | ||
| parts.add(A2uiPart(compile(a2uiRaw))); | ||
| case ResponsePart(): | ||
| throw StateError('Unexpected raw part: ${raw.part}'); | ||
| } |
There was a problem hiding this comment.
Since ResponsePart is a sealed class, it is more idiomatic and maintainable to explicitly match A2uiPart() in the switch statement instead of using the base class ResponsePart() as a fallback. This allows the Dart compiler to perform compile-time exhaustiveness checking, ensuring that any future subclasses of ResponsePart must be explicitly handled here.
| switch (raw.part) { | |
| case TextPart(:final String text): | |
| parts.add(TextPart(text)); | |
| case RawA2uiPart(:final String a2uiRaw): | |
| parts.add(A2uiPart(compile(a2uiRaw))); | |
| case ResponsePart(): | |
| throw StateError('Unexpected raw part: ${raw.part}'); | |
| } | |
| switch (raw.part) { | |
| case TextPart(:final String text): | |
| parts.add(TextPart(text)); | |
| case RawA2uiPart(:final String a2uiRaw): | |
| parts.add(A2uiPart(compile(a2uiRaw))); | |
| case A2uiPart(): | |
| throw StateError('Unexpected raw part: ${raw.part}'); | |
| } |
| inlineCatalogs: [ | ||
| if (rawInline is List) | ||
| for (final Object? catalog in rawInline) | ||
| Catalog.fromJson((catalog! as Map).cast<String, Object?>()), | ||
| ], |
There was a problem hiding this comment.
Casting catalog directly to Map and calling cast can cause runtime TypeError or NullCheckError crashes if the input is malformed or null. It is safer to validate that catalog is indeed a Map before casting and parsing it, throwing a structured A2uiValidationError instead.
inlineCatalogs: [
if (rawInline is List)
for (final Object? catalog in rawInline)
if (catalog is Map)
Catalog.fromJson(catalog.cast<String, Object?>())
else
throw A2uiValidationError(
"Inline catalogs must be JSON objects.",
details: json,
),
],| if (source['components'] is Map) { | ||
| document['components'] = <String, Object?>{ | ||
| for (final String name in components.keys) | ||
| if ((source['components']! as Map).containsKey(name)) | ||
| name: _deepCopyValue((source['components']! as Map)[name]), | ||
| }; | ||
| } |
There was a problem hiding this comment.
source['components'] is repeatedly looked up and cast to Map inside the loop, which is inefficient. Binding source['components'] to a local variable sourceComponents (similar to how sourceFunctions is handled right below) improves performance, readability, and consistency.
| if (source['components'] is Map) { | |
| document['components'] = <String, Object?>{ | |
| for (final String name in components.keys) | |
| if ((source['components']! as Map).containsKey(name)) | |
| name: _deepCopyValue((source['components']! as Map)[name]), | |
| }; | |
| } | |
| final Object? sourceComponents = source['components']; | |
| if (sourceComponents is Map) { | |
| document['components'] = <String, Object?>{ | |
| for (final String name in components.keys) | |
| if (sourceComponents.containsKey(name)) | |
| name: _deepCopyValue(sourceComponents[name]), | |
| }; | |
| } |
Formatting: the Dart formatter in the SDK CI runs (3.13.1, via Flutter stable 3.47.1) disagreed with 3.12.2 on eight test files. Reformatted with 3.13.1. Conformance: six cases added to the shared suites asserted behaviour only the Dart SDK has, which broke the Python and Kotlin harnesses that already consume those suites. A shared suite is a contract every implementation satisfies, so these move back to Dart's own tests, where they were already covered: - parse_full rejecting an unsupported or missing protocol version, and an unknown message type. The Python and Kotlin parsers do not validate the version while parsing. - prune with allowed_functions. Function pruning is implemented by the Dart FunctionPruningTransformer only. - select_catalog raising when renderer and agent share no catalog. Kotlin raises with a different message; Python returns no selection. conformance/README.md now separates these "one SDK has it, others do not yet" gaps from genuine language-level exclusions, and lists the three above. The cases that do hold everywhere stay shared: basic catalog loading and selection, component pruning with anyComponent narrowing, and parsing a v0.9 basic catalog payload.
Implements the API surface described by
a2ui_agent.blueprint.mdfor the Dart agent SDK, limited to protocol v0.9, together with the tests that describe it.Most of the API throws
UnimplementedError; the mechanical parts are implemented. Tests are written against the intended behaviour and markedskip:with a reason, sodart testdoubles as the implementation checklist for the follow-up PR.Addresses the review feedback on flutter/genui#1020: renderer capabilities, validation and catalog-document handling move to
a2ui_core;FunctionApiandFunctionImplementationare separated;BundledCatalogProvideris dropped; unit test data moves to the conformance suite.Version limiting
Every entry point that accepts a versioned payload or a capabilities object rejects anything that is not
v0.9, including payloads that omitversion. That gate is real, not stubbed —A2uiProtocolVersion.fromJsonthrowsA2uiValidationError, andA2uiMessage.fromJson,Catalog.fromJson,A2uiRendererCapabilities.fromJsonandA2uiValidatorall route through it.dart/a2ui_agent— the APILayout mirrors blueprint §2.
A2uiGenerator,A2uiRequestProcessoragentCapabilitiesreal;createProcessor,promptSnippet,parseResponse,validateExamplesstubbedCatalogConfig,CatalogProvider,FileSystemCatalogProvider,InMemoryCatalogProviderCatalogTransformer,ComponentPruningTransformer,FunctionPruningTransformerParser,TextPart,RawA2uiPart,A2uiPart,RawResponsePartParser.parseResponsereal; format parsers stubbedInferenceFormat(Factory),DirectJsonFormat,ExpressFormatresolveCatalogsBundledCatalogProvideris deliberately absent — nothing needs to ship with the SDK.dart/a2ui_core— what moved here, and whyPer the #1020 review, these belong to core because renderers and agents both need them. Changes are the minimum needed to build the agent on top.
Catalog<C extends ComponentApi, F extends FunctionApi>(breaking). Agents parameterise withCatalogFunction(signature only), renderers withFunctionImplementation.SchemaCatalogaliases the agent shape.Catalog.fromJson/catalogSchema/copyWith, plus schema-onlyCatalogComponentandCatalogFunction. Catalog documents round-trip through core, and a pruned catalog renders a pruned document with$defs/anyComponentand$defs/anyFunctionnarrowed to match — so the transformers stay trivial and the schema knowledge lives in one place.A2uiRendererCapabilities, mirroringclient_capabilities.jsonand web_core'sA2uiClientCapabilities.A2uiValidator, with the version gate implemented and the structural / catalog-schema checks declared but stubbed.A2uiProtocolVersionand theA2uiParseError/A2uiCompileError/A2uiCatalogError/A2uiIntegrityError/A2uiRecursionErrorcategories.One bug fixed
DataModel.setsilently dropped a write whose parent path resolved to a primitive (/user/name/firstwhere/user/nameis a string). It now throwsA2uiDataError, matching web_core. The shared dataset surfaced this.conformance/— shared dataTests run against the published basic catalog schema, referenced by relative path rather than copied, so suites cannot drift from the spec.
agent/request_processor.yaml(new,process_requestaction): the blueprint's primary use case end to end — negotiate, prompt, parse — plus three rejection cases.core/data_model.yaml(new,data_modelaction): 35 cases migrated fromrenderers/web_core/src/v0_9/state/data-model.test.ts.core/catalog.yaml,agent/parser.yamlandagent/inference_format.yaml.conformance_schema.jsongains the two actions and aDataErrorcategory. Diff is +113/−3; the file's existing formatting is preserved.Cases withheld from the shared suites
Six cases I first added asserted behaviour only the new Dart SDK has, and broke the Python and Kotlin harnesses that already consume these suites. A shared suite is a contract every implementation satisfies, so they moved back to Dart's own tests and are listed in
conformance/README.mdas gaps rather than disagreements:parse_fullrejecting an unsupported or missing protocol version, and an unknown message type — the Python and Kotlin parsers do not validate the version while parsing.prunewithallowed_functions— function pruning is implemented by the DartFunctionPruningTransformeronly.select_catalograising when renderer and agent share no catalog — Kotlin raises with a different message, Python returns no selection.The new
process_requestaction does assert version rejection, because it has no prior implementations and its contract is being defined with it.Divergences the migration surfaced
The data-model migration compared two live implementations. Rather than silently picking a winner, contested behaviour stayed out of the shared suite and is documented on both sides:
/items/999999999must be rejected; JavaScript arrays are sparse, so the same write is cheap and allowed.null/undefinedpath arguments,undefinedversus a removed key, leading-zero indices.(1) and (2) are worth a maintainer decision — they are real semantic differences between shipped implementations, not test artifacts. So are the three withheld cases above.
CI
flutter_packages_test.ymlgains adart_packagesjob. Thedart/packages were not built, analyzed, or tested by any workflow before this, so the new tests would never have run.Verification
All run locally:
a2ui_core: format,dart analyze --fatal-infos,dart testa2ui_agent: format,dart analyze --fatal-infos,dart testFormatting and analysis were re-run with Dart 3.13.1, the SDK CI uses via Flutter stable 3.47.1, since the formatter differs from 3.12.2.
|
web_core:yarn build,yarn test,yarn lint| 336 passed, 0 lint errors ||
conformance:pytest(suite self-validation) | 10 passed || Python agent SDK conformance harness | 207 passed |
|
yarn build:all,yarn lint:all| pass, 0 errors ||
prettier --check .| clean ||
scripts/fix_licenses.py --check| clean |Follow-ups
Prompt generation, response parsing and streaming, capability negotiation, the EXPRESS grammar, and
A2uiValidator's deep checks. Each has skipped tests describing the target behaviour.