Skip to content

Commit c1e64ff

Browse files
waleedlatif1claude
andcommitted
fix: preserve typed values when resolving workflow inputMapping references
The variable resolver stringifies all resolved references via formatValueForBlock, which is correct for blocks that consume string templates (agents, conditions, functions) but destroys type information for workflow blocks whose inputMapping should pass arrays, objects, numbers, and booleans to child workflows as-is. When resolveTemplate encounters a pure reference (e.g. `<webhook.sender>`) targeting a WORKFLOW or WORKFLOW_INPUT block, it now returns the resolved value directly, bypassing string interpolation. Additionally, buildUnifiedStartOutput was allowing raw workflowInput strings to overwrite already-coerced structuredInput values. A structuredKeys guard now prevents this. Fixes #3105 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent d98545d commit c1e64ff

File tree

4 files changed

+346
-3
lines changed

4 files changed

+346
-3
lines changed

apps/sim/executor/utils/start-block.test.ts

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -215,5 +215,115 @@ describe('start-block utilities', () => {
215215

216216
expect(output.customField).toBe('defaultValue')
217217
})
218+
219+
it.concurrent('preserves coerced types for unified start payload', () => {
220+
const block = createBlock('start_trigger', 'start', {
221+
subBlocks: {
222+
inputFormat: {
223+
value: [
224+
{ name: 'conversation_id', type: 'number' },
225+
{ name: 'sender', type: 'object' },
226+
{ name: 'is_active', type: 'boolean' },
227+
],
228+
},
229+
},
230+
})
231+
232+
const resolution = {
233+
blockId: 'start',
234+
block,
235+
path: StartBlockPath.UNIFIED,
236+
} as const
237+
238+
const output = buildStartBlockOutput({
239+
resolution,
240+
workflowInput: {
241+
conversation_id: '149',
242+
sender: '{"id":10,"email":"user@example.com"}',
243+
is_active: 'true',
244+
},
245+
})
246+
247+
expect(output.conversation_id).toBe(149)
248+
expect(output.sender).toEqual({ id: 10, email: 'user@example.com' })
249+
expect(output.is_active).toBe(true)
250+
})
251+
252+
it.concurrent(
253+
'prefers coerced inputFormat values over duplicated top-level workflowInput keys',
254+
() => {
255+
const block = createBlock('start_trigger', 'start', {
256+
subBlocks: {
257+
inputFormat: {
258+
value: [
259+
{ name: 'conversation_id', type: 'number' },
260+
{ name: 'sender', type: 'object' },
261+
{ name: 'is_active', type: 'boolean' },
262+
],
263+
},
264+
},
265+
})
266+
267+
const resolution = {
268+
blockId: 'start',
269+
block,
270+
path: StartBlockPath.UNIFIED,
271+
} as const
272+
273+
const output = buildStartBlockOutput({
274+
resolution,
275+
workflowInput: {
276+
input: {
277+
conversation_id: '149',
278+
sender: '{"id":10,"email":"user@example.com"}',
279+
is_active: 'false',
280+
},
281+
conversation_id: '150',
282+
sender: '{"id":99,"email":"wrong@example.com"}',
283+
is_active: 'true',
284+
extra: 'keep-me',
285+
},
286+
})
287+
288+
expect(output.conversation_id).toBe(149)
289+
expect(output.sender).toEqual({ id: 10, email: 'user@example.com' })
290+
expect(output.is_active).toBe(false)
291+
expect(output.extra).toBe('keep-me')
292+
}
293+
)
294+
})
295+
296+
describe('EXTERNAL_TRIGGER path', () => {
297+
it.concurrent('preserves coerced types for integration trigger payload', () => {
298+
const block = createBlock('webhook', 'start', {
299+
subBlocks: {
300+
inputFormat: {
301+
value: [
302+
{ name: 'count', type: 'number' },
303+
{ name: 'payload', type: 'object' },
304+
],
305+
},
306+
},
307+
})
308+
309+
const resolution = {
310+
blockId: 'start',
311+
block,
312+
path: StartBlockPath.EXTERNAL_TRIGGER,
313+
} as const
314+
315+
const output = buildStartBlockOutput({
316+
resolution,
317+
workflowInput: {
318+
count: '5',
319+
payload: '{"event":"push"}',
320+
extra: 'untouched',
321+
},
322+
})
323+
324+
expect(output.count).toBe(5)
325+
expect(output.payload).toEqual({ event: 'push' })
326+
expect(output.extra).toBe('untouched')
327+
})
218328
})
219329
})

apps/sim/executor/utils/start-block.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -262,6 +262,7 @@ function buildUnifiedStartOutput(
262262
hasStructured: boolean
263263
): NormalizedBlockOutput {
264264
const output: NormalizedBlockOutput = {}
265+
const structuredKeys = hasStructured ? new Set(Object.keys(structuredInput)) : null
265266

266267
if (hasStructured) {
267268
for (const [key, value] of Object.entries(structuredInput)) {
@@ -272,6 +273,9 @@ function buildUnifiedStartOutput(
272273
if (isPlainObject(workflowInput)) {
273274
for (const [key, value] of Object.entries(workflowInput)) {
274275
if (key === 'onUploadError') continue
276+
// Skip keys already set by schema-coerced structuredInput to
277+
// prevent raw workflowInput strings from overwriting typed values.
278+
if (structuredKeys?.has(key)) continue
275279
// Runtime values override defaults (except undefined/null which mean "not provided")
276280
if (value !== undefined && value !== null) {
277281
output[key] = value
@@ -384,6 +388,7 @@ function buildIntegrationTriggerOutput(
384388
hasStructured: boolean
385389
): NormalizedBlockOutput {
386390
const output: NormalizedBlockOutput = {}
391+
const structuredKeys = hasStructured ? new Set(Object.keys(structuredInput)) : null
387392

388393
if (hasStructured) {
389394
for (const [key, value] of Object.entries(structuredInput)) {
@@ -393,6 +398,7 @@ function buildIntegrationTriggerOutput(
393398

394399
if (isPlainObject(workflowInput)) {
395400
for (const [key, value] of Object.entries(workflowInput)) {
401+
if (structuredKeys?.has(key)) continue
396402
if (value !== undefined && value !== null) {
397403
output[key] = value
398404
} else if (!Object.hasOwn(output, key)) {
Lines changed: 208 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,208 @@
1+
import { describe, expect, it } from 'vitest'
2+
import { BlockType } from '@/executor/constants'
3+
import { ExecutionState } from '@/executor/execution/state'
4+
import type { ExecutionContext } from '@/executor/types'
5+
import { VariableResolver } from '@/executor/variables/resolver'
6+
import type { SerializedBlock, SerializedWorkflow } from '@/serializer/types'
7+
8+
function createSerializedBlock(opts: { id: string; name: string; type: string }): SerializedBlock {
9+
return {
10+
id: opts.id,
11+
position: { x: 0, y: 0 },
12+
config: { tool: opts.type, params: {} },
13+
inputs: {},
14+
outputs: {},
15+
metadata: { id: opts.type, name: opts.name },
16+
enabled: true,
17+
}
18+
}
19+
20+
describe('VariableResolver', () => {
21+
it.concurrent('preserves typed values for workflow_input pure references', () => {
22+
const workflow: SerializedWorkflow = {
23+
version: 'test',
24+
blocks: [createSerializedBlock({ id: 'webhook', name: 'webhook', type: BlockType.TRIGGER })],
25+
connections: [],
26+
loops: {},
27+
parallels: {},
28+
}
29+
30+
const state = new ExecutionState()
31+
state.setBlockOutput('webhook', {
32+
conversation_id: 149,
33+
sender: { id: 10, email: 'user@example.com' },
34+
is_active: true,
35+
})
36+
37+
const resolver = new VariableResolver(workflow, {}, state)
38+
const ctx = { blockStates: new Map() } as unknown as ExecutionContext
39+
40+
const workflowInputBlock = createSerializedBlock({
41+
id: 'wf',
42+
name: 'Workflow',
43+
type: BlockType.WORKFLOW_INPUT,
44+
})
45+
46+
const resolved = resolver.resolveInputs(
47+
ctx,
48+
'wf',
49+
{
50+
inputMapping: {
51+
conversation_id: '<webhook.conversation_id>',
52+
sender: '<webhook.sender>',
53+
is_active: '<webhook.is_active>',
54+
},
55+
},
56+
workflowInputBlock
57+
)
58+
59+
expect(resolved.inputMapping.conversation_id).toBe(149)
60+
expect(resolved.inputMapping.sender).toEqual({ id: 10, email: 'user@example.com' })
61+
expect(resolved.inputMapping.is_active).toBe(true)
62+
})
63+
64+
it.concurrent('formats pure references as strings for non-workflow blocks', () => {
65+
const workflow: SerializedWorkflow = {
66+
version: 'test',
67+
blocks: [createSerializedBlock({ id: 'webhook', name: 'webhook', type: BlockType.TRIGGER })],
68+
connections: [],
69+
loops: {},
70+
parallels: {},
71+
}
72+
73+
const state = new ExecutionState()
74+
state.setBlockOutput('webhook', { conversation_id: 149 })
75+
76+
const resolver = new VariableResolver(workflow, {}, state)
77+
const ctx = { blockStates: new Map() } as unknown as ExecutionContext
78+
79+
const apiBlock = createSerializedBlock({
80+
id: 'api',
81+
name: 'API',
82+
type: BlockType.API,
83+
})
84+
85+
const resolved = resolver.resolveInputs(
86+
ctx,
87+
'api',
88+
{ conversation_id: '<webhook.conversation_id>' },
89+
apiBlock
90+
)
91+
92+
expect(resolved.conversation_id).toBe('149')
93+
})
94+
95+
it.concurrent('preserves nulls and arrays for workflow blocks with pure references', () => {
96+
const workflow: SerializedWorkflow = {
97+
version: 'test',
98+
blocks: [createSerializedBlock({ id: 'webhook', name: 'webhook', type: BlockType.TRIGGER })],
99+
connections: [],
100+
loops: {},
101+
parallels: {},
102+
}
103+
104+
const state = new ExecutionState()
105+
state.setBlockOutput('webhook', {
106+
items: [1, { a: 2 }, [3]],
107+
nothing: null,
108+
})
109+
110+
const resolver = new VariableResolver(workflow, {}, state)
111+
const ctx = { blockStates: new Map() } as unknown as ExecutionContext
112+
113+
const workflowBlock = createSerializedBlock({
114+
id: 'wf',
115+
name: 'Workflow',
116+
type: BlockType.WORKFLOW,
117+
})
118+
119+
const resolved = resolver.resolveInputs(
120+
ctx,
121+
'wf',
122+
{
123+
inputMapping: {
124+
items: ' <webhook.items> ',
125+
nothing: '<webhook.nothing>',
126+
},
127+
},
128+
workflowBlock
129+
)
130+
131+
expect(resolved.inputMapping.items).toEqual([1, { a: 2 }, [3]])
132+
expect(resolved.inputMapping.nothing).toBeNull()
133+
})
134+
135+
it.concurrent('still stringifies when a reference is embedded in text', () => {
136+
const workflow: SerializedWorkflow = {
137+
version: 'test',
138+
blocks: [createSerializedBlock({ id: 'webhook', name: 'webhook', type: BlockType.TRIGGER })],
139+
connections: [],
140+
loops: {},
141+
parallels: {},
142+
}
143+
144+
const state = new ExecutionState()
145+
state.setBlockOutput('webhook', { conversation_id: 149 })
146+
147+
const resolver = new VariableResolver(workflow, {}, state)
148+
const ctx = { blockStates: new Map() } as unknown as ExecutionContext
149+
150+
const workflowInputBlock = createSerializedBlock({
151+
id: 'wf',
152+
name: 'Workflow',
153+
type: BlockType.WORKFLOW_INPUT,
154+
})
155+
156+
const resolved = resolver.resolveInputs(
157+
ctx,
158+
'wf',
159+
{
160+
label: 'id=<webhook.conversation_id>',
161+
},
162+
workflowInputBlock
163+
)
164+
165+
expect(resolved.label).toBe('id=149')
166+
})
167+
168+
it.concurrent('returns null for RESOLVED_EMPTY references in workflow blocks', () => {
169+
const workflow: SerializedWorkflow = {
170+
version: 'test',
171+
blocks: [
172+
createSerializedBlock({ id: 'webhook', name: 'webhook', type: BlockType.TRIGGER }),
173+
createSerializedBlock({ id: 'agent', name: 'Agent', type: BlockType.AGENT }),
174+
],
175+
connections: [],
176+
loops: {},
177+
parallels: {},
178+
}
179+
180+
const state = new ExecutionState()
181+
// webhook has output, but agent has none (never executed on this path)
182+
state.setBlockOutput('webhook', { conversation_id: 149 })
183+
184+
const resolver = new VariableResolver(workflow, {}, state)
185+
const ctx = { blockStates: new Map() } as unknown as ExecutionContext
186+
187+
const workflowBlock = createSerializedBlock({
188+
id: 'wf',
189+
name: 'Workflow',
190+
type: BlockType.WORKFLOW,
191+
})
192+
193+
const resolved = resolver.resolveInputs(
194+
ctx,
195+
'wf',
196+
{
197+
inputMapping: {
198+
from_webhook: '<webhook.conversation_id>',
199+
from_agent: '<Agent.response>',
200+
},
201+
},
202+
workflowBlock
203+
)
204+
205+
expect(resolved.inputMapping.from_webhook).toBe(149)
206+
expect(resolved.inputMapping.from_agent).toBeNull()
207+
})
208+
})

apps/sim/executor/variables/resolver.ts

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -155,17 +155,36 @@ export class VariableResolver {
155155
template: string,
156156
loopScope?: LoopScope,
157157
block?: SerializedBlock
158-
): string {
158+
): any {
159159
const resolutionContext: ResolutionContext = {
160160
executionContext: ctx,
161161
executionState: this.state,
162162
currentNodeId,
163163
loopScope,
164164
}
165165

166-
let replacementError: Error | null = null
167-
168166
const blockType = block?.metadata?.id
167+
168+
// For workflow blocks with pure references (e.g. "<webhook.sender>"),
169+
// return the resolved value directly to preserve its native type.
170+
// Other blocks need string interpolation, but workflow inputMapping
171+
// should pass arrays, objects, numbers, and booleans as-is.
172+
const trimmed = template.trim()
173+
const isPureReference = /^<[^<>]+>$/.test(trimmed)
174+
const isWorkflowBlock =
175+
blockType === BlockType.WORKFLOW || blockType === BlockType.WORKFLOW_INPUT
176+
if (isWorkflowBlock && isPureReference) {
177+
const resolved = this.resolveReference(trimmed, resolutionContext)
178+
if (resolved === RESOLVED_EMPTY) {
179+
return null
180+
}
181+
if (resolved !== undefined) {
182+
return resolved
183+
}
184+
return template
185+
}
186+
187+
let replacementError: Error | null = null
169188
const language =
170189
blockType === BlockType.FUNCTION
171190
? ((block?.config?.params as Record<string, unknown> | undefined)?.language as

0 commit comments

Comments
 (0)