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.

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/21612?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
This commit is contained in:
Marie
2026-06-22 11:56:01 +02:00
committed by GitHub
parent eefae87296
commit 1eadef8ea0
4 changed files with 47 additions and 1 deletions
@@ -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"}]}]';
@@ -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 }', {
@@ -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);
},
@@ -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;
});
};