From c5ab134b0a02034071fc29330d326feacae12444 Mon Sep 17 00:00:00 2001 From: Jessica Wu <47724360+innkuika@users.noreply.github.com> Date: Fri, 23 Oct 2026 17:23:23 +0000 Subject: [PATCH 1/3] fix(web_core): answer callRendererFunction without catalogId with INVALID_FUNCTION_CALL The v1.0 specification requires the renderer to reject a callRendererFunction whose callFunction.catalogId is omitted or unregistered with a rendererFunctionResponse carrying the call's functionCallId and code INVALID_FUNCTION_CALL. RpcHandler already does this for both cases, but the v1.0 version adapter validated the whole envelope against the generated union schema first, so an omitted catalogId threw an A2uiValidationError listing every union branch from processMessages/processMessagesAsync, and the agent never got a response. The v1.0 adapter now accepts a callRendererFunction that fails the union only because callFunction.catalogId is absent, validating the rest of the message against the call schema without that requirement, so the call reaches RpcHandler, which answers INVALID_FUNCTION_CALL. There is no fallback to the surface's default catalog. Any other malformed callRendererFunction still throws. Fixes #3529 --- typescript/core/web_core/CHANGELOG.md | 1 + .../web_core/src/processing/adapters/base.ts | 14 ++- .../web_core/src/processing/adapters/v1_0.ts | 34 +++++- .../src/processing/message-processor.test.ts | 114 ++++++++++++++++-- 4 files changed, 152 insertions(+), 11 deletions(-) diff --git a/typescript/core/web_core/CHANGELOG.md b/typescript/core/web_core/CHANGELOG.md index 16c27644d8..6f083aaf71 100644 --- a/typescript/core/web_core/CHANGELOG.md +++ b/typescript/core/web_core/CHANGELOG.md @@ -1,5 +1,6 @@ ## Unreleased +- (v1_0) `MessageProcessor` answers a `callRendererFunction` that omits `callFunction.catalogId` with a `rendererFunctionResponse` carrying its `functionCallId` and error code `INVALID_FUNCTION_CALL`, as it already answers an unregistered `catalogId`, instead of throwing `A2uiValidationError` from `processMessages` and `processMessagesAsync` and sending the agent no response ([#3529](https://github.com/a2ui-project/a2ui/issues/3529)). The call does not fall back to the surface's default catalog. A `callRendererFunction` that is malformed in any other way still throws. - Pass literal `'aria-label'`, `'aria-description'`, and `'aria-live'` attribute names to `this.setAttribute(...)` in `BasicCatalogA2uiLitElement.applyAccessibilityAttributes`, construct Unicode property escape regular expressions via `new RegExp(..., 'u')` in `uax31.ts` and `expression_parser.ts`, and delegate `ExtensionsSchema` key validation in `common-types.ts` to `isValidUax31Identifier`, improving compatibility with Trusted Types `setAttribute` static analyzers and downstream JavaScript compilers. - `Catalog.toJson()` and `Catalog.validationSchema` write a `z.union` as `anyOf` instead of `oneOf`, so a catalog reloaded with `Catalog.fromJson` accepts the values the original accepts, such as `"md"` for `z.union([z.enum(['md', 'lg']), z.string()])` ([#3362](https://github.com/a2ui-project/a2ui/issues/3362)). Specification-shaped models (`spec()` with `mixins`), including the built-in basic catalogs, still write a union as `oneOf` when no value can match two of its branches. A `oneOf` loaded with `Catalog.fromJson` stays exclusive and is written back as `oneOf`. - Under a `validationConfig` that allows dangling references and orphans or a missing root, as an agent's renderer state configures it, `MessageProcessor` checks an `updateComponents` against the components it changes, instead of copying every component of the surface and checking the whole surface again. It records what the last check found (each component's references, the components referencing it, and a bound on the longest chain below it) and checks cycles, depth and the `allowedParents` and `allowedChildren` constraints of the components the message affects. A component outside the message that references one of its components is checked against only the children the message changes, so a container whose catalog declares `allowedChildren`, receiving one child per message, is not checked against all its children each time. When that record cannot show the message passes, or the components changed outside the processor since, it checks the whole surface as before, so a message that fails reports the same error. A surface sent as n messages of one component each now takes time in proportion to n rather than n squared. Under any other config the checks are unchanged. The composition check is skipped when no catalog the surface's components come from declares `allowedParents` or `allowedChildren`, since it then has nothing to check. diff --git a/typescript/core/web_core/src/processing/adapters/base.ts b/typescript/core/web_core/src/processing/adapters/base.ts index e529680253..c35382c302 100644 --- a/typescript/core/web_core/src/processing/adapters/base.ts +++ b/typescript/core/web_core/src/processing/adapters/base.ts @@ -242,7 +242,7 @@ export abstract class BaseVersionAdapter implements VersionAdapter { } const preparedPayload = this.preparePayloadForValidation(msgObj); - const parseResult = this.schema.safeParse(preparedPayload); + const parseResult = this.validatePayload(preparedPayload); if (!parseResult.success) { const formattedErrors = parseResult.error.errors.map(formatZodIssue).join('; '); throw new A2uiValidationError( @@ -254,6 +254,18 @@ export abstract class BaseVersionAdapter implements VersionAdapter { return this.extractOperationsFromObject(msgObj); } + /** + * Validates a normalized message payload against the version's schema. + * + * @param payload Normalized message payload. + * @returns The schema's parse result. + */ + protected validatePayload( + payload: Record, + ): z.SafeParseReturnType { + return this.schema.safeParse(payload); + } + /** * Normalizes the message object before running schema validation. * diff --git a/typescript/core/web_core/src/processing/adapters/v1_0.ts b/typescript/core/web_core/src/processing/adapters/v1_0.ts index fba1556591..9eb662c986 100644 --- a/typescript/core/web_core/src/processing/adapters/v1_0.ts +++ b/typescript/core/web_core/src/processing/adapters/v1_0.ts @@ -14,13 +14,36 @@ * limitations under the License. */ +import {z} from 'zod'; import {BaseVersionAdapter, ProtocolVersion} from './base.js'; import { InternalComponentPayload, InternalOperation, INTERNAL_OPERATION_TYPES, } from '../operations.js'; -import {AgentToRendererMessageSchema} from '../../v1_0/schema/agent-to-renderer.js'; +import { + AgentToRendererMessageSchema, + CallRendererFunctionMessageSchema, +} from '../../v1_0/schema/agent-to-renderer.js'; +import {FunctionCallSchema} from '../../v1_0/schema/common-types.js'; + +/** + * `CallRendererFunctionMessageSchema` without its `callFunction.catalogId` requirement. + * + * The specification answers a call that omits `catalogId` with an `INVALID_FUNCTION_CALL` + * `rendererFunctionResponse`, which `RpcHandler` sends, so such a call must pass validation. + */ +const CallRendererFunctionOmittingCatalogIdSchema = CallRendererFunctionMessageSchema.extend({ + callRendererFunction: CallRendererFunctionMessageSchema.shape.callRendererFunction.extend({ + callFunction: FunctionCallSchema, + }), +}); + +function omitsCatalogId(payload: Record): boolean { + const crf = payload.callRendererFunction as Record | undefined; + const cf = crf?.callFunction; + return !!cf && typeof cf === 'object' && !Array.isArray(cf) && !('catalogId' in cf); +} /** * Protocol version adapter for specification v1.0. @@ -33,6 +56,15 @@ export class V1Point0Adapter extends BaseVersionAdapter { return [...INTERNAL_OPERATION_TYPES]; } + protected override validatePayload( + payload: Record, + ): z.SafeParseReturnType { + const result = super.validatePayload(payload); + if (result.success || !omitsCatalogId(payload)) return result; + const relaxed = CallRendererFunctionOmittingCatalogIdSchema.safeParse(payload); + return relaxed.success ? relaxed : result; + } + protected extractOperationsFromObject(msgObj: Record): InternalOperation[] { const ops: InternalOperation[] = []; if ('createSurface' in msgObj) { diff --git a/typescript/core/web_core/src/processing/message-processor.test.ts b/typescript/core/web_core/src/processing/message-processor.test.ts index 4fab5608ba..f2bda8a8d6 100644 --- a/typescript/core/web_core/src/processing/message-processor.test.ts +++ b/typescript/core/web_core/src/processing/message-processor.test.ts @@ -830,20 +830,116 @@ describe('MessageProcessor', () => { RpcErrorCode.EXECUTION_ERROR, ); - // 5. Wire message missing callFunction.catalogId -> throws A2uiValidationError - await assert.rejects( - proc.processMessagesAsync({ + // 5. Wire message missing callFunction.catalogId -> INVALID_FUNCTION_CALL, even though + // s1 has a default catalog: callRendererFunction does not fall back to it. + const resNoCatalog = await proc.processMessagesAsync({ + version: 'v1.0', + callRendererFunction: { + surfaceId: 's1', + functionCallId: 'rpc-surf-5', + callFunction: { + call: 'readUserName', + args: {}, + }, + }, + } as any); + assert.strictEqual(resNoCatalog.length, 1); + assert.strictEqual(resNoCatalog[0].rendererFunctionResponse.functionCallId, 'rpc-surf-5'); + assert.strictEqual( + resNoCatalog[0].rendererFunctionResponse.error?.code, + RpcErrorCode.INVALID_FUNCTION_CALL, + ); + }); + + describe('callRendererFunction without a resolvable catalogId (#3529)', () => { + const basicCatalog = new Catalog( + 'https://a2ui.org/specification/v1_0/catalogs/basic/catalog.json', + '1.0', + [], + BASIC_FUNCTIONS, + ); + + function createProcessor() { + const outbound: any[] = []; + const proc = new MessageProcessor([basicCatalog], undefined, { + version: 'v1.0', + outboundListener: msg => { + outbound.push(msg); + }, + }); + return {proc, outbound}; + } + + function callMessage(functionCallId: string, catalogId?: unknown): any { + return { version: 'v1.0', callRendererFunction: { - functionCallId: 'rpc-surf-5', + functionCallId, callFunction: { - call: 'readUserName', - args: {}, + '@call': 'formatString', + ...(catalogId === undefined ? {} : {catalogId}), + args: {value: 'x'}, }, }, - } as any), - A2uiValidationError, - ); + }; + } + + function assertSingleRejection(responses: any[], functionCallId: string) { + assert.strictEqual(responses.length, 1); + assert.strictEqual(responses[0].version, 'v1.0'); + assert.strictEqual(responses[0].rendererFunctionResponse.functionCallId, functionCallId); + assert.strictEqual(responses[0].rendererFunctionResponse.value, undefined); + assert.strictEqual( + responses[0].rendererFunctionResponse.error?.code, + RpcErrorCode.INVALID_FUNCTION_CALL, + ); + } + + it('answers an omitted catalogId with INVALID_FUNCTION_CALL from processMessagesAsync', async () => { + const {proc, outbound} = createProcessor(); + const responses = await proc.processMessagesAsync(callMessage('f1')); + assertSingleRejection(responses, 'f1'); + assertSingleRejection(outbound, 'f1'); + }); + + it('answers an omitted catalogId with INVALID_FUNCTION_CALL from processMessages', async () => { + const {proc, outbound} = createProcessor(); + assert.doesNotThrow(() => proc.processMessages(callMessage('f2'))); + await new Promise(resolve => setImmediate(resolve)); + assertSingleRejection(outbound, 'f2'); + }); + + it('answers an unregistered catalogId with INVALID_FUNCTION_CALL', async () => { + const {proc, outbound} = createProcessor(); + const responses = await proc.processMessagesAsync(callMessage('f3', 'unknown_catalog')); + assertSingleRejection(responses, 'f3'); + assertSingleRejection(outbound, 'f3'); + assert.match( + responses[0].rendererFunctionResponse.error?.message ?? '', + /Catalog not found: unknown_catalog/, + ); + }); + + it('keeps processing the rest of a batch after the rejected call', async () => { + const {proc, outbound} = createProcessor(); + const responses = await proc.processMessagesAsync([ + callMessage('f4'), + {version: 'v1.0', createSurface: {surfaceId: 's1', catalogId: basicCatalog.id}}, + ]); + assertSingleRejection(responses, 'f4'); + assertSingleRejection(outbound, 'f4'); + assert.ok(proc.model.getSurface('s1')); + }); + + it('still rejects a callRendererFunction that is malformed otherwise', async () => { + const {proc, outbound} = createProcessor(); + await assert.rejects(proc.processMessagesAsync(callMessage('f5', 42)), A2uiValidationError); + const noCall = callMessage('f6'); + delete noCall.callRendererFunction.callFunction['@call']; + noCall.callRendererFunction.extra = true; + await assert.rejects(proc.processMessagesAsync(noCall), A2uiValidationError); + assert.strictEqual(outbound.length, 0); + }); }); it('enforces requiresUserActivation in processMessagesAsync via context options', async () => { From 404abababe8188b50fc39699b8291bb02aa7890d Mon Sep 17 00:00:00 2001 From: Jessica Wu <47724360+innkuika@users.noreply.github.com> Date: Fri, 23 Oct 2026 17:41:49 +0000 Subject: [PATCH 2/3] fix(web_core): use Object.hasOwn to detect a missing catalogId Address review feedback: avoid the in operator's prototype lookup. --- typescript/core/web_core/src/processing/adapters/v1_0.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/typescript/core/web_core/src/processing/adapters/v1_0.ts b/typescript/core/web_core/src/processing/adapters/v1_0.ts index 9eb662c986..10fe1f2c6e 100644 --- a/typescript/core/web_core/src/processing/adapters/v1_0.ts +++ b/typescript/core/web_core/src/processing/adapters/v1_0.ts @@ -42,7 +42,7 @@ const CallRendererFunctionOmittingCatalogIdSchema = CallRendererFunctionMessageS function omitsCatalogId(payload: Record): boolean { const crf = payload.callRendererFunction as Record | undefined; const cf = crf?.callFunction; - return !!cf && typeof cf === 'object' && !Array.isArray(cf) && !('catalogId' in cf); + return !!cf && typeof cf === 'object' && !Array.isArray(cf) && !Object.hasOwn(cf, 'catalogId'); } /** From d665683359fc1aacc7268bb707314cff8ce90752 Mon Sep 17 00:00:00 2001 From: Jessica Wu <47724360+innkuika@users.noreply.github.com> Date: Fri, 23 Oct 2026 17:52:54 +0000 Subject: [PATCH 3/3] fix(web_core): build the relaxed callRendererFunction schema lazily Deriving it at module load read CallRendererFunctionMessageSchema before it was initialized in the minified Angular production build, which failed with "Cannot read properties of undefined (reading 'extend')" on every page. Build it on first use instead. --- .../web_core/src/processing/adapters/v1_0.ts | 20 +++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/typescript/core/web_core/src/processing/adapters/v1_0.ts b/typescript/core/web_core/src/processing/adapters/v1_0.ts index 10fe1f2c6e..72ce92f8d5 100644 --- a/typescript/core/web_core/src/processing/adapters/v1_0.ts +++ b/typescript/core/web_core/src/processing/adapters/v1_0.ts @@ -27,17 +27,25 @@ import { } from '../../v1_0/schema/agent-to-renderer.js'; import {FunctionCallSchema} from '../../v1_0/schema/common-types.js'; +let callRendererFunctionOmittingCatalogIdSchema: z.ZodTypeAny | undefined; + /** * `CallRendererFunctionMessageSchema` without its `callFunction.catalogId` requirement. * * The specification answers a call that omits `catalogId` with an `INVALID_FUNCTION_CALL` * `rendererFunctionResponse`, which `RpcHandler` sends, so such a call must pass validation. + * + * Built on first use rather than at module load: the schemas it derives from may not be + * initialized yet when this module is evaluated (for example in bundled production builds). */ -const CallRendererFunctionOmittingCatalogIdSchema = CallRendererFunctionMessageSchema.extend({ - callRendererFunction: CallRendererFunctionMessageSchema.shape.callRendererFunction.extend({ - callFunction: FunctionCallSchema, - }), -}); +function getCallRendererFunctionOmittingCatalogIdSchema(): z.ZodTypeAny { + callRendererFunctionOmittingCatalogIdSchema ??= CallRendererFunctionMessageSchema.extend({ + callRendererFunction: CallRendererFunctionMessageSchema.shape.callRendererFunction.extend({ + callFunction: FunctionCallSchema, + }), + }); + return callRendererFunctionOmittingCatalogIdSchema; +} function omitsCatalogId(payload: Record): boolean { const crf = payload.callRendererFunction as Record | undefined; @@ -61,7 +69,7 @@ export class V1Point0Adapter extends BaseVersionAdapter { ): z.SafeParseReturnType { const result = super.validatePayload(payload); if (result.success || !omitsCatalogId(payload)) return result; - const relaxed = CallRendererFunctionOmittingCatalogIdSchema.safeParse(payload); + const relaxed = getCallRendererFunctionOmittingCatalogIdSchema().safeParse(payload); return relaxed.success ? relaxed : result; }