From ef953ebc1c7f63bddfc69a7e9022c76691b352ab Mon Sep 17 00:00:00 2001 From: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Date: Sat, 25 Jul 2026 00:21:13 +0800 Subject: [PATCH] fix(automation): flow string templates serialize object tokens readably, never [object Object] (#3450) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A flow string field embedding an object-valued token — notably the engine's `$error` ({nodeId, message, ...}) in a fault handler's notify body — rendered as [object Object]: interpolateString's multi-token branch and notify-node both coerced via String(). - New shared stringifyForTemplate helper (builtin/template.ts): objects/arrays JSON-serialized (legible, still carries the message), primitives pass through, null/undefined → ''. - interpolateString embedded branch + notify-node title/body use it. Sole-token branch still returns the raw value (typed fields keep their type); {$error.message} still resolves to the message string. Split from #3425 (readonly-strip half shipped in #3465). Co-Authored-By: Claude Fable 5 --- .changeset/flow-error-object-serialization.md | 21 +++++++ .../src/builtin/notify-node.ts | 9 ++- .../src/builtin/template-object-token.test.ts | 63 +++++++++++++++++++ .../src/builtin/template.ts | 36 +++++++++-- 4 files changed, 120 insertions(+), 9 deletions(-) create mode 100644 .changeset/flow-error-object-serialization.md create mode 100644 packages/services/service-automation/src/builtin/template-object-token.test.ts diff --git a/.changeset/flow-error-object-serialization.md b/.changeset/flow-error-object-serialization.md new file mode 100644 index 0000000000..2f1fd771e2 --- /dev/null +++ b/.changeset/flow-error-object-serialization.md @@ -0,0 +1,21 @@ +--- +"@objectstack/service-automation": patch +--- + +fix(automation): flow string templates serialize object tokens readably, never `[object Object]` (#3450) + +A flow string field that embeds an object-valued token — most notably the +engine's `$error` (`{nodeId, message, ...}`, set on a failed step) in a fault +handler's notify body — rendered as the useless `[object Object]`. The +multi-token branch of `interpolateString` coerced every value with `String()`, +and `notify-node` did the same for a sole `{$error}` token. + +- New shared `stringifyForTemplate` helper (`builtin/template.ts`): objects and + arrays are JSON-serialized (so the text stays legible and still carries the + message), primitives pass through, `null`/`undefined` render as ''. +- `interpolateString`'s embedded-substitution branch and `notify-node`'s + title/body coercion use it. The sole-token branch still returns the raw value + (typed config fields keep their type), and `{$error.message}` still resolves + to just the message string — the documented, cleanest author form. + +Split from #3425 (the readonly-strip half shipped in #3465). diff --git a/packages/services/service-automation/src/builtin/notify-node.ts b/packages/services/service-automation/src/builtin/notify-node.ts index 5f6532c2e5..cb341194b4 100644 --- a/packages/services/service-automation/src/builtin/notify-node.ts +++ b/packages/services/service-automation/src/builtin/notify-node.ts @@ -4,7 +4,7 @@ import type { PluginContext } from '@objectstack/core'; import type { AutomationContext } from '@objectstack/spec/contracts'; import { defineActionDescriptor } from '@objectstack/spec/automation'; import type { AutomationEngine } from '../engine.js'; -import { interpolate, type VariableMap } from './template.js'; +import { interpolate, stringifyForTemplate, type VariableMap } from './template.js'; /** * Structural view of `@objectstack/service-messaging`'s service (ADR-0012), @@ -141,8 +141,11 @@ export function registerNotifyNode(engine: AutomationEngine, ctx: PluginContext) const cfg = (node.config ?? {}) as Record; const recipients = toStringList(interpolate(cfg.recipients ?? cfg.to ?? [], variables, context)); - const title = String(interpolate(cfg.title ?? cfg.subject ?? '', variables, context) ?? ''); - const body = String(interpolate(cfg.message ?? cfg.body ?? '', variables, context) ?? ''); + // stringifyForTemplate (not String()): a sole-token `{$error}` resolves + // to the engine's error OBJECT, which String() would render as the + // useless `[object Object]` (#3450). Serialize it readably instead. + const title = stringifyForTemplate(interpolate(cfg.title ?? cfg.subject ?? '', variables, context)); + const body = stringifyForTemplate(interpolate(cfg.message ?? cfg.body ?? '', variables, context)); const channels = toStringList(cfg.channels); const topic = cfg.topic ? String(cfg.topic) : undefined; const severity = cfg.severity ? String(cfg.severity) : undefined; diff --git a/packages/services/service-automation/src/builtin/template-object-token.test.ts b/packages/services/service-automation/src/builtin/template-object-token.test.ts new file mode 100644 index 0000000000..5d302cf5d1 --- /dev/null +++ b/packages/services/service-automation/src/builtin/template-object-token.test.ts @@ -0,0 +1,63 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #3450 — a flow string template that embeds an OBJECT-valued token (most + * notably the engine's `$error` = `{nodeId, message, ...}`) must never render + * as the useless `[object Object]`. Embedded object/array tokens are JSON- + * serialized so the text stays legible and still carries the message. + */ +import { describe, it, expect } from 'vitest'; +import { interpolateString, stringifyForTemplate } from './template.js'; + +const ctx = {} as any; +const errObj = { nodeId: 'create_contact', message: 'crm_contact is required' }; +const vars = new Map([ + ['$error', errObj], + ['count', 3], + ['flag', true], +]); + +describe('stringifyForTemplate (#3450)', () => { + it('serializes objects and arrays as JSON, never [object Object]', () => { + expect(stringifyForTemplate(errObj)).toBe(JSON.stringify(errObj)); + expect(stringifyForTemplate(errObj)).not.toBe('[object Object]'); + expect(stringifyForTemplate([1, 2])).toBe('[1,2]'); + }); + + it('passes primitives through and renders null/undefined as empty', () => { + expect(stringifyForTemplate('hi')).toBe('hi'); + expect(stringifyForTemplate(3)).toBe('3'); + expect(stringifyForTemplate(true)).toBe('true'); + expect(stringifyForTemplate(null)).toBe(''); + expect(stringifyForTemplate(undefined)).toBe(''); + }); + + it('falls back without throwing on a circular object', () => { + const circular: Record = {}; + circular.self = circular; + expect(() => stringifyForTemplate(circular)).not.toThrow(); + }); +}); + +describe('interpolateString with object tokens (#3450)', () => { + it('renders an embedded $error object as readable JSON, not [object Object]', () => { + const out = interpolateString('Conversion failed: {$error}', vars, ctx) as string; + expect(out).not.toContain('[object Object]'); + expect(out).toContain('crm_contact is required'); + expect(out).toBe(`Conversion failed: ${JSON.stringify(errObj)}`); + }); + + it('still resolves the dotted path to just the message string', () => { + expect(interpolateString('Failed: {$error.message}', vars, ctx)).toBe('Failed: crm_contact is required'); + }); + + it('preserves the raw object for a sole token (type preserved)', () => { + // A single-token template returns the raw value so typed config fields keep + // their type; only EMBEDDED substitution coerces to text. + expect(interpolateString('{$error}', vars, ctx)).toEqual(errObj); + }); + + it('leaves primitive embedded tokens unchanged', () => { + expect(interpolateString('n={count};f={flag}', vars, ctx)).toBe('n=3;f=true'); + }); +}); diff --git a/packages/services/service-automation/src/builtin/template.ts b/packages/services/service-automation/src/builtin/template.ts index f827f73635..142b8d5b72 100644 --- a/packages/services/service-automation/src/builtin/template.ts +++ b/packages/services/service-automation/src/builtin/template.ts @@ -118,10 +118,36 @@ function resolveToken(token: string, variables: VariableMap, context: Automation } } +/** + * Coerce a resolved token value to its string form for EMBEDDED substitution — + * a token inside a larger string, where the result is definitionally text. + * + * A bare `String(value)` renders an object/array as the useless `[object Object]` + * / comma-joined form. That is the #3450 trap: a fault handler whose message + * embeds the engine-set `$error` object (`{nodeId, message, ...}`) surfaced as + * `[object Object]` instead of a readable error. Objects/arrays are JSON- + * serialized so the text stays legible (and still carries the message); an + * author who wants only the message uses the dotted path (`{$error.message}`). + * `null`/`undefined` render as '' (an unresolved token contributes nothing). + */ +export function stringifyForTemplate(value: unknown): string { + if (value === null || value === undefined) return ''; + if (typeof value === 'object') { + try { + return JSON.stringify(value); + } catch { + // Circular / non-serializable — fall back rather than throw mid-flow. + return String(value); + } + } + return String(value); +} + /** * Replace `{...}` tokens in a string with resolved values. * - When the entire string is a single token, returns the raw value (preserving type). - * - Otherwise concatenates string substitutions, with `null`/`undefined` rendered as ''. + * - Otherwise concatenates string substitutions, with `null`/`undefined` rendered as '' + * and objects/arrays JSON-serialized (never `[object Object]`, #3450). */ export function interpolateString( input: string, @@ -134,11 +160,9 @@ export function interpolateString( const value = resolveToken(single[1], variables, context); return value; } - return input.replace(/\{([^{}]+)\}/g, (_match, expr) => { - const value = resolveToken(expr, variables, context); - if (value === undefined || value === null) return ''; - return String(value); - }); + return input.replace(/\{([^{}]+)\}/g, (_match, expr) => + stringifyForTemplate(resolveToken(expr, variables, context)), + ); } /**