Skip to content

feat(a2ui): add A2UI spec v0.9 rendering support alongside v0.8 - #517

Draft
lwangverizon wants to merge 7 commits into
google:mainfrom
lwangverizon:feat/a2ui-v09-support
Draft

feat(a2ui): add A2UI spec v0.9 rendering support alongside v0.8#517
lwangverizon wants to merge 7 commits into
google:mainfrom
lwangverizon:feat/a2ui-v09-support

Conversation

@lwangverizon

Copy link
Copy Markdown

Summary

adk-web can currently render A2UI spec 0.8 only. The renderer pins @a2ui/angular@^0.8.3, and both A2UI ingestion paths in chat.component.ts duck-type the 0.8 message kinds (beginRendering, surfaceUpdate, dataModelUpdate). An agent that emits 0.9 kinds (createSurface, updateComponents, updateDataModel, deleteSurface) is dropped silently — a2uiData is never populated, so nothing renders and no error surfaces anywhere.

This PR adds 0.9 support alongside 0.8, auto-detecting the spec version per payload, with no behavior change intended for existing 0.8 agents.

Motivating case: the staged-ui-widgets sample in adk-samples, which pins A2UI_VERSION = "0.9" and emits {"version": "v0.9", "createSurface": {...}}.

Note

Draft: the code is complete and CI-clean (678 unit tests green, ng build clean), but the manual browser verification in the checklist below has not been run yet. Marking ready once it passes. Design feedback very welcome in the meantime — particularly on the two questions at the bottom.

Approach

Version detection is per payload, not global. A new normalizeA2uiMessages in chat.component.ts prefers the explicit version discriminator and falls back to kind-key matching (the two versions' kind keys are disjoint, so this is unambiguous). Both ingestion paths — A2A DataParts and the inline <a2ui-json> text block — route through it.

The 0.8 renderer is not modified. A2uiCanvasComponent and its bindings are untouched; 0.9 gets a sibling A2uiCanvasV09Component and content-bubble.component.html dispatches on version. The two renderers have genuinely different lifecycles — 0.8's <a2ui-surface> needs a resolved [surface] object re-read from a Map on every ngOnChanges, while 0.9's <a2ui-v09-surface> takes only a surfaceId and resolves internally in ngOnInit — so sharing one component would have meant branching inside it. Keeping 0.8 literally unmodified is also the airtight guarantee against 0.8 regressions: its spec needed no edits.

Both renderers are always registered. No conditional provider gating; dispatch is payload-driven.

A2uiPayload.messages is an array because 0.9 legitimately sends N updateComponents/updateDataModel messages across several surfaces, which the existing three-slot last-wins object cannot represent. The three 0.8 aliases (beginRendering/surfaceUpdate/dataModelUpdate) are still populated for 0.8 payloads so the 0.8 render binding stays byte-identical.

Commits

Seven, each independently reviewable; the tree compiles and tests green at every step.

1 chore(deps)package.json only
2 chore(deps) — regenerated package-lock.json alone, so it can be collapsed
3 refactor(a2ui) — pin 0.8 imports to the /v0_8 subpath; repair 0.8 regressions from the bump
4 feat(a2ui)A2uiPayload + the normalizer. No rendering change yet
5 feat(a2ui) — the v0.9 canvas + main.ts providers. Not yet reachable from a template
6 feat(a2ui) — the content-bubble dispatch. This is the commit that turns the feature on; reverting it disables 0.9 without touching anything else
7 feat(a2ui) — the --a2ui-* theme block

Commit 3 going green is the evidence that the 0.9 bump did not break 0.8. Please don't squash 4 into 6 — the normalizer carries the highest 0.8-regression risk and is worth keeping bisectable on its own.

Dependencies

-    "@a2ui/angular": "^0.8.3",
+    "@a2ui/angular": "^0.9.1",
+    "@a2ui/lit": "^0.8.3",
+    "@a2ui/markdown-it": "^0.0.3",
  • @a2ui/angular@0.9.1 ships both versions via the ./v0_8 and ./v0_9 subpath exports, so this is additive rather than a migration. Peer is @angular/core ^21.2.0 and the lockfile already resolves 21.2.4 — no Angular bump.
  • @a2ui/lit@0.8.3 is promoted transitive → direct. 0.9 drops it, which would break a2ui-theme.ts's Styles import. Styles.merge cannot be hand-rolled to a spread: 8 of its 13 call sites merge different keys in the same color-c-* family, and a spread would leave two conflicting classes set.
  • @a2ui/markdown-it@0.0.3 is an optional peer, but TextComponent injects MarkdownRenderer unconditionally and DefaultMarkdownRenderer does a bare await import('@a2ui/markdown-it'), which esbuild errors on when undeclared.

@a2ui/web_core is deliberately not added. I verified with tsc that Parameters<A2uiRendererService['processMessages']>[0][number] resolves to the real A2uiMessage rather than degrading to any, so the direct dependency isn't needed.

⚠️ One 0.8 behavior change to flag

Commit 3 adds provideMarkdownRenderer() to main.ts. This is a 0.8 fix, not 0.9 setup: MarkdownRenderer is providedIn: 'root' but its base class has no render method, so after the dep bump every 0.8 Text component threw render is not a function at a2ui-angular-v0_8.mjs:1446. The explicit provider swaps in DefaultMarkdownRenderer, which has it.

The side effect is that 0.8 Text now renders real markdown through @a2ui/markdown-it (with DOMPurify) instead of the regex passthrough. I confirmed @a2ui/markdown-it loads rather than falling back. This is a 0.8 rendering change and it's on the verification checklist below, but reviewers who know the 0.8 rendering expectations should look at it directly.

Theming

v0.9 has no theme object. Unlike 0.8, which is themed in TypeScript through the Theme token, 0.9 injects no root stylesheet and defines no a2ui-light/a2ui-dark classes — every component styles itself with inline var(--a2ui-X, <fallback>), and all but a handful of those fallbacks are hardcoded light-mode values (#fff, #333, #ccc). Without commit 7, 0.9 surfaces are unreadable in adk-web's default dark theme, so it's a correctness fix rather than a cosmetic one.

Commit 7 defines the ~50 --a2ui-* properties as var(--mat-sys-*). Because var() resolves at use time, the layer is pure indirection: a theme flip repaints --mat-sys-*, which repaints --a2ui-*. No JavaScript, no new classes, one definition site — ThemeService and styles.scss are untouched. It's scoped to :host rather than a global stylesheet, which also avoids two angular.json edits and keeps --a2ui-* out of the global namespace of an app embedding the component.

Three findings from reading the compiled 0.9 CSS, each commented at its mapping:

  • The six --a2ui-font-size-* properties are mandatory. Upstream reads them as bare var(--a2ui-font-size-l) with no fallback, and an undefined custom property invalidates the whole declaration — every heading would silently drop to the browser default.
  • --a2ui-color-on-secondary is the foreground of the default button, over an --a2ui-color-surface background. It maps to --mat-sys-on-surface; the intuitive --mat-sys-on-secondary-container gives low-contrast text.
  • Two spots can only be reached from outside, via ::ng-deep: .a2ui-button:disabled hardcodes #e9ecef/#6c757d with no custom property, and the 0.9 CheckBox is a bare <input type="checkbox"> without appearance: none, so its --a2ui-checkbox-background/-border variables are inert and it renders in the browser's own light-mode chrome. accent-color is the one property a native checkbox honours. Both overrides should be deleted once upstream tokenizes them.

📌 Integration gotcha for agent authors

catalogId is required on 0.9 createSurface and must equal https://a2ui.org/specification/v0_9/basic_catalog.json, or surface creation fails silently — a console.warn and an empty div. This is the single most likely thing to trip up an agent author.

Because that failure mode is invisible, chat.component.spec.ts wires the real 0.9 provider graph rather than a stub, and asserts console.error was not called on the happy path. That's what converts the silent degradation into a test failure.

Tests

678 passing, up from 643 on main; ng build clean.

  • chat.component.spec.ts — 15 table-driven detectA2uiVersion cases (envelope discriminator, kind-key fallback both ways, {}, unknown key, null, a string); a multi-message 0.9 fixture on both ingestion paths; <a2ui-json>[]</a2ui-json> leaves a2uiData undefined and still strips the text; a mixed-version batch warns.
  • a2ui-canvas-v09.component.spec.ts (new) — dispatch and surfaceId derivation; no change record → no call; the same array reference twice → exactly one call; surfaceId derived from updateComponents alone (the cross-bubble streaming case); a redundant createSurface skipped so a history reload doesn't throw Surface already exists; a failed message contained without abandoning the rest of the batch; and the @if gate.
  • content-bubble.component.spec.ts (new) — there was no spec for this component, and it's the file that catches a mis-dispatch. 0.8 → app-a2ui-canvas only; 0.9 → app-a2ui-canvas-v09 only; absent a2uiData → neither; untagged → falls back to 0.8.

Two notes on deliberate test choices:

  • The @if (surfaceId()) gate in the 0.9 canvas template is load-bearing, not cosmeticComponentHostComponent.ngOnInit looks its surface up once and, on a miss, warns and never retries (a missing component, by contrast, recovers via componentsModel.onCreated). The gate guarantees createSurface has been processed before the child mounts, and there's a test for it.
  • 0.9 fixtures are plain objects checked structurally, not as unknown as <Type> casts. The 0.8 spec uses those casts in four places, which erases all compile-time checking.

chat-panel.component.spec.ts's A2UI test stays xit, now with a comment explaining why: chat-panel doesn't own the canvas (the chain is chat-panel → event-row → event-content → content-bubble → a2ui-canvas) and its TestBed provides none of the A2UI DI graph, so re-enabling it is high-cost and redundant with the new content-bubble spec.

Verification checklist (outstanding — why this is a draft)

npm test and ng build are green. No unit test can catch a catalogId mismatch or 0.9's silent-degradation failure modes, so the following still needs a browser:

  • A surface exercising Card / Button / TextField / Tabs / Modal / Slider / Checkbox, in both dark and light mode
  • Toggling the theme while a surface is mounted (should be instant — pure CSS indirection)
  • History reload of a session containing 0.9 surfaces
  • Streaming where createSurface and updateComponents land in different events/bubbles
  • An existing 0.8 agent still renders identically — including the markdown change flagged above
  • Devtools console clean throughout; any a2ui warn/error treated as a failure

One known residual hazard I could not rule out by reading: if re-processing createSurface on a history reload constructs a new SurfaceModel, an already-mounted child holds a stale reference and blanks. The redundant-createSurface skip should prevent it. If the reload check shows a blank surface, the fix is a surfaceGeneration signal driving a keyed @for to force child recreation.

Explicitly out of scope

  • event.actions.render_ui_widgets with provider: "a2ui" — the staged-ui-widgets sample's other incompatibility. I checked and this is not a standard A2UI contract: zero references in the A2UI spec repo, and A2UI's own official ADK sample uses A2A DataParts. It looks like a standard ADK API rather than a standard A2UI one, which makes it a maintainer question — tracked on A2UI does not work with ADK web #447. This PR adds 0.9 vocabulary support only; the sample can be updated separately to deliver over the <a2ui-json> text block, which I verified renders live and not only on history reload.
  • Pre-existing latent issue at main.ts: {provide: Theme, useValue: A2UI_THEME} supplies a plain object where the Theme class is expected. It works only because consumers read exactly the four fields Theme exposes and A2UI_THEME happens to have exactly those four (upstream's own provideA2UI() does const t = new Theme(); t.update(config.theme)). Left alone — changing it invites regressions unrelated to this PR.
  • Duplicate zod majors in the bundle — the app uses 4.x, web_core needs ^3.25.76. Already the status quo; npm nesting handles it and the two never meet at a type boundary. Deliberately not forcing a dedupe via overrides: zod 4 is not backward compatible and web_core's schema parsing would break at runtime, silently. Happy to measure with ng build --stats-json as a follow-up if you'd like.

Questions for maintainers

  1. actionHandler is a console.debug stub with a TODO. 0.9 client actions (button clicks, field edits) are logged but not routed back to the agent. 0.8 does this through MessageProcessor.events. Should wiring that up land in this PR, or as a follow-up once the 0.9 rendering path is agreed? I left it out to keep this reviewable, and because the interactive round-trip deserves its own tests.
  2. provideA2Ui() does not exist in 0.9.1 (it arrives in 0.10.5), so A2UI_RENDERER_CONFIG is registered by hand and A2uiRendererService is listed explicitly — it's a plain @Injectable() with no providedIn, unlike 0.8's MessageProcessor. Would you rather target 0.10.x now and use provideA2Ui()? I targeted 0.9 to match the spec version the sample pins, but that hand-rolled provider block is the part most likely to churn on the next upgrade.

…kdown-it

@a2ui/angular 0.9.1 ships both spec versions from one package via its ./v0_8
and ./v0_9 subpath exports, so adding A2UI 0.9 support does not mean dropping
0.8. Its bare "." export is still the complete 0.8 API, so existing imports of
Types, Theme, Catalog and MessageProcessor keep resolving unchanged. The peer
range is @angular/core ^21.2.0 and the lockfile already resolves 21.2.4, so no
Angular bump is needed.

@a2ui/lit is promoted from a transitive to a direct dependency: 0.9 drops it,
which would break a2ui-theme.ts's `import {Types, Styles} from '@a2ui/lit/0.8'`.
Types is available from @a2ui/angular, but Styles is not, and Styles.merge
cannot be replaced with an object spread — it strips same-family keys by
prefix, and 8 of its 13 call sites in a2ui-theme.ts merge *different* keys
within the same `color-c-*` family. A spread would leave both set and emit two
conflicting classes.

@a2ui/markdown-it is an optional peer of @a2ui/angular, but in practice it is
required: TextComponent injects the abstract MarkdownRenderer unconditionally
and synthesizes headings by prepending `#`/`##` before rendering, so with the
passthrough fallback every 0.9 heading renders as literal "# My Title".
DefaultMarkdownRenderer also does a bare `await import('@a2ui/markdown-it')`,
which esbuild errors on when the package is undeclared.
Mechanical result of the previous commit; isolated so it can be reviewed
separately from hand-written changes.

Added: @a2ui/web_core 0.9.2 (replacing 0.8.0 at the top level),
@preact/signals-core, @a2ui/markdown-it, date-fns (transitive of web_core
0.9.2), a nested zod 3 under @a2ui/angular, and a nested web_core 0.8.0 +
zod 3 under @a2ui/lit.

The app's own zod stays at 4.x. The two majors never meet at a type boundary —
adk-web never passes zod schemas into A2UI — and npm's nesting keeps them
apart. Forcing a dedupe via `overrides` would break web_core's schema parsing
at runtime, since zod 4 is not backward compatible with its `^3.25.76`
requirement.
… 0.9 bump

Repoint the bare '@a2ui/angular' imports to the explicit '/v0_8' subpath so a
future major that flips the bare export to 0.9 cannot silently retarget the 0.8
renderer. Also drop the dead A2uiCanvasComponent import in chat-panel (not in
that component's `imports`, unused in its template).

Bumping @a2ui/angular to 0.9.1 changed v0.8 runtime behavior in three ways that
this commit repairs, so that going green here is real evidence 0.8 still works:

1. `Types` now comes from '@a2ui/angular/v0_8' instead of '@a2ui/lit/0.8'.
   @a2ui/lit@0.8.3 carries its own nested web_core@0.8.0, while
   @a2ui/angular/v0_8 is backed by web_core@0.9.2. The two copies of the v0.8
   types are structurally incompatible (`ComponentInstance.component` is
   optional in one, required in the other), which broke compilation. @a2ui/lit
   is now used only for `Styles`, which @a2ui/angular does not re-export.

2. `provideMarkdownRenderer()` is now registered in main.ts. `Text`
   unconditionally injects `MarkdownRenderer`, whose base class is declared
   `providedIn: 'root'` but has no `render` method -- so the injection succeeds
   and then throws "render is not a function" on the first Text component. Any
   0.8 agent sending Text would have crashed without this.

3. `processMessages` is wrapped in try/catch. web_core@0.9.2's v0.8 processor
   zod-validates and throws where 0.8.0 tolerated. Uncaught, that escapes
   ngOnChanges and breaks change detection for the whole chat view; a malformed
   payload should break only its own widget. Two new canvas specs cover this:
   one asserts nothing is logged on a clean message, the other that a throwing
   processor is contained, still logged, and leaves surfaceId derivable so a
   later valid message recovers.

Test fixtures that were invalid under the stricter schema are corrected rather
than left masked by the new catch (`Text.text` must be `{literalString}` or
`{path}`, not a bare string), and the chat spec now mirrors main.ts's A2UI
providers, which a schema-valid payload reaches during renderer construction.

645 passing, ng build clean.
Both A2UI ingestion paths duck-typed the three v0.8 message kinds into a
three-slot, last-wins object, so v0.9 kinds (createSurface, updateComponents,
updateDataModel, deleteSurface) were silently dropped: a2uiData was never
populated, nothing rendered, and no error surfaced.

Replace the duplicated bucketing in processA2uiPartIntoMessage and
extractA2uiJsonFromText with one normalizeA2uiMessages(), returning the new
version-tagged A2uiPayload. Version detection prefers the explicit `version`
discriminator and falls back to the kind key, which is unambiguous because the
two versions' kind sets are disjoint. Verified against web_core's v0.9 schema:
the discriminator sits on the envelope only -- every message object declares
`additionalProperties: false`, so a nested `version` is invalid and needs no
handling.

`messages` is an ordered array because v0.9 legitimately sends N
updateComponents / updateDataModel messages across a surface, which the fixed
slots could not represent. The three v0.8 aliases are still populated for v0.8
payloads, so the existing v0.8 render bindings are untouched -- that is the
regression story for this commit.

No rendering change yet: a v0.9 payload normalizes but still falls through to
the v0.8 canvas, which pushes no messages and draws nothing.

One intentional behavior change, covered by a test: <a2ui-json>[]</a2ui-json>
previously produced a truthy `{}` that mounted an empty canvas, and now leaves
a2uiData unset. The text is still stripped either way, so the markup never
leaks into the bubble.

665 passing, ng build clean.
A sibling of A2uiCanvasComponent rather than a branch inside it, because the two
renderers have genuinely different lifecycles: v0.8's <a2ui-surface> needs a
resolved surface object re-read from a map on every change, while v0.9's
<a2ui-v09-surface> takes only a surfaceId and resolves it internally. Leaving
the v0.8 component and its spec literally unmodified is also the strongest
guarantee against v0.8 regressions.

Messages are processed one at a time, not as a single batch, and a createSurface
naming an existing surface is skipped. v0.9's processor throws on a duplicate
createSurface, an unknown catalogId, and an update naming an absent surface. The
renderer service is root-scoped, so its surfaces outlive any one bubble: a
history reload or a re-processed batch would throw on the create and, batched,
abandon every message after it -- leaving a blank surface. Skipping the create
instead lets the idempotent updates land on the existing surface, so the mounted
child keeps a valid reference and no keyed-@for remount is needed.

The @if gate on surfaceId is load-bearing: ComponentHostComponent looks its
surface up once in ngOnInit and, on a miss, warns and never retries -- unlike a
missing component, which recovers via onCreated. Angular runs the parent's
ngOnChanges before creating children, so deriving surfaceId only after
processing guarantees the surface exists before the child mounts. A spec asserts
the element is absent before processing and present after.

DI is registered by hand: v0.9.1 has no provideA2Ui() (that lands in 0.10), and
A2uiRendererService is a plain @Injectable() with no providedIn, so omitting it
would make ComponentHostComponent's inject() throw NG0201.

Not reachable from any template yet -- the dispatch lands in the next commit.

Verified that Parameters<A2uiRendererService['processMessages']>[0][number]
resolves to the real A2uiMessage type rather than degrading to any, so
@a2ui/web_core does not need to become a direct dependency.

674 passing, ng build clean.
This is the commit that turns v0.9 rendering on. Reverting it disables
v0.9 without touching anything else.

`content-bubble.component.html` now branches on `a2uiData.version`: a
`v0.9` payload mounts the new canvas with the whole message batch, and
everything else keeps the historical v0.8 markup character-for-character.
An untagged payload therefore still takes the v0.8 path.

Adds `content-bubble.component.spec.ts` -- there was none, and this is
the file that catches a mis-dispatch. Four cases: v0.8 renders only
`app-a2ui-canvas`, v0.9 renders only `app-a2ui-canvas-v09`, an absent
`a2uiData` renders neither, and an untagged payload falls back to v0.8.
Both renderers are stubbed; this spec is about which canvas is chosen,
not what it draws.

`chat.component.spec.ts` gains the real v0.9 provider graph (mirroring
main.ts) rather than a stub, so the v0.9 history test also proves the
canonical `catalogId` is accepted -- a mismatch is the top integration
gotcha for agent authors and its only symptom is a silent empty surface,
which the `console.error` assertion now turns into a test failure.

Also documents why `chat-panel.component.spec.ts`'s A2UI test stays
disabled: chat-panel is four components removed from the canvas and
provides none of the A2UI DI graph, so re-enabling it is high-cost and
redundant with the new content-bubble spec.
v0.9 has no theme object. Unlike v0.8, which is themed in TypeScript
through the `Theme` token, v0.9 injects no root stylesheet and defines no
`a2ui-light`/`a2ui-dark` classes -- every component styles itself with
inline `var(--a2ui-X, <fallback>)`, and all but a handful of those
fallbacks are hardcoded light-mode values (#fff, google#333, #ccc). Without
this, v0.9 surfaces are unreadable in adk-web's default dark theme, so
it is a correctness fix rather than a cosmetic one.

Defines the ~50 `--a2ui-*` properties as `var(--mat-sys-*)`. Because
`var()` resolves at use time, the layer is pure indirection: a theme flip
repaints `--mat-sys-*`, which repaints `--a2ui-*`. No JavaScript, no new
classes, no second definition site -- ThemeService and styles.scss are
untouched.

Scoped to `:host` rather than a global stylesheet so `--a2ui-*` stays out
of the global namespace of an app embedding this component. Verified safe
for v0.9's Modal, which is a plain `position: fixed` element inside its
own template (no CDK overlay, no body portal), so it stays in this
subtree and inherits.

Three findings from reading the compiled v0.9 CSS, each commented at its
mapping:

- The six `--a2ui-font-size-*` properties are mandatory. Upstream reads
  them as bare `var(--a2ui-font-size-l)` with no fallback, and an
  undefined custom property invalidates the whole declaration -- every
  heading would silently drop to the browser default.
- `--a2ui-color-on-secondary` is the foreground of the *default* button,
  over an `--a2ui-color-surface` background. It maps to
  `--mat-sys-on-surface`; the intuitive `--mat-sys-on-secondary-container`
  gives low-contrast text.
- `.a2ui-button:disabled` hardcodes #e9ecef/#6c757d with no property to
  hook, and the CheckBox is a native `<input type="checkbox">` whose
  background/border variables are inert without `appearance: none`. Both
  need a `:host ::ng-deep` override until upstream tokenizes them.

Card shadow and margin are dropped: a surface already sits inside an
elevated chat bubble, where upstream's drop shadow reads as a second
border and its 16px margin double-pads the bubble.
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