fix: prevent raw workflowInput from overwriting coerced start block values (#3347)

buildUnifiedStartOutput and buildIntegrationTriggerOutput first populate
output with schema-coerced structuredInput values (via coerceValue), then
iterate workflowInput and unconditionally overwrite those keys with raw
strings. This causes typed values (arrays, objects, numbers, booleans)
passed to child workflows to arrive as stringified versions.

Add a structuredKeys guard so the workflowInput loop skips keys already
set by the coerced structuredInput, letting coerceValue's type-aware
parsing (JSON.parse for objects/arrays, Number() for numbers, etc.)
take effect.

Fixes #3105

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Waleed
2026-02-26 07:13:19 -08:00
committed by GitHub
co-authored by Claude Opus 4.6
parent 5ae0115444
commit dde098e8e5
2 changed files with 116 additions and 0 deletions
+110
View File
@@ -215,5 +215,115 @@ describe('start-block utilities', () => {
expect(output.customField).toBe('defaultValue')
})
it.concurrent('preserves coerced types for unified start payload', () => {
const block = createBlock('start_trigger', 'start', {
subBlocks: {
inputFormat: {
value: [
{ name: 'conversation_id', type: 'number' },
{ name: 'sender', type: 'object' },
{ name: 'is_active', type: 'boolean' },
],
},
},
})
const resolution = {
blockId: 'start',
block,
path: StartBlockPath.UNIFIED,
} as const
const output = buildStartBlockOutput({
resolution,
workflowInput: {
conversation_id: '149',
sender: '{"id":10,"email":"user@example.com"}',
is_active: 'true',
},
})
expect(output.conversation_id).toBe(149)
expect(output.sender).toEqual({ id: 10, email: 'user@example.com' })
expect(output.is_active).toBe(true)
})
it.concurrent(
'prefers coerced inputFormat values over duplicated top-level workflowInput keys',
() => {
const block = createBlock('start_trigger', 'start', {
subBlocks: {
inputFormat: {
value: [
{ name: 'conversation_id', type: 'number' },
{ name: 'sender', type: 'object' },
{ name: 'is_active', type: 'boolean' },
],
},
},
})
const resolution = {
blockId: 'start',
block,
path: StartBlockPath.UNIFIED,
} as const
const output = buildStartBlockOutput({
resolution,
workflowInput: {
input: {
conversation_id: '149',
sender: '{"id":10,"email":"user@example.com"}',
is_active: 'false',
},
conversation_id: '150',
sender: '{"id":99,"email":"wrong@example.com"}',
is_active: 'true',
extra: 'keep-me',
},
})
expect(output.conversation_id).toBe(149)
expect(output.sender).toEqual({ id: 10, email: 'user@example.com' })
expect(output.is_active).toBe(false)
expect(output.extra).toBe('keep-me')
}
)
})
describe('EXTERNAL_TRIGGER path', () => {
it.concurrent('preserves coerced types for integration trigger payload', () => {
const block = createBlock('webhook', 'start', {
subBlocks: {
inputFormat: {
value: [
{ name: 'count', type: 'number' },
{ name: 'payload', type: 'object' },
],
},
},
})
const resolution = {
blockId: 'start',
block,
path: StartBlockPath.EXTERNAL_TRIGGER,
} as const
const output = buildStartBlockOutput({
resolution,
workflowInput: {
count: '5',
payload: '{"event":"push"}',
extra: 'untouched',
},
})
expect(output.count).toBe(5)
expect(output.payload).toEqual({ event: 'push' })
expect(output.extra).toBe('untouched')
})
})
})
+6
View File
@@ -262,6 +262,7 @@ function buildUnifiedStartOutput(
hasStructured: boolean
): NormalizedBlockOutput {
const output: NormalizedBlockOutput = {}
const structuredKeys = hasStructured ? new Set(Object.keys(structuredInput)) : null
if (hasStructured) {
for (const [key, value] of Object.entries(structuredInput)) {
@@ -272,6 +273,9 @@ function buildUnifiedStartOutput(
if (isPlainObject(workflowInput)) {
for (const [key, value] of Object.entries(workflowInput)) {
if (key === 'onUploadError') continue
// Skip keys already set by schema-coerced structuredInput to
// prevent raw workflowInput strings from overwriting typed values.
if (structuredKeys?.has(key)) continue
// Runtime values override defaults (except undefined/null which mean "not provided")
if (value !== undefined && value !== null) {
output[key] = value
@@ -384,6 +388,7 @@ function buildIntegrationTriggerOutput(
hasStructured: boolean
): NormalizedBlockOutput {
const output: NormalizedBlockOutput = {}
const structuredKeys = hasStructured ? new Set(Object.keys(structuredInput)) : null
if (hasStructured) {
for (const [key, value] of Object.entries(structuredInput)) {
@@ -393,6 +398,7 @@ function buildIntegrationTriggerOutput(
if (isPlainObject(workflowInput)) {
for (const [key, value] of Object.entries(workflowInput)) {
if (structuredKeys?.has(key)) continue
if (value !== undefined && value !== null) {
output[key] = value
} else if (!Object.hasOwn(output, key)) {