Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions typescript/core/web_core/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
14 changes: 13 additions & 1 deletion typescript/core/web_core/src/processing/adapters/base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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<string, unknown>,
): z.SafeParseReturnType<unknown, unknown> {
return this.schema.safeParse(payload);
}

/**
* Normalizes the message object before running schema validation.
*
Expand Down
42 changes: 41 additions & 1 deletion typescript/core/web_core/src/processing/adapters/v1_0.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>): boolean {
const crf = payload.callRendererFunction as Record<string, unknown> | undefined;
const cf = crf?.callFunction;
return !!cf && typeof cf === 'object' && !Array.isArray(cf) && !Object.hasOwn(cf, 'catalogId');
}

/**
* Protocol version adapter for specification v1.0.
Expand All @@ -33,6 +64,15 @@ export class V1Point0Adapter extends BaseVersionAdapter {
return [...INTERNAL_OPERATION_TYPES];
}

protected override validatePayload(
payload: Record<string, unknown>,
): z.SafeParseReturnType<unknown, unknown> {
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<string, unknown>): InternalOperation[] {
const ops: InternalOperation[] = [];
if ('createSurface' in msgObj) {
Expand Down
114 changes: 105 additions & 9 deletions typescript/core/web_core/src/processing/message-processor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down
Loading