Skip to content
Merged
Show file tree
Hide file tree
Changes from 8 commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 3 additions & 4 deletions renderers/web_core/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 3 additions & 3 deletions renderers/web_core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -88,11 +88,11 @@
"devDependencies": {
"@types/node": "^24.11.0",
"typescript": "^5.8.3",
"wireit": "^0.15.0-pre.2",
"zod-to-json-schema": "^3.25.1"
"wireit": "^0.15.0-pre.2"
},
"dependencies": {
"@preact/signals-core": "^1.13.0",
"zod": "^3.25.76"
"zod": "^3.25.76",
"zod-to-json-schema": "^3.25.1"
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.

This might be bloating the front end, though. I was tempted to add this in the past too, but decided against it in order to keep the front end lean. If you think it's worth it, we should quantify the size hit.

Copy link
Copy Markdown
Collaborator Author

@jacobsimionato jacobsimionato Mar 20, 2026

Choose a reason for hiding this comment

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

Well, we have decided to use Zod to represent schemas, and the A2UI specification supports inline catalogs, requiring those Zod schemas to be converted to JSON. So I think our choices are:
A. Add the dep
B. Write our own more lightweight version of the same conversion
C. Not support inlineCatalogs at all
D. Support inlineCatalogs through an additional package we provide.

I vote we do A for now, then switch to D if people complain. WDYT?

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.

Yeah, let's add the dep for now. If it's too bloated, we can evalutate B.

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.

SGTM. I noticed that zod 4 has this functionality built into the Zod package natively, so I don't know if there's a benefit from excluding it anyway. I guess we should update to zod 4 at at some point.

}
}
64 changes: 64 additions & 0 deletions renderers/web_core/src/v0_9/processing/message-processor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import assert from "node:assert";
import { describe, it, beforeEach } from "node:test";
import { MessageProcessor } from "./message-processor.js";
import { Catalog, ComponentApi } from "../catalog/types.js";
import { z } from "zod";

describe("MessageProcessor", () => {
let processor: MessageProcessor<ComponentApi>;
Expand All @@ -32,6 +33,69 @@ describe("MessageProcessor", () => {
});
});

describe("getClientCapabilities", () => {
it("generates basic client capabilities with supportedCatalogIds", () => {
const caps: any = processor.getClientCapabilities();
assert.strictEqual((caps["v0.9"] as any).inlineCatalogs, undefined);
assert.deepStrictEqual(caps, {
"v0.9": {
supportedCatalogIds: ["test-catalog"],
},
});
});

it("generates inline catalogs when requested", () => {
const buttonApi: ComponentApi = {
name: "Button",
schema: z.object({
label: z.string().describe("The button label"),
}),
};
const cat = new Catalog("cat-1", [buttonApi]);
const proc = new MessageProcessor([cat]);

const caps = proc.getClientCapabilities({ includeInlineCatalogs: true });
const inlineCat = caps["v0.9"].inlineCatalogs[0];

assert.strictEqual(inlineCat.catalogId, "cat-1");
const buttonSchema = inlineCat.components.Button;

assert.ok(buttonSchema.allOf);
assert.strictEqual(
buttonSchema.allOf[0].$ref,
"common_types.json#/$defs/ComponentCommon",
);
assert.strictEqual(buttonSchema.allOf[1].properties.component.const, "Button");
assert.strictEqual(
buttonSchema.allOf[1].properties.label.description,
"The button label",
);
assert.deepStrictEqual(buttonSchema.allOf[1].required, ["component", "label"]);
});

it("transforms REF: descriptions into valid $ref nodes", () => {
const customApi: ComponentApi = {
name: "Custom",
schema: z.object({
title: z
.string()
.describe("REF:common_types.json#/$defs/DynamicString|The title"),
}),
};
const cat = new Catalog("cat-ref", [customApi]);
const proc = new MessageProcessor([cat]);

const caps = proc.getClientCapabilities({ includeInlineCatalogs: true });
const titleSchema =
caps["v0.9"].inlineCatalogs[0].components.Custom.allOf[1].properties.title;

assert.strictEqual(titleSchema.$ref, "common_types.json#/$defs/DynamicString");
assert.strictEqual(titleSchema.description, "The title");
// Ensure Zod's 'type: string' was removed
assert.strictEqual(titleSchema.type, undefined);
});
});

it("creates surface", () => {
processor.processMessages([
{
Expand Down
103 changes: 103 additions & 0 deletions renderers/web_core/src/v0_9/processing/message-processor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import { Catalog, ComponentApi } from "../catalog/types.js";
import { SurfaceGroupModel } from "../state/surface-group-model.js";
import { ComponentModel } from "../state/component-model.js";
import { Subscription } from "../common/events.js";
import { zodToJsonSchema } from "zod-to-json-schema";

import {
A2uiMessage,
Expand All @@ -29,6 +30,14 @@ import {
} from "../schema/server-to-client.js";
import { A2uiStateError, A2uiValidationError } from "../errors.js";

/**
* Options for generating client capabilities.
*/
export interface CapabilitiesOptions {
/** If true, the full definition of all catalogs will be included. */
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 thought the idea for this option was to allow inline catalogs, not to include the full definition of all of them.

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.

Well, for now this API just turns on sending the full capabilities for every catalog. In the future, we might want some more fine-grained API to say "just send full inline catalogs for these three schemas", but I figure this is a simple starting point? WDYT?

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.

Yeah, I guess start here and we can get more complicated/refined as time goes on. I was just thinking about how it is including these with every message and thinking it was a lot.

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.

SGTM!

includeInlineCatalogs?: boolean;
}

/**
* The central processor for A2UI messages.
* @template T The concrete type of the ComponentApi.
Expand All @@ -52,6 +61,100 @@ export class MessageProcessor<T extends ComponentApi> {
}
}

/**
* Generates the a2uiClientCapabilities object for the current processor.
*
* @param options Configuration for capability generation.
* @returns The capabilities object.
*/
getClientCapabilities(options?: CapabilitiesOptions): any {
const capabilities: any = {
"v0.9": {
supportedCatalogIds: this.catalogs.map((c) => c.id),
},
};

if (options?.includeInlineCatalogs) {
capabilities["v0.9"].inlineCatalogs = this.catalogs.map((c) =>
this.generateInlineCatalog(c),
);
}

return capabilities;
}

private generateInlineCatalog(catalog: Catalog<T>): any {
const components: Record<string, any> = {};

for (const [name, api] of catalog.components.entries()) {
const zodSchema = zodToJsonSchema(api.schema, {
target: "jsonSchema2019-09",
}) as any;

// Clean up Zod-specific artifacts and process REF: tags
this.processRefs(zodSchema);

// Wrap in standard A2UI component envelope (ComponentCommon)
components[name] = {
allOf: [
{ $ref: "common_types.json#/$defs/ComponentCommon" },
{
properties: {
component: { const: name },
...zodSchema.properties,
},
required: ["component", ...(zodSchema.required || [])],
},
],
};
}

return {
catalogId: catalog.id,
components,
};
}

private processRefs(node: any): void {
if (typeof node !== "object" || node === null) return;

if (Array.isArray(node)) {
for (const item of node) {
this.processRefs(item);
}
return;
}

for (const key of Object.keys(node)) {
const val = node[key];

if (key === "description" && typeof val === "string") {
if (val.startsWith("REF:")) {
const parts = val.substring(4).split("|");
const ref = parts[0];
const desc = parts[1] || "";

// Mutate the parent node to be a $ref
// We remove other validation keywords that Zod might have added (like type: string)
// but keep the description.
for (const k of Object.keys(node)) {
if (k !== "description") {
delete node[k];
}
}
node["$ref"] = ref;
if (desc) {
node["description"] = desc;
} else {
delete node["description"];
}
}
} else {
this.processRefs(val);
}
}
}

/**
* Subscribes to surface creation events.
*/
Expand Down
Loading
Loading