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..72ce92f8d5 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,44 @@ * 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'; + +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). + */ +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; + const cf = crf?.callFunction; + return !!cf && typeof cf === 'object' && !Array.isArray(cf) && !Object.hasOwn(cf, 'catalogId'); +} /** * Protocol version adapter for specification v1.0. @@ -33,6 +64,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 = getCallRendererFunctionOmittingCatalogIdSchema().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 () => {