Skip to content

Commit 68f44b8

Browse files
improvement(resolver): resovled empty sentinel to not pass through unexecuted valid refs to text inputs (#3266)
1 parent 9920882 commit 68f44b8

File tree

4 files changed

+75
-25
lines changed

4 files changed

+75
-25
lines changed

apps/sim/executor/variables/resolver.ts

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,11 @@ import { BlockResolver } from '@/executor/variables/resolvers/block'
77
import { EnvResolver } from '@/executor/variables/resolvers/env'
88
import { LoopResolver } from '@/executor/variables/resolvers/loop'
99
import { ParallelResolver } from '@/executor/variables/resolvers/parallel'
10-
import type { ResolutionContext, Resolver } from '@/executor/variables/resolvers/reference'
10+
import {
11+
RESOLVED_EMPTY,
12+
type ResolutionContext,
13+
type Resolver,
14+
} from '@/executor/variables/resolvers/reference'
1115
import { WorkflowResolver } from '@/executor/variables/resolvers/workflow'
1216
import type { SerializedBlock, SerializedWorkflow } from '@/serializer/types'
1317

@@ -104,7 +108,11 @@ export class VariableResolver {
104108
loopScope,
105109
}
106110

107-
return this.resolveReference(trimmed, resolutionContext)
111+
const result = this.resolveReference(trimmed, resolutionContext)
112+
if (result === RESOLVED_EMPTY) {
113+
return null
114+
}
115+
return result
108116
}
109117
}
110118

@@ -174,6 +182,13 @@ export class VariableResolver {
174182
return match
175183
}
176184

185+
if (resolved === RESOLVED_EMPTY) {
186+
if (blockType === BlockType.FUNCTION) {
187+
return this.blockResolver.formatValueForBlock(null, blockType, language)
188+
}
189+
return ''
190+
}
191+
177192
return this.blockResolver.formatValueForBlock(resolved, blockType, language)
178193
} catch (error) {
179194
replacementError = error instanceof Error ? error : new Error(String(error))
@@ -207,7 +222,6 @@ export class VariableResolver {
207222

208223
let replacementError: Error | null = null
209224

210-
// Use generic utility for smart variable reference replacement
211225
let result = replaceValidReferences(template, (match) => {
212226
if (replacementError) return match
213227

@@ -217,6 +231,10 @@ export class VariableResolver {
217231
return match
218232
}
219233

234+
if (resolved === RESOLVED_EMPTY) {
235+
return 'null'
236+
}
237+
220238
if (typeof resolved === 'string') {
221239
const escaped = resolved.replace(/\\/g, '\\\\').replace(/'/g, "\\'")
222240
return `'${escaped}'`

apps/sim/executor/variables/resolvers/block.test.ts

Lines changed: 39 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { loggerMock } from '@sim/testing'
22
import { describe, expect, it, vi } from 'vitest'
33
import { ExecutionState } from '@/executor/execution/state'
44
import { BlockResolver } from './block'
5-
import type { ResolutionContext } from './reference'
5+
import { RESOLVED_EMPTY, type ResolutionContext } from './reference'
66

77
vi.mock('@sim/logger', () => loggerMock)
88
vi.mock('@/blocks/registry', async () => {
@@ -134,15 +134,18 @@ describe('BlockResolver', () => {
134134
expect(resolver.resolve('<source.items.1.id>', ctx)).toBe(2)
135135
})
136136

137-
it.concurrent('should return undefined for non-existent path when no schema defined', () => {
138-
const workflow = createTestWorkflow([{ id: 'source', type: 'unknown_block_type' }])
139-
const resolver = new BlockResolver(workflow)
140-
const ctx = createTestContext('current', {
141-
source: { existing: 'value' },
142-
})
137+
it.concurrent(
138+
'should return RESOLVED_EMPTY for non-existent path when no schema defined',
139+
() => {
140+
const workflow = createTestWorkflow([{ id: 'source', type: 'unknown_block_type' }])
141+
const resolver = new BlockResolver(workflow)
142+
const ctx = createTestContext('current', {
143+
source: { existing: 'value' },
144+
})
143145

144-
expect(resolver.resolve('<source.nonexistent>', ctx)).toBeUndefined()
145-
})
146+
expect(resolver.resolve('<source.nonexistent>', ctx)).toBe(RESOLVED_EMPTY)
147+
}
148+
)
146149

147150
it.concurrent('should throw error for path not in output schema', () => {
148151
const workflow = createTestWorkflow([
@@ -162,7 +165,7 @@ describe('BlockResolver', () => {
162165
expect(() => resolver.resolve('<source.invalidField>', ctx)).toThrow(/Available fields:/)
163166
})
164167

165-
it.concurrent('should return undefined for path in schema but missing in data', () => {
168+
it.concurrent('should return RESOLVED_EMPTY for path in schema but missing in data', () => {
166169
const workflow = createTestWorkflow([
167170
{
168171
id: 'source',
@@ -175,7 +178,7 @@ describe('BlockResolver', () => {
175178
})
176179

177180
expect(resolver.resolve('<source.stdout>', ctx)).toBe('log output')
178-
expect(resolver.resolve('<source.result>', ctx)).toBeUndefined()
181+
expect(resolver.resolve('<source.result>', ctx)).toBe(RESOLVED_EMPTY)
179182
})
180183

181184
it.concurrent(
@@ -191,7 +194,7 @@ describe('BlockResolver', () => {
191194
const resolver = new BlockResolver(workflow)
192195
const ctx = createTestContext('current', {})
193196

194-
expect(resolver.resolve('<workflow.childTraceSpans>', ctx)).toBeUndefined()
197+
expect(resolver.resolve('<workflow.childTraceSpans>', ctx)).toBe(RESOLVED_EMPTY)
195198
}
196199
)
197200

@@ -208,7 +211,7 @@ describe('BlockResolver', () => {
208211
const resolver = new BlockResolver(workflow)
209212
const ctx = createTestContext('current', {})
210213

211-
expect(resolver.resolve('<workflowinput.childTraceSpans>', ctx)).toBeUndefined()
214+
expect(resolver.resolve('<workflowinput.childTraceSpans>', ctx)).toBe(RESOLVED_EMPTY)
212215
}
213216
)
214217

@@ -225,20 +228,35 @@ describe('BlockResolver', () => {
225228
const resolver = new BlockResolver(workflow)
226229
const ctx = createTestContext('current', {})
227230

228-
expect(resolver.resolve('<hitl.response>', ctx)).toBeUndefined()
229-
expect(resolver.resolve('<hitl.submission>', ctx)).toBeUndefined()
230-
expect(resolver.resolve('<hitl.resumeInput>', ctx)).toBeUndefined()
231+
expect(resolver.resolve('<hitl.response>', ctx)).toBe(RESOLVED_EMPTY)
232+
expect(resolver.resolve('<hitl.submission>', ctx)).toBe(RESOLVED_EMPTY)
233+
expect(resolver.resolve('<hitl.resumeInput>', ctx)).toBe(RESOLVED_EMPTY)
231234
}
232235
)
233236

234-
it.concurrent('should return undefined for non-existent block', () => {
237+
it.concurrent('should return undefined for block not in workflow', () => {
235238
const workflow = createTestWorkflow([{ id: 'existing' }])
236239
const resolver = new BlockResolver(workflow)
237240
const ctx = createTestContext('current', {})
238241

239242
expect(resolver.resolve('<nonexistent>', ctx)).toBeUndefined()
240243
})
241244

245+
it.concurrent('should return RESOLVED_EMPTY for block in workflow that did not execute', () => {
246+
const workflow = createTestWorkflow([
247+
{ id: 'start-block', name: 'Start', type: 'start_trigger' },
248+
{ id: 'slack-block', name: 'Slack', type: 'slack_trigger' },
249+
])
250+
const resolver = new BlockResolver(workflow)
251+
const ctx = createTestContext('current', {
252+
'slack-block': { message: 'hello from slack' },
253+
})
254+
255+
expect(resolver.resolve('<slack.message>', ctx)).toBe('hello from slack')
256+
expect(resolver.resolve('<start>', ctx)).toBe(RESOLVED_EMPTY)
257+
expect(resolver.resolve('<start.input>', ctx)).toBe(RESOLVED_EMPTY)
258+
})
259+
242260
it.concurrent('should fall back to context blockStates', () => {
243261
const workflow = createTestWorkflow([{ id: 'source' }])
244262
const resolver = new BlockResolver(workflow)
@@ -1012,24 +1030,24 @@ describe('BlockResolver', () => {
10121030
expect(resolver.resolve('<source.other>', ctx)).toBe('exists')
10131031
})
10141032

1015-
it.concurrent('should handle output with undefined values', () => {
1033+
it.concurrent('should return RESOLVED_EMPTY for output with undefined values', () => {
10161034
const workflow = createTestWorkflow([{ id: 'source', type: 'unknown_block_type' }])
10171035
const resolver = new BlockResolver(workflow)
10181036
const ctx = createTestContext('current', {
10191037
source: { value: undefined, other: 'exists' },
10201038
})
10211039

1022-
expect(resolver.resolve('<source.value>', ctx)).toBeUndefined()
1040+
expect(resolver.resolve('<source.value>', ctx)).toBe(RESOLVED_EMPTY)
10231041
})
10241042

1025-
it.concurrent('should return undefined for deeply nested non-existent path', () => {
1043+
it.concurrent('should return RESOLVED_EMPTY for deeply nested non-existent path', () => {
10261044
const workflow = createTestWorkflow([{ id: 'source', type: 'unknown_block_type' }])
10271045
const resolver = new BlockResolver(workflow)
10281046
const ctx = createTestContext('current', {
10291047
source: { level1: { level2: {} } },
10301048
})
10311049

1032-
expect(resolver.resolve('<source.level1.level2.level3>', ctx)).toBeUndefined()
1050+
expect(resolver.resolve('<source.level1.level2.level3>', ctx)).toBe(RESOLVED_EMPTY)
10331051
})
10341052
})
10351053
})

apps/sim/executor/variables/resolvers/block.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import {
1313
import { formatLiteralForCode } from '@/executor/utils/code-formatting'
1414
import {
1515
navigatePath,
16+
RESOLVED_EMPTY,
1617
type ResolutionContext,
1718
type Resolver,
1819
} from '@/executor/variables/resolvers/reference'
@@ -84,7 +85,12 @@ export class BlockResolver implements Resolver {
8485
return result.value
8586
}
8687

87-
return this.handleBackwardsCompat(block, output, pathParts)
88+
const backwardsCompat = this.handleBackwardsCompat(block, output, pathParts)
89+
if (backwardsCompat !== undefined) {
90+
return backwardsCompat
91+
}
92+
93+
return RESOLVED_EMPTY
8894
} catch (error) {
8995
if (error instanceof InvalidFieldError) {
9096
const fallback = this.handleBackwardsCompat(block, output, pathParts)

apps/sim/executor/variables/resolvers/reference.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,14 @@ export interface Resolver {
1212
resolve(reference: string, context: ResolutionContext): any
1313
}
1414

15+
/**
16+
* Sentinel value indicating a reference was resolved to a known block
17+
* that produced no output (e.g., the block exists in the workflow but
18+
* didn't execute on this path). Distinct from `undefined`, which means
19+
* the reference couldn't be matched to any block at all.
20+
*/
21+
export const RESOLVED_EMPTY = Symbol('RESOLVED_EMPTY')
22+
1523
/**
1624
* Navigate through nested object properties using a path array.
1725
* Supports dot notation and array indices.

0 commit comments

Comments
 (0)