Skip to content

Migrate package:genui onto package:a2ui_core (state engine + messages)#974

Merged
andrewkolos merged 77 commits into
flutter:mainfrom
andrewkolos:genui-811-minimal
Jun 25, 2026
Merged

Migrate package:genui onto package:a2ui_core (state engine + messages)#974
andrewkolos merged 77 commits into
flutter:mainfrom
andrewkolos:genui-811-minimal

Conversation

@andrewkolos

@andrewkolos andrewkolos commented Jun 16, 2026

Copy link
Copy Markdown
Collaborator

Advances #811. Supersedes #956.

Migrates package:genui's runtime onto the shared, pure-Dart package:a2ui_core: protocol parsing, message processing, and surface/component/data-model state. SurfaceController becomes a thin wrapper over a2ui_core.MessageProcessor; genui no longer maintains its own parallel A2UI runtime.

The catalog-widget authoring API is unchanged; only the A2UI message types move onto a2ui_core. Unifying the catalog/component types with the core models is a separate follow-up (#801) that also changes the authoring API.

Breaking changes

  • The a2ui_core message types are now used over genui's bespoke ones. The genui message classes (A2uiMessage, CreateSurface, UpdateComponents, UpdateDataModel, DeleteSurface) are removed. SurfaceController.handleMessage, Transport.incomingMessages, and A2uiMessageEvent.message use core.A2uiMessage. Construct with core.CreateSurfaceMessage(...) etc.; core.UpdateComponentsMessage carries raw component JSON maps. Message-handling consumers should depend on a2ui_core directly.
  • SurfaceController.store / DataModelStore removed. Access a surface's data model via SurfaceController.contextFor(id).dataModel.
  • SurfaceRegistry.updateSurface(...) removed. Surface lifecycle flows through SurfaceController.handleMessage.

Behavioral changes inherited from a2ui_core

  • DataModel writes are stricter (errors on type-mismatched intermediate paths and very large list indices; sparse list writes fill skipped entries with null).
  • A duplicate createSurface for an active surface id is now an error.

Full list in packages/genui/CHANGELOG.md. Consumer-facing details migration_0.9.1_to_0.10.0.md.

Note for reviewers

The significant changes are found across less than 10 files. Everything else is mechanical renames and tests.
Some files of note:

  • packages/genui/lib/src/engine/surface_controller.dart: MessageProcessor delegation, handleMessage(core.A2uiMessage), and buffering of messages that arrive before their target surface is created.
  • packages/genui/lib/src/model/data_model.dart: InMemoryDataModel over core.DataModel; _SignalNotifier exposes core signals as ValueListenables.
  • packages/genui/lib/src/model/ui_models.dart: SurfaceDefinition / Component snapshots; SurfaceUpdate events.
  • packages/genui/lib/src/engine/surface_registry.dart: surface lifecycle.
  • packages/genui/lib/src/widgets/surface.dart: definition-snapshot render path.
  • packages/genui/lib/src/model/a2ui_message.dart: now just the catalog-parameterized message schema (the message classes are gone).
  • packages/genui/lib/src/model/schema_validation.dart, widget_utilities.dart.

…e JSON

Four substrate-level changes the genui facade migration depends on or
benefits from. Bundled because they're all isolated to a2ui_core and
each is small enough to review at the same sitting.

1. messages.dart: parse-time envelope validation.
   - Reject envelopes missing the `version` field or carrying a version
     other than 'v0.9' (was: accepted anything).
   - Reject envelopes that contain more than one action key
     (createSurface/updateComponents/updateDataModel/deleteSurface) —
     the v0.9 schema models these as `oneOf`, but the old code silently
     dispatched on whichever check matched first.
   - UpdateDataModelMessage gains a `hasValue` field plus a
     `.removeKey` named constructor. The wire distinction between
     `"value": null` (set to null) and an omitted `value` key (remove
     the key / sparse-clear a list index) now round-trips losslessly.
     Runtime DataModel.set behavior is unchanged; the distinction only
     matters for observers/relayers that inspect the message.

2. data_model.dart: defensively copy incoming Map/List values.
   Wraps every container the caller hands to DataModel (constructor
   `initialData` and `set()` writes) with a deep mutable copy so later
   nested writes don't blow up when callers passed `const` literals or
   otherwise-unmodifiable views. Test added covering the
   "set('/') of a const map, then set('/nested/count')" path.

3. data_path.dart: drop RFC 6901 `~0`/`~1` escape interpretation.
   The web_core reference renderer never implemented escaping despite
   the v0.9 spec citing RFC 6901; a2ui_core was the only implementation
   diverging. Aligns Dart with web_core. Affects only keys containing
   literal '~' or '/' — extremely rare in data model usage.

4. reactivity.dart: add `effect` to the re-exported symbols.
   preact_signals provides `effect()` as a one-shot wrapper for
   `Effect(fn)()`; exporting it lets consumers avoid the
   `Effect()..call()` ceremony at the call site.
… facade)

Wraps a2ui_core.DataModel inside genui's existing DataModel/InMemoryDataModel
public API. Adds a2ui_core to the genui pubspec. Preserves the existing
GenUI surface (DataPath, ValueListenable subscriptions, getValue<T>,
update(DataPath, value), bindExternalState, DataModelTypeException) so
no consumer rename is required.

Substantive pieces inside this commit:

1. data_model.dart: InMemoryDataModel becomes a compat facade.
   - Constructor `InMemoryDataModel()` constructs a fresh core.DataModel
     and owns it. `InMemoryDataModel.wrap(core.DataModel)` is @internal
     and lets the controller share a single live model with the surface.
   - update(DataPath, value) -> core.set(path.toString(), value).
   - subscribe<T>(DataPath) returns a _SignalNotifier — a ChangeNotifier
     backed by a preact_signals effect over core.watch<Object?>. The
     effect forces a notification on every source change (including
     `==`-equal values) so in-place Map/List mutations propagate.
   - getValue<T>(DataPath) preserves the typed-read throw via
     DataModelTypeException so legacy callers keep their type check.
   - bindExternalState is preserved as both instance method and a
     top-level helper.
   - DataContext keeps its existing public surface (subscribe,
     subscribeStream, getValue, update, nested, resolvePath, resolve,
     evaluateConditionStream). Internally it routes through the
     wrapped DataModel.

2. widget_utilities.dart: Bound* widgets rewritten on preact_signals
   internally, but the constructor parameters and builder signature are
   unchanged. Two non-obvious wrinkles preserved from this branch's
   earlier exploration:
   - BoundValue uses a signal+listener bridge (not core.computed)
     because computed gates downstream notifications on `==` equality,
     which for Maps/Lists is identity-based — so a2ui_core's bubble
     notifications on in-place mutations would be silently dropped.
     The bridge force-mirrors the source value on every change.
   - _SignalBuilder is a small private StatefulWidget that subscribes
     to a ReadonlySignal via preact_signals' effect(). signals_flutter
     is NOT compatible (different signals package).

3. client_function.dart: ExecutionContext interface picks up the
   compat-facade signatures (`Object` for path args so callers can
   pass either DataPath or String). format_string.dart drops a couple
   of redundant DataPath constructors at call sites.

4. test/widgets/widget_utilities_test.dart: regression for the
   BoundObject/BoundList in-place mutation case — without the bridge
   fix, these would silently miss bubble-notifications.

Test/dev_tools/example fallout from this commit is minimal because
the public API shape is unchanged.
…n facade

Surfaces internally become live core.SurfaceModel instances managed by
core.SurfaceGroupModel, with the existing SurfaceDefinition / Component
public API preserved as a snapshot facade. GenUI's own renderer reads
the live model for granular per-component rebuilds; external code that
implements SurfaceContext keeps using the legacy snapshot API.

Substantive pieces:

1. surface_registry.dart owns the live core.SurfaceModel for each
   active surface and exposes both:
   - watchSurface(id): ValueListenable<core.SurfaceModel?> (live)
   - watchDefinition(id): ValueListenable<SurfaceDefinition?> (snapshot)
   The latter materializes a SurfaceDefinition.fromCore lazily when
   listeners actually read it, and re-materializes on per-component
   updates. removeSurface only nulls the notifier values — the
   notifiers stay alive across delete so re-create-with-same-id finds
   widgets still attached.

2. interfaces/surface_context.dart exposes the legacy
   `definition: ValueListenable<SurfaceDefinition?>` plus a new
   @internal LiveSurfaceContext extension that adds
   `surface: ValueListenable<core.SurfaceModel?>`. External
   SurfaceContext implementations only need to provide the legacy
   `definition` snapshot path; GenUI's own controller provides both.

3. widgets/surface.dart has two render paths:
   - _buildLiveWidget: used when the context is LiveSurfaceContext.
     Wraps each rendered component in a _ComponentBuilder that
     subscribes to that component's onUpdated, so an UpdateComponents
     for one component rebuilds only its subtree (flutter#811 §3.5).
   - _buildWidgetFromDefinition: used when the context only provides
     the legacy snapshot path. Rebuilds from the snapshot on every
     definition change (pre-flutter#811 behavior).
   This dual path is the cost of the compat-facade strategy. Custom
   SurfaceContext implementations stay on the legacy path; granular
   reactivity is opt-in by implementing LiveSurfaceContext.

4. ui_models.dart: SurfaceDefinition gains a .fromCore(SurfaceModel)
   factory and keeps its validate(Schema), copyWith, asContextDescription
   methods. SurfaceUpdate.{SurfaceAdded,ComponentsUpdated} carries
   both the live `surface` and a lazy `definition` snapshot —
   lifecycle-only listeners pay nothing.

5. engine/data_model_store.dart is kept as a compat facade. Its
   lookup callback now redirects to InMemoryDataModel.wrap of the live
   surface.dataModel for active surfaces, falling back to standalone
   models when no live surface exists. Carries an explicit dartdoc
   marking it as compatibility-only.

6. model/parts/ui.dart's UiPart.create accepts either SurfaceDefinition
   or a raw JsonMap as `definition`. Parsing always re-materializes a
   SurfaceDefinition snapshot so consumers don't see the wire-shape
   difference.

7. Legacy SurfaceDefinition.validate(Schema) is preserved.

Tests covering surface lifecycle live in commit 4 with their
controller/registry counterparts.
…ssor

SurfaceController becomes a thin Flutter-side wrapper around
core.MessageProcessor. The substrate owns canonical state mutation
(create/update/delete surfaces and their components/data models);
this class adds the Flutter-specific concerns: pre-create message
buffering, schema validation, a SurfaceUpdate stream the facade
subscribes to, and the GenUI-named A2uiMessage facade.

Substantive pieces:

1. engine/surface_controller.dart: rewrite.
   - Public `handleMessage(A2uiMessage)` accepts the genui-facade
     message type. It converts to core via `message.toCoreMessage()`
     and delegates to a private `_handleCoreMessage(core.A2uiMessage)`.
     The buffered/flushed path uses the private method directly so
     no double-conversion happens.
   - Holds a core.MessageProcessor built from each catalog's
     `coreCatalog` adapter; subscribes to its
     groupModel.onSurfaceCreated/onSurfaceDeleted and forwards them
     to the SurfaceRegistry.
   - Pre-create buffering: UpdateComponents/UpdateDataModel that
     arrive before their createSurface are held in a per-surfaceId
     queue with `pendingUpdateTimeout` cleanup, then flushed when
     the surface is created.
   - Lenient unknown-catalogId behavior is preserved (registers an
     empty stub core.Catalog so the substrate's "not found" check
     passes; the genui Surface widget surfaces FallbackWidget at
     render time as before).
   - Schema validation runs after each UpdateComponents via the new
     shared schema_validation helper (item 5 below). Mutation is not
     rolled back on failure — the error is reported and the caller
     decides.

2. model/a2ui_message.dart: GenUI message facade classes.
   `A2uiMessage`, `CreateSurface`, `UpdateComponents`,
   `UpdateDataModel`, `DeleteSurface` each wrap the corresponding
   core message. `.fromJson(JsonMap)` parses via core then converts;
   `.fromCore(core.A2uiMessage)` adapts an existing core message;
   `.toCoreMessage()` converts back. `UpdateDataModel` preserves
   the value-omitted vs value-null wire distinction via a
   `hasValue` flag + `.removeKey` named constructor matching
   a2ui_core's new shape.

3. model/catalog.dart: `coreCatalog` extension exposes a
   core.Catalog<core.ComponentApi> view of a genui Catalog so the
   substrate has real component metadata for the processor.
   Functions and theme are not adapted yet — Node Layer (#1282)
   will redo this boundary; until then catalog widgets resolve
   functions through the genui DataContext, not the core one.

4. model/catalog_item.dart: CatalogItemContext keeps both the
   legacy public unnamed constructor (which builds a stand-alone
   substrate context internally) AND an @internal fromCore
   constructor used by the live render path. `withOverrides({...})`
   is the rebind-callbacks-while-preserving-substrate-context
   pattern Catalog.buildWidget uses.

5. model/schema_validation.dart (new): shared validator extracted
   from both SurfaceDefinition.validate and the controller's
   post-mutation validation. Takes an iterable of
   `({id, type, json})` records plus the catalog Schema; both
   call sites adapt their representation into the iterable.

6. transport/a2ui_parser_transformer.dart: routes through
   core.A2uiMessage.fromJson then wraps the result in the facade
   A2uiMessage. Envelope detection (the `_looksLikeA2uiEnvelope`
   helper) treats `version` as an envelope marker, so a payload
   like {"version":"v0.9","unknownAction":{}} is surfaced as a
   validation error rather than falling through to TextEvent.

7. New regression tests:
   - data_model_edge_cases_test.dart: covers the
     mutable-copy-of-const-Map case the substrate fix in commit 1
     introduced.
   - surface_controller_test.dart: store-facade test, duplicate
     createSurface, pre-create dataModel access.
   - a2ui_message_test.dart: facade round-trip cases.
   - core_widgets_test.dart: minor accommodation for the new
     contextFor(...)/dataModel path.
Short guide for consumers. The substrate moved into a2ui_core but
the existing GenUI public API names are preserved as compat facades,
so most catalog widget authors and example apps need no source
changes. The guide:

1. Lists what stays source-compatible: CreateSurface, DataPath,
   DataModel, InMemoryDataModel, SurfaceDefinition, Component,
   SurfaceContext.definition, SurfaceController.store / DataModelStore,
   ActionDelegate.handleEvent's existing signature,
   CatalogItemContext public fields, UiPart.create(definition:
   SurfaceDefinition(...)).

2. Documents what changed internally: SurfaceController delegates
   to MessageProcessor; InMemoryDataModel wraps core.DataModel;
   the Surface widget uses live core models for granular rebuilds
   when its context is GenUI's own controller context, and falls
   back to the legacy snapshot path for custom SurfaceContexts.

3. Lists residual behavior changes to watch for: stricter DataModel
   writes, defensively-copied stored containers, signal-backed
   reactivity inside Bound*, stricter envelope validation, duplicate
   createSurface as an error, RFC 6901 escapes no longer interpreted.

4. Notes that the GenUI-named compat types are the current public
   API, not short-lived shims. A future PR may add a2ui_core-shaped
   aliases for cross-language parity, but the existing names will
   not be removed without a separate deprecation cycle.
…akage

Pre-create writes to `SurfaceContext.dataModel` were lost when the live
surface arrived (the fallback `InMemoryDataModel` stayed shadowed by the
new live wrapper). `DataModelStore` now exposes `attachLive(id, model)`,
called from `_onCoreSurfaceCreated`, which snapshots any fallback data
into the live core model and disposes the fallback.
`_ControllerContext.dataModel` is routed through the store so post-create
re-fetches see the migrated data.

Marks `SurfaceRegistry`, `SurfaceAdded.surface`, and
`ComponentsUpdated.surface` `@internal` so the public consumer surface
stays GenUI-typed. Updates the migration guide to call out the small set
of integrator-facing APIs that still expose `a2ui_core.SurfaceModel`.

Adds tests covering pre-create dataModel access, fallback->live data
migration on createSurface, and facade-level UpdateDataModel
toCoreMessage/fromCore round-trip preservation of the explicit-null vs
omitted distinction.

Adds CHANGELOG entries to both packages.
- _onCoreSurfaceCreated: migrate fallback data via store.attachLive
  BEFORE registry.addSurface notifies listeners, so a synchronous
  ValueListener callback that calls contextFor.dataModel sees the
  populated live model rather than racing the migration.
- DataModelStore.getDataModel: check the _liveDataModels cache before
  invoking the lookup callback. Avoids redundant wrap calls and avoids
  the race where a getDataModel call cached one wrapper just before
  attachLive cached another.
- DataModelStore.attachLive: drop the null-snapshot guard so a pre-create
  `update(root, null)` is preserved across createSurface. Adds a test.
- Revert the genui.dart hide of SurfaceRegistry. SurfaceRegistry existed
  as a public type pre-migration; hiding it would be an unrelated source
  break. The new core-typed `surface` fields on SurfaceAdded /
  SurfaceUpdated remain `@internal`.
- Migration guide: add an explicit Scope section listing the deferred
  follow-ups (catalog widget bodies, action dispatch, sendDataModel,
  GenericBinder, typed-props), the flutter#938 dependency for the
  hasValue/remove runtime split, and the A2UI#1499 RFC 6901 tracking.
`SurfaceRegistry.watchSurface` and `getSurface` are reverted to their
pre-migration return types (`SurfaceDefinition`-typed), and `SurfaceAdded`
/ `SurfaceUpdated` regain their `definition: SurfaceDefinition` field.
Existing consumers of those names see no signature change.

Live core access moves to new `@internal` siblings: `watchLiveSurface`,
`getLiveSurface`, and the existing `.surface` field on the registry
events. The migration guide is updated to reflect the actual set of
internal-marked APIs.

Internal callers (the lookup-callback in `DataModelStore`,
`_findCatalogForSurface`, and `LiveSurfaceContext.surface`) move to the
new live methods.
`SurfaceAdded` and `SurfaceUpdated` regain their pre-migration public
constructor signature `(surfaceId, SurfaceDefinition)`. The live core
surface is moved to an `@internal SurfaceAdded.fromCore` /
`SurfaceUpdated.fromCore` named constructor used by `SurfaceRegistry`;
the `surface` field becomes nullable + `@internal` (null when constructed
via the public ctor, populated when emitted by the registry).

The controller's surfaceUpdates mapper unwraps `surface!` since registry-
emitted events always use `.fromCore`.

Adds a regression test exercising `watchSurface` / `getSurface` against
the `SurfaceDefinition` snapshot.
Same pattern as the registry-event compat fix, applied to the user-
facing `SurfaceAdded` and `ComponentsUpdated` events in
`packages/genui/lib/src/model/ui_models.dart`:

- Public ctor takes `(surfaceId, SurfaceDefinition)` (pre-migration shape),
  leaves `surface` null.
- `@internal SurfaceAdded.fromCore` / `ComponentsUpdated.fromCore` populate
  both the snapshot and the live `core.SurfaceModel`.
- `surface` field is now `core.SurfaceModel?` and `@internal`.
- The surfaceUpdates mapper in SurfaceController uses `.fromCore`.

Marks `SurfaceRegistry.addSurface` and `notifyUpdated` `@internal` (new
in this branch; only SurfaceController calls them). The pre-migration
`SurfaceRegistry.updateSurface(SurfaceDefinition)` is intentionally not
restored; the live-model registry can't honor the definition-only push
path without diverging from the substrate state. The removal is now
called out in the migration guide.

Adds a regression test for the public SurfaceUpdate ctors.
… updateSurface removal

- Adds `const` to public `SurfaceAdded` and `ComponentsUpdated`
  constructors to match the pre-migration shape (and the existing
  `SurfaceRemoved` ctor).
- Promotes the `SurfaceRegistry.updateSurface(...)` removal to a
  BREAKING bullet in the CHANGELOG. It is a pre-migration public API
  removal; preserving it would require diverging the registry from the
  live core surface model.
Per reviewer nit: "replacements" implied external users should reach for
them, which is the opposite of the @internal intent. Reads now as
"internal lifecycle hooks."
schema_validation.dart imported ui_models.dart only to throw
A2uiValidationException, while ui_models.dart imported schema_validation.dart
for SurfaceDefinition.validate. That cycle failed layerlens --fail-on-cycles.

A2uiValidationException is a cross-cutting error type thrown across the model,
engine, and transport layers, and ui_models.dart never used the one it defined.
Move it to a primitives leaf so schema_validation no longer depends on the model
layer and the graph becomes an acyclic DAG.

primitives.dart re-exports it, so package:genui consumers are unchanged.
handleUiEvent guarded on `event is! UserActionEvent`, but extension types
erase to their representation at runtime, so the check was always false and
every UiEvent was submitted to the AI as an action. Add a data-based
discriminator (UiEvent.isUserAction, keyed on the action `name`) and use it
in handleUiEvent and Surface._dispatchEvent, which had the same erased check.

Empty surfaceId was rejected only for CreateSurface; UpdateComponents,
UpdateDataModel, and DeleteSurface with an empty id buffered under "" until
timeout. Reject an empty surfaceId on any message that carries one.

Regression tests cover both.
Add a widget test for live per-component rebuilds (the migration's headline
behavior) and a test for the new duplicate-createSurface error. The migration
nets a deletion of well-tested bespoke code, which lowered packages/genui
aggregate line coverage, so re-mark the high-water baseline from 79.71% to its
post-migration value (79.38%).
Remove the live per-component render path from Surface: the _ComponentBuilder,
the LiveSurfaceContext render subscription, and the component create/delete
listeners. The widget now always rebuilds from SurfaceContext.definition, which
the registry keeps current on every create/update; data changes still propagate
through the Bound* widgets. Each component widget is keyed by id to preserve
widget identity across rebuilds, which the live builder previously supplied.

The live core.SurfaceModel remains tracked for the data adapter; only the
render-path subscription is removed.
Delete the genui-side A2UI message facade classes (A2uiMessage, CreateSurface,
UpdateComponents, UpdateDataModel, DeleteSurface). Production code now passes
a2ui_core message types straight through: handleMessage, the transport
interface/adapter, the parser, and A2uiMessageEvent all use core.A2uiMessage.
The only genui-specific piece retained from the old file is the
catalog-parameterized message schema builder (a2uiMessageSchema).

The parser preserves the friendly "version must be v0.9" error message.

Tests build core messages via a small test-only helper
(test/test_infra/message_builders.dart) that mirrors the old named-argument
shape; component setup uses raw wire JSON. The facade-only message test is
removed (those types no longer exist; a2ui_core covers them).

Downstream consumers (genui_a2a, examples, dev_tools) are not yet migrated.
…es directly

The A2UI message types are renderer-agnostic (pure-Dart a2ui_core), so message-
layer consumers depend on a2ui_core directly rather than reaching through the
Flutter renderer, mirroring how the React/Lit renderers consume web_core.

- genui_a2a: agent connector stream + parsing use core.A2uiMessage.
- catalog_gallery: sample parser/view + tests use core message types
  (components are raw wire maps now: component['component'], not Component.type).
- verdure: ai_provider matches core.CreateSurfaceMessage.

Adds a2ui_core as a direct dependency of each.
DataModelStore existed only to preserve the legacy SurfaceController.store API;
nothing outside genui used it. Inline its small pre-create buffering (a writable
model handed out by contextFor(id).dataModel before the surface exists, migrated
into the live core model on creation) directly into SurfaceController as private
state, and drop the public `store` getter, the class, and its barrel export.

Post-create, data models come from the live core surface; the dead
attachSurface/detachSurface bookkeeping is gone.
…gration

The 0.10.0 entry described the abandoned "all public types preserved as
facades" approach. Update it to reflect reality: A2UI message types and
DataModelStore are removed (not facaded), with the breaking changes spelled
out; SurfaceDefinition/Component are noted as the retained snapshot types
pending flutter#801. Add the matching breaking-change entry to genui_a2a.
The guide described the abandoned facade approach: kept message classes, a
SurfaceController.store facade, and granular per-component rebuilds. Rewrite it
around what actually ships: A2UI messages and DataModelStore are removed (with
before/after code), the catalog authoring API + surface/data-model snapshots are
the retained types pending flutter#801, and the renderer rebuilds from the
SurfaceDefinition snapshot.
The data-substrate migration widened the catalog dataContext path parameters
(ExecutionContext / DataContext subscribe, subscribeStream, getValue, update,
nested, resolvePath, and the constructor) from DataPath to Object, with a
runtime _toDataPath('$path') converter, to also accept raw string paths.

main typed these as DataPath, every caller already passes DataPath(...), and the
widening was used by exactly two test lines. It dropped compile-time checking
and loosened the frozen catalog authoring API. Restore DataPath and remove the
converter.
The live per-component render path was removed earlier; this drops the
scaffolding that only existed to feed it:

- LiveSurfaceContext (and its surface getter) and SurfaceRegistry.watchLiveSurface
  had no remaining readers.
- The @internal `surface` fields on SurfaceAdded/ComponentsUpdated were
  write-only; fromCore still snapshots the definition from the core surface.
- SurfaceRegistry._surfaces is now a plain map instead of a ValueNotifier map
  (nothing watches it; getLiveSurface still reads it for the data model).

Migration guide updated to drop the removed @internal live-surface APIs.
The migration guide documented internals consumers never touch: the
controller-only updateSurface, @internal lifecycle hooks, the internal wrap/
snapshot mechanics, signal backing, mutable-copy storage, and the rendering
strategy. Cut all of it. The guide now covers only what a consumer changes
(message types, store) or observes (stricter DataModel/validation, duplicate-
create error, pointer escapes, null-removes-key).

Also drop a stale CHANGELOG entry that still described the SurfaceAdded/
ComponentsUpdated @internal `surface` fields, which were removed, and trim the
updateSurface entry's internal rationale.
These two genui consumers were missed in the message-layer migration. Add the
a2ui_core dependency and use core.A2uiMessage types directly; in composer,
replace the removed SurfaceController.store with contextFor(id).dataModel.
Import message types unprefixed via `show` rather than `as core`; they
don't collide with anything genui exports. Scope the message section to
custom-transport and direct message construction, and demote the niche
build/parse details. Simplify the DataModel strictness note.
The hasValue flag and removeKey constructor preserved a wire distinction
(explicit value:null vs absent value) that nothing consumes; the processor
collapses both to a key removal. fromJson always reads value, toJson always
emits it.
Drop implementation detail, roadmap notes, and spec cross-references that
belong in a PR description rather than a library changelog.
Match the migration_<from>_to_<to>.md convention of the existing guides;
retitle to "0.9.1 to 0.10.0" and update the CHANGELOG link.
@andrewkolos

Copy link
Copy Markdown
Collaborator Author

/gemini review

@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 refactors package:genui to run on the shared package:a2ui_core runtime. Key changes include migrating A2UI message types to a2ui_core, removing DataModelStore and SurfaceController.store in favor of accessing data models via SurfaceController.contextFor(id).dataModel, and updating dependent packages, examples, and tests.

Several improvement opportunities were identified during the review:

  • In surface_editor.dart, the old _dataModelNotifier should be explicitly disposed before creating a new subscription to prevent memory leaks.
  • In catalog.dart, a public effectiveCatalogId extension should be added to allow SurfaceController to correctly match inline catalogs.
  • In surface_registry.dart, removeSurface should set notifier.value = null before disposal to satisfy the contract of watchSurface and notify active builders.
  • In schema_validation.dart, validateComponents should directly use schema.value instead of performing expensive JSON serialization and parsing on every validation.

Note: Security Review did not run due to the size of the PR.

Comment thread dev_tools/composer/lib/surface_editor.dart
Comment on lines +287 to +293
core.Catalog<core.ComponentApi> coreCatalogFor(Catalog catalog) =>
core.Catalog<core.ComponentApi>(
id: catalog.catalogId ?? catalog._inlineCatalogId,
components: catalog.items
.map<core.ComponentApi>(_CatalogItemComponentApi.new)
.toList(growable: false),
);

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.

high

When catalogId is null (for inline catalogs), _inlineCatalogId is used to register the catalog in a2ui_core. However, since _inlineCatalogId is private to catalog.dart, other files like surface_controller.dart cannot access it to match the catalog with surface.catalog.id.\n\nAdding a public CatalogIdExtension with effectiveCatalogId allows SurfaceController to correctly match and find inline catalogs.

core.Catalog<core.ComponentApi> coreCatalogFor(Catalog catalog) =>\n    core.Catalog<core.ComponentApi>(\n      id: catalog.effectiveCatalogId,\n      components: catalog.items\n          .map<core.ComponentApi>(_CatalogItemComponentApi.new)\n          .toList(growable: false),\n    );\n\nextension CatalogIdExtension on Catalog {\n  /// The effective catalog ID, using [catalogId] if non-null, or a synthesized\n  /// ID if null.\n  String get effectiveCatalogId => catalogId ?? _inlineCatalogId;\n}

Comment thread packages/genui/lib/src/engine/surface_controller.dart
Comment thread packages/genui/lib/src/engine/surface_registry.dart
Comment thread packages/genui/lib/src/model/schema_validation.dart

@jacobsimionato jacobsimionato left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Hey overall, this looks miraculously non-disruptive for developers to me! Nice work!

My main question is whether you have any ideas about avoiding developers needing to add the core. import prefixes to resolve symbol conflicts temporarily. Probably there's nothing we can do, but it'd be amazing if we could avoid it. Could we have a separate top-level import file for the conflicting symbols perhaps?

I didn't hit LGTM yet just because I ran out of time and want to spend a bit more tomorrow.


import 'dart:async';

import 'package:a2ui_core/a2ui_core.dart' as core;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

What are the symbol conflicts that prevent importing both libraries? I think it's worth us making sure that they don't have conflicting symbols in the longer run. If there's some easy way to avoid it now, that would be nice to, avoid all our clients adding as core throughout their codebase then needing to remove it later.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

I think you've since found them, but for future reference, the collisions are: Catalog, DataModel, DataPath, DataContext, CancellationException, CancellationSignal, ExpressionParser, and FormatStringFunction

/// a [core.SurfaceModel] so `a2ui_core` lookups see real component metadata
/// instead of an empty stub.
@internal
core.Catalog<core.ComponentApi> coreCatalogFor(Catalog catalog) =>

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Ahhh this is where the symbol collisions happen! I wonder if there is something we can do here to avoid developers needing to add core. everywhere in their codebases temporarily, and the removing it when we resolve the conflicts.

I can't think of a better solution than what you've done though. Could we make Catalog implement core.Catalog<core.ComponentApi> somehow, rather than needing it to be converted?

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Is the plan to later refactor this to use SurfaceModel instead? I wonder if it could have an alternative constructor or something, so we can start checking in the new logic now without breaking things?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

I like the thinking, ActionDelegate.handleEvent1 already exposes SurfaceDefinition, so we'd still have to reconstruct that every time to avoid breaking component authoring API. I think per our discussion today, it would be more economical to get through a2ui-project/a2ui#1282 first.

Footnotes

  1. https://github.com/flutter/genui/blob/bb50a983c49216348a0de91751585f60e6cbcd6b/packages/genui/lib/src/widgets/surface.dart#L183

@andrewkolos andrewkolos marked this pull request as ready for review June 23, 2026 21:47
@andrewkolos

Copy link
Copy Markdown
Collaborator Author

My main question is whether you have any ideas about avoiding developers needing to add the core. import prefixes to resolve symbol conflicts temporarily. Probably there's nothing we can do, but it'd be amazing if we could avoid it.

I didn't consider this exhaustively. I pretty much always default to the aliasing import/as syntax whenever name collisions are involved`–it's more robust when working on frequently changing code and reduces ambiguity when reading code when the reader knows there are many name collisions in the current context1.

Could we have a separate top-level import file for the conflicting symbols perhaps?

Unless I'm missing something, isn't this equivalent to consumers switching to the selective show import syntax? Regardless, if you have a preference for show over as, I'm happy to reconsider more thoughtfully.

Footnotes

  1. Aside : a classic example from my personal experience is from game programming where libraries for physics, 3d rendering, computer vision, spatial audio, etc. all export primitive math types/functions like Vector3 etc.

@jacobsimionato jacobsimionato left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Overall, I like this light-touch approach of migrating models to start with and avoiding breaking things.

My main concern is not breaking functionality with each step. I tried the PR out locally, and it did seem like there were some issues with conversation flow etc which I couldn't replicate at head, so wondering if you can look into them before merging?

Do you mind trying out my party planning prompt and seeing if you see the same issues? It could be that my experience is just LLM noise and it's intermittently broken at head already.

Basically, if we can be confident this isn't breaking anything in an obvious way, then I'm keen to merge.

With PR

Issue with data model references shown to user

Image

Issue with conversation flow

The agent is not seeing the item that was chosen in the radio buttons for some reason. It didn't know that it's a graduation party.

Image

At head

I didn't see these issues, not sure if it's a fluke. The agent did know it's a graduation party though.

Image

/// Creates a [Surface].
const Surface({
super.key,
required this.surfaceContext,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Not for this PR: A good next step could be to update this so that it can accept a core SurfaceModel and render based on that, doing whatever conversions are necessary. I think the SurfaceModel should expose the necessary data, and allow us to delete SurfaceRegistry etc.

///
/// Orchestrates the lifecycle of UI surfaces, manages communication with the
/// AI service, and handles data model updates.
/// Wraps [core.MessageProcessor] and adds Flutter-side concerns: holding

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

BTW all this extra Flutter-specific logic to handle missing catalogs, and cache messages for surfaces that don't exist isn't necessary in the long run. If we want to add this functionality, we can add it to the unified core library design, or more likely to agent SDKs, to ensure they fix payloads before being sent to the client.

But I think it's awesome that this avoids breaking changes for now. I think we can remove this class later, once we add the node API and are ready to complete the migration.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Agreed. I Added a TODO to increase our chances of remembering to do this later.

}
}

class _CatalogItemComponentApi implements core.ComponentApi {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

I'm trying to think of how we can advance aspects of this towards the unified design as fast as possible with minimal confusion. What do you think about just making the genui CatalogItem and Catalog just inherit from the Core models directly, so no conversion is necessary?

This could be a followup PR, if you agree with the approach. Here's a proposal from Gemini - not sure if it has holes:

Design Document: Catalog Unification & Signal-Based Reactivity

1. Goal

Unify the genui and a2ui_core Catalog and Client Function APIs. By aligning genui components and functions directly with a2ui_core interfaces, we can:

  • Eliminate the translational/adapter layers (coreCatalogFor, _CatalogItemComponentApi).
  • Replace complex, resource-heavy Stream-based reactivity with lightweight, synchronous, and Signal-based updates using preact_signals.
  • Remove the redundant ExpressionParser in genui in favor of a2ui_core's native Expression and Data Pointer resolution.

2. Current Architecture & Bottlenecks

Component API Redundancy

genui uses CatalogItem to define a component, which is then mapped at runtime to core.ComponentApi via a helper _CatalogItemComponentApi inside coreCatalogFor. This translation happens during every surface creation.

Stream vs. Signal Impedance Mismatch

genui.ClientFunction is asynchronous and returns a Stream<Object?>:

abstract interface class ClientFunction {
  Stream<Object?> execute(JsonMap args, ExecutionContext context);
}

However, the underlying a2ui_core engine is built on Signals (preact_signals). This forces genui to:

  1. Wrap synchronous functions (e.g., and, or, regex, length) in Stream.value(...).
  2. Maintain a complex, recursive Stream-based ExpressionParser in genui to combine and evaluate multiple stream arguments.
  3. Convert streams back to signals/notifiers at the widget rendering layer (e.g., BoundString).

3. Proposed Unified Architecture

                 +-----------------------+
                 |   core.ComponentApi   |
                 +-----------+-----------+
                             ^
                             | (Implements)
                 +-----------+-----------+
                 |   genui.CatalogItem   |
                 +-----------------------+

             +-------------------------------+
             |  core.FunctionImplementation  |
             +---------------+---------------+
                             ^
                             | (Implements)
             +---------------+---------------+
             |     genui.ClientFunction      |
             +-------------------------------+

A. Unified Component API

CatalogItem will directly implement core.ComponentApi:

class CatalogItem implements core.ComponentApi {
  @override
  final String name;

  @override
  Schema get schema => dataSchema;

  final CatalogWidgetBuilder widgetBuilder;
  final bool isImplicitlyFlexible;
  final List<ExampleBuilderCallback> exampleData;
}

B. Signal-Based Client Functions

ClientFunction will directly implement core.FunctionImplementation, returning either a static value or a ReadonlySignal<Object?>:

abstract class ClientFunction implements core.FunctionImplementation {
  @override
  String get name;

  @override
  String get description;

  @override
  Schema get argumentSchema;

  @override
  core.A2uiReturnType get returnType;

  /// Executes synchronously. Returns a raw value, a ReadonlySignal, or null.
  @override
  Object? execute(
    Map<String, dynamic> args,
    core.DataContext context, [
    core.CancellationSignal? cancellationSignal,
  ]);
}

C. Direct Catalog Inheritance

genui.Catalog directly extends core.Catalog<CatalogItem>, eliminating adapter wrappers:

class Catalog extends core.Catalog<CatalogItem> {
  final List<String> systemPromptFragments;

  const Catalog(
    List<CatalogItem> components, {
    List<ClientFunction> functions = const [],
    required String super.id,
    this.systemPromptFragments = const [],
  }) : super(components: components, functions: functions);
}

4. Key Simplifications & Code Deletions

1. Simplify BasicFunctions

We can convert all basic functions to pure, synchronous logic returning static values. For example, AndFunction:

class AndFunction extends ClientFunction {
  @override
  Object? execute(Map<String, dynamic> args, core.DataContext context, [...]) {
    final values = args['values'];
    if (values is! List) return false;
    return values.every((v) => _isTruthy(v));
  }
}

2. Delete redundant parser

genui/lib/src/functions/format_string.dart and ExpressionParser can be completely removed. We can reuse the formatString function already present in a2ui_core/lib/src/processing/basic_functions.dart or keep a simplified version returning ReadonlySignal.

3. Streamlined Widget Data Binding

Widget-binding helpers (e.g., BoundString, BoundBool) can directly use the signal-backed values from the context since core.DataContext provides signal-based resolution (resolveListenable).


5. Migration Strategy

  1. Phase 1: Update Core Interfaces
    Change CatalogItem and ClientFunction to implement core.ComponentApi and core.FunctionImplementation.

  2. Phase 2: Migrate Call Sites & Standard Functions
    Rewrite the 14 standard functions in BasicFunctions to return static values or ReadonlySignals.

  3. Phase 3: Clean up rendering adapters
    Update SurfaceController to pass Catalog directly to core.MessageProcessor and remove coreCatalogFor.

  4. Phase 4: Delete stream parser
    Delete ExpressionParser and the stream_transform dependency. Update BoundValue widgets to listen to the signal notifier natively provided by the core layer.

@jacobsimionato

Copy link
Copy Markdown
Collaborator

My main question is whether you have any ideas about avoiding developers needing to add the core. import prefixes to resolve symbol conflicts temporarily. Probably there's nothing we can do, but it'd be amazing if we could avoid it.

I didn't consider this exhaustively. I pretty much always default to the aliasing import/as syntax whenever name collisions are involved`–it's more robust when working on frequently changing code and reduces ambiguity when reading code when the reader knows there are many name collisions in the current context1.

Could we have a separate top-level import file for the conflicting symbols perhaps?

Unless I'm missing something, isn't this equivalent to consumers switching to the selective show import syntax? Regardless, if you have a preference for show over as, I'm happy to reconsider more thoughtfully.

Footnotes

  1. Aside : a classic example from my personal experience is from game programming where libraries for physics, 3d rendering, computer vision, spatial audio, etc. all export primitive math types/functions like Vector3 etc.

Hey no worries, let's leave this, no need to think hard about it. Thanks so much for giving me more context.

@andrewkolos

Copy link
Copy Markdown
Collaborator Author

Thanks for all the additional feedback! I'll be going through each individually, but I can start with the quality regression quality concern since that's also the thing I want to get right here. I actually wasn't able to repro your report here, I experienced failure on both main and here on the 5+ attempts I tried (e.g. I would select "birthday" and the generated text did not acknowledge this in any way and the rest of the generation remained very generic, or I would get into an infinite loop where I kept getting asked for the party type).

At first glance, it doesn't even look like user input gets routed to the LLM at all. The submit button seems to only send up an empty context {"action":{"name":"submitPartyDetails","context":{}}}. The client data model isn't being included in the payload.

However, what I am completely confounded by is that when you ran the app on main, the text produced by the agent acknowledges that the party is for a graduation, it sees the date, it sees the number of guests. I don't see how. Regardless, I can fix this in this PR.

As an aside, I notice that earlier text in the conversation seems to be getting retroactively updated. Said another way, the agent is not acting with an append-only approach (or perhaps payloads are malformed). I think simple_chat has been buggy for quite a while, but I digress.

@jacobsimionato

Copy link
Copy Markdown
Collaborator

Hey thanks for bearing with me with investigating the regressions. As discussed over VC today, it seems like both of the issues I mentioned are actually not regressions and we were able to reproduce them at head. So let's merge this and move on! I can file bugs for the issues.

The a2ui_core migration replaced the removed store.getDataModel() with
contextFor().dataModel and added dispose() calls for the data-model notifier
in surface_editor.dart. Those lines land in files that have no test coverage,
so they raise composer's line count without adding hits and pull it from
20.49% to 20.29% (126/621 lines).

That stays above the package's 20.0% threshold but dips below the recorded
high-water mark, tripping the no-regression gate. composer is a dev tool with
a deliberately low threshold, so re-baselining is the appropriate response to
this unavoidable dilution rather than adding tests written only to raise the
number.
@andrewkolos andrewkolos merged commit 307471c into flutter:main Jun 25, 2026
39 checks passed
@andrewkolos andrewkolos deleted the genui-811-minimal branch June 25, 2026 04:37
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.

2 participants