From 1eadef8ea01653eea861deba4c9dd50bc6b2deeb Mon Sep 17 00:00:00 2001 From: Marie <51697796+ijreilly@users.noreply.github.com> Date: Mon, 22 Jun 2026 11:56:01 +0200 Subject: [PATCH] fix(workflow): serialize object variables in resolved prompts (#21612) ## Problem When a workflow passes a variable into a text input (e.g. an **AI Agent** prompt) and that variable resolves to an object or array, the resolved string contained `[object Object]` instead of the actual content. The AI then received useless input. ## Cause `resolveString` in the shared variable resolver builds the final string with `String.prototype.replace`. When an embedded `{{variable}}` resolved to an object, the replace callback returned the object directly, which JS coerces to `"[object Object]"`. The rich-text resolver had the same issue via `String(resolvedValue)`. ## Fix When an embedded variable resolves to a non-null object (or array), serialize it with `JSON.stringify` before inserting it into the surrounding string. Primitive values keep their existing coercion behavior, and the single-variable case (`{{message}}` with nothing around it) still returns the raw object so non-string consumers are unaffected. Applied the same guard to both the plain and rich-text variable resolvers for consistency. ## Tests Added cases covering embedded object/array variables in both resolvers, plus a guard test confirming a standalone `{{variable}}` still returns the raw object. Review in cubic --- .../rich-text-variable-resolver.test.ts | 17 +++++++++++++++ .../utils/__tests__/variable-resolver.test.ts | 21 +++++++++++++++++++ .../src/utils/rich-text-variable-resolver.ts | 6 +++++- .../src/utils/variable-resolver.ts | 4 ++++ 4 files changed, 47 insertions(+), 1 deletion(-) diff --git a/packages/twenty-shared/src/utils/__tests__/rich-text-variable-resolver.test.ts b/packages/twenty-shared/src/utils/__tests__/rich-text-variable-resolver.test.ts index 4e2dc815e0..241fc90695 100644 --- a/packages/twenty-shared/src/utils/__tests__/rich-text-variable-resolver.test.ts +++ b/packages/twenty-shared/src/utils/__tests__/rich-text-variable-resolver.test.ts @@ -126,6 +126,23 @@ describe('resolveRichTextVariables', () => { ); }); + it('should serialize object values as JSON instead of [object Object]', () => { + const contextWithObject = { + step1: { + message: { foo: 'bar', count: 1 }, + }, + }; + + const input = + '[{"type":"paragraph","content":[{"type":"variableTag","attrs":{"variable":"{{step1.message}}"}}]}]'; + + const result = resolveRichTextVariables(input, contextWithObject); + + expect(result).toBe( + '[{"type":"paragraph","content":[{"type":"text","text":"{\\"foo\\":\\"bar\\",\\"count\\":1}"}]}]', + ); + }); + it('should preserve regular {{variable}} patterns in non-variableTag contexts', () => { const input = '[{"type":"paragraph","content":[{"type":"text","text":"Regular {{step1.message}} pattern"}]}]'; diff --git a/packages/twenty-shared/src/utils/__tests__/variable-resolver.test.ts b/packages/twenty-shared/src/utils/__tests__/variable-resolver.test.ts index 4912493359..f8013e86a8 100644 --- a/packages/twenty-shared/src/utils/__tests__/variable-resolver.test.ts +++ b/packages/twenty-shared/src/utils/__tests__/variable-resolver.test.ts @@ -94,6 +94,27 @@ describe('resolveInput', () => { expect(resolveInput(input, context)).toEqual(expected); }); + it('should serialize an object variable embedded in a string', () => { + expect(resolveInput('Log this message: {{user}}', context)).toBe( + 'Log this message: {"name":"John Doe","age":30}', + ); + }); + + it('should serialize an array variable embedded in a string', () => { + expect( + resolveInput('Themes: {{preferences}}', { + preferences: ['dark', 'light'], + }), + ).toBe('Themes: ["dark","light"]'); + }); + + it('should return the raw object when the whole string is a single variable', () => { + expect(resolveInput('{{user}}', context)).toEqual({ + name: 'John Doe', + age: 30, + }); + }); + it('does not wrap string variables with double quotes', () => { expect( resolveInput('{ {{test}}: 2 }', { diff --git a/packages/twenty-shared/src/utils/rich-text-variable-resolver.ts b/packages/twenty-shared/src/utils/rich-text-variable-resolver.ts index 1223ab2ed3..dc62c3ba0c 100644 --- a/packages/twenty-shared/src/utils/rich-text-variable-resolver.ts +++ b/packages/twenty-shared/src/utils/rich-text-variable-resolver.ts @@ -41,7 +41,11 @@ export const resolveRichTextVariables = ( (_, variableTypeFirst: string, variableAttrsFirst: string) => { const variable = variableTypeFirst ?? variableAttrsFirst; const resolvedValue = evalFromContext(variable, context); - const textValue = isDefined(resolvedValue) ? String(resolvedValue) : ''; + const textValue = !isDefined(resolvedValue) + ? '' + : typeof resolvedValue === 'object' + ? JSON.stringify(resolvedValue) + : String(resolvedValue); return buildTextNodesWithLineBreaks(textValue); }, diff --git a/packages/twenty-shared/src/utils/variable-resolver.ts b/packages/twenty-shared/src/utils/variable-resolver.ts index b4dec1aa62..bcd0ac2d3e 100644 --- a/packages/twenty-shared/src/utils/variable-resolver.ts +++ b/packages/twenty-shared/src/utils/variable-resolver.ts @@ -78,6 +78,10 @@ const resolveString = ( return input.replace(VARIABLE_PATTERN, (matchedToken, _) => { const processedToken = evalFromContext(matchedToken, context); + if (typeof processedToken === 'object' && processedToken !== null) { + return JSON.stringify(processedToken); + } + return processedToken; }); };