feat(a2ui): add A2UI spec v0.9 rendering support alongside v0.8 - #517
Draft
lwangverizon wants to merge 7 commits into
Draft
feat(a2ui): add A2UI spec v0.9 rendering support alongside v0.8#517lwangverizon wants to merge 7 commits into
lwangverizon wants to merge 7 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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
adk-web can currently render A2UI spec 0.8 only. The renderer pins
@a2ui/angular@^0.8.3, and both A2UI ingestion paths inchat.component.tsduck-type the 0.8 message kinds (beginRendering,surfaceUpdate,dataModelUpdate). An agent that emits 0.9 kinds (createSurface,updateComponents,updateDataModel,deleteSurface) is dropped silently —a2uiDatais 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-widgetssample inadk-samples, which pinsA2UI_VERSION = "0.9"and emits{"version": "v0.9", "createSurface": {...}}.Note
Draft: the code is complete and CI-clean (678 unit tests green,
ng buildclean), 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
normalizeA2uiMessagesinchat.component.tsprefers the explicitversiondiscriminator 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.
A2uiCanvasComponentand its bindings are untouched; 0.9 gets a siblingA2uiCanvasV09Componentandcontent-bubble.component.htmldispatches onversion. The two renderers have genuinely different lifecycles — 0.8's<a2ui-surface>needs a resolved[surface]object re-read from aMapon everyngOnChanges, while 0.9's<a2ui-v09-surface>takes only asurfaceIdand resolves internally inngOnInit— 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.messagesis an array because 0.9 legitimately sends NupdateComponents/updateDataModelmessages 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.
chore(deps)—package.jsononlychore(deps)— regeneratedpackage-lock.jsonalone, so it can be collapsedrefactor(a2ui)— pin 0.8 imports to the/v0_8subpath; repair 0.8 regressions from the bumpfeat(a2ui)—A2uiPayload+ the normalizer. No rendering change yetfeat(a2ui)— the v0.9 canvas +main.tsproviders. Not yet reachable from a templatefeat(a2ui)— the content-bubble dispatch. This is the commit that turns the feature on; reverting it disables 0.9 without touching anything elsefeat(a2ui)— the--a2ui-*theme blockCommit 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.9.1ships both versions via the./v0_8and./v0_9subpath exports, so this is additive rather than a migration. Peer is@angular/core ^21.2.0and the lockfile already resolves 21.2.4 — no Angular bump.@a2ui/lit@0.8.3is promoted transitive → direct. 0.9 drops it, which would breaka2ui-theme.ts'sStylesimport.Styles.mergecannot be hand-rolled to a spread: 8 of its 13 call sites merge different keys in the samecolor-c-*family, and a spread would leave two conflicting classes set.@a2ui/markdown-it@0.0.3is an optional peer, butTextComponentinjectsMarkdownRendererunconditionally andDefaultMarkdownRendererdoes a bareawait import('@a2ui/markdown-it'), which esbuild errors on when undeclared.@a2ui/web_coreis deliberately not added. I verified withtscthatParameters<A2uiRendererService['processMessages']>[0][number]resolves to the realA2uiMessagerather than degrading toany, so the direct dependency isn't needed.Commit 3 adds
provideMarkdownRenderer()tomain.ts. This is a 0.8 fix, not 0.9 setup:MarkdownRendererisprovidedIn: 'root'but its base class has norendermethod, so after the dep bump every 0.8Textcomponent threwrender is not a functionata2ui-angular-v0_8.mjs:1446. The explicit provider swaps inDefaultMarkdownRenderer, which has it.The side effect is that 0.8
Textnow renders real markdown through@a2ui/markdown-it(with DOMPurify) instead of the regex passthrough. I confirmed@a2ui/markdown-itloads 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
Themetoken, 0.9 injects no root stylesheet and defines noa2ui-light/a2ui-darkclasses — every component styles itself with inlinevar(--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 asvar(--mat-sys-*). Becausevar()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 —ThemeServiceandstyles.scssare untouched. It's scoped to:hostrather than a global stylesheet, which also avoids twoangular.jsonedits 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:
--a2ui-font-size-*properties are mandatory. Upstream reads them as barevar(--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-secondaryis the foreground of the default button, over an--a2ui-color-surfacebackground. It maps to--mat-sys-on-surface; the intuitive--mat-sys-on-secondary-containergives low-contrast text.::ng-deep:.a2ui-button:disabledhardcodes#e9ecef/#6c757dwith no custom property, and the 0.9CheckBoxis a bare<input type="checkbox">withoutappearance: none, so its--a2ui-checkbox-background/-bordervariables are inert and it renders in the browser's own light-mode chrome.accent-coloris the one property a native checkbox honours. Both overrides should be deleted once upstream tokenizes them.📌 Integration gotcha for agent authors
catalogIdis required on 0.9createSurfaceand must equalhttps://a2ui.org/specification/v0_9/basic_catalog.json, or surface creation fails silently — aconsole.warnand 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.tswires the real 0.9 provider graph rather than a stub, and assertsconsole.errorwas 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 buildclean.chat.component.spec.ts— 15 table-drivendetectA2uiVersioncases (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>leavesa2uiDataundefined and still strips the text; a mixed-version batch warns.a2ui-canvas-v09.component.spec.ts(new) — dispatch andsurfaceIdderivation; no change record → no call; the same array reference twice → exactly one call;surfaceIdderived fromupdateComponentsalone (the cross-bubble streaming case); a redundantcreateSurfaceskipped so a history reload doesn't throwSurface already exists; a failed message contained without abandoning the rest of the batch; and the@ifgate.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-canvasonly; 0.9 →app-a2ui-canvas-v09only; absenta2uiData→ neither; untagged → falls back to 0.8.Two notes on deliberate test choices:
@if (surfaceId())gate in the 0.9 canvas template is load-bearing, not cosmetic —ComponentHostComponent.ngOnInitlooks its surface up once and, on a miss, warns and never retries (a missing component, by contrast, recovers viacomponentsModel.onCreated). The gate guaranteescreateSurfacehas been processed before the child mounts, and there's a test for it.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 staysxit, now with a comment explaining why: chat-panel doesn't own the canvas (the chain ischat-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 testandng buildare green. No unit test can catch acatalogIdmismatch or 0.9's silent-degradation failure modes, so the following still needs a browser:createSurfaceandupdateComponentsland in different events/bubblesa2uiwarn/error treated as a failureOne known residual hazard I could not rule out by reading: if re-processing
createSurfaceon a history reload constructs a newSurfaceModel, an already-mounted child holds a stale reference and blanks. The redundant-createSurfaceskip should prevent it. If the reload check shows a blank surface, the fix is asurfaceGenerationsignal driving a keyed@forto force child recreation.Explicitly out of scope
event.actions.render_ui_widgetswithprovider: "a2ui"— thestaged-ui-widgetssample'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.main.ts:{provide: Theme, useValue: A2UI_THEME}supplies a plain object where theThemeclass is expected. It works only because consumers read exactly the four fieldsThemeexposes andA2UI_THEMEhappens to have exactly those four (upstream's ownprovideA2UI()doesconst t = new Theme(); t.update(config.theme)). Left alone — changing it invites regressions unrelated to this PR.zodmajors in the bundle — the app uses 4.x,web_coreneeds^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 viaoverrides: zod 4 is not backward compatible andweb_core's schema parsing would break at runtime, silently. Happy to measure withng build --stats-jsonas a follow-up if you'd like.Questions for maintainers
actionHandleris aconsole.debugstub with aTODO. 0.9 client actions (button clicks, field edits) are logged but not routed back to the agent. 0.8 does this throughMessageProcessor.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.provideA2Ui()does not exist in 0.9.1 (it arrives in 0.10.5), soA2UI_RENDERER_CONFIGis registered by hand andA2uiRendererServiceis listed explicitly — it's a plain@Injectable()with noprovidedIn, unlike 0.8'sMessageProcessor. Would you rather target 0.10.x now and useprovideA2Ui()? 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.