Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions .changeset/flow-error-object-serialization.md
Original file line number Diff line number Diff line change
@@ -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).
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down Expand Up @@ -141,8 +141,11 @@ export function registerNotifyNode(engine: AutomationEngine, ctx: PluginContext)
const cfg = (node.config ?? {}) as Record<string, unknown>;

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;
Expand Down
Original file line number Diff line number Diff line change
@@ -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<string, unknown>([
['$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<string, unknown> = {};
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');
});
});
36 changes: 30 additions & 6 deletions packages/services/service-automation/src/builtin/template.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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)),
);
}

/**
Expand Down