diff --git a/packages/@n8n/expression-runtime/src/__tests__/typed-rpc.test.ts b/packages/@n8n/expression-runtime/src/__tests__/typed-rpc.test.ts index fdf916b637b..9c5138d6d1d 100644 --- a/packages/@n8n/expression-runtime/src/__tests__/typed-rpc.test.ts +++ b/packages/@n8n/expression-runtime/src/__tests__/typed-rpc.test.ts @@ -505,3 +505,97 @@ describe('Typed RPC: $items() routes via getItems', () => { expect(result).toBeUndefined(); }); }); + +describe('Typed RPC: $fromAI() routes via fromAi', () => { + let evaluator: ExpressionEvaluator; + const caller = {}; + + beforeAll(async () => { + evaluator = new ExpressionEvaluator({ + createBridge: () => new IsolatedVmBridge({ timeout: 5000 }), + maxCodeCacheSize: 64, + }); + await evaluator.initialize(); + await evaluator.acquire(caller); + }); + + afterAll(async () => { + await evaluator.release(caller); + await evaluator.dispose(); + }); + + it('returns the resolved value of data.$fromAI(name)', () => { + const data: Record = { + $fromAI: (name?: string) => `resolved:${name}`, + }; + + const result = evaluator.evaluate("{{ $fromAI('placeholder_one') }}", data, caller); + expect(result).toBe('resolved:placeholder_one'); + }); + + it('forwards name, description, type, and defaultValue verbatim', () => { + const calls: Array = []; + const data: Record = { + $fromAI: (...args: unknown[]) => { + calls.push(args); + return 'ok'; + }, + }; + + evaluator.evaluate("{{ $fromAI('a') }}", data, caller); + evaluator.evaluate("{{ $fromAI('a', 'description') }}", data, caller); + evaluator.evaluate("{{ $fromAI('a', 'description', 'number') }}", data, caller); + evaluator.evaluate("{{ $fromAI('a', 'description', 'number', 42) }}", data, caller); + + expect(calls).toEqual([ + ['a', undefined, undefined, undefined], + ['a', 'description', undefined, undefined], + ['a', 'description', 'number', undefined], + ['a', 'description', 'number', 42], + ]); + }); + + it('forwards arbitrary defaultValue shapes (number, string, boolean, null, object)', () => { + // `defaultValue` is `z.unknown()` because the host applies no shape + // constraint — it just returns the fallback via `??`. Verify the + // bridge structured-clones each shape through to the host. + const calls: Array = []; + const data: Record = { + $fromAI: (...args: unknown[]) => { + calls.push(args); + return 'ok'; + }, + }; + + evaluator.evaluate("{{ $fromAI('a', '', 'string', 42) }}", data, caller); + evaluator.evaluate("{{ $fromAI('a', '', 'string', 'fallback') }}", data, caller); + evaluator.evaluate("{{ $fromAI('a', '', 'string', true) }}", data, caller); + evaluator.evaluate("{{ $fromAI('a', '', 'string', null) }}", data, caller); + evaluator.evaluate("{{ $fromAI('a', '', 'string', { nested: 'value' }) }}", data, caller); + + expect(calls.map((c) => c[3])).toEqual([42, 'fallback', true, null, { nested: 'value' }]); + }); + + it('$fromAi (mid-case) alias routes through the same handler', () => { + const data: Record = { + $fromAI: (name?: string) => `via-aliases:${name}`, + }; + + expect(evaluator.evaluate("{{ $fromAi('x') }}", data, caller)).toBe('via-aliases:x'); + }); + + it('$fromai (all-lower) alias routes through the same handler', () => { + const data: Record = { + $fromAI: (name?: string) => `via-aliases:${name}`, + }; + + expect(evaluator.evaluate("{{ $fromai('x') }}", data, caller)).toBe('via-aliases:x'); + }); + + it('handles missing data.$fromAI gracefully (returns undefined)', () => { + const data: Record = {}; + + const result = evaluator.evaluate("{{ $fromAI('placeholder') }}", data, caller); + expect(result).toBeUndefined(); + }); +}); diff --git a/packages/@n8n/expression-runtime/src/bridge/__tests__/bridge-messages.test.ts b/packages/@n8n/expression-runtime/src/bridge/__tests__/bridge-messages.test.ts index b3c38f6c16f..89d66639ff6 100644 --- a/packages/@n8n/expression-runtime/src/bridge/__tests__/bridge-messages.test.ts +++ b/packages/@n8n/expression-runtime/src/bridge/__tests__/bridge-messages.test.ts @@ -59,6 +59,17 @@ describe('bridgeMessageSchema', () => { expect(parsed.type).toBe('getItems'); }); + it('parses a valid fromAi envelope', () => { + const parsed = bridgeMessageSchema.parse({ + type: 'fromAi', + name: 'placeholder', + description: 'A description', + valueType: 'string', + defaultValue: 'fallback', + }); + expect(parsed.type).toBe('fromAi'); + }); + it('rejects an unknown discriminator value', () => { expect(() => bridgeMessageSchema.parse({ type: 'evalArbitrary', nodeName: 'Foo' })).toThrow(); }); @@ -78,6 +89,43 @@ describe('bridgeMessageSchema', () => { ); }); + describe('fromAi', () => { + it('accepts a minimal envelope (type only)', () => { + // `name` is optional in the schema so empty calls reach the host's + // friendly `ExpressionError("Add a key, e.g. $fromAI(...)")` rather + // than a generic zod error. + expect(() => bridgeMessageSchema.parse({ type: 'fromAi' })).not.toThrow(); + }); + + it('accepts arbitrary defaultValue shapes', () => { + // defaultValue is z.unknown() — host applies no shape constraint. + for (const defaultValue of [42, 'str', true, null, { nested: 1 }, [1, 2]]) { + expect(() => + bridgeMessageSchema.parse({ type: 'fromAi', name: 'a', defaultValue }), + ).not.toThrow(); + } + }); + + it('rejects non-string name', () => { + expect(() => bridgeMessageSchema.parse({ type: 'fromAi', name: 123 })).toThrow(); + }); + + it('rejects non-string description / valueType', () => { + expect(() => + bridgeMessageSchema.parse({ type: 'fromAi', name: 'a', description: 1 }), + ).toThrow(); + expect(() => + bridgeMessageSchema.parse({ type: 'fromAi', name: 'a', valueType: 1 }), + ).toThrow(); + }); + + it('rejects extra fields (.strict)', () => { + expect(() => + bridgeMessageSchema.parse({ type: 'fromAi', name: 'a', hijack: 'extra' }), + ).toThrow(); + }); + }); + describe('getItems', () => { it('accepts negative runIndex (host -1 sentinel for "latest")', () => { // Unlike branchIndex/runIndex on getNode*, getItems allows negative diff --git a/packages/@n8n/expression-runtime/src/bridge/bridge-messages.ts b/packages/@n8n/expression-runtime/src/bridge/bridge-messages.ts index 25435321dc3..f52c8e0e5a2 100644 --- a/packages/@n8n/expression-runtime/src/bridge/bridge-messages.ts +++ b/packages/@n8n/expression-runtime/src/bridge/bridge-messages.ts @@ -93,6 +93,39 @@ export const getItemsMessage = z }) .strict(); +/** + * `$fromAI(name, description?, type?, defaultValue?)` — the AI-builder + * placeholder accessor (aliases: `$fromAi`, `$fromai`). + * + * Two deliberate looseness points in this schema, both to preserve host + * contract / parity: + * + * 1. `name` is `z.string().optional()` (not required) so a call missing + * the argument or passing an empty string reaches the host, which + * throws the user-friendly `ExpressionError("Add a key, e.g. $fromAI('placeholder_name')")`. + * Requiring it here would replace that error with a generic zod + * message. The host also validates the regex `[a-zA-Z0-9_-]{0,64}`; + * we don't pre-empt that either. + * 2. `defaultValue` is `z.unknown()` because the host accepts any value + * as the fallback return (`handleFromAi` returns it directly via + * `??`). Structured-clone at the bridge boundary still prevents + * functions and other non-cloneable values from crossing. + * + * `description` and `type` are forwarded even though the host currently + * ignores them (`_description`, `_type`), so the protocol matches the + * documented call signature. + */ +export const fromAiMessage = z + .object({ + type: z.literal('fromAi'), + name: z.string().optional(), + description: z.string().optional(), + valueType: z.string().optional(), + // `z.unknown()` already accepts `undefined`, so no `.optional()` needed. + defaultValue: z.unknown(), + }) + .strict(); + /** * The full set of messages the bridge will accept. Discriminator is `type`. * @@ -108,6 +141,7 @@ export const bridgeMessageSchema = z.discriminatedUnion('type', [ getInputLastMessage, getInputAllMessage, getItemsMessage, + fromAiMessage, ]); export type BridgeMessage = z.infer; diff --git a/packages/@n8n/expression-runtime/src/bridge/isolated-vm-bridge.ts b/packages/@n8n/expression-runtime/src/bridge/isolated-vm-bridge.ts index 4b3fe5c0309..09dd9141548 100644 --- a/packages/@n8n/expression-runtime/src/bridge/isolated-vm-bridge.ts +++ b/packages/@n8n/expression-runtime/src/bridge/isolated-vm-bridge.ts @@ -496,6 +496,8 @@ export class IsolatedVmBridge implements RuntimeBridge { return this.handleGetInputAll(data); case 'getItems': return this.handleGetItems(msg, data); + case 'fromAi': + return this.handleFromAi(msg, data); default: { // Unreachable at runtime — zod rejects unknown `type` values // before the switch. The `never` assignment is the compile-time @@ -588,6 +590,29 @@ export class IsolatedVmBridge implements RuntimeBridge { return data.$items?.(msg.nodeName, msg.outputIndex, msg.runIndex); } + /** + * Handler for `$fromAI(name, description?, type?, defaultValue?)` and its + * `$fromAi` / `$fromai` aliases. Reads the literal `$fromAI` property + * off `data` (host-wired) and forwards the args. The host validates + * `name` (required + regex) and applies its own resolution / fallback + * logic, so empty / invalid names surface as the host's structured + * `ExpressionError` rather than a generic zod parse error. + * + * Note: `msg.valueType` maps to the host's third positional parameter + * (`_type` in `WorkflowDataProxy.handleFromAi`). The bridge protocol + * renames it to avoid collision with the `type` discriminator on the + * envelope — the host parameter currently goes unused, but if it ever + * gains a name (`type`), this mapping should stay explicit. + * + * @private + */ + private handleFromAi( + msg: Extract, + data: WorkflowData, + ): unknown { + return data.$fromAI?.(msg.name, msg.description, msg.valueType, msg.defaultValue); + } + /** * Execute JavaScript code in the isolated context. * diff --git a/packages/@n8n/expression-runtime/src/runtime/context.ts b/packages/@n8n/expression-runtime/src/runtime/context.ts index 3c6651a685f..b847a714475 100644 --- a/packages/@n8n/expression-runtime/src/runtime/context.ts +++ b/packages/@n8n/expression-runtime/src/runtime/context.ts @@ -268,6 +268,30 @@ export function buildContext( return result; }; + // $fromAI / $fromAi / $fromai — AI-builder placeholder accessor. + // All three host aliases route to the same `handleFromAi` callback; + // the typed-RPC envelope is identical regardless of which name the + // expression used. `name` is forwarded as-is — host validates it + // (required, regex-restricted) and emits a structured `ExpressionError` + // on bad input. + const sendFromAi = ( + name?: string, + description?: string, + valueType?: string, + defaultValue?: unknown, + ) => { + const result = callbacks.callHost.applySync( + null, + [{ type: 'fromAi', name, description, valueType, defaultValue }], + { arguments: { copy: true }, result: { copy: true } }, + ); + throwIfErrorSentinel(result); + return result; + }; + target.$fromAI = sendFromAi; + target.$fromAi = sendFromAi; + target.$fromai = sendFromAi; + // ------------------------------------------------------------------------- // Resolve an unknown key from the host. Called by the proxy's has/get traps // for keys not already on the target. The resolved value is cached on target diff --git a/packages/@n8n/expression-runtime/src/types/evaluator.ts b/packages/@n8n/expression-runtime/src/types/evaluator.ts index 7575c82b366..451ce569752 100644 --- a/packages/@n8n/expression-runtime/src/types/evaluator.ts +++ b/packages/@n8n/expression-runtime/src/types/evaluator.ts @@ -142,10 +142,27 @@ export interface InputProxy { * primitives (`getValueAtPath`, `getArrayElement`), which read paths off * the index signature without needing per-key types. */ +/** + * Signature shared by `$fromAI`, `$fromAi`, and `$fromai` — the three + * host-side aliases that resolve to the same `handleFromAi` callback in + * `WorkflowDataProxy`. The `name` argument is optional in the type so + * empty / missing calls reach the host, which throws a friendly + * `ExpressionError` rather than a generic zod / runtime error. + */ +export type FromAi = ( + name?: string, + description?: string, + valueType?: string, + defaultValue?: unknown, +) => unknown; + export interface WorkflowData { $?: (nodeName: string) => NodeProxy | null | undefined; $input?: InputProxy; $items?: (nodeName?: string, outputIndex?: number, runIndex?: number) => unknown; + $fromAI?: FromAi; + $fromAi?: FromAi; + $fromai?: FromAi; [key: string]: unknown; }