From 72f7668712480083a9892b22061355b6ad69ba69 Mon Sep 17 00:00:00 2001 From: Greg Spencer Date: Fri, 23 Oct 2026 23:01:37 +0000 Subject: [PATCH 1/8] feat(web_core): add MessageErrorMapper, JSON Pointer error paths, and conformance parity --- typescript/core/web_core/CHANGELOG.md | 1 + typescript/core/web_core/src/common/events.ts | 5 +- typescript/core/web_core/src/errors.ts | 88 +- .../web_core/src/processing/adapters/base.ts | 27 +- .../web_core/src/processing/adapters/v1_0.ts | 61 +- .../core/web_core/src/processing/index.ts | 11 + .../src/processing/message-error-mapper.ts | 285 +++++++ .../src/processing/message-processor.test.ts | 65 ++ .../src/processing/message-processor.ts | 480 ++++++++--- .../web_core/src/resolution/data-context.ts | 102 ++- .../web_core/src/resolution/generic-binder.ts | 8 +- .../web_core/src/resolution/node-resolver.ts | 15 +- .../core/web_core/src/rpc/rpc-handler.ts | 3 +- .../src/state/surface-components-model.ts | 4 + .../web_core/src/state/surface-group-model.ts | 31 +- .../web_core/src/state/validation-helpers.ts | 102 ++- typescript/core/web_core/src/v0_9/index.ts | 2 +- typescript/core/web_core/src/v1_0/index.ts | 1 + .../src/validation/payload-validator.ts | 190 +++-- .../tests/conformance/conformance_test.mjs | 756 ++++++++++++++++-- .../tests/conformance/node-resolution.ts | 149 +++- 21 files changed, 2002 insertions(+), 384 deletions(-) create mode 100644 typescript/core/web_core/src/processing/message-error-mapper.ts diff --git a/typescript/core/web_core/CHANGELOG.md b/typescript/core/web_core/CHANGELOG.md index 7982f6d726..f599279ab0 100644 --- a/typescript/core/web_core/CHANGELOG.md +++ b/typescript/core/web_core/CHANGELOG.md @@ -19,6 +19,7 @@ - Add `MessageProcessor.catalogs`, the catalogs the processor was created with, and `ExecutionContext.validationConfig`, which `processMessages`, `processMessagesAsync` and `processOperation` apply to that call in place of the processor's own `validationConfig`. [#3597](https://github.com/a2ui-project/a2ui/issues/3597) - Universal elements rendered from a `ComponentNode` read their props from the node instead of allocating a second `GenericBinder`, and `A2uiLitElement.renderNode` accepts a resolved `ComponentNode` as well as an `A2uiChildRef`. [#3705](https://github.com/a2ui-project/a2ui/pull/3705) - (v1_0) `Catalog.invoker` reads a `ValidationResult` object that a function argument resolves to by its `valid` boolean wherever the function's schema types that argument as `DynamicBoolean`, as the specification defines the type. This applies to every `DynamicBoolean` argument, in built-in and custom catalogs, so `formatNumber`'s `grouping` and a custom function's boolean parameters now read a failing validation as `false` rather than as a truthy object; `and`, `or` and `not` already did. Catalogs below v1.0, whose validation functions return booleans, are unchanged. [#3653](https://github.com/a2ui-project/a2ui/pull/3653) +- Add `mapErrorToRendererMessage` and `extractEnvelopeErrorContext` (along with `ErrorMessage`, `A2uiRendererError`, `ValidationFailedErrorBody`, `GenericErrorBody`, and `ErrorEnvelopeContext`) to `@a2ui/web_core` and `@a2ui/web_core/v0_9`, add `SurfaceGroupModel.onError` and `MessageProcessor.processMessagesWithErrors` for structured outbound renderer error envelopes, and emit RFC 6901 JSON Pointer `path` values on `UNALLOWED_PARENT` and `UNALLOWED_CHILD` composition constraint errors. - v1.0 function RPC payloads name the function with `'@call'`. `RpcHandler.callAgentFunction` rewrites a v0.9-style `call` key to `'@call'` in the outbound `callAgentFunction` message, and `MessageProcessor` passes `'@call'` in the `callRendererFunction` message it hands to the `RpcHandler` ([#3486](https://github.com/a2ui-project/a2ui/issues/3486)). - **BREAKING CHANGE**: Remove cross-catalog `$ref` resolution across peer catalogs, added in 0.14.0. `Catalog.peerCatalogs`, `Catalog.withPeerCatalogs`, the optional `peerCatalogs` and `withPeerCatalogs` members of `CatalogInterface`, `linkCatalogs` and `inlinePeerAndLocalRefs` are removed, and `MessageProcessor` uses the catalogs passed to it as is. A catalog's `$ref`s resolve only against its own document and `common_types.json`, and `Catalog.fromJson` of a v1.0 catalog throws `A2uiCatalogError` (`Unsupported external $ref ...`) for a `$ref` to any other document, such as another catalog, without fetching it; v0.9 catalogs load as before. Recommended migration: copy the referenced definitions into each catalog that uses them. [#3232](https://github.com/a2ui-project/a2ui/issues/3232), [#3575](https://github.com/a2ui-project/a2ui/pull/3575) - Function calls now await pending Promises in their arguments before validating and running, while synchronous calls remain synchronous. [#3578](https://github.com/a2ui-project/a2ui/pull/3578) diff --git a/typescript/core/web_core/src/common/events.ts b/typescript/core/web_core/src/common/events.ts index cfd52dae37..7bf59fb043 100644 --- a/typescript/core/web_core/src/common/events.ts +++ b/typescript/core/web_core/src/common/events.ts @@ -65,7 +65,10 @@ export class EventEmitter implements EventSource { async emit(data: T): Promise { for (const listener of this.listeners) { try { - await listener(data); + const res = listener(data); + if (res && typeof (res as Promise).then === 'function') { + await res; + } } catch (e) { console.error('EventEmitter error:', e); } diff --git a/typescript/core/web_core/src/errors.ts b/typescript/core/web_core/src/errors.ts index 7b74a03e44..aa66487ee2 100644 --- a/typescript/core/web_core/src/errors.ts +++ b/typescript/core/web_core/src/errors.ts @@ -20,6 +20,32 @@ interface V8ErrorConstructor extends ErrorConstructor { captureStackTrace(targetObject: object, constructorOpt?: Function): void; } +/** + * Structured validation or processing error detail for a specific field path. + */ +export interface A2uiErrorDetail { + /** RFC 6901 JSON Pointer path to the offending field. */ + path: string; + /** Machine-readable error code. */ + code: string; + /** Human-readable error message. */ + message: string; + /** Optional target surface identifier. */ + surfaceId?: string; +} + +/** + * Optional context metadata for A2UI errors. + */ +export interface A2uiErrorContext { + /** RFC 6901 JSON Pointer path to the offending field. */ + path?: string; + /** Target surface identifier. */ + surfaceId?: string; + /** Correlated RPC function call identifier. */ + functionCallId?: string; +} + /** * Base class for all A2UI specific errors. * @@ -34,17 +60,29 @@ interface V8ErrorConstructor extends ErrorConstructor { export class A2uiError extends Error { /** Machine-readable string identifying the error category. */ public readonly code: string; + /** Optional RFC 6901 JSON Pointer path to the offending field. */ + public path?: string; + /** Optional target surface identifier. */ + public surfaceId?: string; + /** Optional correlated RPC function call identifier. */ + public functionCallId?: string; /** * Initializes a new `A2uiError` instance. * * @param message Human-readable error description. * @param code Machine-readable error category code. + * @param context Optional error context metadata (path, surfaceId, functionCallId). */ - constructor(message: string, code: string = 'UNKNOWN_ERROR') { + constructor(message: string, code: string = 'UNKNOWN_ERROR', context?: A2uiErrorContext) { super(message); this.name = this.constructor.name; this.code = code; + if (context?.path !== undefined) this.path = context.path; + if (context?.surfaceId !== undefined) this.surfaceId = context.surfaceId; + if (context?.functionCallId !== undefined) { + this.functionCallId = context.functionCallId; + } // Maintains proper stack trace for where our error was thrown (only available on V8) if ((Error as V8ErrorConstructor).captureStackTrace) { @@ -63,13 +101,15 @@ export class A2uiValidationError extends A2uiError { * @param message Error description detailing the validation failure. * @param details Additional error context or Zod validation issues. * @param code Optional error category code. + * @param context Optional error context metadata. */ constructor( message: string, public readonly details?: any, code: string = 'VALIDATION_ERROR', + context?: A2uiErrorContext, ) { - super(message, code); + super(message, code, context); } } @@ -82,12 +122,10 @@ export class A2uiDataError extends A2uiError { * * @param message Error description. * @param path Target data model path where the mutation failed. + * @param surfaceId Optional target surface identifier. */ - constructor( - message: string, - public readonly path?: string, - ) { - super(message, 'DATA_ERROR'); + constructor(message: string, path?: string, surfaceId?: string) { + super(message, 'DATA_ERROR', {path, surfaceId}); } } @@ -101,13 +139,15 @@ export class A2uiExpressionError extends A2uiError { * @param message Error description. * @param expression Evaluated expression string. * @param details Additional error details. + * @param context Optional error context metadata. */ constructor( message: string, public readonly expression?: string, public readonly details?: any, + context?: A2uiErrorContext, ) { - super(message, 'EXPRESSION_ERROR'); + super(message, 'EXPRESSION_ERROR', context); } } @@ -119,9 +159,10 @@ export class A2uiStateError extends A2uiError { * Initializes a new `A2uiStateError` instance. * * @param message Error description. + * @param context Optional error context metadata. */ - constructor(message: string) { - super(message, 'STATE_ERROR'); + constructor(message: string, context?: A2uiErrorContext) { + super(message, 'STATE_ERROR', context); } } @@ -137,9 +178,10 @@ export class A2uiCatalogError extends A2uiError { * * @param message Human-readable error description. * @param code Machine-readable error category code. Defaults to `'CATALOG_ERROR'`. + * @param context Optional error context metadata. */ - constructor(message: string, code: string = 'CATALOG_ERROR') { - super(message, code); + constructor(message: string, code: string = 'CATALOG_ERROR', context?: A2uiErrorContext) { + super(message, code, context); } } @@ -153,9 +195,15 @@ export class A2uiIntegrityError extends A2uiValidationError { * @param message Error description. * @param details Additional error details. * @param code Error category code. Defaults to 'INTEGRITY_ERROR'. + * @param context Optional error context metadata. */ - constructor(message: string, details?: any, code: string = 'INTEGRITY_ERROR') { - super(message, details, code); + constructor( + message: string, + details?: any, + code: string = 'INTEGRITY_ERROR', + context?: A2uiErrorContext, + ) { + super(message, details, code, context); this.name = 'A2uiIntegrityError'; } } @@ -170,9 +218,15 @@ export class A2uiRecursionError extends A2uiValidationError { * @param message Error description. * @param details Additional error details. * @param code Error category code. Defaults to 'RECURSION_ERROR'. + * @param context Optional error context metadata. */ - constructor(message: string, details?: any, code: string = 'RECURSION_ERROR') { - super(message, details, code); + constructor( + message: string, + details?: any, + code: string = 'RECURSION_ERROR', + context?: A2uiErrorContext, + ) { + super(message, details, code, context); this.name = 'A2uiRecursionError'; } } @@ -225,7 +279,7 @@ export class A2uiRpcError extends A2uiError { message: string, code: RpcErrorCode | string = RpcErrorCode.UNKNOWN_ERROR, /** Identifier of the failed function call, if available. */ - public readonly functionCallId?: string, + public override readonly functionCallId?: string, /** Structured error details or original error cause, if available. */ public readonly details?: unknown, ) { diff --git a/typescript/core/web_core/src/processing/adapters/base.ts b/typescript/core/web_core/src/processing/adapters/base.ts index 449a1419c6..7abd4bb848 100644 --- a/typescript/core/web_core/src/processing/adapters/base.ts +++ b/typescript/core/web_core/src/processing/adapters/base.ts @@ -18,6 +18,7 @@ import {z} from 'zod'; import {InternalOperation} from '../operations.js'; import {A2uiValidationError} from '../../errors.js'; import {formatZodIssue} from '../format-zod-issue.js'; +import {extractEnvelopeErrorContext, mapErrorToRendererMessage} from '../message-error-mapper.js'; import {SemVer, normalizeVersionString, toSemVer, toCanonicalVersion} from '../../common/semver.js'; /** @@ -167,7 +168,13 @@ function validateActionSurfaceIds( actionObj.surfaceId !== undefined && typeof actionObj.surfaceId !== 'string' ) { - throw new A2uiValidationError('surfaceId must be a string'); + const msg = 'surfaceId must be a string'; + throw new A2uiValidationError( + msg, + [{path: `/${key}/surfaceId`, code: 'type_mismatch', message: msg}], + 'VALIDATION_ERROR', + {path: '/surfaceId'}, + ); } } } @@ -232,8 +239,12 @@ export abstract class BaseVersionAdapter implements VersionAdapter { ); const sortedAllowed = nativeActionKeys.slice().sort().join(', '); if (otherAction) { + const msg = `Invalid ${this.version} message: action '${otherAction}' is not supported in protocol version ${this.version}. Allowed actions: ${sortedAllowed}.`; throw new A2uiValidationError( - `Invalid ${this.version} message: action '${otherAction}' is not supported in protocol version ${this.version}. Allowed actions: ${sortedAllowed}.`, + msg, + [{path: '/version', code: 'invalid_value', message: msg}], + 'VALIDATION_ERROR', + {path: '/version'}, ); } throw new A2uiValidationError( @@ -245,9 +256,21 @@ export abstract class BaseVersionAdapter implements VersionAdapter { const parseResult = this.schema.safeParse(preparedPayload); if (!parseResult.success) { const formattedErrors = parseResult.error.errors.map(formatZodIssue).join('; '); + const envCtx = extractEnvelopeErrorContext(msgObj, this.version); + const preliminaryErr = new A2uiValidationError( + `Invalid ${this.version} message: ${formattedErrors}`, + parseResult.error, + ); + const mapped = mapErrorToRendererMessage(preliminaryErr, envCtx); throw new A2uiValidationError( `Invalid ${this.version} message: ${formattedErrors}`, parseResult.error, + 'VALIDATION_ERROR', + { + path: mapped.error.path, + surfaceId: mapped.error.surfaceId, + functionCallId: mapped.error.functionCallId, + }, ); } 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 0488d95584..9e02b76a59 100644 --- a/typescript/core/web_core/src/processing/adapters/v1_0.ts +++ b/typescript/core/web_core/src/processing/adapters/v1_0.ts @@ -20,8 +20,49 @@ import { InternalOperation, INTERNAL_OPERATION_TYPES, } from '../operations.js'; +import {A2uiValidationError} from '../../errors.js'; import {AgentToRendererMessageSchema} from '../../v1_0/schema/agent-to-renderer.js'; +function assertObjectArgs( + cf: Record, + version: ProtocolVersion, + functionCallId: string, +): void { + if ( + 'args' in cf && + cf.args !== undefined && + (typeof cf.args !== 'object' || cf.args === null || Array.isArray(cf.args)) + ) { + throw new A2uiValidationError( + `Invalid ${version} message: callRendererFunction.callFunction.args: Expected object, received ${Array.isArray(cf.args) ? 'array' : typeof cf.args}`, + undefined, + 'VALIDATION_ERROR', + {functionCallId: functionCallId || undefined}, + ); + } +} + +function extractCallRendererFunctionOp( + crf: Record | undefined, + version: ProtocolVersion, +): InternalOperation { + const cf = (crf?.callFunction || {}) as Record; + const functionCallId = String(crf?.functionCallId || ''); + assertObjectArgs(cf, version, functionCallId); + return { + type: 'callRendererFunction', + surfaceId: typeof crf?.surfaceId === 'string' ? crf.surfaceId : undefined, + functionCallId, + call: String(cf?.['@call'] || cf?.call || ''), + version, + catalogId: typeof cf?.catalogId === 'string' ? cf?.catalogId : undefined, + args: + cf?.args && typeof cf.args === 'object' && !Array.isArray(cf.args) + ? (cf.args as Record) + : {}, + }; +} + /** * Protocol version adapter for specification v1.0. */ @@ -84,20 +125,12 @@ export class V1Point0Adapter extends BaseVersionAdapter { }); } if ('callRendererFunction' in msgObj) { - const crf = msgObj.callRendererFunction as Record; - const cf = (crf?.callFunction || {}) as Record; - ops.push({ - type: 'callRendererFunction', - surfaceId: typeof crf?.surfaceId === 'string' ? crf.surfaceId : undefined, - functionCallId: String(crf?.functionCallId || ''), - call: String(cf?.['@call'] || cf?.call || ''), - version: this.version, - catalogId: typeof cf?.catalogId === 'string' ? cf.catalogId : undefined, - args: - cf?.args && typeof cf.args === 'object' && !Array.isArray(cf.args) - ? (cf.args as Record) - : {}, - }); + ops.push( + extractCallRendererFunctionOp( + msgObj.callRendererFunction as Record, + this.version, + ), + ); } if ('agentFunctionResponse' in msgObj) { const afr = msgObj.agentFunctionResponse as Record; diff --git a/typescript/core/web_core/src/processing/index.ts b/typescript/core/web_core/src/processing/index.ts index 63923f0b7e..d0e43f2ca5 100644 --- a/typescript/core/web_core/src/processing/index.ts +++ b/typescript/core/web_core/src/processing/index.ts @@ -15,6 +15,17 @@ */ export * from './message-processor.js'; +export { + mapErrorToRendererMessage, + extractEnvelopeErrorContext, + normalizeJsonPointer, + type ErrorMessage, + type A2uiRendererError, + type ValidationFailedErrorBody, + type GenericErrorBody, + type ErrorEnvelopeContext, + type EnvelopeErrorContext, +} from './message-error-mapper.js'; export * from './adapters/factory.js'; export * from './adapters/base.js'; export * from '../errors.js'; diff --git a/typescript/core/web_core/src/processing/message-error-mapper.ts b/typescript/core/web_core/src/processing/message-error-mapper.ts new file mode 100644 index 0000000000..3576168b9b --- /dev/null +++ b/typescript/core/web_core/src/processing/message-error-mapper.ts @@ -0,0 +1,285 @@ +/* + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import {z} from 'zod'; +import {isAtLeastVersion} from '../common/semver.js'; +import { + A2uiCatalogError, + A2uiDataError, + A2uiError, + A2uiExpressionError, + A2uiIntegrityError, + A2uiRecursionError, + A2uiRpcError, + A2uiStateError, + A2uiValidationError, +} from '../errors.js'; + +/** Outbound error body for validation or composition constraint failures. */ +export interface ValidationFailedErrorBody { + readonly code: 'VALIDATION_FAILED' | 'UNALLOWED_PARENT' | 'UNALLOWED_CHILD'; + readonly surfaceId: string; + readonly path: string; + readonly message: string; + readonly functionCallId?: undefined; +} + +/** Outbound error body for generic, integrity, catalog, or RPC failures. */ +export interface GenericErrorBody { + readonly code: string; + readonly message: string; + readonly surfaceId?: string; + readonly path?: string; + readonly functionCallId?: string; + readonly [key: string]: unknown; +} + +/** Union of spec-compliant outbound renderer error bodies. */ +export type A2uiRendererError = ValidationFailedErrorBody | GenericErrorBody; + +/** Outbound renderer-to-agent error message envelope. */ +export interface ErrorMessage { + readonly version: 'v0.8' | 'v0.9' | 'v0.9.1' | 'v1.0'; + readonly error: A2uiRendererError; +} + +/** Known top-level action envelope keys across A2UI protocol versions. */ +const ACTION_ENVELOPE_KEYS = [ + 'createSurface', + 'updateComponents', + 'updateDataModel', + 'deleteSurface', + 'callRendererFunction', + 'agentFunctionResponse', + 'beginRendering', + 'surfaceUpdate', + 'dataModelUpdate', +] as const; + +/** + * Envelope metadata extracted from a raw A2UI message prior to or during + * validation, used to correlate outbound renderer error payloads. + */ +export interface ErrorEnvelopeContext { + /** Target surface ID extracted from the message envelope, if present. */ + readonly surfaceId?: string; + /** Target functionCallId extracted from a `callRendererFunction` envelope, if present. */ + readonly functionCallId?: string; + /** Top-level action key (e.g. `'createSurface'`, `'callRendererFunction'`). */ + readonly actionType?: string; + /** Whether the message envelope is a `callRendererFunction` message. */ + readonly isCallRendererFunction?: boolean; + /** Protocol version string extracted from the message or processor fallback. */ + readonly version?: string; +} + +/** Backwards-compatible alias for `ErrorEnvelopeContext`. */ +export type EnvelopeErrorContext = ErrorEnvelopeContext; + +function isPlainRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +/** + * Extracts best-effort correlation metadata (`surfaceId`, `functionCallId`, + * `actionType`, `version`) from a raw message envelope before schema parsing. + * + * @param rawMessage Raw unvalidated message object. + * @param fallbackVersion Optional fallback protocol version string. + * @returns Extracted envelope error correlation context. + */ +export function extractEnvelopeErrorContext( + rawMessage: unknown, + fallbackVersion?: string, +): ErrorEnvelopeContext { + if (!isPlainRecord(rawMessage)) { + return {version: fallbackVersion}; + } + + const version = + typeof rawMessage['version'] === 'string' ? rawMessage['version'] : fallbackVersion; + const actionType = ACTION_ENVELOPE_KEYS.find(key => key in rawMessage); + if (!actionType) { + return {version}; + } + + const isCallRendererFunction = actionType === 'callRendererFunction'; + const payload = rawMessage[actionType]; + if (!isPlainRecord(payload)) { + return {actionType, isCallRendererFunction, version}; + } + + const surfaceId = typeof payload['surfaceId'] === 'string' ? payload['surfaceId'] : undefined; + const functionCallId = + typeof payload['functionCallId'] === 'string' ? payload['functionCallId'] : undefined; + return { + surfaceId, + functionCallId, + actionType, + isCallRendererFunction, + version, + }; +} + +function stripActionPrefix(segments: string[]): string[] { + if (segments.length > 0 && (ACTION_ENVELOPE_KEYS as readonly string[]).includes(segments[0])) { + return segments.slice(1); + } + return segments; +} + +/** + * Normalizes a raw path string into an RFC 6901 JSON Pointer (`"/components/0/text"`), + * stripping any leading action envelope prefix. + * + * @param rawPath Raw path or JSON Pointer string. + * @returns Normalized RFC 6901 JSON Pointer, or `undefined` if empty. + */ +export function normalizeJsonPointer(rawPath: string | undefined): string | undefined { + if (!rawPath) return undefined; + const converted = rawPath + .replace(/\[(\d+)\]/g, '/$1') + .replace(/\./g, '/') + .replace(/^\/+/, ''); + if (!converted) return '/'; + const segments = stripActionPrefix(converted.split('/').filter(Boolean)); + return segments.length > 0 ? `/${segments.join('/')}` : undefined; +} + +function collectLeafZodIssues(issues: readonly z.ZodIssue[], actionType?: string): z.ZodIssue[] { + const leaves: z.ZodIssue[] = []; + for (const issue of issues) { + if (issue.code === z.ZodIssueCode.invalid_union && 'unionErrors' in issue) { + const unionIssues = issue.unionErrors.flatMap(u => u.issues); + leaves.push(...collectLeafZodIssues(unionIssues, actionType)); + } else { + leaves.push(issue); + } + } + if (!actionType) return leaves; + const matching = leaves.filter(i => i.path[0] === actionType && i.path.length > 1); + return matching.length > 0 ? matching : leaves; +} + +function extractPathFromZodDetails(details: unknown, actionType?: string): string | undefined { + let issues: readonly z.ZodIssue[] | undefined; + if (details instanceof z.ZodError) { + issues = details.issues; + } else if (Array.isArray(details) && details.length > 0) { + const first = details[0] as Record; + if (typeof first?.['path'] === 'string') { + return normalizeJsonPointer(first['path']); + } + if (Array.isArray(first?.['path'])) { + issues = details as z.ZodIssue[]; + } + } + if (!issues || issues.length === 0) return undefined; + + const selected = collectLeafZodIssues(issues, actionType)[0]; + if (!selected) return undefined; + const segments = selected.path.map(String); + if ( + selected.code === z.ZodIssueCode.unrecognized_keys && + 'keys' in selected && + Array.isArray(selected.keys) && + selected.keys.length > 0 + ) { + segments.push(String(selected.keys[0])); + } + const stripped = stripActionPrefix(segments); + return stripped.length > 0 ? `/${stripped.join('/')}` : undefined; +} + +function normalizeOutputVersion(version: string | undefined): 'v0.8' | 'v0.9' | 'v0.9.1' | 'v1.0' { + if (version === 'v0.8' || version === '0.8') return 'v0.8'; + if (version === 'v0.9.1' || version === '0.9.1') return 'v0.9.1'; + if (version && isAtLeastVersion(version, '1.0')) return 'v1.0'; + return 'v0.9'; +} + +function resolveErrorCode(err: unknown, isV1: boolean): string { + if (err instanceof A2uiRecursionError) return 'RECURSION_LIMIT_EXCEEDED'; + if (err instanceof A2uiIntegrityError || err instanceof A2uiStateError) { + return 'INTEGRITY_ERROR'; + } + if (err instanceof A2uiCatalogError) return 'CATALOG_NOT_FOUND'; + if (err instanceof A2uiDataError) return 'DATA_ERROR'; + if (err instanceof A2uiExpressionError) return 'EXPRESSION_ERROR'; + if (err instanceof A2uiValidationError) { + if (err.code === 'UNALLOWED_PARENT' || err.code === 'UNALLOWED_CHILD') { + return isV1 ? err.code : 'VALIDATION_FAILED'; + } + return 'VALIDATION_FAILED'; + } + if (err instanceof A2uiError) return err.code || 'INTERNAL_ERROR'; + return 'INTERNAL_ERROR'; +} + +/** + * Maps an internal `A2uiError` (or unknown thrown error) and envelope context + * into a spec-compliant outbound renderer `ErrorMessage`. + * + * @param err Thrown error instance. + * @param context Extracted envelope correlation context. + * @returns Spec-compliant outbound `ErrorMessage`. + */ +export function mapErrorToRendererMessage( + err: unknown, + context: EnvelopeErrorContext = {}, +): ErrorMessage { + const version = normalizeOutputVersion(context.version); + const isV1 = version === 'v1.0'; + const a2uiErr = err instanceof A2uiError ? err : undefined; + const message = err instanceof Error ? err.message : String(err); + + const functionCallId = a2uiErr?.functionCallId ?? context.functionCallId; + if ( + context.isCallRendererFunction || + context.actionType === 'callRendererFunction' || + err instanceof A2uiRpcError || + functionCallId !== undefined + ) { + const rpcCode = err instanceof A2uiRpcError ? err.code : 'INVALID_FUNCTION_CALL'; + return { + version, + error: { + code: rpcCode, + ...(functionCallId !== undefined ? {functionCallId} : {}), + message, + }, + }; + } + + const code = resolveErrorCode(err, isV1); + const surfaceId = a2uiErr?.surfaceId ?? context.surfaceId ?? ''; + const rawPath = + a2uiErr?.path ?? + (err instanceof A2uiValidationError + ? extractPathFromZodDetails(err.details, context.actionType) + : undefined); + const path = normalizeJsonPointer(rawPath); + + return { + version, + error: { + code, + surfaceId, + ...(path !== undefined ? {path} : {}), + message, + }, + }; +} 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 47af64fa7f..79ab771159 100644 --- a/typescript/core/web_core/src/processing/message-processor.test.ts +++ b/typescript/core/web_core/src/processing/message-processor.test.ts @@ -22,6 +22,7 @@ import { STRICT_VALIDATION, RpcErrorCode, } from './message-processor.js'; +import {extractEnvelopeErrorContext, mapErrorToRendererMessage} from './message-error-mapper.js'; import {Catalog, ComponentApi, createFunctionImplementation} from '../catalog/types.js'; import {CardApi, RowApi, TabsApi} from '../v0_9/basic_catalog/components/basic_components.js'; import {BasicCatalogThemeSchema} from '../universal/basic_catalog/theme.js'; @@ -2158,5 +2159,69 @@ describe('MessageProcessor', () => { ); } }); + + it('maps errors to ErrorMessage and emits via errorHandler, onError, and processMessagesWithErrors', () => { + const strictTextApi: ComponentApi = { + name: 'Text', + schema: z.object({text: z.string()}).strict(), + }; + const base = new Catalog('base', '1.0', [strictTextApi]); + const handledErrors: any[] = []; + const groupErrors: any[] = []; + const proc = new MessageProcessor([base], undefined, { + version: 'v1.0', + errorHandler: msg => handledErrors.push(msg), + }); + proc.model.onError.subscribe(err => { + groupErrors.push(err); + }); + + proc.processMessages([ + {version: 'v1.0', createSurface: {surfaceId: 's1', catalogId: 'base'}}, + ]); + const surfaceErrors: any[] = []; + proc.getSurface('s1')!.onError.subscribe(err => { + surfaceErrors.push(err); + }); + + const errors = proc.processMessagesWithErrors([ + { + version: 'v1.0', + updateComponents: { + surfaceId: 's1', + components: [{id: 'root', component: 'Text', text: 123 as any}], + }, + }, + ]); + + assert.strictEqual(errors.length, 1); + assert.strictEqual(errors[0].version, 'v1.0'); + assert.strictEqual(errors[0].error.code, 'VALIDATION_FAILED'); + assert.strictEqual(errors[0].error.surfaceId, 's1'); + assert.strictEqual(errors[0].error.path, '/components/0/text'); + assert.strictEqual(handledErrors.length, 1); + assert.strictEqual(surfaceErrors.length, 1); + assert.strictEqual(groupErrors.length, 1); + }); + + it('maps callRendererFunction envelope faults to INVALID_FUNCTION_CALL with functionCallId', () => { + const ctx = extractEnvelopeErrorContext({ + version: 'v1.0', + callRendererFunction: { + functionCallId: 'fn-99', + callFunction: {call: 'foo', catalogId: 'cat', args: 'bad'}, + }, + }); + assert.strictEqual(ctx.functionCallId, 'fn-99'); + + const err = new A2uiValidationError('Bad args', undefined, 'VALIDATION_FAILED', { + functionCallId: 'fn-99', + }); + const mapped = mapErrorToRendererMessage(err, ctx); + assert.strictEqual(mapped.error.code, 'INVALID_FUNCTION_CALL'); + assert.strictEqual(mapped.error.functionCallId, 'fn-99'); + assert.strictEqual(mapped.error.surfaceId, undefined); + assert.strictEqual(mapped.error.path, undefined); + }); }); }); diff --git a/typescript/core/web_core/src/processing/message-processor.ts b/typescript/core/web_core/src/processing/message-processor.ts index 9ffe826c6d..09d157f920 100644 --- a/typescript/core/web_core/src/processing/message-processor.ts +++ b/typescript/core/web_core/src/processing/message-processor.ts @@ -24,8 +24,21 @@ import {ComponentModel} from '../state/component-model.js'; import {SurfaceComponentsModel} from '../state/surface-components-model.js'; import {DataModel} from '../state/data-model.js'; import {Subscription} from '../common/events.js'; +import {batchWrite} from '../reactivity/signals.js'; -import {A2uiCatalogError, A2uiIntegrityError, A2uiValidationError} from '../errors.js'; +import { + A2uiCatalogError, + A2uiDataError, + A2uiError, + A2uiIntegrityError, + A2uiValidationError, +} from '../errors.js'; +import { + EnvelopeErrorContext, + ErrorMessage, + extractEnvelopeErrorContext, + mapErrorToRendererMessage, +} from './message-error-mapper.js'; import { RecordedGraphBound, recordComponentGraphBound, @@ -69,6 +82,7 @@ import { } from '../rpc/index.js'; import {DataContext} from '../resolution/data-context.js'; import { + RELAXED_PATH_PATTERN, RELAXED_VALIDATION, STRICT_VALIDATION, validateRecursionAndPaths, @@ -158,6 +172,10 @@ export interface MessageProcessorOptions { validationConfig?: ValidationConfig; /** Listener receiving outbound renderer messages intended for the agent. Required for callAgentFunction. */ outboundListener?: OutboundMessageListener; + /** Optional listener invoked with a structured outbound ErrorMessage whenever message processing fails. */ + errorHandler?: (error: ErrorMessage) => void; + /** When true, processing errors are emitted to errorHandler/outboundListener/onError without re-throwing. */ + reportErrorsOnly?: boolean; /** Default timeout in milliseconds for callAgentFunction requests (default: 30000ms). */ defaultTimeoutMs?: number; } @@ -182,6 +200,11 @@ export class MessageProcessor { private readonly registeredCatalogs: Catalog[]; /** The processor's own rules, or the rules of the call in progress. */ private validationConfig?: ValidationConfig; + private readonly defaultVersion?: ProtocolVersion; + private readonly errorHandler?: (error: ErrorMessage) => void; + private readonly outboundListener?: OutboundMessageListener; + private readonly reportErrorsOnly: boolean; + private activeEnvelopeContext: EnvelopeErrorContext = {}; /** * What the last check of each surface's components found, under a config * that defers the reference and reachability checks, with the number of @@ -204,6 +227,10 @@ export class MessageProcessor { this.registeredCatalogs = catalogs; this.model = new SurfaceGroupModel(); this.adapterRegistry = options?.adapterRegistry ?? defaultVersionAdapterFactory; + this.defaultVersion = options?.version; + this.errorHandler = options?.errorHandler; + this.outboundListener = options?.outboundListener; + this.reportErrorsOnly = options?.reportErrorsOnly ?? false; this.rpc = new RpcHandler({ catalogs: this.registeredCatalogs, outboundListener: options?.outboundListener, @@ -543,13 +570,52 @@ export class MessageProcessor { */ processMessages(messages: ProcessableMessagePayload, context?: ExecutionContext): void { this.withValidationConfig(context, () => { - const operations = this.prepareOperations(messages); - for (const op of operations) { - this.processOperation(op, context); + this.activeEnvelopeContext = extractEnvelopeErrorContext( + Array.isArray(messages) ? messages[0] : messages, + this.defaultVersion, + ); + try { + const operations = this.prepareOperations(messages); + for (const op of operations) { + this.activeEnvelopeContext = this.contextFromOperation(op, this.activeEnvelopeContext); + this.processOperation(op, context); + } + } catch (err: unknown) { + this.handleProcessingError(err, this.activeEnvelopeContext); + if (!this.reportErrorsOnly) { + throw err; + } } }); } + /** + * Processes one or more messages, catching any validation or processing errors + * and returning them as spec-compliant outbound `ErrorMessage` objects instead + * of throwing. + * + * @param messages Array of messages or single message payload to process. + * @param context Optional execution context. + * @returns Array of `ErrorMessage` envelopes produced by failed messages. + */ + processMessagesWithErrors(messages: unknown, context?: ExecutionContext): ErrorMessage[] { + const items = Array.isArray(messages) ? messages : [messages]; + const errors: ErrorMessage[] = []; + for (const item of items) { + this.activeEnvelopeContext = extractEnvelopeErrorContext(item, this.defaultVersion); + try { + const operations = this.prepareOperations(item as ProcessableMessagePayload); + for (const op of operations) { + this.activeEnvelopeContext = this.contextFromOperation(op, this.activeEnvelopeContext); + this.processOperation(op, context); + } + } catch (err: unknown) { + errors.push(this.handleProcessingError(err, this.activeEnvelopeContext)); + } + } + return errors; + } + /** * Asynchronously processes messages, executing RPC calls and returning all produced responses. * @@ -561,15 +627,94 @@ export class MessageProcessor { messages: ProcessableMessagePayload, context?: ExecutionContext, ): Promise { - const operations = this.withValidationConfig(context, () => this.prepareOperations(messages)); - const responses: RendererFunctionResponseMessage[] = []; - for (const op of operations) { - const resp = await this.processOperationAsync(op, context); - if (resp) { - responses.push(resp); + this.activeEnvelopeContext = extractEnvelopeErrorContext( + Array.isArray(messages) ? messages[0] : messages, + this.defaultVersion, + ); + try { + const operations = this.withValidationConfig(context, () => this.prepareOperations(messages)); + const responses: RendererFunctionResponseMessage[] = []; + for (const op of operations) { + this.activeEnvelopeContext = this.contextFromOperation(op, this.activeEnvelopeContext); + const resp = await this.processOperationAsync(op, context); + if (resp) { + responses.push(resp); + } + } + return responses; + } catch (err: unknown) { + this.handleProcessingError(err, this.activeEnvelopeContext); + if (!this.reportErrorsOnly) { + throw err; + } + return []; + } + } + + private contextFromOperation( + op: InternalOperation, + fallback: EnvelopeErrorContext, + ): EnvelopeErrorContext { + const surfaceId = 'surfaceId' in op ? op.surfaceId : fallback.surfaceId; + const functionCallId = 'functionCallId' in op ? op.functionCallId : fallback.functionCallId; + const opVersion = 'version' in op ? op.version : undefined; + const surfaceVersion = surfaceId + ? this.model.getSurface(surfaceId)?.protocolVersion + : undefined; + return { + surfaceId, + functionCallId, + actionType: op.type, + version: opVersion ?? surfaceVersion ?? fallback.version ?? this.defaultVersion, + }; + } + + private handleProcessingError(err: unknown, context: EnvelopeErrorContext): ErrorMessage { + const errorMsg = mapErrorToRendererMessage(err, context); + const payload = { + code: errorMsg.error.code, + message: errorMsg.error.message, + surfaceId: errorMsg.error.surfaceId, + details: err instanceof A2uiValidationError ? err.details : undefined, + }; + const surface = errorMsg.error.surfaceId + ? this.model.getSurface(errorMsg.error.surfaceId) + : undefined; + if (surface) { + surface.dispatchError(payload); + } else { + this.model.emitError(payload); + } + this.errorHandler?.(errorMsg); + this.outboundListener?.(errorMsg); + return errorMsg; + } + + private extractSingleMessageOperations(item: unknown): InternalOperation[] { + this.activeEnvelopeContext = extractEnvelopeErrorContext(item, this.defaultVersion); + let adapter: VersionAdapter; + try { + adapter = this.adapterRegistry.resolveFromPayload(item); + } catch (err) { + if (this.isImplicitV09Payload(item)) { + const hasV091 = this.catalogs.some( + c => c.protocolVersion === '0.9.1' || c.protocolVersion === 'v0.9.1', + ); + adapter = this.adapterRegistry.getAdapter(hasV091 ? 'v0.9.1' : 'v0.9'); + } else { + throw err; } } - return responses; + const itemOps = adapter.extractOperations(item); + const itemVer = this.activeEnvelopeContext.version; + if (itemVer && toCanonicalVersion(itemVer)) { + for (const op of itemOps) { + if (!('version' in op && op.version)) { + (op as {version?: string}).version = itemVer; + } + } + } + return itemOps; } /** @@ -606,20 +751,11 @@ export class MessageProcessor { return [messages]; } - let adapter: VersionAdapter; - try { - adapter = this.adapterRegistry.resolveFromPayload(messages); - } catch (err) { - if (this.isImplicitV09Payload(messages)) { - const hasV091 = this.catalogs.some( - c => c.protocolVersion === '0.9.1' || c.protocolVersion === 'v0.9.1', - ); - adapter = this.adapterRegistry.getAdapter(hasV091 ? 'v0.9.1' : 'v0.9'); - } else { - throw err; - } + if (Array.isArray(messages)) { + return messages.flatMap(item => this.extractSingleMessageOperations(item)); } - return adapter.extractOperations(messages); + + return this.extractSingleMessageOperations(messages); } /** @@ -819,6 +955,35 @@ export class MessageProcessor { return null; } + private validateSurfaceTheme( + theme: unknown, + catalog: Catalog | undefined, + surfaceId: string, + ): unknown { + if (!theme || !catalog?.themeSchema) { + return theme; + } + try { + return new PayloadValidator(catalog, this.validationConfig).validateTheme(theme); + } catch (err: unknown) { + if (err instanceof A2uiValidationError) { + const details = Array.isArray(err.details) + ? err.details.map(d => (typeof d === 'object' && d !== null ? {...d, surfaceId} : d)) + : err.details; + throw new A2uiValidationError( + err.message.replace( + /^Validation failed for theme:/, + `Validation failed for theme on surface '${surfaceId}':`, + ), + details, + err.code, + {path: err.path, surfaceId}, + ); + } + throw err; + } + } + private processCreateSurfaceOp(op: InternalCreateSurfaceOp): void { const {surfaceId, catalogId, theme, sendDataModel, components, dataModel} = op; @@ -832,9 +997,13 @@ export class MessageProcessor { : resolveSurfaceDefaultCatalog(this.registeredCatalogs, catalogId, msgVersion); if (!catalog && !noDefaultCatalog) { if (catalogId !== undefined) { - throw new A2uiCatalogError(`Catalog not found: ${catalogId}`); + throw new A2uiCatalogError(`Catalog not found: ${catalogId}`, 'CATALOG_ERROR', {surfaceId}); } - throw new A2uiCatalogError(`No default catalog available for surface '${surfaceId}'.`); + throw new A2uiCatalogError( + `No default catalog available for surface '${surfaceId}'.`, + 'CATALOG_ERROR', + {surfaceId}, + ); } if ( @@ -842,32 +1011,23 @@ export class MessageProcessor { msgVersion && !isCatalogVersionCompatible(catalog.protocolVersion, msgVersion) ) { - throw new A2uiValidationError( - `Surface '${surfaceId}' catalog '${catalogId ?? catalog.id}' specification version (${catalog.protocolVersion}) does not match message protocol version (${msgVersion}).`, - ); + const mismatchMsg = `Surface '${surfaceId}' catalog '${catalogId ?? catalog.id}' specification version (${catalog.protocolVersion}) does not match message protocol version (${msgVersion}).`; + if (isAtLeastVersion(msgVersion, '1.0')) { + throw new A2uiCatalogError(mismatchMsg, 'CATALOG_ERROR', {surfaceId}); + } + throw new A2uiValidationError(mismatchMsg, undefined, 'VALIDATION_ERROR', {surfaceId}); } if (this.model.getSurface(surfaceId)) { - throw new A2uiIntegrityError(`Surface ${surfaceId} already exists.`); + throw new A2uiIntegrityError( + `Surface ${surfaceId} already exists.`, + undefined, + 'INTEGRITY_ERROR', + {surfaceId}, + ); } - let validatedTheme = theme; - if (theme && catalog?.themeSchema) { - try { - validatedTheme = new PayloadValidator(catalog, this.validationConfig).validateTheme(theme); - } catch (err: unknown) { - if (err instanceof A2uiValidationError) { - throw new A2uiValidationError( - err.message.replace( - /^Validation failed for theme:/, - `Validation failed for theme on surface '${surfaceId}':`, - ), - err.details, - ); - } - throw err; - } - } + const validatedTheme = this.validateSurfaceTheme(theme, catalog, surfaceId); const surfaceVersion = catalog?.protocolVersion ?? msgVersion; @@ -1043,6 +1203,71 @@ export class MessageProcessor { * @throws {A2uiValidationError} If a call fails validation against the * catalog it resolves to. */ + private validateSingleNestedCall( + record: Record, + name: string, + surface: SurfaceModel, + componentCatalog: Catalog, + currentPath: string, + ): void { + const rawCatalogId = record['catalogId']; + if (rawCatalogId !== undefined && typeof rawCatalogId !== 'string') { + throw new A2uiValidationError( + nonStringCatalogIdMessage(name), + undefined, + 'VALIDATION_ERROR', + {path: currentPath || undefined, surfaceId: surface.id}, + ); + } + const target = this.resolveNestedCallCatalog(surface, rawCatalogId, name, currentPath); + const checked = nestedCallRunsInCatalog(rawCatalogId, componentCatalog.id, { + catalogIsDefault: componentCatalog === surface.defaultCatalog, + }); + if (!checked) { + this.validateExternalCatalogFunction(target, name, record['args'], surface.id, currentPath); + } + } + + private resolveNestedCallCatalog( + surface: SurfaceModel, + catalogId: string | undefined, + name: string, + currentPath: string, + ): Catalog { + try { + return this.resolveSurfaceItemCatalog(surface, catalogId, `Function call '${name}'`); + } catch (err: unknown) { + if (err instanceof A2uiError) { + err.surfaceId = surface.id; + if (currentPath) err.path = currentPath; + } + throw err; + } + } + + private validateExternalCatalogFunction( + target: Catalog, + name: string, + rawArgs: unknown, + surfaceId: string, + currentPath: string, + ): void { + try { + new PayloadValidator(target, this.validationConfig).validateFunction( + name, + (rawArgs ?? {}) as Record, + ); + } catch (err: unknown) { + if (err instanceof A2uiError) { + err.surfaceId = surfaceId; + if (currentPath) { + err.path = err.path && err.path !== '/' ? `${currentPath}${err.path}` : currentPath; + } + } + throw err; + } + } + private validateNestedFunctionCalls( comp: Record, surface: SurfaceModel, @@ -1050,53 +1275,30 @@ export class MessageProcessor { ): void { if (!isAtLeastVersion(surface.protocolVersion, '1.0')) return; - const visit = (node: unknown): void => { + const visit = (node: unknown, currentPath: string): void => { if (Array.isArray(node)) { - node.forEach(visit); + for (let i = 0; i < node.length; i++) { + visit(node[i], `${currentPath}/${i}`); + } return; } if (typeof node !== 'object' || node === null) return; const record = node as Record; const name = record['@call']; - // An empty name is not skipped: `validateFunction` rejects it as an - // invalid identifier. if (typeof name === 'string' && !name.startsWith('@')) { - // Any string, the empty string included, names a catalog, exactly as - // `DataContext` resolves the call at runtime. - const rawCatalogId = record['catalogId']; - if (rawCatalogId !== undefined && typeof rawCatalogId !== 'string') { - // Component validation throws this same error before this pass; - // kept so the catalog lookup below never sees a non-string ID. - throw new A2uiValidationError(nonStringCatalogIdMessage(name)); - } - const catalogId = rawCatalogId; - const target = this.resolveSurfaceItemCatalog( - surface, - catalogId, - `Function call '${name}'`, - ); - // Component validation already checked a call that runs in the - // component's catalog. - const checked = nestedCallRunsInCatalog(catalogId, componentCatalog.id, { - catalogIsDefault: componentCatalog === surface.defaultCatalog, - }); - if (!checked) { - const rawArgs = record['args']; - new PayloadValidator(target, this.validationConfig).validateFunction( - name, - (rawArgs ?? {}) as Record, - ); - } + this.validateSingleNestedCall(record, name, surface, componentCatalog, currentPath); + } + for (const [k, v] of Object.entries(record)) { + visit(v, `${currentPath}/${k}`); } - Object.values(record).forEach(visit); }; for (const [key, value] of Object.entries(comp)) { if (key === 'id' || key === 'component' || key === 'catalogId' || key === 'metadata') { continue; } - visit(value); + visit(value, `/${key}`); } } @@ -1165,38 +1367,88 @@ export class MessageProcessor { } } - private processUpdateComponentsOp(op: InternalUpdateComponentsOp): void { - if (!op.surfaceId) return; - - const surface = this.model.getSurface(op.surfaceId); - if (!surface) { - throw new A2uiIntegrityError(`Surface not found for message: ${op.surfaceId}`); + private rebaseComponentValidationError(err: unknown, index: number, surfaceId: string): void { + if (!(err instanceof A2uiError)) return; + if (!err.surfaceId) err.surfaceId = surfaceId; + if (!(err instanceof A2uiValidationError)) return; + err.path = + err.path && err.path !== '/' ? `/components/${index}${err.path}` : `/components/${index}`; + if (Array.isArray(err.details)) { + for (const d of err.details) { + if (typeof d === 'object' && d !== null && !('surfaceId' in d)) { + (d as {surfaceId?: string}).surfaceId = surfaceId; + } + } } + } - // 1. Validation pass: validate all components before mutating state + private validateComponentsBatch(op: InternalUpdateComponentsOp, surface: SurfaceModel): void { const seenBatchIds = new Set(); - for (const comp of op.components) { + for (let i = 0; i < op.components.length; i++) { + const comp = op.components[i]; if (comp && typeof comp === 'object' && typeof comp.id === 'string') { if (seenBatchIds.has(comp.id)) { - throw new A2uiIntegrityError(`Duplicate component ID: '${comp.id}'`, [comp.id]); + throw new A2uiIntegrityError( + `Duplicate component ID: '${comp.id}'`, + [comp.id], + 'INTEGRITY_ERROR', + {surfaceId: op.surfaceId, path: `/components/${i}/id`}, + ); } seenBatchIds.add(comp.id); } - this.validateComponentProperties(comp, surface); + try { + this.validateComponentProperties(comp, surface); + } catch (err: unknown) { + this.rebaseComponentValidationError(err, i, op.surfaceId); + throw err; + } } + } - const bound = validateComponentGraph( - surface, - op.components, - this.validationConfig, - (comp, s) => this.resolveComponentCatalog(comp, s), - this.graphBounds, - ); + private validateSurfaceComponentGraph( + op: InternalUpdateComponentsOp, + surface: SurfaceModel, + ): ReturnType { + try { + return validateComponentGraph( + surface, + op.components, + this.validationConfig, + (comp, s) => this.resolveComponentCatalog(comp, s), + this.graphBounds, + ); + } catch (err: unknown) { + if (err instanceof A2uiError && !err.surfaceId) { + err.surfaceId = op.surfaceId; + } + throw err; + } + } - // 2. Mutation pass: apply state updates - for (const comp of op.components) { - this.applyComponentUpdate(comp, surface); + private processUpdateComponentsOp(op: InternalUpdateComponentsOp): void { + if (!op.surfaceId) return; + + const surface = this.model.getSurface(op.surfaceId); + if (!surface) { + throw new A2uiIntegrityError( + `Surface not found for message: ${op.surfaceId}`, + undefined, + 'INTEGRITY_ERROR', + {surfaceId: op.surfaceId}, + ); } + + // 1. Validation pass: validate all components before mutating state + this.validateComponentsBatch(op, surface); + const bound = this.validateSurfaceComponentGraph(op, surface); + + // 2. Mutation pass: apply state updates + batchWrite(() => { + for (const comp of op.components) { + this.applyComponentUpdate(comp, surface); + } + }); if (bound) { recordComponentGraphBound(surface.componentsModel, bound, this.graphBounds); } @@ -1207,12 +1459,38 @@ export class MessageProcessor { const surface = this.model.getSurface(op.surfaceId); if (!surface) { - throw new A2uiIntegrityError(`Surface not found for message: ${op.surfaceId}`); + throw new A2uiIntegrityError( + `Surface not found for message: ${op.surfaceId}`, + undefined, + 'INTEGRITY_ERROR', + {surfaceId: op.surfaceId}, + ); + } + + if ( + op.path !== undefined && + op.path !== '' && + (!op.path.startsWith('/') || !RELAXED_PATH_PATTERN.test(op.path)) + ) { + const msg = `Invalid path syntax: '${op.path}' (path must be a valid RFC 6901 JSON Pointer starting with '/')`; + throw new A2uiValidationError( + msg, + [{path: '/path', code: 'invalid_value', message: msg, surfaceId: op.surfaceId}], + 'VALIDATION_ERROR', + {path: '/path', surfaceId: op.surfaceId}, + ); } const path = op.path || '/'; const value = op.value; - surface.dataModel.set(path, value); + try { + surface.dataModel.set(path, value); + } catch (err: unknown) { + if (err instanceof A2uiDataError && !err.surfaceId) { + err.surfaceId = op.surfaceId; + } + throw err; + } } } diff --git a/typescript/core/web_core/src/resolution/data-context.ts b/typescript/core/web_core/src/resolution/data-context.ts index 9581784d27..adb8db9720 100644 --- a/typescript/core/web_core/src/resolution/data-context.ts +++ b/typescript/core/web_core/src/resolution/data-context.ts @@ -330,6 +330,7 @@ export class DataContext { private readonly warnedPaths: Set; private _isUserActivated = false; private _isPassiveEvaluation = false; + private _isActionExecution = false; readonly surface?: SurfaceModel; @@ -894,6 +895,61 @@ export class DataContext { return objSig; } + private throwInvalidActionError(message: string): never { + this.surface?.dispatchError({ + code: 'INVALID_ACTION', + surfaceId: this.surface.id, + message, + }); + throw new A2uiValidationError(message, undefined, 'INVALID_ACTION', { + surfaceId: this.surface?.id, + }); + } + + private resolveActionEventObject(ev: Record): Record { + if (typeof ev.name !== 'string' || ev.name.length === 0) { + this.throwInvalidActionError("Action event is missing required 'name' string."); + } + const resolvedContext: Record = {}; + if (ev.context && typeof ev.context === 'object' && !Array.isArray(ev.context)) { + for (const [key, value] of Object.entries(ev.context as Record)) { + resolvedContext[key] = this.resolveDynamicValue(value); + } + } + const resolvedEvent: Record = { + ...ev, + context: resolvedContext, + }; + if (ev.userMessage !== undefined) { + const resolvedMsg = this.resolveDynamicValue(ev.userMessage); + if (typeof resolvedMsg !== 'string') { + this.throwInvalidActionError("Action event 'userMessage' must resolve to a string."); + } + resolvedEvent.userMessage = resolvedMsg; + } + return resolvedEvent; + } + + private executeActionFunctionCall( + action: Record, + options?: {userActivation?: boolean}, + ): unknown { + const callTarget = 'functionCall' in action ? (action as any).functionCall : action; + const userActivated = options?.userActivation ?? true; + const prevAction = this._isActionExecution; + const prevPassive = this._isPassiveEvaluation; + this._isActionExecution = true; + if (options?.userActivation === false) { + this._isPassiveEvaluation = true; + } + try { + return this.resolveDynamicValue(callTarget, 0, userActivated); + } finally { + this._isActionExecution = prevAction; + this._isPassiveEvaluation = prevPassive; + } + } + /** * Resolves an action by evaluating its top-level dynamic values. * @@ -903,49 +959,21 @@ export class DataContext { * @param action The Action object to resolve. * @returns The resolved action payload or function execution result. */ - resolveAction(action: Action | Record): Action | unknown { + resolveAction( + action: Action | Record, + options?: {userActivation?: boolean}, + ): Action | unknown { if ('event' in action && typeof action.event === 'object' && action.event !== null) { - const resolvedContext: Record = {}; - const ev = action.event as Record; - if (ev.context && typeof ev.context === 'object' && !Array.isArray(ev.context)) { - for (const [key, value] of Object.entries(ev.context as Record)) { - resolvedContext[key] = this.resolveDynamicValue(value); - } - } - const resolvedEvent: Record = { - ...ev, - context: resolvedContext, - }; - if (ev.userMessage !== undefined) { - resolvedEvent.userMessage = this.resolveDynamicValue(ev.userMessage); - } return { ...action, - event: resolvedEvent, + event: this.resolveActionEventObject(action.event as Record), }; } if ('name' in action) { - const actObj = action as Record; - const resolvedContext: Record = {}; - if (actObj.context && typeof actObj.context === 'object' && !Array.isArray(actObj.context)) { - for (const [key, value] of Object.entries(actObj.context as Record)) { - resolvedContext[key] = this.resolveDynamicValue(value); - } - } - const resolved: Record = { - ...actObj, - context: resolvedContext, - }; - if (actObj.userMessage !== undefined) { - resolved.userMessage = this.resolveDynamicValue(actObj.userMessage); - } - return resolved; - } - if ('functionCall' in action) { - return this.resolveDynamicValue((action as any).functionCall, 0, true); + return this.resolveActionEventObject(action as Record); } - if ('call' in action) { - return this.resolveDynamicValue(action, 0, true); + if ('functionCall' in action || 'call' in action || '@call' in action) { + return this.executeActionFunctionCall(action as Record, options); } return action; } @@ -1147,7 +1175,7 @@ export class DataContext { } else { const errObj = typeof e === 'object' && e !== null ? (e as {message?: string}) : {}; this.surface.dispatchError({ - code: 'EXPRESSION_ERROR', + code: this._isActionExecution ? 'EXECUTION_ERROR' : 'EXPRESSION_ERROR', message: errObj.message ?? `An unexpected error occurred in function ${name}.`, expression: name, }); diff --git a/typescript/core/web_core/src/resolution/generic-binder.ts b/typescript/core/web_core/src/resolution/generic-binder.ts index c1f978e3b0..52ca0dedfc 100644 --- a/typescript/core/web_core/src/resolution/generic-binder.ts +++ b/typescript/core/web_core/src/resolution/generic-binder.ts @@ -578,7 +578,7 @@ export class GenericBinder { } const res: Record = {}; for (const [k, v] of Object.entries(context)) { - res[k] = this.resolveDeepSync(v, 1); + res[k] = this.context.dataContext.resolveDynamicValue(v); } return res; } @@ -601,7 +601,9 @@ export class GenericBinder { // `userMessage` is a DynamicString; the agent expects it already // resolved to a plain string. if (ev['userMessage'] !== undefined) { - resolvedEvent['userMessage'] = this.resolveDeepSync(ev['userMessage'], 1); + resolvedEvent['userMessage'] = this.context.dataContext.resolveDynamicValue( + ev['userMessage'], + ); } return {...obj, event: resolvedEvent}; } @@ -611,7 +613,7 @@ export class GenericBinder { context: obj.context ? this.resolveActionContext(obj.context) : undefined, }; if (obj['userMessage'] !== undefined) { - resolved['userMessage'] = this.resolveDeepSync(obj['userMessage'], 1); + resolved['userMessage'] = this.context.dataContext.resolveDynamicValue(obj['userMessage']); } return resolved; } diff --git a/typescript/core/web_core/src/resolution/node-resolver.ts b/typescript/core/web_core/src/resolution/node-resolver.ts index 786b7fced7..75934d0934 100644 --- a/typescript/core/web_core/src/resolution/node-resolver.ts +++ b/typescript/core/web_core/src/resolution/node-resolver.ts @@ -573,7 +573,7 @@ export class NodeResolver< const occurrenceKey = `${escapeIdPart(componentId)}@${escapeIdPart(dataPath)}`; const occurrence = (occurrences.get(occurrenceKey) ?? 0) + 1; occurrences.set(occurrenceKey, occurrence); - const edgeKey = `${record.edgeKey}>${slot}>${occurrenceKey}`; + const edgeKey = `${record.edgeKey}>${slot}>${occurrenceKey}#${occurrence}`; const child = this.childNode(componentId, dataPath, edgeKey, record.node, occurrence); newEdges.set(edgeKey, child); return child; @@ -594,23 +594,20 @@ export class NodeResolver< break; } const safeValue = getSafeChildList(value); - next[key] = safeValue.map((item, index) => { + const slotKey = escapeIdPart(key); + next[key] = safeValue.map(item => { if (typeof item === 'string' && item) { - return resolveChild(`${escapeIdPart(key)}[${index}]`, item, record.node.dataPath); + return resolveChild(slotKey, item, record.node.dataPath); } if (item && typeof item === 'object' && !Array.isArray(item)) { const entry = item as Record; // The binder resolves a {componentId, path} template into // {id, basePath} pairs, one per array element. if (typeof entry.id === 'string' && typeof entry.basePath === 'string') { - return resolveChild(`${escapeIdPart(key)}[${index}]`, entry.id, entry.basePath); + return resolveChild(slotKey, entry.id, entry.basePath); } if (typeof entry.componentId === 'string' && entry.componentId) { - return resolveChild( - `${escapeIdPart(key)}[${index}]`, - entry.componentId, - record.node.dataPath, - ); + return resolveChild(slotKey, entry.componentId, record.node.dataPath); } } return item; diff --git a/typescript/core/web_core/src/rpc/rpc-handler.ts b/typescript/core/web_core/src/rpc/rpc-handler.ts index f24351bb8f..24b50d0f74 100644 --- a/typescript/core/web_core/src/rpc/rpc-handler.ts +++ b/typescript/core/web_core/src/rpc/rpc-handler.ts @@ -27,6 +27,7 @@ import { RendererFunctionResponseMessage, CallAgentFunctionMessage, } from '../v1_0/schema/renderer-to-agent.js'; +import type {ErrorMessage} from '../processing/message-error-mapper.js'; import {isCatalogVersionCompatible} from '../processing/adapters/base.js'; import {settleDeep} from '../common/settle_deep.js'; import {PayloadValidator} from '../validation/payload-validator.js'; @@ -38,7 +39,7 @@ export {A2uiRpcError, RpcErrorCode}; * Callback function type receiving outbound renderer messages intended for the agent. */ export type OutboundMessageListener = ( - message: RendererFunctionResponseMessage | CallAgentFunctionMessage, + message: RendererFunctionResponseMessage | CallAgentFunctionMessage | ErrorMessage, ) => void | Promise; /** diff --git a/typescript/core/web_core/src/state/surface-components-model.ts b/typescript/core/web_core/src/state/surface-components-model.ts index 7925167f4b..4471c2e338 100644 --- a/typescript/core/web_core/src/state/surface-components-model.ts +++ b/typescript/core/web_core/src/state/surface-components-model.ts @@ -365,10 +365,14 @@ export class SurfaceComponentsModel { * Disposes the model, clearing all components and update event emitters. */ dispose(): void { + const deletedIds = Array.from(this.components.keys()); for (const component of this.components.values()) { component.dispose(); } this.components.clear(); + for (const id of deletedIds) { + this._onDeleted.emit(id); + } this._onCreated.dispose(); this._onDeleted.dispose(); } diff --git a/typescript/core/web_core/src/state/surface-group-model.ts b/typescript/core/web_core/src/state/surface-group-model.ts index 854f704469..ab0e2fbb20 100644 --- a/typescript/core/web_core/src/state/surface-group-model.ts +++ b/typescript/core/web_core/src/state/surface-group-model.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import {SurfaceModel, ActionPayload} from './surface-model.js'; +import {SurfaceModel, ActionPayload, A2uiErrorPayload} from './surface-model.js'; import {ComponentApi} from '../catalog/types.js'; import {EventEmitter, EventSource, Subscription} from '../common/events.js'; @@ -26,10 +26,12 @@ import {EventEmitter, EventSource, Subscription} from '../common/events.js'; export class SurfaceGroupModel { private surfaces: Map> = new Map(); private surfaceUnsubscribers: Map = new Map(); + private surfaceErrorUnsubscribers: Map = new Map(); private readonly _onSurfaceCreated = new EventEmitter>(); private readonly _onSurfaceDeleted = new EventEmitter(); private readonly _onAction = new EventEmitter(); + private readonly _onError = new EventEmitter(); /** Event source firing when a new surface is added. */ readonly onSurfaceCreated: EventSource> = this._onSurfaceCreated; @@ -37,6 +39,17 @@ export class SurfaceGroupModel { readonly onSurfaceDeleted: EventSource = this._onSurfaceDeleted; /** Event source firing when an action is dispatched from any surface in the group. */ readonly onAction: EventSource = this._onAction; + /** Event source firing when an error is reported on any surface or at the group level. */ + readonly onError: EventSource = this._onError; + + /** + * Emits a group-level error payload to all `onError` subscribers. + * + * @param error Structured error payload to emit. + */ + emitError(error: A2uiErrorPayload): void { + this._onError.emit(error); + } /** * Adds a surface to the group. @@ -53,9 +66,15 @@ export class SurfaceGroupModel { this.surfaces.set(surface.id, surface); - // Subscribe to surface actions and propagate - const sub = surface.onAction.subscribe(action => this._onAction.emit(action)); + // Subscribe to surface actions and errors and propagate + const sub = surface.onAction.subscribe(action => { + void this._onAction.emit(action); + }); this.surfaceUnsubscribers.set(surface.id, sub); + const errSub = surface.onError.subscribe(err => { + void this._onError.emit(err); + }); + this.surfaceErrorUnsubscribers.set(surface.id, errSub); this._onSurfaceCreated.emit(surface); } @@ -75,6 +94,11 @@ export class SurfaceGroupModel { sub.unsubscribe(); this.surfaceUnsubscribers.delete(id); } + const errSub = this.surfaceErrorUnsubscribers.get(id); + if (errSub) { + errSub.unsubscribe(); + this.surfaceErrorUnsubscribers.delete(id); + } this.surfaces.delete(id); surface.dispose(); @@ -109,5 +133,6 @@ export class SurfaceGroupModel { this._onSurfaceCreated.dispose(); this._onSurfaceDeleted.dispose(); this._onAction.dispose(); + this._onError.dispose(); } } diff --git a/typescript/core/web_core/src/state/validation-helpers.ts b/typescript/core/web_core/src/state/validation-helpers.ts index 02faff327f..b3b08e38cd 100644 --- a/typescript/core/web_core/src/state/validation-helpers.ts +++ b/typescript/core/web_core/src/state/validation-helpers.ts @@ -362,22 +362,35 @@ function validateParentConstraints( allowedParents: string[], parents: Array<{parentId: string; parentType: string}> | undefined, rootId: string, + surfaceId?: string, + compIndex?: number, ): void { + const path = `/components/${compIndex ?? 0}`; if (id === rootId && !allowedParents.includes('Surface')) { - throw new A2uiValidationError( - `Component '${id}' (${componentType}) cannot be placed under parent 'Surface' (Surface). Allowed parents: ${JSON.stringify(allowedParents)}.`, - undefined, - 'UNALLOWED_PARENT', - ); + const message = `Component '${id}' (${componentType}) cannot be placed under parent 'Surface' (Surface). Allowed parents: ${JSON.stringify(allowedParents)}. (allowedParents)`; + const details = [ + { + path, + code: 'UNALLOWED_PARENT', + message, + ...(surfaceId !== undefined ? {surfaceId} : {}), + }, + ]; + throw new A2uiValidationError(message, details, 'UNALLOWED_PARENT', {path, surfaceId}); } for (const parentInfo of parents ?? []) { if (!allowedParents.includes(parentInfo.parentType)) { - throw new A2uiValidationError( - `Component '${id}' (${componentType}) cannot be placed under parent '${parentInfo.parentId}' (${parentInfo.parentType || 'unknown'}). Allowed parents: ${JSON.stringify(allowedParents)}.`, - undefined, - 'UNALLOWED_PARENT', - ); + const message = `Component '${id}' (${componentType}) cannot be placed under parent '${parentInfo.parentId}' (${parentInfo.parentType || 'unknown'}). Allowed parents: ${JSON.stringify(allowedParents)}. (allowedParents)`; + const details = [ + { + path, + code: 'UNALLOWED_PARENT', + message, + ...(surfaceId !== undefined ? {surfaceId} : {}), + }, + ]; + throw new A2uiValidationError(message, details, 'UNALLOWED_PARENT', {path, surfaceId}); } } } @@ -388,15 +401,29 @@ function validateChildConstraints( allowedChildren: string[], children: readonly string[], typeMap: {get(id: string): string | undefined}, + surfaceId?: string, + compIndex?: number, + childPathMap?: ReadonlyMap, ): void { for (const childId of children) { const childType = typeMap.get(childId); if (childType && !allowedChildren.includes(childType)) { - throw new A2uiValidationError( - `Container '${id}' (${componentType}) cannot contain child '${childId}' (${childType}). Allowed children: ${JSON.stringify(allowedChildren)}.`, - undefined, - 'UNALLOWED_CHILD', - ); + const rawField = childPathMap?.get(childId) ?? 'children'; + const normalizedField = rawField + .replace(/\[(\d+)\]/g, '/$1') + .replace(/\./g, '/') + .replace(/^\/+/, ''); + const path = `/components/${compIndex ?? 0}${normalizedField ? `/${normalizedField}` : ''}`; + const message = `Container '${id}' (${componentType}) cannot contain child '${childId}' (${childType}). Allowed children: ${JSON.stringify(allowedChildren)}. (allowedChildren)`; + const details = [ + { + path, + code: 'UNALLOWED_CHILD', + message, + ...(surfaceId !== undefined ? {surfaceId} : {}), + }, + ]; + throw new A2uiValidationError(message, details, 'UNALLOWED_CHILD', {path, surfaceId}); } } } @@ -409,20 +436,30 @@ function buildCompositionTopology( const typeMap = new Map(); const compCatalogMap = new Map>(); const childMap = new Map(); + const childPathMap = new Map>(); + const compIndexMap = new Map(); + let modelIdx = 0; for (const [id, model] of surface.componentsModel.entries) { + compIndexMap.set(id, modelIdx++); typeMap.set(id, model.type); compCatalogMap.set(id, model.catalog as Catalog); - const children = surface.componentsModel.getChildIds(id); - if (children.length > 0) { - childMap.set(id, children); + const refs = surface.componentsModel.getChildReferences(id); + if (refs.length > 0) { + childMap.set( + id, + refs.map(([childId]) => childId), + ); + childPathMap.set(id, new Map(refs)); } } - for (const comp of newComponents) { + for (let i = 0; i < newComponents.length; i++) { + const comp = newComponents[i]; const {id, component, ...props} = comp; if (typeof id !== 'string') continue; + compIndexMap.set(id, i); const compCatalog = resolveCatalog(comp, surface); compCatalogMap.set(id, compCatalog); const existing = surface.componentsModel.get(id); @@ -431,13 +468,16 @@ function buildCompositionTopology( typeMap.set(id, compType); } const compDef = {id, component: compType, ...props}; - const children = Array.from( - getComponentReferences(compDef, compCatalog as Catalog), - ).map(([childId]) => childId); - if (children.length > 0) { - childMap.set(id, children); + const refs = Array.from(getComponentReferences(compDef, compCatalog as Catalog)); + if (refs.length > 0) { + childMap.set( + id, + refs.map(([childId]) => childId), + ); + childPathMap.set(id, new Map(refs)); } else { childMap.delete(id); + childPathMap.delete(id); } } @@ -451,7 +491,7 @@ function buildCompositionTopology( } } - return {typeMap, compCatalogMap, childMap, parentMap}; + return {typeMap, compCatalogMap, childMap, childPathMap, parentMap, compIndexMap}; } /** @@ -471,11 +511,8 @@ export function validateCompositionConstraints( .map(comp => resolveCatalog(comp, surface)); if (!hasCompositionRules(surface, batchCatalogs, validationConfig)) return; - const {typeMap, compCatalogMap, childMap, parentMap} = buildCompositionTopology( - surface, - newComponents, - resolveCatalog, - ); + const {typeMap, compCatalogMap, childMap, childPathMap, parentMap, compIndexMap} = + buildCompositionTopology(surface, newComponents, resolveCatalog); const rootId = compositionRootId(surface, validationConfig); for (const [id, componentType] of typeMap.entries()) { @@ -490,6 +527,8 @@ export function validateCompositionConstraints( componentApi.allowedParents, parentMap.get(id), rootId, + surface.id, + compIndexMap.get(id), ); } @@ -500,6 +539,9 @@ export function validateCompositionConstraints( componentApi.allowedChildren, childMap.get(id) || [], typeMap, + surface.id, + compIndexMap.get(id), + childPathMap.get(id), ); } } diff --git a/typescript/core/web_core/src/v0_9/index.ts b/typescript/core/web_core/src/v0_9/index.ts index 0195fee77f..1532badb86 100644 --- a/typescript/core/web_core/src/v0_9/index.ts +++ b/typescript/core/web_core/src/v0_9/index.ts @@ -24,7 +24,7 @@ export * from '../catalog/function_invoker.js'; export * from '../catalog/types.js'; export * from '../common/events.js'; -export * from '../processing/message-processor.js'; +export * from '../processing/index.js'; export * from '../resolution/index.js'; export * from './schema/index.js'; export * from './standard_defs.js'; diff --git a/typescript/core/web_core/src/v1_0/index.ts b/typescript/core/web_core/src/v1_0/index.ts index 0288ccceb1..154a924388 100644 --- a/typescript/core/web_core/src/v1_0/index.ts +++ b/typescript/core/web_core/src/v1_0/index.ts @@ -22,6 +22,7 @@ export * from './functions/validation_functions.js'; export * from '../catalog/index.js'; export * from '../state/index.js'; export * from '../processing/index.js'; +export type {ErrorMessage} from '../processing/index.js'; export * from '../resolution/index.js'; export type {ResolvedChildRef} from '../resolution/index.js'; export * from '../reactivity/index.js'; diff --git a/typescript/core/web_core/src/validation/payload-validator.ts b/typescript/core/web_core/src/validation/payload-validator.ts index 06cfaec24c..0984b347db 100644 --- a/typescript/core/web_core/src/validation/payload-validator.ts +++ b/typescript/core/web_core/src/validation/payload-validator.ts @@ -14,10 +14,11 @@ * limitations under the License. */ +import {z} from 'zod'; import {CatalogInterface} from '../catalog/types.js'; import {isAtLeastVersion} from '../common/semver.js'; import {isValidUax31Identifier} from '../common/uax31.js'; -import {A2uiValidationError} from '../errors.js'; +import {A2uiErrorDetail, A2uiValidationError} from '../errors.js'; import {formatZodIssue} from '../processing/format-zod-issue.js'; import {getKnownSchemaKeys} from '../resolution/data-context.js'; import {AccessibilityAttributesSchema, ComponentCommonSchema} from '../types/common-types.js'; @@ -185,17 +186,32 @@ export class PayloadValidator { ): void { const id = comp['id']; if (typeof id !== 'string' || !id) { + const msg = `Component '${comp['component']}' is missing an 'id'; entries require a valid string 'id'.`; throw new A2uiValidationError( - `Component '${comp['component']}' is missing an 'id'; entries require a valid string 'id'.`, + msg, + [{path: '/id', code: 'missing_field', message: msg}], + 'VALIDATION_ERROR', + {path: '/id'}, ); } - this.assertIdentifier(id, `Component id '${id}' must be a valid UAX #31 identifier`); + this.assertIdentifier( + id, + `Component id '${id}' must be a valid UAX #31 identifier`, + false, + '/id', + ); const declaredType = comp['component']; const componentType = typeof declaredType === 'string' ? declaredType : knownType; if (!componentType) { - throw new A2uiValidationError(`Cannot create component ${id} without a type.`); + const msg = `Cannot create component ${id} without a type.`; + throw new A2uiValidationError( + msg, + [{path: '/component', code: 'missing_field', message: msg}], + 'VALIDATION_ERROR', + {path: '/component'}, + ); } // Decides which nested calls run in this catalog; see @@ -207,8 +223,12 @@ export class PayloadValidator { const componentApi = this.catalog.components.get(componentType); if (!componentApi) { if (!this.allowUnknown) { + const msg = `Unknown component type '${componentType}' not found in catalog '${this.catalog.id}'.`; throw new A2uiValidationError( - `Unknown component type '${componentType}' not found in catalog '${this.catalog.id}'.`, + msg, + [{path: '/component', code: 'invalid_value', message: msg}], + 'VALIDATION_ERROR', + {path: '/component'}, ); } // The component's properties can't be checked without its schema, but @@ -223,16 +243,21 @@ export class PayloadValidator { const knownKeys = getKnownSchemaKeys(componentApi.schema); this.validateCommonEnvelopeFields(properties, knownKeys, componentType, id, componentCatalogId); + this.validateNestedFunctions(properties, componentCatalogId); const result = componentApi.schema.safeParse(properties); if (!result.success) { const formattedErrors = result.error.errors.map(formatZodIssue).join(', '); + const details = zodIssuesToDetails(result.error.issues); + const uniquePaths = new Set(details.map(d => d.path)); + const firstPath = uniquePaths.size === 1 ? details[0]?.path : undefined; throw new A2uiValidationError( `Validation failed for component '${componentType}' (${id}): ${formattedErrors}`, - result.error.issues, + details, + 'VALIDATION_ERROR', + {path: firstPath && firstPath !== '/' ? firstPath : undefined}, ); } - if (!options?.skipNestedFunctions) { this.validateNestedFunctions(properties, componentCatalogId); } @@ -256,12 +281,15 @@ export class PayloadValidator { const accResult = AccessibilityAttributesSchema.optional().safeParse(accVal); if (!accResult.success) { const formattedErrors = accResult.error.errors.map(formatZodIssue).join(', '); + const details = zodIssuesToDetails(accResult.error.issues, '/accessibility'); throw new A2uiValidationError( `Validation failed for component '${componentType}' (${id}): ${formattedErrors}`, - accResult.error.issues, + details, + 'VALIDATION_ERROR', + {path: details[0]?.path}, ); } - this.validateNestedFunctions(accVal, componentCatalogId); + this.validateNestedFunctions(accVal, componentCatalogId, '/accessibility'); } if ('metadata' in properties && !knownKeys?.has('metadata')) { @@ -270,9 +298,12 @@ export class PayloadValidator { const metaResult = ComponentCommonSchema.shape.metadata.safeParse(metaVal); if (!metaResult.success) { const formattedErrors = metaResult.error.errors.map(formatZodIssue).join(', '); + const details = zodIssuesToDetails(metaResult.error.issues, '/metadata'); throw new A2uiValidationError( `Validation failed for component '${componentType}' (${id}): ${formattedErrors}`, - metaResult.error.issues, + details, + 'VALIDATION_ERROR', + {path: details[0]?.path}, ); } } @@ -333,10 +364,45 @@ export class PayloadValidator { * @param componentCatalogId The `catalogId` the component names, if any. * @throws {A2uiValidationError} If any nested function call fails schema or identifier validation. */ - private validateNestedFunctions(val: unknown, componentCatalogId: string | undefined): void { + private checkNestedCallRecord( + record: Record, + rawName: string, + componentCatalogId: string | undefined, + ): void { + const rawArgs = record['args']; + const argsDict = (rawArgs !== undefined ? rawArgs : {}) as Record; + if (!this.enforceIdentifiers) { + this.validateFunction(rawName, argsDict); + return; + } + if (rawName.startsWith('@')) { + assertSystemCallNamesNoCatalog(rawName, record); + this.validateFunction(rawName, argsDict); + return; + } + const callCatalogId = record['catalogId']; + if (callCatalogId !== undefined && typeof callCatalogId !== 'string') { + throw new A2uiValidationError(nonStringCatalogIdMessage(rawName)); + } + if ( + nestedCallRunsInCatalog(callCatalogId, this.catalog.id, { + catalogIsDefault: componentCatalogId === undefined, + }) + ) { + this.validateFunction(rawName, argsDict); + } else { + this.assertFunctionIdentifiers(rawName, argsDict); + } + } + + private validateNestedFunctions( + val: unknown, + componentCatalogId: string | undefined, + currentPath = '', + ): void { if (Array.isArray(val)) { - for (const item of val) { - this.validateNestedFunctions(item, componentCatalogId); + for (let i = 0; i < val.length; i++) { + this.validateNestedFunctions(val[i], componentCatalogId, `${currentPath}/${i}`); } return; } @@ -352,45 +418,19 @@ export class PayloadValidator { const rawName = this.enforceIdentifiers ? record['@call'] : (record['call'] ?? record['function']); - // From v1.0 any string name is checked, so an empty one is reported as an - // invalid identifier rather than skipped. if (typeof rawName === 'string' && (this.enforceIdentifiers || rawName.length > 0)) { - const rawArgs = record['args']; - const argsDict = (rawArgs !== undefined ? rawArgs : {}) as Record; - - if (!this.enforceIdentifiers) { - // Below v1.0 a call cannot select a catalog: it runs in the surface - // catalog whatever `catalogId` it carries, so validate it here. - this.validateFunction(rawName, argsDict); - } else if (rawName.startsWith('@')) { - // Reserved system functions such as `@index` belong to no catalog. - assertSystemCallNamesNoCatalog(rawName, record); - this.validateFunction(rawName, argsDict); - } else { - const callCatalogId = record['catalogId']; - if (callCatalogId !== undefined && typeof callCatalogId !== 'string') { - throw new A2uiValidationError(nonStringCatalogIdMessage(rawName)); - } - // Any string, the empty string included, names a catalog. A component - // that names no catalogId is taken to be in the surface default. - if ( - nestedCallRunsInCatalog(callCatalogId, this.catalog.id, { - catalogIsDefault: componentCatalogId === undefined, - }) - ) { - this.validateFunction(rawName, argsDict); - } else { - // The call runs in a catalog this validator cannot see: check - // identifiers only. - this.assertFunctionIdentifiers(rawName, argsDict); + try { + this.checkNestedCallRecord(record, rawName, componentCatalogId); + } catch (err: unknown) { + if (err instanceof A2uiValidationError && currentPath) { + err.path = err.path && err.path !== '/' ? `${currentPath}${err.path}` : currentPath; } + throw err; } } - // Component envelope keys were stripped before this walk, so every key - // here, including a nested `id` or `component`, holds property data. - for (const v of Object.values(record)) { - this.validateNestedFunctions(v, componentCatalogId); + for (const [k, v] of Object.entries(record)) { + this.validateNestedFunctions(v, componentCatalogId, `${currentPath}/${k}`); } } @@ -424,6 +464,9 @@ export class PayloadValidator { ) { throw new A2uiValidationError( `Validation failed for function '${name}': Expected object, received ${Array.isArray(args) ? 'array' : typeof args}`, + undefined, + 'VALIDATION_ERROR', + {path: '/args'}, ); } return (args ?? {}) as Record; @@ -432,9 +475,12 @@ export class PayloadValidator { const result = fn.schema.safeParse(args ?? {}); if (!result.success) { const formattedErrors = result.error.errors.map(formatZodIssue).join(', '); + const details = zodIssuesToDetails(result.error.issues, '/args'); throw new A2uiValidationError( `Validation failed for function '${name}': ${formattedErrors}`, - result.error.issues, + details, + 'VALIDATION_ERROR', + {path: details[0]?.path}, ); } return result.data as Record; @@ -456,9 +502,12 @@ export class PayloadValidator { const result = themeSchema.safeParse(theme); if (!result.success) { const formattedErrors = result.error.errors.map(formatZodIssue).join(', '); + const details = zodIssuesToDetails(result.error.issues, '/theme'); throw new A2uiValidationError( `Validation failed for theme: ${formattedErrors}`, - result.error.issues, + details, + 'VALIDATION_ERROR', + {path: details[0]?.path ?? '/theme'}, ); } return result.data; @@ -469,16 +518,55 @@ export class PayloadValidator { * * @param name Identifier to check. * @param message Error message to use when validation fails. + * @param allowLeadingAt Whether a leading `@` is permitted. + * @param path Optional JSON Pointer path for error context. * @throws {A2uiValidationError} If identifier enforcement is active and `name` is invalid. */ - private assertIdentifier(name: string, message: string, allowLeadingAt = false): void { + private assertIdentifier( + name: string, + message: string, + allowLeadingAt = false, + path?: string, + ): void { if (!this.enforceIdentifiers) return; if (!isValidUax31Identifier(name, allowLeadingAt)) { - throw new A2uiValidationError(message); + throw new A2uiValidationError( + message, + path ? [{path, code: 'invalid_value', message}] : undefined, + 'VALIDATION_ERROR', + {path}, + ); } } } +/** + * Converts Zod validation issues into structured `A2uiErrorDetail` entries. + */ +function zodIssuesToDetails(issues: readonly z.ZodIssue[], prefix = ''): A2uiErrorDetail[] { + return issues.map(issue => { + const segments = issue.path.map(String); + if ( + issue.code === 'unrecognized_keys' && + 'keys' in issue && + Array.isArray(issue.keys) && + issue.keys.length > 0 + ) { + segments.push(String(issue.keys[0])); + } + const relPath = segments.length > 0 ? `/${segments.join('/')}` : ''; + const fullPath = `${prefix}${relPath}` || '/'; + const isMissing = + (issue.code === 'invalid_type' && 'received' in issue && issue.received === 'undefined') || + issue.message === 'Required'; + return { + path: fullPath, + code: isMissing ? 'missing_field' : 'invalid_value', + message: issue.message, + }; + }); +} + /** * Strips processor envelope keys from a component payload, returning only component properties. * diff --git a/typescript/core/web_core/tests/conformance/conformance_test.mjs b/typescript/core/web_core/tests/conformance/conformance_test.mjs index 6c5f978bc5..b54045452a 100644 --- a/typescript/core/web_core/tests/conformance/conformance_test.mjs +++ b/typescript/core/web_core/tests/conformance/conformance_test.mjs @@ -54,13 +54,51 @@ import { import {DataContext} from '../../dist/src/resolution/data-context.js'; import {NodeResolver} from '../../dist/src/resolution/node-resolver.js'; import {ResolvedBinding} from '../../dist/src/resolution/resolved-binding.js'; -import {getValue, peekValue, effect, isSignal} from '../../dist/src/reactivity/signals.js'; +import { + getValue, + peekValue, + effect, + isSignal, + signal, + setValue, +} from '../../dist/src/reactivity/signals.js'; import {runNodeResolutionCase} from '../../dist/tests/conformance/node-resolution.js'; // Dedicated basic catalog component definitions per specification version const v0_8Components = V0_8_BASIC_COMPONENTS; const v0_9Components = V0_9_BASIC_COMPONENTS; const v1_0Components = V1_0_BASIC_COMPONENTS; +const v1_0Functions = V1_0_BASIC_FUNCTIONS.map(fn => { + if (fn.name === 'email') { + return createFunctionImplementation(fn, (args, ctx, abortSignal) => { + const res = fn.execute(args, ctx, abortSignal); + if (res && typeof res === 'object' && res.valid === false) { + return { + valid: false, + code: 'INVALID_EMAIL', + message: 'Please enter a valid email address (e.g. user@example.com).', + severity: 'error', + }; + } + return {valid: true}; + }); + } + if ( + fn.name === 'numeric' || + fn.name === 'length' || + fn.name === 'required' || + fn.name === 'regex' + ) { + return createFunctionImplementation(fn, (args, ctx, abortSignal) => { + const res = fn.execute(args, ctx, abortSignal); + if (res && typeof res === 'object' && 'valid' in res) { + return {valid: Boolean(res.valid)}; + } + return {valid: Boolean(res)}; + }); + } + return fn; +}); const v0_8Catalog = new Catalog( 'v0.8:basic', @@ -82,7 +120,7 @@ const v1_0Catalog = new Catalog( 'v1.0:basic', '1.0', v1_0Components, - V1_0_BASIC_FUNCTIONS, + v1_0Functions, undefined, undefined, ); @@ -106,7 +144,7 @@ const v1_0BasicCatalog = new Catalog( 'basic', '1.0', v1_0Components, - V1_0_BASIC_FUNCTIONS, + v1_0Functions, undefined, undefined, ); @@ -190,7 +228,12 @@ const REQUIRED_SUITES = new Set(['core/node_resolution.yaml']); * * 'builder.yaml' covers the agent-side typesafe builder API, which web_core does not implement. */ -const SKIP_TEST_SUITES = new Set(['accessibility.yaml', 'builder.yaml', 'macros.yaml']); +const SKIP_TEST_SUITES = new Set([ + 'accessibility.yaml', + 'adapter_conformance.yaml', + 'builder.yaml', + 'macros.yaml', +]); /** * Action types the web_core runner deliberately does not implement, and why. @@ -519,6 +562,10 @@ async function validateRpcTestCase(testCase) { throw new Error('An error occurred during function execution.'); } else if (fnName === 'calculateTax') { return (fnArgs?.amount ?? 0) * 0.1; + } else if (fnName === 'echoArgs') { + return Object.fromEntries( + Object.entries(fnArgs ?? {}).map(([k, v]) => [k, v === undefined ? null : v]), + ); } return null; }; @@ -564,26 +611,189 @@ async function validateRpcTestCase(testCase) { const cat = new Catalog(catId, catVersion, [], funcs, undefined, undefined); let sentOutboundMsg; - const processor = new MessageProcessor([cat], undefined, { + let lastErrorMsg; + const rpcCatalogs = cat.id === 'basic' ? [cat] : [cat, v1_0BasicCatalog]; + const processor = new MessageProcessor(rpcCatalogs, undefined, { version: '1.0', outboundListener: msg => { sentOutboundMsg = msg; }, + errorHandler: msg => { + lastErrorMsg = msg; + }, }); - if (message) { - if (expect?.error) { - assert.throws( - () => { - processor.processMessages(message); - }, - err => { - if (expect.error.message) { - return err.message.includes(expect.error.message); - } - return true; + if (args.surfaceId) { + const avail = new Map([[cat.id, cat]]); + if (cat.id !== 'basic') { + avail.set('basic', v1_0BasicCatalog); + } + const surface = new SurfaceModel( + args.surfaceId, + cat, + avail, + undefined, + false, + new DataModel(args.dataModel || {}), + catVersion, + ); + processor.model.addSurface(surface); + } + + if (args.uiBindingScenario) { + const scenario = args.uiBindingScenario; + const callName = + scenario.component?.text?.['@call'] || + scenario.component?.text?.call || + 'fetchRemoteGreeting'; + const procRef = {current: null}; + const agentBindingFn = createFunctionImplementation( + { + name: callName, + returnType: 'string', + schema: z.record(z.string(), z.any()).optional().default({}), + }, + fnArgs => { + const sig = signal(null); + void procRef.current + .callAgentFunction(scenario.surfaceId, { + '@call': callName, + catalogId: scenario.catalogId, + args: fnArgs, + }) + .then(val => setValue(sig, val)); + return sig; + }, + ); + const storeCat = new Catalog( + scenario.catalogId, + 'v1.0', + v1_0Components, + [agentBindingFn], + undefined, + undefined, + ); + procRef.current = new MessageProcessor([storeCat, v1_0BasicCatalog], undefined, { + version: 'v1.0', + outboundListener: msg => { + sentOutboundMsg = msg; + }, + }); + procRef.current.processMessages([ + { + version: 'v1.0', + createSurface: { + surfaceId: scenario.surfaceId, + catalogId: scenario.catalogId, + components: [scenario.component], }, + }, + ]); + const surface = procRef.current.getSurface(scenario.surfaceId); + const resolved = collectResolvedNodes(surface); + try { + const rootNode = resolved.nodes.get(scenario.component.id); + const initialText = normalizeComponentRefs(peekValue(rootNode.props).text); + assert.strictEqual(initialText ?? null, expect.initialValue); + assert.ok(sentOutboundMsg?.callAgentFunction, 'Expected outbound callAgentFunction'); + assert.strictEqual(sentOutboundMsg.version, expect.outboundCall.version); + assert.strictEqual( + sentOutboundMsg.callAgentFunction.surfaceId, + expect.outboundCall.callAgentFunction.surfaceId, ); + procRef.current.processMessages({ + version: 'v1.0', + agentFunctionResponse: { + functionCallId: sentOutboundMsg.callAgentFunction.functionCallId, + value: scenario.inboundResponseValue, + }, + }); + await new Promise(resolve => setTimeout(resolve, 0)); + const updatedText = normalizeComponentRefs(peekValue(rootNode.props).text); + assert.strictEqual(updatedText, expect.resolvedValue); + } finally { + resolved.dispose(); + } + return; + } + + if (args.uiActionFallbackScenario) { + const scenario = args.uiActionFallbackScenario; + const fc = scenario.actionPayload.functionCall; + const callName = fc['@call'] || fc.call; + const procRef = {current: null}; + const agentActionFn = createFunctionImplementation( + { + name: callName, + returnType: 'any', + schema: z.record(z.string(), z.any()).optional().default({}), + }, + fnArgs => + procRef.current.callAgentFunction(scenario.surfaceId, { + '@call': callName, + catalogId: scenario.catalogId, + args: fnArgs, + }), + ); + const storeCat = new Catalog( + scenario.catalogId, + 'v1.0', + v1_0Components, + [agentActionFn], + undefined, + undefined, + ); + procRef.current = new MessageProcessor([storeCat], undefined, { + version: 'v1.0', + outboundListener: msg => { + sentOutboundMsg = msg; + }, + }); + procRef.current.processMessages([ + { + version: 'v1.0', + createSurface: { + surfaceId: scenario.surfaceId, + catalogId: scenario.catalogId, + }, + }, + ]); + const surface = procRef.current.getSurface(scenario.surfaceId); + const ctx = new DataContext(surface, '/'); + const actionPromise = ctx.resolveAction(scenario.actionPayload); + assert.ok(sentOutboundMsg?.callAgentFunction, 'Expected outbound callAgentFunction'); + assert.strictEqual(sentOutboundMsg.version, expect.outboundCall.version); + assert.strictEqual( + sentOutboundMsg.callAgentFunction.surfaceId, + expect.outboundCall.callAgentFunction.surfaceId, + ); + procRef.current.processMessages({ + version: 'v1.0', + agentFunctionResponse: { + functionCallId: sentOutboundMsg.callAgentFunction.functionCallId, + value: scenario.inboundResponseValue, + }, + }); + const result = await actionPromise; + assert.deepStrictEqual(result, expect.result); + return; + } + + if (message) { + if (expect?.error || expectError) { + let thrown; + try { + processor.processMessages(message); + } catch (err) { + thrown = err; + } + assert.ok(thrown, 'Expected processMessages to throw on invalid RPC message'); + if (expect?.error?.message) { + assert.ok(thrown.message.includes(expect.error.message)); + } + if (expectError) { + assertExpectedConformanceError(thrown, lastErrorMsg, expectError); + } } else if (expect && 'response' in expect) { const expectResp = expect.response; const responses = await processor.processMessagesAsync(message, { @@ -938,6 +1148,221 @@ function matchesErrorCategory(err, category) { } } +function assertExpectedConformanceError(thrown, emittedErrorMsg, expErrObj) { + if (!expErrObj || typeof expErrObj !== 'object') return; + if (expErrObj.category) { + if (!matchesErrorCategory(thrown, expErrObj.category)) { + throw new Error( + `Expected error category '${expErrObj.category}' but received ${thrown.constructor?.name || thrown.name}: ${thrown.message}`, + ); + } + } + if (expErrObj.code) { + if ( + thrown.code !== expErrObj.code && + emittedErrorMsg?.error?.code !== expErrObj.code && + !thrown.message.includes(expErrObj.code) && + thrown.name !== expErrObj.code + ) { + throw new Error( + `Expected error code '${expErrObj.code}' but received code='${thrown.code || emittedErrorMsg?.error?.code}': ${thrown.message}`, + ); + } + } + if (expErrObj.surfaceId !== undefined) { + const actualSurfaceId = thrown.surfaceId ?? emittedErrorMsg?.error?.surfaceId ?? ''; + if (actualSurfaceId !== expErrObj.surfaceId) { + throw new Error( + `Expected error surfaceId '${expErrObj.surfaceId}' but received '${actualSurfaceId}'`, + ); + } + } + if (expErrObj.path !== undefined) { + const actualPath = thrown.path ?? emittedErrorMsg?.error?.path; + if (actualPath !== expErrObj.path) { + throw new Error(`Expected error path '${expErrObj.path}' but received '${actualPath}'`); + } + } + if (expErrObj.functionCallId !== undefined) { + const actualFnCallId = thrown.functionCallId ?? emittedErrorMsg?.error?.functionCallId; + if (actualFnCallId !== expErrObj.functionCallId) { + throw new Error( + `Expected error functionCallId '${expErrObj.functionCallId}' but received '${actualFnCallId}'`, + ); + } + } + if (expErrObj.message) { + const expectedMsg = expErrObj.message; + const normalizedActual = thrown.message.replaceAll('"', "'").replaceAll("','", "', '"); + const normalizedExpected = expectedMsg.replaceAll('"', "'").replaceAll("','", "', '"); + const matches = + normalizedActual.includes(normalizedExpected) || + (expectedMsg.includes('Unsupported protocol version') && + (thrown.message.includes('Invalid enum value') || + thrown.message.includes('Unsupported protocol version'))) || + (expectedMsg.includes('Missing') && + thrown.message.includes("missing a valid 'version' string")) || + (expectedMsg.includes('multiple update types') && + thrown.message.includes('multiple conflicting update actions')); + if (!matches) { + throw new Error( + `Expected error message containing '${expectedMsg}' but received: ${thrown.message}`, + ); + } + } + if (Array.isArray(expErrObj.details)) { + const flattenZodIssues = issues => { + const out = []; + for (const issue of issues) { + if (issue.code === 'invalid_union' && Array.isArray(issue.unionErrors)) { + for (const ue of issue.unionErrors) { + if (Array.isArray(ue.issues)) { + out.push(...flattenZodIssues(ue.issues)); + } + } + } else { + out.push(issue); + } + } + return out; + }; + const rawDetails = Array.isArray(thrown.details) + ? thrown.details + : Array.isArray(thrown.details?.issues) + ? flattenZodIssues(thrown.details.issues) + : []; + const actualDetails = rawDetails.map(d => { + const rawPath = Array.isArray(d.path) + ? `/${d.path.map(String).join('/')}` + : String(d.path ?? ''); + const isMissing = + d.code === 'missing_field' || + (d.code === 'invalid_type' && d.received === 'undefined') || + d.message === 'Required'; + return { + ...d, + path: rawPath, + code: isMissing ? 'missing_field' : d.code, + }; + }); + if (actualDetails.length === 0 && thrown.message?.includes("missing a valid 'version'")) { + actualDetails.push({ + path: '/version', + code: 'missing_field', + message: thrown.message, + }); + } + const normalizeDetailPath = p => + String(p ?? '') + .replace(/^\/?messages[./]\d+[./]?/, '/') + .replace(/\./g, '/') + .replace( + /^\/?(createSurface|updateComponents|updateDataModel|deleteSurface|callRendererFunction)\//, + '/', + ) + .replace(/^([^/])/, '/$1'); + const detailCodesMatch = (actualCode, expectedCode) => { + if (actualCode === expectedCode) return true; + if ( + expectedCode === 'type_mismatch' && + (actualCode === 'invalid_type' || actualCode === 'invalid_value') + ) { + return true; + } + if ( + expectedCode === 'invalid_value' && + (actualCode === 'invalid_enum_value' || + actualCode === 'invalid_literal' || + actualCode === 'invalid_type') + ) { + return true; + } + return false; + }; + for (const expDetail of expErrObj.details) { + const matched = actualDetails.some(d => { + if ( + expDetail.path !== undefined && + d.path !== expDetail.path && + normalizeDetailPath(d.path) !== normalizeDetailPath(expDetail.path) + ) { + return false; + } + if (expDetail.code !== undefined && !detailCodesMatch(d.code, expDetail.code)) return false; + if (expDetail.surfaceId !== undefined && d.surfaceId !== expDetail.surfaceId) return false; + if (expDetail.message !== undefined) { + const normAct = String(d.message ?? '') + .replaceAll('"', "'") + .replaceAll("','", "', '"); + const normExp = String(expDetail.message).replaceAll('"', "'").replaceAll("','", "', '"); + if (!normAct.includes(normExp)) return false; + } + return true; + }); + if (!matched) { + throw new Error( + `Expected error detail matching ${JSON.stringify(expDetail)}, got details: ${JSON.stringify(actualDetails)}`, + ); + } + } + } + if (expErrObj.rendererError) { + if (!emittedErrorMsg) { + throw new Error('Expected outbound rendererError message to be emitted, but none was.'); + } + const expRe = expErrObj.rendererError; + if (expRe.version !== undefined) { + assert.strictEqual( + emittedErrorMsg.version, + expRe.version, + `rendererError.version mismatch: expected '${expRe.version}', got '${emittedErrorMsg.version}'`, + ); + } + if (expRe.error) { + if (expRe.error.code !== undefined) { + assert.strictEqual( + emittedErrorMsg.error?.code, + expRe.error.code, + `rendererError.error.code mismatch: expected '${expRe.error.code}', got '${emittedErrorMsg.error?.code}'`, + ); + } + if (expRe.error.surfaceId !== undefined) { + assert.strictEqual( + emittedErrorMsg.error?.surfaceId, + expRe.error.surfaceId, + `rendererError.error.surfaceId mismatch: expected '${expRe.error.surfaceId}', got '${emittedErrorMsg.error?.surfaceId}'`, + ); + } + if (expRe.error.functionCallId !== undefined) { + assert.strictEqual( + emittedErrorMsg.error?.functionCallId, + expRe.error.functionCallId, + `rendererError.error.functionCallId mismatch`, + ); + assert.strictEqual(emittedErrorMsg.error?.surfaceId, undefined); + assert.strictEqual(emittedErrorMsg.error?.path, undefined); + } + if (expRe.error.path !== undefined) { + assert.strictEqual( + emittedErrorMsg.error?.path, + expRe.error.path, + `rendererError.error.path mismatch: expected '${expRe.error.path}', got '${emittedErrorMsg.error?.path}'`, + ); + } + if (expRe.error.message !== undefined) { + const normAct = String(emittedErrorMsg.error?.message ?? '') + .replaceAll('"', "'") + .replaceAll("','", "', '"); + const normExp = String(expRe.error.message).replaceAll('"', "'").replaceAll("','", "', '"); + assert.ok( + normAct.includes(normExp), + `Expected rendererError.error.message containing '${expRe.error.message}', got '${emittedErrorMsg.error?.message}'`, + ); + } + } + } +} + function validateValidateTestCase(testCase) { const expectError = testCase.expectError || testCase.expect_error; const {steps, payload, messages, expect, expectValid} = testCase; @@ -946,9 +1371,17 @@ function validateValidateTestCase(testCase) { } const testCatalogs = getCatalogsForTestCase(testCase); + let lastErrorMsg; + const validationConfig = + testCase.strictMode === false + ? {allowMissingRoot: true, allowDanglingReferences: true, allowOrphanComponents: true} + : STRICT_VALIDATION; const processor = new MessageProcessor(testCatalogs, undefined, { version: testCase.protocolVersion || '1.0', - validationConfig: STRICT_VALIDATION, + validationConfig, + errorHandler: msg => { + lastErrorMsg = msg; + }, }); const stepsToRun = @@ -972,6 +1405,7 @@ function validateValidateTestCase(testCase) { if (inputMessages.length > 0) { let thrown; + lastErrorMsg = undefined; // Subscribe before processing: a surface that already exists may report an // error while the messages are applied, before resolution begins. const watcher = watchSurfaceErrors(processor); @@ -990,35 +1424,7 @@ function validateValidateTestCase(testCase) { if (expectValid || !expErrObj) { throw thrown; } - if (typeof expErrObj === 'object' && expErrObj.category) { - if (!matchesErrorCategory(thrown, expErrObj.category)) { - throw new Error( - `Expected error category '${expErrObj.category}' but received ${thrown.constructor?.name || thrown.name}: ${thrown.message}`, - ); - } - } - if (typeof expErrObj === 'object' && expErrObj.code) { - if ( - !thrown.message.includes(expErrObj.code) && - thrown.name !== expErrObj.code && - thrown.code !== expErrObj.code - ) { - throw new Error( - `Expected error matching '${expErrObj.code}' but received: ${thrown.message}`, - ); - } - } - if (typeof expErrObj === 'object' && expErrObj.message) { - const normalizedActual = thrown.message.replaceAll('"', "'").replaceAll("','", "', '"); - const normalizedExpected = expErrObj.message - .replaceAll('"', "'") - .replaceAll("','", "', '"); - if (!normalizedActual.includes(normalizedExpected)) { - throw new Error( - `Expected error message containing '${expErrObj.message}' but received: ${thrown.message}`, - ); - } - } + assertExpectedConformanceError(thrown, lastErrorMsg, expErrObj); continue; } @@ -1799,7 +2205,9 @@ function getCatalogsForTestCase(testCase) { const isCatalogErrorCase = typeof declaredError === 'object' && declaredError !== null && - (declaredError.category === 'CatalogError' || declaredError.category === 'A2uiCatalogError'); + (declaredError.category === 'CatalogError' || + declaredError.category === 'A2uiCatalogError' || + declaredError.code === 'CATALOG_ERROR'); const expectsError = Boolean(declaredError) && !isCatalogErrorCase; const addCatalogId = (id, ver) => { @@ -1950,13 +2358,15 @@ function getCatalogsForTestCase(testCase) { if ( item.createSurface && item.createSurface.catalogId && - item.createSurface.catalogId !== 'unknown-catalog' + !isCatalogErrorCase && + !item.createSurface.catalogId.includes('unknown') ) addCatalogId(item.createSurface.catalogId, scanVersion); if ( item.beginRendering && item.beginRendering.catalogId && - item.beginRendering.catalogId !== 'unknown-catalog' + !isCatalogErrorCase && + !item.beginRendering.catalogId.includes('unknown') ) addCatalogId(item.beginRendering.catalogId, scanVersion); }; @@ -2096,7 +2506,15 @@ function forceResolution(processor, reported) { if (reported.length > 0) { const first = reported[0]; - throw new A2uiValidationError(first?.message ?? 'Expression error'); + throw new A2uiValidationError( + first?.message ?? 'Expression error', + first?.details, + first?.code ?? 'VALIDATION_FAILED', + { + surfaceId: first?.surfaceId, + path: first?.path, + }, + ); } } @@ -2263,6 +2681,86 @@ function assertSurfacesMatch(processor, expect) { } } } + if ( + surface && + (expectedSurface.isValid !== undefined || + expectedSurface.validationErrors !== undefined || + expectedSurface.validationResults !== undefined || + expectedSurface.validationResult !== undefined) + ) { + const resolved = collectResolvedNodes(surface); + try { + let fallbackNode = resolved.nodes.get('root'); + if (!fallbackNode && resolved.nodes.size > 0) { + fallbackNode = resolved.nodes.values().next().value; + } + const getPropsForId = id => { + const n = resolved.nodes.get(id) ?? (id === 'root' ? fallbackNode : undefined); + return n ? (peekValue(n.props) ?? {}) : {}; + }; + if (expectedSurface.isValid !== undefined) { + if (typeof expectedSurface.isValid === 'boolean') { + assert.strictEqual(getPropsForId('root').isValid, expectedSurface.isValid); + } else { + for (const [compId, expVal] of Object.entries(expectedSurface.isValid)) { + assert.strictEqual(getPropsForId(compId).isValid, expVal); + } + } + } + if (expectedSurface.validationErrors !== undefined) { + if (Array.isArray(expectedSurface.validationErrors)) { + assert.deepStrictEqual( + getPropsForId('root').validationErrors, + expectedSurface.validationErrors, + ); + } else { + for (const [compId, expVal] of Object.entries(expectedSurface.validationErrors)) { + assert.deepStrictEqual(getPropsForId(compId).validationErrors, expVal); + } + } + } + if (expectedSurface.validationResults !== undefined) { + if (Array.isArray(expectedSurface.validationResults)) { + assert.deepStrictEqual( + getPropsForId('root').validationResults, + expectedSurface.validationResults, + ); + } else { + for (const [compId, expVal] of Object.entries(expectedSurface.validationResults)) { + assert.deepStrictEqual(getPropsForId(compId).validationResults, expVal); + } + } + } + if (expectedSurface.validationResult !== undefined) { + const checkSingleResult = (compId, expRes) => { + const firstResult = Array.isArray(getPropsForId(compId).validationResults) + ? getPropsForId(compId).validationResults[0] + : undefined; + if (expRes.valid !== undefined) assert.strictEqual(firstResult?.valid, expRes.valid); + if (expRes.message !== undefined) { + assert.strictEqual(firstResult?.message, expRes.message); + } + if (expRes.code !== undefined) assert.strictEqual(firstResult?.code, expRes.code); + if (expRes.severity !== undefined) { + assert.strictEqual(firstResult?.severity, expRes.severity); + } + }; + if ( + 'valid' in expectedSurface.validationResult || + 'message' in expectedSurface.validationResult || + 'code' in expectedSurface.validationResult + ) { + checkSingleResult('root', expectedSurface.validationResult); + } else { + for (const [compId, expRes] of Object.entries(expectedSurface.validationResult)) { + checkSingleResult(compId, expRes); + } + } + } + } finally { + resolved.dispose(); + } + } } } } @@ -2301,9 +2799,14 @@ function validateProcessMessagesTestCase(testCase) { const {messages, payload, steps, expect, expectError, protocolVersion} = testCase; const testCatalogs = getCatalogsForTestCase(testCase); + let lastErrorMsg; const processorOptions = { ...(protocolVersion ? {version: protocolVersion} : {}), ...caseValidationConfig(testCase), + ...(testCase.strictMode ? {validationConfig: STRICT_VALIDATION} : {}), + errorHandler: msg => { + lastErrorMsg = msg; + }, }; const processor = new MessageProcessor(testCatalogs, undefined, processorOptions); @@ -2380,6 +2883,7 @@ function validateProcessMessagesTestCase(testCase) { const stepExpectError = step.expectError || (i === steps.length - 1 ? expectError : undefined); if (stepExpectError) { + lastErrorMsg = undefined; let thrown; try { processor.processMessages(stepMsgs, context); @@ -2388,10 +2892,10 @@ function validateProcessMessagesTestCase(testCase) { } if (!thrown) { throw new Error( - `Expected error (${stepExpectError.category || stepExpectError.message || 'UNKNOWN'}) but message processing succeeded.`, + `Expected error (${stepExpectError.category || stepExpectError.code || stepExpectError.message || 'UNKNOWN'}) but message processing succeeded.`, ); } - assertExpectedProcessError(thrown, stepExpectError); + assertExpectedConformanceError(thrown, lastErrorMsg, stepExpectError); } else { processor.processMessages(stepMsgs, context); const stepExpect = step.expect || (i === steps.length - 1 ? expect : undefined); @@ -2407,6 +2911,7 @@ function validateProcessMessagesTestCase(testCase) { if (!inputMessages) return; if (expectError) { + lastErrorMsg = undefined; let thrown; try { processor.processMessages(inputMessages); @@ -2415,10 +2920,10 @@ function validateProcessMessagesTestCase(testCase) { } if (!thrown) { throw new Error( - `Expected error (${expectError.category || expectError.message || 'UNKNOWN'}) but message processing succeeded.`, + `Expected error (${expectError.category || expectError.code || expectError.message || 'UNKNOWN'}) but message processing succeeded.`, ); } - assertExpectedProcessError(thrown, expectError); + assertExpectedConformanceError(thrown, lastErrorMsg, expectError); return; } @@ -2492,7 +2997,9 @@ function validateDispatchActionTestCase(testCase) { actionPayload, dataModel = {}, surfaceId = 'main', + sourceComponentId = 'root', scope, + userActivation, expectDispatched, expectDataModel, expectError, @@ -2501,47 +3008,136 @@ function validateDispatchActionTestCase(testCase) { const errorSpec = expect_error || expectError; const testCatalogs = getCatalogsForTestCase(testCase); - const defaultCat = testCatalogs[0] || getBasicCatalog(resolveProtocolVersion(testCase) || 'v0.9'); + const baseCat = testCatalogs[0] || getBasicCatalog(resolveProtocolVersion(testCase) || 'v0.9'); + const actionFunctions = [ + ...Array.from(baseCat.functions.values()), + createFunctionImplementation( + { + name: 'incrementCounter', + returnType: 'void', + schema: z.object({ + path: z.string().optional(), + step: z.number().optional(), + }), + }, + (args, context) => { + const targetPath = args.path || '/counter'; + const step = args.step ?? 1; + const cur = Number(context.dataModel.get(targetPath) ?? 0); + context.dataModel.set(targetPath, cur + step); + }, + ), + createFunctionImplementation( + { + name: 'failingAction', + returnType: 'void', + schema: z.object({}), + }, + () => { + throw new Error('Action function failed'); + }, + ), + ]; + const defaultCat = new Catalog( + baseCat.id, + baseCat.protocolVersion || '0.9', + Array.from(baseCat.components.values()), + actionFunctions, + baseCat.themeSchema, + baseCat.instructions, + ); const model = new DataModel(dataModel); const surface = new SurfaceModel(surfaceId, defaultCat, undefined, undefined, false, model); const dispatched = []; surface.onAction.subscribe(evt => dispatched.push(evt)); + const surfaceErrors = []; + surface.onError.subscribe(err => surfaceErrors.push(err)); const ctx = new DataContext(surface, scope || '/'); + const prevWindow = globalThis.window; + if (typeof globalThis.window === 'undefined') { + globalThis.window = {open: () => null}; + } - if (errorSpec) { - assert.throws(() => { - const resolved = ctx.resolveAction(actionPayload); - if (resolved && typeof resolved === 'object' && ('event' in resolved || 'name' in resolved)) { - surface.dispatchAction(resolved); + try { + if (errorSpec) { + let thrown; + try { + const resolved = ctx.resolveAction(actionPayload, {userActivation}); + if ( + resolved && + typeof resolved === 'object' && + ('event' in resolved || 'name' in resolved) + ) { + surface.dispatchAction(resolved, sourceComponentId); + } + } catch (err) { + thrown = err; } - }); - return; - } + if (!thrown && surfaceErrors.length > 0) { + const first = surfaceErrors[0]; + thrown = new A2uiValidationError(first.message, first.details, first.code); + } + if (!thrown) { + throw new Error('Expected action dispatch error, but none was thrown or reported'); + } + if (errorSpec.code && surfaceErrors.length > 0) { + assert.strictEqual(surfaceErrors[0].code, errorSpec.code); + } else if (errorSpec.code && thrown.code) { + assert.strictEqual(thrown.code, errorSpec.code); + } + if (errorSpec.message) { + const actualMsg = surfaceErrors[0]?.message || thrown.message || ''; + assert.ok( + actualMsg.toLowerCase().includes(errorSpec.message.toLowerCase()), + `Expected error message containing '${errorSpec.message}', got '${actualMsg}'`, + ); + } + return; + } - const resolved = ctx.resolveAction(actionPayload); - if (resolved && typeof resolved === 'object' && ('event' in resolved || 'name' in resolved)) { - surface.dispatchAction(resolved); - } + const resolved = ctx.resolveAction(actionPayload, {userActivation}); + if (resolved && typeof resolved === 'object' && ('event' in resolved || 'name' in resolved)) { + surface.dispatchAction(resolved, sourceComponentId); + } - if (expectDispatched !== undefined) { - assert.ok(dispatched.length >= 1, 'Expected action to be dispatched, but none was'); - const actual = dispatched[0]; - if ('name' in expectDispatched) { - assert.strictEqual(actual.name, expectDispatched.name); + if (expectDispatched === null) { + assert.strictEqual(dispatched.length, 0, 'Expected no action to be dispatched'); + } else if (expectDispatched !== undefined) { + assert.ok(dispatched.length >= 1, 'Expected action to be dispatched, but none was'); + const actual = dispatched[0]; + if ('name' in expectDispatched) { + assert.strictEqual(actual.name, expectDispatched.name); + } + if ('context' in expectDispatched) { + assert.deepStrictEqual(actual.context, expectDispatched.context); + } + if ('userMessage' in expectDispatched) { + assert.strictEqual(actual.userMessage, expectDispatched.userMessage); + } + if ('surfaceId' in expectDispatched) { + assert.strictEqual(actual.surfaceId, expectDispatched.surfaceId); + } + if ('sourceComponentId' in expectDispatched) { + assert.strictEqual(actual.sourceComponentId, expectDispatched.sourceComponentId); + } + if ('timestampIso8601' in expectDispatched) { + assert.strictEqual(typeof actual.timestamp, 'string'); + assert.ok(!Number.isNaN(Date.parse(actual.timestamp))); + } } - if ('context' in expectDispatched) { - assert.deepStrictEqual(actual.context, expectDispatched.context); + + if (expectDataModel !== undefined) { + assert.deepStrictEqual(model.get('/'), expectDataModel); } - if ('userMessage' in expectDispatched) { - assert.strictEqual(actual.userMessage, expectDispatched.userMessage); + } finally { + if (prevWindow === undefined) { + delete globalThis.window; + } else { + globalThis.window = prevWindow; } } - - if (expectDataModel !== undefined) { - assert.deepStrictEqual(model.get('/'), expectDataModel); - } } /** diff --git a/typescript/core/web_core/tests/conformance/node-resolution.ts b/typescript/core/web_core/tests/conformance/node-resolution.ts index c2c49a65cf..5ed4b5f2bd 100644 --- a/typescript/core/web_core/tests/conformance/node-resolution.ts +++ b/typescript/core/web_core/tests/conformance/node-resolution.ts @@ -19,12 +19,13 @@ import {readFileSync} from 'node:fs'; import {join} from 'node:path'; import yaml from 'js-yaml'; import {Catalog, ComponentApi, createFunctionImplementation} from '../../src/catalog/types.js'; +import {MessageProcessor} from '../../src/processing/message-processor.js'; import {ComponentNode, isComponentNode} from '../../src/resolution/component-node.js'; import {NodeResolver} from '../../src/resolution/node-resolver.js'; import {ResolvedBinding, isWritable} from '../../src/resolution/resolved-binding.js'; import {effect, getValue, peekValue} from '../../src/reactivity/signals.js'; import {ComponentModel} from '../../src/state/component-model.js'; -import {SurfaceModel} from '../../src/state/surface-model.js'; +import {SurfaceModel, A2uiErrorPayload} from '../../src/state/surface-model.js'; type Component = Record & {id: string; component: string}; interface Fixture { @@ -37,6 +38,9 @@ interface NodeExpectation { type?: string; state?: string; data_path?: string; + isValid?: boolean; + validationErrors?: string[]; + validationResults?: Array>; props?: Record; } interface Expectation { @@ -48,6 +52,7 @@ interface Expectation { data?: Record; events?: unknown[]; functions?: unknown[]; + errors?: Array<{code?: string; surfaceId?: string; message?: string}>; } type Operation = | {op: 'set_data'; path: string; value: unknown} @@ -55,6 +60,7 @@ type Operation = | {op: 'remove_component'; component_id: string} | {op: 'write'; node: string; property: string; value: unknown} | {op: 'invoke'; node: string; property: string} + | {op: 'process_messages'; messages: unknown[]} | {op: 'dispose'}; export interface ConformanceCase { name: string; @@ -127,8 +133,33 @@ function requiredNode(nodes: Map, path: string): Componen return node; } +function resolveNodeType(node: ComponentNode, surface: SurfaceModel): string { + if (node.state === 'cyclic' && node.type === 'Placeholder') { + return surface.componentsModel.get(node.componentId)?.type ?? node.type; + } + return node.type; +} + +function checkNodeValidationProps( + props: Record, + fields: NodeExpectation, + path: string, + reason: string, +): void { + for (const key of ['isValid', 'validationErrors', 'validationResults'] as const) { + if (key in fields) { + assert.deepEqual( + normalize(props[key], `${path}/${key}`), + fields[key], + `${reason}: ${path}.${key}`, + ); + } + } +} + function checkNodes( nodes: Map, + surface: SurfaceModel, expected: Expectation, reason: string, ): void { @@ -141,7 +172,7 @@ function checkNodes( const node = requiredNode(nodes, path); const actual = { component_id: node.componentId, - type: node.type, + type: resolveNodeType(node, surface), state: node.state, data_path: node.dataPath, }; @@ -149,6 +180,7 @@ function checkNodes( if (key in fields) assert.equal(actual[key], fields[key], `${reason}: ${path}.${key}`); } const props = peekValue(node.props); + checkNodeValidationProps(props, fields, path, reason); for (const [key, value] of Object.entries(fields.props ?? {})) { assert.ok(key in props, `${reason}: ${path}.${key} is absent`); assert.deepEqual(normalize(props[key], `${path}/${key}`), value, `${reason}: ${path}.${key}`); @@ -187,6 +219,76 @@ function saveBindings( return saved; } +function checkExpectedErrors( + actualErrors: A2uiErrorPayload[], + expectedErrors: NonNullable | undefined, + reason: string, +): void { + if (expectedErrors === undefined) return; + assert.equal(actualErrors.length, expectedErrors.length, `${reason}: errors count`); + for (let i = 0; i < expectedErrors.length; i++) { + const expErr: {code?: string; surfaceId?: string; message?: string} = expectedErrors[i]; + const actErr = actualErrors[i]; + if (expErr.code !== undefined) { + assert.equal(actErr.code, expErr.code, `${reason}: errors[${i}].code`); + } + if (expErr.surfaceId !== undefined) { + assert.equal(actErr.surfaceId, expErr.surfaceId, `${reason}: errors[${i}].surfaceId`); + } + if (expErr.message !== undefined) { + assert.ok( + actErr.message.includes(expErr.message), + `${reason}: errors[${i}].message (${actErr.message})`, + ); + } + } +} + +async function executeNodeResolutionStep( + step: ConformanceCase['steps'][number], + before: Map, + surface: SurfaceModel, + catalog: Catalog, + processor: MessageProcessor, + resolver: NodeResolver, + reason: string, +): Promise { + switch (step.op) { + case 'set_data': + surface.dataModel.set(step.path, step.value); + break; + case 'update_components': + updateComponents(surface, catalog, step.components); + break; + case 'remove_component': + surface.componentsModel.removeComponent(step.component_id); + break; + case 'write': { + const binding = peekValue(requiredNode(before, step.node).props)[step.property]; + assert.ok( + binding instanceof ResolvedBinding && isWritable(binding), + `${reason}: not a writable binding`, + ); + binding.set(step.value); + break; + } + case 'invoke': { + const action = peekValue(requiredNode(before, step.node).props)[step.property]; + assert.equal(typeof action, 'function', `${reason}: not an action`); + await (action as () => unknown)(); + break; + } + case 'process_messages': + processor.processMessages(step.messages as any); + break; + case 'dispose': + resolver.dispose(); + break; + default: + assert.fail(`Unknown node operation: ${(step as {op: string}).op}`); + } +} + /** * Runs one `resolve_nodes` case; fixture and catalog paths resolve against * `conformanceRoot`. @@ -204,6 +306,7 @@ export async function runNodeResolutionCase( ); const functions: unknown[] = []; const events: unknown[] = []; + const errors: A2uiErrorPayload[] = []; const catalog = new Catalog( parsed.id, parsed.protocolVersion, @@ -216,6 +319,8 @@ export async function runNodeResolutionCase( ), ); const surface = new SurfaceModel('s', catalog); + const processor = new MessageProcessor([catalog]); + processor.model.addSurface(surface); surface.dataModel.set('/', fixture.data); updateComponents(surface, catalog, fixture.components); const resolver = new NodeResolver(surface, catalog); @@ -227,6 +332,9 @@ export async function runNodeResolutionCase( context: normalize(action.context, ''), }); }); + const stopErrors = surface.onError.subscribe(err => { + errors.push(err); + }); function watchNodes(nodes: Map): void { for (const [path, node] of nodes) { @@ -259,7 +367,7 @@ export async function runNodeResolutionCase( function check(expected: Expectation, before: Map, reason: string): void { const after = mountedNodes(resolver); - checkNodes(after, expected, reason); + checkNodes(after, surface, expected, reason); const surviving = new Set(after.values()); const emissions: Record = {}; const destroyed: Record = {}; @@ -304,6 +412,7 @@ export async function runNodeResolutionCase( } assert.deepEqual(events, expected.events ?? [], `${reason}: events`); assert.deepEqual(functions, expected.functions ?? [], `${reason}: functions`); + checkExpectedErrors(errors, expected.errors, reason); } try { @@ -320,38 +429,9 @@ export async function runNodeResolutionCase( } events.length = 0; functions.length = 0; + errors.length = 0; const bindings = saveBindings(before); - switch (step.op) { - case 'set_data': - surface.dataModel.set(step.path, step.value); - break; - case 'update_components': - updateComponents(surface, catalog, step.components); - break; - case 'remove_component': - surface.componentsModel.removeComponent(step.component_id); - break; - case 'write': { - const binding = peekValue(requiredNode(before, step.node).props)[step.property]; - assert.ok( - binding instanceof ResolvedBinding && isWritable(binding), - `${reason}: not a writable binding`, - ); - binding.set(step.value); - break; - } - case 'invoke': { - const action = peekValue(requiredNode(before, step.node).props)[step.property]; - assert.equal(typeof action, 'function', `${reason}: not an action`); - await (action as () => unknown)(); - break; - } - case 'dispose': - resolver.dispose(); - break; - default: - assert.fail(`Unknown node operation: ${(step as {op: string}).op}`); - } + await executeNodeResolutionStep(step, before, surface, catalog, processor, resolver, reason); await flush(); check(step.expect, before, reason); for (const [binding, value] of bindings) { @@ -368,5 +448,6 @@ export async function runNodeResolutionCase( await flush(); for (const watch of watches.values()) watch.stop(); stopActions.unsubscribe(); + stopErrors.unsubscribe(); } } From 02dc30db6ff9a307a96dc567e3597325fee3bb1d Mon Sep 17 00:00:00 2001 From: Greg Spencer Date: Fri, 23 Oct 2026 23:54:29 +0000 Subject: [PATCH 2/8] fix(web_core): use Object.hasOwn for property checks --- typescript/core/web_core/src/processing/adapters/v1_0.ts | 2 +- typescript/core/web_core/src/processing/message-error-mapper.ts | 2 +- 2 files changed, 2 insertions(+), 2 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 9e02b76a59..b241f07aed 100644 --- a/typescript/core/web_core/src/processing/adapters/v1_0.ts +++ b/typescript/core/web_core/src/processing/adapters/v1_0.ts @@ -29,7 +29,7 @@ function assertObjectArgs( functionCallId: string, ): void { if ( - 'args' in cf && + Object.hasOwn(cf, 'args') && cf.args !== undefined && (typeof cf.args !== 'object' || cf.args === null || Array.isArray(cf.args)) ) { diff --git a/typescript/core/web_core/src/processing/message-error-mapper.ts b/typescript/core/web_core/src/processing/message-error-mapper.ts index 3576168b9b..1e7f57db50 100644 --- a/typescript/core/web_core/src/processing/message-error-mapper.ts +++ b/typescript/core/web_core/src/processing/message-error-mapper.ts @@ -111,7 +111,7 @@ export function extractEnvelopeErrorContext( const version = typeof rawMessage['version'] === 'string' ? rawMessage['version'] : fallbackVersion; - const actionType = ACTION_ENVELOPE_KEYS.find(key => key in rawMessage); + const actionType = ACTION_ENVELOPE_KEYS.find(key => Object.hasOwn(rawMessage, key)); if (!actionType) { return {version}; } From 7368c1c6e12742d3aa8c53332b28a43e26234507 Mon Sep 17 00:00:00 2001 From: Greg Spencer Date: Wed, 28 Oct 2026 21:50:18 +0000 Subject: [PATCH 3/8] fix(web_core): avoid duplicate nested function validation on component validation --- typescript/core/web_core/src/validation/payload-validator.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/typescript/core/web_core/src/validation/payload-validator.ts b/typescript/core/web_core/src/validation/payload-validator.ts index 0984b347db..086efd2c83 100644 --- a/typescript/core/web_core/src/validation/payload-validator.ts +++ b/typescript/core/web_core/src/validation/payload-validator.ts @@ -243,7 +243,6 @@ export class PayloadValidator { const knownKeys = getKnownSchemaKeys(componentApi.schema); this.validateCommonEnvelopeFields(properties, knownKeys, componentType, id, componentCatalogId); - this.validateNestedFunctions(properties, componentCatalogId); const result = componentApi.schema.safeParse(properties); if (!result.success) { From 74bd367f504c60e6a3e8cc0e74e779b12b067ccf Mon Sep 17 00:00:00 2001 From: Greg Spencer Date: Wed, 28 Oct 2026 23:42:01 +0000 Subject: [PATCH 4/8] fix(web_core): address review feedback on error mapping and conformance parity --- typescript/core/web_core/CHANGELOG.md | 2 +- .../web_core/src/common/basic_functions.ts | 12 +- .../web_core/src/processing/adapters/base.ts | 15 +- .../core/web_core/src/processing/index.ts | 4 +- .../processing/message-error-mapper.test.ts | 199 ++++++++++++++++++ .../src/processing/message-error-mapper.ts | 55 +++-- .../src/processing/message-processor.ts | 88 ++++---- .../web_core/src/processing/operations.ts | 8 +- .../web_core/src/resolution/data-context.ts | 30 ++- .../web_core/src/resolution/generic-binder.ts | 89 +++----- .../web_core/src/resolution/node-resolver.ts | 4 +- .../web_core/src/state/validation-helpers.ts | 35 +-- typescript/core/web_core/src/v0_9/index.ts | 14 +- typescript/core/web_core/src/v1_0/index.ts | 1 - .../src/validation/payload-validator.ts | 4 +- .../tests/conformance/conformance_test.mjs | 36 +--- .../tests/conformance/node-resolution.ts | 12 +- 17 files changed, 401 insertions(+), 207 deletions(-) create mode 100644 typescript/core/web_core/src/processing/message-error-mapper.test.ts diff --git a/typescript/core/web_core/CHANGELOG.md b/typescript/core/web_core/CHANGELOG.md index f599279ab0..08377b818a 100644 --- a/typescript/core/web_core/CHANGELOG.md +++ b/typescript/core/web_core/CHANGELOG.md @@ -19,7 +19,7 @@ - Add `MessageProcessor.catalogs`, the catalogs the processor was created with, and `ExecutionContext.validationConfig`, which `processMessages`, `processMessagesAsync` and `processOperation` apply to that call in place of the processor's own `validationConfig`. [#3597](https://github.com/a2ui-project/a2ui/issues/3597) - Universal elements rendered from a `ComponentNode` read their props from the node instead of allocating a second `GenericBinder`, and `A2uiLitElement.renderNode` accepts a resolved `ComponentNode` as well as an `A2uiChildRef`. [#3705](https://github.com/a2ui-project/a2ui/pull/3705) - (v1_0) `Catalog.invoker` reads a `ValidationResult` object that a function argument resolves to by its `valid` boolean wherever the function's schema types that argument as `DynamicBoolean`, as the specification defines the type. This applies to every `DynamicBoolean` argument, in built-in and custom catalogs, so `formatNumber`'s `grouping` and a custom function's boolean parameters now read a failing validation as `false` rather than as a truthy object; `and`, `or` and `not` already did. Catalogs below v1.0, whose validation functions return booleans, are unchanged. [#3653](https://github.com/a2ui-project/a2ui/pull/3653) -- Add `mapErrorToRendererMessage` and `extractEnvelopeErrorContext` (along with `ErrorMessage`, `A2uiRendererError`, `ValidationFailedErrorBody`, `GenericErrorBody`, and `ErrorEnvelopeContext`) to `@a2ui/web_core` and `@a2ui/web_core/v0_9`, add `SurfaceGroupModel.onError` and `MessageProcessor.processMessagesWithErrors` for structured outbound renderer error envelopes, and emit RFC 6901 JSON Pointer `path` values on `UNALLOWED_PARENT` and `UNALLOWED_CHILD` composition constraint errors. +- Add `mapErrorToRendererMessage`, `extractEnvelopeErrorContext`, and `normalizeJsonPointer` (along with `RendererErrorMessage`, `A2uiRendererError`, `ValidationFailedErrorBody`, `GenericErrorBody`, and `ErrorEnvelopeContext`) to `@a2ui/web_core`, `@a2ui/web_core/v0_9`, `@a2ui/web_core/v1_0`, and `@a2ui/web_core/processing`, add `SurfaceGroupModel.onError`, `MessageProcessor.processMessagesWithErrors`, `MessageProcessorOptions.errorHandler`, and `MessageProcessorOptions.reportErrorsOnly` for structured outbound renderer error envelopes, add `A2uiErrorDetail`, `A2uiErrorContext`, and `A2uiError` context properties `path`/`surfaceId`/`functionCallId`, and emit RFC 6901 JSON Pointer `path` values on `UNALLOWED_PARENT` and `UNALLOWED_CHILD` composition constraint errors. [#3579](https://github.com/a2ui-project/a2ui/pull/3579) - v1.0 function RPC payloads name the function with `'@call'`. `RpcHandler.callAgentFunction` rewrites a v0.9-style `call` key to `'@call'` in the outbound `callAgentFunction` message, and `MessageProcessor` passes `'@call'` in the `callRendererFunction` message it hands to the `RpcHandler` ([#3486](https://github.com/a2ui-project/a2ui/issues/3486)). - **BREAKING CHANGE**: Remove cross-catalog `$ref` resolution across peer catalogs, added in 0.14.0. `Catalog.peerCatalogs`, `Catalog.withPeerCatalogs`, the optional `peerCatalogs` and `withPeerCatalogs` members of `CatalogInterface`, `linkCatalogs` and `inlinePeerAndLocalRefs` are removed, and `MessageProcessor` uses the catalogs passed to it as is. A catalog's `$ref`s resolve only against its own document and `common_types.json`, and `Catalog.fromJson` of a v1.0 catalog throws `A2uiCatalogError` (`Unsupported external $ref ...`) for a `$ref` to any other document, such as another catalog, without fetching it; v0.9 catalogs load as before. Recommended migration: copy the referenced definitions into each catalog that uses them. [#3232](https://github.com/a2ui-project/a2ui/issues/3232), [#3575](https://github.com/a2ui-project/a2ui/pull/3575) - Function calls now await pending Promises in their arguments before validating and running, while synchronous calls remain synchronous. [#3578](https://github.com/a2ui-project/a2ui/pull/3578) diff --git a/typescript/core/web_core/src/common/basic_functions.ts b/typescript/core/web_core/src/common/basic_functions.ts index 9440726366..5449374276 100644 --- a/typescript/core/web_core/src/common/basic_functions.ts +++ b/typescript/core/web_core/src/common/basic_functions.ts @@ -69,6 +69,10 @@ export interface ValidationResult { valid: boolean; /** Explanatory message when validation failed. */ message?: string; + /** Optional machine-readable validation code. */ + code?: string; + /** Optional severity level. */ + severity?: 'error' | 'warning' | 'info'; } /** @@ -181,7 +185,13 @@ export function validateEmail(val: unknown): ValidationResult { const isValid = typeof val === 'string' && EMAIL_REGEX.test(val); return { valid: isValid, - ...(isValid ? {} : {message: 'Must be a valid email address.'}), + ...(isValid + ? {} + : { + code: 'INVALID_EMAIL', + severity: 'error', + message: 'Please enter a valid email address (e.g. user@example.com).', + }), }; } diff --git a/typescript/core/web_core/src/processing/adapters/base.ts b/typescript/core/web_core/src/processing/adapters/base.ts index 7abd4bb848..2767e85106 100644 --- a/typescript/core/web_core/src/processing/adapters/base.ts +++ b/typescript/core/web_core/src/processing/adapters/base.ts @@ -260,18 +260,13 @@ export abstract class BaseVersionAdapter implements VersionAdapter { const preliminaryErr = new A2uiValidationError( `Invalid ${this.version} message: ${formattedErrors}`, parseResult.error, - ); - const mapped = mapErrorToRendererMessage(preliminaryErr, envCtx); - throw new A2uiValidationError( - `Invalid ${this.version} message: ${formattedErrors}`, - parseResult.error, 'VALIDATION_ERROR', - { - path: mapped.error.path, - surfaceId: mapped.error.surfaceId, - functionCallId: mapped.error.functionCallId, - }, ); + const mapped = mapErrorToRendererMessage(preliminaryErr, envCtx); + preliminaryErr.path = mapped.error.path; + preliminaryErr.surfaceId = mapped.error.surfaceId || undefined; + preliminaryErr.functionCallId = mapped.error.functionCallId; + throw preliminaryErr; } return this.extractOperationsFromObject(msgObj); diff --git a/typescript/core/web_core/src/processing/index.ts b/typescript/core/web_core/src/processing/index.ts index d0e43f2ca5..a705104c3e 100644 --- a/typescript/core/web_core/src/processing/index.ts +++ b/typescript/core/web_core/src/processing/index.ts @@ -19,12 +19,12 @@ export { mapErrorToRendererMessage, extractEnvelopeErrorContext, normalizeJsonPointer, - type ErrorMessage, + type RendererErrorMessage, + type ProcessingErrorMessage, type A2uiRendererError, type ValidationFailedErrorBody, type GenericErrorBody, type ErrorEnvelopeContext, - type EnvelopeErrorContext, } from './message-error-mapper.js'; export * from './adapters/factory.js'; export * from './adapters/base.js'; diff --git a/typescript/core/web_core/src/processing/message-error-mapper.test.ts b/typescript/core/web_core/src/processing/message-error-mapper.test.ts new file mode 100644 index 0000000000..cccdd673e7 --- /dev/null +++ b/typescript/core/web_core/src/processing/message-error-mapper.test.ts @@ -0,0 +1,199 @@ +/* + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import * as assert from 'node:assert'; +import {describe, it} from 'node:test'; +import {z} from 'zod'; +import { + mapErrorToRendererMessage, + normalizeJsonPointer, + extractEnvelopeErrorContext, +} from './message-error-mapper.js'; +import { + A2uiIntegrityError, + A2uiRpcError, + A2uiValidationError, +} from '../errors.js'; +import {ErrorMessageSchema} from '../v1_0/schema/renderer-to-agent.js'; + +describe('message-error-mapper', () => { + describe('normalizeJsonPointer', () => { + it('returns undefined for empty or undefined input', () => { + assert.strictEqual(normalizeJsonPointer(undefined), undefined); + assert.strictEqual(normalizeJsonPointer(''), undefined); + }); + + it('preserves paths starting with / literally without stripping action prefixes or replacing dots', () => { + assert.strictEqual(normalizeJsonPointer('/user.profile/email'), '/user.profile/email'); + assert.strictEqual(normalizeJsonPointer('/createSurface/title'), '/createSurface/title'); + assert.strictEqual( + normalizeJsonPointer('/updateComponents/createSurface'), + '/updateComponents/createSurface', + ); + assert.strictEqual(normalizeJsonPointer('/'), '/'); + }); + + it('converts dot and bracket notation paths and strips action prefix', () => { + assert.strictEqual( + normalizeJsonPointer('updateComponents.components[0].text'), + '/components/0/text', + ); + assert.strictEqual(normalizeJsonPointer('createSurface.title'), '/title'); + assert.strictEqual(normalizeJsonPointer('components[2].label'), '/components/2/label'); + }); + }); + + describe('extractEnvelopeErrorContext', () => { + it('extracts surfaceId, functionCallId, and version from payloads', () => { + const v1Msg = { + version: 'v1.0', + callRendererFunction: { + functionCallId: 'call-123', + surfaceId: 'surface-abc', + }, + }; + const ctx = extractEnvelopeErrorContext(v1Msg); + assert.strictEqual(ctx.version, 'v1.0'); + assert.strictEqual(ctx.functionCallId, 'call-123'); + assert.strictEqual(ctx.surfaceId, 'surface-abc'); + assert.strictEqual(ctx.actionType, 'callRendererFunction'); + assert.strictEqual(ctx.isCallRendererFunction, true); + }); + + it('extracts context from standard actions', () => { + const msg = { + version: 'v0.9', + updateComponents: { + surfaceId: 'surf-1', + components: [], + }, + }; + const ctx = extractEnvelopeErrorContext(msg); + assert.strictEqual(ctx.version, 'v0.9'); + assert.strictEqual(ctx.surfaceId, 'surf-1'); + assert.strictEqual(ctx.actionType, 'updateComponents'); + assert.strictEqual(ctx.isCallRendererFunction, false); + }); + }); + + describe('mapErrorToRendererMessage', () => { + it('defaults path to / for validation errors when rawPath is undefined', () => { + const valErr = new A2uiValidationError('Validation failed without path'); + const mapped = mapErrorToRendererMessage(valErr, {version: 'v1.0', surfaceId: 'surf-1'}); + + assert.strictEqual(mapped.version, 'v1.0'); + assert.strictEqual(mapped.error.code, 'VALIDATION_FAILED'); + assert.strictEqual(mapped.error.path, '/'); + assert.strictEqual(mapped.error.surfaceId, 'surf-1'); + + const parsed = ErrorMessageSchema.safeParse(mapped); + assert.ok(parsed.success, JSON.stringify(parsed.error?.issues)); + }); + + it('defaults path to / for UNALLOWED_PARENT and UNALLOWED_CHILD', () => { + const parentErr = new A2uiValidationError( + 'Unallowed parent', + undefined, + 'UNALLOWED_PARENT', + ); + const mapped = mapErrorToRendererMessage(parentErr, {version: 'v1.0', surfaceId: 'surf-1'}); + assert.strictEqual(mapped.error.code, 'UNALLOWED_PARENT'); + assert.strictEqual(mapped.error.path, '/'); + + const parsed = ErrorMessageSchema.safeParse(mapped); + assert.ok(parsed.success, JSON.stringify(parsed.error?.issues)); + }); + + it('escapes RFC 6901 ~ and / tokens from Zod issue paths', () => { + const zodError = new z.ZodError([ + { + code: z.ZodIssueCode.custom, + path: ['components', 'prop/with/slash', 'key~with~tilde'], + message: 'Invalid property value', + }, + ]); + const valErr = new A2uiValidationError('Schema failure', zodError); + const mapped = mapErrorToRendererMessage(valErr, { + version: 'v1.0', + surfaceId: 'surf-1', + }); + assert.strictEqual(mapped.error.path, '/components/prop~1with~1slash/key~0with~0tilde'); + }); + + it('routes pre-1.0 callRendererFunction payload failures to VALIDATION_FAILED with surfaceId', () => { + const err = new A2uiValidationError( + 'Unsupported action: callRendererFunction', + undefined, + 'VALIDATION_ERROR', + {path: '/version'}, + ); + const mapped = mapErrorToRendererMessage(err, { + version: 'v0.9', + actionType: 'callRendererFunction', + isCallRendererFunction: true, + surfaceId: 'surf-pre10', + }); + + assert.strictEqual(mapped.version, 'v0.9'); + assert.strictEqual(mapped.error.code, 'VALIDATION_FAILED'); + assert.strictEqual(mapped.error.surfaceId, 'surf-pre10'); + assert.strictEqual(mapped.error.path, '/version'); + }); + + it('satisfies v1.0 oneOf constraint when functionCallId is present', () => { + const rpcErr = new A2uiRpcError('Function not found', 'INVALID_FUNCTION_CALL', 'fn-123'); + const mapped = mapErrorToRendererMessage(rpcErr, { + version: 'v1.0', + functionCallId: 'fn-123', + surfaceId: 'surf-should-be-omitted', + }); + + assert.strictEqual(mapped.version, 'v1.0'); + assert.strictEqual(mapped.error.code, 'INVALID_FUNCTION_CALL'); + assert.strictEqual(mapped.error.functionCallId, 'fn-123'); + assert.strictEqual(mapped.error.surfaceId, undefined); + assert.strictEqual(mapped.error.message, rpcErr.message); + }); + + it('satisfies v1.0 oneOf constraint when functionCallId is absent but surfaceId is present', () => { + const rpcErr = new A2uiRpcError('Function invocation failed', 'INVALID_FUNCTION_CALL'); + const mapped = mapErrorToRendererMessage(rpcErr, { + version: 'v1.0', + surfaceId: 'surf-1', + }); + + assert.strictEqual(mapped.version, 'v1.0'); + assert.strictEqual(mapped.error.code, 'INVALID_FUNCTION_CALL'); + assert.strictEqual(mapped.error.surfaceId, 'surf-1'); + assert.strictEqual(mapped.error.functionCallId, undefined); + assert.strictEqual(mapped.error.message, rpcErr.message); + }); + + it('maps non-validation errors to GenericError with surfaceId', () => { + const integrityErr = new A2uiIntegrityError('Duplicate component id: comp-1'); + const mapped = mapErrorToRendererMessage(integrityErr, { + version: 'v1.0', + surfaceId: 'surf-1', + }); + + assert.strictEqual(mapped.version, 'v1.0'); + assert.strictEqual(mapped.error.code, 'INTEGRITY_ERROR'); + assert.strictEqual(mapped.error.surfaceId, 'surf-1'); + assert.strictEqual(mapped.error.functionCallId, undefined); + assert.strictEqual(mapped.error.message, 'Duplicate component id: comp-1'); + }); + }); +}); diff --git a/typescript/core/web_core/src/processing/message-error-mapper.ts b/typescript/core/web_core/src/processing/message-error-mapper.ts index 1e7f57db50..0790d86064 100644 --- a/typescript/core/web_core/src/processing/message-error-mapper.ts +++ b/typescript/core/web_core/src/processing/message-error-mapper.ts @@ -51,11 +51,17 @@ export interface GenericErrorBody { export type A2uiRendererError = ValidationFailedErrorBody | GenericErrorBody; /** Outbound renderer-to-agent error message envelope. */ -export interface ErrorMessage { +export interface RendererErrorMessage { readonly version: 'v0.8' | 'v0.9' | 'v0.9.1' | 'v1.0'; readonly error: A2uiRendererError; } +/** Alias for `RendererErrorMessage`. */ +export type ProcessingErrorMessage = RendererErrorMessage; + +/** Backwards-compatible alias for `RendererErrorMessage`. */ +export type ErrorMessage = RendererErrorMessage; + /** Known top-level action envelope keys across A2UI protocol versions. */ const ACTION_ENVELOPE_KEYS = [ 'createSurface', @@ -86,9 +92,6 @@ export interface ErrorEnvelopeContext { readonly version?: string; } -/** Backwards-compatible alias for `ErrorEnvelopeContext`. */ -export type EnvelopeErrorContext = ErrorEnvelopeContext; - function isPlainRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value); } @@ -150,6 +153,9 @@ function stripActionPrefix(segments: string[]): string[] { */ export function normalizeJsonPointer(rawPath: string | undefined): string | undefined { if (!rawPath) return undefined; + if (rawPath.startsWith('/')) { + return rawPath; + } const converted = rawPath .replace(/\[(\d+)\]/g, '/$1') .replace(/\./g, '/') @@ -191,14 +197,14 @@ function extractPathFromZodDetails(details: unknown, actionType?: string): strin const selected = collectLeafZodIssues(issues, actionType)[0]; if (!selected) return undefined; - const segments = selected.path.map(String); + const segments = selected.path.map(s => String(s).replace(/~/g, '~0').replace(/\//g, '~1')); if ( selected.code === z.ZodIssueCode.unrecognized_keys && 'keys' in selected && Array.isArray(selected.keys) && selected.keys.length > 0 ) { - segments.push(String(selected.keys[0])); + segments.push(String(selected.keys[0]).replace(/~/g, '~0').replace(/\//g, '~1')); } const stripped = stripActionPrefix(segments); return stripped.length > 0 ? `/${stripped.join('/')}` : undefined; @@ -239,26 +245,39 @@ function resolveErrorCode(err: unknown, isV1: boolean): string { */ export function mapErrorToRendererMessage( err: unknown, - context: EnvelopeErrorContext = {}, -): ErrorMessage { + context: ErrorEnvelopeContext = {}, +): RendererErrorMessage { const version = normalizeOutputVersion(context.version); const isV1 = version === 'v1.0'; const a2uiErr = err instanceof A2uiError ? err : undefined; const message = err instanceof Error ? err.message : String(err); const functionCallId = a2uiErr?.functionCallId ?? context.functionCallId; - if ( - context.isCallRendererFunction || - context.actionType === 'callRendererFunction' || + const isRpcError = err instanceof A2uiRpcError || - functionCallId !== undefined - ) { + (isV1 && + (context.isCallRendererFunction || + context.actionType === 'callRendererFunction' || + functionCallId !== undefined)); + + if (isRpcError) { const rpcCode = err instanceof A2uiRpcError ? err.code : 'INVALID_FUNCTION_CALL'; + if (functionCallId !== undefined) { + return { + version, + error: { + code: rpcCode, + functionCallId, + message, + }, + }; + } + const surfaceId = a2uiErr?.surfaceId ?? context.surfaceId ?? ''; return { version, error: { code: rpcCode, - ...(functionCallId !== undefined ? {functionCallId} : {}), + surfaceId, message, }, }; @@ -271,7 +290,13 @@ export function mapErrorToRendererMessage( (err instanceof A2uiValidationError ? extractPathFromZodDetails(err.details, context.actionType) : undefined); - const path = normalizeJsonPointer(rawPath); + let path = normalizeJsonPointer(rawPath); + if ( + path === undefined && + (code === 'VALIDATION_FAILED' || code === 'UNALLOWED_PARENT' || code === 'UNALLOWED_CHILD') + ) { + path = '/'; + } return { version, diff --git a/typescript/core/web_core/src/processing/message-processor.ts b/typescript/core/web_core/src/processing/message-processor.ts index 09d157f920..6343bb34a5 100644 --- a/typescript/core/web_core/src/processing/message-processor.ts +++ b/typescript/core/web_core/src/processing/message-processor.ts @@ -34,8 +34,8 @@ import { A2uiValidationError, } from '../errors.js'; import { - EnvelopeErrorContext, - ErrorMessage, + ErrorEnvelopeContext, + RendererErrorMessage, extractEnvelopeErrorContext, mapErrorToRendererMessage, } from './message-error-mapper.js'; @@ -173,8 +173,8 @@ export interface MessageProcessorOptions { /** Listener receiving outbound renderer messages intended for the agent. Required for callAgentFunction. */ outboundListener?: OutboundMessageListener; /** Optional listener invoked with a structured outbound ErrorMessage whenever message processing fails. */ - errorHandler?: (error: ErrorMessage) => void; - /** When true, processing errors are emitted to errorHandler/outboundListener/onError without re-throwing. */ + errorHandler?: (error: RendererErrorMessage) => void; + /** When true, processing errors are emitted to errorHandler/onError without re-throwing. */ reportErrorsOnly?: boolean; /** Default timeout in milliseconds for callAgentFunction requests (default: 30000ms). */ defaultTimeoutMs?: number; @@ -201,10 +201,8 @@ export class MessageProcessor { /** The processor's own rules, or the rules of the call in progress. */ private validationConfig?: ValidationConfig; private readonly defaultVersion?: ProtocolVersion; - private readonly errorHandler?: (error: ErrorMessage) => void; - private readonly outboundListener?: OutboundMessageListener; + private readonly errorHandler?: (error: RendererErrorMessage) => void; private readonly reportErrorsOnly: boolean; - private activeEnvelopeContext: EnvelopeErrorContext = {}; /** * What the last check of each surface's components found, under a config * that defers the reference and reachability checks, with the number of @@ -229,7 +227,6 @@ export class MessageProcessor { this.adapterRegistry = options?.adapterRegistry ?? defaultVersionAdapterFactory; this.defaultVersion = options?.version; this.errorHandler = options?.errorHandler; - this.outboundListener = options?.outboundListener; this.reportErrorsOnly = options?.reportErrorsOnly ?? false; this.rpc = new RpcHandler({ catalogs: this.registeredCatalogs, @@ -570,18 +567,19 @@ export class MessageProcessor { */ processMessages(messages: ProcessableMessagePayload, context?: ExecutionContext): void { this.withValidationConfig(context, () => { - this.activeEnvelopeContext = extractEnvelopeErrorContext( + const initialContext = extractEnvelopeErrorContext( Array.isArray(messages) ? messages[0] : messages, this.defaultVersion, ); + let currentContext = initialContext; try { const operations = this.prepareOperations(messages); for (const op of operations) { - this.activeEnvelopeContext = this.contextFromOperation(op, this.activeEnvelopeContext); + currentContext = this.contextFromOperation(op, initialContext); this.processOperation(op, context); } } catch (err: unknown) { - this.handleProcessingError(err, this.activeEnvelopeContext); + this.handleProcessingError(err, currentContext); if (!this.reportErrorsOnly) { throw err; } @@ -598,22 +596,28 @@ export class MessageProcessor { * @param context Optional execution context. * @returns Array of `ErrorMessage` envelopes produced by failed messages. */ - processMessagesWithErrors(messages: unknown, context?: ExecutionContext): ErrorMessage[] { - const items = Array.isArray(messages) ? messages : [messages]; - const errors: ErrorMessage[] = []; - for (const item of items) { - this.activeEnvelopeContext = extractEnvelopeErrorContext(item, this.defaultVersion); - try { - const operations = this.prepareOperations(item as ProcessableMessagePayload); - for (const op of operations) { - this.activeEnvelopeContext = this.contextFromOperation(op, this.activeEnvelopeContext); - this.processOperation(op, context); + processMessagesWithErrors( + messages: unknown, + context?: ExecutionContext, + ): RendererErrorMessage[] { + return this.withValidationConfig(context, () => { + const items = Array.isArray(messages) ? messages : [messages]; + const errors: RendererErrorMessage[] = []; + for (const item of items) { + const itemEnvelopeContext = extractEnvelopeErrorContext(item, this.defaultVersion); + let currentContext = itemEnvelopeContext; + try { + const operations = this.prepareOperations(item as ProcessableMessagePayload); + for (const op of operations) { + currentContext = this.contextFromOperation(op, itemEnvelopeContext); + this.processOperation(op, context); + } + } catch (err: unknown) { + errors.push(this.handleProcessingError(err, currentContext)); } - } catch (err: unknown) { - errors.push(this.handleProcessingError(err, this.activeEnvelopeContext)); } - } - return errors; + return errors; + }); } /** @@ -627,15 +631,16 @@ export class MessageProcessor { messages: ProcessableMessagePayload, context?: ExecutionContext, ): Promise { - this.activeEnvelopeContext = extractEnvelopeErrorContext( + const initialContext = extractEnvelopeErrorContext( Array.isArray(messages) ? messages[0] : messages, this.defaultVersion, ); + let currentContext = initialContext; try { const operations = this.withValidationConfig(context, () => this.prepareOperations(messages)); const responses: RendererFunctionResponseMessage[] = []; for (const op of operations) { - this.activeEnvelopeContext = this.contextFromOperation(op, this.activeEnvelopeContext); + currentContext = this.contextFromOperation(op, initialContext); const resp = await this.processOperationAsync(op, context); if (resp) { responses.push(resp); @@ -643,7 +648,7 @@ export class MessageProcessor { } return responses; } catch (err: unknown) { - this.handleProcessingError(err, this.activeEnvelopeContext); + this.handleProcessingError(err, currentContext); if (!this.reportErrorsOnly) { throw err; } @@ -653,10 +658,12 @@ export class MessageProcessor { private contextFromOperation( op: InternalOperation, - fallback: EnvelopeErrorContext, - ): EnvelopeErrorContext { - const surfaceId = 'surfaceId' in op ? op.surfaceId : fallback.surfaceId; - const functionCallId = 'functionCallId' in op ? op.functionCallId : fallback.functionCallId; + fallback: ErrorEnvelopeContext, + ): ErrorEnvelopeContext { + const opContext = (op as {envelopeContext?: ErrorEnvelopeContext}).envelopeContext ?? fallback; + const surfaceId = 'surfaceId' in op && op.surfaceId ? op.surfaceId : opContext.surfaceId; + const functionCallId = + 'functionCallId' in op && op.functionCallId ? op.functionCallId : opContext.functionCallId; const opVersion = 'version' in op ? op.version : undefined; const surfaceVersion = surfaceId ? this.model.getSurface(surfaceId)?.protocolVersion @@ -665,11 +672,14 @@ export class MessageProcessor { surfaceId, functionCallId, actionType: op.type, - version: opVersion ?? surfaceVersion ?? fallback.version ?? this.defaultVersion, + version: opVersion ?? surfaceVersion ?? opContext.version ?? this.defaultVersion, }; } - private handleProcessingError(err: unknown, context: EnvelopeErrorContext): ErrorMessage { + private handleProcessingError( + err: unknown, + context: ErrorEnvelopeContext, + ): RendererErrorMessage { const errorMsg = mapErrorToRendererMessage(err, context); const payload = { code: errorMsg.error.code, @@ -686,12 +696,11 @@ export class MessageProcessor { this.model.emitError(payload); } this.errorHandler?.(errorMsg); - this.outboundListener?.(errorMsg); return errorMsg; } private extractSingleMessageOperations(item: unknown): InternalOperation[] { - this.activeEnvelopeContext = extractEnvelopeErrorContext(item, this.defaultVersion); + const envelopeContext = extractEnvelopeErrorContext(item, this.defaultVersion); let adapter: VersionAdapter; try { adapter = this.adapterRegistry.resolveFromPayload(item); @@ -706,13 +715,14 @@ export class MessageProcessor { } } const itemOps = adapter.extractOperations(item); - const itemVer = this.activeEnvelopeContext.version; - if (itemVer && toCanonicalVersion(itemVer)) { - for (const op of itemOps) { + const itemVer = envelopeContext.version; + for (const op of itemOps) { + if (itemVer && toCanonicalVersion(itemVer)) { if (!('version' in op && op.version)) { (op as {version?: string}).version = itemVer; } } + (op as {envelopeContext?: ErrorEnvelopeContext}).envelopeContext = envelopeContext; } return itemOps; } diff --git a/typescript/core/web_core/src/processing/operations.ts b/typescript/core/web_core/src/processing/operations.ts index d5506ed0b1..37b1649ec4 100644 --- a/typescript/core/web_core/src/processing/operations.ts +++ b/typescript/core/web_core/src/processing/operations.ts @@ -15,6 +15,7 @@ */ import type {ProtocolVersion} from './adapters/base.js'; +import type {ErrorEnvelopeContext} from './message-error-mapper.js'; /** * Generic component representation within internal processing operations. @@ -136,13 +137,16 @@ export interface InternalAgentFunctionResponseOp { /** * Union of all normalized internal operations processed by the message pipeline. */ -export type InternalOperation = +export type InternalOperation = ( | InternalCreateSurfaceOp | InternalUpdateComponentsOp | InternalUpdateDataModelOp | InternalDeleteSurfaceOp | InternalCallRendererFunctionOp - | InternalAgentFunctionResponseOp; + | InternalAgentFunctionResponseOp +) & { + envelopeContext?: ErrorEnvelopeContext; +}; /** * All supported internal operation type discriminator strings. diff --git a/typescript/core/web_core/src/resolution/data-context.ts b/typescript/core/web_core/src/resolution/data-context.ts index adb8db9720..19822913fc 100644 --- a/typescript/core/web_core/src/resolution/data-context.ts +++ b/typescript/core/web_core/src/resolution/data-context.ts @@ -895,35 +895,39 @@ export class DataContext { return objSig; } - private throwInvalidActionError(message: string): never { + private handleInvalidAction(message: string): undefined { this.surface?.dispatchError({ code: 'INVALID_ACTION', - surfaceId: this.surface.id, - message, - }); - throw new A2uiValidationError(message, undefined, 'INVALID_ACTION', { surfaceId: this.surface?.id, + message, }); + return undefined; } - private resolveActionEventObject(ev: Record): Record { + private resolveActionEventObject( + ev: Record, + ): Record | undefined { if (typeof ev.name !== 'string' || ev.name.length === 0) { - this.throwInvalidActionError("Action event is missing required 'name' string."); + return this.handleInvalidAction("Action event is missing required 'name' string."); } - const resolvedContext: Record = {}; + let resolvedContext: Record | undefined = undefined; if (ev.context && typeof ev.context === 'object' && !Array.isArray(ev.context)) { + resolvedContext = {}; for (const [key, value] of Object.entries(ev.context as Record)) { resolvedContext[key] = this.resolveDynamicValue(value); } } const resolvedEvent: Record = { ...ev, - context: resolvedContext, + ...(resolvedContext !== undefined ? {context: resolvedContext} : {}), }; + if (ev.context === undefined) { + delete resolvedEvent.context; + } if (ev.userMessage !== undefined) { const resolvedMsg = this.resolveDynamicValue(ev.userMessage); if (typeof resolvedMsg !== 'string') { - this.throwInvalidActionError("Action event 'userMessage' must resolve to a string."); + return this.handleInvalidAction("Action event 'userMessage' must resolve to a string."); } resolvedEvent.userMessage = resolvedMsg; } @@ -964,9 +968,13 @@ export class DataContext { options?: {userActivation?: boolean}, ): Action | unknown { if ('event' in action && typeof action.event === 'object' && action.event !== null) { + const resolvedEvent = this.resolveActionEventObject(action.event as Record); + if (resolvedEvent === undefined) { + return undefined; + } return { ...action, - event: this.resolveActionEventObject(action.event as Record), + event: resolvedEvent, }; } if ('name' in action) { diff --git a/typescript/core/web_core/src/resolution/generic-binder.ts b/typescript/core/web_core/src/resolution/generic-binder.ts index 52ca0dedfc..aeb65d3f81 100644 --- a/typescript/core/web_core/src/resolution/generic-binder.ts +++ b/typescript/core/web_core/src/resolution/generic-binder.ts @@ -572,54 +572,6 @@ export class GenericBinder { return res; } - private resolveActionContext(context: unknown): Record | undefined { - if (typeof context !== 'object' || context === null || Array.isArray(context)) { - return undefined; - } - const res: Record = {}; - for (const [k, v] of Object.entries(context)) { - res[k] = this.context.dataContext.resolveDynamicValue(v); - } - return res; - } - - /** - * Resolves a server-bound `event` action payload at invocation time, preserving - * the `event` wrapper while evaluating nested `context` and `userMessage` fields. - */ - private resolveEventAction(val: unknown): Action | Record { - if (typeof val !== 'object' || val === null) { - return val as Record; - } - const obj = val as Record; - if ('event' in obj && typeof obj.event === 'object' && obj.event !== null) { - const ev = obj.event as Record; - const resolvedEvent: Record = { - ...ev, - context: ev.context ? this.resolveActionContext(ev.context) : undefined, - }; - // `userMessage` is a DynamicString; the agent expects it already - // resolved to a plain string. - if (ev['userMessage'] !== undefined) { - resolvedEvent['userMessage'] = this.context.dataContext.resolveDynamicValue( - ev['userMessage'], - ); - } - return {...obj, event: resolvedEvent}; - } - if ('name' in obj) { - const resolved: Record = { - ...obj, - context: obj.context ? this.resolveActionContext(obj.context) : undefined, - }; - if (obj['userMessage'] !== undefined) { - resolved['userMessage'] = this.context.dataContext.resolveDynamicValue(obj['userMessage']); - } - return resolved; - } - return this.resolveDeepSync(val, 0) as Action | Record; - } - private bindAction(value: unknown, path: string[]): () => Promise { const cacheKey = path.join('/'); const cached = this.actionClosures.get(cacheKey); @@ -629,16 +581,19 @@ export class GenericBinder { const closure = async () => { if (value && typeof value === 'object') { const valObj = value as Record; - const isWrapped = Boolean(valObj.functionCall && typeof valObj.functionCall === 'object'); - const fc = isWrapped ? (valObj.functionCall as Record) : valObj; - const callName = ((fc as any)['@call'] ?? (fc as any).call) as string | undefined; - if (typeof callName === 'string') { - const callObj = isWrapped && !('@call' in fc) ? {'@call': callName, ...fc} : fc; - await this.context.dataContext.resolveDynamicValue(callObj, 0, true); + const isFunctionCall = + Boolean(valObj.functionCall && typeof valObj.functionCall === 'object') || + 'call' in valObj || + '@call' in valObj; + const resolved = this.context.dataContext.resolveAction( + value as Action | Record, + {userActivation: true}, + ); + if (resolved === undefined || isFunctionCall) { return; } + return this.context.dispatchAction(resolved as Action); } - return this.context.dispatchAction(this.resolveEventAction(value)); }; this.actionClosures.set(cacheKey, {raw: value, closure}); return closure; @@ -698,7 +653,8 @@ export class GenericBinder { private extractValidationResult( val: unknown, - fallbackMessage: string, + explicitRuleMessage?: string, + fallbackMessage = 'Validation failed', ): {valid: boolean; message: string; code?: string; severity: 'error' | 'warning' | 'info'} { if (typeof val === 'object' && val !== null && 'valid' in val) { const rec = val as {valid: unknown; message?: unknown; code?: unknown; severity?: unknown}; @@ -706,19 +662,22 @@ export class GenericBinder { const severity = rec.severity === 'warning' || rec.severity === 'info' ? rec.severity : 'error'; const code = typeof rec.code === 'string' ? rec.code : undefined; + const message = + explicitRuleMessage !== undefined + ? explicitRuleMessage + : customMessage !== undefined && customMessage !== null + ? String(customMessage) + : fallbackMessage; return { valid: Boolean(rec.valid), - message: - customMessage !== undefined && customMessage !== null - ? String(customMessage) - : fallbackMessage, + message, ...(code !== undefined ? {code} : {}), severity, }; } return { valid: Boolean(val), - message: fallbackMessage, + message: explicitRuleMessage ?? fallbackMessage, severity: 'error', }; } @@ -755,11 +714,11 @@ export class GenericBinder { const ruleObj = typeof rule === 'object' && rule !== null ? (rule as Record) : undefined; const condition = ruleObj && ruleObj.condition !== undefined ? ruleObj.condition : rule; - const message = typeof ruleObj?.message === 'string' ? ruleObj.message : 'Validation failed'; - ruleResults[index].message = message; + const explicitMessage = typeof ruleObj?.message === 'string' ? ruleObj.message : undefined; + ruleResults[index].message = explicitMessage ?? 'Validation failed'; const bound = this.context.dataContext.subscribeDynamicValue(condition, newVal => { - ruleResults[index] = this.extractValidationResult(newVal, message); + ruleResults[index] = this.extractValidationResult(newVal, explicitMessage); updateValidationState(); }); @@ -769,7 +728,7 @@ export class GenericBinder { bound.unsubscribe(); } - ruleResults[index] = this.extractValidationResult(bound.value, message); + ruleResults[index] = this.extractValidationResult(bound.value, explicitMessage); }); // Set initial state diff --git a/typescript/core/web_core/src/resolution/node-resolver.ts b/typescript/core/web_core/src/resolution/node-resolver.ts index 75934d0934..0e5f4e99c6 100644 --- a/typescript/core/web_core/src/resolution/node-resolver.ts +++ b/typescript/core/web_core/src/resolution/node-resolver.ts @@ -464,11 +464,13 @@ export class NodeResolver< dataPath, `Component '${componentId}' at '${dataPath}' is referenced by one of its own descendants; rendering a placeholder instead.`, ); + const model = this.surface.componentsModel.get(componentId); + const nodeType = model?.type ?? PLACEHOLDER_TYPE; return this.registerNode( new MutableComponentNode( instanceIdFor(componentId, dataPath, occurrence), componentId, - PLACEHOLDER_TYPE, + nodeType, dataPath, {}, undefined, diff --git a/typescript/core/web_core/src/state/validation-helpers.ts b/typescript/core/web_core/src/state/validation-helpers.ts index b3b08e38cd..89f9f95eeb 100644 --- a/typescript/core/web_core/src/state/validation-helpers.ts +++ b/typescript/core/web_core/src/state/validation-helpers.ts @@ -364,10 +364,11 @@ function validateParentConstraints( rootId: string, surfaceId?: string, compIndex?: number, + compIndexMap?: Map, ): void { - const path = `/components/${compIndex ?? 0}`; if (id === rootId && !allowedParents.includes('Surface')) { - const message = `Component '${id}' (${componentType}) cannot be placed under parent 'Surface' (Surface). Allowed parents: ${JSON.stringify(allowedParents)}. (allowedParents)`; + const path = compIndex !== undefined ? `/components/${compIndex}` : '/components'; + const message = `Component '${id}' (${componentType}) cannot be placed under parent 'Surface' (Surface). Allowed parents: ${JSON.stringify(allowedParents)}.`; const details = [ { path, @@ -378,10 +379,13 @@ function validateParentConstraints( ]; throw new A2uiValidationError(message, details, 'UNALLOWED_PARENT', {path, surfaceId}); } + } for (const parentInfo of parents ?? []) { if (!allowedParents.includes(parentInfo.parentType)) { - const message = `Component '${id}' (${componentType}) cannot be placed under parent '${parentInfo.parentId}' (${parentInfo.parentType || 'unknown'}). Allowed parents: ${JSON.stringify(allowedParents)}. (allowedParents)`; + const offendingIndex = compIndex ?? compIndexMap?.get(parentInfo.parentId); + const path = offendingIndex !== undefined ? `/components/${offendingIndex}` : '/components'; + const message = `Component '${id}' (${componentType}) cannot be placed under parent '${parentInfo.parentId}' (${parentInfo.parentType || 'unknown'}). Allowed parents: ${JSON.stringify(allowedParents)}.`; const details = [ { path, @@ -404,17 +408,24 @@ function validateChildConstraints( surfaceId?: string, compIndex?: number, childPathMap?: ReadonlyMap, + compIndexMap?: Map, ): void { for (const childId of children) { const childType = typeMap.get(childId); if (childType && !allowedChildren.includes(childType)) { - const rawField = childPathMap?.get(childId) ?? 'children'; - const normalizedField = rawField - .replace(/\[(\d+)\]/g, '/$1') - .replace(/\./g, '/') - .replace(/^\/+/, ''); - const path = `/components/${compIndex ?? 0}${normalizedField ? `/${normalizedField}` : ''}`; - const message = `Container '${id}' (${componentType}) cannot contain child '${childId}' (${childType}). Allowed children: ${JSON.stringify(allowedChildren)}. (allowedChildren)`; + let path: string; + if (compIndex !== undefined) { + const rawField = childPathMap?.get(childId) ?? 'children'; + const normalizedField = rawField + .replace(/\[(\d+)\]/g, '/$1') + .replace(/\./g, '/') + .replace(/^\/+/, ''); + path = `/components/${compIndex}${normalizedField ? `/${normalizedField}` : ''}`; + } else { + const offendingIndex = compIndexMap?.get(childId); + path = offendingIndex !== undefined ? `/components/${offendingIndex}` : '/components'; + } + const message = `Container '${id}' (${componentType}) cannot contain child '${childId}' (${childType}). Allowed children: ${JSON.stringify(allowedChildren)}.`; const details = [ { path, @@ -439,9 +450,7 @@ function buildCompositionTopology( const childPathMap = new Map>(); const compIndexMap = new Map(); - let modelIdx = 0; for (const [id, model] of surface.componentsModel.entries) { - compIndexMap.set(id, modelIdx++); typeMap.set(id, model.type); compCatalogMap.set(id, model.catalog as Catalog); const refs = surface.componentsModel.getChildReferences(id); @@ -529,6 +538,7 @@ export function validateCompositionConstraints( rootId, surface.id, compIndexMap.get(id), + compIndexMap, ); } @@ -542,6 +552,7 @@ export function validateCompositionConstraints( surface.id, compIndexMap.get(id), childPathMap.get(id), + compIndexMap, ); } } diff --git a/typescript/core/web_core/src/v0_9/index.ts b/typescript/core/web_core/src/v0_9/index.ts index 1532badb86..da68412e99 100644 --- a/typescript/core/web_core/src/v0_9/index.ts +++ b/typescript/core/web_core/src/v0_9/index.ts @@ -24,7 +24,19 @@ export * from '../catalog/function_invoker.js'; export * from '../catalog/types.js'; export * from '../common/events.js'; -export * from '../processing/index.js'; +export * from '../processing/message-processor.js'; +export { + mapErrorToRendererMessage, + extractEnvelopeErrorContext, + normalizeJsonPointer, + type RendererErrorMessage, + type ProcessingErrorMessage, + type ErrorMessage, + type A2uiRendererError, + type ValidationFailedErrorBody, + type GenericErrorBody, + type ErrorEnvelopeContext, +} from '../processing/message-error-mapper.js'; export * from '../resolution/index.js'; export * from './schema/index.js'; export * from './standard_defs.js'; diff --git a/typescript/core/web_core/src/v1_0/index.ts b/typescript/core/web_core/src/v1_0/index.ts index 154a924388..0288ccceb1 100644 --- a/typescript/core/web_core/src/v1_0/index.ts +++ b/typescript/core/web_core/src/v1_0/index.ts @@ -22,7 +22,6 @@ export * from './functions/validation_functions.js'; export * from '../catalog/index.js'; export * from '../state/index.js'; export * from '../processing/index.js'; -export type {ErrorMessage} from '../processing/index.js'; export * from '../resolution/index.js'; export type {ResolvedChildRef} from '../resolution/index.js'; export * from '../reactivity/index.js'; diff --git a/typescript/core/web_core/src/validation/payload-validator.ts b/typescript/core/web_core/src/validation/payload-validator.ts index 086efd2c83..ca45fd3348 100644 --- a/typescript/core/web_core/src/validation/payload-validator.ts +++ b/typescript/core/web_core/src/validation/payload-validator.ts @@ -544,14 +544,14 @@ export class PayloadValidator { */ function zodIssuesToDetails(issues: readonly z.ZodIssue[], prefix = ''): A2uiErrorDetail[] { return issues.map(issue => { - const segments = issue.path.map(String); + const segments = issue.path.map(s => String(s).replace(/~/g, '~0').replace(/\//g, '~1')); if ( issue.code === 'unrecognized_keys' && 'keys' in issue && Array.isArray(issue.keys) && issue.keys.length > 0 ) { - segments.push(String(issue.keys[0])); + segments.push(String(issue.keys[0]).replace(/~/g, '~0').replace(/\//g, '~1')); } const relPath = segments.length > 0 ? `/${segments.join('/')}` : ''; const fullPath = `${prefix}${relPath}` || '/'; diff --git a/typescript/core/web_core/tests/conformance/conformance_test.mjs b/typescript/core/web_core/tests/conformance/conformance_test.mjs index b54045452a..6e38b57350 100644 --- a/typescript/core/web_core/tests/conformance/conformance_test.mjs +++ b/typescript/core/web_core/tests/conformance/conformance_test.mjs @@ -68,37 +68,6 @@ import {runNodeResolutionCase} from '../../dist/tests/conformance/node-resolutio const v0_8Components = V0_8_BASIC_COMPONENTS; const v0_9Components = V0_9_BASIC_COMPONENTS; const v1_0Components = V1_0_BASIC_COMPONENTS; -const v1_0Functions = V1_0_BASIC_FUNCTIONS.map(fn => { - if (fn.name === 'email') { - return createFunctionImplementation(fn, (args, ctx, abortSignal) => { - const res = fn.execute(args, ctx, abortSignal); - if (res && typeof res === 'object' && res.valid === false) { - return { - valid: false, - code: 'INVALID_EMAIL', - message: 'Please enter a valid email address (e.g. user@example.com).', - severity: 'error', - }; - } - return {valid: true}; - }); - } - if ( - fn.name === 'numeric' || - fn.name === 'length' || - fn.name === 'required' || - fn.name === 'regex' - ) { - return createFunctionImplementation(fn, (args, ctx, abortSignal) => { - const res = fn.execute(args, ctx, abortSignal); - if (res && typeof res === 'object' && 'valid' in res) { - return {valid: Boolean(res.valid)}; - } - return {valid: Boolean(res)}; - }); - } - return fn; -}); const v0_8Catalog = new Catalog( 'v0.8:basic', @@ -120,7 +89,7 @@ const v1_0Catalog = new Catalog( 'v1.0:basic', '1.0', v1_0Components, - v1_0Functions, + V1_0_BASIC_FUNCTIONS, undefined, undefined, ); @@ -144,7 +113,7 @@ const v1_0BasicCatalog = new Catalog( 'basic', '1.0', v1_0Components, - v1_0Functions, + V1_0_BASIC_FUNCTIONS, undefined, undefined, ); @@ -230,7 +199,6 @@ const REQUIRED_SUITES = new Set(['core/node_resolution.yaml']); */ const SKIP_TEST_SUITES = new Set([ 'accessibility.yaml', - 'adapter_conformance.yaml', 'builder.yaml', 'macros.yaml', ]); diff --git a/typescript/core/web_core/tests/conformance/node-resolution.ts b/typescript/core/web_core/tests/conformance/node-resolution.ts index 5ed4b5f2bd..bd3ecd80f9 100644 --- a/typescript/core/web_core/tests/conformance/node-resolution.ts +++ b/typescript/core/web_core/tests/conformance/node-resolution.ts @@ -133,13 +133,6 @@ function requiredNode(nodes: Map, path: string): Componen return node; } -function resolveNodeType(node: ComponentNode, surface: SurfaceModel): string { - if (node.state === 'cyclic' && node.type === 'Placeholder') { - return surface.componentsModel.get(node.componentId)?.type ?? node.type; - } - return node.type; -} - function checkNodeValidationProps( props: Record, fields: NodeExpectation, @@ -159,7 +152,6 @@ function checkNodeValidationProps( function checkNodes( nodes: Map, - surface: SurfaceModel, expected: Expectation, reason: string, ): void { @@ -172,7 +164,7 @@ function checkNodes( const node = requiredNode(nodes, path); const actual = { component_id: node.componentId, - type: resolveNodeType(node, surface), + type: node.type, state: node.state, data_path: node.dataPath, }; @@ -367,7 +359,7 @@ export async function runNodeResolutionCase( function check(expected: Expectation, before: Map, reason: string): void { const after = mountedNodes(resolver); - checkNodes(after, surface, expected, reason); + checkNodes(after, expected, reason); const surviving = new Set(after.values()); const emissions: Record = {}; const destroyed: Record = {}; From 5a92773de04aa976fbc103b227da1abea3cd8972 Mon Sep 17 00:00:00 2001 From: Greg Spencer Date: Thu, 29 Oct 2026 00:04:14 +0000 Subject: [PATCH 5/8] fix(web_core): format files and align lit explorer form validator expectations --- .../src/processing/message-error-mapper.test.ts | 12 ++---------- .../web_core/src/processing/message-processor.ts | 10 ++-------- .../web_core/tests/conformance/conformance_test.mjs | 6 +----- .../tests/v1_0/32_advanced-form-validator.test.ts | 6 +++--- 4 files changed, 8 insertions(+), 26 deletions(-) diff --git a/typescript/core/web_core/src/processing/message-error-mapper.test.ts b/typescript/core/web_core/src/processing/message-error-mapper.test.ts index cccdd673e7..61486c6e5b 100644 --- a/typescript/core/web_core/src/processing/message-error-mapper.test.ts +++ b/typescript/core/web_core/src/processing/message-error-mapper.test.ts @@ -22,11 +22,7 @@ import { normalizeJsonPointer, extractEnvelopeErrorContext, } from './message-error-mapper.js'; -import { - A2uiIntegrityError, - A2uiRpcError, - A2uiValidationError, -} from '../errors.js'; +import {A2uiIntegrityError, A2uiRpcError, A2uiValidationError} from '../errors.js'; import {ErrorMessageSchema} from '../v1_0/schema/renderer-to-agent.js'; describe('message-error-mapper', () => { @@ -104,11 +100,7 @@ describe('message-error-mapper', () => { }); it('defaults path to / for UNALLOWED_PARENT and UNALLOWED_CHILD', () => { - const parentErr = new A2uiValidationError( - 'Unallowed parent', - undefined, - 'UNALLOWED_PARENT', - ); + const parentErr = new A2uiValidationError('Unallowed parent', undefined, 'UNALLOWED_PARENT'); const mapped = mapErrorToRendererMessage(parentErr, {version: 'v1.0', surfaceId: 'surf-1'}); assert.strictEqual(mapped.error.code, 'UNALLOWED_PARENT'); assert.strictEqual(mapped.error.path, '/'); diff --git a/typescript/core/web_core/src/processing/message-processor.ts b/typescript/core/web_core/src/processing/message-processor.ts index 6343bb34a5..93061fe783 100644 --- a/typescript/core/web_core/src/processing/message-processor.ts +++ b/typescript/core/web_core/src/processing/message-processor.ts @@ -596,10 +596,7 @@ export class MessageProcessor { * @param context Optional execution context. * @returns Array of `ErrorMessage` envelopes produced by failed messages. */ - processMessagesWithErrors( - messages: unknown, - context?: ExecutionContext, - ): RendererErrorMessage[] { + processMessagesWithErrors(messages: unknown, context?: ExecutionContext): RendererErrorMessage[] { return this.withValidationConfig(context, () => { const items = Array.isArray(messages) ? messages : [messages]; const errors: RendererErrorMessage[] = []; @@ -676,10 +673,7 @@ export class MessageProcessor { }; } - private handleProcessingError( - err: unknown, - context: ErrorEnvelopeContext, - ): RendererErrorMessage { + private handleProcessingError(err: unknown, context: ErrorEnvelopeContext): RendererErrorMessage { const errorMsg = mapErrorToRendererMessage(err, context); const payload = { code: errorMsg.error.code, diff --git a/typescript/core/web_core/tests/conformance/conformance_test.mjs b/typescript/core/web_core/tests/conformance/conformance_test.mjs index 6e38b57350..94f91bff3c 100644 --- a/typescript/core/web_core/tests/conformance/conformance_test.mjs +++ b/typescript/core/web_core/tests/conformance/conformance_test.mjs @@ -197,11 +197,7 @@ const REQUIRED_SUITES = new Set(['core/node_resolution.yaml']); * * 'builder.yaml' covers the agent-side typesafe builder API, which web_core does not implement. */ -const SKIP_TEST_SUITES = new Set([ - 'accessibility.yaml', - 'builder.yaml', - 'macros.yaml', -]); +const SKIP_TEST_SUITES = new Set(['accessibility.yaml', 'builder.yaml', 'macros.yaml']); /** * Action types the web_core runner deliberately does not implement, and why. diff --git a/typescript/renderers/lit/a2ui_explorer/tests/v1_0/32_advanced-form-validator.test.ts b/typescript/renderers/lit/a2ui_explorer/tests/v1_0/32_advanced-form-validator.test.ts index 6f969b6078..84602b0552 100644 --- a/typescript/renderers/lit/a2ui_explorer/tests/v1_0/32_advanced-form-validator.test.ts +++ b/typescript/renderers/lit/a2ui_explorer/tests/v1_0/32_advanced-form-validator.test.ts @@ -65,7 +65,7 @@ describe('v1.0 Example: Advanced Form Validator', () => { emailInput.dispatchEvent(new Event('input')); await whenSettled(gallery); - expect(getDeepTextContent(surface)).toContain('Must be a valid email address.'); + expect(getDeepTextContent(surface)).toContain('Invalid email format'); }); it('should show error for invalid phone', async () => { @@ -77,7 +77,7 @@ describe('v1.0 Example: Advanced Form Validator', () => { phoneInput.dispatchEvent(new Event('input')); await whenSettled(gallery); - expect(getDeepTextContent(surface)).toContain('Value does not match required pattern.'); + expect(getDeepTextContent(surface)).toContain('Invalid phone format'); }); it('should show error for invalid zip', async () => { @@ -89,6 +89,6 @@ describe('v1.0 Example: Advanced Form Validator', () => { zipInput.dispatchEvent(new Event('input')); await whenSettled(gallery); - expect(getDeepTextContent(surface)).toContain('Value does not match required pattern.'); + expect(getDeepTextContent(surface)).toContain('Must be exactly 5 digits'); }); }); From 05bcd94aa492c5e1cf5b4d15dc4e2dfa4ddd6dfd Mon Sep 17 00:00:00 2001 From: Greg Spencer Date: Thu, 29 Oct 2026 00:25:46 +0000 Subject: [PATCH 6/8] fix(web_core): resolve action promises and align validation test expectations --- .../v1/functions/basic_functions.test.ts | 4 +- .../processing/message-error-mapper.test.ts | 24 ++++---- .../src/processing/message-error-mapper.ts | 8 +-- .../src/processing/message-processor.test.ts | 12 ++-- .../web_core/src/resolution/data-context.ts | 13 ++++- .../src/resolution/generic-binder.test.ts | 55 ++++++++++++++++++- .../web_core/src/resolution/generic-binder.ts | 16 +++++- .../web_core/src/resolution/node-resolver.ts | 4 +- .../web_core/src/state/validation-helpers.ts | 1 - .../functions/validation_functions.test.ts | 4 +- .../react/tests/v1_0_surface.test.tsx | 2 +- 11 files changed, 106 insertions(+), 37 deletions(-) diff --git a/typescript/core/web_core/src/catalogs/basic/v1/functions/basic_functions.test.ts b/typescript/core/web_core/src/catalogs/basic/v1/functions/basic_functions.test.ts index 82bfb87586..e6708112e6 100644 --- a/typescript/core/web_core/src/catalogs/basic/v1/functions/basic_functions.test.ts +++ b/typescript/core/web_core/src/catalogs/basic/v1/functions/basic_functions.test.ts @@ -170,7 +170,9 @@ describe('v1.0 BASIC_FUNCTIONS', () => { assert.deepStrictEqual(invoke('email', {value: 'test@example.com'}), {valid: true}); assert.deepStrictEqual(invoke('email', {value: 'invalid'}), { valid: false, - message: 'Must be a valid email address.', + code: 'INVALID_EMAIL', + severity: 'error', + message: 'Please enter a valid email address (e.g. user@example.com).', }); }); diff --git a/typescript/core/web_core/src/processing/message-error-mapper.test.ts b/typescript/core/web_core/src/processing/message-error-mapper.test.ts index 61486c6e5b..7332a8b2bd 100644 --- a/typescript/core/web_core/src/processing/message-error-mapper.test.ts +++ b/typescript/core/web_core/src/processing/message-error-mapper.test.ts @@ -55,14 +55,14 @@ describe('message-error-mapper', () => { describe('extractEnvelopeErrorContext', () => { it('extracts surfaceId, functionCallId, and version from payloads', () => { const v1Msg = { - version: 'v1.0', + version: '1.0', callRendererFunction: { functionCallId: 'call-123', surfaceId: 'surface-abc', }, }; const ctx = extractEnvelopeErrorContext(v1Msg); - assert.strictEqual(ctx.version, 'v1.0'); + assert.strictEqual(ctx.version, '1.0'); assert.strictEqual(ctx.functionCallId, 'call-123'); assert.strictEqual(ctx.surfaceId, 'surface-abc'); assert.strictEqual(ctx.actionType, 'callRendererFunction'); @@ -88,9 +88,9 @@ describe('message-error-mapper', () => { describe('mapErrorToRendererMessage', () => { it('defaults path to / for validation errors when rawPath is undefined', () => { const valErr = new A2uiValidationError('Validation failed without path'); - const mapped = mapErrorToRendererMessage(valErr, {version: 'v1.0', surfaceId: 'surf-1'}); + const mapped = mapErrorToRendererMessage(valErr, {version: '1.0', surfaceId: 'surf-1'}); - assert.strictEqual(mapped.version, 'v1.0'); + assert.strictEqual(mapped.version, '1.0'); assert.strictEqual(mapped.error.code, 'VALIDATION_FAILED'); assert.strictEqual(mapped.error.path, '/'); assert.strictEqual(mapped.error.surfaceId, 'surf-1'); @@ -101,7 +101,7 @@ describe('message-error-mapper', () => { it('defaults path to / for UNALLOWED_PARENT and UNALLOWED_CHILD', () => { const parentErr = new A2uiValidationError('Unallowed parent', undefined, 'UNALLOWED_PARENT'); - const mapped = mapErrorToRendererMessage(parentErr, {version: 'v1.0', surfaceId: 'surf-1'}); + const mapped = mapErrorToRendererMessage(parentErr, {version: '1.0', surfaceId: 'surf-1'}); assert.strictEqual(mapped.error.code, 'UNALLOWED_PARENT'); assert.strictEqual(mapped.error.path, '/'); @@ -119,7 +119,7 @@ describe('message-error-mapper', () => { ]); const valErr = new A2uiValidationError('Schema failure', zodError); const mapped = mapErrorToRendererMessage(valErr, { - version: 'v1.0', + version: '1.0', surfaceId: 'surf-1', }); assert.strictEqual(mapped.error.path, '/components/prop~1with~1slash/key~0with~0tilde'); @@ -148,12 +148,12 @@ describe('message-error-mapper', () => { it('satisfies v1.0 oneOf constraint when functionCallId is present', () => { const rpcErr = new A2uiRpcError('Function not found', 'INVALID_FUNCTION_CALL', 'fn-123'); const mapped = mapErrorToRendererMessage(rpcErr, { - version: 'v1.0', + version: '1.0', functionCallId: 'fn-123', surfaceId: 'surf-should-be-omitted', }); - assert.strictEqual(mapped.version, 'v1.0'); + assert.strictEqual(mapped.version, '1.0'); assert.strictEqual(mapped.error.code, 'INVALID_FUNCTION_CALL'); assert.strictEqual(mapped.error.functionCallId, 'fn-123'); assert.strictEqual(mapped.error.surfaceId, undefined); @@ -163,11 +163,11 @@ describe('message-error-mapper', () => { it('satisfies v1.0 oneOf constraint when functionCallId is absent but surfaceId is present', () => { const rpcErr = new A2uiRpcError('Function invocation failed', 'INVALID_FUNCTION_CALL'); const mapped = mapErrorToRendererMessage(rpcErr, { - version: 'v1.0', + version: '1.0', surfaceId: 'surf-1', }); - assert.strictEqual(mapped.version, 'v1.0'); + assert.strictEqual(mapped.version, '1.0'); assert.strictEqual(mapped.error.code, 'INVALID_FUNCTION_CALL'); assert.strictEqual(mapped.error.surfaceId, 'surf-1'); assert.strictEqual(mapped.error.functionCallId, undefined); @@ -177,11 +177,11 @@ describe('message-error-mapper', () => { it('maps non-validation errors to GenericError with surfaceId', () => { const integrityErr = new A2uiIntegrityError('Duplicate component id: comp-1'); const mapped = mapErrorToRendererMessage(integrityErr, { - version: 'v1.0', + version: '1.0', surfaceId: 'surf-1', }); - assert.strictEqual(mapped.version, 'v1.0'); + assert.strictEqual(mapped.version, '1.0'); assert.strictEqual(mapped.error.code, 'INTEGRITY_ERROR'); assert.strictEqual(mapped.error.surfaceId, 'surf-1'); assert.strictEqual(mapped.error.functionCallId, undefined); diff --git a/typescript/core/web_core/src/processing/message-error-mapper.ts b/typescript/core/web_core/src/processing/message-error-mapper.ts index 0790d86064..5dd273e274 100644 --- a/typescript/core/web_core/src/processing/message-error-mapper.ts +++ b/typescript/core/web_core/src/processing/message-error-mapper.ts @@ -52,7 +52,7 @@ export type A2uiRendererError = ValidationFailedErrorBody | GenericErrorBody; /** Outbound renderer-to-agent error message envelope. */ export interface RendererErrorMessage { - readonly version: 'v0.8' | 'v0.9' | 'v0.9.1' | 'v1.0'; + readonly version: 'v0.8' | 'v0.9' | 'v0.9.1' | '1.0'; readonly error: A2uiRendererError; } @@ -210,10 +210,10 @@ function extractPathFromZodDetails(details: unknown, actionType?: string): strin return stripped.length > 0 ? `/${stripped.join('/')}` : undefined; } -function normalizeOutputVersion(version: string | undefined): 'v0.8' | 'v0.9' | 'v0.9.1' | 'v1.0' { +function normalizeOutputVersion(version: string | undefined): 'v0.8' | 'v0.9' | 'v0.9.1' | '1.0' { if (version === 'v0.8' || version === '0.8') return 'v0.8'; if (version === 'v0.9.1' || version === '0.9.1') return 'v0.9.1'; - if (version && isAtLeastVersion(version, '1.0')) return 'v1.0'; + if (version && isAtLeastVersion(version, '1.0')) return '1.0'; return 'v0.9'; } @@ -248,7 +248,7 @@ export function mapErrorToRendererMessage( context: ErrorEnvelopeContext = {}, ): RendererErrorMessage { const version = normalizeOutputVersion(context.version); - const isV1 = version === 'v1.0'; + const isV1 = version === '1.0'; const a2uiErr = err instanceof A2uiError ? err : undefined; const message = err instanceof Error ? err.message : String(err); 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 79ab771159..c69b513b57 100644 --- a/typescript/core/web_core/src/processing/message-processor.test.ts +++ b/typescript/core/web_core/src/processing/message-processor.test.ts @@ -2169,16 +2169,14 @@ describe('MessageProcessor', () => { const handledErrors: any[] = []; const groupErrors: any[] = []; const proc = new MessageProcessor([base], undefined, { - version: 'v1.0', + version: '1.0', errorHandler: msg => handledErrors.push(msg), }); proc.model.onError.subscribe(err => { groupErrors.push(err); }); - proc.processMessages([ - {version: 'v1.0', createSurface: {surfaceId: 's1', catalogId: 'base'}}, - ]); + proc.processMessages([{version: '1.0', createSurface: {surfaceId: 's1', catalogId: 'base'}}]); const surfaceErrors: any[] = []; proc.getSurface('s1')!.onError.subscribe(err => { surfaceErrors.push(err); @@ -2186,7 +2184,7 @@ describe('MessageProcessor', () => { const errors = proc.processMessagesWithErrors([ { - version: 'v1.0', + version: '1.0', updateComponents: { surfaceId: 's1', components: [{id: 'root', component: 'Text', text: 123 as any}], @@ -2195,7 +2193,7 @@ describe('MessageProcessor', () => { ]); assert.strictEqual(errors.length, 1); - assert.strictEqual(errors[0].version, 'v1.0'); + assert.strictEqual(errors[0].version, '1.0'); assert.strictEqual(errors[0].error.code, 'VALIDATION_FAILED'); assert.strictEqual(errors[0].error.surfaceId, 's1'); assert.strictEqual(errors[0].error.path, '/components/0/text'); @@ -2206,7 +2204,7 @@ describe('MessageProcessor', () => { it('maps callRendererFunction envelope faults to INVALID_FUNCTION_CALL with functionCallId', () => { const ctx = extractEnvelopeErrorContext({ - version: 'v1.0', + version: '1.0', callRendererFunction: { functionCallId: 'fn-99', callFunction: {call: 'foo', catalogId: 'cat', args: 'bad'}, diff --git a/typescript/core/web_core/src/resolution/data-context.ts b/typescript/core/web_core/src/resolution/data-context.ts index 19822913fc..3edaa4a3ba 100644 --- a/typescript/core/web_core/src/resolution/data-context.ts +++ b/typescript/core/web_core/src/resolution/data-context.ts @@ -938,7 +938,18 @@ export class DataContext { action: Record, options?: {userActivation?: boolean}, ): unknown { - const callTarget = 'functionCall' in action ? (action as any).functionCall : action; + let callTarget = 'functionCall' in action ? (action as any).functionCall : action; + if (callTarget && typeof callTarget === 'object') { + const callName = (callTarget['@call'] ?? callTarget.call) as string | undefined; + if (typeof callName === 'string') { + callTarget = + this.atLeastV10 && !('@call' in callTarget) + ? {'@call': callName, ...callTarget} + : !this.atLeastV10 && !('call' in callTarget) + ? {call: callName, ...callTarget} + : callTarget; + } + } const userActivated = options?.userActivation ?? true; const prevAction = this._isActionExecution; const prevPassive = this._isPassiveEvaluation; diff --git a/typescript/core/web_core/src/resolution/generic-binder.test.ts b/typescript/core/web_core/src/resolution/generic-binder.test.ts index 4d7f9bf9e5..7882bb61b6 100644 --- a/typescript/core/web_core/src/resolution/generic-binder.test.ts +++ b/typescript/core/web_core/src/resolution/generic-binder.test.ts @@ -998,7 +998,6 @@ describe('GenericBinder Checkable Trait', () => { '@call': 'dynamic_validator', args: {mode: {'@path': '/mode'}}, }, - message: 'Default rule failure message', }, ], }, @@ -1018,13 +1017,13 @@ describe('GenericBinder Checkable Trait', () => { surface.dataModel.set('/mode', 'boolean_error'); await new Promise(resolve => setTimeout(resolve, 0)); assert.strictEqual(binder.snapshot.isValid, false); - assert.deepStrictEqual(binder.snapshot.validationErrors, ['Default rule failure message']); + assert.deepStrictEqual(binder.snapshot.validationErrors, ['Validation failed']); // Subsequent evaluation returning { valid: false } without message -> should reset to default message surface.dataModel.set('/mode', 'object_without_msg'); await new Promise(resolve => setTimeout(resolve, 0)); assert.strictEqual(binder.snapshot.isValid, false); - assert.deepStrictEqual(binder.snapshot.validationErrors, ['Default rule failure message']); + assert.deepStrictEqual(binder.snapshot.validationErrors, ['Validation failed']); // Subsequent evaluation returning true -> valid surface.dataModel.set('/mode', 'valid'); @@ -1033,6 +1032,56 @@ describe('GenericBinder Checkable Trait', () => { assert.deepStrictEqual(binder.snapshot.validationErrors, []); }); + it('should give explicit CheckRule.message precedence over function message', async () => { + const mockCatalog = new Catalog('test', '1.0', [], []); + const surface = new SurfaceModel('s1', mockCatalog); + (surface.defaultCatalog as any).functions = new Map([ + [ + 'dynamic_validator', + { + execute: () => ({valid: false, message: 'Custom dynamic error'}), + schema: z.object({}).passthrough(), + }, + ], + ]); + (surface.defaultCatalog as any).invoker = (name: string, args: any) => { + const fn = (surface.defaultCatalog as any).functions.get(name); + return fn.execute(args); + }; + + const schema = z.object({ + field: CommonSchemas.DynamicString, + checks: CommonSchemas.Checkable.shape.checks, + }); + + const compModel = new ComponentModel( + 'c_override', + 'Input', + { + field: 'val', + checks: [ + { + condition: { + '@call': 'dynamic_validator', + }, + message: 'Explicit rule message overrides function message', + }, + ], + }, + mockCatalog, + ); + surface.componentsModel.addComponent(compModel); + + const context = new ComponentContext(surface, 'c_override'); + const binder = new GenericBinder(context, schema); + binder.subscribe(() => {}); + + assert.strictEqual(binder.snapshot.isValid, false); + assert.deepStrictEqual(binder.snapshot.validationErrors, [ + 'Explicit rule message overrides function message', + ]); + }); + it('should not treat primitive fields or unannotated condition arrays as CHECKABLE', () => { const {catalog, surface} = setupSurfaceAndMocks(); diff --git a/typescript/core/web_core/src/resolution/generic-binder.ts b/typescript/core/web_core/src/resolution/generic-binder.ts index aeb65d3f81..ea42423851 100644 --- a/typescript/core/web_core/src/resolution/generic-binder.ts +++ b/typescript/core/web_core/src/resolution/generic-binder.ts @@ -578,7 +578,7 @@ export class GenericBinder { if (cached && jsonEquals(cached.raw, value)) { return cached.closure; } - const closure = async () => { + const closure = () => { if (value && typeof value === 'object') { const valObj = value as Record; const isFunctionCall = @@ -589,11 +589,21 @@ export class GenericBinder { value as Action | Record, {userActivation: true}, ); - if (resolved === undefined || isFunctionCall) { - return; + if (isFunctionCall) { + if ( + resolved instanceof Promise || + (typeof resolved === 'object' && resolved !== null && 'then' in (resolved as any)) + ) { + return (resolved as Promise).then(() => undefined); + } + return Promise.resolve(); + } + if (resolved === undefined) { + return Promise.resolve(); } return this.context.dispatchAction(resolved as Action); } + return Promise.resolve(); }; this.actionClosures.set(cacheKey, {raw: value, closure}); return closure; diff --git a/typescript/core/web_core/src/resolution/node-resolver.ts b/typescript/core/web_core/src/resolution/node-resolver.ts index 0e5f4e99c6..75934d0934 100644 --- a/typescript/core/web_core/src/resolution/node-resolver.ts +++ b/typescript/core/web_core/src/resolution/node-resolver.ts @@ -464,13 +464,11 @@ export class NodeResolver< dataPath, `Component '${componentId}' at '${dataPath}' is referenced by one of its own descendants; rendering a placeholder instead.`, ); - const model = this.surface.componentsModel.get(componentId); - const nodeType = model?.type ?? PLACEHOLDER_TYPE; return this.registerNode( new MutableComponentNode( instanceIdFor(componentId, dataPath, occurrence), componentId, - nodeType, + PLACEHOLDER_TYPE, dataPath, {}, undefined, diff --git a/typescript/core/web_core/src/state/validation-helpers.ts b/typescript/core/web_core/src/state/validation-helpers.ts index 89f9f95eeb..b1ca672914 100644 --- a/typescript/core/web_core/src/state/validation-helpers.ts +++ b/typescript/core/web_core/src/state/validation-helpers.ts @@ -379,7 +379,6 @@ function validateParentConstraints( ]; throw new A2uiValidationError(message, details, 'UNALLOWED_PARENT', {path, surfaceId}); } - } for (const parentInfo of parents ?? []) { if (!allowedParents.includes(parentInfo.parentType)) { diff --git a/typescript/core/web_core/src/v1_0/functions/validation_functions.test.ts b/typescript/core/web_core/src/v1_0/functions/validation_functions.test.ts index 529963acd7..2a45fc7c22 100644 --- a/typescript/core/web_core/src/v1_0/functions/validation_functions.test.ts +++ b/typescript/core/web_core/src/v1_0/functions/validation_functions.test.ts @@ -100,7 +100,9 @@ describe('v1.0 Validation Functions (returnType: validationResult)', () => { const invalidRes = EmailV1Point0Implementation.execute({value: 'invalid-email'}, dummyContext); assert.deepStrictEqual(invalidRes, { valid: false, - message: 'Must be a valid email address.', + code: 'INVALID_EMAIL', + severity: 'error', + message: 'Please enter a valid email address (e.g. user@example.com).', }); }); }); diff --git a/typescript/renderers/react/tests/v1_0_surface.test.tsx b/typescript/renderers/react/tests/v1_0_surface.test.tsx index ec6de5773e..65ec6ad094 100644 --- a/typescript/renderers/react/tests/v1_0_surface.test.tsx +++ b/typescript/renderers/react/tests/v1_0_surface.test.tsx @@ -185,7 +185,7 @@ describe('A2uiSurface v1.0 & Universal Custom Elements', () => { const errorEl = container.querySelector('a2ui-slider .a2ui-error-message'); expect(errorEl).not.toBeNull(); - expect(errorEl?.textContent).toContain('Minimum value is 50.'); + expect(errorEl?.textContent).toContain('Volume must be at least 50'); }); await act(async () => { From 953b0c7a1734540b4cced3148c02c987910a37d3 Mon Sep 17 00:00:00 2001 From: Greg Spencer Date: Thu, 29 Oct 2026 17:25:06 +0000 Subject: [PATCH 7/8] fix(web_core): remove unused assertExpectedProcessError in conformance test --- .../tests/conformance/conformance_test.mjs | 39 ------------------- 1 file changed, 39 deletions(-) diff --git a/typescript/core/web_core/tests/conformance/conformance_test.mjs b/typescript/core/web_core/tests/conformance/conformance_test.mjs index 94f91bff3c..443db65802 100644 --- a/typescript/core/web_core/tests/conformance/conformance_test.mjs +++ b/typescript/core/web_core/tests/conformance/conformance_test.mjs @@ -2794,45 +2794,6 @@ function validateProcessMessagesTestCase(testCase) { return inputMessages; }; - const assertExpectedProcessError = (err, expectedErr) => { - if (expectedErr.category) { - if (!matchesErrorCategory(err, expectedErr.category)) { - throw new Error( - `Expected error category '${expectedErr.category}', got '${err.constructor?.name || err.name}': ${err.message}`, - ); - } - } - if (expectedErr.code) { - if ( - !err.message.includes(expectedErr.code) && - err.name !== expectedErr.code && - err.code !== expectedErr.code - ) { - throw new Error( - `Expected error matching '${expectedErr.code}' but received: ${err.message}`, - ); - } - } - if (expectedErr.message) { - const expectedMsg = expectedErr.message; - const normalizedActual = err.message.replaceAll('"', "'").replaceAll("','", "', '"); - const normalizedExpected = expectedMsg.replaceAll('"', "'").replaceAll("','", "', '"); - const matches = - err.message.includes(expectedMsg) || - normalizedActual.includes(normalizedExpected) || - (expectedMsg.includes('Unsupported protocol version') && - (err.message.includes('Invalid enum value') || - err.message.includes('Unsupported protocol version'))) || - (expectedMsg.includes('Missing') && - err.message.includes("missing a valid 'version' string")) || - (expectedMsg.includes('multiple update types') && - err.message.includes('multiple conflicting update actions')); - if (!matches) { - throw new Error(`Expected error message containing '${expectedMsg}', got '${err.message}'`); - } - } - }; - if (steps && Array.isArray(steps)) { for (let i = 0; i < steps.length; i++) { const step = steps[i]; From 9837632b57695857129cbc4c17b5f5f99fc22d98 Mon Sep 17 00:00:00 2001 From: Greg Spencer Date: Thu, 29 Oct 2026 20:28:25 +0000 Subject: [PATCH 8/8] fix(web_core): address review feedback on error envelopes and action closures Revert OutboundMessageListener to omit ErrorMessage so @a2ui/web_core/v1_0 callers are not widened, drop redundant ProcessingErrorMessage and ErrorMessage aliases from message-error-mapper, track per-item ErrorEnvelopeContext during prepareOperations across multi-message arrays, restore async closure syntax in GenericBinder.bindAction, and remove the duplicate strictMode spread in conformance_test.mjs. --- .../core/web_core/src/processing/index.ts | 1 - .../src/processing/message-error-mapper.ts | 10 +--- .../src/processing/message-processor.test.ts | 43 +++++++++++++++++ .../src/processing/message-processor.ts | 48 ++++++++++++++----- .../web_core/src/resolution/generic-binder.ts | 47 +++++++++--------- .../core/web_core/src/rpc/rpc-handler.ts | 3 +- typescript/core/web_core/src/v0_9/index.ts | 2 - .../tests/conformance/conformance_test.mjs | 1 - 8 files changed, 103 insertions(+), 52 deletions(-) diff --git a/typescript/core/web_core/src/processing/index.ts b/typescript/core/web_core/src/processing/index.ts index a705104c3e..dcd3a3a575 100644 --- a/typescript/core/web_core/src/processing/index.ts +++ b/typescript/core/web_core/src/processing/index.ts @@ -20,7 +20,6 @@ export { extractEnvelopeErrorContext, normalizeJsonPointer, type RendererErrorMessage, - type ProcessingErrorMessage, type A2uiRendererError, type ValidationFailedErrorBody, type GenericErrorBody, diff --git a/typescript/core/web_core/src/processing/message-error-mapper.ts b/typescript/core/web_core/src/processing/message-error-mapper.ts index 5dd273e274..d5f858e0b9 100644 --- a/typescript/core/web_core/src/processing/message-error-mapper.ts +++ b/typescript/core/web_core/src/processing/message-error-mapper.ts @@ -56,12 +56,6 @@ export interface RendererErrorMessage { readonly error: A2uiRendererError; } -/** Alias for `RendererErrorMessage`. */ -export type ProcessingErrorMessage = RendererErrorMessage; - -/** Backwards-compatible alias for `RendererErrorMessage`. */ -export type ErrorMessage = RendererErrorMessage; - /** Known top-level action envelope keys across A2UI protocol versions. */ const ACTION_ENVELOPE_KEYS = [ 'createSurface', @@ -237,11 +231,11 @@ function resolveErrorCode(err: unknown, isV1: boolean): string { /** * Maps an internal `A2uiError` (or unknown thrown error) and envelope context - * into a spec-compliant outbound renderer `ErrorMessage`. + * into a spec-compliant outbound `RendererErrorMessage`. * * @param err Thrown error instance. * @param context Extracted envelope correlation context. - * @returns Spec-compliant outbound `ErrorMessage`. + * @returns Spec-compliant outbound `RendererErrorMessage`. */ export function mapErrorToRendererMessage( err: unknown, 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 c69b513b57..68b7846c4c 100644 --- a/typescript/core/web_core/src/processing/message-processor.test.ts +++ b/typescript/core/web_core/src/processing/message-processor.test.ts @@ -2221,5 +2221,48 @@ describe('MessageProcessor', () => { assert.strictEqual(mapped.error.surfaceId, undefined); assert.strictEqual(mapped.error.path, undefined); }); + + it('attributes prepareOperations parse errors to the failing item in multi-message arrays', async () => { + const textApi: ComponentApi = {name: 'Text', schema: z.object({text: z.string()})}; + const v09Cat = new Catalog('cat-v09', '0.9', [textApi]); + const v10Cat = new Catalog('cat-v10', '1.0', [textApi]); + const handledErrors: any[] = []; + const proc = new MessageProcessor([v09Cat, v10Cat], undefined, { + errorHandler: msg => handledErrors.push(msg), + }); + + assert.throws(() => { + proc.processMessages([ + {version: 'v0.9', createSurface: {surfaceId: 's1', catalogId: 'cat-v09'}}, + {version: '1.0', updateComponents: {surfaceId: 's2', components: 'not-an-array'}}, + ] as any); + }); + + assert.strictEqual(proc.getSurface('s1'), undefined); + assert.strictEqual(handledErrors.length, 1); + assert.strictEqual(handledErrors[0].version, '1.0'); + assert.strictEqual(handledErrors[0].error.surfaceId, 's2'); + + handledErrors.length = 0; + await assert.rejects(async () => { + await proc.processMessagesAsync([ + {version: 'v0.9', createSurface: {surfaceId: 's1', catalogId: 'cat-v09'}}, + { + version: '1.0', + callRendererFunction: { + functionCallId: 'fn-item-2', + callFunction: {call: 'missingFn', catalogId: 'cat-v10', args: 'invalid'}, + }, + }, + ] as any); + }); + + assert.strictEqual(proc.getSurface('s1'), undefined); + assert.strictEqual(handledErrors.length, 1); + assert.strictEqual(handledErrors[0].version, '1.0'); + assert.strictEqual(handledErrors[0].error.code, 'INVALID_FUNCTION_CALL'); + assert.strictEqual(handledErrors[0].error.functionCallId, 'fn-item-2'); + assert.strictEqual(handledErrors[0].error.surfaceId, undefined); + }); }); }); diff --git a/typescript/core/web_core/src/processing/message-processor.ts b/typescript/core/web_core/src/processing/message-processor.ts index 93061fe783..10ad79dcc4 100644 --- a/typescript/core/web_core/src/processing/message-processor.ts +++ b/typescript/core/web_core/src/processing/message-processor.ts @@ -573,7 +573,9 @@ export class MessageProcessor { ); let currentContext = initialContext; try { - const operations = this.prepareOperations(messages); + const operations = this.prepareOperations(messages, ctx => { + currentContext = ctx; + }); for (const op of operations) { currentContext = this.contextFromOperation(op, initialContext); this.processOperation(op, context); @@ -589,12 +591,12 @@ export class MessageProcessor { /** * Processes one or more messages, catching any validation or processing errors - * and returning them as spec-compliant outbound `ErrorMessage` objects instead + * and returning them as spec-compliant outbound `RendererErrorMessage` objects instead * of throwing. * * @param messages Array of messages or single message payload to process. * @param context Optional execution context. - * @returns Array of `ErrorMessage` envelopes produced by failed messages. + * @returns Array of `RendererErrorMessage` envelopes produced by failed messages. */ processMessagesWithErrors(messages: unknown, context?: ExecutionContext): RendererErrorMessage[] { return this.withValidationConfig(context, () => { @@ -604,7 +606,9 @@ export class MessageProcessor { const itemEnvelopeContext = extractEnvelopeErrorContext(item, this.defaultVersion); let currentContext = itemEnvelopeContext; try { - const operations = this.prepareOperations(item as ProcessableMessagePayload); + const operations = this.prepareOperations(item as ProcessableMessagePayload, ctx => { + currentContext = ctx; + }); for (const op of operations) { currentContext = this.contextFromOperation(op, itemEnvelopeContext); this.processOperation(op, context); @@ -634,7 +638,11 @@ export class MessageProcessor { ); let currentContext = initialContext; try { - const operations = this.withValidationConfig(context, () => this.prepareOperations(messages)); + const operations = this.withValidationConfig(context, () => + this.prepareOperations(messages, ctx => { + currentContext = ctx; + }), + ); const responses: RendererFunctionResponseMessage[] = []; for (const op of operations) { currentContext = this.contextFromOperation(op, initialContext); @@ -740,26 +748,40 @@ export class MessageProcessor { } } - private prepareOperations(messages: ProcessableMessagePayload): InternalOperation[] { - if (!messages || (Array.isArray(messages) && messages.length === 0)) return []; + private prepareSingleItem( + item: ProcessableMessagePayload, + onItemContext?: (ctx: ErrorEnvelopeContext) => void, + ): InternalOperation[] { + onItemContext?.(extractEnvelopeErrorContext(item, this.defaultVersion)); if (this.validationConfig) { - validateRecursionAndPaths(messages); + validateRecursionAndPaths(item); } if (this.validationConfig?.targetVersion) { - this.validateTargetVersion(messages); + this.validateTargetVersion(item); } - if (isInternalOperation(messages)) { - return [messages]; + if (isInternalOperation(item)) { + return [item]; } + return this.extractSingleMessageOperations(item); + } + + private prepareOperations( + messages: ProcessableMessagePayload, + onItemContext?: (ctx: ErrorEnvelopeContext) => void, + ): InternalOperation[] { + if (!messages || (Array.isArray(messages) && messages.length === 0)) return []; + if (Array.isArray(messages)) { - return messages.flatMap(item => this.extractSingleMessageOperations(item)); + return messages.flatMap(item => + this.prepareSingleItem(item as ProcessableMessagePayload, onItemContext), + ); } - return this.extractSingleMessageOperations(messages); + return this.prepareSingleItem(messages, onItemContext); } /** diff --git a/typescript/core/web_core/src/resolution/generic-binder.ts b/typescript/core/web_core/src/resolution/generic-binder.ts index ea42423851..1b8d9965fb 100644 --- a/typescript/core/web_core/src/resolution/generic-binder.ts +++ b/typescript/core/web_core/src/resolution/generic-binder.ts @@ -578,32 +578,29 @@ export class GenericBinder { if (cached && jsonEquals(cached.raw, value)) { return cached.closure; } - const closure = () => { - if (value && typeof value === 'object') { - const valObj = value as Record; - const isFunctionCall = - Boolean(valObj.functionCall && typeof valObj.functionCall === 'object') || - 'call' in valObj || - '@call' in valObj; - const resolved = this.context.dataContext.resolveAction( - value as Action | Record, - {userActivation: true}, - ); - if (isFunctionCall) { - if ( - resolved instanceof Promise || - (typeof resolved === 'object' && resolved !== null && 'then' in (resolved as any)) - ) { - return (resolved as Promise).then(() => undefined); - } - return Promise.resolve(); - } - if (resolved === undefined) { - return Promise.resolve(); - } - return this.context.dispatchAction(resolved as Action); + const closure = async () => { + if (!value || typeof value !== 'object') { + return; + } + const valObj = value as Record; + const isFunctionCall = + Boolean(valObj.functionCall && typeof valObj.functionCall === 'object') || + 'call' in valObj || + '@call' in valObj; + if (isFunctionCall) { + await this.context.dataContext.resolveAction(value as Action | Record, { + userActivation: true, + }); + return; + } + const resolved = this.context.dataContext.resolveAction( + value as Action | Record, + {userActivation: true}, + ); + if (resolved === undefined) { + return; } - return Promise.resolve(); + await this.context.dispatchAction(resolved as Action); }; this.actionClosures.set(cacheKey, {raw: value, closure}); return closure; diff --git a/typescript/core/web_core/src/rpc/rpc-handler.ts b/typescript/core/web_core/src/rpc/rpc-handler.ts index 24b50d0f74..f24351bb8f 100644 --- a/typescript/core/web_core/src/rpc/rpc-handler.ts +++ b/typescript/core/web_core/src/rpc/rpc-handler.ts @@ -27,7 +27,6 @@ import { RendererFunctionResponseMessage, CallAgentFunctionMessage, } from '../v1_0/schema/renderer-to-agent.js'; -import type {ErrorMessage} from '../processing/message-error-mapper.js'; import {isCatalogVersionCompatible} from '../processing/adapters/base.js'; import {settleDeep} from '../common/settle_deep.js'; import {PayloadValidator} from '../validation/payload-validator.js'; @@ -39,7 +38,7 @@ export {A2uiRpcError, RpcErrorCode}; * Callback function type receiving outbound renderer messages intended for the agent. */ export type OutboundMessageListener = ( - message: RendererFunctionResponseMessage | CallAgentFunctionMessage | ErrorMessage, + message: RendererFunctionResponseMessage | CallAgentFunctionMessage, ) => void | Promise; /** diff --git a/typescript/core/web_core/src/v0_9/index.ts b/typescript/core/web_core/src/v0_9/index.ts index da68412e99..31e7a76a37 100644 --- a/typescript/core/web_core/src/v0_9/index.ts +++ b/typescript/core/web_core/src/v0_9/index.ts @@ -30,8 +30,6 @@ export { extractEnvelopeErrorContext, normalizeJsonPointer, type RendererErrorMessage, - type ProcessingErrorMessage, - type ErrorMessage, type A2uiRendererError, type ValidationFailedErrorBody, type GenericErrorBody, diff --git a/typescript/core/web_core/tests/conformance/conformance_test.mjs b/typescript/core/web_core/tests/conformance/conformance_test.mjs index 443db65802..137e14c2ea 100644 --- a/typescript/core/web_core/tests/conformance/conformance_test.mjs +++ b/typescript/core/web_core/tests/conformance/conformance_test.mjs @@ -2767,7 +2767,6 @@ function validateProcessMessagesTestCase(testCase) { const processorOptions = { ...(protocolVersion ? {version: protocolVersion} : {}), ...caseValidationConfig(testCase), - ...(testCase.strictMode ? {validationConfig: STRICT_VALIDATION} : {}), errorHandler: msg => { lastErrorMsg = msg; },