Repository navigation
feat(a2ui): add A2UI spec v0.9 rendering support alongside v0.8 - #517
lwangverizon wants to merge 16 commits into
Conversation
…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.
|
Hi @lwangverizon, thanks for this PR — I'd like to help get v0.9 rendering landed. Some context on where things stand today:
I can take on:
Before I start, two questions so I don't duplicate work:
|
I'm sorry this was quite opaque. The internal CL had to do with how the way that adk-web imported A2UI (from the root of the package when it meant the v0.8 endpoint) was blocking our ability to reclaim that root of the package for other things. I didn't really fix the rendering code of adk-web at all, just how it imported the code it needed (the only real change I remember making is injecting the markdown rendering how the newer A2UI wanted). ((So yeah, I think this is still needed. Also, A2UI v1.0 is coming, so maybe that also needs some changes!)) |
|
Thanks @ditman for clarifying, and @batou9150 for offering to help! Please
send a PR against my feat/a2ui-v09-support branch so we can keep #517 as
the main review. I’d appreciate your help with the rebase and browser
verification.
Dropping the superseded dependency changes and switching to provideA2Ui()
sounds good. Please preserve main’s existing provideA2uiMarkdown()
integration.
Let’s keep action round-tripping as a separate follow-up, as you suggested,
and handle v1.0 separately unless it directly affects this work. Thanks
again!
On Tue, 27 Oct 2026 15:12:04 -0700, David Iglesias ***@***.***> wrote:
ditman left a comment (google/adk-web#517) > @ditman @wyf7107 —
#520 mentioned continuing in an
internal CL. Does that already cover v0.9 rendering? I'm sorry this was
quite opaque. The internal CL had to do with how the way that adk-web
imported A2UI (from the root of the package when it meant the v0.8
endpoint) was blocking our ability to reclaim that root of the package for
other things. I didn't really fix the rendering code of adk-web at all,
just how it imported the code it needed (the only real change I remember
making is injecting the markdown rendering how the newer A2UI wanted). ((So
yeah, I *think* this is still needed. Also, A2UI v1.0 is coming, so maybe
that also needs some changes!)) -- Reply to this email directly
8000
or view it
on GitHub:
#517 (comment) You are
receiving this because you were mentioned. Message ID:
***@***.***>
|
Since the @a2ui 0.10 bump (google#520), web_core'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. `processMessages` is now wrapped in try/catch, and two new canvas specs cover it: 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. Also drop the dead A2uiCanvasComponent import in chat-panel (not in that component's `imports`, unused in its template). The /v0_8 import pinning, the `Types` source fix and the markdown provider from the original version of this commit are already on main via google#520 (`provideA2uiMarkdown()`), so they are dropped here.
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 goes through provideA2Ui() from @a2ui/angular/v0_9 (available since 0.10.5, which main is on), with BasicCatalog and a console.debug actionHandler stub; routing client actions back to the agent is a follow-up. A2uiRendererService is providedIn: 'root' in 0.10, so it needs no explicit provider. Markdown reuses main's provideA2uiMarkdown() ngx-markdown bridge, extended to 8000 also provide the v0.9 MarkdownRenderer. Without it, v0.9 Text falls back to DefaultMarkdownRenderer, which needs the optional @a2ui/markdown-it peer and renders raw markdown when it is absent. 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.
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.
A v0.9 event may carry several surfaces -- each message names its own surfaceId -- but A2uiCanvasV09Component derived a single surfaceId per batch and mounted one <a2ui-v09-surface>, so every surface after the first was processed and silently never shown. Track all surfaceIds in first-mention order (`surfaceIds`), skipping any that a deleteSurface in the same batch removes, and mount one surface per id with @for. `surfaceId` remains as a computed alias for the first one. They are still derived only after processing, so a child never mounts before its surface exists. Found with a test agent emitting three staged-ui-widgets surfaces in one <a2ui-json> block: only the first rendered before this change.
A v0.9 surface is one live, root-scoped model. When an agent streams it -- createSurface, updateComponents and updateDataModel in separate events -- every bubble derived the surfaceId from its own messages and mounted the same surface again, so it was shown once per event. Mount only the surfaces a batch creates. A batch that merely updates a surface is still processed, and its changes land on the copy already mounted in the creating bubble. History reload is unaffected: the creating event's batch still contains the createSurface (skipped as redundant when the surface already exists), so that bubble still mounts it. This replaces the "derive the surfaceId from updateComponents alone" spec with one asserting an update-only batch is processed but not mounted, and adds a three-event streaming spec. The creating bubble can still log a transient "Component root not found ... Waiting for it" between createSurface and updateComponents: the renderer waits for `root` by design and recovers once it arrives.
|
@lwangverizon — the rebase is up as lwangverizon#1, against Browser verificationSetup:
Out of scope here, but found along the way:
|
|
Thanks @batou9150 for the rebase, the surface fixes, and the thorough browser verification, and @ditman for clarifying the internal CL's scope. I've reviewed and integrated lwangverizon#1 into this branch, preserving the existing history. The updated code uses main's dependency versions and I also added a few fixes during review:
Local validation passes: 875 tests passed, 37 skipped, and the production build passed. Added real-renderer regression checks cover v0.9 markdown and deletion across bubbles. The standalone backend/browser checklist remains your reported verification; I haven't independently repeated it. GitHub's change and Google CLA checks are also green. I've updated the description and marked this ready for review. As agreed, action round-tripping remains a separate follow-up; the upstream modal stacking and ADK backend extension/streaming issues also remain separate. Thanks again for helping move this forward! |
Summary
ADK Web drops A2UI v0.9 message kinds, leaving agents that emit
createSurface,updateComponents, andupdateDataModelwithout a rendered UI. This change detects the spec version per payload and adds a v0.9 canvas alongside the existing v0.8 path.<a2ui-json>blocks. Accept bothapplication/json+a2uiandapplication/a2ui+json.@a2ui/angular0.10.5 dependency andprovideA2Ui(). Preserve the ngx-markdown bridge and provide it to both renderer versions.The canonical basic catalog ID is
https://a2ui.org/specification/v0_9/catalogs/basic/catalog.json.Validation
Scope and follow-ups
Client actions reach an
actionHandlerstub; round-tripping to the agent remains a separate follow-up agreed in the discussion. Upstream modal stacking and ADK backend extension/streaming transport issues are also separate. A2UI v1.0 is outside this change.The integration preserves both the original branch history and the contributor's rebased commits; the normalizer and renderer dispatch remain separate commits in that history.