From 9f47ec71bd93fbd8ca739fc55744a8a89d23f66d Mon Sep 17 00:00:00 2001 From: Jay Prajapati <79649559+jayy-77@users.noreply.github.com> Date: Sun, 22 Feb 2026 03:56:28 +0530 Subject: [PATCH 1/2] fix: preserve typed inputs across workflow chaining Fixes #3105. --- apps/sim/executor/utils/start-block.test.ts | 33 +++++++ apps/sim/executor/utils/start-block.ts | 2 + apps/sim/executor/variables/resolver.test.ts | 94 ++++++++++++++++++++ apps/sim/executor/variables/resolver.ts | 16 +++- 4 files changed, 143 insertions(+), 2 deletions(-) create mode 100644 apps/sim/executor/variables/resolver.test.ts diff --git a/apps/sim/executor/utils/start-block.test.ts b/apps/sim/executor/utils/start-block.test.ts index 4c6fd708e0..ecd973a8b6 100644 --- a/apps/sim/executor/utils/start-block.test.ts +++ b/apps/sim/executor/utils/start-block.test.ts @@ -215,5 +215,38 @@ 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) + }) }) }) diff --git a/apps/sim/executor/utils/start-block.ts b/apps/sim/executor/utils/start-block.ts index d18229d20e..a166d3d5b9 100644 --- a/apps/sim/executor/utils/start-block.ts +++ b/apps/sim/executor/utils/start-block.ts @@ -261,6 +261,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)) { @@ -271,6 +272,7 @@ function buildUnifiedStartOutput( if (isPlainObject(workflowInput)) { for (const [key, value] of Object.entries(workflowInput)) { if (key === 'onUploadError') continue + if (structuredKeys?.has(key)) continue // Runtime values override defaults (except undefined/null which mean "not provided") if (value !== undefined && value !== null) { output[key] = value diff --git a/apps/sim/executor/variables/resolver.test.ts b/apps/sim/executor/variables/resolver.test.ts new file mode 100644 index 0000000000..40db851bfc --- /dev/null +++ b/apps/sim/executor/variables/resolver.test.ts @@ -0,0 +1,94 @@ +import { describe, expect, it } from 'vitest' +import { BlockType } from '@/executor/constants' +import { ExecutionState } from '@/executor/execution/state' +import type { ExecutionContext } from '@/executor/types' +import { VariableResolver } from '@/executor/variables/resolver' +import type { SerializedBlock, SerializedWorkflow } from '@/serializer/types' + +function createSerializedBlock(opts: { id: string; name: string; type: string }): SerializedBlock { + return { + id: opts.id, + position: { x: 0, y: 0 }, + config: { tool: opts.type, params: {} }, + inputs: {}, + outputs: {}, + metadata: { id: opts.type, name: opts.name }, + enabled: true, + } +} + +describe('VariableResolver', () => { + it.concurrent('preserves typed values for workflow_input pure references', () => { + const workflow: SerializedWorkflow = { + version: 'test', + blocks: [createSerializedBlock({ id: 'webhook', name: 'webhook', type: BlockType.TRIGGER })], + connections: [], + loops: {}, + parallels: {}, + } + + const state = new ExecutionState() + state.setBlockOutput('webhook', { + conversation_id: 149, + sender: { id: 10, email: 'user@example.com' }, + is_active: true, + }) + + const resolver = new VariableResolver(workflow, {}, state) + const ctx = { blockStates: new Map() } as unknown as ExecutionContext + + const workflowInputBlock = createSerializedBlock({ + id: 'wf', + name: 'Workflow', + type: BlockType.WORKFLOW_INPUT, + }) + + const resolved = resolver.resolveInputs( + ctx, + 'wf', + { + inputMapping: { + conversation_id: '', + sender: '', + is_active: '', + }, + }, + workflowInputBlock + ) + + expect(resolved.inputMapping.conversation_id).toBe(149) + expect(resolved.inputMapping.sender).toEqual({ id: 10, email: 'user@example.com' }) + expect(resolved.inputMapping.is_active).toBe(true) + }) + + it.concurrent('formats pure references for non-workflow blocks', () => { + const workflow: SerializedWorkflow = { + version: 'test', + blocks: [createSerializedBlock({ id: 'webhook', name: 'webhook', type: BlockType.TRIGGER })], + connections: [], + loops: {}, + parallels: {}, + } + + const state = new ExecutionState() + state.setBlockOutput('webhook', { conversation_id: 149 }) + + const resolver = new VariableResolver(workflow, {}, state) + const ctx = { blockStates: new Map() } as unknown as ExecutionContext + + const apiBlock = createSerializedBlock({ + id: 'api', + name: 'API', + type: BlockType.API, + }) + + const resolved = resolver.resolveInputs( + ctx, + 'api', + { conversation_id: '' }, + apiBlock + ) + + expect(resolved.conversation_id).toBe('149') + }) +}) diff --git a/apps/sim/executor/variables/resolver.ts b/apps/sim/executor/variables/resolver.ts index 980708931b..014e668d63 100644 --- a/apps/sim/executor/variables/resolver.ts +++ b/apps/sim/executor/variables/resolver.ts @@ -147,7 +147,7 @@ export class VariableResolver { template: string, loopScope?: LoopScope, block?: SerializedBlock - ): string { + ): any { const resolutionContext: ResolutionContext = { executionContext: ctx, executionState: this.state, @@ -155,6 +155,19 @@ export class VariableResolver { loopScope, } + const blockType = block?.metadata?.id + const trimmed = template.trim() + const isPureReference = /^<[^<>]+>$/.test(trimmed) + const isWorkflowInput = + blockType === BlockType.WORKFLOW || blockType === BlockType.WORKFLOW_INPUT + if (isWorkflowInput && isPureReference) { + const resolved = this.resolveReference(trimmed, resolutionContext) + if (resolved !== undefined) { + return resolved + } + return template + } + let replacementError: Error | null = null // Use generic utility for smart variable reference replacement @@ -167,7 +180,6 @@ export class VariableResolver { return match } - const blockType = block?.metadata?.id const isInTemplateLiteral = blockType === BlockType.FUNCTION && template.includes('${') && From f9393fe707149c9dbd7fa4334369f3305e8db433 Mon Sep 17 00:00:00 2001 From: Jay Prajapati <79649559+jayy-77@users.noreply.github.com> Date: Sun, 22 Feb 2026 05:01:31 +0530 Subject: [PATCH 2/2] fix(executor): normalize workflow input mapping types Parse nested JSON strings in workflow inputMapping before starting child executions, preserving objects/arrays/booleans/numbers across workflow chaining. Add edge-case coverage for nulls/arrays, embedded references, duplicated keys in unified starts, and mapping normalization. Fixes #3105 --- .../workflow/workflow-handler.test.ts | 93 +++++++++++++++++-- .../handlers/workflow/workflow-handler.ts | 6 +- apps/sim/executor/utils/start-block.test.ts | 43 +++++++++ apps/sim/executor/variables/resolver.test.ts | 73 +++++++++++++++ 4 files changed, 206 insertions(+), 9 deletions(-) diff --git a/apps/sim/executor/handlers/workflow/workflow-handler.test.ts b/apps/sim/executor/handlers/workflow/workflow-handler.test.ts index 21d9cd3fdb..5d326f85e6 100644 --- a/apps/sim/executor/handlers/workflow/workflow-handler.test.ts +++ b/apps/sim/executor/handlers/workflow/workflow-handler.test.ts @@ -1,22 +1,69 @@ -import { beforeEach, describe, expect, it, type Mock, vi } from 'vitest' +import { afterAll, beforeAll, beforeEach, describe, expect, it, type Mock, vi } from 'vitest' import { BlockType } from '@/executor/constants' -import { WorkflowBlockHandler } from '@/executor/handlers/workflow/workflow-handler' import type { ExecutionContext } from '@/executor/types' import type { SerializedBlock } from '@/serializer/types' +const executorMocks = vi.hoisted(() => ({ + execute: vi.fn(), + lastArgs: undefined as any, +})) + +vi.mock('@/executor', () => ({ + Executor: vi.fn().mockImplementation((args: any) => { + executorMocks.lastArgs = args + return { execute: executorMocks.execute } + }), +})) + +vi.mock('@/serializer', () => ({ + Serializer: class { + serializeWorkflow() { + return { + version: 'test', + blocks: [], + connections: [], + loops: {}, + parallels: {}, + } + } + }, +})) + vi.mock('@/lib/auth/internal', () => ({ generateInternalToken: vi.fn().mockResolvedValue('test-token'), })) +vi.mock('@/executor/utils/lazy-cleanup', () => ({ + lazyCleanupInputMapping: vi.fn(async (_workflowId: string, _blockId: string, mapping: any) => { + return mapping + }), +})) + +vi.mock('@/stores/workflows/registry/store', () => ({ + useWorkflowRegistry: { + getState: () => ({ workflows: {} }), + }, +})) + // Mock fetch globally global.fetch = vi.fn() describe('WorkflowBlockHandler', () => { - let handler: WorkflowBlockHandler + let WorkflowBlockHandler: typeof import('@/executor/handlers/workflow/workflow-handler').WorkflowBlockHandler + let handler: InstanceType let mockBlock: SerializedBlock let mockContext: ExecutionContext let mockFetch: Mock + beforeAll(async () => { + vi.stubEnv('NEXT_PUBLIC_APP_URL', 'http://localhost:3000') + ;({ WorkflowBlockHandler } = await import('@/executor/handlers/workflow/workflow-handler')) + }) + + afterAll(() => { + vi.unstubAllEnvs() + }) + beforeEach(() => { // Mock window.location.origin for getBaseUrl() ;(global as any).window = { @@ -58,6 +105,7 @@ describe('WorkflowBlockHandler', () => { // Reset all mocks vi.clearAllMocks() + executorMocks.lastArgs = undefined // Setup default fetch mock mockFetch.mockResolvedValue({ @@ -70,10 +118,10 @@ describe('WorkflowBlockHandler', () => { blocks: [ { id: 'starter', - metadata: { id: BlockType.STARTER, name: 'Starter' }, + type: BlockType.STARTER, + name: 'Starter', position: { x: 0, y: 0 }, - config: { tool: BlockType.STARTER, params: {} }, - inputs: {}, + subBlocks: {}, outputs: {}, enabled: true, }, @@ -145,6 +193,39 @@ describe('WorkflowBlockHandler', () => { 'Error in child workflow "child-workflow-id": Network error' ) }) + + it('normalizes stringified JSON values in inputMapping before starting child workflow', async () => { + executorMocks.execute.mockResolvedValue({ + success: true, + output: { ok: true }, + } as any) + + const inputs = { + workflowId: 'child-workflow-id', + inputMapping: { + conversation_id: '149', + sender: '{"id":10,"email":"user@example.com"}', + is_active: 'true', + metadata: '{"nested":"[1,2]"}', + nullish: 'null', + invalid: '{bad', + }, + } + + await expect(handler.execute(mockContext, mockBlock, inputs)).resolves.toMatchObject({ + success: true, + childWorkflowName: 'Child Workflow', + }) + + expect(executorMocks.lastArgs?.workflowInput).toEqual({ + conversation_id: 149, + sender: { id: 10, email: 'user@example.com' }, + is_active: true, + metadata: { nested: [1, 2] }, + nullish: null, + invalid: '{bad', + }) + }) }) describe('loadChildWorkflow', () => { diff --git a/apps/sim/executor/handlers/workflow/workflow-handler.ts b/apps/sim/executor/handlers/workflow/workflow-handler.ts index d8f7ced358..06abf8236e 100644 --- a/apps/sim/executor/handlers/workflow/workflow-handler.ts +++ b/apps/sim/executor/handlers/workflow/workflow-handler.ts @@ -11,7 +11,7 @@ import type { StreamingExecution, } from '@/executor/types' import { buildAPIUrl, buildAuthHeaders } from '@/executor/utils/http' -import { parseJSON } from '@/executor/utils/json' +import { parseJSON, parseObjectStrings } from '@/executor/utils/json' import { lazyCleanupInputMapping } from '@/executor/utils/lazy-cleanup' import { Serializer } from '@/serializer' import type { SerializedBlock } from '@/serializer/types' @@ -81,7 +81,7 @@ export class WorkflowBlockHandler implements BlockHandler { `Executing child workflow: ${childWorkflowName} (${workflowId}) at depth ${currentDepth}` ) - let childWorkflowInput: Record = {} + let childWorkflowInput: any = {} if (inputs.inputMapping !== undefined && inputs.inputMapping !== null) { const normalized = parseJSON(inputs.inputMapping, inputs.inputMapping) @@ -93,7 +93,7 @@ export class WorkflowBlockHandler implements BlockHandler { normalized, childWorkflow.rawBlocks || {} ) - childWorkflowInput = cleanedMapping as Record + childWorkflowInput = parseObjectStrings(cleanedMapping) } else { childWorkflowInput = {} } diff --git a/apps/sim/executor/utils/start-block.test.ts b/apps/sim/executor/utils/start-block.test.ts index ecd973a8b6..3eef2ec1e2 100644 --- a/apps/sim/executor/utils/start-block.test.ts +++ b/apps/sim/executor/utils/start-block.test.ts @@ -248,5 +248,48 @@ describe('start-block utilities', () => { 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') + } + ) }) }) diff --git a/apps/sim/executor/variables/resolver.test.ts b/apps/sim/executor/variables/resolver.test.ts index 40db851bfc..0a1bd1c053 100644 --- a/apps/sim/executor/variables/resolver.test.ts +++ b/apps/sim/executor/variables/resolver.test.ts @@ -91,4 +91,77 @@ describe('VariableResolver', () => { expect(resolved.conversation_id).toBe('149') }) + + it.concurrent('preserves nulls and arrays for workflow blocks with pure references', () => { + const workflow: SerializedWorkflow = { + version: 'test', + blocks: [createSerializedBlock({ id: 'webhook', name: 'webhook', type: BlockType.TRIGGER })], + connections: [], + loops: {}, + parallels: {}, + } + + const state = new ExecutionState() + state.setBlockOutput('webhook', { + items: [1, { a: 2 }, [3]], + nothing: null, + }) + + const resolver = new VariableResolver(workflow, {}, state) + const ctx = { blockStates: new Map() } as unknown as ExecutionContext + + const workflowBlock = createSerializedBlock({ + id: 'wf', + name: 'Workflow', + type: BlockType.WORKFLOW, + }) + + const resolved = resolver.resolveInputs( + ctx, + 'wf', + { + inputMapping: { + items: ' ', + nothing: '', + }, + }, + workflowBlock + ) + + expect(resolved.inputMapping.items).toEqual([1, { a: 2 }, [3]]) + expect(resolved.inputMapping.nothing).toBeNull() + }) + + it.concurrent('still stringifies when a reference is embedded in text', () => { + const workflow: SerializedWorkflow = { + version: 'test', + blocks: [createSerializedBlock({ id: 'webhook', name: 'webhook', type: BlockType.TRIGGER })], + connections: [], + loops: {}, + parallels: {}, + } + + const state = new ExecutionState() + state.setBlockOutput('webhook', { conversation_id: 149 }) + + const resolver = new VariableResolver(workflow, {}, state) + const ctx = { blockStates: new Map() } as unknown as ExecutionContext + + const workflowInputBlock = createSerializedBlock({ + id: 'wf', + name: 'Workflow', + type: BlockType.WORKFLOW_INPUT, + }) + + const resolved = resolver.resolveInputs( + ctx, + 'wf', + { + label: 'id=', + }, + workflowInputBlock + ) + + expect(resolved.label).toBe('id=149') + }) })