mirror of
https://github.com/simstudioai/sim.git
synced 2026-02-19 02:34:37 -05:00
improvement(serializer): canonical subblock, serialization cleanups, schedules/webhooks are deployment version friendly (#2848)
* hide form deployment tab from docs * progress * fix resolution * cleanup code * fix positioning * cleanup dead sockets adv mode ops * address greptile comments * fix tests plus more simplification * fix cleanup * bring back advanced mode with specific definition * revert feature flags * improvement(subblock): ui * resolver change to make all var references optional chaining * fix(webhooks/schedules): deployment version friendly * fix tests * fix credential sets with new lifecycle * prep merge * add back migration * fix display check for adv fields * fix trigger vs block scoping --------- Co-authored-by: Emir Karabeg <emirkarabeg@berkeley.edu>
This commit is contained in:
committed by
GitHub
parent
ce3ddb6ba0
commit
78e4ca9d45
@@ -515,103 +515,131 @@ describe('Serializer', () => {
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* Advanced mode field filtering tests
|
||||
*/
|
||||
describe('advanced mode field filtering', () => {
|
||||
it.concurrent('should include all fields when block is in advanced mode', () => {
|
||||
describe('canonical mode field selection', () => {
|
||||
it.concurrent('should use advanced value when canonicalModes specifies advanced', () => {
|
||||
const serializer = new Serializer()
|
||||
|
||||
const advancedModeBlock: any = {
|
||||
const block: any = {
|
||||
id: 'slack-1',
|
||||
type: 'slack',
|
||||
name: 'Test Slack Block',
|
||||
position: { x: 0, y: 0 },
|
||||
advancedMode: true, // Advanced mode enabled
|
||||
data: {
|
||||
canonicalModes: { channel: 'advanced' },
|
||||
},
|
||||
subBlocks: {
|
||||
channel: { value: 'general' }, // basic mode field
|
||||
manualChannel: { value: 'C1234567890' }, // advanced mode field
|
||||
text: { value: 'Hello world' }, // both mode field
|
||||
username: { value: 'bot' }, // both mode field
|
||||
operation: { value: 'send' },
|
||||
destinationType: { value: 'channel' },
|
||||
channel: { value: 'general' },
|
||||
manualChannel: { value: 'C1234567890' },
|
||||
text: { value: 'Hello world' },
|
||||
username: { value: 'bot' },
|
||||
},
|
||||
outputs: {},
|
||||
enabled: true,
|
||||
}
|
||||
|
||||
const serialized = serializer.serializeWorkflow({ 'slack-1': advancedModeBlock }, [], {})
|
||||
|
||||
const serialized = serializer.serializeWorkflow({ 'slack-1': block }, [], {})
|
||||
const slackBlock = serialized.blocks.find((b) => b.id === 'slack-1')
|
||||
expect(slackBlock).toBeDefined()
|
||||
|
||||
expect(slackBlock?.config.params.channel).toBe('general')
|
||||
expect(slackBlock?.config.params.manualChannel).toBe('C1234567890')
|
||||
expect(slackBlock).toBeDefined()
|
||||
expect(slackBlock?.config.params.channel).toBe('C1234567890')
|
||||
expect(slackBlock?.config.params.manualChannel).toBeUndefined()
|
||||
expect(slackBlock?.config.params.text).toBe('Hello world')
|
||||
expect(slackBlock?.config.params.username).toBe('bot')
|
||||
})
|
||||
|
||||
it.concurrent('should exclude advanced-only fields when block is in basic mode', () => {
|
||||
it.concurrent('should use basic value when canonicalModes specifies basic', () => {
|
||||
const serializer = new Serializer()
|
||||
|
||||
const basicModeBlock: any = {
|
||||
const block: any = {
|
||||
id: 'slack-1',
|
||||
type: 'slack',
|
||||
name: 'Test Slack Block',
|
||||
position: { x: 0, y: 0 },
|
||||
advancedMode: false, // Basic mode enabled
|
||||
data: {
|
||||
canonicalModes: { channel: 'basic' },
|
||||
},
|
||||
subBlocks: {
|
||||
channel: { value: 'general' }, // basic mode field
|
||||
manualChannel: { value: 'C1234567890' }, // advanced mode field
|
||||
text: { value: 'Hello world' }, // both mode field
|
||||
username: { value: 'bot' }, // both mode field
|
||||
operation: { value: 'send' },
|
||||
destinationType: { value: 'channel' },
|
||||
channel: { value: 'general' },
|
||||
manualChannel: { value: 'C1234567890' },
|
||||
text: { value: 'Hello world' },
|
||||
username: { value: 'bot' },
|
||||
},
|
||||
outputs: {},
|
||||
enabled: true,
|
||||
}
|
||||
|
||||
const serialized = serializer.serializeWorkflow({ 'slack-1': basicModeBlock }, [], {})
|
||||
|
||||
const serialized = serializer.serializeWorkflow({ 'slack-1': block }, [], {})
|
||||
const slackBlock = serialized.blocks.find((b) => b.id === 'slack-1')
|
||||
expect(slackBlock).toBeDefined()
|
||||
|
||||
expect(slackBlock).toBeDefined()
|
||||
expect(slackBlock?.config.params.channel).toBe('general')
|
||||
expect(slackBlock?.config.params.manualChannel).toBeUndefined()
|
||||
expect(slackBlock?.config.params.text).toBe('Hello world')
|
||||
expect(slackBlock?.config.params.username).toBe('bot')
|
||||
})
|
||||
|
||||
it.concurrent(
|
||||
'should exclude advanced-only fields when advancedMode is undefined (defaults to basic mode)',
|
||||
() => {
|
||||
const serializer = new Serializer()
|
||||
it.concurrent('should fall back to legacy advancedMode when canonicalModes not set', () => {
|
||||
const serializer = new Serializer()
|
||||
|
||||
const defaultModeBlock: any = {
|
||||
id: 'slack-1',
|
||||
type: 'slack',
|
||||
name: 'Test Slack Block',
|
||||
position: { x: 0, y: 0 },
|
||||
subBlocks: {
|
||||
channel: { value: 'general' },
|
||||
manualChannel: { value: 'C1234567890' },
|
||||
text: { value: 'Hello world' },
|
||||
username: { value: 'bot' },
|
||||
},
|
||||
outputs: {},
|
||||
enabled: true,
|
||||
}
|
||||
|
||||
const serialized = serializer.serializeWorkflow({ 'slack-1': defaultModeBlock }, [], {})
|
||||
|
||||
const slackBlock = serialized.blocks.find((b) => b.id === 'slack-1')
|
||||
expect(slackBlock).toBeDefined()
|
||||
|
||||
expect(slackBlock?.config.params.channel).toBe('general')
|
||||
expect(slackBlock?.config.params.manualChannel).toBeUndefined()
|
||||
expect(slackBlock?.config.params.text).toBe('Hello world')
|
||||
expect(slackBlock?.config.params.username).toBe('bot')
|
||||
const block: any = {
|
||||
id: 'slack-1',
|
||||
type: 'slack',
|
||||
name: 'Test Slack Block',
|
||||
position: { x: 0, y: 0 },
|
||||
advancedMode: true,
|
||||
subBlocks: {
|
||||
operation: { value: 'send' },
|
||||
destinationType: { value: 'channel' },
|
||||
channel: { value: 'general' },
|
||||
manualChannel: { value: 'C1234567890' },
|
||||
text: { value: 'Hello world' },
|
||||
username: { value: 'bot' },
|
||||
},
|
||||
outputs: {},
|
||||
enabled: true,
|
||||
}
|
||||
)
|
||||
|
||||
it.concurrent('should filter memories field correctly in agent blocks', () => {
|
||||
const serialized = serializer.serializeWorkflow({ 'slack-1': block }, [], {})
|
||||
const slackBlock = serialized.blocks.find((b) => b.id === 'slack-1')
|
||||
|
||||
expect(slackBlock).toBeDefined()
|
||||
expect(slackBlock?.config.params.channel).toBe('C1234567890')
|
||||
expect(slackBlock?.config.params.manualChannel).toBeUndefined()
|
||||
})
|
||||
|
||||
it.concurrent('should use basic value by default when no mode specified', () => {
|
||||
const serializer = new Serializer()
|
||||
|
||||
const block: any = {
|
||||
id: 'slack-1',
|
||||
type: 'slack',
|
||||
name: 'Test Slack Block',
|
||||
position: { x: 0, y: 0 },
|
||||
subBlocks: {
|
||||
operation: { value: 'send' },
|
||||
destinationType: { value: 'channel' },
|
||||
channel: { value: 'general' },
|
||||
manualChannel: { value: 'C1234567890' },
|
||||
text: { value: 'Hello world' },
|
||||
username: { value: 'bot' },
|
||||
},
|
||||
outputs: {},
|
||||
enabled: true,
|
||||
}
|
||||
|
||||
const serialized = serializer.serializeWorkflow({ 'slack-1': block }, [], {})
|
||||
const slackBlock = serialized.blocks.find((b) => b.id === 'slack-1')
|
||||
|
||||
expect(slackBlock).toBeDefined()
|
||||
expect(slackBlock?.config.params.channel).toBe('general')
|
||||
expect(slackBlock?.config.params.manualChannel).toBeUndefined()
|
||||
})
|
||||
|
||||
it.concurrent('should preserve advanced-only values when present in basic mode', () => {
|
||||
const serializer = new Serializer()
|
||||
|
||||
const agentInBasicMode: any = {
|
||||
@@ -637,7 +665,9 @@ describe('Serializer', () => {
|
||||
|
||||
expect(agentBlock?.config.params.systemPrompt).toBe('You are helpful')
|
||||
expect(agentBlock?.config.params.userPrompt).toBe('Hello')
|
||||
expect(agentBlock?.config.params.memories).toBeUndefined()
|
||||
expect(agentBlock?.config.params.memories).toEqual([
|
||||
{ role: 'user', content: 'My name is John' },
|
||||
])
|
||||
expect(agentBlock?.config.params.model).toBe('claude-3-sonnet')
|
||||
})
|
||||
|
||||
|
||||
@@ -1,9 +1,17 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import type { Edge } from 'reactflow'
|
||||
import { parseResponseFormatSafely } from '@/lib/core/utils/response-format'
|
||||
import { BlockPathCalculator } from '@/lib/workflows/blocks/block-path-calculator'
|
||||
import {
|
||||
buildCanonicalIndex,
|
||||
buildSubBlockValues,
|
||||
evaluateSubBlockCondition,
|
||||
getCanonicalValues,
|
||||
isNonEmptyValue,
|
||||
isSubBlockFeatureEnabled,
|
||||
} from '@/lib/workflows/subblocks/visibility'
|
||||
import { getBlock } from '@/blocks'
|
||||
import type { SubBlockConfig } from '@/blocks/types'
|
||||
import { REFERENCE } from '@/executor/constants'
|
||||
import type { SerializedBlock, SerializedWorkflow } from '@/serializer/types'
|
||||
import type { BlockState, Loop, Parallel } from '@/stores/workflows/workflow/types'
|
||||
import { generateLoopBlocks, generateParallelBlocks } from '@/stores/workflows/workflow/utils'
|
||||
@@ -27,67 +35,37 @@ export class WorkflowValidationError extends Error {
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to check if a subblock should be included in serialization based on current mode
|
||||
* Helper function to check if a subblock should be serialized.
|
||||
*/
|
||||
function shouldIncludeField(subBlockConfig: SubBlockConfig, isAdvancedMode: boolean): boolean {
|
||||
const fieldMode = subBlockConfig.mode
|
||||
function shouldSerializeSubBlock(
|
||||
subBlockConfig: SubBlockConfig,
|
||||
values: Record<string, unknown>,
|
||||
displayAdvancedOptions: boolean,
|
||||
isTriggerContext: boolean,
|
||||
isTriggerCategory: boolean,
|
||||
canonicalIndex: ReturnType<typeof buildCanonicalIndex>
|
||||
): boolean {
|
||||
if (!isSubBlockFeatureEnabled(subBlockConfig)) return false
|
||||
|
||||
if (fieldMode === 'advanced' && !isAdvancedMode) {
|
||||
return false // Skip advanced-only fields when in basic mode
|
||||
if (subBlockConfig.mode === 'trigger') {
|
||||
if (!isTriggerContext && !isTriggerCategory) return false
|
||||
} else if (isTriggerContext && !isTriggerCategory) {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
const isCanonicalMember = Boolean(canonicalIndex.canonicalIdBySubBlockId[subBlockConfig.id])
|
||||
if (isCanonicalMember) {
|
||||
return evaluateSubBlockCondition(subBlockConfig.condition, values)
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluates a condition object against current field values.
|
||||
* Used to determine if a conditionally-visible field should be included in params.
|
||||
*/
|
||||
function evaluateCondition(
|
||||
condition:
|
||||
| {
|
||||
field: string
|
||||
value: any
|
||||
not?: boolean
|
||||
and?: { field: string; value: any; not?: boolean }
|
||||
}
|
||||
| (() => {
|
||||
field: string
|
||||
value: any
|
||||
not?: boolean
|
||||
and?: { field: string; value: any; not?: boolean }
|
||||
})
|
||||
| undefined,
|
||||
values: Record<string, any>
|
||||
): boolean {
|
||||
if (!condition) return true
|
||||
if (subBlockConfig.mode === 'advanced' && !displayAdvancedOptions) {
|
||||
return isNonEmptyValue(values[subBlockConfig.id])
|
||||
}
|
||||
if (subBlockConfig.mode === 'basic' && displayAdvancedOptions) {
|
||||
return false
|
||||
}
|
||||
|
||||
const actual = typeof condition === 'function' ? condition() : condition
|
||||
const fieldValue = values[actual.field]
|
||||
|
||||
const valueMatch = Array.isArray(actual.value)
|
||||
? fieldValue != null &&
|
||||
(actual.not ? !actual.value.includes(fieldValue) : actual.value.includes(fieldValue))
|
||||
: actual.not
|
||||
? fieldValue !== actual.value
|
||||
: fieldValue === actual.value
|
||||
|
||||
const andMatch = !actual.and
|
||||
? true
|
||||
: (() => {
|
||||
const andFieldValue = values[actual.and!.field]
|
||||
const andValueMatch = Array.isArray(actual.and!.value)
|
||||
? andFieldValue != null &&
|
||||
(actual.and!.not
|
||||
? !actual.and!.value.includes(andFieldValue)
|
||||
: actual.and!.value.includes(andFieldValue))
|
||||
: actual.and!.not
|
||||
? andFieldValue !== actual.and!.value
|
||||
: andFieldValue === actual.and!.value
|
||||
return andValueMatch
|
||||
})()
|
||||
|
||||
return valueMatch && andMatch
|
||||
return evaluateSubBlockCondition(subBlockConfig.condition, values)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -241,16 +219,12 @@ export class Serializer {
|
||||
// Extract parameters from UI state
|
||||
const params = this.extractParams(block)
|
||||
|
||||
try {
|
||||
const isTriggerCategory = blockConfig.category === 'triggers'
|
||||
if (block.triggerMode === true || isTriggerCategory) {
|
||||
params.triggerMode = true
|
||||
}
|
||||
if (block.advancedMode === true) {
|
||||
params.advancedMode = true
|
||||
}
|
||||
} catch (_) {
|
||||
// no-op: conservative, avoid blocking serialization if blockConfig is unexpected
|
||||
const isTriggerCategory = blockConfig.category === 'triggers'
|
||||
if (block.triggerMode === true || isTriggerCategory) {
|
||||
params.triggerMode = true
|
||||
}
|
||||
if (block.advancedMode === true) {
|
||||
params.advancedMode = true
|
||||
}
|
||||
|
||||
// Validate required fields that only users can provide (before execution starts)
|
||||
@@ -271,16 +245,7 @@ export class Serializer {
|
||||
// For non-custom tools, we determine the tool ID
|
||||
const nonCustomTools = tools.filter((tool: any) => tool.type !== 'custom-tool')
|
||||
if (nonCustomTools.length > 0) {
|
||||
try {
|
||||
toolId = blockConfig.tools.config?.tool
|
||||
? blockConfig.tools.config.tool(params)
|
||||
: blockConfig.tools.access[0]
|
||||
} catch (error) {
|
||||
logger.warn('Tool selection failed during serialization, using default:', {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
})
|
||||
toolId = blockConfig.tools.access[0]
|
||||
}
|
||||
toolId = this.selectToolId(blockConfig, params)
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error processing tools in agent block:', { error })
|
||||
@@ -289,16 +254,7 @@ export class Serializer {
|
||||
}
|
||||
} else {
|
||||
// For non-agent blocks, get tool ID from block config as usual
|
||||
try {
|
||||
toolId = blockConfig.tools.config?.tool
|
||||
? blockConfig.tools.config.tool(params)
|
||||
: blockConfig.tools.access[0]
|
||||
} catch (error) {
|
||||
logger.warn('Tool selection failed during serialization, using default:', {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
})
|
||||
toolId = blockConfig.tools.access[0]
|
||||
}
|
||||
toolId = this.selectToolId(blockConfig, params)
|
||||
}
|
||||
|
||||
// Get inputs from block config
|
||||
@@ -322,7 +278,10 @@ export class Serializer {
|
||||
// Include response format fields if available
|
||||
...(params.responseFormat
|
||||
? {
|
||||
responseFormat: this.parseResponseFormatSafely(params.responseFormat),
|
||||
responseFormat:
|
||||
parseResponseFormatSafely(params.responseFormat, block.id, {
|
||||
allowReferences: true,
|
||||
}) ?? undefined,
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
@@ -337,52 +296,9 @@ export class Serializer {
|
||||
}
|
||||
}
|
||||
|
||||
private parseResponseFormatSafely(responseFormat: any): any {
|
||||
if (!responseFormat) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
// If already an object, return as-is
|
||||
if (typeof responseFormat === 'object' && responseFormat !== null) {
|
||||
return responseFormat
|
||||
}
|
||||
|
||||
// Handle string values
|
||||
if (typeof responseFormat === 'string') {
|
||||
const trimmedValue = responseFormat.trim()
|
||||
|
||||
// Check for variable references like <start.input>
|
||||
if (trimmedValue.startsWith(REFERENCE.START) && trimmedValue.includes(REFERENCE.END)) {
|
||||
// Keep variable references as-is
|
||||
return trimmedValue
|
||||
}
|
||||
|
||||
if (trimmedValue === '') {
|
||||
return undefined
|
||||
}
|
||||
|
||||
// Try to parse as JSON
|
||||
try {
|
||||
return JSON.parse(trimmedValue)
|
||||
} catch (error) {
|
||||
// If parsing fails, return undefined to avoid crashes
|
||||
// This allows the workflow to continue without structured response format
|
||||
logger.warn('Failed to parse response format as JSON in serializer, using undefined:', {
|
||||
value: trimmedValue,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
})
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
// For any other type, return undefined
|
||||
return undefined
|
||||
}
|
||||
|
||||
private extractParams(block: BlockState): Record<string, any> {
|
||||
// Special handling for subflow blocks (loops, parallels, etc.)
|
||||
if (block.type === 'loop' || block.type === 'parallel') {
|
||||
return {} // Loop and parallel blocks don't have traditional params
|
||||
return {}
|
||||
}
|
||||
|
||||
const blockConfig = getBlock(block.type)
|
||||
@@ -391,43 +307,42 @@ export class Serializer {
|
||||
}
|
||||
|
||||
const params: Record<string, any> = {}
|
||||
const isAdvancedMode = block.advancedMode ?? false
|
||||
const legacyAdvancedMode = block.advancedMode ?? false
|
||||
const canonicalModeOverrides = block.data?.canonicalModes
|
||||
const isStarterBlock = block.type === 'starter'
|
||||
const isAgentBlock = block.type === 'agent'
|
||||
const isTriggerContext = block.triggerMode ?? false
|
||||
const isTriggerCategory = blockConfig.category === 'triggers'
|
||||
const canonicalIndex = buildCanonicalIndex(blockConfig.subBlocks)
|
||||
const allValues = buildSubBlockValues(block.subBlocks)
|
||||
|
||||
// First pass: collect ALL raw values for condition evaluation
|
||||
const allValues: Record<string, any> = {}
|
||||
Object.entries(block.subBlocks).forEach(([id, subBlock]) => {
|
||||
allValues[id] = subBlock.value
|
||||
})
|
||||
|
||||
// Second pass: filter by mode and conditions
|
||||
Object.entries(block.subBlocks).forEach(([id, subBlock]) => {
|
||||
const matchingConfigs = blockConfig.subBlocks.filter((config) => config.id === id)
|
||||
|
||||
// Include field if it matches current mode OR if it's the starter inputFormat with values
|
||||
const hasStarterInputFormatValues =
|
||||
isStarterBlock &&
|
||||
id === 'inputFormat' &&
|
||||
Array.isArray(subBlock.value) &&
|
||||
subBlock.value.length > 0
|
||||
|
||||
// Include legacy agent block fields (systemPrompt, userPrompt, memories) even if not in current config
|
||||
// This ensures backward compatibility with old workflows that were exported before the messages array migration
|
||||
const isLegacyAgentField =
|
||||
isAgentBlock && ['systemPrompt', 'userPrompt', 'memories'].includes(id)
|
||||
|
||||
const anyConditionMet =
|
||||
matchingConfigs.length === 0
|
||||
? true
|
||||
: matchingConfigs.some(
|
||||
(config) =>
|
||||
shouldIncludeField(config, isAdvancedMode) &&
|
||||
evaluateCondition(config.condition, allValues)
|
||||
)
|
||||
const shouldInclude =
|
||||
matchingConfigs.length === 0 ||
|
||||
matchingConfigs.some((config) =>
|
||||
shouldSerializeSubBlock(
|
||||
config,
|
||||
allValues,
|
||||
legacyAdvancedMode,
|
||||
isTriggerContext,
|
||||
isTriggerCategory,
|
||||
canonicalIndex
|
||||
)
|
||||
)
|
||||
|
||||
if (
|
||||
(matchingConfigs.length > 0 && anyConditionMet) ||
|
||||
(matchingConfigs.length > 0 && shouldInclude) ||
|
||||
hasStarterInputFormatValues ||
|
||||
isLegacyAgentField
|
||||
) {
|
||||
@@ -435,56 +350,38 @@ export class Serializer {
|
||||
}
|
||||
})
|
||||
|
||||
// Then check for any subBlocks with default values
|
||||
blockConfig.subBlocks.forEach((subBlockConfig) => {
|
||||
const id = subBlockConfig.id
|
||||
if (
|
||||
(params[id] === null || params[id] === undefined) &&
|
||||
params[id] == null &&
|
||||
subBlockConfig.value &&
|
||||
shouldIncludeField(subBlockConfig, isAdvancedMode)
|
||||
shouldSerializeSubBlock(
|
||||
subBlockConfig,
|
||||
allValues,
|
||||
legacyAdvancedMode,
|
||||
isTriggerContext,
|
||||
isTriggerCategory,
|
||||
canonicalIndex
|
||||
)
|
||||
) {
|
||||
// If the value is absent and there's a default value function, use it
|
||||
params[id] = subBlockConfig.value(params)
|
||||
}
|
||||
})
|
||||
|
||||
// Finally, consolidate canonical parameters (e.g., selector and manual ID into a single param)
|
||||
const canonicalGroups: Record<string, { basic?: string; advanced: string[] }> = {}
|
||||
blockConfig.subBlocks.forEach((sb) => {
|
||||
if (!sb.canonicalParamId) return
|
||||
const key = sb.canonicalParamId
|
||||
if (!canonicalGroups[key]) canonicalGroups[key] = { basic: undefined, advanced: [] }
|
||||
if (sb.mode === 'advanced') canonicalGroups[key].advanced.push(sb.id)
|
||||
else canonicalGroups[key].basic = sb.id
|
||||
})
|
||||
Object.values(canonicalIndex.groupsById).forEach((group) => {
|
||||
const { basicValue, advancedValue } = getCanonicalValues(group, params)
|
||||
const pairMode =
|
||||
canonicalModeOverrides?.[group.canonicalId] ?? (legacyAdvancedMode ? 'advanced' : 'basic')
|
||||
const chosen = pairMode === 'advanced' ? advancedValue : basicValue
|
||||
|
||||
Object.entries(canonicalGroups).forEach(([canonicalKey, group]) => {
|
||||
const basicId = group.basic
|
||||
const advancedIds = group.advanced
|
||||
const basicVal = basicId ? params[basicId] : undefined
|
||||
const advancedVal = advancedIds
|
||||
.map((id) => params[id])
|
||||
.find(
|
||||
(v) => v !== undefined && v !== null && (typeof v !== 'string' || v.trim().length > 0)
|
||||
)
|
||||
|
||||
let chosen: any
|
||||
if (advancedVal !== undefined && basicVal !== undefined) {
|
||||
chosen = isAdvancedMode ? advancedVal : basicVal
|
||||
} else if (advancedVal !== undefined) {
|
||||
chosen = advancedVal
|
||||
} else if (basicVal !== undefined) {
|
||||
chosen = isAdvancedMode ? undefined : basicVal
|
||||
} else {
|
||||
chosen = undefined
|
||||
}
|
||||
|
||||
const sourceIds = [basicId, ...advancedIds].filter(Boolean) as string[]
|
||||
const sourceIds = [group.basicId, ...group.advancedIds].filter(Boolean) as string[]
|
||||
sourceIds.forEach((id) => {
|
||||
if (id !== canonicalKey) delete params[id]
|
||||
if (id !== group.canonicalId) delete params[id]
|
||||
})
|
||||
if (chosen !== undefined) params[canonicalKey] = chosen
|
||||
else delete params[canonicalKey]
|
||||
|
||||
if (chosen !== undefined) {
|
||||
params[group.canonicalId] = chosen
|
||||
}
|
||||
})
|
||||
|
||||
return params
|
||||
@@ -520,17 +417,7 @@ export class Serializer {
|
||||
}
|
||||
|
||||
// Determine the current tool ID using the same logic as the serializer
|
||||
let currentToolId = ''
|
||||
try {
|
||||
currentToolId = blockConfig.tools.config?.tool
|
||||
? blockConfig.tools.config.tool(params)
|
||||
: blockConfig.tools.access[0]
|
||||
} catch (error) {
|
||||
logger.warn('Tool selection failed during validation, using default:', {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
})
|
||||
currentToolId = blockConfig.tools.access[0]
|
||||
}
|
||||
const currentToolId = this.selectToolId(blockConfig, params)
|
||||
|
||||
// Get the specific tool to validate against
|
||||
const currentTool = getTool(currentToolId)
|
||||
@@ -538,8 +425,11 @@ export class Serializer {
|
||||
return // Tool not found, skip validation
|
||||
}
|
||||
|
||||
// Check required user-only parameters for the current tool
|
||||
const missingFields: string[] = []
|
||||
const displayAdvancedOptions = block.advancedMode ?? false
|
||||
const isTriggerContext = block.triggerMode ?? false
|
||||
const isTriggerCategory = blockConfig.category === 'triggers'
|
||||
const canonicalIndex = buildCanonicalIndex(blockConfig.subBlocks || [])
|
||||
|
||||
// Iterate through the tool's parameters, not the block's subBlocks
|
||||
Object.entries(currentTool.params || {}).forEach(([paramId, paramConfig]) => {
|
||||
@@ -549,20 +439,23 @@ export class Serializer {
|
||||
let shouldValidateParam = true
|
||||
|
||||
if (matchingConfigs.length > 0) {
|
||||
const isAdvancedMode = block.advancedMode ?? false
|
||||
|
||||
shouldValidateParam = matchingConfigs.some((subBlockConfig: any) => {
|
||||
const includedByMode = shouldIncludeField(subBlockConfig, isAdvancedMode)
|
||||
|
||||
const includedByCondition = evaluateCondition(subBlockConfig.condition, params)
|
||||
const includedByMode = shouldSerializeSubBlock(
|
||||
subBlockConfig,
|
||||
params,
|
||||
displayAdvancedOptions,
|
||||
isTriggerContext,
|
||||
isTriggerCategory,
|
||||
canonicalIndex
|
||||
)
|
||||
|
||||
const isRequired = (() => {
|
||||
if (!subBlockConfig.required) return false
|
||||
if (typeof subBlockConfig.required === 'boolean') return subBlockConfig.required
|
||||
return evaluateCondition(subBlockConfig.required, params)
|
||||
return evaluateSubBlockCondition(subBlockConfig.required, params)
|
||||
})()
|
||||
|
||||
return includedByMode && includedByCondition && isRequired
|
||||
return includedByMode && isRequired
|
||||
})
|
||||
}
|
||||
|
||||
@@ -572,10 +465,15 @@ export class Serializer {
|
||||
|
||||
const fieldValue = params[paramId]
|
||||
if (fieldValue === undefined || fieldValue === null || fieldValue === '') {
|
||||
const activeConfig = matchingConfigs.find(
|
||||
(config: any) =>
|
||||
shouldIncludeField(config, block.advancedMode ?? false) &&
|
||||
evaluateCondition(config.condition, params)
|
||||
const activeConfig = matchingConfigs.find((config: any) =>
|
||||
shouldSerializeSubBlock(
|
||||
config,
|
||||
params,
|
||||
displayAdvancedOptions,
|
||||
isTriggerContext,
|
||||
isTriggerCategory,
|
||||
canonicalIndex
|
||||
)
|
||||
)
|
||||
const displayName = activeConfig?.title || paramId
|
||||
missingFields.push(displayName)
|
||||
@@ -629,6 +527,19 @@ export class Serializer {
|
||||
return accessibleMap
|
||||
}
|
||||
|
||||
private selectToolId(blockConfig: any, params: Record<string, any>): string {
|
||||
try {
|
||||
return blockConfig.tools.config?.tool
|
||||
? blockConfig.tools.config.tool(params)
|
||||
: blockConfig.tools.access[0]
|
||||
} catch (error) {
|
||||
logger.warn('Tool selection failed during serialization, using default:', {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
})
|
||||
return blockConfig.tools.access[0]
|
||||
}
|
||||
}
|
||||
|
||||
deserializeWorkflow(workflow: SerializedWorkflow): {
|
||||
blocks: Record<string, BlockState>
|
||||
edges: Edge[]
|
||||
|
||||
@@ -147,20 +147,19 @@ const { mockBlockConfigs, createMockGetBlock, slackWithCanonicalParam } = vi.hoi
|
||||
config: { tool: () => 'slack_send_message' },
|
||||
},
|
||||
subBlocks: [
|
||||
{ id: 'channel', type: 'dropdown', label: 'Channel', mode: 'basic' },
|
||||
{
|
||||
id: 'channel',
|
||||
type: 'dropdown',
|
||||
label: 'Channel',
|
||||
mode: 'basic',
|
||||
canonicalParamId: 'channel',
|
||||
},
|
||||
{
|
||||
id: 'manualChannel',
|
||||
type: 'short-input',
|
||||
label: 'Channel ID',
|
||||
mode: 'advanced',
|
||||
canonicalParamId: 'targetChannel',
|
||||
},
|
||||
{
|
||||
id: 'channelSelector',
|
||||
type: 'dropdown',
|
||||
label: 'Channel Selector',
|
||||
mode: 'basic',
|
||||
canonicalParamId: 'targetChannel',
|
||||
canonicalParamId: 'channel',
|
||||
},
|
||||
{ id: 'text', type: 'long-input', label: 'Message' },
|
||||
{ id: 'username', type: 'short-input', label: 'Username', mode: 'both' },
|
||||
@@ -656,16 +655,18 @@ describe('Serializer Extended Tests', () => {
|
||||
})
|
||||
|
||||
describe('canonical parameter handling', () => {
|
||||
it('should consolidate basic/advanced mode fields into canonical param in advanced mode', () => {
|
||||
it('should use advanced value when canonicalModes specifies advanced', () => {
|
||||
const serializer = new Serializer()
|
||||
const block: BlockState = {
|
||||
id: 'slack-1',
|
||||
type: 'slack',
|
||||
name: 'Slack',
|
||||
position: { x: 0, y: 0 },
|
||||
advancedMode: true,
|
||||
data: {
|
||||
canonicalModes: { channel: 'advanced' },
|
||||
},
|
||||
subBlocks: {
|
||||
channelSelector: { id: 'channelSelector', type: 'dropdown', value: 'general' },
|
||||
channel: { id: 'channel', type: 'channel-selector', value: 'general' },
|
||||
manualChannel: { id: 'manualChannel', type: 'short-input', value: 'C12345' },
|
||||
text: { id: 'text', type: 'long-input', value: 'Hello' },
|
||||
},
|
||||
@@ -676,22 +677,23 @@ describe('Serializer Extended Tests', () => {
|
||||
const serialized = serializer.serializeWorkflow({ 'slack-1': block }, [], {})
|
||||
const slackBlock = serialized.blocks.find((b) => b.id === 'slack-1')
|
||||
|
||||
expect(slackBlock?.config.params.targetChannel).toBe('C12345')
|
||||
expect(slackBlock?.config.params.channelSelector).toBeUndefined()
|
||||
expect(slackBlock?.config.params.channel).toBe('C12345')
|
||||
expect(slackBlock?.config.params.manualChannel).toBeUndefined()
|
||||
})
|
||||
|
||||
it('should consolidate to basic value when in basic mode', () => {
|
||||
it('should use basic value when canonicalModes specifies basic', () => {
|
||||
const serializer = new Serializer()
|
||||
const block: BlockState = {
|
||||
id: 'slack-1',
|
||||
type: 'slack',
|
||||
name: 'Slack',
|
||||
position: { x: 0, y: 0 },
|
||||
advancedMode: false,
|
||||
data: {
|
||||
canonicalModes: { channel: 'basic' },
|
||||
},
|
||||
subBlocks: {
|
||||
channelSelector: { id: 'channelSelector', type: 'dropdown', value: 'general' },
|
||||
manualChannel: { id: 'manualChannel', type: 'short-input', value: '' },
|
||||
channel: { id: 'channel', type: 'channel-selector', value: 'general' },
|
||||
manualChannel: { id: 'manualChannel', type: 'short-input', value: 'C12345' },
|
||||
text: { id: 'text', type: 'long-input', value: 'Hello' },
|
||||
},
|
||||
outputs: {},
|
||||
@@ -701,7 +703,7 @@ describe('Serializer Extended Tests', () => {
|
||||
const serialized = serializer.serializeWorkflow({ 'slack-1': block }, [], {})
|
||||
const slackBlock = serialized.blocks.find((b) => b.id === 'slack-1')
|
||||
|
||||
expect(slackBlock?.config.params.targetChannel).toBe('general')
|
||||
expect(slackBlock?.config.params.channel).toBe('general')
|
||||
})
|
||||
|
||||
it('should handle missing canonical param values', () => {
|
||||
@@ -711,9 +713,8 @@ describe('Serializer Extended Tests', () => {
|
||||
type: 'slack',
|
||||
name: 'Slack',
|
||||
position: { x: 0, y: 0 },
|
||||
advancedMode: false,
|
||||
subBlocks: {
|
||||
channelSelector: { id: 'channelSelector', type: 'dropdown', value: null },
|
||||
channel: { id: 'channel', type: 'channel-selector', value: null },
|
||||
manualChannel: { id: 'manualChannel', type: 'short-input', value: null },
|
||||
text: { id: 'text', type: 'long-input', value: 'Hello' },
|
||||
},
|
||||
@@ -724,8 +725,7 @@ describe('Serializer Extended Tests', () => {
|
||||
const serialized = serializer.serializeWorkflow({ 'slack-1': block }, [], {})
|
||||
const slackBlock = serialized.blocks.find((b) => b.id === 'slack-1')
|
||||
|
||||
// When both values are null, the canonical param is set to null (preserving the null value)
|
||||
expect(slackBlock?.config.params.targetChannel).toBeNull()
|
||||
expect(slackBlock?.config.params.channel).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user