Skip to content
Open
Show file tree
Hide file tree
Changes from 8 commits
Commits
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
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.

}
}
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
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.

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