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 ) 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 }> = [], subflows: { loops?: Record; parallels?: Record } = {} ) { 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 = {}, contextBlockStates?: Map, parallelBlockMapping?: Map ): 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('')).toBe(true) expect(resolver.canResolve('')).toBe(true) expect(resolver.canResolve('')).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('')).toBe(true) expect(resolver.canResolve('')).toBe(true) }) it.concurrent('should return false for special prefixes', () => { const resolver = new BlockResolver(createTestWorkflow()) expect(resolver.canResolve('')).toBe(false) expect(resolver.canResolve('')).toBe(false) expect(resolver.canResolve('')).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('', 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('', ctx)).toEqual({ message: 'hello' }) expect(resolver.resolve('', 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('', ctx)).toBe('jane@acme.com') expect(resolver.resolve('', 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('', ctx)).toBe('plain@acme.com') const reversedResolver = new BlockResolver(createTestWorkflow([...blocks].reverse())) expect(reversedResolver.resolve('', 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('', ctx)).toBe('Alice') expect(resolver.resolve('', 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('', 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('', 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('', 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('', 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('', 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('', ctx)).toBe('Alice') expect(resolver.resolve('', ctx)).toBe(1) expect(() => resolver.resolve('', 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('', ctx)).toBe('event-1') expect(() => resolver.resolve('', ctx)).toThrow('too large to inline') expect(() => resolver.resolve('', 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('', ctx)).toEqual({ id: 1 }) expect(resolver.resolve('', 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('', 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('', ctx)).toThrow( /"invalidField" doesn't exist on block "source"/ ) expect(() => resolver.resolve('', 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('', ctx)).toBe('log output') expect(resolver.resolve('', 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('', 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('', 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('', ctx)).toBe(RESOLVED_EMPTY) expect(resolver.resolve('', ctx)).toBe(RESOLVED_EMPTY) expect(resolver.resolve('', 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('', 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('', ctx)).toBe('hello from slack') expect(resolver.resolve('', ctx)).toBe(RESOLVED_EMPTY) expect(resolver.resolve('', 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('', ctx)).toBe('email summary') expect(resolver.resolve('', ctx)).toBe(RESOLVED_EMPTY) expect(resolver.resolve('', 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('', ctx)).toBe('hi') expect(resolver.resolve('', 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('', 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: ', () => { 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('', ctx)).toEqual({ message: 'hello', userId: 123 }) expect(resolver.resolve('', ctx)).toBe('hello') expect(resolver.resolve('', ctx)).toBe(123) }) it.concurrent('should resolve new format: ', () => { 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('', ctx)).toBe(201) }) it.concurrent('should resolve new format: ', () => { 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('', ctx)).toEqual({ 'X-Custom-Header': 'custom-value', 'Content-Type': 'application/json', }) }) it.concurrent( 'should resolve old format (backwards compat): ', () => { 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: should strip 'response.' and resolve to data expect(resolver.resolve('', ctx)).toEqual({ message: 'hello', userId: 123, }) expect(resolver.resolve('', ctx)).toBe('hello') expect(resolver.resolve('', ctx)).toBe(123) } ) it.concurrent( 'should resolve old format (backwards compat): ', () => { 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: should strip 'response.' and resolve to status expect(resolver.resolve('', ctx)).toBe(404) } ) it.concurrent( 'should resolve old format (backwards compat): ', () => { 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: should strip 'response.' and resolve to headers expect(resolver.resolve('', 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('', 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('', 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('', ctx)).toBe('some value') expect(resolver.resolve('', ctx)).toBe('other value') } ) }) describe('Workflow block with child Response block backwards compatibility', () => { it.concurrent('should resolve new format: ', () => { 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('', ctx)).toEqual({ userId: 456, name: 'Test User', }) expect(resolver.resolve('', ctx)).toBe(456) expect(resolver.resolve('', ctx)).toBe('Test User') }) it.concurrent('should resolve new format: ', () => { 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('', ctx)).toBe(201) }) it.concurrent('should resolve new format: ', () => { 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('', ctx)).toEqual({ 'X-Trace-Id': 'trace-abc-123', }) }) it.concurrent( 'should resolve old format (backwards compat): ', () => { 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: should strip 'response.' and resolve to result.data expect(resolver.resolve('', ctx)).toEqual({ userId: 456, name: 'Test User', }) expect(resolver.resolve('', ctx)).toBe(456) expect(resolver.resolve('', ctx)).toBe('Test User') } ) it.concurrent( 'should resolve old format (backwards compat): ', () => { 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: should strip 'response.' and resolve to result.status expect(resolver.resolve('', ctx)).toBe(500) } ) it.concurrent( 'should resolve old format (backwards compat): ', () => { 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: should strip 'response.' and resolve to result.headers expect(resolver.resolve('', 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('', ctx)).toBe(true) expect(resolver.resolve('', 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('', ctx)).toBe(false) expect(resolver.resolve('', 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('', ctx)).toBe('AI generated response') expect(resolver.resolve('', 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('', 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('', ctx)).toBe( 'from external API' ) expect(resolver.resolve('', 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('', ctx1)).toBe(1) // Old format also works (backwards compat kicks in because result.response is undefined) expect(resolver.resolve('', 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('', ctx2)).toBe('data') }) it.concurrent( 'real-world scenario: parent workflow referencing child Response block via ', () => { /** * 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 * * 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 * - 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: , ... } 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: // Should work via backwards compatibility (strips 'response.') expect(resolver.resolve('', ctx)).toEqual({ hi: 'fuck' }) expect(resolver.resolve('', ctx)).toBe('fuck') expect(resolver.resolve('', ctx)).toBe(200) // NEW reference pattern: // Should work directly expect(resolver.resolve('', ctx)).toEqual({ hi: 'fuck' }) expect(resolver.resolve('', ctx)).toBe('fuck') expect(resolver.resolve('', ctx)).toBe(200) // Other workflow block properties should still work expect(resolver.resolve('', ctx)).toBe(true) expect(resolver.resolve('', ctx)).toBe('vibrant-cliff') } ) it.concurrent( 'real-world scenario: accessing entire response object via (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: 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('', 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 ', () => { /** * 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: should return the entire result object const response = resolver.resolve('', 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('', ctx)).toEqual({ s: 'example string', nums: [1, 2, 3], n: 42, obj: { key1: 'value1', key2: 'value2' }, }) expect(resolver.resolve('', ctx)).toBe(206) expect(resolver.resolve('', 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('', ctx)).toEqual({ data: 'test' }) expect(resolver.resolve('', ctx)).toEqual({ data: 'test' }) expect(resolver.resolve('', 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('', 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('', 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('', ctx)).toBeNull() expect(resolver.resolve('', 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('', 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('', ctx)).toBe(RESOLVED_EMPTY) }) }) })