Files
twenty/packages/twenty-shared/src/utils/rich-text-variable-resolver.ts
T
Marie 1eadef8ea0 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. -->
2026-06-22 09:56:01 +00:00

54 lines
1.5 KiB
TypeScript

import { isDefined } from '@/utils/validation';
import { evalFromContext } from './evalFromContext';
const VARIABLE_TAG_PATTERN =
/\{"type":"variableTag","attrs":\{"variable":"(\{\{[^{}]+\}\})"\}\}|\{"attrs":\{"variable":"(\{\{[^{}]+\}\})"\},"type":"variableTag"\}/g;
const escapeJsonString = (text: string): string => {
return JSON.stringify(text).slice(1, -1);
};
const buildTextNodesWithLineBreaks = (text: string): string => {
const lines = text.split('\n');
if (lines.length === 1) {
return `{"type":"text","text":"${escapeJsonString(text)}"}`;
}
return lines
.map((line, index) => {
const textNode = `{"type":"text","text":"${escapeJsonString(line)}"}`;
if (index < lines.length - 1) {
return `${textNode},{"type":"hardBreak"}`;
}
return textNode;
})
.join(',');
};
export const resolveRichTextVariables = (
input: string | null | undefined,
context: Record<string, unknown>,
): string | undefined => {
if (!isDefined(input)) {
return undefined;
}
return input.replace(
VARIABLE_TAG_PATTERN,
(_, variableTypeFirst: string, variableAttrsFirst: string) => {
const variable = variableTypeFirst ?? variableAttrsFirst;
const resolvedValue = evalFromContext(variable, context);
const textValue = !isDefined(resolvedValue)
? ''
: typeof resolvedValue === 'object'
? JSON.stringify(resolvedValue)
: String(resolvedValue);
return buildTextNodesWithLineBreaks(textValue);
},
);
};