diff --git a/typescript/core/web_core/CHANGELOG.md b/typescript/core/web_core/CHANGELOG.md index ade0d66181..e0f0e8b85e 100644 --- a/typescript/core/web_core/CHANGELOG.md +++ b/typescript/core/web_core/CHANGELOG.md @@ -18,6 +18,7 @@ - (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) - 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) - Pass literal `'aria-label'`, `'aria-description'`, and `'aria-live'` attribute names to `this.setAttribute(...)` in `BasicCatalogA2uiLitElement.applyAccessibilityAttributes`, construct Unicode property escape regular expressions via `new RegExp(..., 'u')` in `uax31.ts` and `expression_parser.ts`, and delegate `ExtensionsSchema` key validation in `common-types.ts` to `isValidUax31Identifier`, improving compatibility with Trusted Types `setAttribute` static analyzers and downstream JavaScript compilers. - `Catalog.toJson()` and `Catalog.validationSchema` write a `z.union` as `anyOf` instead of `oneOf`, so a catalog reloaded with `Catalog.fromJson` accepts the values the original accepts, such as `"md"` for `z.union([z.enum(['md', 'lg']), z.string()])` ([#3362](https://github.com/a2ui-project/a2ui/issues/3362)). Specification-shaped models (`spec()` with `mixins`), including the built-in basic catalogs, still write a union as `oneOf` when no value can match two of its branches. A `oneOf` loaded with `Catalog.fromJson` stays exclusive and is written back as `oneOf`. - Under a `validationConfig` that allows dangling references and orphans or a missing root, as an agent's renderer state configures it, `MessageProcessor` checks an `updateComponents` against the components it changes, instead of copying every component of the surface and checking the whole surface again. It records what the last check found (each component's references, the components referencing it, and a bound on the longest chain below it) and checks cycles, depth and the `allowedParents` and `allowedChildren` constraints of the components the message affects. A component outside the message that references one of its components is checked against only the children the message changes, so a container whose catalog declares `allowedChildren`, receiving one child per message, is not checked against all its children each time. When that record cannot show the message passes, or the components changed outside the processor since, it checks the whole surface as before, so a message that fails reports the same error. A surface sent as n messages of one component each now takes time in proportion to n rather than n squared. Under any other config the checks are unchanged. The composition check is skipped when no catalog the surface's components come from declares `allowedParents` or `allowedChildren`, since it then has nothing to check. diff --git a/typescript/core/web_core/src/catalog/types.test.ts b/typescript/core/web_core/src/catalog/types.test.ts index e30f01b198..8b409e4025 100644 --- a/typescript/core/web_core/src/catalog/types.test.ts +++ b/typescript/core/web_core/src/catalog/types.test.ts @@ -179,6 +179,85 @@ describe('Catalog Types', () => { assert.ok(defs); assert.ok(defs.DynamicString); }); + + describe('invoker with pending arguments', () => { + const upper = createFunctionImplementation( + {name: 'upper', returnType: 'string', schema: z.object({'value': z.string()})}, + args => args.value.toUpperCase(), + ); + const catalog = new Catalog('test-cat', '1.0', [], [upper]); + + it('runs synchronously when every argument is settled', () => { + assert.strictEqual(catalog.invoker('upper', {value: 'abc'}, {} as any), 'ABC'); + }); + + it('awaits a pending argument before validating against a z.string() schema', async () => { + const result = catalog.invoker('upper', {value: Promise.resolve('abc')}, {} as any); + assert.strictEqual(typeof (result as Promise).then, 'function'); + assert.strictEqual(await result, 'ABC'); + }); + + it('rejects with the validation error once a pending argument settles to the wrong type', async () => { + await assert.rejects( + catalog.invoker('upper', {value: Promise.resolve(42)}, {} as any) as Promise, + (err: any) => + err instanceof A2uiExpressionError && + err.message.includes('Validation failed') && + err.expression === 'upper', + ); + }); + + it('propagates the rejection of a pending argument', async () => { + const failure = new Error('inner failure'); + await assert.rejects( + catalog.invoker('upper', {value: Promise.reject(failure)}, {} as any) as Promise, + failure, + ); + }); + + it('resolves to undefined when aborted before the arguments settle', async () => { + const abortController = new AbortController(); + let executed = false; + const spy = createFunctionImplementation( + {name: 'spy', returnType: 'string', schema: z.object({'value': z.string()})}, + () => { + executed = true; + return 'ran'; + }, + ); + const spyCatalog = new Catalog('test-cat', '1.0', [], [spy]); + const result = spyCatalog.invoker( + 'spy', + {value: Promise.resolve('abc')}, + {} as any, + abortController.signal, + ); + abortController.abort(); + assert.strictEqual(await result, undefined); + assert.strictEqual(executed, false); + }); + + it('enforces user activation synchronously, before awaiting any argument', () => { + const guarded = createFunctionImplementation( + { + name: 'guarded', + returnType: 'string', + schema: z.object({'value': z.string()}), + requiresUserActivation: true, + }, + () => 'ran', + ); + const guardedCatalog = new Catalog('test-cat', '1.0', [], [guarded]); + assert.throws( + () => + guardedCatalog.invoker('guarded', {value: Promise.resolve('abc')}, { + isPassiveEvaluation: true, + } as any), + (err: any) => + err instanceof A2uiExpressionError && err.message.includes('requires user activation'), + ); + }); + }); }); describe('InferredComponentApiSchemaType', () => { diff --git a/typescript/core/web_core/src/catalog/types.ts b/typescript/core/web_core/src/catalog/types.ts index beb92af050..eb4b973484 100644 --- a/typescript/core/web_core/src/catalog/types.ts +++ b/typescript/core/web_core/src/catalog/types.ts @@ -20,6 +20,7 @@ import {DataContext} from '../resolution/data-context.js'; import {Signal} from '../reactivity/signals.js'; import {A2uiCatalogError, A2uiExpressionError} from '../errors.js'; import {isAtLeastVersion} from '../common/semver.js'; +import {isThenable, settleDeep} from '../common/settle_deep.js'; import {SpecVersion} from '../spec_versions.js'; import {parseCatalogSchema} from './schema_loader.js'; import type {JsonSchemaObject} from './spec_annotations.js'; @@ -928,25 +929,38 @@ function createCatalogInvoker( ); } - try { - // From v1.0 a `DynamicBoolean` argument may resolve to the - // `ValidationResult` a validation function returns; it is read by its - // validity, as the specification defines the type. - const args = isAtLeastVersion(protocolVersion, SpecVersion.V1_0) - ? coerceValidationResults(fn.schema, rawArgs) - : rawArgs; - const safeArgs = fn.schema.parse(args); - return execute.call(fn, safeArgs, ctx, abortSignal); - } catch (e: any) { - if (e?.name === 'ZodError' || e instanceof z.ZodError) { - throw new A2uiExpressionError( - `Validation failed for function '${name}': ${e.message}`, - name, - e.errors ?? e.issues, - ); + const parseAndExecute = (resolvedArgs: unknown): unknown => { + try { + // From v1.0 a `DynamicBoolean` argument may resolve to the + // `ValidationResult` a validation function returns; it is read by its + // validity, as the specification defines the type. + const args = isAtLeastVersion(protocolVersion, SpecVersion.V1_0) + ? coerceValidationResults(fn.schema, resolvedArgs) + : resolvedArgs; + const safeArgs = fn.schema.parse(args); + return execute.call(fn, safeArgs, ctx, abortSignal); + } catch (e: any) { + if (e?.name === 'ZodError' || e instanceof z.ZodError) { + throw new A2uiExpressionError( + `Validation failed for function '${name}': ${e.message}`, + name, + e.errors ?? e.issues, + ); + } + throw e; } - throw e; + }; + + // An argument produced by an asynchronous function arrives as a pending + // Promise. Validation and execution wait for every such argument, while + // fully settled arguments keep the call synchronous. + const settledArgs = settleDeep(rawArgs); + if (!isThenable(settledArgs)) { + return parseAndExecute(settledArgs); } + return Promise.resolve(settledArgs).then(resolvedArgs => + abortSignal?.aborted ? undefined : parseAndExecute(resolvedArgs), + ); }; } diff --git a/typescript/core/web_core/src/common/settle_deep.test.ts b/typescript/core/web_core/src/common/settle_deep.test.ts new file mode 100644 index 0000000000..45b303bbef --- /dev/null +++ b/typescript/core/web_core/src/common/settle_deep.test.ts @@ -0,0 +1,117 @@ +/* + * 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 {signal} from '../reactivity/signals.js'; +import {isPlainObject, isThenable, settleDeep} from './settle_deep.js'; + +describe('isThenable', () => { + it('recognizes Promises and objects with a callable then', () => { + assert.strictEqual(isThenable(Promise.resolve(1)), true); + assert.strictEqual(isThenable({then: () => {}}), true); + }); + + it('rejects everything else', () => { + assert.strictEqual(isThenable(null), false); + assert.strictEqual(isThenable(undefined), false); + assert.strictEqual(isThenable('then'), false); + assert.strictEqual(isThenable({then: 1}), false); + assert.strictEqual(isThenable([]), false); + }); +}); + +describe('isPlainObject', () => { + it('accepts object literals and null-prototype objects', () => { + assert.strictEqual(isPlainObject({a: 1}), true); + assert.strictEqual(isPlainObject(Object.create(null)), true); + }); + + it('rejects arrays, null, primitives and class instances', () => { + assert.strictEqual(isPlainObject([]), false); + assert.strictEqual(isPlainObject(null), false); + assert.strictEqual(isPlainObject('x'), false); + assert.strictEqual(isPlainObject(new Date(0)), false); + assert.strictEqual(isPlainObject(signal(1)), false); + }); +}); + +describe('settleDeep', () => { + it('returns primitives unchanged and synchronously', () => { + assert.strictEqual(settleDeep(1), 1); + assert.strictEqual(settleDeep('a'), 'a'); + assert.strictEqual(settleDeep(null), null); + assert.strictEqual(settleDeep(undefined), undefined); + assert.strictEqual(settleDeep(false), false); + }); + + it('returns the same array and plain object when nothing is pending', () => { + const list = [1, {a: [2, 3]}]; + const record = {a: 1, b: {c: [null, 'd']}}; + assert.strictEqual(settleDeep(list), list); + assert.strictEqual(settleDeep(record), record); + }); + + it('returns class instances as they are without walking them', () => { + const date = new Date(0); + const sig = signal(Promise.resolve(1)); + assert.strictEqual(settleDeep(date), date); + assert.strictEqual(settleDeep(sig), sig); + const holder = {date, sig}; + assert.strictEqual(settleDeep(holder), holder); + }); + + it('awaits a top-level Promise', async () => { + assert.strictEqual(await settleDeep(Promise.resolve('x')), 'x'); + }); + + it('awaits Promises nested in arrays and plain objects', async () => { + const settled = await settleDeep({ + a: Promise.resolve(1), + b: [2, Promise.resolve(3)], + c: {d: Promise.resolve({e: Promise.resolve(4)})}, + f: 'static', + }); + assert.deepStrictEqual(settled, {a: 1, b: [2, 3], c: {d: {e: 4}}, f: 'static'}); + }); + + it('settles a chain of Promises that resolve to Promises', async () => { + const chained = Promise.resolve(Promise.resolve([Promise.resolve(1)])); + assert.deepStrictEqual(await settleDeep(chained), [1]); + }); + + it('keeps the keys of a settled object in order', async () => { + const settled = (await settleDeep({z: Promise.resolve(1), a: 2})) as Record; + assert.deepStrictEqual(Object.keys(settled), ['z', 'a']); + }); + + it('rejects when a nested Promise rejects', async () => { + const failure = new Error('boom'); + await assert.rejects( + settleDeep({ok: Promise.resolve(1), bad: [Promise.reject(failure)]}) as Promise, + failure, + ); + }); + + it('does not mutate the input while settling', async () => { + const pending = Promise.resolve(1); + const input = {a: pending, b: [pending]}; + const settled = await settleDeep(input); + assert.notStrictEqual(settled, input); + assert.strictEqual(input.a, pending); + assert.strictEqual(input.b[0], pending); + }); +}); diff --git a/typescript/core/web_core/src/common/settle_deep.ts b/typescript/core/web_core/src/common/settle_deep.ts new file mode 100644 index 0000000000..2c3ea126c0 --- /dev/null +++ b/typescript/core/web_core/src/common/settle_deep.ts @@ -0,0 +1,87 @@ +/* + * 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. + */ + +/** + * Settling of pending values nested in resolved function arguments. + * + * The runtime resolves the arguments of a function call before invoking it. + * An argument produced by an asynchronous function arrives as a pending + * Promise, possibly nested in an array or a plain object built from other + * arguments. `settleDeep` awaits every such Promise before the arguments are + * validated and the function runs, and returns the input itself, synchronously, + * when nothing in it is pending, so synchronous expressions stay synchronous. + */ + +/** + * Checks whether a value is a Promise-like object. + * + * @param value The value to inspect. + * @returns Whether `value` has a callable `then`. + */ +export function isThenable(value: unknown): value is PromiseLike { + return ( + typeof value === 'object' && + value !== null && + typeof (value as {then?: unknown}).then === 'function' + ); +} + +/** + * Checks whether a value is a plain object: not an array, `null`, or an + * instance of a class such as `Date` or a `Signal`. + * + * @param value The value to inspect. + * @returns Whether `value` is a plain object. + */ +export function isPlainObject(value: unknown): value is Record { + if (Array.isArray(value) || typeof value !== 'object' || value === null) { + return false; + } + const proto = Object.getPrototypeOf(value); + return proto === Object.prototype || proto === null; +} + +/** + * Recursively awaits the Promises nested in the arrays and plain objects of + * `value`. + * + * Only arrays and plain objects are walked; a `Date`, a `Signal`, or any other + * class instance is returned as it is. A rejected Promise anywhere in `value` + * rejects the returned Promise. + * + * @param value The value to settle. + * @returns `value` itself when nothing in it is pending, otherwise a Promise + * of `value` with every nested Promise replaced by its settled value. + */ +export function settleDeep(value: T): T | Promise { + if (isThenable(value)) { + return Promise.resolve(value).then(settleDeep); + } + if (Array.isArray(value)) { + const items = value.map(settleDeep); + return items.some(isThenable) ? Promise.all(items) : value; + } + if (isPlainObject(value)) { + const entries = Object.entries(value).map(([key, item]) => [key, settleDeep(item)] as const); + if (!entries.some(([, item]) => isThenable(item))) { + return value; + } + return Promise.all(entries.map(([, item]) => item)).then(settled => + Object.fromEntries(entries.map(([key], i) => [key, settled[i]])), + ); + } + return value; +} diff --git a/typescript/core/web_core/src/resolution/data-context.test.ts b/typescript/core/web_core/src/resolution/data-context.test.ts index 46f5448178..ebd400de79 100644 --- a/typescript/core/web_core/src/resolution/data-context.test.ts +++ b/typescript/core/web_core/src/resolution/data-context.test.ts @@ -16,7 +16,7 @@ import * as assert from 'node:assert'; import {describe, it, beforeEach} from 'node:test'; -import {signal, computed, peekValue, getValue, setValue} from '../reactivity/signals.js'; +import {signal, computed, peekValue, getValue, setValue, effect} from '../reactivity/signals.js'; import {z} from 'zod'; import {DataModel} from '../state/data-model.js'; import {SurfaceModel} from '../state/surface-model.js'; @@ -29,7 +29,7 @@ import { unescapeObjectKey, validateReservedDirectives, } from './data-context.js'; -import {Catalog} from '../catalog/types.js'; +import {Catalog, createFunctionImplementation} from '../catalog/types.js'; import {MAX_FUNCTION_CALL_ARGS} from '../types/helpers.js'; import {A2uiCatalogError, A2uiExpressionError, A2uiValidationError} from '../errors.js'; @@ -1379,3 +1379,222 @@ describe('DataContext', () => { }); }); }); + +describe('DataContext asynchronous function arguments', () => { + /** Resolves on the next macrotask, after every pending microtask has run. */ + const flush = () => new Promise(resolve => setTimeout(resolve, 0)); + + const upper = createFunctionImplementation( + { + name: 'upper', + returnType: 'string', + schema: z.object({'value': z.string()}), + }, + args => args.value.toUpperCase(), + ); + const fetchText = createFunctionImplementation( + { + name: 'fetchText', + returnType: 'any', + schema: z.object({'text': z.string()}), + }, + async args => { + await Promise.resolve(); + return args.text; + }, + ); + const fail = createFunctionImplementation( + {name: 'fail', returnType: 'any', schema: z.object({})}, + async () => { + await Promise.resolve(); + throw new A2uiExpressionError('inner failure', 'fail'); + }, + ); + const wrap = createFunctionImplementation( + { + name: 'wrap', + returnType: 'any', + schema: z.object({'items': z.array(z.string()), 'meta': z.object({'label': z.string()})}), + }, + args => ({items: args.items, label: args.meta.label}), + ); + const guarded = createFunctionImplementation( + { + name: 'guarded', + returnType: 'string', + schema: z.object({'value': z.string()}), + requiresUserActivation: true, + }, + args => `guarded:${args.value}`, + ); + + let surface: SurfaceModel; + let ctx: DataContext; + let errors: Array<{code: string; message: string; expression?: string}>; + + beforeEach(() => { + const catalog = new Catalog('cat', '1.0', [], [upper, fetchText, fail, wrap, guarded]); + surface = new SurfaceModel('s1', catalog); + surface.dataModel.set('/name', 'alice'); + ctx = new DataContext(surface, '/'); + errors = []; + surface.onError.subscribe(e => { + errors.push(e); + }); + }); + + const call = (name: string, args: Record) => ({'@call': name, args}); + + it('keeps a synchronous call synchronous', () => { + assert.strictEqual( + ctx.resolveDynamicValue(call('upper', {value: {'@path': '/name'}})), + 'ALICE', + ); + assert.strictEqual(errors.length, 0); + }); + + it('settles an asynchronous inner result before validating the outer call', async () => { + const result = ctx.resolveDynamicValue>( + call('upper', {value: call('fetchText', {text: 'hello'})}), + ); + assert.strictEqual(typeof result.then, 'function'); + assert.strictEqual(await result, 'HELLO'); + assert.strictEqual(errors.length, 0); + }); + + it('settles Promises nested in arrays and objects of the arguments', async () => { + const result = await ctx.resolveDynamicValue>( + call('wrap', { + items: [call('fetchText', {text: 'a'}), 'b', call('fetchText', {text: 'c'})], + meta: {label: call('fetchText', {text: 'tag'})}, + }), + ); + assert.deepStrictEqual(result, {items: ['a', 'b', 'c'], label: 'tag'}); + assert.strictEqual(errors.length, 0); + }); + + it('reports an inner rejection once, at the top level, and resolves to undefined', async () => { + const result = await ctx.resolveDynamicValue>( + call('upper', {value: call('upper', {value: call('fail', {})})}), + ); + assert.strictEqual(result, undefined); + assert.strictEqual(errors.length, 1); + assert.strictEqual(errors[0].code, 'EXPRESSION_ERROR'); + assert.strictEqual(errors[0].message, 'inner failure'); + assert.strictEqual(errors[0].expression, 'fail'); + }); + + it('reports a validation failure of the outer call after its arguments settle', async () => { + const result = await ctx.resolveDynamicValue>( + call('wrap', {items: call('fetchText', {text: 'not an array'}), meta: {label: 'x'}}), + ); + assert.strictEqual(result, undefined); + assert.strictEqual(errors.length, 1); + assert.match(errors[0].message, /Validation failed for function 'wrap'/); + }); + + it('resolves an action whose function call has an asynchronous argument', async () => { + const result = await ctx.resolveAction({ + functionCall: call('upper', {value: call('fetchText', {text: 'clicked'})}), + }); + assert.strictEqual(result, 'CLICKED'); + }); + + it('enforces user activation before awaiting an asynchronous argument', async () => { + const passive = ctx.resolveSignal(call('guarded', {value: call('fetchText', {text: 'x'})})); + assert.strictEqual(peekValue(passive), undefined); + assert.strictEqual(errors.length, 1); + assert.match(errors[0].message, /requires user activation/); + passive.unsubscribe?.(); + + errors.length = 0; + const activated = await ctx.resolveAction({ + functionCall: call('guarded', {value: call('fetchText', {text: 'x'})}), + }); + assert.strictEqual(activated, 'guarded:x'); + assert.strictEqual(errors.length, 0); + }); + + it('exposes an asynchronous result through a signal once it settles', async () => { + const sig = ctx.resolveSignal(call('fetchText', {text: {'@path': '/name'}})); + assert.strictEqual(peekValue(sig), undefined); + await flush(); + assert.strictEqual(peekValue(sig), 'alice'); + + const seen: unknown[] = []; + const dispose = effect(() => { + seen.push(getValue(sig)); + }); + surface.dataModel.set('/name', 'bob'); + await flush(); + assert.deepStrictEqual(seen, ['alice', 'bob']); + dispose(); + sig.unsubscribe?.(); + }); + + it('ignores a stale completion that arrives after the subscription ended', async () => { + let release!: (value: string) => void; + const slow = createFunctionImplementation( + {name: 'slow', returnType: 'any', schema: z.object({'value': z.string()})}, + () => new Promise(resolve => (release = resolve)), + ); + const catalog = new Catalog('cat', '1.0', [], [slow]); + const slowSurface = new SurfaceModel('s2', catalog); + slowSurface.dataModel.set('/v', 'first'); + const slowCtx = new DataContext(slowSurface, '/'); + + const sub = slowCtx.subscribeDynamicValue( + call('slow', {value: {'@path': '/v'}}), + () => {}, + ); + assert.strictEqual(sub.value, undefined); + sub.unsubscribe(); + release('late'); + await flush(); + assert.strictEqual(sub.value, undefined); + }); + + it('ignores a stale completion when the arguments changed before it arrived', async () => { + const pending: Array<(value: string) => void> = []; + const slow = createFunctionImplementation( + {name: 'slow', returnType: 'any', schema: z.object({'value': z.string()})}, + () => new Promise(resolve => pending.push(resolve)), + ); + const catalog = new Catalog('cat', '1.0', [], [slow]); + const slowSurface = new SurfaceModel('s2', catalog); + slowSurface.dataModel.set('/v', 'first'); + const slowCtx = new DataContext(slowSurface, '/'); + + const sig = slowCtx.resolveSignal(call('slow', {value: {'@path': '/v'}})); + slowSurface.dataModel.set('/v', 'second'); + assert.strictEqual(pending.length, 2); + pending[1]('second'); + await flush(); + assert.strictEqual(peekValue(sig), 'second'); + pending[0]('first'); + await flush(); + assert.strictEqual(peekValue(sig), 'second'); + sig.unsubscribe?.(); + }); + + it('reports a rejected signal evaluation as an EXPRESSION_ERROR', async () => { + const sig = ctx.resolveSignal(call('fail', {})); + assert.strictEqual(peekValue(sig), undefined); + assert.strictEqual(errors.length, 0); + await flush(); + assert.strictEqual(peekValue(sig), undefined); + assert.strictEqual(errors.length, 1); + assert.strictEqual(errors[0].code, 'EXPRESSION_ERROR'); + assert.strictEqual(errors[0].message, 'inner failure'); + sig.unsubscribe?.(); + }); + + it('leaves a thenable alone when resolving a value synchronously', () => { + const pendingValue = Promise.resolve('x'); + assert.strictEqual(ctx.resolveDynamicValue(pendingValue), pendingValue); + assert.strictEqual( + ctx.resolveDynamicValue<{nested: unknown}>({nested: pendingValue}).nested, + pendingValue, + ); + }); +}); diff --git a/typescript/core/web_core/src/resolution/data-context.ts b/typescript/core/web_core/src/resolution/data-context.ts index 7bea01632f..9581784d27 100644 --- a/typescript/core/web_core/src/resolution/data-context.ts +++ b/typescript/core/web_core/src/resolution/data-context.ts @@ -30,6 +30,7 @@ import {type FunctionCall, type Action} from '../types/common-types.js'; import {MAX_FUNCTION_CALL_ARGS} from '../types/helpers.js'; import {A2uiCatalogError, A2uiExpressionError, A2uiValidationError} from '../errors.js'; import {isAtLeastVersion} from '../common/semver.js'; +import {isThenable} from '../common/settle_deep.js'; import {FunctionInvoker} from '../catalog/function_invoker.js'; import {resolveSurfaceCatalog} from '../state/resolve-surface-catalog.js'; @@ -467,7 +468,7 @@ export class DataContext { * @returns Whether the value contains dynamic elements requiring resolution. */ private containsDynamicValue(value: unknown): boolean { - if (value === null || typeof value !== 'object') { + if (value === null || typeof value !== 'object' || isThenable(value)) { return false; } if (Array.isArray(value)) { @@ -512,8 +513,13 @@ export class DataContext { * * Evaluates the value once at the current moment without creating reactive subscriptions. * Use `subscribeDynamicValue` for reactive updates. + * + * A pending Promise, such as the result of an asynchronous function nested + * in the arguments of another call, is returned unchanged: the invoker of + * the enclosing call awaits it before validating the arguments. * @param value The DynamicValue object or raw value from the A2UI JSON payload. * @param depth The current recursion depth when evaluating nested arguments or expressions. + * @param userActivated Whether the evaluation was initiated by an active user action. * @returns The synchronously resolved value. */ resolveDynamicValue(value: unknown, depth = 0, userActivated = false): V { @@ -525,7 +531,7 @@ export class DataContext { return undefined as any; } - if (value === null || typeof value !== 'object') { + if (value === null || typeof value !== 'object' || isThenable(value)) { return value as V; } @@ -552,12 +558,18 @@ export class DataContext { return this.resolveFunctionCallValue(rec as unknown as FunctionCall, depth, userActivated); } - return this.resolvePlainObjectValue(rec, depth); + return this.resolvePlainObjectValue(rec, depth, userActivated); } /** * Resolves a function call by validating arguments and invoking the function. * + * An asynchronous function returns a pending Promise. At the top level of an + * expression the Promise is returned with its rejection turned into an + * `EXPRESSION_ERROR` on the surface, since nothing else would report it; + * nested in the arguments of another call it is returned as it is, so the + * enclosing call awaits it and a rejection reaches that call's handler. + * * @param call Function call definition to execute. * @param depth Current recursion depth for nested expression tracking. * @param userActivated Whether the evaluation was initiated by an active user action. @@ -601,6 +613,19 @@ export class DataContext { return undefined as unknown as V; } + if (isThenable(result)) { + if (depth > 0) { + return result as V; + } + return Promise.resolve(result).then( + value => value, + (e: unknown) => { + this.dispatchExpressionError(e, callName); + return undefined; + }, + ) as V; + } + return (isSignal(result) ? peekValue(result) : result) as V; } @@ -609,9 +634,14 @@ export class DataContext { * * @param rec Plain object dictionary to resolve. * @param depth Current recursion depth for nested expression tracking. + * @param userActivated Whether the evaluation was initiated by an active user action. * @returns A copy of the object with all nested dynamic values resolved. */ - private resolvePlainObjectValue(rec: Record, depth = 0): V { + private resolvePlainObjectValue( + rec: Record, + depth = 0, + userActivated = false, + ): V { if (this.atLeastV10) { validateReservedDirectives(Object.keys(rec), this.surfaceProtocolVersion); } @@ -621,7 +651,7 @@ export class DataContext { const resolved: Record = {}; for (const [k, v] of Object.entries(rec)) { const key = this.atLeastV10 ? unescapeObjectKey(k) : k; - resolved[key] = this.resolveDynamicValue(v, depth + 1); + resolved[key] = this.resolveDynamicValue(v, depth + 1, userActivated); } return resolved as unknown as V; } @@ -697,6 +727,13 @@ export class DataContext { return signal(value as V); } + // 1a. A pending value: the signal holds `undefined` until it settles. + if (isThenable(value)) { + const pendingSig = signal(undefined); + this.settleIntoSignal(pendingSig, value, 'DynamicValue'); + return pendingSig as Signal; + } + // 1b. Arrays: each element may itself be a DynamicValue (e.g. `and`/`or` `values`) if (Array.isArray(value)) { // Fast path: fully static arrays need no per-element signals. @@ -753,7 +790,16 @@ export class DataContext { abortSignal: abortController.signal, invoker: targetCatalog.invoker, }); - const sig = isSignal(result) ? result : signal(result as V); + let sig: Signal; + if (isSignal(result)) { + sig = result; + } else if (isThenable(result)) { + const pendingSig = signal(undefined); + this.settleIntoSignal(pendingSig, result, callName, abortController.signal); + sig = pendingSig as Signal; + } else { + sig = signal(result as V); + } sig.unsubscribe = () => abortController.abort(); return sig; } @@ -793,6 +839,10 @@ export class DataContext { innerUnsubscribe = effect(() => { setValue(resultSig, getValue(res)); }); + } else if (isThenable(res)) { + // Keep the current value until the call settles; a completion + // that arrives after the arguments changed again is ignored. + this.settleIntoSignal(resultSig, res, callName, abortController.signal); } else { setValue(resultSig, res); } @@ -1023,6 +1073,41 @@ export class DataContext { } } + /** + * Writes the settled value of a pending function result into a signal. + * + * The signal keeps its current value while the result is pending. A + * completion that arrives after `abortSignal` fired is ignored, since the + * arguments changed or the subscription ended in the meantime. A rejection + * is dispatched as an `EXPRESSION_ERROR` and resets the signal, as a + * synchronous failure of the same call would. + * + * @param sig The signal that exposes the result. + * @param pending The pending result. + * @param name Name of the function being evaluated. + * @param abortSignal Signal that marks the evaluation as stale. + */ + private settleIntoSignal( + sig: Signal, + pending: PromiseLike, + name: string, + abortSignal?: AbortSignal, + ): void { + Promise.resolve(pending).then( + value => { + if (!abortSignal?.aborted) { + setValue(sig, value as V); + } + }, + (e: unknown) => { + if (!abortSignal?.aborted) { + this.dispatchExpressionError(e, name); + setValue(sig, undefined); + } + }, + ); + } + /** * Dispatches an evaluation failure to the surface as an `EXPRESSION_ERROR`. * diff --git a/typescript/core/web_core/src/rpc/rpc-handler.ts b/typescript/core/web_core/src/rpc/rpc-handler.ts index 72c81bc663..42d8578c3a 100644 --- a/typescript/core/web_core/src/rpc/rpc-handler.ts +++ b/typescript/core/web_core/src/rpc/rpc-handler.ts @@ -28,6 +28,7 @@ import { CallAgentFunctionMessage, } from '../v1_0/schema/renderer-to-agent.js'; import {isCatalogVersionCompatible} from '../processing/adapters/base.js'; +import {settleDeep} from '../common/settle_deep.js'; import {PayloadValidator} from '../validation/payload-validator.js'; import {A2uiRpcError, RpcErrorCode} from '../errors.js'; @@ -203,10 +204,28 @@ export class RpcHandler { return errorResponse; } + // A nested asynchronous call leaves a pending Promise in the resolved + // arguments; wait for it before validating them. + let settledArgs: Record | undefined; + try { + settledArgs = (await settleDeep(resolvedArgsResult.args)) as + | Record + | undefined; + } catch (err: unknown) { + const errorResponse = this.createResponseError( + functionCallId, + RpcErrorCode.EXECUTION_ERROR, + this.extractErrorMessage(err) || 'An error occurred during function execution.', + version, + ); + await this.emitOutboundResponse(errorResponse); + return errorResponse; + } + const parsedArgsResult = this.parseArguments( resolved.catalog, resolved.funcImpl, - resolvedArgsResult.args, + settledArgs, call, ); if ('error' in parsedArgsResult) { @@ -492,7 +511,7 @@ export class RpcHandler { }); try { for (const [key, val] of Object.entries(args)) { - resolvedArgs[key] = context.resolveDynamicValue(val, 0, isUserActivated); + resolvedArgs[key] = context.resolveDynamicValue(val, 1, isUserActivated); } } catch (err: unknown) { expressionError = diff --git a/typescript/core/web_core/src/rpc/rpc.test.ts b/typescript/core/web_core/src/rpc/rpc.test.ts index 970e395082..5b9d716bdc 100644 --- a/typescript/core/web_core/src/rpc/rpc.test.ts +++ b/typescript/core/web_core/src/rpc/rpc.test.ts @@ -487,6 +487,81 @@ describe('Stage 3 (Sauce-TS) Bidirectional RPC & @index Function Verification', ); }); + it('awaits an asynchronous nested call in the arguments before validating a callRendererFunction', async () => { + const asyncEchoImpl = createFunctionImplementation( + { + name: 'asyncEcho', + returnType: 'any' as const, + schema: z.object({value: z.string()}), + allowedCallers: 'rendererOrAgent' as const, + }, + async args => { + await Promise.resolve(); + return args.value; + }, + ); + const catalog = new Catalog('basic', '1.0', [], [customRpcImpl, asyncEchoImpl]); + const handler = new RpcHandler({catalogs: [catalog]}); + const surface = new SurfaceModel('s1', catalog); + const context = new DataContext(surface, '/'); + + const response = await handler.handleCallRendererFunction( + { + version: 'v1.0' as const, + callRendererFunction: { + functionCallId: 'rpc-async-arg', + callFunction: { + '@call': 'customRpc', + catalogId: 'basic', + args: {text: {'@call': 'asyncEcho', args: {value: 'settled'}}}, + }, + }, + } as any, + context, + false, + ); + assert.strictEqual(response.rendererFunctionResponse.error, undefined); + assert.strictEqual(response.rendererFunctionResponse.value, 'Processed: settled'); + }); + + it('answers with an error when an asynchronous nested argument rejects', async () => { + const asyncFailImpl = createFunctionImplementation( + { + name: 'asyncFail', + returnType: 'any' as const, + schema: z.object({}), + allowedCallers: 'rendererOrAgent' as const, + }, + async () => { + await Promise.resolve(); + throw new Error('upstream failed'); + }, + ); + const catalog = new Catalog('basic', '1.0', [], [customRpcImpl, asyncFailImpl]); + const handler = new RpcHandler({catalogs: [catalog]}); + const surface = new SurfaceModel('s1', catalog); + const context = new DataContext(surface, '/'); + + const response = await handler.handleCallRendererFunction( + { + version: 'v1.0' as const, + callRendererFunction: { + functionCallId: 'rpc-async-fail', + callFunction: { + '@call': 'customRpc', + catalogId: 'basic', + args: {text: {'@call': 'asyncFail', args: {}}}, + }, + }, + } as any, + context, + false, + ); + assert.strictEqual(response.rendererFunctionResponse.error?.code, RpcErrorCode.EXECUTION_ERROR); + assert.match(response.rendererFunctionResponse.error?.message ?? '', /upstream failed/); + assert.strictEqual(response.rendererFunctionResponse.value, undefined); + }); + it('formats A2uiRpcError with the blueprint-standard (message, code, functionCallId, details) signature', () => { const standardErr = new A2uiRpcError('Something failed', RpcErrorCode.TIMEOUT, 'call-1'); assert.strictEqual(standardErr.code, 'TIMEOUT');