diff --git a/.github/a2ui-catalog.instructions.md b/.github/a2ui-catalog.instructions.md index b12ff88906..df80610044 100644 --- a/.github/a2ui-catalog.instructions.md +++ b/.github/a2ui-catalog.instructions.md @@ -13,3 +13,9 @@ For the `` shell, treat `className` and `wrapSurface` as complementary the When evolving `packages/genui/a2ui-playground`, treat protocol-prefixed hashes such as `#/a2ui/...` and `#/openui/...` as the canonical routes, and preserve the current mainline tab names (`create`, `examples`, `components`) when adding protocol-aware routing. If you keep compatibility aliases for older or transitional paths such as `#/demos` or `#/chat`, parse them into the canonical route model instead of letting a rebase silently rename the mainline routes. When a GenUI package builds a CLI or other generated artifact that another workspace package executes during its own build, declare that package's `dist/**` (or equivalent generated directory) as Turbo `build.outputs`. Without explicit outputs, cache hits can skip restoring the built CLI and leave downstream workspace bins pointing at missing files. + +When implementing A2UI v0.9 functions in `packages/genui/a2ui`, keep function resolution scoped to the active catalog first, with the global `FunctionRegistry` only as an escape hatch. Dynamic component props, checks, and function-call actions should all go through the same `resolveDynamicValue` / `executeFunctionCall` path so data bindings, nested function calls, zod argument coercion from `@a2ui/web_core`, and `formatString` data-context interpolation stay consistent. + +When verifying `packages/genui/a2ui-playground`, remember that `pnpm -F @lynx-js/a2ui-reactlynx build` regenerates catalog JSON only. The playground consumes `@lynx-js/a2ui-reactlynx` through package exports under `dist/**`, so run `pnpm -F @lynx-js/a2ui-reactlynx exec tsc -p tsconfig.build.json` before rebuilding the playground if runtime TypeScript changed. + +For known A2UI playground examples, keep the web preview URL on `?demo=` instead of swapping it to the payload-store `messagesUrl`. `render.html` intentionally fetches known demo JSON in the browser shell and passes resolved messages into Lynx, avoiding fetch differences in the Lynx worker runtime; use payload-store URLs for custom edited JSON. diff --git a/packages/genui/a2ui-catalog-extractor/src/cli.ts b/packages/genui/a2ui-catalog-extractor/src/cli.ts index c71de4273c..32834b58fe 100644 --- a/packages/genui/a2ui-catalog-extractor/src/cli.ts +++ b/packages/genui/a2ui-catalog-extractor/src/cli.ts @@ -9,9 +9,11 @@ import { pathToFileURL } from 'node:url'; import { extractCatalogComponentsFromTypeDocJson, + extractCatalogFunctionsFromTypeDocJson, findCatalogSourceFiles, + writeCatalogArtifacts, writeCatalogComponents, - writeComponentCatalogs, + writeCatalogFunctions, } from './index.js'; import type { TypeDocProject } from './index.js'; @@ -107,12 +109,20 @@ export async function runCli( const components = extractCatalogComponentsFromTypeDocJson(project, { cwd, }); + const functions = extractCatalogFunctionsFromTypeDocJson(project, { + cwd, + }); writeCatalogComponents(components, { cwd, outDir: options.outDir, }); + writeCatalogFunctions(functions, { + cwd, + outDir: options.outDir, + }); printGeneratedComponents(components); + printGeneratedFunctions(functions); return 0; } @@ -137,24 +147,26 @@ export async function runCli( ); } - const components = await writeComponentCatalogs({ + const { components, functions } = await writeCatalogArtifacts({ cwd, outDir: options.outDir, sourceFiles: uniqueSourceFiles, }); printGeneratedComponents(components); + printGeneratedFunctions(functions); - // Fail loudly if we matched source files but emitted no components — + // Fail loudly if we matched source files but emitted no artifacts — // this used to silently succeed on Windows when TypeDoc rejected // backslash entry-point paths, and downstream packages then failed // to import the missing `catalog.json` files. - if (components.length === 0) { + if (components.length === 0 && functions.length === 0) { console.error( `[a2ui-catalog-extractor] Found ${uniqueSourceFiles.length} ` - + `source file(s) but emitted 0 component catalogs. Make sure ` - + `each catalog props interface is annotated with ` - + `\`@a2uiCatalog \`.`, + + `source file(s) but emitted 0 component catalogs and 0 ` + + `function definitions. Make sure each catalog props interface ` + + `is annotated with \`@a2uiCatalog \` and each function ` + + `is annotated with \`@a2uiFunction \`.`, ); return 1; } @@ -169,6 +181,14 @@ function printGeneratedComponents(components: { name: string }[]): void { } } +function printGeneratedFunctions(functions: { name: string }[]): void { + if (functions.length === 0) return; + console.info(`Generated ${functions.length} A2UI function definition files.`); + for (const fn of functions) { + console.info(`Generated function definition for ${fn.name}`); + } +} + function readValue(args: string[], index: number, option: string): string { const value = args[index]; if (!value || value.startsWith('-')) { diff --git a/packages/genui/a2ui-catalog-extractor/src/index.ts b/packages/genui/a2ui-catalog-extractor/src/index.ts index 1502ac0d7a..2854ffba6c 100644 --- a/packages/genui/a2ui-catalog-extractor/src/index.ts +++ b/packages/genui/a2ui-catalog-extractor/src/index.ts @@ -63,8 +63,14 @@ export interface FunctionDefinition { | 'void'; } +/** A function discovered in source via `@a2uiFunction`, with its origin path. */ +export interface CatalogFunction extends FunctionDefinition { + filePath: string; +} + interface ParsedDoc { a2uiCatalogName?: string; + a2uiFunctionName?: string; defaultValue?: unknown; deprecated?: boolean; description?: string; @@ -84,10 +90,21 @@ export interface TypeDocReflection { kind?: number; kindString?: string; name: string; + parameters?: TypeDocReflection[]; + signatures?: TypeDocSignature[]; sources?: TypeDocSource[]; type?: TypeDocType; } +export interface TypeDocSignature { + comment?: TypeDocComment; + kind?: number; + kindString?: string; + name?: string; + parameters?: TypeDocReflection[]; + type?: TypeDocType; +} + export interface TypeDocSource { character?: number; fileName?: string; @@ -197,6 +214,62 @@ export function extractCatalogComponentsFromTypeDocJson( return extractCatalogComponentsFromTypeDocProject(project, options); } +export async function extractCatalogFunctions( + options: ExtractCatalogOptions, +): Promise { + const project = await createTypeDocProject(options); + return extractCatalogFunctionsFromTypeDocProject( + project, + options.cwd ? { cwd: options.cwd } : {}, + ); +} + +export function extractCatalogFunctionsFromTypeDocProject( + project: ProjectReflection | TypeDocProject, + options: ExtractCatalogFromTypeDocOptions = {}, +): CatalogFunction[] { + const cwd = options.cwd ? path.resolve(options.cwd) : process.cwd(); + const functions: CatalogFunction[] = []; + + for (const reflection of walkReflections(project as TypeDocProject)) { + const parsedDoc = parseComment(reflection.comment); + let docWithSignature = parsedDoc; + let signature = pickFunctionSignature(reflection); + if (signature && !docWithSignature.a2uiFunctionName) { + docWithSignature = parseComment(signature.comment); + } + if (docWithSignature.a2uiFunctionName === undefined) { + continue; + } + signature ??= pickFunctionSignature(reflection); + if (!signature) { + throw createReflectionError( + reflection, + `\`@a2uiFunction\` requires a function signature on "${reflection.name}".`, + ); + } + + functions.push({ + filePath: getReflectionFilePath(reflection, cwd), + name: docWithSignature.a2uiFunctionName || reflection.name, + ...(docWithSignature.description + ? { description: docWithSignature.description } + : {}), + parameters: createFunctionParametersSchema(signature, reflection), + returnType: resolveReturnType(signature, reflection), + }); + } + + return functions; +} + +export function extractCatalogFunctionsFromTypeDocJson( + project: TypeDocProject, + options: ExtractCatalogFromTypeDocOptions = {}, +): CatalogFunction[] { + return extractCatalogFunctionsFromTypeDocProject(project, options); +} + export async function writeComponentCatalogs( options: WriteComponentCatalogOptions, ): Promise { @@ -222,6 +295,75 @@ export function writeCatalogComponents( } } +export async function writeCatalogFunctionDefinitions( + options: WriteComponentCatalogOptions, +): Promise { + const functions = await extractCatalogFunctions(options); + writeCatalogFunctions(functions, options); + return functions; +} + +export interface CatalogArtifacts { + components: CatalogComponent[]; + functions: CatalogFunction[]; +} + +/** + * Bootstrap TypeDoc once and emit both component and function catalog files. + * Preferred entry point for the CLI — running the conversion twice doubles + * cold-start latency on large catalogs. + */ +export async function writeCatalogArtifacts( + options: WriteComponentCatalogOptions, +): Promise { + const project = await createTypeDocProject(options); + const cwdOptions = options.cwd ? { cwd: options.cwd } : {}; + const components = extractCatalogComponentsFromTypeDocProject( + project, + cwdOptions, + ); + const functions = extractCatalogFunctionsFromTypeDocProject( + project, + cwdOptions, + ); + writeCatalogComponents(components, options); + writeCatalogFunctions(functions, options); + return { components, functions }; +} + +// Function names must be valid JavaScript identifiers so they're safe to +// (a) use as filesystem paths without escaping `..` or path separators and +// (b) survive A2UI 0.9's wire format (`FunctionCall.call` is a bare name). +const FUNCTION_NAME_RE = /^[a-z_$][\w$]*$/i; + +export function writeCatalogFunctions( + functions: CatalogFunction[], + options: { cwd?: string; outDir: string }, +): void { + if (functions.length === 0) return; + const cwd = options.cwd ? path.resolve(options.cwd) : process.cwd(); + const functionsDir = path.join( + path.resolve(cwd, options.outDir), + 'functions', + ); + + fs.mkdirSync(functionsDir, { recursive: true }); + for (const fn of functions) { + if (!FUNCTION_NAME_RE.test(fn.name)) { + throw new Error( + `[a2ui-catalog-extractor] Invalid function name "${fn.name}". ` + + 'Names must match /^[A-Za-z_$][A-Za-z0-9_$]*$/ so they can be ' + + 'used as filenames and as FunctionCall.call identifiers.', + ); + } + const { filePath: _filePath, ...definition } = fn; + fs.writeFileSync( + path.join(functionsDir, `${fn.name}.json`), + `${JSON.stringify({ [fn.name]: definition }, null, 2)}\n`, + ); + } +} + export function createA2UICatalog(options: { catalogId: string; components: CatalogComponent[] | Record; @@ -259,7 +401,11 @@ async function createTypeDocProject( const tsconfigPath = getTsconfigPath(cwd, options.tsconfig); const bootstrapOptions: TypeDocOptions = { - blockTags: [...OptionDefaults.blockTags, '@a2uiCatalog'], + blockTags: [ + ...OptionDefaults.blockTags, + '@a2uiCatalog', + '@a2uiFunction', + ], entryPoints: sourceFiles, excludePrivate: false, excludeProtected: false, @@ -321,6 +467,237 @@ function isInterfaceReflection(reflection: TypeDocReflection): boolean { || reflection.kindString === 'Interface'; } +function pickFunctionSignature( + reflection: TypeDocReflection, +): TypeDocSignature | undefined { + if (reflection.signatures && reflection.signatures.length > 0) { + return reflection.signatures[0]; + } + // Type alias / function-typed variable: `type x = (a: string) => boolean`. + const declarationSignatures = reflection.type?.declaration?.signatures; + if (declarationSignatures && declarationSignatures.length > 0) { + return declarationSignatures[0]; + } + return undefined; +} + +function createFunctionParametersSchema( + signature: TypeDocSignature, + owner: TypeDocReflection, +): JsonSchema { + const parameters = signature.parameters ?? []; + + // A2UI 0.9 function calls carry `args: Record`. The natural + // TypeScript convention is `function fn(args: { name: T1, ... }): R`. When + // we see exactly one inline-object parameter, unwrap it so the emitted + // schema describes the args record directly rather than nesting it under + // a synthetic `args` property. + if (parameters.length === 1) { + const only = parameters[0]!; + if (only.type?.type === 'reflection' && only.type.declaration) { + return parseFunctionObjectReflection(only.type.declaration, owner); + } + } + + const schema: JsonSchema = { + type: 'object', + properties: {}, + required: [], + additionalProperties: false, + }; + + for (const parameter of parameters) { + if (!parameter.type) { + throw createReflectionError( + owner, + `Missing type for function parameter "${parameter.name}".`, + ); + } + const propertySchema = parseFunctionParamType(parameter.type, parameter); + applyDocToSchema(propertySchema, parseComment(parameter.comment)); + schema.properties![parameter.name] = propertySchema; + if (!isOptionalProperty(parameter)) { + schema.required!.push(parameter.name); + } + } + + return schema; +} + +/** + * A relaxed sibling of `parseObjectReflection` used for function-arg shapes. + * Permits `any`/`unknown` so validators like `required(value)` and + * formatters like `formatString({ options })` can accept arbitrary inputs + * — the agent receives `{}` (no `type`) which is JSON-Schema-equivalent to + * "any value". + */ +function parseFunctionObjectReflection( + declaration: TypeDocReflection, + owner: TypeDocReflection, +): JsonSchema { + if (declaration.children === undefined) { + throw createReflectionError( + owner, + `Missing object declaration for "${owner.name}".`, + ); + } + + const schema: JsonSchema = { + type: 'object', + properties: {}, + required: [], + additionalProperties: false, + }; + + for (const child of declaration.children) { + if (!isPropertyReflection(child)) { + continue; + } + if (!child.type) { + throw createReflectionError(child, `Missing type for "${child.name}".`); + } + const propertySchema = parseFunctionParamType(child.type, child); + applyDocToSchema(propertySchema, parseComment(child.comment)); + schema.properties![child.name] = propertySchema; + if (!isOptionalProperty(child)) { + schema.required!.push(child.name); + } + } + + return schema; +} + +function parseFunctionParamType( + type: TypeDocType, + owner: TypeDocReflection, +): JsonSchema { + if (type.type === 'intrinsic') { + const name = type.name ?? ''; + if (name === 'unknown' || name === 'any') { + // JSON Schema for "any value" — drop the type constraint. + return {}; + } + } + if (type.type === 'reflection' && type.declaration) { + return parseFunctionObjectReflection(type.declaration, owner); + } + if (type.type === 'array' && type.elementType) { + return { + type: 'array', + items: parseFunctionParamType(type.elementType, owner), + }; + } + if (type.type === 'union' && type.types) { + const actualTypes = type.types.filter(t => !isUndefinedType(t)); + if (actualTypes.length === 1) { + return parseFunctionParamType(actualTypes[0]!, owner); + } + if ( + actualTypes.some(t => + t.type === 'intrinsic' + && (t.name === 'unknown' || t.name === 'any') + ) + ) { + return {}; + } + } + if (type.type === 'reference') { + const referenceName = String(type.name ?? type.qualifiedName ?? ''); + if (referenceName === 'Record' && type.typeArguments?.length === 2) { + return { + type: 'object', + additionalProperties: parseFunctionParamType( + type.typeArguments[1]!, + owner, + ), + }; + } + } + return parseTypeDocType(type, owner); +} + +function resolveReturnType( + signature: TypeDocSignature, + owner: TypeDocReflection, +): FunctionDefinition['returnType'] { + const type = signature.type; + if (!type) { + return 'void'; + } + return mapTypeToReturnType(type, owner); +} + +function mapTypeToReturnType( + type: TypeDocType, + owner: TypeDocReflection, +): FunctionDefinition['returnType'] { + switch (type.type) { + case 'intrinsic': { + const name = type.name ?? ''; + if ( + name === 'string' || name === 'number' || name === 'boolean' + || name === 'void' || name === 'any' + ) { + return name; + } + throw createReflectionError( + owner, + `Unsupported function return type "${name}" for "${owner.name}". ` + + `Expected string, number, boolean, array, object, any, or void.`, + ); + } + case 'array': + return 'array'; + case 'reflection': + case 'reference': { + const referenceName = String(type.name ?? type.qualifiedName ?? ''); + if (referenceName === 'Promise') { + throw createReflectionError( + owner, + `Async functions are not supported by A2UI 0.9; "${owner.name}" ` + + `must return a synchronous value.`, + ); + } + if (referenceName === 'Array' || referenceName === 'ReadonlyArray') { + return 'array'; + } + if (referenceName === 'Record' || type.type === 'reflection') { + return 'object'; + } + return 'object'; + } + case 'union': { + const actualTypes = (type.types ?? []).filter( + candidate => !isUndefinedType(candidate), + ); + if (actualTypes.length === 1) { + return mapTypeToReturnType(actualTypes[0]!, owner); + } + const mapped = actualTypes.map(candidate => + mapTypeToReturnType(candidate, owner) + ); + if (mapped.every(value => value === mapped[0])) { + return mapped[0]!; + } + return 'any'; + } + case 'literal': { + switch (typeof type.value) { + case 'string': + return 'string'; + case 'number': + return 'number'; + case 'boolean': + return 'boolean'; + default: + return 'any'; + } + } + default: + return 'any'; + } +} + function isPropertyReflection(reflection: TypeDocReflection): boolean { return reflection.kind === ReflectionKind.Property || reflection.kindString === 'Property'; @@ -591,9 +968,16 @@ function parseReferenceType( 'A2UI catalog Record keys must be string-compatible.', ); } + const valueType = typeArguments[1]!; + if (isAmbiguousIntrinsicType(valueType)) { + return { + type: 'object', + additionalProperties: true, + }; + } return { type: 'object', - additionalProperties: parseTypeDocType(typeArguments[1]!, owner), + additionalProperties: parseTypeDocType(valueType, owner), }; } @@ -624,6 +1008,9 @@ function parseComment(comment: TypeDocComment | undefined): ParsedDoc { case '@a2uiCatalog': parsedDoc.a2uiCatalogName = content; break; + case '@a2uiFunction': + parsedDoc.a2uiFunctionName = content; + break; case '@remarks': if (content) { parsedDoc.description = parsedDoc.description @@ -737,6 +1124,11 @@ function isNullType(type: TypeDocType): boolean { || (type.type === 'literal' && type.value === null); } +function isAmbiguousIntrinsicType(type: TypeDocType): boolean { + return type.type === 'intrinsic' + && (type.name === 'unknown' || type.name === 'any'); +} + function getStringLiteralValue(type: TypeDocType): string | undefined { if (type.type === 'literal' && typeof type.value === 'string') { return type.value; diff --git a/packages/genui/a2ui-catalog-extractor/test/extractor-functions.test.ts b/packages/genui/a2ui-catalog-extractor/test/extractor-functions.test.ts new file mode 100644 index 0000000000..4030121b0e --- /dev/null +++ b/packages/genui/a2ui-catalog-extractor/test/extractor-functions.test.ts @@ -0,0 +1,121 @@ +// Copyright 2026 The Lynx Authors. All rights reserved. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { afterAll, describe, expect, test } from '@rstest/core'; + +import { + extractCatalogFunctions, + findCatalogSourceFiles, + writeCatalogArtifacts, +} from '../src/index.js'; + +const __filename = fileURLToPath(import.meta.url); +const packageDir = path.resolve(path.dirname(__filename), '..'); +const fixtureDir = path.join(packageDir, 'test/fixtures'); +const catalogFixtureDir = path.join(fixtureDir, 'catalog'); +const functionsFixtureDir = path.join(fixtureDir, 'functions'); +const expectedFunctionsDir = path.join(fixtureDir, 'expected-functions'); +const fixtureTsconfig = 'tsconfig.json'; +const tempDirs: string[] = []; + +void afterAll(() => { + for (const dir of tempDirs) { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +describe('extractCatalogFunctions', () => { + test('extracts function definitions from TS fixtures', async () => { + const functions = await extractCatalogFunctions({ + cwd: fixtureDir, + sourceFiles: findCatalogSourceFiles(functionsFixtureDir), + tsconfig: fixtureTsconfig, + }); + + const byName = Object.fromEntries(functions.map(fn => [fn.name, fn])); + expect(Object.keys(byName).sort()).toEqual(['formatString', 'required']); + + const expectedRequired = readExpectedFunctionJson('required'); + const expectedFormatString = readExpectedFunctionJson('formatString'); + + expect(byName['required']).toMatchObject({ + filePath: path.join(functionsFixtureDir, 'basicFunctions.ts'), + ...expectedRequired['required'], + }); + expect(byName['formatString']).toMatchObject({ + filePath: path.join(functionsFixtureDir, 'basicFunctions.ts'), + ...expectedFormatString['formatString'], + }); + }); + + test('writeCatalogArtifacts emits both component and function files', async () => { + const outDir = createTempDir(); + const sourceFiles = [ + ...findCatalogSourceFiles(catalogFixtureDir), + ...findCatalogSourceFiles(functionsFixtureDir), + ]; + + const { components, functions } = await writeCatalogArtifacts({ + cwd: fixtureDir, + outDir, + sourceFiles, + tsconfig: fixtureTsconfig, + }); + + expect(components.map(component => component.name).sort()).toEqual([ + 'DemoCard', + 'DemoText', + 'QuickStartCard', + ]); + expect(functions.map(fn => fn.name).sort()).toEqual([ + 'formatString', + 'required', + ]); + + expect(readFunctionJson(outDir, 'required')).toEqual( + readExpectedFunctionJson('required'), + ); + expect(readFunctionJson(outDir, 'formatString')).toEqual( + readExpectedFunctionJson('formatString'), + ); + }); + + test('rejects async return types', async () => { + await expect(extractCatalogFunctions({ + cwd: fixtureDir, + sourceFiles: ['invalid/AsyncFunction.ts'], + tsconfig: fixtureTsconfig, + })).rejects.toThrow(/Async functions are not supported/); + }); +}); + +function createTempDir(): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'a2ui-catalog-out-')); + tempDirs.push(dir); + return dir; +} + +function readExpectedFunctionJson( + name: string, +): Record> { + return JSON.parse( + fs.readFileSync(path.join(expectedFunctionsDir, `${name}.json`), 'utf8'), + ) as Record>; +} + +function readFunctionJson( + rootDir: string, + name: string, +): Record { + return JSON.parse( + fs.readFileSync( + path.join(rootDir, 'functions', `${name}.json`), + 'utf8', + ), + ) as Record; +} diff --git a/packages/genui/a2ui-catalog-extractor/test/fixtures/catalog/DemoCard.tsx b/packages/genui/a2ui-catalog-extractor/test/fixtures/catalog/DemoCard.tsx index 410d414719..9bfc01aaf8 100644 --- a/packages/genui/a2ui-catalog-extractor/test/fixtures/catalog/DemoCard.tsx +++ b/packages/genui/a2ui-catalog-extractor/test/fixtures/catalog/DemoCard.tsx @@ -21,6 +21,8 @@ export interface DemoCardProps { * @defaultValue `{}` */ context?: Record; + /** Free-form function argument bag. */ + functionArgs?: Record; /** Server-dispatched action payload. */ action: { event: { diff --git a/packages/genui/a2ui-catalog-extractor/test/fixtures/expected-catalog/DemoCard/catalog.json b/packages/genui/a2ui-catalog-extractor/test/fixtures/expected-catalog/DemoCard/catalog.json index b21ddc9299..a2325f1d45 100644 --- a/packages/genui/a2ui-catalog-extractor/test/fixtures/expected-catalog/DemoCard/catalog.json +++ b/packages/genui/a2ui-catalog-extractor/test/fixtures/expected-catalog/DemoCard/catalog.json @@ -70,6 +70,11 @@ "description": "Extra payload.", "default": {} }, + "functionArgs": { + "type": "object", + "additionalProperties": true, + "description": "Free-form function argument bag." + }, "action": { "type": "object", "properties": { diff --git a/packages/genui/a2ui-catalog-extractor/test/fixtures/expected-functions/formatString.json b/packages/genui/a2ui-catalog-extractor/test/fixtures/expected-functions/formatString.json new file mode 100644 index 0000000000..c1729de868 --- /dev/null +++ b/packages/genui/a2ui-catalog-extractor/test/fixtures/expected-functions/formatString.json @@ -0,0 +1,20 @@ +{ + "formatString": { + "name": "formatString", + "description": "Interpolates `${path}` placeholders against the data model.", + "parameters": { + "type": "object", + "properties": { + "value": { + "type": "string", + "description": "Template literal with `${path}` placeholders." + } + }, + "required": [ + "value" + ], + "additionalProperties": false + }, + "returnType": "string" + } +} diff --git a/packages/genui/a2ui-catalog-extractor/test/fixtures/expected-functions/required.json b/packages/genui/a2ui-catalog-extractor/test/fixtures/expected-functions/required.json new file mode 100644 index 0000000000..500583dd0b --- /dev/null +++ b/packages/genui/a2ui-catalog-extractor/test/fixtures/expected-functions/required.json @@ -0,0 +1,20 @@ +{ + "required": { + "name": "required", + "description": "Returns true when the value is not empty.", + "parameters": { + "type": "object", + "properties": { + "value": { + "type": "string", + "description": "The value to check." + } + }, + "required": [ + "value" + ], + "additionalProperties": false + }, + "returnType": "boolean" + } +} diff --git a/packages/genui/a2ui-catalog-extractor/test/fixtures/functions/basicFunctions.ts b/packages/genui/a2ui-catalog-extractor/test/fixtures/functions/basicFunctions.ts new file mode 100644 index 0000000000..e8e118314b --- /dev/null +++ b/packages/genui/a2ui-catalog-extractor/test/fixtures/functions/basicFunctions.ts @@ -0,0 +1,34 @@ +// Copyright 2026 The Lynx Authors. All rights reserved. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. + +/** + * Returns true when the value is not empty. + * + * @a2uiFunction required + */ +export function required(args: { + /** The value to check. */ + value: string; +}): boolean { + return args.value.length > 0; +} + +/** + * Interpolates `${path}` placeholders against the data model. + * + * @a2uiFunction formatString + */ +export function formatString(args: { + /** Template literal with `${path}` placeholders. */ + value: string; +}): string { + return args.value; +} + +/** + * Helper that is not marked with `@a2uiFunction`; should be ignored. + */ +export function notExported(args: { value: string }): string { + return args.value; +} diff --git a/packages/genui/a2ui-catalog-extractor/test/fixtures/invalid/AsyncFunction.ts b/packages/genui/a2ui-catalog-extractor/test/fixtures/invalid/AsyncFunction.ts new file mode 100644 index 0000000000..15b13b6289 --- /dev/null +++ b/packages/genui/a2ui-catalog-extractor/test/fixtures/invalid/AsyncFunction.ts @@ -0,0 +1,12 @@ +// Copyright 2026 The Lynx Authors. All rights reserved. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. + +/** + * Async functions are not supported by A2UI 0.9. + * + * @a2uiFunction asyncWork + */ +export function asyncWork(args: { value: string }): Promise { + return Promise.resolve(args.value); +} diff --git a/packages/genui/a2ui-catalog-extractor/test/fixtures/tsconfig.json b/packages/genui/a2ui-catalog-extractor/test/fixtures/tsconfig.json index 2e6721505c..ad59226cea 100644 --- a/packages/genui/a2ui-catalog-extractor/test/fixtures/tsconfig.json +++ b/packages/genui/a2ui-catalog-extractor/test/fixtures/tsconfig.json @@ -6,5 +6,10 @@ "strict": true, "target": "ESNext", }, - "include": ["catalog/**/*.tsx", "invalid/**/*.tsx"], + "include": [ + "catalog/**/*.tsx", + "invalid/**/*.tsx", + "invalid/**/*.ts", + "functions/**/*.ts", + ], } diff --git a/packages/genui/a2ui-playground/lynx-src/a2ui/App.tsx b/packages/genui/a2ui-playground/lynx-src/a2ui/App.tsx index ab24e2e8a3..7ac5ad4fb8 100644 --- a/packages/genui/a2ui-playground/lynx-src/a2ui/App.tsx +++ b/packages/genui/a2ui-playground/lynx-src/a2ui/App.tsx @@ -15,6 +15,7 @@ import { Row, Tabs, Text, + basicFunctions, createMessageStore, normalizePayloadToMessages as normalizeProtocolMessages, } from '@lynx-js/a2ui-reactlynx'; @@ -58,9 +59,11 @@ const DEFAULT_STREAM_DELAY_MS = 800; // shipped from the package — this list makes the cost of "everything" // visible and lets the bundler tree-shake when you only need a few. // -// Schemas are not attached because the playground doesn't perform an -// agent handshake. To include schemas, pair each component with its -// `catalog.json` manifest — see +// Function entries are included because the gallery payloads use A2UI +// basic-catalog calls such as `formatDate` in dynamic props and checks. +// +// To include component schemas, pair each component with its `catalog.json` +// manifest — see // `packages/genui/a2ui/src/catalog/README.md`. function manifestEntry( component: unknown, @@ -82,6 +85,7 @@ const ALL_BUILTINS: readonly CatalogInput[] = [ manifestEntry(CheckBox, checkBoxManifest), manifestEntry(RadioGroup, radioGroupManifest), manifestEntry(Tabs, tabsManifest), + ...basicFunctions, ]; interface InitData { diff --git a/packages/genui/a2ui/package.json b/packages/genui/a2ui/package.json index 2c303f288e..190d729423 100644 --- a/packages/genui/a2ui/package.json +++ b/packages/genui/a2ui/package.json @@ -84,6 +84,10 @@ "./react": { "types": "./dist/react/index.d.ts", "default": "./dist/react/index.js" + }, + "./functions": { + "types": "./dist/functions/index.d.ts", + "default": "./dist/functions/index.js" } }, "main": "./dist/index.js", diff --git a/packages/genui/a2ui/src/catalog/Button/index.tsx b/packages/genui/a2ui/src/catalog/Button/index.tsx index 7a3a9d1d59..41aa5551ac 100644 --- a/packages/genui/a2ui/src/catalog/Button/index.tsx +++ b/packages/genui/a2ui/src/catalog/Button/index.tsx @@ -2,6 +2,8 @@ // Licensed under the Apache License Version 2.0 that can be found in the // LICENSE file in the root directory of this source tree. import { A2UIRenderer } from '../../react/A2UIRenderer.jsx'; +import { useChecks } from '../../react/useChecks.js'; +import type { CheckLike } from '../../react/useChecks.js'; import type { GenericComponentProps } from '../../store/types.js'; import '../../../styles/catalog/Button.css'; @@ -18,9 +20,37 @@ export interface ButtonProps extends GenericComponentProps { event: { name: string; /** Context is a JSON object map in v0.9. */ - context?: Record; + context?: Record; + }; + } | { + functionCall: { + call: string; + args: Record; + returnType?: + | 'string' + | 'number' + | 'boolean' + | 'array' + | 'object' + | 'any' + | 'void'; }; }; + checks?: Array<{ + condition: boolean | { path: string } | { + call: string; + args: Record; + returnType?: + | 'string' + | 'number' + | 'boolean' + | 'array' + | 'object' + | 'any' + | 'void'; + }; + message: string; + }>; } export function Button( @@ -29,14 +59,28 @@ export function Button( const { action, child, + id, isValid = true, surface, sendAction, variant = 'primary', + dataContextPath, } = props; + const checks = props.checks as CheckLike[] | undefined; + const { ok, firstFailureMessage } = useChecks({ + checks, + componentId: id ?? '', + surface, + dataContextPath, + }); + + // The button is interactive only when both gates pass: `isValid` (the + // agent's imperative "disabled" signal) and `ok` (the data-driven result + // of evaluating the `checks` array). + const enabled = isValid && ok; const handleClick = () => { - if (isValid && action) { + if (enabled && action) { void sendAction?.(action as Record); } }; @@ -46,13 +90,20 @@ export function Button( : undefined; return ( - - {childResource - ? - : Button} - + <> + + {childResource + ? + : Button} + + {!ok && firstFailureMessage + ? {firstFailureMessage} + : null} + ); } diff --git a/packages/genui/a2ui/src/catalog/CheckBox/index.tsx b/packages/genui/a2ui/src/catalog/CheckBox/index.tsx index 73ada4ae33..84a99f21e5 100644 --- a/packages/genui/a2ui/src/catalog/CheckBox/index.tsx +++ b/packages/genui/a2ui/src/catalog/CheckBox/index.tsx @@ -1,6 +1,8 @@ // Copyright 2026 The Lynx Authors. All rights reserved. // Licensed under the Apache License Version 2.0 that can be found in the // LICENSE file in the root directory of this source tree. +import { useChecks } from '../../react/useChecks.js'; +import type { CheckLike } from '../../react/useChecks.js'; import type { GenericComponentProps } from '../../store/types.js'; import '../../../styles/catalog/CheckBox.css'; @@ -9,21 +11,77 @@ import '../../../styles/catalog/CheckBox.css'; * @a2uiCatalog CheckBox */ export interface CheckBoxProps extends GenericComponentProps { - label: string | { path: string }; - value: boolean | { path: string }; + label: string | { path: string } | { + call: string; + args: Record; + returnType?: + | 'string' + | 'number' + | 'boolean' + | 'array' + | 'object' + | 'any' + | 'void'; + }; + value: boolean | { path: string } | { + call: string; + args: Record; + returnType?: + | 'string' + | 'number' + | 'boolean' + | 'array' + | 'object' + | 'any' + | 'void'; + }; + checks?: Array<{ + condition: boolean | { path: string } | { + call: string; + args: Record; + returnType?: + | 'string' + | 'number' + | 'boolean' + | 'array' + | 'object' + | 'any' + | 'void'; + }; + message: string; + }>; } export function CheckBox( props: CheckBoxProps, ): import('@lynx-js/react').ReactNode { - const { id, label = 'CheckBox', value, setValue } = props; + const { + id, + label = 'CheckBox', + value, + setValue, + surface, + dataContextPath, + } = props; + const checks = props.checks as CheckLike[] | undefined; + + const { ok, firstFailureMessage } = useChecks({ + checks, + componentId: id ?? '', + surface, + dataContextPath, + }); const handleChange = () => { setValue?.('value', !value); }; return ( - + ✓} {label as string} + {!ok && firstFailureMessage + ? {firstFailureMessage} + : null} ); } diff --git a/packages/genui/a2ui/src/catalog/RadioGroup/index.tsx b/packages/genui/a2ui/src/catalog/RadioGroup/index.tsx index 8f2580b6c8..4f5d938beb 100644 --- a/packages/genui/a2ui/src/catalog/RadioGroup/index.tsx +++ b/packages/genui/a2ui/src/catalog/RadioGroup/index.tsx @@ -3,6 +3,8 @@ // LICENSE file in the root directory of this source tree. import { Radio, RadioGroupRoot, RadioIndicator } from '@lynx-js/lynx-ui'; +import { useChecks } from '../../react/useChecks.js'; +import type { CheckLike } from '../../react/useChecks.js'; import type { GenericComponentProps } from '../../store/types.js'; import '../../../styles/catalog/RadioGroup.css'; @@ -21,11 +23,48 @@ const HitSlop = { */ export interface RadioGroupComponentProps extends GenericComponentProps { /** The list of string options to display. */ - items: string[] | { path: string }; + items: string[] | { path: string } | { + call: string; + args: Record; + returnType?: + | 'string' + | 'number' + | 'boolean' + | 'array' + | 'object' + | 'any' + | 'void'; + }; /** The currently selected value. */ - value: string | { path: string }; + value: string | { path: string } | { + call: string; + args: Record; + returnType?: + | 'string' + | 'number' + | 'boolean' + | 'array' + | 'object' + | 'any' + | 'void'; + }; /** A hint for the visual style of the radio group. */ usageHint?: 'default' | 'card' | 'row'; + checks?: Array<{ + condition: boolean | { path: string } | { + call: string; + args: Record; + returnType?: + | 'string' + | 'number' + | 'boolean' + | 'array' + | 'object' + | 'any' + | 'void'; + }; + message: string; + }>; } export function RadioGroup( @@ -38,13 +77,25 @@ export function RadioGroup( | ((key: string, value: unknown) => void) | undefined; const explicitItems = Array.isArray(items) ? items : []; + const checks = props.checks as CheckLike[] | undefined; + + const { ok, firstFailureMessage } = useChecks({ + checks, + componentId: props.id ?? '', + surface: props.surface, + dataContextPath: props.dataContextPath, + }); const handleValueChange = (newValue: string) => { setValue?.('value', newValue); }; return ( - + {explicitItems.map((itemValue: string) => ( @@ -63,6 +114,9 @@ export function RadioGroup( ))} + {!ok && firstFailureMessage + ? {firstFailureMessage} + : null} ); } diff --git a/packages/genui/a2ui/src/catalog/Text/index.tsx b/packages/genui/a2ui/src/catalog/Text/index.tsx index 06fe3b3394..1d6c633a78 100644 --- a/packages/genui/a2ui/src/catalog/Text/index.tsx +++ b/packages/genui/a2ui/src/catalog/Text/index.tsx @@ -8,8 +8,19 @@ import '../../../styles/catalog/Text.css'; * @a2uiCatalog Text */ export interface TextProps extends GenericComponentProps { - /** Literal text or path binding. */ - text: string | { path: string }; + /** Literal text, path binding, or function call. */ + text: string | { path: string } | { + call: string; + args: Record; + returnType?: + | 'string' + | 'number' + | 'boolean' + | 'array' + | 'object' + | 'any' + | 'void'; + }; variant?: 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'caption' | 'body'; weight?: number; } diff --git a/packages/genui/a2ui/src/catalog/defineCatalog.ts b/packages/genui/a2ui/src/catalog/defineCatalog.ts index f0b5ea56dd..a79c744d8d 100644 --- a/packages/genui/a2ui/src/catalog/defineCatalog.ts +++ b/packages/genui/a2ui/src/catalog/defineCatalog.ts @@ -3,6 +3,11 @@ // LICENSE file in the root directory of this source tree. import type { ComponentType } from '@lynx-js/react'; +import { functionRegistry } from '../store/FunctionRegistry.js'; +import type { + FunctionDefinition, + FunctionImpl, +} from '../store/FunctionRegistry.js'; import type { GenericComponentProps } from '../store/types.js'; /** @@ -27,21 +32,82 @@ export type CatalogManifest = Record; export type CatalogComponent = ComponentType; /** - * What the developer passes into `defineCatalog`. Three forms: + * A function-definition manifest as emitted by the extractor under + * `dist/catalog/functions/.json`. + */ +/** + * The structured function definition that flows from the extractor (or the + * upstream basic-catalog manifests) into the catalog and out to the agent + * during the handshake. Aliased onto the store's `FunctionDefinition` so + * `functionRegistry.register({ definition })` accepts catalog entries + * directly without re-shaping. + */ +export type CatalogFunctionDefinition = FunctionDefinition; + +export type FunctionManifest = Record; + +/** + * A function entry — pair an impl with its name, plus an optional manifest + * so the handshake can announce the schema to the agent. + */ +export interface CatalogFunctionEntry { + kind: 'function'; + name: string; + impl: FunctionImpl; + definition?: CatalogFunctionDefinition; +} + +/** + * Build a function entry for `defineCatalog`. Either pair the impl with the + * extracted manifest, or pass just the impl (the registry still routes + * calls; the agent just won't see the parameter schema). * - * - **Bare component** — name is read from `displayName ?? name`. Useful - * for renderer-only consumers who don't need to announce schemas to the - * agent. - * - **`[component, manifest]` tuple** — name and schema are read from the - * manifest. Use this when you want `serializeCatalog(...)` to include a - * schema for this component in the agent handshake. - * - **`ResolvedCatalogEntry`** — pass-through for already-resolved entries - * (e.g. the output of `mergeCatalogs(...)` or another `defineCatalog`). + * @example + * const requiredEntry = defineFunction(required, requiredManifest); + * const catalog = defineCatalog([Text, Button, requiredEntry]); + */ +export function defineFunction( + impl: FunctionImpl, + manifest?: FunctionManifest, +): CatalogFunctionEntry { + if (manifest) { + const keys = Object.keys(manifest); + if (keys.length === 0) { + throw new Error( + '[a2ui] Empty manifest passed to defineFunction; expected ' + + '`{ : definition }`.', + ); + } + const name = keys[0]!; + const definition = manifest[name]!; + return { + kind: 'function', + name, + impl, + definition, + }; + } + const name = (impl as { displayName?: string; name?: string }).displayName + ?? (impl as { name?: string }).name; + if (!name) { + throw new Error( + '[a2ui] Cannot add a function to the catalog: no name available. ' + + 'Pair it with its manifest as `defineFunction(impl, manifest)`, ' + + 'or set `impl.displayName = "..."`.', + ); + } + return { kind: 'function', name, impl }; +} + +/** + * What the developer passes into `defineCatalog`. Components and function + * entries can be intermixed. */ export type CatalogInput = | CatalogComponent | readonly [CatalogComponent, CatalogManifest] - | ResolvedCatalogEntry; + | ResolvedCatalogEntry + | CatalogFunctionEntry; /** * A resolved catalog entry. Internal representation; consumers don't usually @@ -53,12 +119,23 @@ export interface ResolvedCatalogEntry { schema?: CatalogSchema; } -export type Catalog = readonly ResolvedCatalogEntry[]; +export interface Catalog { + readonly components: readonly ResolvedCatalogEntry[]; + readonly functions: readonly CatalogFunctionEntry[]; +} /** The serialized payload sent to the agent during channel handshake. */ export interface SerializedCatalog { version: '0.9'; components: Array<{ name: string; schema?: CatalogSchema }>; + functions?: CatalogFunctionDefinition[]; +} + +function isFunctionEntry(input: CatalogInput): input is CatalogFunctionEntry { + return typeof input === 'object' + && input !== null + && !Array.isArray(input) + && (input as CatalogFunctionEntry).kind === 'function'; } function isResolvedEntry(input: CatalogInput): input is ResolvedCatalogEntry { @@ -66,7 +143,8 @@ function isResolvedEntry(input: CatalogInput): input is ResolvedCatalogEntry { && input !== null && !Array.isArray(input) && 'component' in input - && 'name' in input; + && 'name' in input + && !isFunctionEntry(input); } function isTuple( @@ -91,7 +169,7 @@ function deriveBareName(component: CatalogComponent): string { return name; } -function resolveInput(input: CatalogInput): ResolvedCatalogEntry { +function resolveComponentInput(input: CatalogInput): ResolvedCatalogEntry { if (isResolvedEntry(input)) return input; if (isTuple(input)) { const [component, manifest] = input; @@ -103,45 +181,71 @@ function resolveInput(input: CatalogInput): ResolvedCatalogEntry { ); } const name = keys[0]!; - const entry: ResolvedCatalogEntry = { name, component }; + const entry: ResolvedCatalogEntry = { name, component: component }; const schema = manifest[name]; if (schema !== undefined) entry.schema = schema; return entry; } - return { name: deriveBareName(input), component: input }; + return { + name: deriveBareName(input as CatalogComponent), + component: input as CatalogComponent, + }; } /** - * Build a catalog from a list of components and/or `[component, manifest]` - * pairs. The protocol name comes from the manifest key (tuple form) or - * from `displayName ?? name` (bare component form). Duplicate names are - * rejected. + * Build a catalog from a list of components, `[component, manifest]` pairs, + * and/or function entries. Duplicate names within the same kind are rejected. + * Function entries register their impls into `functionRegistry` immediately, + * so any `executeFunctionCall` after `defineCatalog` can route to them. * * @example * import { Text, Button } from '@lynx-js/a2ui-reactlynx'; + * import { defineCatalog, defineFunction } from '@lynx-js/a2ui-reactlynx'; + * import { required } from '@lynx-js/a2ui-reactlynx/functions'; * import textManifest from '@lynx-js/a2ui-reactlynx/catalog/Text/catalog.json' * with { type: 'json' }; * * const catalog = defineCatalog([ - * [Text, textManifest], // renderer + handshake metadata - * Button, // renderer-only + * [Text, textManifest], + * Button, + * defineFunction(required), * ]); */ export function defineCatalog(inputs: readonly CatalogInput[]): Catalog { - const entries: ResolvedCatalogEntry[] = []; - const seen = new Set(); + const components: ResolvedCatalogEntry[] = []; + const functions: CatalogFunctionEntry[] = []; + const seenComponents = new Set(); + const seenFunctions = new Set(); + for (const input of inputs) { - const entry = resolveInput(input); - if (seen.has(entry.name)) { + if (isFunctionEntry(input)) { + if (seenFunctions.has(input.name)) { + throw new Error( + `[a2ui] Duplicate function name in catalog: "${input.name}". ` + + `Use mergeCatalogs() if you intend to override.`, + ); + } + seenFunctions.add(input.name); + functions.push(input); + functionRegistry.register({ + name: input.name, + impl: input.impl, + ...(input.definition ? { definition: input.definition } : {}), + }); + continue; + } + const entry = resolveComponentInput(input); + if (seenComponents.has(entry.name)) { throw new Error( `[a2ui] Duplicate component name in catalog: "${entry.name}". ` + `Use mergeCatalogs() if you intend to override.`, ); } - seen.add(entry.name); - entries.push(entry); + seenComponents.add(entry.name); + components.push(entry); } - return entries; + + return { components, functions }; } /** @@ -149,11 +253,24 @@ export function defineCatalog(inputs: readonly CatalogInput[]): Catalog { * a page-level catalog overrides a brand-level one which overrides built-ins. */ export function mergeCatalogs(...catalogs: Catalog[]): Catalog { - const map = new Map(); + const componentMap = new Map(); + const functionMap = new Map(); for (const cat of catalogs) { - for (const entry of cat) map.set(entry.name, entry); + for (const entry of cat.components) componentMap.set(entry.name, entry); + for (const fn of cat.functions) functionMap.set(fn.name, fn); + } + // Re-register so the registry tracks the merged set. + for (const fn of functionMap.values()) { + functionRegistry.register({ + name: fn.name, + impl: fn.impl, + ...(fn.definition ? { definition: fn.definition } : {}), + }); } - return Array.from(map.values()); + return { + components: Array.from(componentMap.values()), + functions: Array.from(functionMap.values()), + }; } /** @@ -164,22 +281,28 @@ export function resolveCatalog( catalog: Catalog, ): ReadonlyMap { const map = new Map(); - for (const entry of catalog) map.set(entry.name, entry.component); + for (const entry of catalog.components) map.set(entry.name, entry.component); return map; } /** * Produce the JSON manifest the client should announce to the agent during - * channel handshake. Entries without an attached schema serialize to - * `{ name }` only — useful for letting the agent at least know what's - * renderable. + * channel handshake. Component entries without an attached schema serialize + * to `{ name }` only — useful for letting the agent at least know what's + * renderable. Function entries serialize with their parameter schema when + * available. */ export function serializeCatalog(catalog: Catalog): SerializedCatalog { const components: Array<{ name: string; schema?: CatalogSchema }> = []; - for (const entry of catalog) { + for (const entry of catalog.components) { const out: { name: string; schema?: CatalogSchema } = { name: entry.name }; if (entry.schema !== undefined) out.schema = entry.schema; components.push(out); } - return { version: '0.9', components }; + const serialized: SerializedCatalog = { version: '0.9', components }; + const functions = catalog.functions + .filter(fn => fn.definition !== undefined) + .map(fn => fn.definition!); + if (functions.length > 0) serialized.functions = functions; + return serialized; } diff --git a/packages/genui/a2ui/src/catalog/index.ts b/packages/genui/a2ui/src/catalog/index.ts index c696c3e50f..88cac90897 100755 --- a/packages/genui/a2ui/src/catalog/index.ts +++ b/packages/genui/a2ui/src/catalog/index.ts @@ -3,6 +3,7 @@ // LICENSE file in the root directory of this source tree. export { defineCatalog, + defineFunction, mergeCatalogs, resolveCatalog, serializeCatalog, @@ -10,9 +11,12 @@ export { export type { Catalog, CatalogComponent, + CatalogFunctionDefinition, + CatalogFunctionEntry, CatalogInput, CatalogManifest, CatalogSchema, + FunctionManifest, ResolvedCatalogEntry, SerializedCatalog, } from './defineCatalog.js'; diff --git a/packages/genui/a2ui/src/functions/index.ts b/packages/genui/a2ui/src/functions/index.ts new file mode 100644 index 0000000000..e70118c31c --- /dev/null +++ b/packages/genui/a2ui/src/functions/index.ts @@ -0,0 +1,122 @@ +// Copyright 2026 The Lynx Authors. All rights reserved. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. +import { + Catalog as A2UICoreCatalog, + MessageProcessor as A2UICoreMessageProcessor, + isSignal, +} from '@a2ui/web_core/v0_9'; +import type { DataContext, FunctionImplementation } from '@a2ui/web_core/v0_9'; +import { BASIC_FUNCTIONS } from '@a2ui/web_core/v0_9/basic_catalog'; + +import { defineFunction } from '../catalog/defineCatalog.js'; +import type { + CatalogFunctionDefinition, + CatalogFunctionEntry, + FunctionManifest, +} from '../catalog/defineCatalog.js'; +import { functionRegistry } from '../store/FunctionRegistry.js'; +import type { + FunctionCallContext, + FunctionImpl, +} from '../store/FunctionRegistry.js'; + +const BASIC_CATALOG_ID = + 'https://a2ui.org/specification/v0_9/basic_catalog.json'; + +function createUpstreamContext( + context: FunctionCallContext | undefined, +): DataContext { + return { + resolveDynamicValue(value: unknown) { + return context?.resolveDynamicValue(value); + }, + resolveSignal(value: unknown) { + return context?.resolveSignal(value); + }, + set(path: string, value: unknown) { + context?.set(path, value); + }, + path: context?.dataContextPath ?? '/', + } as unknown as DataContext; +} + +/** + * Adapt an upstream `FunctionImplementation` (zod-typed args, returns a + * raw value OR a Preact Signal, takes a `DataContext`) into the simpler + * `(args) => unknown` shape the renderer's `executeFunctionCall` expects. + */ +function adaptUpstreamImpl(impl: FunctionImplementation): FunctionImpl { + return (args, context) => { + const safeArgs = impl.schema.parse(args) as Record; + const result: unknown = impl.execute( + safeArgs, + createUpstreamContext(context), + ); + if (isSignal(result)) return result.value as unknown; + return result; + }; +} + +const adaptedBasicFunctionImpls: readonly { + name: string; + impl: FunctionImpl; +}[] = BASIC_FUNCTIONS.map(fn => ({ + name: fn.name, + impl: adaptUpstreamImpl(fn), +})); + +function createBasicFunctionManifests(): Map { + const upstreamCatalog = new A2UICoreCatalog( + BASIC_CATALOG_ID, + [], + BASIC_FUNCTIONS, + ); + const processor = new A2UICoreMessageProcessor([upstreamCatalog]); + const inlineCatalog = processor.getClientCapabilities({ + includeInlineCatalogs: true, + })['v0.9'].inlineCatalogs?.[0]; + const definitions = inlineCatalog?.functions ?? []; + return new Map(definitions.map(definition => { + const typedDefinition = definition as CatalogFunctionDefinition; + return [ + typedDefinition.name, + { [typedDefinition.name]: typedDefinition }, + ]; + })); +} + +const basicFunctionManifests = createBasicFunctionManifests(); + +/** + * The A2UI 0.9 basic-catalog function implementations packaged as + * `CatalogFunctionEntry`s, ready to spread into ``. + * The impls themselves come from `@a2ui/web_core` so we stay aligned with + * the upstream spec for free. + * + * @example + * + */ +export const basicFunctions: readonly CatalogFunctionEntry[] = + adaptedBasicFunctionImpls + .map(({ name, impl }) => { + Object.defineProperty(impl, 'name', { value: name }); + return defineFunction(impl, basicFunctionManifests.get(name)); + }); + +/** + * Manual escape hatch for consumers who build their own renderer and don't + * go through `defineCatalog`. Registers every adapted basic-catalog impl + * into the shared `functionRegistry`. Calling more than once is harmless — + * later registrations override earlier ones, which is the intended override + * path. + */ +export function registerBasicFunctions(): void { + for (const entry of adaptedBasicFunctionImpls) { + const definition = basicFunctionManifests.get(entry.name)?.[entry.name]; + functionRegistry.register({ + ...entry, + ...(definition ? { definition } : {}), + }); + } +} diff --git a/packages/genui/a2ui/src/index.ts b/packages/genui/a2ui/src/index.ts index 90ef609eb4..14f808fae3 100644 --- a/packages/genui/a2ui/src/index.ts +++ b/packages/genui/a2ui/src/index.ts @@ -8,10 +8,11 @@ export { A2UI, NodeRenderer, useAction, + useChecks, useDataBinding, useResolvedProps, } from './react/index.js'; -export type { A2UIProps, ActionProps } from './react/index.js'; +export type { A2UIProps, ActionProps, CheckLike } from './react/index.js'; // Store — a pure raw-message buffer. The developer's IO module pushes // protocol messages into it; `` subscribes and processes them. @@ -39,10 +40,26 @@ export { normalizePayloadToMessages, prepareMessagesForProcessing, } from './store/index.js'; +// Function registry primitives. Consumers building a custom renderer reach +// the impls through these; `` consumers usually just spread +// `basicFunctions` into `catalogs`. +export { + executeFunctionCall, + functionRegistry, + FunctionRegistry, + resolveDynamicValue, +} from './store/index.js'; +export type { + FunctionCallContext, + FunctionEntry, + FunctionImpl, + ResolveFunctionOptions, +} from './store/index.js'; // Catalog — declarative composition. export { defineCatalog, + defineFunction, mergeCatalogs, resolveCatalog, serializeCatalog, @@ -50,9 +67,12 @@ export { export type { Catalog, CatalogComponent, + CatalogFunctionDefinition, + CatalogFunctionEntry, CatalogInput, CatalogManifest, CatalogSchema, + FunctionManifest, ResolvedCatalogEntry, SerializedCatalog, } from './catalog/index.js'; @@ -79,3 +99,9 @@ export { Text, Icon, } from './catalog/index.js'; + +// A2UI 0.9 basic-catalog functions — registered + announced when spread +// into ``. Impls come from +// `@a2ui/web_core` (the upstream basic-catalog package), so the wire +// contract stays aligned with the spec for free. +export { basicFunctions, registerBasicFunctions } from './functions/index.js'; diff --git a/packages/genui/a2ui/src/react/A2UIRenderer.tsx b/packages/genui/a2ui/src/react/A2UIRenderer.tsx index 6b255b58a3..10bccca1ef 100644 --- a/packages/genui/a2ui/src/react/A2UIRenderer.tsx +++ b/packages/genui/a2ui/src/react/A2UIRenderer.tsx @@ -4,6 +4,7 @@ import { memo, useEffect, useMemo, useSyncExternalStore } from '@lynx-js/react'; import type { ReactNode } from '@lynx-js/react'; +import { useA2UIContext } from './useA2UIContext.js'; import { useAction } from './useAction.js'; import { useCatalog } from './useCatalog.js'; import { splitUnsupportedProps, useResolvedProps } from './useDataBinding.js'; @@ -243,6 +244,7 @@ function NodeRendererImpl( surface, renderUnsupported, } = props; + const { catalog: activeCatalog, processor } = useA2UIContext(); const catalog = useCatalog(); const resource = surface.resources.get(initialComponent.id!); @@ -273,6 +275,8 @@ function NodeRendererImpl( effectiveComponent, surface, effectiveComponent.dataContextPath, + processor, + activeCatalog.functions, ); const actionProps = useMemo( diff --git a/packages/genui/a2ui/src/react/FormContext.ts b/packages/genui/a2ui/src/react/FormContext.ts new file mode 100644 index 0000000000..cea18531ac --- /dev/null +++ b/packages/genui/a2ui/src/react/FormContext.ts @@ -0,0 +1,22 @@ +// Copyright 2026 The Lynx Authors. All rights reserved. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. +import { createContext } from '@lynx-js/react'; + +import type { FormController } from '../store/FormController.js'; + +export type { + CheckFailure, + CheckOutcome, + FormController, +} from '../store/FormController.js'; +export { createFormController } from '../store/FormController.js'; + +/** + * React context exposing the nearest enclosing form controller, if any. + * Inputs use it to broadcast their check outcomes; Buttons use it to read + * `isValid` and disable themselves until every input passes. + */ +export const FormContext: ReturnType< + typeof createContext +> = createContext(null); diff --git a/packages/genui/a2ui/src/react/index.ts b/packages/genui/a2ui/src/react/index.ts index 285641b4de..df107c2f3e 100644 --- a/packages/genui/a2ui/src/react/index.ts +++ b/packages/genui/a2ui/src/react/index.ts @@ -9,9 +9,17 @@ // `A2UIProvider`, `A2UIRenderer`, `A2UIContext`, `useA2UIContext`, and // `useCatalog` are intentionally NOT exported — they're internal details // of how `` mounts itself. Custom components don't need them. +// +// `FormContext` and `FormController` are also internal. `useChecks` reads +// from `FormContext` so a follow-up PR can introduce a `
` component +// that aggregates input validity — exporting the context now would +// pre-commit the package to a Provider-based API before there's a real +// consumer to validate it. export { A2UI } from './A2UI.jsx'; export type { A2UIProps } from './A2UI.jsx'; export { NodeRenderer } from './A2UIRenderer.jsx'; export { useAction } from './useAction.js'; export type { ActionProps } from './useAction.js'; export { useDataBinding, useResolvedProps } from './useDataBinding.js'; +export { useChecks } from './useChecks.js'; +export type { CheckLike } from './useChecks.js'; diff --git a/packages/genui/a2ui/src/react/useAction.ts b/packages/genui/a2ui/src/react/useAction.ts index 6860da4a17..de9caa556b 100644 --- a/packages/genui/a2ui/src/react/useAction.ts +++ b/packages/genui/a2ui/src/react/useAction.ts @@ -6,6 +6,10 @@ import type * as v0_9 from '@a2ui/web_core/v0_9'; import { useCallback } from '@lynx-js/react'; import { useA2UIContext } from './useA2UIContext.js'; +import { + executeFunctionCall, + resolveDynamicValue, +} from '../store/resolveFunctionCall.js'; import type { UserActionPayload } from '../store/types.js'; export interface ActionProps { @@ -14,124 +18,26 @@ export interface ActionProps { dataContext?: string | undefined; } -function isObject(value: unknown): value is Record { - return typeof value === 'object' && value !== null; -} - -function isDataBinding(value: unknown): value is v0_9.DataBinding { - return !!value && typeof value === 'object' - && 'path' in (value as Record); -} - -function isFunctionCall(value: unknown): value is v0_9.FunctionCall { - return !!value && typeof value === 'object' - && 'call' in (value as Record); -} - -function makeResolvers(processor: { - getOrCreateSurface( - id: string, - ): { store: { getSignal(p: string): { value: unknown } } }; - resolvePath(path: string, ctx?: string): string; -}) { - const resolveFromStore = ( - path: string, - surfaceId: string, - dataContextPath?: string, - ): unknown => { - const surface = processor.getOrCreateSurface(surfaceId); - const store = surface.store; - const resolvedPath = processor.resolvePath(path, dataContextPath); - const signal = store.getSignal(resolvedPath); - const raw = signal.value; - if (!raw) return raw; - try { - return JSON.parse(raw as string); - } catch { - return raw; - } - }; - - const resolveDynamicValue = ( - value: v0_9.DynamicValue, - surfaceId: string, - dataContextPath?: string, - ): unknown => { - if ( - typeof value === 'string' || typeof value === 'number' - || typeof value === 'boolean' - ) { - return value; - } - - if (Array.isArray(value)) { - return value.map((v) => - resolveDynamicValue( - v as v0_9.DynamicValue, - surfaceId, - dataContextPath, - ) - ); - } - - if (isDataBinding(value)) { - return resolveFromStore(value.path, surfaceId, dataContextPath); - } - - if (isFunctionCall(value)) { - return resolveFunctionCall(value, surfaceId, dataContextPath); - } - - return value; - }; - - const resolveFunctionArguments = ( - args: Record | undefined, - surfaceId: string, - dataContextPath?: string, - ): Record | undefined => { - if (!args) return undefined; - const resolved: Record = {}; - for (const [key, val] of Object.entries(args)) { - if (isObject(val) && !isDataBinding(val) && !isFunctionCall(val)) { - resolved[key] = { ...val }; - } else { - resolved[key] = resolveDynamicValue( - val as v0_9.DynamicValue, - surfaceId, - dataContextPath, - ); - } - } - return resolved; - }; - - const resolveFunctionCall = ( - fn: v0_9.FunctionCall, - surfaceId: string, - dataContextPath?: string, - ): Record => ({ - call: fn.call, - args: resolveFunctionArguments(fn.args, surfaceId, dataContextPath), - returnType: fn.returnType, - }); - - return { resolveDynamicValue, resolveFunctionCall }; -} - export function useAction( props: ActionProps, ): { sendAction: (action: v0_9.Action) => Promise } { const { id, surfaceId, dataContext } = props; - const { processor } = useA2UIContext(); + const { catalog, processor } = useA2UIContext(); const sendAction = useCallback( (action: v0_9.Action) => { + if ('functionCall' in action && action.functionCall) { + return Promise.resolve(executeFunctionCall( + processor, + action.functionCall, + surfaceId, + dataContext, + { functions: catalog.functions }, + )); + } + let name = 'unknownAction'; let context: Record = {}; - const { resolveDynamicValue, resolveFunctionCall } = makeResolvers( - processor, - ); if ('event' in action && action.event) { name = action.event.name; @@ -140,19 +46,15 @@ export function useAction( const resolvedContext: Record = {}; for (const [key, value] of Object.entries(ctx)) { resolvedContext[key] = resolveDynamicValue( - value as v0_9.DynamicValue, + processor, + value, surfaceId, dataContext, + { functions: catalog.functions }, ); } context = resolvedContext; } - } else if ('functionCall' in action && action.functionCall) { - const fn = action.functionCall; - name = fn.call; - context = { - functionCall: resolveFunctionCall(fn, surfaceId, dataContext), - }; } const userAction: UserActionPayload = { @@ -168,7 +70,7 @@ export function useAction( // prop, which the developer wires to their agent. return processor.dispatch({ userAction }); }, - [id, surfaceId, dataContext, processor], + [id, surfaceId, dataContext, processor, catalog.functions], ); return { sendAction }; diff --git a/packages/genui/a2ui/src/react/useChecks.ts b/packages/genui/a2ui/src/react/useChecks.ts new file mode 100644 index 0000000000..db92a6d409 --- /dev/null +++ b/packages/genui/a2ui/src/react/useChecks.ts @@ -0,0 +1,149 @@ +// Copyright 2026 The Lynx Authors. All rights reserved. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. +import { effect } from '@preact/signals'; + +import { useContext, useEffect, useState } from '@lynx-js/react'; + +import { FormContext } from './FormContext.js'; +import { useA2UIContext } from './useA2UIContext.js'; +import type { CatalogFunctionEntry } from '../catalog/defineCatalog.js'; +import type { CheckFailure, CheckOutcome } from '../store/FormController.js'; +import type { MessageProcessor } from '../store/MessageProcessor.js'; +import { + executeFunctionCall, + isDataBinding, + isFunctionCall, + resolveDynamicValue, +} from '../store/resolveFunctionCall.js'; +import type { Surface } from '../store/types.js'; + +/** + * A v0.9 `CheckRule` is `{ condition, message }` where `condition` is a + * boolean, a `DataBinding`, or a `FunctionCall`. We accept the loose + * `unknown` shape so component props don't have to import the v0_9 + * types just to pass them through. + */ +export interface CheckLike { + condition: unknown; + message: string; +} + +function evaluateCondition( + processor: MessageProcessor, + condition: unknown, + surfaceId: string, + dataContextPath?: string, + functions?: readonly CatalogFunctionEntry[], +): boolean { + if (typeof condition === 'boolean') return condition; + if (isFunctionCall(condition)) { + const result = executeFunctionCall( + processor, + condition, + surfaceId, + dataContextPath, + { functions }, + ); + return Boolean(result); + } + if (isDataBinding(condition)) { + return Boolean( + resolveDynamicValue(processor, condition, surfaceId, dataContextPath), + ); + } + // Unknown shape — treat as passing rather than blocking the user. + return true; +} + +function evaluateChecks( + processor: MessageProcessor, + checks: CheckLike[] | undefined, + surface: Surface | undefined, + dataContextPath?: string, + functions?: readonly CatalogFunctionEntry[], +): CheckOutcome { + if (!surface || !Array.isArray(checks) || checks.length === 0) { + return { ok: true, failures: [] }; + } + const failures: CheckFailure[] = []; + for (const rule of checks) { + const ok = evaluateCondition( + processor, + rule.condition, + surface.surfaceId, + dataContextPath, + functions, + ); + if (!ok) { + failures.push({ + call: isFunctionCall(rule.condition) + ? rule.condition.call + : 'condition', + message: rule.message, + }); + } + } + return { ok: failures.length === 0, failures }; +} + +/** + * Evaluate an input component's `checks` array reactively. Returns the + * current outcome plus the first failure message (handy for inline error + * rendering). When an enclosing `` exists, the input + * is also registered with it so Buttons in the same form can react to + * `isValid`. + */ +export function useChecks( + options: { + checks: CheckLike[] | undefined; + componentId: string; + surface: Surface | undefined; + dataContextPath?: string | undefined; + }, +): CheckOutcome & { firstFailureMessage: string | undefined } { + const { checks, componentId, surface, dataContextPath } = options; + const { catalog, processor } = useA2UIContext(); + const form = useContext(FormContext); + + const [outcome, setOutcome] = useState(() => + evaluateChecks( + processor, + checks, + surface, + dataContextPath, + catalog.functions, + ) + ); + + useEffect(() => { + if (!surface) { + setOutcome({ ok: true, failures: [] }); + return; + } + const dispose = effect(() => { + const next = evaluateChecks( + processor, + checks, + surface, + dataContextPath, + catalog.functions, + ); + setOutcome(next); + }); + return dispose; + }, [processor, checks, surface, dataContextPath, catalog.functions]); + + useEffect(() => { + // Skip registration when no componentId is available — otherwise every + // unnamed input collides under the same '' key in the form controller. + if (!form || !componentId) return; + return form.setOutcome(componentId, outcome); + }, [form, componentId, outcome]); + + return { + ok: outcome.ok, + failures: outcome.failures, + firstFailureMessage: outcome.failures[0]?.message, + }; +} diff --git a/packages/genui/a2ui/src/react/useDataBinding.ts b/packages/genui/a2ui/src/react/useDataBinding.ts index 0fb50c177a..2e07a1b8d4 100644 --- a/packages/genui/a2ui/src/react/useDataBinding.ts +++ b/packages/genui/a2ui/src/react/useDataBinding.ts @@ -11,6 +11,12 @@ import { useSyncExternalStore, } from '@lynx-js/react'; +import type { CatalogFunctionEntry } from '../catalog/defineCatalog.js'; +import type { MessageProcessor } from '../store/MessageProcessor.js'; +import { + isFunctionCall, + resolveDynamicValue, +} from '../store/resolveFunctionCall.js'; import type { Surface } from '../store/types.js'; const noop = () => { @@ -152,12 +158,22 @@ export function resolveProperties( properties: Record, surface: Surface | undefined, dataContextPath?: string, + processor?: MessageProcessor, + functions?: readonly CatalogFunctionEntry[], ) { if (!properties) return properties; const result: Record = {}; for (const key in properties) { const prop = properties[key]; - if (isDataBinding(prop)) { + if (isFunctionCall(prop) && surface && processor) { + result[key] = resolveDynamicValue( + processor, + prop, + surface.surfaceId, + dataContextPath, + { functions }, + ); + } else if (isDataBinding(prop)) { let path = (prop as Record)['path'] as | string | undefined; @@ -204,27 +220,41 @@ export function useResolvedProps( properties: Record, surface: Surface | undefined, dataContextPath?: string, + processor?: MessageProcessor, + functions?: readonly CatalogFunctionEntry[], ): readonly [Record, (key: string, value: unknown) => void] { const cacheRef = useRef | null>(null); const computeSnapshot = useCallback(() => { - const next = resolveProperties(properties, surface, dataContextPath); + const next = resolveProperties( + properties, + surface, + dataContextPath, + processor, + functions, + ); if (cacheRef.current && shallowEqual(cacheRef.current, next)) { return cacheRef.current; } cacheRef.current = next; return next; - }, [properties, surface, dataContextPath]); + }, [properties, surface, dataContextPath, processor, functions]); const subscribe = useCallback( (cb: () => void) => { if (!surface?.store) return noop; return effect(() => { - resolveProperties(properties, surface, dataContextPath); + resolveProperties( + properties, + surface, + dataContextPath, + processor, + functions, + ); cb(); }); }, - [properties, surface, dataContextPath], + [properties, surface, dataContextPath, processor, functions], ); const resolved = useSyncExternalStore( diff --git a/packages/genui/a2ui/src/store/FormController.ts b/packages/genui/a2ui/src/store/FormController.ts new file mode 100644 index 0000000000..bde6a5f556 --- /dev/null +++ b/packages/genui/a2ui/src/store/FormController.ts @@ -0,0 +1,63 @@ +// Copyright 2026 The Lynx Authors. All rights reserved. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. +import { computed, signal } from '@preact/signals'; +import type { Signal } from '@preact/signals'; + +export interface CheckFailure { + /** Name of the function call that returned a falsy condition. */ + call: string; + /** Operator-friendly message attached to the failing check. */ + message: string; +} + +export interface CheckOutcome { + ok: boolean; + failures: CheckFailure[]; +} + +export interface FormController { + /** Reactive view of whether every registered input currently passes. */ + isValid: Signal; + /** Per-input outcomes, keyed by component id. */ + outcomes: ReadonlyMap>; + /** + * Register or update an input's outcome. The returned `dispose` removes + * the input from the form when it unmounts. + */ + setOutcome(componentId: string, outcome: CheckOutcome): () => void; +} + +export function createFormController(): FormController { + const inputs = new Map>(); + // Tick on add/remove so the membership change is itself a reactive + // dependency of `isValid` (per-outcome value changes are already reactive + // because we read `entry.value` inside the computed). + const membership = signal(0); + const isValid = computed(() => { + void membership.value; + for (const outcome of inputs.values()) { + if (!outcome.value.ok) return false; + } + return true; + }); + return { + isValid, + outcomes: inputs as unknown as ReadonlyMap>, + setOutcome(componentId, outcome) { + let entry = inputs.get(componentId); + if (entry) { + entry.value = outcome; + } else { + entry = signal(outcome); + inputs.set(componentId, entry); + membership.value++; + } + return () => { + if (inputs.delete(componentId)) { + membership.value++; + } + }; + }, + }; +} diff --git a/packages/genui/a2ui/src/store/FunctionRegistry.ts b/packages/genui/a2ui/src/store/FunctionRegistry.ts new file mode 100644 index 0000000000..13046b4fe0 --- /dev/null +++ b/packages/genui/a2ui/src/store/FunctionRegistry.ts @@ -0,0 +1,83 @@ +// Copyright 2026 The Lynx Authors. All rights reserved. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. + +import type { Signal } from '@preact/signals'; + +import type { MessageProcessor } from './MessageProcessor.js'; + +/** + * Runtime context passed to client-side function implementations. It mirrors + * the small DataContext subset used by upstream A2UI basic functions while + * staying tied to this renderer's `MessageProcessor` + `SignalStore`. + */ +export interface FunctionCallContext { + processor: MessageProcessor; + surfaceId: string; + dataContextPath?: string | undefined; + resolveDynamicValue(value: unknown): unknown; + resolveSignal(value: unknown): Signal; + set(path: string, value: unknown): void; +} + +/** + * Function implementations live on the client; the agent only references + * functions by name. The registry is the bridge from the wire-level + * `FunctionCall.call` string to the actual code that runs locally. + */ +export type FunctionImpl = ( + args: Record, + context?: FunctionCallContext, +) => unknown; + +/** + * Structured definition announced to the agent during catalog handshake. + * Kept here (in store/) so callers in both `catalog/` and `react/` can + * share the same shape without importing across the layering boundary. + */ +export interface FunctionDefinition { + name: string; + description?: string; + parameters: Record; + returnType: + | 'string' + | 'number' + | 'boolean' + | 'array' + | 'object' + | 'any' + | 'void'; +} + +export interface FunctionEntry { + name: string; + /** Optional function definition announced to the agent as part of the handshake. */ + definition?: FunctionDefinition | undefined; + impl: FunctionImpl; +} + +export class FunctionRegistry { + private readonly entries = new Map(); + + register(entry: FunctionEntry): void { + this.entries.set(entry.name, entry); + } + + unregister(name: string): void { + this.entries.delete(name); + } + + has(name: string): boolean { + return this.entries.has(name); + } + + resolve(name: string): FunctionImpl | undefined { + return this.entries.get(name)?.impl; + } + + list(): FunctionEntry[] { + return Array.from(this.entries.values()); + } +} + +export const functionRegistry: FunctionRegistry = new FunctionRegistry(); diff --git a/packages/genui/a2ui/src/store/MessageProcessor.ts b/packages/genui/a2ui/src/store/MessageProcessor.ts index 265ae94082..9187195f8b 100644 --- a/packages/genui/a2ui/src/store/MessageProcessor.ts +++ b/packages/genui/a2ui/src/store/MessageProcessor.ts @@ -181,58 +181,67 @@ export class MessageProcessor { surface.resources.set(newId, createResource(newId)); } - const childIds: string[] = []; const anyCloned = cloned as unknown as Record; + const clonedChildIds = new Map(); + + const cloneChild = (childId: string): string | null => { + const existing = clonedChildIds.get(childId); + if (existing) return existing; + const newChildId = this.cloneComponentTree( + childId, + newIdSuffix, + dataContextPath, + surface, + updates, + ); + if (newChildId) { + clonedChildIds.set(childId, newChildId); + } + return newChildId; + }; if (Array.isArray(anyCloned['children'])) { + const newChildren: string[] = []; for (const childId of anyCloned['children']) { - if (typeof childId === 'string') { - childIds.push(childId); - } + if (typeof childId !== 'string') continue; + const newChildId = cloneChild(childId); + if (newChildId) newChildren.push(newChildId); } + anyCloned['children'] = newChildren; } if (typeof anyCloned['child'] === 'string') { - childIds.push(anyCloned['child']); + const newChildId = cloneChild(anyCloned['child']); + if (newChildId) anyCloned['child'] = newChildId; } if (Array.isArray(anyCloned['tabs'])) { - for (const tab of anyCloned['tabs'] as unknown[]) { + anyCloned['tabs'] = (anyCloned['tabs'] as unknown[]).map((tab) => { if ( - tab && typeof tab === 'object' && 'child' in tab - && typeof (tab as Record)['child'] === 'string' + !tab || typeof tab !== 'object' || !('child' in tab) + || typeof (tab as Record)['child'] !== 'string' ) { - childIds.push((tab as Record)['child'] as string); + return tab; } - } + + const tabRecord = tab as Record; + const newChildId = cloneChild(tabRecord['child'] as string); + if (!newChildId) return tab; + return { + ...tabRecord, + child: newChildId, + }; + }); } if (typeof anyCloned['trigger'] === 'string') { - childIds.push(anyCloned['trigger']); + const newChildId = cloneChild(anyCloned['trigger']); + if (newChildId) anyCloned['trigger'] = newChildId; } if (typeof anyCloned['content'] === 'string') { - childIds.push(anyCloned['content']); - } - - const newChildren: string[] = []; - for (const childId of childIds) { - const newChildId = this.cloneComponentTree( - childId, - newIdSuffix, - dataContextPath, - surface, - updates, - ); - if (newChildId) { - newChildren.push(newChildId); - } - } - - if (Array.isArray(anyCloned['children'])) { - anyCloned['children'] = newChildren; - } else if (newChildren.length > 0) { - anyCloned['children'] = newChildren; + const newChildId = cloneChild(anyCloned['content']); + if (newChildId) anyCloned['content'] = newChildId; } return newId; @@ -422,13 +431,16 @@ export class MessageProcessor { if (!templateInfo) continue; const dataSignal = surface.store.getSignal(templateInfo.path); + const rawData = dataSignal.value; let data: unknown; - try { - data = dataSignal.value - ? JSON.parse(dataSignal.value as string) - : undefined; - } catch { - data = undefined; + if (typeof rawData === 'string') { + try { + data = rawData ? JSON.parse(rawData) : undefined; + } catch { + data = undefined; + } + } else { + data = rawData; } const explicitChildren: string[] = []; @@ -466,11 +478,9 @@ export class MessageProcessor { } } - if (explicitChildren.length > 0) { - anyComponent['children'] = explicitChildren; - componentUpdates.push(component); - componentUpdates.push(...generatedUpdates); - } + anyComponent['children'] = explicitChildren; + componentUpdates.push(component); + componentUpdates.push(...generatedUpdates); } if (componentUpdates.length > 0) { diff --git a/packages/genui/a2ui/src/store/index.ts b/packages/genui/a2ui/src/store/index.ts index b8c7d6882b..e27904640e 100644 --- a/packages/genui/a2ui/src/store/index.ts +++ b/packages/genui/a2ui/src/store/index.ts @@ -25,3 +25,18 @@ export type { SurfaceId, UserActionPayload, } from './types.js'; +export { FunctionRegistry, functionRegistry } from './FunctionRegistry.js'; +export type { FunctionEntry, FunctionImpl } from './FunctionRegistry.js'; +export type { FunctionCallContext } from './FunctionRegistry.js'; +// `createFormController` + `FormController` are intentionally not exported. +// `useChecks` uses them internally to keep the door open for a follow-up +// `` component, but they aren't public API until that consumer +// lands and validates the shape. +export { + executeFunctionCall, + isDataBinding, + isFunctionCall, + resolveDynamicValue, + resolveFunctionArguments, +} from './resolveFunctionCall.js'; +export type { ResolveFunctionOptions } from './resolveFunctionCall.js'; diff --git a/packages/genui/a2ui/src/store/resolveFunctionCall.ts b/packages/genui/a2ui/src/store/resolveFunctionCall.ts new file mode 100644 index 0000000000..20b25999c5 --- /dev/null +++ b/packages/genui/a2ui/src/store/resolveFunctionCall.ts @@ -0,0 +1,261 @@ +// Copyright 2026 The Lynx Authors. All rights reserved. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. +import type * as v0_9 from '@a2ui/web_core/v0_9'; +import { computed, signal } from '@preact/signals'; +import type { Signal } from '@preact/signals'; + +import { functionRegistry } from './FunctionRegistry.js'; +import type { + FunctionCallContext, + FunctionImpl, + FunctionRegistry, +} from './FunctionRegistry.js'; +import type { MessageProcessor } from './MessageProcessor.js'; +import type { CatalogFunctionEntry } from '../catalog/defineCatalog.js'; + +function isObject(value: unknown): value is Record { + return typeof value === 'object' && value !== null; +} + +export function isDataBinding(value: unknown): value is v0_9.DataBinding { + return isObject(value) && 'path' in value; +} + +export function isFunctionCall(value: unknown): value is v0_9.FunctionCall { + return isObject(value) && 'call' in value; +} + +function resolveFromStore( + processor: MessageProcessor, + path: string, + surfaceId: string, + dataContextPath?: string, +): unknown { + const surface = processor.getOrCreateSurface(surfaceId); + const store = surface.store; + const resolvedPath = processor.resolvePath(path, dataContextPath); + const signal = store.getSignal(resolvedPath); + const raw = signal.value; + if (raw === undefined || raw === null) return raw; + if (typeof raw !== 'string') return raw; + try { + return JSON.parse(raw); + } catch { + return raw; + } +} + +function setInStore( + processor: MessageProcessor, + path: string, + value: unknown, + surfaceId: string, + dataContextPath?: string, +): void { + const surface = processor.getOrCreateSurface(surfaceId); + const resolvedPath = processor.resolvePath(path, dataContextPath); + surface.store.update(resolvedPath, value); +} + +function signalFromStore( + processor: MessageProcessor, + path: string, + surfaceId: string, + dataContextPath?: string, +): Signal { + const surface = processor.getOrCreateSurface(surfaceId); + const resolvedPath = processor.resolvePath(path, dataContextPath); + return surface.store.getSignal(resolvedPath); +} + +export function resolveDynamicValue( + processor: MessageProcessor, + value: unknown, + surfaceId: string, + dataContextPath?: string, + options: ResolveFunctionOptions = {}, +): unknown { + if ( + typeof value === 'string' || typeof value === 'number' + || typeof value === 'boolean' + ) { + return value; + } + if (Array.isArray(value)) { + return value.map(item => + resolveDynamicValue(processor, item, surfaceId, dataContextPath, options) + ); + } + if (isDataBinding(value)) { + return resolveFromStore(processor, value.path, surfaceId, dataContextPath); + } + if (isFunctionCall(value)) { + return executeFunctionCall( + processor, + value, + surfaceId, + dataContextPath, + options, + ); + } + return value; +} + +function resolveSignal( + processor: MessageProcessor, + value: unknown, + surfaceId: string, + dataContextPath: string | undefined, + options: ResolveFunctionOptions, +): Signal { + if (isDataBinding(value)) { + return signalFromStore(processor, value.path, surfaceId, dataContextPath); + } + if (isFunctionCall(value)) { + return computed(() => + executeFunctionCall(processor, value, surfaceId, dataContextPath, options) + ); + } + if (Array.isArray(value)) { + return computed(() => + value.map(item => + resolveDynamicValue( + processor, + item, + surfaceId, + dataContextPath, + options, + ) + ) + ); + } + return signal(value); +} + +function createFunctionContext( + processor: MessageProcessor, + surfaceId: string, + dataContextPath: string | undefined, + options: ResolveFunctionOptions, +): FunctionCallContext { + return { + processor, + surfaceId, + ...(dataContextPath === undefined ? {} : { dataContextPath }), + resolveDynamicValue(value) { + return resolveDynamicValue( + processor, + value, + surfaceId, + dataContextPath, + options, + ); + }, + resolveSignal(value) { + return resolveSignal( + processor, + value, + surfaceId, + dataContextPath, + options, + ); + }, + set(path, value) { + setInStore(processor, path, value, surfaceId, dataContextPath); + }, + }; +} + +export function resolveFunctionArguments( + processor: MessageProcessor, + args: Record | undefined, + surfaceId: string, + dataContextPath?: string, + options: ResolveFunctionOptions = {}, +): Record { + const resolved: Record = {}; + if (!args) return resolved; + for (const [key, raw] of Object.entries(args)) { + if (Array.isArray(raw) || isDataBinding(raw) || isFunctionCall(raw)) { + resolved[key] = resolveDynamicValue( + processor, + raw, + surfaceId, + dataContextPath, + options, + ); + } else if (isObject(raw)) { + resolved[key] = { ...raw }; + } else { + resolved[key] = resolveDynamicValue( + processor, + raw, + surfaceId, + dataContextPath, + options, + ); + } + } + return resolved; +} + +export interface ResolveFunctionOptions { + functions?: readonly CatalogFunctionEntry[] | undefined; + registry?: FunctionRegistry | undefined; +} + +function resolveFunctionImpl( + name: string, + options: ResolveFunctionOptions, +): FunctionImpl | undefined { + const scoped = options.functions?.find(entry => entry.name === name)?.impl; + if (scoped) return scoped; + return (options.registry ?? functionRegistry).resolve(name); +} + +const warnedUnknownFunctions = new Set(); + +/** + * Resolve arguments, look the function up in the registry, and invoke it. + * When no impl is registered, log once and return `undefined` so callers + * (checks, dynamic-property bindings) can degrade gracefully. + */ +export function executeFunctionCall( + processor: MessageProcessor, + fn: v0_9.FunctionCall, + surfaceId: string, + dataContextPath?: string, + options: ResolveFunctionOptions = {}, +): unknown { + const impl = resolveFunctionImpl(fn.call, options); + const resolvedArgs = resolveFunctionArguments( + processor, + fn.args, + surfaceId, + dataContextPath, + options, + ); + if (!impl) { + if (!warnedUnknownFunctions.has(fn.call)) { + warnedUnknownFunctions.add(fn.call); + console.warn( + `[a2ui] No client implementation registered for function ` + + `"${fn.call}". Returning undefined.`, + ); + } + return undefined; + } + try { + return impl( + resolvedArgs, + createFunctionContext(processor, surfaceId, dataContextPath, options), + ); + } catch (error) { + console.warn( + `[a2ui] Function "${fn.call}" threw while resolving. Returning undefined.`, + error, + ); + return undefined; + } +} diff --git a/packages/genui/a2ui/styles/catalog/Button.css b/packages/genui/a2ui/styles/catalog/Button.css index 0f4df4fe3c..65ea3a5c57 100644 --- a/packages/genui/a2ui/styles/catalog/Button.css +++ b/packages/genui/a2ui/styles/catalog/Button.css @@ -46,6 +46,17 @@ background-color: inherit; } +.button-invalid { + opacity: 0.45; +} + +.button-error { + margin-top: calc(var(--a2ui-spacing-xs) * -1); + color: var(--a2ui-color-error, #d92d20); + font-size: var(--a2ui-font-size-xs); + line-height: var(--a2ui-line-height-body); +} + .button .text-body, .button .text-caption, .button .text-h1, diff --git a/packages/genui/a2ui/styles/catalog/CheckBox.css b/packages/genui/a2ui/styles/catalog/CheckBox.css index 6eaad8ce1e..adecd78cc6 100644 --- a/packages/genui/a2ui/styles/catalog/CheckBox.css +++ b/packages/genui/a2ui/styles/catalog/CheckBox.css @@ -25,8 +25,18 @@ border-color: var(--a2ui-color-primary); } +.checkbox-row-invalid .checkbox-input { + border-color: var(--a2ui-color-error, #d92d20); +} + .checkbox-label { font-size: 14px; line-height: 1.5; color: var(--a2ui-color-on-background); } + +.checkbox-error { + color: var(--a2ui-color-error, #d92d20); + font-size: var(--a2ui-font-size-xs); + line-height: var(--a2ui-line-height-body); +} diff --git a/packages/genui/a2ui/styles/catalog/RadioGroup.css b/packages/genui/a2ui/styles/catalog/RadioGroup.css index 2fee667c77..a312b849ea 100644 --- a/packages/genui/a2ui/styles/catalog/RadioGroup.css +++ b/packages/genui/a2ui/styles/catalog/RadioGroup.css @@ -45,6 +45,17 @@ background-color: var(--a2ui-color-surface-muted); } +.radio-group-invalid .radio-item { + border-color: var(--a2ui-color-error, #d92d20); +} + +.radio-group-error { + margin-top: var(--a2ui-spacing-xs); + color: var(--a2ui-color-error, #d92d20); + font-size: var(--a2ui-font-size-xs); + line-height: var(--a2ui-line-height-body); +} + /* ========================= * Radio Option * ========================= */ diff --git a/packages/genui/a2ui/test/basicFunctions.test.ts b/packages/genui/a2ui/test/basicFunctions.test.ts new file mode 100644 index 0000000000..55d0faa999 --- /dev/null +++ b/packages/genui/a2ui/test/basicFunctions.test.ts @@ -0,0 +1,98 @@ +// Copyright 2026 The Lynx Authors. All rights reserved. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. +// +// Smoke tests for the upstream-`BASIC_FUNCTIONS` adapter. Per-function +// behavior is covered by `@a2ui/web_core`'s own test suite — we only verify +// here that the adapter wires upstream impls into our `FunctionRegistry` +// and that the `email` omission is honored. +import { afterAll, beforeAll, describe, expect, test } from '@rstest/core'; + +import { + basicFunctions, + registerBasicFunctions, +} from '../src/functions/index.js'; +import { + FunctionRegistry, + functionRegistry, +} from '../src/store/FunctionRegistry.js'; + +describe('basicFunctions adapter', () => { + test('exposes the validators + logic functions consumed by useChecks', () => { + const names = basicFunctions.map(fn => fn.name); + for ( + const required of [ + 'required', + 'regex', + 'length', + 'numeric', + 'email', + 'and', + 'or', + 'not', + ] + ) { + expect(names).toContain(required); + } + }); + + test('exposes the spec\'s formatters + side-effect functions', () => { + const names = basicFunctions.map(fn => fn.name); + for ( + const required of [ + 'formatString', + 'formatNumber', + 'formatCurrency', + 'formatDate', + 'pluralize', + 'openUrl', + ] + ) { + expect(names).toContain(required); + } + }); + + test('carries upstream function definitions for handshakes', () => { + const add = basicFunctions.find(fn => fn.name === 'add'); + expect(add?.definition).toMatchObject({ + name: 'add', + returnType: 'number', + parameters: { + type: 'object', + }, + }); + + const parameters = add?.definition?.parameters as + | { properties?: Record } + | undefined; + expect(parameters?.properties?.['a']).toMatchObject({ type: 'number' }); + expect(parameters?.properties?.['b']).toMatchObject({ type: 'number' }); + }); + + describe('registerBasicFunctions', () => { + const snapshot = new FunctionRegistry(); + void beforeAll(() => { + // Save anything already registered so we can restore it after. + for (const entry of functionRegistry.list()) { + snapshot.register(entry); + } + }); + void afterAll(() => { + // Best-effort cleanup so other tests see the pre-test state. + for (const entry of functionRegistry.list()) { + functionRegistry.unregister(entry.name); + } + for (const entry of snapshot.list()) { + functionRegistry.register(entry); + } + }); + + test('routes a known function through the upstream impl', () => { + registerBasicFunctions(); + const required = functionRegistry.resolve('required'); + expect(required).toBeDefined(); + expect(required!({ value: '' })).toBe(false); + expect(required!({ value: 'hi' })).toBe(true); + }); + }); +}); diff --git a/packages/genui/a2ui/test/catalog.test.ts b/packages/genui/a2ui/test/catalog.test.ts index bf31efe77e..300a4e837d 100644 --- a/packages/genui/a2ui/test/catalog.test.ts +++ b/packages/genui/a2ui/test/catalog.test.ts @@ -57,9 +57,9 @@ const TABS_MANIFEST: CatalogManifest = { describe('defineCatalog', () => { test('bare component derives name from displayName ?? function name', () => { const cat = defineCatalog([Text, Button]); - expect(cat.map((e) => e.name)).toEqual(['Text', 'Button']); - expect(cat[0]!.component).toBe(Text); - expect(cat[0]!.schema).toBeUndefined(); + expect(cat.components.map((e) => e.name)).toEqual(['Text', 'Button']); + expect(cat.components[0]!.component).toBe(Text); + expect(cat.components[0]!.schema).toBeUndefined(); }); test('tuple form derives name + schema from manifest', () => { @@ -67,21 +67,21 @@ describe('defineCatalog', () => { [Text, TEXT_MANIFEST], [Button, BUTTON_MANIFEST], ]); - expect(cat[0]!.name).toBe('Text'); - expect(cat[0]!.schema).toEqual(TEXT_MANIFEST.Text); - expect(cat[1]!.name).toBe('Button'); - expect(cat[1]!.schema).toEqual(BUTTON_MANIFEST.Button); + expect(cat.components[0]!.name).toBe('Text'); + expect(cat.components[0]!.schema).toEqual(TEXT_MANIFEST.Text); + expect(cat.components[1]!.name).toBe('Button'); + expect(cat.components[1]!.schema).toEqual(BUTTON_MANIFEST.Button); }); test('mixes bare and tuple inputs in one call', () => { const cat = defineCatalog([Text, [Button, BUTTON_MANIFEST]]); - expect(cat[0]!.schema).toBeUndefined(); - expect(cat[1]!.schema).toEqual(BUTTON_MANIFEST.Button); + expect(cat.components[0]!.schema).toBeUndefined(); + expect(cat.components[1]!.schema).toEqual(BUTTON_MANIFEST.Button); }); test('passes through already-resolved entries', () => { const inner = defineCatalog([[Text, TEXT_MANIFEST]]); - const outer = defineCatalog(inner); + const outer = defineCatalog(inner.components); expect(outer).toEqual(inner); }); @@ -100,7 +100,7 @@ describe('defineCatalog', () => { Object.defineProperty(fn, 'name', { value: 'Mangled' }); (fn as { displayName?: string }).displayName = 'Custom'; const cat = defineCatalog([fn]); - expect(cat[0]!.name).toBe('Custom'); + expect(cat.components[0]!.name).toBe('Custom'); }); }); @@ -110,7 +110,7 @@ describe('mergeCatalogs', () => { const Override = namedStub('Text'); const b = defineCatalog([Override]); const merged = mergeCatalogs(a, b); - const text = merged.find((e) => e.name === 'Text')!; + const text = merged.components.find((e) => e.name === 'Text')!; expect(text.component).toBe(Override); expect(text.schema).toBeUndefined(); }); diff --git a/packages/genui/a2ui/test/defineCatalogFunctions.test.ts b/packages/genui/a2ui/test/defineCatalogFunctions.test.ts new file mode 100644 index 0000000000..9e7f648a27 --- /dev/null +++ b/packages/genui/a2ui/test/defineCatalogFunctions.test.ts @@ -0,0 +1,99 @@ +// Copyright 2026 The Lynx Authors. All rights reserved. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. +import { describe, expect, test } from '@rstest/core'; + +import { + defineCatalog, + defineFunction, + mergeCatalogs, + serializeCatalog, +} from '../src/catalog/defineCatalog.js'; +import { functionRegistry } from '../src/store/FunctionRegistry.js'; + +function MockComponent(): null { + return null; +} +MockComponent.displayName = 'MockComponent'; + +function namedImpl(args: Record): unknown { + return args['value']; +} +Object.defineProperty(namedImpl, 'name', { value: 'pickValue' }); + +function laterImpl(args: Record): unknown { + return args['value']; +} + +const requiredManifest = { + required: { + name: 'required', + parameters: { type: 'object', properties: { value: { type: 'string' } } }, + returnType: 'boolean' as const, + }, +}; + +describe('defineCatalog with function entries', () => { + test('separates components and functions', () => { + const catalog = defineCatalog([ + MockComponent, + defineFunction(namedImpl, requiredManifest), + ]); + + expect(catalog.components.map(c => c.name)).toEqual(['MockComponent']); + expect(catalog.functions.map(f => f.name)).toEqual(['required']); + expect(functionRegistry.has('required')).toBe(true); + }); + + test('rejects duplicate function names', () => { + expect(() => + defineCatalog([ + defineFunction(namedImpl, requiredManifest), + defineFunction(namedImpl, requiredManifest), + ]) + ).toThrow(/Duplicate function name/); + }); + + test('serializeCatalog announces functions in the handshake', () => { + const catalog = defineCatalog([ + MockComponent, + defineFunction(namedImpl, requiredManifest), + ]); + + const serialized = serializeCatalog(catalog); + expect(serialized.version).toBe('0.9'); + expect(serialized.components).toEqual([{ name: 'MockComponent' }]); + expect(serialized.functions).toEqual([ + requiredManifest.required, + ]); + }); + + test('serializeCatalog omits functions array when definitions are absent', () => { + const catalog = defineCatalog([MockComponent, defineFunction(namedImpl)]); + const serialized = serializeCatalog(catalog); + expect(serialized.functions).toBeUndefined(); + }); + + test('mergeCatalogs preserves functions and re-registers impls', () => { + const a = defineCatalog([defineFunction(namedImpl, requiredManifest)]); + const b = defineCatalog([ + { + kind: 'function' as const, + name: 'required', + impl: laterImpl, + definition: requiredManifest.required, + }, + ]); + + const merged = mergeCatalogs(a, b); + expect(merged.functions).toHaveLength(1); + expect(merged.functions[0]!.impl).toBe(laterImpl); + expect(functionRegistry.resolve('required')).toBe(laterImpl); + }); + + test('defineFunction without a manifest reads the impl name', () => { + const entry = defineFunction(namedImpl); + expect(entry.name).toBe('pickValue'); + expect(entry.definition).toBeUndefined(); + }); +}); diff --git a/packages/genui/a2ui/test/executeFunctionCall.test.ts b/packages/genui/a2ui/test/executeFunctionCall.test.ts new file mode 100644 index 0000000000..a150f5fdff --- /dev/null +++ b/packages/genui/a2ui/test/executeFunctionCall.test.ts @@ -0,0 +1,178 @@ +// Copyright 2026 The Lynx Authors. All rights reserved. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. +import { beforeEach, describe, expect, test } from '@rstest/core'; + +import { basicFunctions } from '../src/functions/index.js'; +import { functionRegistry } from '../src/store/FunctionRegistry.js'; +import { MessageProcessor } from '../src/store/MessageProcessor.js'; +import { + executeFunctionCall, + resolveDynamicValue, +} from '../src/store/resolveFunctionCall.js'; + +describe('executeFunctionCall', () => { + const surfaceId = 'execTestSurface'; + let processor: MessageProcessor; + + void beforeEach(() => { + processor = new MessageProcessor(); + functionRegistry.register({ + name: 'identity', + impl: (args) => args['value'], + }); + functionRegistry.register({ + name: 'add', + impl: (args) => Number(args['a']) + Number(args['b']), + }); + }); + + test('routes by name and returns the impl result', () => { + expect(executeFunctionCall( + processor, + { call: 'identity', args: { value: 'hi' }, returnType: 'string' }, + surfaceId, + )).toBe('hi'); + }); + + test('resolves data-binding args against the surface store', () => { + const surface = processor.getOrCreateSurface(surfaceId); + surface.store.update('/a', '7'); + surface.store.update('/b', '8'); + expect(executeFunctionCall( + processor, + { + call: 'add', + args: { a: { path: '/a' }, b: { path: '/b' } }, + returnType: 'number', + }, + surfaceId, + )).toBe(15); + }); + + test('returns undefined and warns once for unknown functions', () => { + const captured: string[] = []; + const originalWarn = console.warn; + console.warn = (...args: unknown[]) => { + captured.push(args.map(String).join(' ')); + }; + + try { + expect(executeFunctionCall( + processor, + { call: 'doesNotExist', args: {}, returnType: 'any' }, + surfaceId, + )).toBeUndefined(); + // Second call should not duplicate the warning. + executeFunctionCall( + processor, + { call: 'doesNotExist', args: {}, returnType: 'any' }, + surfaceId, + ); + expect( + captured.filter(line => line.includes('doesNotExist')).length, + ).toBe(1); + } finally { + console.warn = originalWarn; + } + }); + + test('resolveDynamicValue evaluates nested function calls', () => { + expect(resolveDynamicValue( + processor, + { + call: 'add', + args: { a: 1, b: { call: 'identity', args: { value: 2 } } }, + returnType: 'number', + }, + surfaceId, + )).toBe(3); + }); + + test('resolves array args without turning them into objects', () => { + const surface = processor.getOrCreateSurface(surfaceId); + surface.store.update('/email', ''); + surface.store.update('/password', 'long-password'); + + expect(executeFunctionCall( + processor, + { + call: 'and', + args: { + values: [ + { + call: 'required', + args: { value: { path: '/email' } }, + returnType: 'boolean', + }, + { + call: 'length', + args: { value: { path: '/password' }, min: 8 }, + returnType: 'boolean', + }, + ], + }, + returnType: 'boolean', + }, + surfaceId, + undefined, + { functions: basicFunctions }, + )).toBe(false); + + surface.store.update('/email', 'ada@example.com'); + + expect(executeFunctionCall( + processor, + { + call: 'and', + args: { + values: [ + { + call: 'required', + args: { value: { path: '/email' } }, + returnType: 'boolean', + }, + { + call: 'length', + args: { value: { path: '/password' }, min: 8 }, + returnType: 'boolean', + }, + ], + }, + returnType: 'boolean', + }, + surfaceId, + undefined, + { functions: basicFunctions }, + )).toBe(true); + }); + + test('basic functions use upstream zod parsing and data context', () => { + const surface = processor.getOrCreateSurface(surfaceId); + surface.store.update('/name', 'Ada'); + + expect(executeFunctionCall( + processor, + { + call: 'add', + args: { a: '7', b: '8' }, + returnType: 'number', + }, + surfaceId, + undefined, + { functions: basicFunctions }, + )).toBe(15); + + expect(executeFunctionCall( + processor, + { + call: 'formatString', + args: { value: 'Hello ${/name}' }, + returnType: 'string', + }, + surfaceId, + undefined, + { functions: basicFunctions }, + )).toBe('Hello Ada'); + }); +}); diff --git a/packages/genui/a2ui/test/formContext.test.ts b/packages/genui/a2ui/test/formContext.test.ts new file mode 100644 index 0000000000..5e4cdb385e --- /dev/null +++ b/packages/genui/a2ui/test/formContext.test.ts @@ -0,0 +1,46 @@ +// Copyright 2026 The Lynx Authors. All rights reserved. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. +import { describe, expect, test } from '@rstest/core'; + +import { createFormController } from '../src/store/FormController.js'; + +describe('FormController', () => { + test('isValid is true when no inputs registered', () => { + const form = createFormController(); + expect(form.isValid.value).toBe(true); + }); + + test('isValid is false when any input fails', () => { + const form = createFormController(); + form.setOutcome('input-a', { ok: true, failures: [] }); + form.setOutcome('input-b', { + ok: false, + failures: [{ call: 'required', message: 'Required' }], + }); + expect(form.isValid.value).toBe(false); + }); + + test('isValid flips back to true when failing input is removed', () => { + const form = createFormController(); + const disposeA = form.setOutcome('input-a', { + ok: false, + failures: [{ call: 'required', message: 'Required' }], + }); + form.setOutcome('input-b', { ok: true, failures: [] }); + expect(form.isValid.value).toBe(false); + disposeA(); + expect(form.isValid.value).toBe(true); + }); + + test('setOutcome updates existing entries in place', () => { + const form = createFormController(); + form.setOutcome('input-a', { + ok: false, + failures: [{ call: 'required', message: 'Required' }], + }); + expect(form.isValid.value).toBe(false); + form.setOutcome('input-a', { ok: true, failures: [] }); + expect(form.isValid.value).toBe(true); + }); +}); diff --git a/packages/genui/a2ui/test/functionRegistry.test.ts b/packages/genui/a2ui/test/functionRegistry.test.ts new file mode 100644 index 0000000000..2868b7e948 --- /dev/null +++ b/packages/genui/a2ui/test/functionRegistry.test.ts @@ -0,0 +1,53 @@ +// Copyright 2026 The Lynx Authors. All rights reserved. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. +import { beforeEach, describe, expect, test } from '@rstest/core'; + +import { FunctionRegistry } from '../src/store/FunctionRegistry.js'; + +describe('FunctionRegistry', () => { + let registry: FunctionRegistry; + + void beforeEach(() => { + registry = new FunctionRegistry(); + }); + + test('register/resolve round-trip', () => { + registry.register({ name: 'identity', impl: (args) => args['value'] }); + const fn = registry.resolve('identity'); + expect(fn).toBeDefined(); + expect(fn!({ value: 42 })).toBe(42); + }); + + test('has reflects registered state', () => { + expect(registry.has('foo')).toBe(false); + registry.register({ name: 'foo', impl: () => null }); + expect(registry.has('foo')).toBe(true); + registry.unregister('foo'); + expect(registry.has('foo')).toBe(false); + }); + + test('list returns every registered entry', () => { + registry.register({ name: 'a', impl: () => 1 }); + registry.register({ name: 'b', impl: () => 2 }); + const names = registry.list().map(entry => entry.name).sort(); + expect(names).toEqual(['a', 'b']); + }); + + test('re-registering by name overrides the prior impl', () => { + registry.register({ name: 'pick', impl: () => 'first' }); + registry.register({ name: 'pick', impl: () => 'second' }); + expect(registry.resolve('pick')!({})).toBe('second'); + }); + + test('definition is preserved when provided', () => { + registry.register({ + name: 'schemed', + impl: () => 0, + definition: { parameters: { type: 'object' } }, + }); + expect(registry.list()[0]!.definition).toEqual({ + parameters: { type: 'object' }, + }); + }); +}); diff --git a/packages/genui/a2ui/test/processor.test.ts b/packages/genui/a2ui/test/processor.test.ts index 47b534cad5..9b82a9b0cd 100644 --- a/packages/genui/a2ui/test/processor.test.ts +++ b/packages/genui/a2ui/test/processor.test.ts @@ -116,6 +116,147 @@ describe('MessageProcessor', () => { expect(surface.store.getSignal('/title').value).toBe('hello'); }); + test('expands dynamic children templates on layout components', () => { + const proc = new MessageProcessor(); + const events: Array<{ type: string; updates?: Array<{ id?: string }> }> = + []; + proc.onUpdate((event) => { + events.push(event as { type: string; updates?: Array<{ id?: string }> }); + }); + + proc.processMessages([ + { createSurface: { surfaceId: 's1' } }, + { + updateComponents: { + surfaceId: 's1', + components: [ + { id: 'root', component: 'Column', children: ['events'] }, + { + id: 'events', + component: 'Column', + children: { componentId: 'event-template', path: '/events' }, + }, + { + id: 'event-template', + component: 'Column', + children: ['event-title', 'event-time'], + }, + { id: 'event-title', component: 'Text', text: { path: 'title' } }, + { id: 'event-time', component: 'Text', text: { path: 'time' } }, + ], + }, + }, + { + updateDataModel: { + surfaceId: 's1', + value: { + events: [ + { title: 'Lunch', time: '12:00 - 12:45 PM' }, + { title: 'Team standup', time: '3:30 - 4:00 PM' }, + ], + }, + }, + }, + ] as ServerToClientMessage[]); + + const surface = proc.getOrCreateSurface('s1'); + expect(surface.components.get('events')).toMatchObject({ + children: ['event-template:0', 'event-template:1'], + }); + expect(surface.components.get('event-template:0')).toMatchObject({ + dataContextPath: '/events/0', + children: ['event-title:0', 'event-time:0'], + }); + expect(surface.components.get('event-title:1')).toMatchObject({ + dataContextPath: '/events/1', + }); + expect( + events.some(event => + event.type === 'surfaceUpdate' + && event.updates?.some(update => update.id === 'event-title:0') + ), + ).toBe(true); + }); + + test('rewrites non-children child references when cloning templates', () => { + const proc = new MessageProcessor(); + proc.processMessages([ + { createSurface: { surfaceId: 's1' } }, + { + updateComponents: { + surfaceId: 's1', + components: [ + { + id: 'root', + component: 'Column', + children: { componentId: 'item-card', path: '/items' }, + }, + { id: 'item-card', component: 'Card', child: 'item-body' }, + { id: 'item-body', component: 'Text', text: { path: 'name' } }, + ], + }, + }, + { + updateDataModel: { + surfaceId: 's1', + value: { + items: [{ name: 'Apple' }], + }, + }, + }, + ] as ServerToClientMessage[]); + + const surface = proc.getOrCreateSurface('s1'); + expect(surface.components.get('root')).toMatchObject({ + children: ['item-card:0'], + }); + expect(surface.components.get('item-card:0')).toMatchObject({ + child: 'item-body:0', + dataContextPath: '/items/0', + }); + expect(surface.components.get('item-body:0')).toMatchObject({ + dataContextPath: '/items/0', + }); + }); + + test('clears dynamic children when template data becomes empty', () => { + const proc = new MessageProcessor(); + proc.processMessages([ + { createSurface: { surfaceId: 's1' } }, + { + updateComponents: { + surfaceId: 's1', + components: [ + { + id: 'root', + component: 'Column', + children: { componentId: 'item', path: '/items' }, + }, + { id: 'item', component: 'Text', text: { path: 'name' } }, + ], + }, + }, + { + updateDataModel: { + surfaceId: 's1', + value: { items: [{ name: 'Apple' }, { name: 'Banana' }] }, + }, + }, + { + updateDataModel: { + surfaceId: 's1', + path: '/items', + value: [], + }, + }, + ] as ServerToClientMessage[]); + + const surface = proc.getOrCreateSurface('s1'); + expect(surface.components.get('root')).toMatchObject({ + children: [], + }); + }); + test('dispatch with no listeners resolves with empty array', async () => { const proc = new MessageProcessor(); const result = await proc.dispatch({ userAction: { name: 'x' } });