Skip to content

Define the Dart a2ui_agent API and its tests, limited to protocol v0.9 - #2408

Draft
polina-c wants to merge 2 commits into
mainfrom
dart-a2ui-agent-api-and-tests
Draft

Define the Dart a2ui_agent API and its tests, limited to protocol v0.9#2408
polina-c wants to merge 2 commits into
mainfrom
dart-a2ui-agent-api-and-tests

Conversation

@polina-c

@polina-c polina-c commented Aug 26, 2026

Copy link
Copy Markdown
Collaborator

Implements the API surface described by a2ui_agent.blueprint.md for 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 marked skip: with a reason, so dart test doubles 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; FunctionApi and FunctionImplementation are separated; BundledCatalogProvider is 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 omit version. That gate is real, not stubbed — A2uiProtocolVersion.fromJson throws A2uiValidationError, and A2uiMessage.fromJson, Catalog.fromJson, A2uiRendererCapabilities.fromJson and A2uiValidator all route through it.

dart/a2ui_agent — the API

Layout mirrors blueprint §2.

Area Types State
Facade A2uiGenerator, A2uiRequestProcessor agentCapabilities real; createProcessor, promptSnippet, parseResponse, validateExamples stubbed
Catalogs CatalogConfig, CatalogProvider, FileSystemCatalogProvider, InMemoryCatalogProvider real
Transformers CatalogTransformer, ComponentPruningTransformer, FunctionPruningTransformer real
Parsing Parser, TextPart, RawA2uiPart, A2uiPart, RawResponsePart parts and Parser.parseResponse real; format parsers stubbed
Formats InferenceFormat(Factory), DirectJsonFormat, ExpressFormat wiring real; prompt generation, compile/decompile, streaming stubbed
Negotiation resolveCatalogs stubbed

BundledCatalogProvider is deliberately absent — nothing needs to ship with the SDK.

dart/a2ui_core — what moved here, and why

Per 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 with CatalogFunction (signature only), renderers with FunctionImplementation. SchemaCatalog aliases the agent shape.
  • Catalog.fromJson / catalogSchema / copyWith, plus schema-only CatalogComponent and CatalogFunction. Catalog documents round-trip through core, and a pruned catalog renders a pruned document with $defs/anyComponent and $defs/anyFunction narrowed to match — so the transformers stay trivial and the schema knowledge lives in one place.
  • A2uiRendererCapabilities, mirroring client_capabilities.json and web_core's A2uiClientCapabilities.
  • A2uiValidator, with the version gate implemented and the structural / catalog-schema checks declared but stubbed.
  • A2uiProtocolVersion and the A2uiParseError / A2uiCompileError / A2uiCatalogError / A2uiIntegrityError / A2uiRecursionError categories.

One bug fixed

DataModel.set silently dropped a write whose parent path resolved to a primitive (/user/name/first where /user/name is a string). It now throws A2uiDataError, matching web_core. The shared dataset surfaced this.

conformance/ — shared data

Tests 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_request action): the blueprint's primary use case end to end — negotiate, prompt, parse — plus three rejection cases.
  • core/data_model.yaml (new, data_model action): 35 cases migrated from renderers/web_core/src/v0_9/state/data-model.test.ts.
  • Basic-catalog cases added to core/catalog.yaml, agent/parser.yaml and agent/inference_format.yaml.
  • conformance_schema.json gains the two actions and a DataError category. 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.md as gaps rather than disagreements:

  • 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.

The new process_request action 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:

  1. Notification on an unchanged value. Dart notifies observers unconditionally (a mutable container can change in place without changing identity); web_core copies containers on read and notifies only on a real change. Visible when the root is replaced.
  2. List index caps. Dart lists are dense, so /items/999999999 must be rejected; JavaScript arrays are sparse, so the same write is cheap and allowed.
  3. Plus the expected language-level ones: prototype-pollution guards, null/undefined path arguments, undefined versus 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.yml gains a dart_packages job. The dart/ packages were not built, analyzed, or tested by any workflow before this, so the new tests would never have run.

Verification

All run locally:

Check Result
a2ui_core: format, dart analyze --fatal-infos, dart test 146 passed, 8 skipped
a2ui_agent: format, dart analyze --fatal-infos, dart test 104 passed, 217 skipped

Formatting 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.

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.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +62 to +69
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}');
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

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.

Suggested change
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}');
}

Comment on lines +61 to +65
inlineCatalogs: [
if (rawInline is List)
for (final Object? catalog in rawInline)
Catalog.fromJson((catalog! as Map).cast<String, Object?>()),
],

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

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,
              ),
      ],

Comment on lines +305 to +311
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]),
};
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

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.

Suggested change
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant