Skip to content

Commit c88eeda

Browse files
os-zhuangclaude
andauthored
fix(automation): flow string templates serialize object tokens readably, never [object Object] (#3450) (#3470)
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: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent 7f4a8a1 commit c88eeda

4 files changed

Lines changed: 120 additions & 9 deletions

File tree

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
---
2+
"@objectstack/service-automation": patch
3+
---
4+
5+
fix(automation): flow string templates serialize object tokens readably, never `[object Object]` (#3450)
6+
7+
A flow string field that embeds an object-valued token — most notably the
8+
engine's `$error` (`{nodeId, message, ...}`, set on a failed step) in a fault
9+
handler's notify body — rendered as the useless `[object Object]`. The
10+
multi-token branch of `interpolateString` coerced every value with `String()`,
11+
and `notify-node` did the same for a sole `{$error}` token.
12+
13+
- New shared `stringifyForTemplate` helper (`builtin/template.ts`): objects and
14+
arrays are JSON-serialized (so the text stays legible and still carries the
15+
message), primitives pass through, `null`/`undefined` render as ''.
16+
- `interpolateString`'s embedded-substitution branch and `notify-node`'s
17+
title/body coercion use it. The sole-token branch still returns the raw value
18+
(typed config fields keep their type), and `{$error.message}` still resolves
19+
to just the message string — the documented, cleanest author form.
20+
21+
Split from #3425 (the readonly-strip half shipped in #3465).

packages/services/service-automation/src/builtin/notify-node.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import type { PluginContext } from '@objectstack/core';
44
import type { AutomationContext } from '@objectstack/spec/contracts';
55
import { defineActionDescriptor } from '@objectstack/spec/automation';
66
import type { AutomationEngine } from '../engine.js';
7-
import { interpolate, type VariableMap } from './template.js';
7+
import { interpolate, stringifyForTemplate, type VariableMap } from './template.js';
88

99
/**
1010
* Structural view of `@objectstack/service-messaging`'s service (ADR-0012),
@@ -141,8 +141,11 @@ export function registerNotifyNode(engine: AutomationEngine, ctx: PluginContext)
141141
const cfg = (node.config ?? {}) as Record<string, unknown>;
142142

143143
const recipients = toStringList(interpolate(cfg.recipients ?? cfg.to ?? [], variables, context));
144-
const title = String(interpolate(cfg.title ?? cfg.subject ?? '', variables, context) ?? '');
145-
const body = String(interpolate(cfg.message ?? cfg.body ?? '', variables, context) ?? '');
144+
// stringifyForTemplate (not String()): a sole-token `{$error}` resolves
145+
// to the engine's error OBJECT, which String() would render as the
146+
// useless `[object Object]` (#3450). Serialize it readably instead.
147+
const title = stringifyForTemplate(interpolate(cfg.title ?? cfg.subject ?? '', variables, context));
148+
const body = stringifyForTemplate(interpolate(cfg.message ?? cfg.body ?? '', variables, context));
146149
const channels = toStringList(cfg.channels);
147150
const topic = cfg.topic ? String(cfg.topic) : undefined;
148151
const severity = cfg.severity ? String(cfg.severity) : undefined;
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* #3450 — a flow string template that embeds an OBJECT-valued token (most
5+
* notably the engine's `$error` = `{nodeId, message, ...}`) must never render
6+
* as the useless `[object Object]`. Embedded object/array tokens are JSON-
7+
* serialized so the text stays legible and still carries the message.
8+
*/
9+
import { describe, it, expect } from 'vitest';
10+
import { interpolateString, stringifyForTemplate } from './template.js';
11+
12+
const ctx = {} as any;
13+
const errObj = { nodeId: 'create_contact', message: 'crm_contact is required' };
14+
const vars = new Map<string, unknown>([
15+
['$error', errObj],
16+
['count', 3],
17+
['flag', true],
18+
]);
19+
20+
describe('stringifyForTemplate (#3450)', () => {
21+
it('serializes objects and arrays as JSON, never [object Object]', () => {
22+
expect(stringifyForTemplate(errObj)).toBe(JSON.stringify(errObj));
23+
expect(stringifyForTemplate(errObj)).not.toBe('[object Object]');
24+
expect(stringifyForTemplate([1, 2])).toBe('[1,2]');
25+
});
26+
27+
it('passes primitives through and renders null/undefined as empty', () => {
28+
expect(stringifyForTemplate('hi')).toBe('hi');
29+
expect(stringifyForTemplate(3)).toBe('3');
30+
expect(stringifyForTemplate(true)).toBe('true');
31+
expect(stringifyForTemplate(null)).toBe('');
32+
expect(stringifyForTemplate(undefined)).toBe('');
33+
});
34+
35+
it('falls back without throwing on a circular object', () => {
36+
const circular: Record<string, unknown> = {};
37+
circular.self = circular;
38+
expect(() => stringifyForTemplate(circular)).not.toThrow();
39+
});
40+
});
41+
42+
describe('interpolateString with object tokens (#3450)', () => {
43+
it('renders an embedded $error object as readable JSON, not [object Object]', () => {
44+
const out = interpolateString('Conversion failed: {$error}', vars, ctx) as string;
45+
expect(out).not.toContain('[object Object]');
46+
expect(out).toContain('crm_contact is required');
47+
expect(out).toBe(`Conversion failed: ${JSON.stringify(errObj)}`);
48+
});
49+
50+
it('still resolves the dotted path to just the message string', () => {
51+
expect(interpolateString('Failed: {$error.message}', vars, ctx)).toBe('Failed: crm_contact is required');
52+
});
53+
54+
it('preserves the raw object for a sole token (type preserved)', () => {
55+
// A single-token template returns the raw value so typed config fields keep
56+
// their type; only EMBEDDED substitution coerces to text.
57+
expect(interpolateString('{$error}', vars, ctx)).toEqual(errObj);
58+
});
59+
60+
it('leaves primitive embedded tokens unchanged', () => {
61+
expect(interpolateString('n={count};f={flag}', vars, ctx)).toBe('n=3;f=true');
62+
});
63+
});

packages/services/service-automation/src/builtin/template.ts

Lines changed: 30 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -118,10 +118,36 @@ function resolveToken(token: string, variables: VariableMap, context: Automation
118118
}
119119
}
120120

121+
/**
122+
* Coerce a resolved token value to its string form for EMBEDDED substitution —
123+
* a token inside a larger string, where the result is definitionally text.
124+
*
125+
* A bare `String(value)` renders an object/array as the useless `[object Object]`
126+
* / comma-joined form. That is the #3450 trap: a fault handler whose message
127+
* embeds the engine-set `$error` object (`{nodeId, message, ...}`) surfaced as
128+
* `[object Object]` instead of a readable error. Objects/arrays are JSON-
129+
* serialized so the text stays legible (and still carries the message); an
130+
* author who wants only the message uses the dotted path (`{$error.message}`).
131+
* `null`/`undefined` render as '' (an unresolved token contributes nothing).
132+
*/
133+
export function stringifyForTemplate(value: unknown): string {
134+
if (value === null || value === undefined) return '';
135+
if (typeof value === 'object') {
136+
try {
137+
return JSON.stringify(value);
138+
} catch {
139+
// Circular / non-serializable — fall back rather than throw mid-flow.
140+
return String(value);
141+
}
142+
}
143+
return String(value);
144+
}
145+
121146
/**
122147
* Replace `{...}` tokens in a string with resolved values.
123148
* - When the entire string is a single token, returns the raw value (preserving type).
124-
* - Otherwise concatenates string substitutions, with `null`/`undefined` rendered as ''.
149+
* - Otherwise concatenates string substitutions, with `null`/`undefined` rendered as ''
150+
* and objects/arrays JSON-serialized (never `[object Object]`, #3450).
125151
*/
126152
export function interpolateString(
127153
input: string,
@@ -134,11 +160,9 @@ export function interpolateString(
134160
const value = resolveToken(single[1], variables, context);
135161
return value;
136162
}
137-
return input.replace(/\{([^{}]+)\}/g, (_match, expr) => {
138-
const value = resolveToken(expr, variables, context);
139-
if (value === undefined || value === null) return '';
140-
return String(value);
141-
});
163+
return input.replace(/\{([^{}]+)\}/g, (_match, expr) =>
164+
stringifyForTemplate(resolveToken(expr, variables, context)),
165+
);
142166
}
143167

144168
/**

0 commit comments

Comments
 (0)