From 43eec00e6bf6e971737e082ec21a42402f6596c3 Mon Sep 17 00:00:00 2001 From: Rohit Gahlawat <283466839+Rohit-Gahlawat@users.noreply.github.com> Date: Fri, 19 Jun 2026 15:26:24 +0530 Subject: [PATCH] fix(core): Keep $fromAI placeholders intact on fields with expressions disabled (#31681) Co-authored-by: Alexander Gekov <40495748+alexander-gekov@users.noreply.github.com> --- packages/workflow/src/node-helpers.ts | 4 +- packages/workflow/test/node-helpers.test.ts | 64 +++++++++++++++++++++ 2 files changed, 67 insertions(+), 1 deletion(-) diff --git a/packages/workflow/src/node-helpers.ts b/packages/workflow/src/node-helpers.ts index 20b2f26062c..23022392d39 100644 --- a/packages/workflow/src/node-helpers.ts +++ b/packages/workflow/src/node-helpers.ts @@ -9,6 +9,7 @@ import { v4 as uuid } from 'uuid'; import { EXECUTE_WORKFLOW_NODE_TYPE, WORKFLOW_TOOL_LANGCHAIN_NODE_TYPE } from './constants'; import { UnexpectedError, UserError } from './errors'; import { isExpression } from './expressions/expression-helpers'; +import { isFromAIOnlyExpression } from './from-ai-parse-utils'; import { NodeConnectionTypes } from './interfaces'; import type { FieldType, @@ -823,7 +824,8 @@ export function getNodeParameters( // Strip expression prefix if noDataExpression is true if (nodeProperties.noDataExpression && nodeParameters[nodeProperties.name] !== undefined) { const value = nodeParameters[nodeProperties.name]; - if (isExpression(value)) { + // A lone $fromAI() placeholder must keep its "=" or the AI tool call never resolves it (#30531) + if (isExpression(value) && !isFromAIOnlyExpression(value)) { nodeParameters[nodeProperties.name] = value.slice(1); nodeParametersFull[nodeProperties.name] = nodeParameters[nodeProperties.name]; } diff --git a/packages/workflow/test/node-helpers.test.ts b/packages/workflow/test/node-helpers.test.ts index cc7fa7f93e9..ea47a6e6496 100644 --- a/packages/workflow/test/node-helpers.test.ts +++ b/packages/workflow/test/node-helpers.test.ts @@ -6729,6 +6729,70 @@ describe('NodeHelpers', () => { // When undefined, the default value (empty string) is used expect(result?.resource).toBe(''); }); + + describe('$fromAI placeholder carve-out', () => { + const resolveQuery = (query: string) => { + const properties: INodeProperties[] = [ + { + name: 'query', + displayName: 'Query', + type: 'string', + default: '', + noDataExpression: true, + }, + ]; + + const nodeValues: Record = { query }; + + const node: INode = { + id: 'test-123', + name: 'Test', + type: 'n8n-nodes-base.test', + typeVersion: 1, + position: [0, 0], + parameters: nodeValues, + credentials: {}, + }; + + const description: INodeTypeDescription = { + displayName: 'Test', + name: 'Test', + group: [], + version: 1, + description: 'Test', + defaults: {}, + inputs: [], + outputs: [], + properties, + }; + + return getNodeParameters(properties, nodeValues, true, false, node, description)?.query; + }; + + it('keeps a lone $fromAI() placeholder intact', () => { + const query = "=$fromAI('sqlQuery', 'The SQL query to execute', 'string')"; + expect(resolveQuery(query)).toBe(query); + }); + + it('keeps a $fromAI() placeholder wrapped in {{ }} intact', () => { + const query = "={{ $fromAI('sqlQuery') }}"; + expect(resolveQuery(query)).toBe(query); + }); + + it('still strips a plain expression that is not a $fromAI() call', () => { + expect(resolveQuery('=$env.SECRET')).toBe('$env.SECRET'); + }); + + it('still strips $fromAI() concatenated with another expression', () => { + expect(resolveQuery("=$fromAI('x') + $env.SECRET")).toBe("$fromAI('x') + $env.SECRET"); + }); + + it('still strips $fromAI() with an interpolated value in its argument', () => { + // Split the `${` token so the lint rule doesn't read it as interpolation. + const interpolated = '`$' + '{$env.SECRET}`'; + expect(resolveQuery(`=$fromAI(${interpolated})`)).toBe(`$fromAI(${interpolated})`); + }); + }); }); describe('getNodeParameters filter defaults', () => {