d25d482dc2
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
1401 lines
54 KiB
TypeScript
1401 lines
54 KiB
TypeScript
import { describe, expect, it, vi } from 'vitest'
|
|
import { compactExecutionPayload } from '@/lib/execution/payloads/serializer'
|
|
import { ExecutionState } from '@/executor/execution/state'
|
|
import { BlockResolver } from './block'
|
|
import { RESOLVED_EMPTY, type ResolutionContext } from './reference'
|
|
|
|
/**
|
|
* Minimal block configs providing only the fields needed by getBlockSchema / getEffectiveBlockOutputs.
|
|
* This avoids loading all 200+ block definition files via the real registry.
|
|
* Uses vi.hoisted() so the mock data is available when vi.mock factories execute.
|
|
*/
|
|
const MOCK_BLOCKS = vi.hoisted(
|
|
() =>
|
|
({
|
|
start_trigger: {
|
|
type: 'start_trigger',
|
|
category: 'triggers',
|
|
subBlocks: [{ id: 'inputFormat', type: 'input-format' }],
|
|
outputs: {},
|
|
triggers: { enabled: true, available: ['chat', 'manual', 'api'] },
|
|
},
|
|
function: {
|
|
type: 'function',
|
|
category: 'tools',
|
|
subBlocks: [],
|
|
outputs: {
|
|
result: {
|
|
type: 'json',
|
|
description: 'Return value from the executed JavaScript function',
|
|
},
|
|
stdout: { type: 'string', description: 'Console log output' },
|
|
},
|
|
},
|
|
response: {
|
|
type: 'response',
|
|
category: 'tools',
|
|
subBlocks: [],
|
|
outputs: {
|
|
data: { type: 'json', description: 'Response data' },
|
|
status: { type: 'number', description: 'HTTP status code' },
|
|
headers: { type: 'json', description: 'Response headers' },
|
|
},
|
|
},
|
|
workflow: {
|
|
type: 'workflow',
|
|
category: 'tools',
|
|
subBlocks: [],
|
|
outputs: {
|
|
success: { type: 'boolean', description: 'Execution success status' },
|
|
childWorkflowName: { type: 'string', description: 'Child workflow name' },
|
|
childWorkflowId: { type: 'string', description: 'Child workflow ID' },
|
|
result: { type: 'json', description: 'Workflow execution result' },
|
|
error: { type: 'string', description: 'Error message' },
|
|
childTraceSpans: {
|
|
type: 'json',
|
|
description: 'Child workflow trace spans',
|
|
hiddenFromDisplay: true,
|
|
},
|
|
},
|
|
},
|
|
workflow_input: {
|
|
type: 'workflow_input',
|
|
category: 'tools',
|
|
subBlocks: [],
|
|
outputs: {
|
|
success: { type: 'boolean', description: 'Execution success status' },
|
|
childWorkflowName: { type: 'string', description: 'Child workflow name' },
|
|
childWorkflowId: { type: 'string', description: 'Child workflow ID' },
|
|
result: { type: 'json', description: 'Workflow execution result' },
|
|
error: { type: 'string', description: 'Error message' },
|
|
childTraceSpans: {
|
|
type: 'json',
|
|
description: 'Child workflow trace spans',
|
|
hiddenFromDisplay: true,
|
|
},
|
|
},
|
|
},
|
|
human_in_the_loop: {
|
|
type: 'human_in_the_loop',
|
|
category: 'tools',
|
|
subBlocks: [],
|
|
outputs: {
|
|
url: { type: 'string', description: 'Resume UI URL' },
|
|
resumeEndpoint: { type: 'string', description: 'Resume API endpoint URL' },
|
|
response: {
|
|
type: 'json',
|
|
description: 'Display data shown to the approver',
|
|
hiddenFromDisplay: true,
|
|
},
|
|
submission: {
|
|
type: 'json',
|
|
description: 'Form submission data',
|
|
hiddenFromDisplay: true,
|
|
},
|
|
resumeInput: {
|
|
type: 'json',
|
|
description: 'Raw input data submitted when resuming',
|
|
hiddenFromDisplay: true,
|
|
},
|
|
submittedAt: {
|
|
type: 'string',
|
|
description: 'ISO timestamp when the workflow was resumed',
|
|
},
|
|
},
|
|
},
|
|
agent: {
|
|
type: 'agent',
|
|
category: 'tools',
|
|
subBlocks: [],
|
|
outputs: {
|
|
response: { type: 'json', description: 'Agent response' },
|
|
tokens: { type: 'json', description: 'Token usage' },
|
|
},
|
|
},
|
|
}) as Record<string, any>
|
|
)
|
|
|
|
vi.mock('@/blocks/registry', () => ({
|
|
getBlock: (type: string) => MOCK_BLOCKS[type] ?? undefined,
|
|
registry: MOCK_BLOCKS,
|
|
getAllBlocks: () => Object.values(MOCK_BLOCKS),
|
|
getAllBlockTypes: () => Object.keys(MOCK_BLOCKS),
|
|
isValidBlockType: (type: string) => type in MOCK_BLOCKS,
|
|
getBlockByToolName: () => undefined,
|
|
getBlocksByCategory: () => [],
|
|
getLatestBlock: () => undefined,
|
|
}))
|
|
vi.mock('@/blocks', () => ({
|
|
getBlock: (type: string) => MOCK_BLOCKS[type] ?? undefined,
|
|
registry: MOCK_BLOCKS,
|
|
getAllBlocks: () => Object.values(MOCK_BLOCKS),
|
|
getAllBlockTypes: () => Object.keys(MOCK_BLOCKS),
|
|
isValidBlockType: (type: string) => type in MOCK_BLOCKS,
|
|
getBlockByToolName: () => undefined,
|
|
getBlocksByCategory: () => [],
|
|
}))
|
|
|
|
function createTestWorkflow(
|
|
blocks: Array<{
|
|
id: string
|
|
name?: string
|
|
type?: string
|
|
outputs?: Record<string, any>
|
|
}> = [],
|
|
subflows: { loops?: Record<string, any>; parallels?: Record<string, any> } = {}
|
|
) {
|
|
return {
|
|
version: '1.0',
|
|
blocks: blocks.map((b) => ({
|
|
id: b.id,
|
|
position: { x: 0, y: 0 },
|
|
config: { tool: b.type ?? 'function', params: {} },
|
|
inputs: {},
|
|
outputs: b.outputs ?? {},
|
|
metadata: { id: b.type ?? 'function', name: b.name ?? b.id },
|
|
enabled: true,
|
|
})),
|
|
connections: [],
|
|
loops: subflows.loops ?? {},
|
|
parallels: subflows.parallels ?? {},
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Creates a test ResolutionContext with block outputs.
|
|
*/
|
|
function createTestContext(
|
|
currentNodeId: string,
|
|
blockOutputs: Record<string, any> = {},
|
|
contextBlockStates?: Map<string, { output: any }>,
|
|
parallelBlockMapping?: Map<string, any>
|
|
): ResolutionContext {
|
|
const state = new ExecutionState()
|
|
for (const [blockId, output] of Object.entries(blockOutputs)) {
|
|
state.setBlockOutput(blockId, output)
|
|
}
|
|
|
|
return {
|
|
executionContext: {
|
|
workspaceId: 'workspace-1',
|
|
workflowId: 'workflow-1',
|
|
executionId: 'execution-1',
|
|
blockStates: contextBlockStates ?? new Map(),
|
|
parallelBlockMapping,
|
|
},
|
|
executionState: state,
|
|
currentNodeId,
|
|
} as unknown as ResolutionContext
|
|
}
|
|
|
|
describe('BlockResolver', () => {
|
|
describe('canResolve', () => {
|
|
it.concurrent('should return true for block references', () => {
|
|
const resolver = new BlockResolver(createTestWorkflow([{ id: 'block-1' }]))
|
|
expect(resolver.canResolve('<block-1>')).toBe(true)
|
|
expect(resolver.canResolve('<block-1.output>')).toBe(true)
|
|
expect(resolver.canResolve('<block-1.result.value>')).toBe(true)
|
|
})
|
|
|
|
it.concurrent('should return true for block references by name', () => {
|
|
const resolver = new BlockResolver(createTestWorkflow([{ id: 'block-1', name: 'My Block' }]))
|
|
expect(resolver.canResolve('<myblock>')).toBe(true)
|
|
expect(resolver.canResolve('<My Block>')).toBe(true)
|
|
})
|
|
|
|
it.concurrent('should return false for special prefixes', () => {
|
|
const resolver = new BlockResolver(createTestWorkflow())
|
|
expect(resolver.canResolve('<loop.index>')).toBe(false)
|
|
expect(resolver.canResolve('<parallel.currentItem>')).toBe(false)
|
|
expect(resolver.canResolve('<variable.myvar>')).toBe(false)
|
|
})
|
|
|
|
it.concurrent('should return false for non-references', () => {
|
|
const resolver = new BlockResolver(createTestWorkflow())
|
|
expect(resolver.canResolve('plain text')).toBe(false)
|
|
expect(resolver.canResolve('{{ENV_VAR}}')).toBe(false)
|
|
expect(resolver.canResolve('block-1.output')).toBe(false)
|
|
})
|
|
})
|
|
|
|
describe('resolve', () => {
|
|
it.concurrent('should resolve block output by ID', () => {
|
|
const workflow = createTestWorkflow([{ id: 'source-block' }])
|
|
const resolver = new BlockResolver(workflow)
|
|
const ctx = createTestContext('current', {
|
|
'source-block': { result: 'success', data: { value: 42 } },
|
|
})
|
|
|
|
expect(resolver.resolve('<source-block>', ctx)).toEqual({
|
|
result: 'success',
|
|
data: { value: 42 },
|
|
})
|
|
})
|
|
|
|
it.concurrent('should resolve block output by name', () => {
|
|
const workflow = createTestWorkflow([{ id: 'block-123', name: 'My Source Block' }])
|
|
const resolver = new BlockResolver(workflow)
|
|
const ctx = createTestContext('current', {
|
|
'block-123': { message: 'hello' },
|
|
})
|
|
|
|
expect(resolver.resolve('<mysourceblock>', ctx)).toEqual({ message: 'hello' })
|
|
expect(resolver.resolve('<My Source Block>', ctx)).toEqual({ message: 'hello' })
|
|
})
|
|
|
|
it.concurrent('should resolve blocks whose names contain dots via dot-stripped names', () => {
|
|
const workflow = createTestWorkflow([{ id: 'block-dot', name: 'Hunter.io 1' }])
|
|
const resolver = new BlockResolver(workflow)
|
|
const ctx = createTestContext('current', {
|
|
'block-dot': { email: 'jane@acme.com', score: 92 },
|
|
})
|
|
|
|
expect(resolver.resolve('<hunterio1.email>', ctx)).toBe('jane@acme.com')
|
|
expect(resolver.resolve('<hunterio1.score>', ctx)).toBe(92)
|
|
})
|
|
|
|
it.concurrent('should keep dot-free names as the reference target on legacy collisions', () => {
|
|
const blocks = [
|
|
{ id: 'block-dotted', name: 'Hunter.io 1' },
|
|
{ id: 'block-plain', name: 'Hunterio 1' },
|
|
]
|
|
const ctx = createTestContext('current', {
|
|
'block-dotted': { email: 'dotted@acme.com' },
|
|
'block-plain': { email: 'plain@acme.com' },
|
|
})
|
|
|
|
const resolver = new BlockResolver(createTestWorkflow(blocks))
|
|
expect(resolver.resolve('<hunterio1.email>', ctx)).toBe('plain@acme.com')
|
|
|
|
const reversedResolver = new BlockResolver(createTestWorkflow([...blocks].reverse()))
|
|
expect(reversedResolver.resolve('<hunterio1.email>', ctx)).toBe('plain@acme.com')
|
|
})
|
|
|
|
it.concurrent('should resolve nested property path', () => {
|
|
const workflow = createTestWorkflow([{ id: 'source' }])
|
|
const resolver = new BlockResolver(workflow)
|
|
const ctx = createTestContext('current', {
|
|
source: { user: { profile: { name: 'Alice', email: 'alice@test.com' } } },
|
|
})
|
|
|
|
expect(resolver.resolve('<source.user.profile.name>', ctx)).toBe('Alice')
|
|
expect(resolver.resolve('<source.user.profile.email>', ctx)).toBe('alice@test.com')
|
|
})
|
|
|
|
it('does not fall back to unscoped block state inside cloned subflow branches', () => {
|
|
const workflow = createTestWorkflow([{ id: 'source' }], {
|
|
parallels: { 'parallel-1': { id: 'parallel-1', nodes: ['source'] } },
|
|
})
|
|
const resolver = new BlockResolver(workflow)
|
|
const ctx = createTestContext(
|
|
'consumer__clone-inner__obranch-1₍0₎',
|
|
{},
|
|
new Map([['source', { output: { result: 'branch-0' } }]])
|
|
)
|
|
|
|
expect(resolver.resolve('<source.result>', ctx)).toBe(RESOLVED_EMPTY)
|
|
})
|
|
|
|
it('allows cloned subflows to resolve top-level upstream block state', () => {
|
|
const workflow = createTestWorkflow([{ id: 'source' }])
|
|
const resolver = new BlockResolver(workflow)
|
|
const ctx = createTestContext(
|
|
'consumer__clone-inner__obranch-1₍0₎',
|
|
{},
|
|
new Map([['source', { output: { result: 'global' } }]])
|
|
)
|
|
|
|
expect(resolver.resolve('<source.result>', ctx)).toBe('global')
|
|
})
|
|
|
|
it('uses parallel block mappings to resolve cloned subflow outputs in later batches', () => {
|
|
const workflow = createTestWorkflow(
|
|
[
|
|
{ id: 'nested-loop', name: 'Nested Loop' },
|
|
{ id: 'consumer', name: 'Consumer' },
|
|
],
|
|
{
|
|
loops: { 'nested-loop': { id: 'nested-loop', nodes: ['loop-task'] } },
|
|
parallels: { 'parallel-1': { id: 'parallel-1', nodes: ['nested-loop', 'consumer'] } },
|
|
}
|
|
)
|
|
const resolver = new BlockResolver(workflow)
|
|
const ctx = createTestContext(
|
|
'consumer₍0₎',
|
|
{
|
|
'nested-loop': { results: ['branch-0'] },
|
|
'nested-loop__obranch-2': { results: ['branch-2'] },
|
|
},
|
|
undefined,
|
|
new Map([
|
|
[
|
|
'consumer₍0₎',
|
|
{ originalBlockId: 'consumer', parallelId: 'parallel-1', iterationIndex: 2 },
|
|
],
|
|
])
|
|
)
|
|
|
|
expect(resolver.resolve('<nestedloop.results>', ctx)).toEqual(['branch-2'])
|
|
})
|
|
|
|
it('resolves regular block outputs from the same cloned branch scope', () => {
|
|
const workflow = createTestWorkflow(
|
|
[
|
|
{ id: 'source', name: 'Source' },
|
|
{ id: 'consumer', name: 'Consumer' },
|
|
],
|
|
{
|
|
parallels: { 'parallel-1': { id: 'parallel-1', nodes: ['source', 'consumer'] } },
|
|
}
|
|
)
|
|
const resolver = new BlockResolver(workflow)
|
|
const ctx = createTestContext('consumer__cloneabc__obranch-2₍0₎', {
|
|
'source__obranch-2': { result: 'wrong-container-alias' },
|
|
'source__cloneabc__obranch-2₍0₎': { result: 'same-branch' },
|
|
})
|
|
|
|
expect(resolver.resolve('<source.result>', ctx)).toBe('same-branch')
|
|
})
|
|
|
|
it('uses outer branch suffix over inner parallel mappings for cloned subflow outputs', () => {
|
|
const workflow = createTestWorkflow(
|
|
[
|
|
{ id: 'sibling-loop', name: 'Sibling Loop' },
|
|
{ id: 'inner-task', name: 'Inner Task' },
|
|
],
|
|
{
|
|
loops: { 'sibling-loop': { id: 'sibling-loop', nodes: ['loop-task'] } },
|
|
parallels: {
|
|
'outer-parallel': { id: 'outer-parallel', nodes: ['sibling-loop', 'inner-parallel'] },
|
|
'inner-parallel': { id: 'inner-parallel', nodes: ['inner-task'] },
|
|
},
|
|
}
|
|
)
|
|
const resolver = new BlockResolver(workflow)
|
|
const ctx = createTestContext(
|
|
'inner-task__clone-inner__obranch-2₍0₎',
|
|
{
|
|
'sibling-loop__obranch-1': { results: ['inner-branch-1'] },
|
|
'sibling-loop__obranch-2': { results: ['outer-branch-2'] },
|
|
},
|
|
undefined,
|
|
new Map([
|
|
[
|
|
'inner-task__clone-inner__obranch-2₍0₎',
|
|
{ originalBlockId: 'inner-task', parallelId: 'inner-parallel', iterationIndex: 1 },
|
|
],
|
|
])
|
|
)
|
|
|
|
expect(resolver.resolve('<siblingloop.results>', ctx)).toEqual(['outer-branch-2'])
|
|
})
|
|
|
|
it('should resolve nested scalar paths inside compacted block references', async () => {
|
|
const workflow = createTestWorkflow([{ id: 'source' }])
|
|
const resolver = new BlockResolver(workflow)
|
|
const compacted = await compactExecutionPayload(
|
|
{
|
|
user: { profile: { name: 'Alice' } },
|
|
items: Array.from({ length: 100 }, (_, index) => ({ id: index })),
|
|
},
|
|
{
|
|
thresholdBytes: 64,
|
|
workspaceId: 'workspace-1',
|
|
workflowId: 'workflow-1',
|
|
executionId: 'execution-1',
|
|
}
|
|
)
|
|
const ctx = createTestContext('current', { source: compacted })
|
|
|
|
expect(resolver.resolve('<source.user.profile.name>', ctx)).toBe('Alice')
|
|
expect(resolver.resolve('<source.items[1].id>', ctx)).toBe(1)
|
|
expect(() => resolver.resolve('<source>', ctx)).toThrow('too large to inline')
|
|
})
|
|
|
|
it('should reject full container references that contain compacted children', async () => {
|
|
const workflow = createTestWorkflow([{ id: 'source' }])
|
|
const resolver = new BlockResolver(workflow)
|
|
const compacted = await compactExecutionPayload(
|
|
{
|
|
metadata: { id: 'event-1' },
|
|
attachment: { body: 'x'.repeat(2048) },
|
|
},
|
|
{ thresholdBytes: 256, preserveRoot: true }
|
|
)
|
|
const ctx = createTestContext('current', { source: compacted })
|
|
|
|
expect(resolver.resolve('<source.metadata.id>', ctx)).toBe('event-1')
|
|
expect(() => resolver.resolve('<source>', ctx)).toThrow('too large to inline')
|
|
expect(() => resolver.resolve('<source.attachment>', ctx)).toThrow('too large to inline')
|
|
})
|
|
|
|
it.concurrent('should resolve array index in path', () => {
|
|
const workflow = createTestWorkflow([{ id: 'source' }])
|
|
const resolver = new BlockResolver(workflow)
|
|
const ctx = createTestContext('current', {
|
|
source: { items: [{ id: 1 }, { id: 2 }, { id: 3 }] },
|
|
})
|
|
|
|
expect(resolver.resolve('<source.items.0>', ctx)).toEqual({ id: 1 })
|
|
expect(resolver.resolve('<source.items.1.id>', ctx)).toBe(2)
|
|
})
|
|
|
|
it.concurrent(
|
|
'should return RESOLVED_EMPTY for non-existent path when no schema defined',
|
|
() => {
|
|
const workflow = createTestWorkflow([{ id: 'source', type: 'unknown_block_type' }])
|
|
const resolver = new BlockResolver(workflow)
|
|
const ctx = createTestContext('current', {
|
|
source: { existing: 'value' },
|
|
})
|
|
|
|
expect(resolver.resolve('<source.nonexistent>', ctx)).toBe(RESOLVED_EMPTY)
|
|
}
|
|
)
|
|
|
|
it.concurrent('should throw error for path not in output schema', () => {
|
|
const workflow = createTestWorkflow([
|
|
{
|
|
id: 'source',
|
|
type: 'start_trigger',
|
|
},
|
|
])
|
|
const resolver = new BlockResolver(workflow)
|
|
const ctx = createTestContext('current', {
|
|
source: { input: 'value' },
|
|
})
|
|
|
|
expect(() => resolver.resolve('<source.invalidField>', ctx)).toThrow(
|
|
/"invalidField" doesn't exist on block "source"/
|
|
)
|
|
expect(() => resolver.resolve('<source.invalidField>', ctx)).toThrow(/Available fields:/)
|
|
})
|
|
|
|
it.concurrent('should return RESOLVED_EMPTY for path in schema but missing in data', () => {
|
|
const workflow = createTestWorkflow([
|
|
{
|
|
id: 'source',
|
|
type: 'function',
|
|
},
|
|
])
|
|
const resolver = new BlockResolver(workflow)
|
|
const ctx = createTestContext('current', {
|
|
source: { stdout: 'log output' },
|
|
})
|
|
|
|
expect(resolver.resolve('<source.stdout>', ctx)).toBe('log output')
|
|
expect(resolver.resolve('<source.result>', ctx)).toBe(RESOLVED_EMPTY)
|
|
})
|
|
|
|
it.concurrent(
|
|
'should allow hiddenFromDisplay fields for pre-execution schema validation',
|
|
() => {
|
|
const workflow = createTestWorkflow([
|
|
{
|
|
id: 'workflow-block',
|
|
name: 'Workflow',
|
|
type: 'workflow',
|
|
},
|
|
])
|
|
const resolver = new BlockResolver(workflow)
|
|
const ctx = createTestContext('current', {})
|
|
|
|
expect(resolver.resolve('<workflow.childTraceSpans>', ctx)).toBe(RESOLVED_EMPTY)
|
|
}
|
|
)
|
|
|
|
it.concurrent(
|
|
'should allow hiddenFromDisplay fields for workflow_input pre-execution schema validation',
|
|
() => {
|
|
const workflow = createTestWorkflow([
|
|
{
|
|
id: 'workflow-input-block',
|
|
name: 'Workflow Input',
|
|
type: 'workflow_input',
|
|
},
|
|
])
|
|
const resolver = new BlockResolver(workflow)
|
|
const ctx = createTestContext('current', {})
|
|
|
|
expect(resolver.resolve('<workflowinput.childTraceSpans>', ctx)).toBe(RESOLVED_EMPTY)
|
|
}
|
|
)
|
|
|
|
it.concurrent(
|
|
'should allow hiddenFromDisplay fields for HITL pre-execution schema validation',
|
|
() => {
|
|
const workflow = createTestWorkflow([
|
|
{
|
|
id: 'hitl-block',
|
|
name: 'HITL',
|
|
type: 'human_in_the_loop',
|
|
},
|
|
])
|
|
const resolver = new BlockResolver(workflow)
|
|
const ctx = createTestContext('current', {})
|
|
|
|
expect(resolver.resolve('<hitl.response>', ctx)).toBe(RESOLVED_EMPTY)
|
|
expect(resolver.resolve('<hitl.submission>', ctx)).toBe(RESOLVED_EMPTY)
|
|
expect(resolver.resolve('<hitl.resumeInput>', ctx)).toBe(RESOLVED_EMPTY)
|
|
}
|
|
)
|
|
|
|
it.concurrent('should return undefined for block not in workflow', () => {
|
|
const workflow = createTestWorkflow([{ id: 'existing' }])
|
|
const resolver = new BlockResolver(workflow)
|
|
const ctx = createTestContext('current', {})
|
|
|
|
expect(resolver.resolve('<nonexistent>', ctx)).toBeUndefined()
|
|
})
|
|
|
|
it.concurrent('should return RESOLVED_EMPTY for block in workflow that did not execute', () => {
|
|
const workflow = createTestWorkflow([
|
|
{ id: 'start-block', name: 'Start', type: 'start_trigger' },
|
|
{ id: 'slack-block', name: 'Slack', type: 'slack_trigger' },
|
|
])
|
|
const resolver = new BlockResolver(workflow)
|
|
const ctx = createTestContext('current', {
|
|
'slack-block': { message: 'hello from slack' },
|
|
})
|
|
|
|
expect(resolver.resolve('<slack.message>', ctx)).toBe('hello from slack')
|
|
expect(resolver.resolve('<start>', ctx)).toBe(RESOLVED_EMPTY)
|
|
expect(resolver.resolve('<start.input>', ctx)).toBe(RESOLVED_EMPTY)
|
|
})
|
|
|
|
it.concurrent(
|
|
'should return RESOLVED_EMPTY for nested json path on function block that did not execute',
|
|
() => {
|
|
// Repro for the branched-trigger CRM workflow bug: a function block
|
|
// on an untaken branch is referenced via a nested path under its
|
|
// `result` (declared `type: 'json'`). The resolver must not validate
|
|
// the path against the declared top-level schema keys in this case.
|
|
const workflow = createTestWorkflow([
|
|
{ id: 'normalize-email', name: 'NormalizeEmail', type: 'function' },
|
|
{ id: 'normalize-calendar', name: 'NormalizeCalendar', type: 'function' },
|
|
])
|
|
const resolver = new BlockResolver(workflow)
|
|
const ctx = createTestContext('current', {
|
|
'normalize-email': { result: { summary: 'email summary' }, stdout: '' },
|
|
})
|
|
|
|
expect(resolver.resolve('<NormalizeEmail.result.summary>', ctx)).toBe('email summary')
|
|
expect(resolver.resolve('<NormalizeCalendar.result.summary>', ctx)).toBe(RESOLVED_EMPTY)
|
|
expect(resolver.resolve('<NormalizeCalendar.result>', ctx)).toBe(RESOLVED_EMPTY)
|
|
}
|
|
)
|
|
|
|
it.concurrent(
|
|
'should return RESOLVED_EMPTY for nested json path on executed block when data is missing',
|
|
() => {
|
|
// Even for a block that ran, drilling into a `json`-typed field that
|
|
// the runtime output didn't include should resolve to empty rather
|
|
// than throw — `json` explicitly means dynamic shape.
|
|
const workflow = createTestWorkflow([
|
|
{ id: 'normalize-email', name: 'NormalizeEmail', type: 'function' },
|
|
])
|
|
const resolver = new BlockResolver(workflow)
|
|
const ctx = createTestContext('current', {
|
|
'normalize-email': { result: { subject: 'hi' }, stdout: '' },
|
|
})
|
|
|
|
expect(resolver.resolve('<NormalizeEmail.result.subject>', ctx)).toBe('hi')
|
|
expect(resolver.resolve('<NormalizeEmail.result.summary>', ctx)).toBe(RESOLVED_EMPTY)
|
|
}
|
|
)
|
|
|
|
it.concurrent('should fall back to context blockStates', () => {
|
|
const workflow = createTestWorkflow([{ id: 'source' }])
|
|
const resolver = new BlockResolver(workflow)
|
|
const contextStates = new Map([['source', { output: { fallback: true } }]])
|
|
const ctx = createTestContext('current', {}, contextStates)
|
|
|
|
expect(resolver.resolve('<source>', ctx)).toEqual({ fallback: true })
|
|
})
|
|
})
|
|
|
|
describe('formatValueForBlock', () => {
|
|
it.concurrent('should format string for condition block', () => {
|
|
const resolver = new BlockResolver(createTestWorkflow())
|
|
const result = resolver.formatValueForBlock('hello world', 'condition')
|
|
expect(result).toBe('"hello world"')
|
|
})
|
|
|
|
it.concurrent('should escape special characters for condition block', () => {
|
|
const resolver = new BlockResolver(createTestWorkflow())
|
|
expect(resolver.formatValueForBlock('line1\nline2', 'condition')).toBe('"line1\\nline2"')
|
|
expect(resolver.formatValueForBlock('quote "test"', 'condition')).toBe('"quote \\"test\\""')
|
|
expect(resolver.formatValueForBlock('backslash \\', 'condition')).toBe('"backslash \\\\"')
|
|
expect(resolver.formatValueForBlock('tab\there', 'condition')).toBe('"tab\there"')
|
|
})
|
|
|
|
it.concurrent('should format object for condition block', () => {
|
|
const resolver = new BlockResolver(createTestWorkflow())
|
|
const result = resolver.formatValueForBlock({ key: 'value' }, 'condition')
|
|
expect(result).toBe('{"key":"value"}')
|
|
})
|
|
|
|
it.concurrent('should format null/undefined for condition block', () => {
|
|
const resolver = new BlockResolver(createTestWorkflow())
|
|
expect(resolver.formatValueForBlock(null, 'condition')).toBe('null')
|
|
expect(resolver.formatValueForBlock(undefined, 'condition')).toBe('undefined')
|
|
})
|
|
|
|
it.concurrent('should format number for condition block', () => {
|
|
const resolver = new BlockResolver(createTestWorkflow())
|
|
expect(resolver.formatValueForBlock(42, 'condition')).toBe('42')
|
|
expect(resolver.formatValueForBlock(3.14, 'condition')).toBe('3.14')
|
|
expect(resolver.formatValueForBlock(-100, 'condition')).toBe('-100')
|
|
})
|
|
|
|
it.concurrent('should format boolean for condition block', () => {
|
|
const resolver = new BlockResolver(createTestWorkflow())
|
|
expect(resolver.formatValueForBlock(true, 'condition')).toBe('true')
|
|
expect(resolver.formatValueForBlock(false, 'condition')).toBe('false')
|
|
})
|
|
|
|
it.concurrent('should format string for function block (JSON escaped)', () => {
|
|
const resolver = new BlockResolver(createTestWorkflow())
|
|
const result = resolver.formatValueForBlock('hello', 'function')
|
|
expect(result).toBe('"hello"')
|
|
})
|
|
|
|
it.concurrent('should format object for function block', () => {
|
|
const resolver = new BlockResolver(createTestWorkflow())
|
|
const result = resolver.formatValueForBlock({ a: 1 }, 'function')
|
|
expect(result).toBe('{"a":1}')
|
|
})
|
|
|
|
it.concurrent('should format null/undefined for function block', () => {
|
|
const resolver = new BlockResolver(createTestWorkflow())
|
|
expect(resolver.formatValueForBlock(null, 'function')).toBe('null')
|
|
expect(resolver.formatValueForBlock(undefined, 'function')).toBe('undefined')
|
|
})
|
|
|
|
it.concurrent('should format string for response block (no quotes)', () => {
|
|
const resolver = new BlockResolver(createTestWorkflow())
|
|
expect(resolver.formatValueForBlock('plain text', 'response')).toBe('plain text')
|
|
})
|
|
|
|
it.concurrent('should format object for response block', () => {
|
|
const resolver = new BlockResolver(createTestWorkflow())
|
|
expect(resolver.formatValueForBlock({ key: 'value' }, 'response')).toBe('{"key":"value"}')
|
|
})
|
|
|
|
it.concurrent('should format array for response block', () => {
|
|
const resolver = new BlockResolver(createTestWorkflow())
|
|
expect(resolver.formatValueForBlock([1, 2, 3], 'response')).toBe('[1,2,3]')
|
|
})
|
|
|
|
it.concurrent('should format primitives for response block', () => {
|
|
const resolver = new BlockResolver(createTestWorkflow())
|
|
expect(resolver.formatValueForBlock(42, 'response')).toBe('42')
|
|
expect(resolver.formatValueForBlock(true, 'response')).toBe('true')
|
|
})
|
|
|
|
it.concurrent('should format object for default block type', () => {
|
|
const resolver = new BlockResolver(createTestWorkflow())
|
|
expect(resolver.formatValueForBlock({ x: 1 }, undefined)).toBe('{"x":1}')
|
|
expect(resolver.formatValueForBlock({ x: 1 }, 'agent')).toBe('{"x":1}')
|
|
})
|
|
|
|
it.concurrent('should format primitive for default block type', () => {
|
|
const resolver = new BlockResolver(createTestWorkflow())
|
|
expect(resolver.formatValueForBlock('text', undefined)).toBe('text')
|
|
expect(resolver.formatValueForBlock(123, undefined)).toBe('123')
|
|
})
|
|
})
|
|
|
|
describe('Response block backwards compatibility', () => {
|
|
it.concurrent('should resolve new format: <responseBlock.data>', () => {
|
|
const workflow = createTestWorkflow([
|
|
{ id: 'response-block', name: 'Response', type: 'response' },
|
|
])
|
|
const resolver = new BlockResolver(workflow)
|
|
const ctx = createTestContext('current', {
|
|
'response-block': {
|
|
data: { message: 'hello', userId: 123 },
|
|
status: 200,
|
|
headers: { 'Content-Type': 'application/json' },
|
|
},
|
|
})
|
|
|
|
expect(resolver.resolve('<response.data>', ctx)).toEqual({ message: 'hello', userId: 123 })
|
|
expect(resolver.resolve('<response.data.message>', ctx)).toBe('hello')
|
|
expect(resolver.resolve('<response.data.userId>', ctx)).toBe(123)
|
|
})
|
|
|
|
it.concurrent('should resolve new format: <responseBlock.status>', () => {
|
|
const workflow = createTestWorkflow([
|
|
{ id: 'response-block', name: 'Response', type: 'response' },
|
|
])
|
|
const resolver = new BlockResolver(workflow)
|
|
const ctx = createTestContext('current', {
|
|
'response-block': {
|
|
data: { message: 'hello' },
|
|
status: 201,
|
|
headers: {},
|
|
},
|
|
})
|
|
|
|
expect(resolver.resolve('<response.status>', ctx)).toBe(201)
|
|
})
|
|
|
|
it.concurrent('should resolve new format: <responseBlock.headers>', () => {
|
|
const workflow = createTestWorkflow([
|
|
{ id: 'response-block', name: 'Response', type: 'response' },
|
|
])
|
|
const resolver = new BlockResolver(workflow)
|
|
const ctx = createTestContext('current', {
|
|
'response-block': {
|
|
data: {},
|
|
status: 200,
|
|
headers: { 'X-Custom-Header': 'custom-value', 'Content-Type': 'application/json' },
|
|
},
|
|
})
|
|
|
|
expect(resolver.resolve('<response.headers>', ctx)).toEqual({
|
|
'X-Custom-Header': 'custom-value',
|
|
'Content-Type': 'application/json',
|
|
})
|
|
})
|
|
|
|
it.concurrent(
|
|
'should resolve old format (backwards compat): <responseBlock.response.data>',
|
|
() => {
|
|
const workflow = createTestWorkflow([
|
|
{ id: 'response-block', name: 'Response', type: 'response' },
|
|
])
|
|
const resolver = new BlockResolver(workflow)
|
|
const ctx = createTestContext('current', {
|
|
'response-block': {
|
|
data: { message: 'hello', userId: 123 },
|
|
status: 200,
|
|
headers: { 'Content-Type': 'application/json' },
|
|
},
|
|
})
|
|
|
|
// Old format: <responseBlock.response.data> should strip 'response.' and resolve to data
|
|
expect(resolver.resolve('<response.response.data>', ctx)).toEqual({
|
|
message: 'hello',
|
|
userId: 123,
|
|
})
|
|
expect(resolver.resolve('<response.response.data.message>', ctx)).toBe('hello')
|
|
expect(resolver.resolve('<response.response.data.userId>', ctx)).toBe(123)
|
|
}
|
|
)
|
|
|
|
it.concurrent(
|
|
'should resolve old format (backwards compat): <responseBlock.response.status>',
|
|
() => {
|
|
const workflow = createTestWorkflow([
|
|
{ id: 'response-block', name: 'Response', type: 'response' },
|
|
])
|
|
const resolver = new BlockResolver(workflow)
|
|
const ctx = createTestContext('current', {
|
|
'response-block': {
|
|
data: { message: 'hello' },
|
|
status: 404,
|
|
headers: {},
|
|
},
|
|
})
|
|
|
|
// Old format: <responseBlock.response.status> should strip 'response.' and resolve to status
|
|
expect(resolver.resolve('<response.response.status>', ctx)).toBe(404)
|
|
}
|
|
)
|
|
|
|
it.concurrent(
|
|
'should resolve old format (backwards compat): <responseBlock.response.headers>',
|
|
() => {
|
|
const workflow = createTestWorkflow([
|
|
{ id: 'response-block', name: 'Response', type: 'response' },
|
|
])
|
|
const resolver = new BlockResolver(workflow)
|
|
const ctx = createTestContext('current', {
|
|
'response-block': {
|
|
data: {},
|
|
status: 200,
|
|
headers: { 'X-Request-Id': 'abc-123' },
|
|
},
|
|
})
|
|
|
|
// Old format: <responseBlock.response.headers> should strip 'response.' and resolve to headers
|
|
expect(resolver.resolve('<response.response.headers>', ctx)).toEqual({
|
|
'X-Request-Id': 'abc-123',
|
|
})
|
|
}
|
|
)
|
|
|
|
it.concurrent('should resolve entire Response block output with new format', () => {
|
|
const workflow = createTestWorkflow([
|
|
{ id: 'response-block', name: 'My Response', type: 'response' },
|
|
])
|
|
const resolver = new BlockResolver(workflow)
|
|
const fullOutput = {
|
|
data: { result: 'success' },
|
|
status: 200,
|
|
headers: { 'Content-Type': 'application/json' },
|
|
}
|
|
const ctx = createTestContext('current', { 'response-block': fullOutput })
|
|
|
|
expect(resolver.resolve('<myresponse>', ctx)).toEqual(fullOutput)
|
|
})
|
|
|
|
it.concurrent(
|
|
'should only strip response prefix for response block type, not other blocks',
|
|
() => {
|
|
// For non-response blocks, 'response' is a valid property name that should NOT be stripped
|
|
const workflow = createTestWorkflow([{ id: 'agent-block', name: 'Agent', type: 'agent' }])
|
|
const resolver = new BlockResolver(workflow)
|
|
const ctx = createTestContext('current', {
|
|
'agent-block': {
|
|
response: { content: 'AI generated text' },
|
|
tokens: { input: 100, output: 50 },
|
|
},
|
|
})
|
|
|
|
// For agent blocks, 'response' is a valid property and should be accessed normally
|
|
expect(resolver.resolve('<agent.response.content>', ctx)).toBe('AI generated text')
|
|
}
|
|
)
|
|
|
|
it.concurrent(
|
|
'should NOT strip response prefix if output actually has response key (edge case)',
|
|
() => {
|
|
// Edge case: What if a Response block somehow has a 'response' key in its output?
|
|
// This shouldn't happen in practice, but if it does, we should respect it.
|
|
const workflow = createTestWorkflow([
|
|
{ id: 'response-block', name: 'Response', type: 'response' },
|
|
])
|
|
const resolver = new BlockResolver(workflow)
|
|
// Hypothetical edge case where output has an actual 'response' property
|
|
const ctx = createTestContext('current', {
|
|
'response-block': {
|
|
response: { legacyData: 'some value' },
|
|
data: { newData: 'other value' },
|
|
},
|
|
})
|
|
|
|
// Since output.response exists, we should NOT strip it - access the actual 'response' property
|
|
expect(resolver.resolve('<response.response.legacyData>', ctx)).toBe('some value')
|
|
expect(resolver.resolve('<response.data.newData>', ctx)).toBe('other value')
|
|
}
|
|
)
|
|
})
|
|
|
|
describe('Workflow block with child Response block backwards compatibility', () => {
|
|
it.concurrent('should resolve new format: <workflowBlock.result.data>', () => {
|
|
const workflow = createTestWorkflow([
|
|
{ id: 'workflow-block', name: 'My Workflow', type: 'workflow' },
|
|
])
|
|
const resolver = new BlockResolver(workflow)
|
|
// After our change, child workflow with Response block returns { data, status, headers }
|
|
// Workflow block wraps it in { success, result: { data, status, headers }, ... }
|
|
const ctx = createTestContext('current', {
|
|
'workflow-block': {
|
|
success: true,
|
|
childWorkflowName: 'Child Workflow',
|
|
result: {
|
|
data: { userId: 456, name: 'Test User' },
|
|
status: 200,
|
|
headers: { 'Content-Type': 'application/json' },
|
|
},
|
|
},
|
|
})
|
|
|
|
expect(resolver.resolve('<myworkflow.result.data>', ctx)).toEqual({
|
|
userId: 456,
|
|
name: 'Test User',
|
|
})
|
|
expect(resolver.resolve('<myworkflow.result.data.userId>', ctx)).toBe(456)
|
|
expect(resolver.resolve('<myworkflow.result.data.name>', ctx)).toBe('Test User')
|
|
})
|
|
|
|
it.concurrent('should resolve new format: <workflowBlock.result.status>', () => {
|
|
const workflow = createTestWorkflow([
|
|
{ id: 'workflow-block', name: 'My Workflow', type: 'workflow' },
|
|
])
|
|
const resolver = new BlockResolver(workflow)
|
|
const ctx = createTestContext('current', {
|
|
'workflow-block': {
|
|
success: true,
|
|
childWorkflowName: 'Child Workflow',
|
|
result: {
|
|
data: { message: 'created' },
|
|
status: 201,
|
|
headers: {},
|
|
},
|
|
},
|
|
})
|
|
|
|
expect(resolver.resolve('<myworkflow.result.status>', ctx)).toBe(201)
|
|
})
|
|
|
|
it.concurrent('should resolve new format: <workflowBlock.result.headers>', () => {
|
|
const workflow = createTestWorkflow([
|
|
{ id: 'workflow-block', name: 'My Workflow', type: 'workflow' },
|
|
])
|
|
const resolver = new BlockResolver(workflow)
|
|
const ctx = createTestContext('current', {
|
|
'workflow-block': {
|
|
success: true,
|
|
childWorkflowName: 'Child Workflow',
|
|
result: {
|
|
data: {},
|
|
status: 200,
|
|
headers: { 'X-Trace-Id': 'trace-abc-123' },
|
|
},
|
|
},
|
|
})
|
|
|
|
expect(resolver.resolve('<myworkflow.result.headers>', ctx)).toEqual({
|
|
'X-Trace-Id': 'trace-abc-123',
|
|
})
|
|
})
|
|
|
|
it.concurrent(
|
|
'should resolve old format (backwards compat): <workflowBlock.result.response.data>',
|
|
() => {
|
|
const workflow = createTestWorkflow([
|
|
{ id: 'workflow-block', name: 'My Workflow', type: 'workflow' },
|
|
])
|
|
const resolver = new BlockResolver(workflow)
|
|
const ctx = createTestContext('current', {
|
|
'workflow-block': {
|
|
success: true,
|
|
childWorkflowName: 'Child Workflow',
|
|
result: {
|
|
data: { userId: 456, name: 'Test User' },
|
|
status: 200,
|
|
headers: { 'Content-Type': 'application/json' },
|
|
},
|
|
},
|
|
})
|
|
|
|
// Old format: <workflowBlock.result.response.data> should strip 'response.' and resolve to result.data
|
|
expect(resolver.resolve('<myworkflow.result.response.data>', ctx)).toEqual({
|
|
userId: 456,
|
|
name: 'Test User',
|
|
})
|
|
expect(resolver.resolve('<myworkflow.result.response.data.userId>', ctx)).toBe(456)
|
|
expect(resolver.resolve('<myworkflow.result.response.data.name>', ctx)).toBe('Test User')
|
|
}
|
|
)
|
|
|
|
it.concurrent(
|
|
'should resolve old format (backwards compat): <workflowBlock.result.response.status>',
|
|
() => {
|
|
const workflow = createTestWorkflow([
|
|
{ id: 'workflow-block', name: 'My Workflow', type: 'workflow' },
|
|
])
|
|
const resolver = new BlockResolver(workflow)
|
|
const ctx = createTestContext('current', {
|
|
'workflow-block': {
|
|
success: true,
|
|
childWorkflowName: 'Child Workflow',
|
|
result: {
|
|
data: { message: 'error' },
|
|
status: 500,
|
|
headers: {},
|
|
},
|
|
},
|
|
})
|
|
|
|
// Old format: <workflowBlock.result.response.status> should strip 'response.' and resolve to result.status
|
|
expect(resolver.resolve('<myworkflow.result.response.status>', ctx)).toBe(500)
|
|
}
|
|
)
|
|
|
|
it.concurrent(
|
|
'should resolve old format (backwards compat): <workflowBlock.result.response.headers>',
|
|
() => {
|
|
const workflow = createTestWorkflow([
|
|
{ id: 'workflow-block', name: 'My Workflow', type: 'workflow' },
|
|
])
|
|
const resolver = new BlockResolver(workflow)
|
|
const ctx = createTestContext('current', {
|
|
'workflow-block': {
|
|
success: true,
|
|
childWorkflowName: 'Child Workflow',
|
|
result: {
|
|
data: {},
|
|
status: 200,
|
|
headers: { 'Cache-Control': 'no-cache' },
|
|
},
|
|
},
|
|
})
|
|
|
|
// Old format: <workflowBlock.result.response.headers> should strip 'response.' and resolve to result.headers
|
|
expect(resolver.resolve('<myworkflow.result.response.headers>', ctx)).toEqual({
|
|
'Cache-Control': 'no-cache',
|
|
})
|
|
}
|
|
)
|
|
|
|
it.concurrent('should resolve workflow block success and other properties', () => {
|
|
const workflow = createTestWorkflow([
|
|
{ id: 'workflow-block', name: 'My Workflow', type: 'workflow' },
|
|
])
|
|
const resolver = new BlockResolver(workflow)
|
|
const ctx = createTestContext('current', {
|
|
'workflow-block': {
|
|
success: true,
|
|
childWorkflowName: 'Child Workflow',
|
|
result: { data: {}, status: 200, headers: {} },
|
|
},
|
|
})
|
|
|
|
expect(resolver.resolve('<myworkflow.success>', ctx)).toBe(true)
|
|
expect(resolver.resolve('<myworkflow.childWorkflowName>', ctx)).toBe('Child Workflow')
|
|
})
|
|
|
|
it.concurrent('should handle workflow block with failed child workflow', () => {
|
|
const workflow = createTestWorkflow([
|
|
{ id: 'workflow-block', name: 'My Workflow', type: 'workflow' },
|
|
])
|
|
const resolver = new BlockResolver(workflow)
|
|
const ctx = createTestContext('current', {
|
|
'workflow-block': {
|
|
success: false,
|
|
childWorkflowName: 'Child Workflow',
|
|
result: {},
|
|
error: 'Child workflow execution failed',
|
|
},
|
|
})
|
|
|
|
expect(resolver.resolve('<myworkflow.success>', ctx)).toBe(false)
|
|
expect(resolver.resolve('<myworkflow.error>', ctx)).toBe('Child workflow execution failed')
|
|
})
|
|
|
|
it.concurrent('should handle workflow block where child has non-Response final block', () => {
|
|
// When child workflow does NOT have a Response block as final block,
|
|
// the result structure will be different (not data/status/headers)
|
|
const workflow = createTestWorkflow([
|
|
{ id: 'workflow-block', name: 'My Workflow', type: 'workflow' },
|
|
])
|
|
const resolver = new BlockResolver(workflow)
|
|
const ctx = createTestContext('current', {
|
|
'workflow-block': {
|
|
success: true,
|
|
childWorkflowName: 'Child Workflow',
|
|
result: {
|
|
content: 'AI generated response',
|
|
tokens: { input: 100, output: 50 },
|
|
},
|
|
},
|
|
})
|
|
|
|
// No backwards compat needed here since child didn't have Response block
|
|
expect(resolver.resolve('<myworkflow.result.content>', ctx)).toBe('AI generated response')
|
|
expect(resolver.resolve('<myworkflow.result.tokens.input>', ctx)).toBe(100)
|
|
})
|
|
|
|
it.concurrent('should not apply workflow backwards compat for non-workflow blocks', () => {
|
|
// For non-workflow blocks, 'result.response' is a valid path that should NOT be modified
|
|
const workflow = createTestWorkflow([
|
|
{ id: 'function-block', name: 'Function', type: 'function' },
|
|
])
|
|
const resolver = new BlockResolver(workflow)
|
|
const ctx = createTestContext('current', {
|
|
'function-block': {
|
|
result: {
|
|
response: { apiData: 'test' },
|
|
other: 'value',
|
|
},
|
|
},
|
|
})
|
|
|
|
// For function blocks, 'result.response' is a valid nested property
|
|
expect(resolver.resolve('<function.result.response.apiData>', ctx)).toBe('test')
|
|
})
|
|
|
|
it.concurrent(
|
|
'should NOT strip result.response if child actually has response property (edge case)',
|
|
() => {
|
|
// Edge case: Child workflow's final output legitimately has a 'response' property
|
|
// (e.g., child ended with an Agent block that outputs response data)
|
|
const workflow = createTestWorkflow([
|
|
{ id: 'workflow-block', name: 'My Workflow', type: 'workflow' },
|
|
])
|
|
const resolver = new BlockResolver(workflow)
|
|
const ctx = createTestContext('current', {
|
|
'workflow-block': {
|
|
success: true,
|
|
childWorkflowName: 'Child Workflow',
|
|
result: {
|
|
// Child workflow ended with Agent block, not Response block
|
|
content: 'AI generated text',
|
|
response: { apiCallData: 'from external API' }, // legitimate 'response' property
|
|
},
|
|
},
|
|
})
|
|
|
|
// Since output.result.response exists, we should NOT strip it - access the actual property
|
|
expect(resolver.resolve('<myworkflow.result.response.apiCallData>', ctx)).toBe(
|
|
'from external API'
|
|
)
|
|
expect(resolver.resolve('<myworkflow.result.content>', ctx)).toBe('AI generated text')
|
|
}
|
|
)
|
|
|
|
it.concurrent('should handle mixed scenarios correctly', () => {
|
|
// Test that new format works when child workflow had Response block
|
|
const workflow = createTestWorkflow([
|
|
{ id: 'workflow-block', name: 'My Workflow', type: 'workflow' },
|
|
])
|
|
const resolver = new BlockResolver(workflow)
|
|
|
|
// Scenario 1: Child had Response block (new format - no 'response' key in result)
|
|
const ctx1 = createTestContext('current', {
|
|
'workflow-block': {
|
|
success: true,
|
|
result: { data: { id: 1 }, status: 200, headers: {} },
|
|
},
|
|
})
|
|
// New format works
|
|
expect(resolver.resolve('<myworkflow.result.data.id>', ctx1)).toBe(1)
|
|
// Old format also works (backwards compat kicks in because result.response is undefined)
|
|
expect(resolver.resolve('<myworkflow.result.response.data.id>', ctx1)).toBe(1)
|
|
|
|
// Scenario 2: Child had Agent block with 'response' property
|
|
const ctx2 = createTestContext('current', {
|
|
'workflow-block': {
|
|
success: true,
|
|
result: {
|
|
content: 'text',
|
|
response: { external: 'data' }, // actual 'response' property
|
|
},
|
|
},
|
|
})
|
|
// Access the actual 'response' property - no stripping
|
|
expect(resolver.resolve('<myworkflow.result.response.external>', ctx2)).toBe('data')
|
|
})
|
|
|
|
it.concurrent(
|
|
'real-world scenario: parent workflow referencing child Response block via <workflow1.result.response.data>',
|
|
() => {
|
|
/**
|
|
* This test simulates the exact scenario from user workflows:
|
|
*
|
|
* Child workflow (vibrant-cliff):
|
|
* Start → Function 1 (returns "fuck") → Response 1
|
|
* Response 1 outputs: { data: { hi: "fuck" }, status: 200, headers: {...} }
|
|
*
|
|
* Parent workflow (flying-glacier):
|
|
* Start → Workflow 1 (calls vibrant-cliff) → Function 1
|
|
* Function 1 code: return <workflow1.result.response.data>
|
|
*
|
|
* After our changes:
|
|
* - Child Response block outputs { data, status, headers } (no wrapper)
|
|
* - Workflow block wraps it in { success, result: { data, status, headers }, ... }
|
|
* - Parent uses OLD reference <workflow1.result.response.data>
|
|
* - Backwards compat should strip 'response.' and resolve to result.data
|
|
*/
|
|
const workflow = createTestWorkflow([
|
|
{ id: 'workflow-block', name: 'Workflow 1', type: 'workflow' },
|
|
])
|
|
const resolver = new BlockResolver(workflow)
|
|
|
|
// Simulate the workflow block output after child (vibrant-cliff) executes
|
|
// Child's Response block now outputs { data, status, headers } directly (no wrapper)
|
|
// Workflow block wraps it in { success, result: <child_output>, ... }
|
|
const ctx = createTestContext('current', {
|
|
'workflow-block': {
|
|
success: true,
|
|
childWorkflowName: 'vibrant-cliff',
|
|
result: {
|
|
// This is what Response block outputs after our changes (no 'response' wrapper)
|
|
data: { hi: 'fuck' },
|
|
status: 200,
|
|
headers: { 'Content-Type': 'application/json' },
|
|
},
|
|
},
|
|
})
|
|
|
|
// OLD reference pattern: <workflow1.result.response.data>
|
|
// Should work via backwards compatibility (strips 'response.')
|
|
expect(resolver.resolve('<workflow1.result.response.data>', ctx)).toEqual({ hi: 'fuck' })
|
|
expect(resolver.resolve('<workflow1.result.response.data.hi>', ctx)).toBe('fuck')
|
|
expect(resolver.resolve('<workflow1.result.response.status>', ctx)).toBe(200)
|
|
|
|
// NEW reference pattern: <workflow1.result.data>
|
|
// Should work directly
|
|
expect(resolver.resolve('<workflow1.result.data>', ctx)).toEqual({ hi: 'fuck' })
|
|
expect(resolver.resolve('<workflow1.result.data.hi>', ctx)).toBe('fuck')
|
|
expect(resolver.resolve('<workflow1.result.status>', ctx)).toBe(200)
|
|
|
|
// Other workflow block properties should still work
|
|
expect(resolver.resolve('<workflow1.success>', ctx)).toBe(true)
|
|
expect(resolver.resolve('<workflow1.childWorkflowName>', ctx)).toBe('vibrant-cliff')
|
|
}
|
|
)
|
|
|
|
it.concurrent(
|
|
'real-world scenario: accessing entire response object via <workflow1.result.response> (workflow type)',
|
|
() => {
|
|
const workflow = createTestWorkflow([
|
|
{ id: 'workflow-block', name: 'Workflow 1', type: 'workflow' },
|
|
])
|
|
const resolver = new BlockResolver(workflow)
|
|
|
|
// Child Response block output (new format - no wrapper)
|
|
const ctx = createTestContext('current', {
|
|
'workflow-block': {
|
|
success: true,
|
|
childWorkflowName: 'response-workflow-child-editor',
|
|
result: {
|
|
data: {
|
|
s: 'example string',
|
|
nums: [1, 2, 3],
|
|
n: 42,
|
|
obj: { key1: 'value1', key2: 'value2' },
|
|
},
|
|
status: 206,
|
|
headers: { 'Content-Type': 'application/json', apple: 'banana' },
|
|
},
|
|
},
|
|
})
|
|
|
|
// OLD reference: <workflow1.result.response> should return the entire result object
|
|
// This is used when the user wants to get data, status, headers all at once
|
|
const response = resolver.resolve('<workflow1.result.response>', ctx)
|
|
expect(response).toEqual({
|
|
data: {
|
|
s: 'example string',
|
|
nums: [1, 2, 3],
|
|
n: 42,
|
|
obj: { key1: 'value1', key2: 'value2' },
|
|
},
|
|
status: 206,
|
|
headers: { 'Content-Type': 'application/json', apple: 'banana' },
|
|
})
|
|
|
|
// Verify individual fields can be accessed from the returned object
|
|
expect(response.status).toBe(206)
|
|
expect(response.headers.apple).toBe('banana')
|
|
expect(response.data.s).toBe('example string')
|
|
expect(response.data.n).toBe(42)
|
|
expect(response.data.nums).toEqual([1, 2, 3])
|
|
expect(response.data.obj.key1).toBe('value1')
|
|
}
|
|
)
|
|
|
|
it.concurrent(
|
|
'real-world scenario: workflow_input type block with <workflow1.result.response>',
|
|
() => {
|
|
/**
|
|
* CRITICAL: Workflow blocks can have type 'workflow' OR 'workflow_input'.
|
|
* Both must support backwards compatibility.
|
|
*
|
|
* This test uses 'workflow_input' which is the actual type in production.
|
|
*/
|
|
const workflow = createTestWorkflow([
|
|
{ id: 'workflow-block', name: 'Workflow 1', type: 'workflow_input' },
|
|
])
|
|
const resolver = new BlockResolver(workflow)
|
|
|
|
const ctx = createTestContext('current', {
|
|
'workflow-block': {
|
|
success: true,
|
|
childWorkflowName: 'response-workflow-child-editor',
|
|
result: {
|
|
data: {
|
|
s: 'example string',
|
|
nums: [1, 2, 3],
|
|
n: 42,
|
|
obj: { key1: 'value1', key2: 'value2' },
|
|
},
|
|
status: 206,
|
|
headers: { 'Content-Type': 'application/json', apple: 'banana' },
|
|
},
|
|
},
|
|
})
|
|
|
|
// OLD reference: <workflow1.result.response> should return the entire result object
|
|
const response = resolver.resolve('<workflow1.result.response>', ctx)
|
|
expect(response).toEqual({
|
|
data: {
|
|
s: 'example string',
|
|
nums: [1, 2, 3],
|
|
n: 42,
|
|
obj: { key1: 'value1', key2: 'value2' },
|
|
},
|
|
status: 206,
|
|
headers: { 'Content-Type': 'application/json', apple: 'banana' },
|
|
})
|
|
|
|
// Also test drilling into specific fields
|
|
expect(resolver.resolve('<workflow1.result.response.data>', ctx)).toEqual({
|
|
s: 'example string',
|
|
nums: [1, 2, 3],
|
|
n: 42,
|
|
obj: { key1: 'value1', key2: 'value2' },
|
|
})
|
|
expect(resolver.resolve('<workflow1.result.response.status>', ctx)).toBe(206)
|
|
expect(resolver.resolve('<workflow1.result.response.data.s>', ctx)).toBe('example string')
|
|
}
|
|
)
|
|
})
|
|
|
|
describe('edge cases', () => {
|
|
it.concurrent('should handle case-insensitive block name matching', () => {
|
|
const workflow = createTestWorkflow([{ id: 'block-1', name: 'My Block' }])
|
|
const resolver = new BlockResolver(workflow)
|
|
const ctx = createTestContext('current', { 'block-1': { data: 'test' } })
|
|
|
|
expect(resolver.resolve('<MYBLOCK>', ctx)).toEqual({ data: 'test' })
|
|
expect(resolver.resolve('<myblock>', ctx)).toEqual({ data: 'test' })
|
|
expect(resolver.resolve('<MyBlock>', ctx)).toEqual({ data: 'test' })
|
|
})
|
|
|
|
it.concurrent('should handle block names with spaces', () => {
|
|
const workflow = createTestWorkflow([{ id: 'block-1', name: 'API Request Block' }])
|
|
const resolver = new BlockResolver(workflow)
|
|
const ctx = createTestContext('current', { 'block-1': { status: 200 } })
|
|
|
|
expect(resolver.resolve('<apirequestblock>', ctx)).toEqual({ status: 200 })
|
|
})
|
|
|
|
it.concurrent('should handle empty path returning entire output', () => {
|
|
const workflow = createTestWorkflow([{ id: 'source' }])
|
|
const resolver = new BlockResolver(workflow)
|
|
const output = { a: 1, b: 2, c: { nested: true } }
|
|
const ctx = createTestContext('current', { source: output })
|
|
|
|
expect(resolver.resolve('<source>', ctx)).toEqual(output)
|
|
})
|
|
|
|
it.concurrent('should handle output with null values', () => {
|
|
const workflow = createTestWorkflow([{ id: 'source' }])
|
|
const resolver = new BlockResolver(workflow)
|
|
const ctx = createTestContext('current', {
|
|
source: { value: null, other: 'exists' },
|
|
})
|
|
|
|
expect(resolver.resolve('<source.value>', ctx)).toBeNull()
|
|
expect(resolver.resolve('<source.other>', ctx)).toBe('exists')
|
|
})
|
|
|
|
it.concurrent('should return RESOLVED_EMPTY for output with undefined values', () => {
|
|
const workflow = createTestWorkflow([{ id: 'source', type: 'unknown_block_type' }])
|
|
const resolver = new BlockResolver(workflow)
|
|
const ctx = createTestContext('current', {
|
|
source: { value: undefined, other: 'exists' },
|
|
})
|
|
|
|
expect(resolver.resolve('<source.value>', ctx)).toBe(RESOLVED_EMPTY)
|
|
})
|
|
|
|
it.concurrent('should return RESOLVED_EMPTY for deeply nested non-existent path', () => {
|
|
const workflow = createTestWorkflow([{ id: 'source', type: 'unknown_block_type' }])
|
|
const resolver = new BlockResolver(workflow)
|
|
const ctx = createTestContext('current', {
|
|
source: { level1: { level2: {} } },
|
|
})
|
|
|
|
expect(resolver.resolve('<source.level1.level2.level3>', ctx)).toBe(RESOLVED_EMPTY)
|
|
})
|
|
})
|
|
})
|