From b3687a88534bcab30b8a148c294372123d4a3685 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Wed, 25 Feb 2026 19:48:34 -0800 Subject: [PATCH] fix: prevent raw workflowInput from overwriting coerced start block values 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 --- apps/sim/executor/utils/start-block.test.ts | 110 ++++++++++++++++++++ apps/sim/executor/utils/start-block.ts | 6 ++ 2 files changed, 116 insertions(+) diff --git a/apps/sim/executor/utils/start-block.test.ts b/apps/sim/executor/utils/start-block.test.ts index 4c6fd708e0..9a3942cbee 100644 --- a/apps/sim/executor/utils/start-block.test.ts +++ b/apps/sim/executor/utils/start-block.test.ts @@ -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') + }) }) }) diff --git a/apps/sim/executor/utils/start-block.ts b/apps/sim/executor/utils/start-block.ts index 3b7982f934..5ac0936e1b 100644 --- a/apps/sim/executor/utils/start-block.ts +++ b/apps/sim/executor/utils/start-block.ts @@ -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)) {