diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/components/tools/sub-block-renderer.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/components/tools/sub-block-renderer.tsx index 20492c4127d..1c539a38ebd 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/components/tools/sub-block-renderer.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/components/tools/sub-block-renderer.tsx @@ -1,6 +1,7 @@ 'use client' import { useCallback, useEffect, useRef } from 'react' +import type { CanonicalModeOverrides } from '@/lib/workflows/subblocks/visibility' import { buildToolSubBlockId, resolveToolParamSync, @@ -21,6 +22,7 @@ interface ToolSubBlockRendererProps { /** The tool's block type (e.g. `gmail`), so its params' selectors resolve dependencies. */ toolType: string toolParams: Record | undefined + canonicalModeOverrides?: CanonicalModeOverrides onParamChange: (toolIndex: number, paramId: string, value: string) => void disabled: boolean canonicalToggle?: { @@ -59,6 +61,7 @@ export function ToolSubBlockRenderer({ effectiveParamId, toolType, toolParams, + canonicalModeOverrides, onParamChange, disabled, canonicalToggle, @@ -132,7 +135,7 @@ export function ToolSubBlockRenderer({ } return ( - + { + it.each([ + { + name: 'uses the nested tool modes instead of the host modes', + context: { blockType: 'table', canonicalModeOverrides: { tableId: 'advanced' as const } }, + host: { '0:tableId': 'basic' as const }, + expected: { tableId: 'advanced' }, + }, + { + name: 'keeps missing nested modes missing instead of inheriting host modes', + context: { blockType: 'table', canonicalModeOverrides: undefined }, + host: { '0:tableId': 'advanced' as const }, + expected: undefined, + }, + { + name: 'uses host modes outside a nested tool', + context: null, + host: { tableId: 'basic' as const }, + expected: { tableId: 'basic' }, + }, + ] as const)('$name', ({ context, host, expected }) => + expect(getDependencyCanonicalModeOverrides(context, host)).toEqual(expected) + ) +}) describe('isMcpToolAlreadySelected', () => { describe('basic functionality', () => { diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/tool-input.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/tool-input.tsx index 753e0818874..a2e8db08161 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/tool-input.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/tool-input.tsx @@ -105,6 +105,7 @@ import { type CanonicalIndex, type CanonicalModeOverrides, evaluateSubBlockCondition, + getCanonicalSubBlocksForSurface, isCanonicalPair, reindexToolCanonicalModes, resolveCanonicalMode, @@ -526,13 +527,14 @@ export const ToolInput = memo(function ToolInput({ for (const [toolIndex, tool] of selectedTools.entries()) { const blockConfig = allBlocks.find((b: { type: string }) => b.type === tool.type) if (!blockConfig?.subBlocks) continue - const toolCanonical = buildCanonicalIndex(blockConfig.subBlocks) + const actionSubBlocks = getCanonicalSubBlocksForSurface(blockConfig.subBlocks, false) + const toolCanonical = buildCanonicalIndex(actionSubBlocks) const scopedOverrides = scopeCanonicalModesForTool( canonicalModeOverrides, toolIndex, tool.type ) - const reactiveSubBlock = blockConfig.subBlocks.find( + const reactiveSubBlock = actionSubBlocks.find( (sb: { reactiveCondition?: unknown }) => sb.reactiveCondition ) const reactiveCond = reactiveSubBlock?.reactiveCondition as @@ -1744,14 +1746,17 @@ export const ToolInput = memo(function ToolInput({ ) : null - const toolCanonicalIndex: CanonicalIndex | null = toolBlock?.subBlocks - ? buildCanonicalIndex(toolBlock.subBlocks) + const toolActionSubBlocks = toolBlock?.subBlocks + ? getCanonicalSubBlocksForSurface(toolBlock.subBlocks, false) + : null + const toolCanonicalIndex: CanonicalIndex | null = toolActionSubBlocks + ? buildCanonicalIndex(toolActionSubBlocks) : null const toolContextValues = toolCanonicalIndex ? buildPreviewContextValues(tool.params || {}, { blockType: tool.type, - subBlocks: toolBlock!.subBlocks, + subBlocks: toolActionSubBlocks!, canonicalIndex: toolCanonicalIndex, values: { operation: tool.operation, ...tool.params }, overrides: toolScopedOverrides, @@ -2149,6 +2154,7 @@ export const ToolInput = memo(function ToolInput({ effectiveParamId={effectiveParamId} toolType={tool.type} toolParams={tool.params} + canonicalModeOverrides={toolScopedOverrides} onParamChange={handleParamChange} disabled={disabled} canonicalToggle={canonicalToggleProp} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-canonical-sub-block-value.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-canonical-sub-block-value.ts index 3cb5fc25bfc..fb0c3565fc3 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-canonical-sub-block-value.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-canonical-sub-block-value.ts @@ -1,7 +1,12 @@ import { useCallback, useMemo } from 'react' import { isEqual } from 'es-toolkit' import { useStoreWithEqualityFn } from 'zustand/traditional' -import { buildCanonicalIndex, resolveDependencyValue } from '@/lib/workflows/subblocks/visibility' +import { + buildCanonicalIndex, + getCanonicalSubBlocksForSurface, + isPureTriggerBlockConfig, + resolveDependencyValue, +} from '@/lib/workflows/subblocks/visibility' import { getBlock } from '@/blocks/registry' import { useWorkflowRegistry } from '@/stores/workflows/registry/store' import { useSubBlockStore } from '@/stores/workflows/subblock/store' @@ -22,10 +27,15 @@ export function useCanonicalSubBlockValue( const activeWorkflowId = useWorkflowRegistry((s) => s.activeWorkflowId) const blockState = useWorkflowStore((state) => state.blocks[blockId]) const blockConfig = blockState?.type ? getBlock(blockState.type) : null - const canonicalIndex = useMemo( - () => buildCanonicalIndex(blockConfig?.subBlocks || []), - [blockConfig?.subBlocks] - ) + const canonicalIndex = useMemo(() => { + const subBlocks = blockConfig?.subBlocks || [] + return buildCanonicalIndex( + getCanonicalSubBlocksForSurface( + subBlocks, + Boolean(blockState?.triggerMode) || isPureTriggerBlockConfig(blockConfig ?? undefined) + ) + ) + }, [blockConfig?.subBlocks, blockState?.triggerMode]) const canonicalModeOverrides = blockState?.data?.canonicalModes return useStoreWithEqualityFn( diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-dependency-block-type.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-dependency-block-type.ts index 05dac09c4db..93a7e85cea9 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-dependency-block-type.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-dependency-block-type.ts @@ -1,19 +1,24 @@ 'use client' import { createContext, useContext } from 'react' +import type { CanonicalModeOverrides } from '@/lib/workflows/subblocks/visibility' -const DependencyBlockTypeContext = createContext(null) +export interface DependencyBlockContextValue { + blockType: string + canonicalModeOverrides: CanonicalModeOverrides | undefined +} -/** - * Provider set by tool-input param rendering (value = the tool's block type, e.g. `gmail`). - */ +const DependencyBlockTypeContext = createContext(null) + +/** Provides a nested tool's block type and already-scoped canonical modes. */ export const DependencyBlockTypeProvider = DependencyBlockTypeContext.Provider -/** - * The block type whose config should drive dependency (`dependsOn`) canonical resolution - * for the current subblock. Null for normal blocks (resolve against the host block). Set - * to the tool's type for tool-input params, so a nested tool's selector resolves its - * parents against the TOOL's config (e.g. a Gmail tool's `credential` -> `oauthCredential`, - * which the host Agent block's subblocks don't define) and can fetch its options. - */ -export const useDependencyBlockType = () => useContext(DependencyBlockTypeContext) +export const useDependencyBlockContext = () => useContext(DependencyBlockTypeContext) + +export function getDependencyCanonicalModeOverrides( + context: DependencyBlockContextValue | null, + hostOverrides: CanonicalModeOverrides | undefined +): CanonicalModeOverrides | undefined { + // A nested tool with no scoped mode must use legacy inference, not another tool's host keys. + return context ? context.canonicalModeOverrides : hostOverrides +} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-depends-on-gate.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-depends-on-gate.ts index 54576b7c819..d3bbcfd79b1 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-depends-on-gate.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-depends-on-gate.ts @@ -5,7 +5,9 @@ import { isEqual } from 'es-toolkit' import { useStoreWithEqualityFn } from 'zustand/traditional' import { buildCanonicalIndex, + getCanonicalSubBlocksForSurface, isNonEmptyValue, + isPureTriggerBlockConfig, normalizeDependencyValue, parseDependsOn, resolveDependencyValue, @@ -15,7 +17,10 @@ import type { SubBlockConfig } from '@/blocks/types' import { useWorkflowRegistry } from '@/stores/workflows/registry/store' import { useSubBlockStore } from '@/stores/workflows/subblock/store' import { useWorkflowStore } from '@/stores/workflows/workflow/store' -import { useDependencyBlockType } from './use-dependency-block-type' +import { + getDependencyCanonicalModeOverrides, + useDependencyBlockContext, +} from './use-dependency-block-type' /** * Centralized dependsOn gating for sub-block components. @@ -35,17 +40,26 @@ export function useDependsOnGate( const activeWorkflowId = useWorkflowRegistry((s) => s.activeWorkflowId) const blockState = useWorkflowStore((state) => state.blocks[blockId]) - const dependencyBlockType = useDependencyBlockType() + const dependencyBlockContext = useDependencyBlockContext() + const dependencyBlockType = dependencyBlockContext?.blockType const blockConfig = dependencyBlockType ? getBlock(dependencyBlockType) : blockState?.type ? getBlock(blockState.type) : null - const canonicalIndex = useMemo( - () => buildCanonicalIndex(blockConfig?.subBlocks || []), - [blockConfig?.subBlocks] + const canonicalIndex = useMemo(() => { + const subBlocks = blockConfig?.subBlocks || [] + return buildCanonicalIndex( + getCanonicalSubBlocksForSurface( + subBlocks, + Boolean(blockState?.triggerMode) || isPureTriggerBlockConfig(blockConfig ?? undefined) + ) + ) + }, [blockConfig?.subBlocks, blockState?.triggerMode]) + const canonicalModeOverrides = getDependencyCanonicalModeOverrides( + dependencyBlockContext, + blockState?.data?.canonicalModes ) - const canonicalModeOverrides = blockState?.data?.canonicalModes // Parse dependsOn config to get all/any field lists const { allFields, anyFields, allDependsOnFields } = useMemo( diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-fetched-options.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-fetched-options.ts index b70f1eca44b..97b49e202f6 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-fetched-options.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-fetched-options.ts @@ -2,7 +2,12 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { getErrorMessage } from '@sim/utils/errors' import { isEqual } from 'es-toolkit' import { useStoreWithEqualityFn } from 'zustand/traditional' -import { buildCanonicalIndex, resolveDependencyValue } from '@/lib/workflows/subblocks/visibility' +import { + buildCanonicalIndex, + getCanonicalSubBlocksForSurface, + isPureTriggerBlockConfig, + resolveDependencyValue, +} from '@/lib/workflows/subblocks/visibility' import { getBlock } from '@/blocks/registry' import { useWorkflowRegistry } from '@/stores/workflows/registry/store' import { useSubBlockStore } from '@/stores/workflows/subblock/store' @@ -73,10 +78,15 @@ export function useFetchedOptions({ const blockState = useWorkflowStore((state) => state.blocks[blockId]) const blockConfig = blockState?.type ? getBlock(blockState.type) : null const canonicalModeOverrides = blockState?.data?.canonicalModes - const canonicalIndex = useMemo( - () => buildCanonicalIndex(blockConfig?.subBlocks || []), - [blockConfig?.subBlocks] - ) + const canonicalIndex = useMemo(() => { + const subBlocks = blockConfig?.subBlocks || [] + return buildCanonicalIndex( + getCanonicalSubBlocksForSurface( + subBlocks, + Boolean(blockState?.triggerMode) || isPureTriggerBlockConfig(blockConfig ?? undefined) + ) + ) + }, [blockConfig?.subBlocks, blockState?.triggerMode]) const dependencyValues = useStoreWithEqualityFn( useSubBlockStore, diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/editor.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/editor.tsx index 3beec443a18..e959193cc53 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/editor.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/editor.tsx @@ -23,11 +23,12 @@ import { isRetryEligibleBlock } from '@/lib/workflows/blocks/retry-eligibility' import { buildCanonicalIndex, evaluateSubBlockCondition, + getCanonicalSubBlocksForSurface, hasAdvancedValues, isCanonicalPair, + isPureTriggerBlockConfig, isStandaloneAdvancedMode, resolveCanonicalMode, - shouldUseSubBlockForTriggerModeCanonicalIndex, } from '@/lib/workflows/subblocks/visibility' import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider' import { @@ -159,9 +160,11 @@ export function Editor() { const subBlocksForCanonical = useMemo(() => { const subBlocks = blockConfig?.subBlocks || [] - if (!triggerMode) return subBlocks - return subBlocks.filter(shouldUseSubBlockForTriggerModeCanonicalIndex) - }, [blockConfig?.subBlocks, triggerMode]) + return getCanonicalSubBlocksForSurface( + subBlocks, + triggerMode || isPureTriggerBlockConfig(blockConfig ?? undefined) + ) + }, [blockConfig, triggerMode]) const canonicalIndex = useMemo( () => buildCanonicalIndex(subBlocksForCanonical), diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/hooks/use-editor-subblock-layout.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/hooks/use-editor-subblock-layout.ts index 6ca9b69470d..eb9d635d0af 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/hooks/use-editor-subblock-layout.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/hooks/use-editor-subblock-layout.ts @@ -2,12 +2,13 @@ import { useCallback, useMemo } from 'react' import { buildCanonicalIndex, evaluateSubBlockCondition, + getCanonicalSubBlocksForSurface, + isPureTriggerBlockConfig, isSubBlockFeatureEnabled, isSubBlockHidden, isSubBlockVisibleForMode, isSubBlockVisibleForTriggerMode, isToolInputOnlySubBlock, - shouldUseSubBlockForTriggerModeCanonicalIndex, } from '@/lib/workflows/subblocks/visibility' import type { BlockConfig, SubBlockConfig } from '@/blocks/types' import { usePermissionConfig } from '@/hooks/use-permission-config' @@ -43,9 +44,17 @@ export function useEditorSubblockLayout( ) const { config: permissionConfig } = usePermissionConfig() + const canonicalSubBlocks = useMemo(() => { + const subBlocks = config?.subBlocks || [] + return getCanonicalSubBlocksForSurface( + subBlocks, + displayTriggerMode || isPureTriggerBlockConfig(config) + ) + }, [config?.subBlocks, displayTriggerMode]) + // Evaluate reactive conditions (hooks-based, must be called before useMemo) const hiddenByReactiveCondition = useReactiveConditions( - config?.subBlocks || [], + canonicalSubBlocks, blockId, activeWorkflowId, blockDataFromStore?.canonicalModes @@ -102,10 +111,7 @@ export function useEditorSubblockLayout( {} ) - const subBlocksForCanonical = displayTriggerMode - ? (config.subBlocks || []).filter(shouldUseSubBlockForTriggerModeCanonicalIndex) - : config.subBlocks || [] - const canonicalIndex = buildCanonicalIndex(subBlocksForCanonical) + const canonicalIndex = buildCanonicalIndex(canonicalSubBlocks) const effectiveAdvanced = displayAdvancedMode const canonicalModeOverrides = blockData?.canonicalModes @@ -169,5 +175,6 @@ export function useEditorSubblockLayout( blockDataFromStore, hiddenByReactiveCondition, permissionConfig.disableSkills, + canonicalSubBlocks, ]) } diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/workflow-block.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/workflow-block.tsx index 406dd5f06a0..6cb97a66df8 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/workflow-block.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/workflow-block.tsx @@ -64,7 +64,9 @@ import { } from '@/lib/workflows/subblocks/display' import { buildCanonicalIndex, + getCanonicalSubBlocksForSurface, hasAdvancedValues, + isPureTriggerBlockConfig, resolveDependencyValue, } from '@/lib/workflows/subblocks/visibility' import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider' @@ -791,11 +793,22 @@ export const WorkflowBlock = memo(function WorkflowBlock({ ]) } - const canonicalIndex = useMemo(() => buildCanonicalIndex(config.subBlocks), [config.subBlocks]) + const canonicalSubBlocks = useMemo( + () => + getCanonicalSubBlocksForSurface( + config.subBlocks, + displayTriggerMode || isPureTriggerBlockConfig(config) + ), + [config.subBlocks, displayTriggerMode] + ) + const canonicalIndex = useMemo( + () => buildCanonicalIndex(canonicalSubBlocks), + [canonicalSubBlocks] + ) const canonicalModeOverrides = currentStoreBlock?.data?.canonicalModes const hiddenByReactiveCondition = useReactiveConditions( - config.subBlocks, + canonicalSubBlocks, id, activeWorkflowId, canonicalModeOverrides @@ -832,7 +845,7 @@ export const WorkflowBlock = memo(function WorkflowBlock({ const effectiveAdvanced = canEditWorkflow ? displayAdvancedMode - : displayAdvancedMode || hasAdvancedValues(config.subBlocks, rawValues, canonicalIndex) + : displayAdvancedMode || hasAdvancedValues(canonicalSubBlocks, rawValues, canonicalIndex) const effectiveTrigger = displayTriggerMode const canvasPresentation = resolveCanvasBlockPresentation(config, name, rawValues) @@ -919,8 +932,8 @@ export const WorkflowBlock = memo(function WorkflowBlock({ ) return canEditWorkflow ? displayAdvancedMode - : displayAdvancedMode || hasAdvancedValues(config.subBlocks, rawValues, canonicalIndex) - }, [subBlockState, displayAdvancedMode, config.subBlocks, canonicalIndex, canEditWorkflow]) + : displayAdvancedMode || hasAdvancedValues(canonicalSubBlocks, rawValues, canonicalIndex) + }, [subBlockState, displayAdvancedMode, canonicalSubBlocks, canonicalIndex, canEditWorkflow]) const shouldShowDefaultHandles = showsCanvasDefaultHandles(config, type, displayTriggerMode) diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-editor/preview-editor.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-editor/preview-editor.tsx index e7dd73f90ce..659a74988c9 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-editor/preview-editor.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-editor/preview-editor.tsx @@ -32,7 +32,9 @@ import { extractReferencePrefixes } from '@/lib/workflows/sanitization/reference import { buildCanonicalIndex, evaluateSubBlockCondition, + getCanonicalSubBlocksForSurface, hasAdvancedValues, + isPureTriggerBlockConfig, isSubBlockFeatureEnabled, isSubBlockVisibleForMode, isToolInputOnlySubBlock, @@ -1055,9 +1057,15 @@ function PreviewEditorContent({ }, {}) }, [subBlockValues]) + const isPureTriggerBlock = isPureTriggerBlockConfig(blockConfig) + const triggerCanonicalSurface = block.triggerMode === true || isPureTriggerBlock + const canonicalSubBlocks = useMemo( + () => getCanonicalSubBlocksForSurface(blockConfig?.subBlocks || [], triggerCanonicalSurface), + [blockConfig?.subBlocks, triggerCanonicalSurface] + ) const canonicalIndex = useMemo( - () => buildCanonicalIndex(blockConfig?.subBlocks || []), - [blockConfig?.subBlocks] + () => buildCanonicalIndex(canonicalSubBlocks), + [canonicalSubBlocks] ) const isSubflow = block.type === 'loop' || block.type === 'parallel' @@ -1115,9 +1123,8 @@ function PreviewEditorContent({ const canonicalModeOverrides = block.data?.canonicalModes const effectiveAdvanced = (block.advancedMode ?? false) || - hasAdvancedValues(blockConfig.subBlocks, rawValues, canonicalIndex) + hasAdvancedValues(canonicalSubBlocks, rawValues, canonicalIndex) - const isPureTriggerBlock = blockConfig.triggers?.enabled && blockConfig.category === 'triggers' const effectiveTrigger = block.triggerMode === true const visibleSubBlocks = blockConfig.subBlocks.filter((subBlock) => { diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-workflow/components/block/block.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-workflow/components/block/block.tsx index a83ce1bba88..bec60f9314d 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-workflow/components/block/block.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-workflow/components/block/block.tsx @@ -29,7 +29,10 @@ import { } from '@/lib/workflows/subblocks/display' import { buildCanonicalIndex, + type CanonicalModeOverrides, evaluateSubBlockCondition, + getCanonicalSubBlocksForSurface, + isPureTriggerBlockConfig, isSubBlockFeatureEnabled, isSubBlockVisibleForMode, isToolInputOnlySubBlock, @@ -84,6 +87,8 @@ interface WorkflowPreviewBlockData { executionStatus?: ExecutionStatus /** Subblock values from the workflow state */ subBlockValues?: Record + /** Persisted Basic/Advanced selections for canonical subblock pairs. */ + canonicalModes?: CanonicalModeOverrides /** * Whether the block routes its failures to a second output. The port is the * rendered half of that choice, so it only exists when the choice was made — @@ -231,6 +236,7 @@ function WorkflowPreviewBlockInner({ data }: NodeProps isPreviewSelected = false, executionStatus, subBlockValues, + canonicalModes, errorEnabled = false, hasErrorConnection = false, lightweight = false, @@ -238,9 +244,15 @@ function WorkflowPreviewBlockInner({ data }: NodeProps const blockConfig = getBlock(type) + const isPureTriggerBlock = isPureTriggerBlockConfig(blockConfig) + const triggerCanonicalSurface = isTrigger || type === 'starter' || isPureTriggerBlock + const canonicalSubBlocks = useMemo( + () => getCanonicalSubBlocksForSurface(blockConfig?.subBlocks || [], triggerCanonicalSurface), + [blockConfig?.subBlocks, triggerCanonicalSurface] + ) const canonicalIndex = useMemo( - () => buildCanonicalIndex(blockConfig?.subBlocks || []), - [blockConfig?.subBlocks] + () => buildCanonicalIndex(canonicalSubBlocks), + [canonicalSubBlocks] ) const rawValues = useMemo(() => { @@ -266,7 +278,6 @@ function WorkflowPreviewBlockInner({ data }: NodeProps const displayableSubBlocks = useMemo(() => { if (!blockConfig?.subBlocks) return [] - const isPureTriggerBlock = blockConfig.triggers?.enabled && blockConfig.category === 'triggers' const effectiveTrigger = isTrigger || type === 'starter' return blockConfig.subBlocks.filter((subBlock) => { @@ -289,7 +300,7 @@ function WorkflowPreviewBlockInner({ data }: NodeProps /** Skip value-dependent visibility checks in lightweight mode */ if (lightweight) return !subBlock.condition - if (!isSubBlockVisibleForMode(subBlock, false, canonicalIndex, rawValues, undefined)) { + if (!isSubBlockVisibleForMode(subBlock, false, canonicalIndex, rawValues, canonicalModes)) { return false } if (subBlock.condition && !evaluateSubBlockCondition(subBlock.condition, rawValues)) { @@ -311,6 +322,7 @@ function WorkflowPreviewBlockInner({ data }: NodeProps type, isTrigger, canonicalIndex, + canonicalModes, rawValues, canvasPresentation, ]) @@ -702,7 +714,8 @@ function shouldSkipPreviewBlockRender( prevProps.data.executionStatus !== nextProps.data.executionStatus || prevProps.data.errorEnabled !== nextProps.data.errorEnabled || prevProps.data.hasErrorConnection !== nextProps.data.hasErrorConnection || - prevProps.data.lightweight !== nextProps.data.lightweight + prevProps.data.lightweight !== nextProps.data.lightweight || + !areCanonicalModesEqual(prevProps.data.canonicalModes, nextProps.data.canonicalModes) ) { return false } @@ -728,6 +741,20 @@ function shouldSkipPreviewBlockRender( return true } +function areCanonicalModesEqual( + previous: CanonicalModeOverrides | undefined, + next: CanonicalModeOverrides | undefined +): boolean { + if (previous === next) return true + if (!previous || !next) return false + + const previousKeys = Object.keys(previous) + const nextKeys = Object.keys(next) + if (previousKeys.length !== nextKeys.length) return false + + return previousKeys.every((key) => previous[key] === next[key]) +} + /** * Preview block component for workflow visualization in readonly contexts. * Optimized for rendering without hooks or store subscriptions. diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-workflow/preview-workflow.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-workflow/preview-workflow.tsx index e73be1a64be..ee1ab6b94a5 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-workflow/preview-workflow.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-workflow/preview-workflow.tsx @@ -484,6 +484,7 @@ export function PreviewWorkflow({ isPreviewSelected: isSelected, executionStatus, subBlockValues: block.subBlocks, + canonicalModes: block.data?.canonicalModes, errorEnabled: block.errorEnabled === true, hasErrorConnection: blocksWithErrorEdge.has(blockId), lightweight, diff --git a/apps/sim/ee/workspace-forking/lib/copy/copy-workflows.test.ts b/apps/sim/ee/workspace-forking/lib/copy/copy-workflows.test.ts index abbe788a5b4..84331db7a82 100644 --- a/apps/sim/ee/workspace-forking/lib/copy/copy-workflows.test.ts +++ b/apps/sim/ee/workspace-forking/lib/copy/copy-workflows.test.ts @@ -358,6 +358,7 @@ describe('copyWorkflowStateIntoTarget canonicalModes reindex propagation', () => async () => { mockSaveWorkflowToNormalizedTables.mockResolvedValue({ success: true }) const seenCanonicalModes: Array | undefined> = [] + const seenTriggerModes: Array = [] const tx = { insert: () => ({ values: () => Promise.resolve() }), } as unknown as DbOrTx @@ -373,8 +374,9 @@ describe('copyWorkflowStateIntoTarget canonicalModes reindex propagation', () => blocks: { block1: { id: 'block1', - type: 'agent', - name: 'Agent', + type: 'mixed_trigger_test', + name: 'Mixed Trigger', + triggerMode: true, subBlocks: {}, // The source's ORIGINAL (pre-drop) canonicalModes - every step after the // transform must see the REINDEXED value below instead, not this one. @@ -393,8 +395,15 @@ describe('copyWorkflowStateIntoTarget canonicalModes reindex propagation', () => // Simulates a `tool-input` drop shifting tool 1 -> 0: returns subBlocks unchanged but // reports the reindexed canonicalModes via the callback, exactly like // `createForkBootstrapTransform`/`createForkSubBlockTransform` do. - transformSubBlocks: (subBlocks, _blockType, canonicalModes, onCanonicalModesChanged) => { + transformSubBlocks: ( + subBlocks, + _blockType, + canonicalModes, + onCanonicalModesChanged, + triggerMode + ) => { seenCanonicalModes.push(canonicalModes) + seenTriggerModes.push(triggerMode) onCanonicalModesChanged?.({ '0:credential': 'advanced' }) return subBlocks }, @@ -406,6 +415,7 @@ describe('copyWorkflowStateIntoTarget canonicalModes reindex propagation', () => } // The transform received the source's original value... expect(seenCanonicalModes).toEqual([{ '1:credential': 'advanced' }]) + expect(seenTriggerModes).toEqual([true]) // ...and the PERSISTED block carries the reindexed one, not the stale source value. expect(persistedBlock.data?.canonicalModes).toEqual({ '0:credential': 'advanced' }) } diff --git a/apps/sim/ee/workspace-forking/lib/copy/copy-workflows.ts b/apps/sim/ee/workspace-forking/lib/copy/copy-workflows.ts index 3d5cbb2bf71..7a0b605c75a 100644 --- a/apps/sim/ee/workspace-forking/lib/copy/copy-workflows.ts +++ b/apps/sim/ee/workspace-forking/lib/copy/copy-workflows.ts @@ -483,10 +483,16 @@ export async function copyWorkflowStateIntoTarget( block.data as { canonicalModes?: Record } | undefined )?.canonicalModes if (transformSubBlocks) { - subBlocks = transformSubBlocks(subBlocks, block.type, activeCanonicalModes, (next) => { - activeCanonicalModes = next - updatedData = { ...updatedData, canonicalModes: next } as BlockData - }) + subBlocks = transformSubBlocks( + subBlocks, + block.type, + activeCanonicalModes, + (next) => { + activeCanonicalModes = next + updatedData = { ...updatedData, canonicalModes: next } as BlockData + }, + block.triggerMode + ) } if (varIdMapping.size > 0) { subBlocks = remapVariableIdsInSubBlocks(subBlocks, varIdMapping) @@ -527,7 +533,8 @@ export async function copyWorkflowStateIntoTarget( block.name, targetCurrent, subBlocks, - activeCanonicalModes + activeCanonicalModes, + block.triggerMode ) ) } diff --git a/apps/sim/ee/workspace-forking/lib/mapping/dependent-reconfigs.test.ts b/apps/sim/ee/workspace-forking/lib/mapping/dependent-reconfigs.test.ts index 50accd71868..4302eb40331 100644 --- a/apps/sim/ee/workspace-forking/lib/mapping/dependent-reconfigs.test.ts +++ b/apps/sim/ee/workspace-forking/lib/mapping/dependent-reconfigs.test.ts @@ -18,12 +18,21 @@ import type { WorkflowState } from '@/stores/workflows/workflow/types' const blockWith = (subBlocks: SubBlockConfig[]): BlockConfig => ({ name: 'Test', description: '', subBlocks, outputs: {} }) as unknown as BlockConfig +const subBlock = ( + id: string, + type: SubBlockConfig['type'], + config: Partial = {} +): SubBlockConfig => ({ id, title: id, type, ...config }) as SubBlockConfig + const sourceState = ( blockType: string, - subBlocks: Record + subBlocks: Record, + overrides: Partial = {} ): WorkflowState => ({ - blocks: { 'block-1': { id: 'block-1', type: blockType, name: 'Block', subBlocks } }, + blocks: { + 'block-1': { id: 'block-1', type: blockType, name: 'Block', subBlocks, ...overrides }, + }, edges: [], loops: {}, parallels: {}, @@ -155,6 +164,62 @@ describe('collectForkDependentReconfigs', () => { expect(result).toEqual([]) }) + it('uses only the trigger surface and its active canonical context', () => { + vi.mocked(getBlock).mockReturnValue( + blockWith([ + subBlock('credential', 'oauth-input', { mode: 'basic' }), + subBlock('actionFolder', 'folder-selector', { + dependsOn: ['credential'], + selectorKey: 'google.drive', + }), + subBlock('triggerCredentials', 'oauth-input', { mode: 'trigger' }), + subBlock('triggerFolder', 'folder-selector', { + dependsOn: ['triggerCredentials'], + selectorKey: 'google.drive', + mode: 'trigger', + }), + subBlock('triggerSpreadsheetSelector', 'file-selector', { + canonicalParamId: 'spreadsheetId', + mode: 'trigger', + }), + subBlock('triggerManualSpreadsheetId', 'short-input', { + canonicalParamId: 'spreadsheetId', + mode: 'trigger-advanced', + }), + ]) + ) + const state = sourceState( + 'google_drive', + { + credential: { value: 'stale-action-credential' }, + actionFolder: { value: 'stale-action-folder' }, + triggerCredentials: { value: 'trigger-credential' }, + triggerFolder: { value: 'trigger-folder' }, + triggerSpreadsheetSelector: { value: 'stale-basic-spreadsheet' }, + triggerManualSpreadsheetId: { value: 'active-advanced-spreadsheet' }, + }, + { + name: 'Drive Trigger', + triggerMode: true, + data: { canonicalModes: { spreadsheetId: 'advanced' } }, + } + ) + + const result = collectForkDependentReconfigs( + [replaceItem], + new Map([['wf-src', state]]), + resolve + ) + + expect(result).toHaveLength(1) + expect(result[0]).toMatchObject({ + parentSourceId: 'trigger-credential', + subBlockKey: 'triggerFolder', + currentValue: 'trigger-folder', + context: { spreadsheetId: 'active-advanced-spreadsheet' }, + }) + }) + it('emits a knowledge-base-dependent document selector', () => { vi.mocked(getBlock).mockReturnValue( blockWith([ diff --git a/apps/sim/ee/workspace-forking/lib/mapping/dependent-reconfigs.ts b/apps/sim/ee/workspace-forking/lib/mapping/dependent-reconfigs.ts index f5684cd9b26..cabb312dfeb 100644 --- a/apps/sim/ee/workspace-forking/lib/mapping/dependent-reconfigs.ts +++ b/apps/sim/ee/workspace-forking/lib/mapping/dependent-reconfigs.ts @@ -10,7 +10,9 @@ import { buildSubBlockValues, type CanonicalModeOverrides, evaluateSubBlockCondition, + getCanonicalSubBlocksForSurface, isNonEmptyValue, + isPureTriggerBlockConfig, scopeCanonicalModesForTool, } from '@/lib/workflows/subblocks/visibility' import { getBlock } from '@/blocks/registry' @@ -69,6 +71,8 @@ interface EmitAnchoredParams { targetWorkflowId: string /** Canonical-mode overrides for resolving the active parent member (undefined -> value heuristic). */ canonicalModes?: CanonicalModeOverrides + /** Restrict a mixed action/trigger block to its trigger subblocks. */ + triggerMode?: boolean /** Memoized so the deterministic target block id is derived at most once per block. */ resolveTargetBlockId: () => string /** Map a dependent's config id to its wire `subBlockKey` (identity, or nested `tools[i].id`). */ @@ -101,6 +105,7 @@ function emitAnchoredDependents(params: EmitAnchoredParams): void { blockName, targetWorkflowId, canonicalModes, + triggerMode, resolveTargetBlockId, makeSubBlockKey, makeTitle, @@ -108,15 +113,20 @@ function emitAnchoredDependents(params: EmitAnchoredParams): void { chaining, out, } = params - const fullContext = buildSelectorContextFromBlock(contextBlockType, contextSubBlocks) - const canonicalIndex = buildCanonicalIndex(config.subBlocks) - const gates = createCanonicalModeGates(config.subBlocks, values, canonicalModes) - const configById = new Map(config.subBlocks.filter((cfg) => cfg.id).map((cfg) => [cfg.id, cfg])) + const triggerSurface = triggerMode === true || isPureTriggerBlockConfig(config) + const activeSubBlocks = getCanonicalSubBlocksForSurface(config.subBlocks, triggerSurface) + const fullContext = buildSelectorContextFromBlock(contextBlockType, contextSubBlocks, { + canonicalModes, + triggerMode: triggerSurface, + }) + const canonicalIndex = buildCanonicalIndex(activeSubBlocks) + const gates = createCanonicalModeGates(activeSubBlocks, values, canonicalModes) + const configById = new Map(activeSubBlocks.filter((cfg) => cfg.id).map((cfg) => [cfg.id, cfg])) // A field could hang off two anchors (or be reachable via two paths); emit it once. const seen = new Set() for (const anchor of PARENT_ANCHORS) { - for (const anchorCfg of config.subBlocks) { + for (const anchorCfg of activeSubBlocks) { if (anchorCfg.type !== anchor.subBlockType || !anchorCfg.id) continue // An anchor whose canonical pair is in ADVANCED (manual) mode is skipped entirely: the // active value is the user-owned manual member's, which is verbatim by policy - a sync @@ -147,7 +157,7 @@ function emitAnchoredDependents(params: EmitAnchoredParams): void { if (typeof value === 'string' && value) context[key] = value } - for (const clear of getWorkflowSearchDependentClears(config.subBlocks, anchorCfg.id)) { + for (const clear of getWorkflowSearchDependentClears(activeSubBlocks, anchorCfg.id)) { const dependent = configById.get(clear.subBlockId) if (!dependent?.id || !dependent.selectorKey) continue // Skip fields gated off by their `condition` - a selector under a now-inactive @@ -277,6 +287,7 @@ export function collectForkDependentReconfigs( blockName: block.name, targetWorkflowId: item.targetWorkflowId, canonicalModes: block.data?.canonicalModes, + triggerMode: block.triggerMode === true, resolveTargetBlockId: resolveBlockId, makeSubBlockKey: (id) => id, makeTitle: (dependent) => dependent.title ?? dependent.id ?? '', diff --git a/apps/sim/ee/workspace-forking/lib/promote/cleared-refs.ts b/apps/sim/ee/workspace-forking/lib/promote/cleared-refs.ts index 540c2e10e84..57fbc5b10ae 100644 --- a/apps/sim/ee/workspace-forking/lib/promote/cleared-refs.ts +++ b/apps/sim/ee/workspace-forking/lib/promote/cleared-refs.ts @@ -28,7 +28,7 @@ import { } from '@/ee/workspace-forking/lib/promote/sync-blockers' import type { ForkBlockIdResolver } from '@/ee/workspace-forking/lib/remap/block-identity' import { - createCanonicalModeGates, + createBlockCanonicalModeGates, type ForkReference, type ForkReferenceResolver, type ForkRemapKind, @@ -88,24 +88,27 @@ function baseSubBlockId(key: string): string { function collectForkWorkflowReferences( subBlocks: SubBlockRecord, config: ReturnType, - canonicalModes: CanonicalModeOverrides | undefined + canonicalModes: CanonicalModeOverrides | undefined, + triggerMode?: boolean ): Array<{ workflowId: string; subBlockKey: string }> { const out: Array<{ workflowId: string; subBlockKey: string }> = [] // Collapse each canonical pair to its ACTIVE member and skip condition-hidden fields: only a // value that serializes is a ref that a sync would clear (the advanced // `manualWorkflowId`/`manualWorkflowIds` are user-owned and preserved verbatim, an inactive // operation's selector never executes) - neither may become an unresolvable sync blocker. - // Shares {@link createCanonicalModeGates} with the reference scan, so the scalar `workflowId` + // Shares {@link createBlockCanonicalModeGates} with the reference scan, so the scalar + // `workflowId` // pair, the deployments block's scalar `workflowSelector` pair, and the logs block's // multi-select `workflowSelector` (`workflowIds` group) all resolve through their OWN group. A // missing config or a non-pair member is never skipped (no-pair states keep emitting). - const gates = createCanonicalModeGates( - config?.subBlocks, + const gates = createBlockCanonicalModeGates( + config, buildSubBlockValues(subBlocks), - canonicalModes + canonicalModes, + triggerMode ) const detectionSkipped = (key: string) => - gates.isDormantMember(key) || gates.isConditionHidden(key) + gates.isInactiveSurfaceMember(key) || gates.isDormantMember(key) || gates.isConditionHidden(key) for (const [key, subBlock] of Object.entries(subBlocks)) { if (!subBlock || typeof subBlock !== 'object') continue const baseKey = baseSubBlockId(key) @@ -198,6 +201,7 @@ export function collectForkClearedRefCandidates( blockName: blockLabel, blockType: block.type, canonicalModes: block.data?.canonicalModes, + triggerMode: block.triggerMode, }) for (const ref of scan.unmapped) { if (CLEARED_REF_EXCLUDED_KINDS.has(ref.kind)) continue @@ -219,7 +223,8 @@ export function collectForkClearedRefCandidates( for (const wfRef of collectForkWorkflowReferences( subBlocks, config, - block.data?.canonicalModes + block.data?.canonicalModes, + block.triggerMode )) { if (workflowIdMap.has(wfRef.workflowId)) continue out.push({ @@ -371,7 +376,8 @@ function hasForkSyncBlockerCandidates( const workflowRefs = collectForkWorkflowReferences( subBlocks, getBlock(block.type), - block.data?.canonicalModes + block.data?.canonicalModes, + block.triggerMode ) if (workflowRefs.some((ref) => !workflowIdMap.has(ref.workflowId))) return true } diff --git a/apps/sim/ee/workspace-forking/lib/remap/fork-bootstrap.ts b/apps/sim/ee/workspace-forking/lib/remap/fork-bootstrap.ts index 51860636a71..550cf2a2046 100644 --- a/apps/sim/ee/workspace-forking/lib/remap/fork-bootstrap.ts +++ b/apps/sim/ee/workspace-forking/lib/remap/fork-bootstrap.ts @@ -21,7 +21,7 @@ export type ForkCopyResolver = (kind: ForkRemapKind, sourceId: string) => string * the child defines the key). */ export function createForkBootstrapTransform(resolveCopied: ForkCopyResolver): SubBlockTransform { - return (subBlocks, blockType, canonicalModes, onCanonicalModesChanged) => { + return (subBlocks, blockType, canonicalModes, onCanonicalModesChanged, triggerMode) => { // Every resolution at fork-create IS a copy (the resolver is the copy id map), so all // remapped keys carry copy provenance - copy-faithful dependents (column picks) survive. // `blockType`/`canonicalModes` activate the mode policy: active basic remaps, active @@ -29,6 +29,7 @@ export function createForkBootstrapTransform(resolveCopied: ForkCopyResolver): S const result = remapForkSubBlocks(subBlocks, resolveCopied, 'create', { blockType, canonicalModes, + triggerMode, isCopiedTarget: (kind, sourceId) => resolveCopied(kind, sourceId) != null, }) if (result.canonicalModes) onCanonicalModesChanged?.(result.canonicalModes) @@ -37,7 +38,8 @@ export function createForkBootstrapTransform(resolveCopied: ForkCopyResolver): S blockType, result.remappedKeys, result.canonicalModes ?? canonicalModes, - result.copyRemappedKeys + result.copyRemappedKeys, + triggerMode ) } } diff --git a/apps/sim/ee/workspace-forking/lib/remap/reference-scan.ts b/apps/sim/ee/workspace-forking/lib/remap/reference-scan.ts index cf0e9190cbb..80c493319b8 100644 --- a/apps/sim/ee/workspace-forking/lib/remap/reference-scan.ts +++ b/apps/sim/ee/workspace-forking/lib/remap/reference-scan.ts @@ -12,6 +12,7 @@ interface ScannerBlock { type: string subBlocks: unknown canonicalModes?: CanonicalModeOverrides + triggerMode?: boolean } /** @@ -45,6 +46,7 @@ export function toScannerBlocks(state: WorkflowState): ScannerBlock[] { type: block.type, subBlocks: block.subBlocks as unknown, canonicalModes: block.data?.canonicalModes, + triggerMode: block.triggerMode, })) } diff --git a/apps/sim/ee/workspace-forking/lib/remap/remap-references.test.ts b/apps/sim/ee/workspace-forking/lib/remap/remap-references.test.ts index 26bd1bc0d55..9fa4cf5ccd7 100644 --- a/apps/sim/ee/workspace-forking/lib/remap/remap-references.test.ts +++ b/apps/sim/ee/workspace-forking/lib/remap/remap-references.test.ts @@ -45,6 +45,48 @@ const blockConfigs: Record = { }, } +const mixedToolCanonicalSubBlocks: SubBlockConfig[] = [ + { + id: 'credential', + title: 'Credential', + type: 'oauth-input', + canonicalParamId: 'oauthCredential', + mode: 'basic', + }, + { + id: 'triggerCredentials', + title: 'Trigger Credential', + type: 'oauth-input', + canonicalParamId: 'oauthCredential', + mode: 'trigger', + }, +] + +const mixedSurfaceCredentialConfigs = { + action: [ + { + id: 'credential', + title: 'Action Credential', + type: 'oauth-input', + canonicalParamId: 'oauthCredential', + mode: 'basic', + }, + { + id: 'manualCredential', + title: 'Action Credential ID', + type: 'short-input', + canonicalParamId: 'oauthCredential', + mode: 'advanced', + }, + ] as SubBlockConfig[], + trigger: { + id: 'triggerCredentials', + title: 'Trigger Credential', + type: 'oauth-input', + mode: 'trigger', + } as SubBlockConfig, +} + describe('remapToolBlockResources', () => { it('remaps nested credential + knowledge-base ids and leaves external selectors', () => { const tool = { @@ -162,6 +204,29 @@ describe('remapToolBlockResources', () => { expect((result.params as Record).credential).toBe('cred-dst') }) + it('does not remap a trigger-only credential stored on an Agent tool', () => { + const tool = { + type: 'mixedblock', + toolId: 'mixedblock_run', + params: { credential: '', triggerCredentials: 'cred-trigger' }, + } + const recorded: Array<{ kind: string; id: string }> = [] + const result = remapToolBlockResources(tool, { + resolve: (kind, id) => (kind === 'credential' ? `${id}-mapped` : null), + resolveFileKey: () => null, + record: (kind, id) => recorded.push({ kind, id }), + clearUnresolved: false, + parentCanonicalModes: { '0:oauthCredential': 'basic' }, + toolIndex: 0, + blockConfigs: { + mixedblock: { subBlocks: mixedToolCanonicalSubBlocks }, + }, + }) + + expect(result).toBe(tool) + expect(recorded).toEqual([]) + }) + it('clears a dependent tool param when its parent resource is remapped', () => { const tool = { type: 'depblock', @@ -833,9 +898,128 @@ describe('scanWorkflowReferences canonical-pair detection', () => { // The advanced escape-hatch id is preserved verbatim (not auto-remapped). expect(result.subBlocks.manualCredential.value).toBe('cred-active') }) + + it('remaps the live trigger credential despite an action-only Advanced mode', () => { + vi.mocked(getBlock).mockReturnValue( + blockWith([ + ...mixedSurfaceCredentialConfigs.action, + { ...mixedSurfaceCredentialConfigs.trigger, canonicalParamId: 'oauthCredential' }, + ]) + ) + const subBlocks: SubBlockRecord = { + credential: { type: 'oauth-input', value: 'action-basic' }, + manualCredential: { type: 'short-input', value: 'action-advanced' }, + triggerCredentials: { type: 'oauth-input', value: 'trigger-live' }, + } + const canonicalModes = { oauthCredential: 'advanced' as const } + + const remapped = remapForkSubBlocks( + subBlocks, + (kind, id) => (kind === 'credential' && id === 'trigger-live' ? 'trigger-target' : null), + 'promote', + { blockType: 'google_calendar', canonicalModes, triggerMode: true } + ) + const scan = scanWorkflowReferences( + [ + { + id: 'b1', + name: 'Calendar Trigger', + type: 'google_calendar', + triggerMode: true, + canonicalModes, + subBlocks, + }, + ], + () => null + ) + + expect(remapped.subBlocks).toMatchObject({ + credential: { value: '' }, + manualCredential: { value: '' }, + triggerCredentials: { value: 'trigger-target' }, + }) + expect(remapped.references.map((ref) => ref.sourceId)).toEqual(['trigger-live']) + expect(scan.references.map((ref) => ref.sourceId)).toEqual(['trigger-live']) + }) + + it.each([ + ['action', false, []], + ['trigger', true, ['hidden-trigger-credential']], + ] as const)( + '%s surface handles Airtable trigger credentials correctly', + (_, triggerMode, refs) => { + vi.mocked(getBlock).mockReturnValue( + blockWith([...mixedSurfaceCredentialConfigs.action, mixedSurfaceCredentialConfigs.trigger]) + ) + const subBlocks: SubBlockRecord = { + triggerCredentials: { type: 'oauth-input', value: 'hidden-trigger-credential' }, + } + + const remapped = remapForkSubBlocks(subBlocks, () => null, 'promote', { + blockType: 'airtable', + triggerMode, + }) + const scan = scanWorkflowReferences( + [ + { + id: 'b1', + name: 'Airtable Action', + type: 'airtable', + triggerMode, + subBlocks, + }, + ], + () => null + ) + + expect(remapped.subBlocks.triggerCredentials.value).toBe('') + expect(remapped.references.map((ref) => ref.sourceId)).toEqual(refs) + expect(remapped.unmapped.map((ref) => ref.sourceId)).toEqual(refs) + expect(scan.references.map((ref) => ref.sourceId)).toEqual(refs) + } + ) }) describe('collectClearedDependents', () => { + it.each([ + ['action', false, []], + ['trigger', true, ['labelIds']], + ] as const)('%s surface reports only live Gmail trigger fields', (_, triggerMode, expected) => { + vi.mocked(getBlock).mockReturnValue( + blockWith([ + ...mixedSurfaceCredentialConfigs.action, + mixedSurfaceCredentialConfigs.trigger, + { + id: 'labelIds', + title: 'Gmail Labels to Monitor', + type: 'dropdown', + mode: 'trigger', + dependsOn: ['triggerCredentials'], + }, + ]) + ) + const targetDraft: SubBlockRecord = { + triggerCredentials: { type: 'oauth-input', value: 'trigger-credential' }, + labelIds: { type: 'dropdown', value: ['INBOX'] }, + } + const merged: SubBlockRecord = { + triggerCredentials: { type: 'oauth-input', value: '' }, + labelIds: { type: 'dropdown', value: [] }, + } + + expect( + collectClearedDependents( + 'gmail', + 'b1', + 'Gmail Action', + targetDraft, + merged, + undefined, + triggerMode + ) + ).toEqual(expected.map((subBlockKey) => expect.objectContaining({ subBlockKey }))) + }) + it('flags a required dependent the target had set but the merge left empty', () => { vi.mocked(getBlock).mockReturnValue( blockWith([ @@ -1005,6 +1189,49 @@ describe('collectClearedDependents', () => { }, ]) }) + + it('does not flag a trigger-only dependent nested inside an Agent tool', () => { + vi.mocked(getBlock).mockImplementation((type) => { + if (type === 'agent') return blockWith([{ id: 'tools', title: 'Tools', type: 'tool-input' }]) + if (type === 'mixedblock') + return blockWith([ + ...mixedToolCanonicalSubBlocks, + { + id: 'triggerFolder', + title: 'Trigger Folder', + type: 'folder-selector', + dependsOn: ['triggerCredentials'], + required: true, + mode: 'trigger', + }, + ]) + return undefined as unknown as BlockConfig + }) + const targetDraft: SubBlockRecord = { + tools: entry('tools', 'tool-input', [ + { + type: 'mixedblock', + title: 'Mixed', + params: { credential: 'c-target', triggerFolder: 'TRIGGER' }, + }, + ]), + } + const merged: SubBlockRecord = { + tools: entry('tools', 'tool-input', [ + { + type: 'mixedblock', + title: 'Mixed', + params: { credential: 'c-new', triggerFolder: '' }, + }, + ]), + } + + expect( + collectClearedDependents('agent', 'b1', 'Agent', targetDraft, merged, { + '0:oauthCredential': 'basic', + }) + ).toEqual([]) + }) }) describe('applyDependentOverrides', () => { diff --git a/apps/sim/ee/workspace-forking/lib/remap/remap-references.ts b/apps/sim/ee/workspace-forking/lib/remap/remap-references.ts index 9dfb419f3ac..d8dbc47c0da 100644 --- a/apps/sim/ee/workspace-forking/lib/remap/remap-references.ts +++ b/apps/sim/ee/workspace-forking/lib/remap/remap-references.ts @@ -23,8 +23,10 @@ import { buildSubBlockValues, type CanonicalModeOverrides, evaluateSubBlockCondition, + getCanonicalSubBlocksForSurface, isCanonicalPair, isNonEmptyValue, + isPureTriggerBlockConfig, reindexCanonicalModesByPosition, resolveActiveCanonicalValue, resolveCanonicalMode, @@ -204,7 +206,8 @@ export type SubBlockTransform = ( subBlocks: SubBlockRecord, blockType: string, canonicalModes?: CanonicalModeOverrides, - onCanonicalModesChanged?: (next: CanonicalModeOverrides) => void + onCanonicalModesChanged?: (next: CanonicalModeOverrides) => void, + triggerMode?: boolean ) => SubBlockRecord /** @@ -216,6 +219,8 @@ export type SubBlockTransform = ( * through verbatim, and a dormant member's value is cleared and never detected. */ export interface CanonicalModeGates { + /** The key belongs to the block's inactive action/trigger surface. */ + isInactiveSurfaceMember: (subBlockKey: string) => boolean /** The key is a pair member that is NOT the pair's active member. */ isDormantMember: (subBlockKey: string) => boolean /** The key is the pair's ACTIVE advanced member - the live, user-owned manual field. */ @@ -229,6 +234,7 @@ export interface CanonicalModeGates { } const NO_GATES: CanonicalModeGates = { + isInactiveSurfaceMember: () => false, isDormantMember: () => false, isActiveManualMember: () => false, isManualParentDependent: () => false, @@ -277,6 +283,7 @@ export function createCanonicalModeGates( } return { + isInactiveSurfaceMember: () => false, isDormantMember: (subBlockKey) => { const baseKey = baseKeyOf(subBlockKey) const group = groupFor(baseKey) @@ -306,14 +313,56 @@ export function createCanonicalModeGates( } } -/** Per-block context for the fork remap. `blockType`/`canonicalModes` gate DETECTION (not rewrite). */ +/** + * Build canonical gates for the block surface that can execute. Mixed action/trigger blocks may + * reuse one canonical id across both surfaces, so the full config would let an action-only mode + * mark a live trigger member dormant. Canonical members on the other surface remain dormant so + * their workspace-scoped values are cleaned up; every other off-surface field is separately marked + * inactive so it cannot become a reference blocker or needs-configuration prompt. + */ +export function createBlockCanonicalModeGates( + config: ReturnType, + values: Record, + canonicalModes?: CanonicalModeOverrides, + triggerMode?: boolean +): CanonicalModeGates { + if (!config) return NO_GATES + const triggerSurface = triggerMode === true || isPureTriggerBlockConfig(config) + const activeSubBlocks = getCanonicalSubBlocksForSurface(config.subBlocks, triggerSurface) + const activeGates = createCanonicalModeGates(activeSubBlocks, values, canonicalModes) + const activeIds = new Set(activeSubBlocks.map((subBlock) => subBlock.id).filter(Boolean)) + const inactiveSurfaceIds = new Set( + config.subBlocks + .filter((subBlock) => subBlock.id && !activeIds.has(subBlock.id)) + .map((subBlock) => subBlock.id) + ) + const dormantSurfaceIds = new Set( + config.subBlocks + .filter((subBlock) => subBlock.canonicalParamId && inactiveSurfaceIds.has(subBlock.id)) + .map((subBlock) => subBlock.id) + ) + if (inactiveSurfaceIds.size === 0) return activeGates + + return { + ...activeGates, + isInactiveSurfaceMember: (subBlockKey) => + inactiveSurfaceIds.has(subBlockKey.replace(/_\d+$/, '')), + isDormantMember: (subBlockKey) => + dormantSurfaceIds.has(subBlockKey.replace(/_\d+$/, '')) || + activeGates.isDormantMember(subBlockKey), + } +} + +/** Per-block context for surface-aware fork remapping and reference detection. */ export interface RemapForkContext { blockId?: string blockName?: string - /** Block type, to build the canonical index for active-member DETECTION gating (rewrite unaffected). */ + /** Block type, used to build the canonical index for the active action/trigger surface. */ blockType?: string /** Canonical-mode overrides (`block.data.canonicalModes`), picking the active member per pair. */ canonicalModes?: CanonicalModeOverrides + /** Whether this mixed action/trigger block is currently using its trigger surface. */ + triggerMode?: boolean /** Target MCP server row lookup for rewriting remapped tool-input entries' server metadata. */ resolveMcpServerMeta?: ForkMcpServerMetaResolver /** @@ -418,7 +467,16 @@ export function remapToolBlockResources( opts.toolIndex, tool.type ) - const toolBlockSubBlocks = (opts.blockConfigs?.[tool.type] ?? getBlock(tool.type))?.subBlocks + const allToolBlockSubBlocks = (opts.blockConfigs?.[tool.type] ?? getBlock(tool.type))?.subBlocks + const toolBlockSubBlocks = allToolBlockSubBlocks + ? getCanonicalSubBlocksForSurface(allToolBlockSubBlocks, false) + : undefined + const actionToolSubBlockIds = new Set(toolBlockSubBlocks?.map((config) => config.id) ?? []) + const inactiveToolSurfaceIds = new Set( + allToolBlockSubBlocks + ?.filter((config) => !actionToolSubBlockIds.has(config.id)) + .map((config) => config.id) ?? [] + ) const gates = createCanonicalModeGates(toolBlockSubBlocks, toolValues, scopedModes) // Clear DORMANT member keys first: a stale inactive value must not survive the copy (and must @@ -435,6 +493,7 @@ export function remapToolBlockResources( // params so they're caught even when their config is filtered out by a reactive condition // (the registry loop below would otherwise miss them). Dormant members were cleared above. for (const paramId of Object.keys(params)) { + if (inactiveToolSurfaceIds.has(paramId)) continue const overrideKind = getToolParamOverrideKind(paramId) if (!overrideKind) continue if (gates.isDormantMember(paramId)) continue @@ -825,7 +884,7 @@ export function remapForkSubBlocks( if (!mapped) unmapped.set(key, reference) } - // Mode policy (see {@link createCanonicalModeGates}): only the ACTIVE canonical member is a + // Mode policy (see {@link createBlockCanonicalModeGates}): only the ACTIVE canonical member is a // real reference. An active BASIC selector is remapped + detected (mapping/copy/blockers); an // active ADVANCED (manual) member - and every dependent scoped to it - passes through VERBATIM // (user-owned, never remapped, never a mapping requirement); a DORMANT member's value is @@ -833,10 +892,11 @@ export function remapForkSubBlocks( // subblock is still rewritten but not detected. Needs `blockType` for the config; an unknown // block type gets no gating (everything detected, nothing passed through - the conservative // default). - const gates = createCanonicalModeGates( - context?.blockType ? getBlock(context.blockType)?.subBlocks : undefined, + const gates = createBlockCanonicalModeGates( + context?.blockType ? getBlock(context.blockType) : undefined, buildSubBlockValues(subBlocks), - context?.canonicalModes + context?.canonicalModes, + context?.triggerMode ) for (const [subBlockKey, subBlock] of Object.entries(subBlocks)) { @@ -867,7 +927,11 @@ export function remapForkSubBlocks( const verbatimManual = !dormant && (gates.isActiveManualMember(subBlockKey) || gates.isManualParentDependent(subBlockKey)) - const detectionSkipped = dormant || verbatimManual || gates.isConditionHidden(subBlockKey) + const detectionSkipped = + dormant || + gates.isInactiveSurfaceMember(subBlockKey) || + verbatimManual || + gates.isConditionHidden(subBlockKey) // `{{ENV}}` detection is gated on EXECUTION, not on ownership. A dormant member and a // condition-hidden field never execute, so their refs must not become sync blockers - but an // ACTIVE MANUAL member is exactly the value that DOES execute, and its `{{KEY}}` is a live @@ -877,7 +941,8 @@ export function remapForkSubBlocks( // missing that secret silently passed the required-env gate instead of blocking the sync. // Resource-id detection keeps `verbatimManual` (a hand-typed id stays a user-owned escape // hatch); only env refs, which are never workspace-scoped ids, are detected here. - const envDetectionSkipped = dormant || gates.isConditionHidden(subBlockKey) + const envDetectionSkipped = + dormant || gates.isInactiveSurfaceMember(subBlockKey) || gates.isConditionHidden(subBlockKey) if (dormant && isNonEmptyValue(value)) { value = '' } @@ -1065,7 +1130,9 @@ export function clearDependentsOnRemap( remappedKeys: ReadonlySet, canonicalModes?: CanonicalModeOverrides, /** Keys remapped via a COPY (see {@link RemapSubBlocksResult.copyRemappedKeys}). */ - copyRemappedKeys?: ReadonlySet + copyRemappedKeys?: ReadonlySet, + /** Whether this mixed action/trigger block is currently using its trigger surface. */ + triggerMode?: boolean ): SubBlockRecord { if (remappedKeys.size === 0) return subBlocks const config = getBlock(blockType) @@ -1076,10 +1143,11 @@ export function clearDependentsOnRemap( // (only the active mode is serialized). With `canonicalModes` absent the value heuristic keeps a // populated basic member active, so this is a no-op for the normal case; the gate only bites the // toggle-with-stale-dormant edge (advanced active + a dormant basic that was remapped). - const gates = createCanonicalModeGates( - config.subBlocks, + const gates = createBlockCanonicalModeGates( + config, buildSubBlockValues(subBlocks), - canonicalModes + canonicalModes, + triggerMode ) // The exemption's parent test: an mcp-server selector whose POST-remap value is non-empty was @@ -1225,6 +1293,7 @@ function collectClearedToolParamDependents( if (!isRecord(targetTool) || targetTool.type !== tool.type) continue const toolConfig = getBlock(tool.type) if (!toolConfig) continue + const actionToolSubBlocks = getCanonicalSubBlocksForSurface(toolConfig.subBlocks, false) const targetParams = isRecord(targetTool.params) ? targetTool.params : {} const mergedParams = isRecord(tool.params) ? tool.params : {} // A tool's `operation` lives at the tool level, not in params, but conditions @@ -1237,12 +1306,12 @@ function collectClearedToolParamDependents( // active member executes). Modes resolve like the tool-input UI: tool-scoped overrides, // then the value heuristic over the merged params. const gates = createCanonicalModeGates( - toolConfig.subBlocks, + actionToolSubBlocks, mergedValues, scopeCanonicalModesForTool(parentCanonicalModes, index, tool.type) ) const toolLabel = typeof tool.title === 'string' && tool.title ? tool.title : toolConfig.name - for (const cfg of toolConfig.subBlocks) { + for (const cfg of actionToolSubBlocks) { if (!cfg.dependsOn || !cfg.id) continue // Only flag a param the TARGET tool had configured (not one the source carried in). if (!isNonEmptyValue(targetParams[cfg.id])) continue @@ -1280,7 +1349,8 @@ export function collectClearedDependents( blockName: string, targetCurrentSubBlocks: SubBlockRecord, mergedSubBlocks: SubBlockRecord, - canonicalModes?: CanonicalModeOverrides + canonicalModes?: CanonicalModeOverrides, + triggerMode?: boolean ): NeedsConfigurationField[] { const config = getBlock(blockType) if (!config) return [] @@ -1288,10 +1358,11 @@ export function collectClearedDependents( const mergedValues = buildSubBlockValues(mergedSubBlocks) // A DORMANT canonical member the merge cleared is not a lost configuration - only the pair's // active member executes, so an inactive slot must never demand a re-pick. - const gates = createCanonicalModeGates(config.subBlocks, mergedValues, canonicalModes) + const gates = createBlockCanonicalModeGates(config, mergedValues, canonicalModes, triggerMode) const fields: NeedsConfigurationField[] = [] for (const cfg of config.subBlocks) { if (!cfg.id) continue + if (gates.isInactiveSurfaceMember(cfg.id)) continue // Only flag a field the target had configured (so the user lost their own selection), // still empty after merge, and currently active (a value under a now-inactive // `condition`/operation or a dormant canonical member isn't really in play). @@ -1497,10 +1568,11 @@ export function createForkSubBlockTransform( isCopiedTarget?: (kind: ForkRemapKind, sourceId: string) => boolean } ): SubBlockTransform { - return (subBlocks, blockType, canonicalModes, onCanonicalModesChanged) => { + return (subBlocks, blockType, canonicalModes, onCanonicalModesChanged, triggerMode) => { const result = remapSubBlocks(subBlocks, resolve, { blockType, canonicalModes, + triggerMode, resolveMcpServerMeta: options?.resolveMcpServerMeta, isCopiedTarget: options?.isCopiedTarget, }) @@ -1510,7 +1582,8 @@ export function createForkSubBlockTransform( blockType, result.remappedKeys, result.canonicalModes ?? canonicalModes, - result.copyRemappedKeys + result.copyRemappedKeys, + triggerMode ) } } @@ -1534,6 +1607,8 @@ export function scanWorkflowReferences( subBlocks: unknown /** `block.data.canonicalModes`, picking the active member per canonical pair for detection. */ canonicalModes?: CanonicalModeOverrides + /** Whether this mixed action/trigger block is currently using its trigger surface. */ + triggerMode?: boolean }>, resolve: ForkReferenceResolver ): WorkflowReferenceScan { @@ -1549,6 +1624,7 @@ export function scanWorkflowReferences( blockName: block.name, blockType: block.type, canonicalModes: block.canonicalModes, + triggerMode: block.triggerMode, }) for (const reference of blockResult.references) { const key = `${reference.kind}:${reference.sourceId}` diff --git a/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/validation.test.ts b/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/validation.test.ts index abc89f9cf4d..c7eab2a4ae2 100644 --- a/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/validation.test.ts +++ b/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/validation.test.ts @@ -122,6 +122,27 @@ const canonicalCredBlockConfig = { ], } +const mixedTriggerCredBlockConfig = { + type: 'mixedtriggercred', + name: 'Mixed Trigger Credential', + outputs: {}, + subBlocks: [ + { id: 'credential', type: 'oauth-input', canonicalParamId: 'oauthCredential', mode: 'basic' }, + { + id: 'manualCredential', + type: 'short-input', + canonicalParamId: 'oauthCredential', + mode: 'advanced', + }, + { + id: 'triggerCredentials', + type: 'oauth-input', + canonicalParamId: 'oauthCredential', + mode: 'trigger', + }, + ], +} + // Mirrors video_generator_v3: routes provider -> tool; only video_falai has hosting. const videoBlockConfig = { type: 'video_generator_v3', @@ -235,6 +256,7 @@ const blockConfigsByType: Record = { huggingface: huggingfaceBlockConfig, knowledge: knowledgeBlockConfig, canonicalcred: canonicalCredBlockConfig, + mixedtriggercred: mixedTriggerCredBlockConfig, video_generator_v3: videoBlockConfig, custom_key_block: customKeyBlockConfig, image_generator_v2: imageBlockConfig, @@ -1162,6 +1184,32 @@ describe('collectUnresolvedReferences', () => { expect(refs).toHaveLength(1) expect(refs[0]).toMatchObject({ field: 'credential', kind: 'credential' }) }) + + it('validates the trigger credential instead of a dormant action credential', async () => { + mockValidateSelectorIds.mockResolvedValue({ valid: [], invalid: ['trigger-credential'] }) + const state = { + blocks: { + c1: { + type: 'mixedtriggercred', + name: 'Trigger', + triggerMode: true, + data: { canonicalModes: { oauthCredential: 'advanced' } }, + subBlocks: { + credential: { value: 'dormant-action' }, + manualCredential: { value: 'dormant-action-manual' }, + triggerCredentials: { value: 'trigger-credential' }, + }, + }, + }, + } + + const refs = await collectUnresolvedReferences(state, CTX) + + expect(mockValidateSelectorIds).toHaveBeenCalledOnce() + expect(mockValidateSelectorIds).toHaveBeenCalledWith('oauth-input', 'trigger-credential', CTX) + expect(refs).toHaveLength(1) + expect(refs[0]).toMatchObject({ field: 'triggerCredentials', kind: 'credential' }) + }) }) describe('validateInputsForBlock - agent tools (tool-input)', () => { diff --git a/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/validation.ts b/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/validation.ts index 2780a902496..bd24c8153c1 100644 --- a/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/validation.ts +++ b/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/validation.ts @@ -11,7 +11,9 @@ import { getSkillById } from '@/lib/workflows/skills/operations' import { buildCanonicalIndex, buildSubBlockValues, + getCanonicalSubBlocksForSurface, isCanonicalPair, + isPureTriggerBlockConfig, resolveCanonicalMode, } from '@/lib/workflows/subblocks/visibility' import { getBlock } from '@/blocks/registry' @@ -1019,11 +1021,15 @@ function collectSelectorFields( const blockConfig = getBlock(blockType) if (!blockConfig) continue - const canonicalIndex = buildCanonicalIndex(blockConfig.subBlocks) + const activeSubBlocks = getCanonicalSubBlocksForSurface( + blockConfig.subBlocks, + blockData.triggerMode === true || isPureTriggerBlockConfig(blockConfig) + ) + const canonicalIndex = buildCanonicalIndex(activeSubBlocks) const allValues = buildSubBlockValues(blockData.subBlocks || {}) const canonicalModeOverrides = blockData.data?.canonicalModes - for (const subBlockConfig of blockConfig.subBlocks) { + for (const subBlockConfig of activeSubBlocks) { if (!SELECTOR_TYPES.has(subBlockConfig.type)) continue // oauth-input credentials are only validated when explicitly requested diff --git a/apps/sim/lib/workflows/autolayout/utils.test.ts b/apps/sim/lib/workflows/autolayout/utils.test.ts index 87ea37a937e..f4cf78e44c3 100644 --- a/apps/sim/lib/workflows/autolayout/utils.test.ts +++ b/apps/sim/lib/workflows/autolayout/utils.test.ts @@ -340,6 +340,23 @@ describe('getBlockMetrics preview row estimation', () => { expect(spread.height).toBe(plain.height) }) + it('measures exactly the two active trigger rows', () => { + mockGetBlock.mockReturnValue(tableLikeConfig) + const block = createTableBlock('basic') + block.triggerMode = true + block.subBlocks = { + ...block.subBlocks, + eventType: { id: 'eventType', type: 'dropdown', value: 'new_row' }, + } + + expect(getBlockMetrics(block).height).toBe( + BLOCK_DIMENSIONS.HEADER_HEIGHT + + BLOCK_DIMENSIONS.WORKFLOW_CONTENT_PADDING + + 2 * BLOCK_DIMENSIONS.WORKFLOW_ROW_HEIGHT + + BLOCK_DIMENSIONS.WORKFLOW_CONTENT_GAP + ) + }) + it('never estimates a card shorter than the rows it can actually paint', () => { /* * The estimate only runs for a block that has never mounted, and on the diff --git a/apps/sim/lib/workflows/autolayout/utils.ts b/apps/sim/lib/workflows/autolayout/utils.ts index 9bb11f18835..63938adc172 100644 --- a/apps/sim/lib/workflows/autolayout/utils.ts +++ b/apps/sim/lib/workflows/autolayout/utils.ts @@ -32,6 +32,7 @@ import { buildSubBlockValues, type CanonicalModeOverrides, evaluateSubBlockCondition, + getCanonicalSubBlocksForSurface, isSubBlockFeatureEnabled, isSubBlockHidden, isSubBlockVisibleForMode, @@ -198,10 +199,15 @@ function getVisiblePreviewSubBlocks(block: BlockState): { rawValues.__canonicalModes = canonicalModeOverrides } - const canonicalIndex = buildCanonicalIndex(blockConfig.subBlocks) + const isPureTriggerBlock = blockConfig.triggers?.enabled && blockConfig.category === 'triggers' + const canonicalIndex = buildCanonicalIndex( + getCanonicalSubBlocksForSurface( + blockConfig.subBlocks, + Boolean(block.triggerMode) || Boolean(isPureTriggerBlock) + ) + ) const effectiveAdvanced = Boolean(block.advancedMode) const effectiveTrigger = Boolean(block.triggerMode) - const isPureTriggerBlock = blockConfig.triggers?.enabled && blockConfig.category === 'triggers' const visibleSubBlocks = blockConfig.subBlocks.filter((subBlock) => { if (subBlock.hidden || subBlock.hideFromPreview) return false diff --git a/apps/sim/lib/workflows/comparison/resolve-values.ts b/apps/sim/lib/workflows/comparison/resolve-values.ts index 64cfc114cef..087ac3c876c 100644 --- a/apps/sim/lib/workflows/comparison/resolve-values.ts +++ b/apps/sim/lib/workflows/comparison/resolve-values.ts @@ -179,6 +179,7 @@ function extractSelectorContext( workflowId, workspaceId, canonicalModes: block.data?.canonicalModes, + triggerMode: block.triggerMode === true, }) } diff --git a/apps/sim/lib/workflows/migrations/subblock-migrations.test.ts b/apps/sim/lib/workflows/migrations/subblock-migrations.test.ts index 923a6d4976b..209f79df458 100644 --- a/apps/sim/lib/workflows/migrations/subblock-migrations.test.ts +++ b/apps/sim/lib/workflows/migrations/subblock-migrations.test.ts @@ -9,6 +9,7 @@ vi.unmock('@/blocks/registry') import * as blocksBarrel from '@/blocks' import { getBlock as getRealBlock } from '@/blocks/registry' +import { extractBlockParams } from '@/serializer' import { backfillCanonicalModes, migrateSubblockIds, @@ -40,6 +41,8 @@ function makeBlock(overrides: Partial & { type: string }): BlockStat } as BlockState } +const stored = (value: unknown) => ({ value }) as BlockState['subBlocks'][string] + /** * `dropParkedSubblocks` deletes any subblock whose id starts with `_removed_`, * on the assumption that no live block declares one. Nothing enforces that @@ -435,6 +438,87 @@ describe('migrateSubblockIds', () => { }) describe('backfillCanonicalModes', () => { + it.each([ + { + name: 'action', + triggerMode: false, + values: ['action-basic', 'action-advanced', 'dormant-trigger'], + calendarId: undefined, + expectedModes: { oauthCredential: 'basic' }, + absentMode: undefined, + expectedCredential: 'action-basic', + }, + { + name: 'trigger', + triggerMode: true, + values: ['dormant-action', 'dormant-action-advanced', 'trigger-basic'], + calendarId: 'trigger-calendar', + expectedModes: { calendarId: 'basic' }, + absentMode: 'oauthCredential', + expectedCredential: 'trigger-basic', + }, + ])( + 'backfills only the $name surface of the real Google Calendar block', + ({ triggerMode, values, calendarId, expectedModes, absentMode, expectedCredential }) => { + const [credential, manualCredential, triggerCredentials] = values + const subBlocks: BlockState['subBlocks'] = { + credential: stored(credential), + manualCredential: stored(manualCredential), + triggerCredentials: stored(triggerCredentials), + } + if (calendarId) { + subBlocks.calendarId = stored(calendarId) + } + + const input: Record = { + b1: makeBlock({ type: 'google_calendar', triggerMode, subBlocks }), + } + const paramsBeforeBackfill = extractBlockParams(input.b1) + const { blocks, migrated } = backfillCanonicalModes(input) + + expect(migrated).toBe(true) + expect(blocks.b1.data?.canonicalModes).toMatchObject(expectedModes) + if (absentMode) expect(blocks.b1.data?.canonicalModes).not.toHaveProperty(absentMode) + expect(extractBlockParams(blocks.b1)).toEqual(paramsBeforeBackfill) + expect(paramsBeforeBackfill.oauthCredential).toBe(expectedCredential) + } + ) + + it('uses the trigger surface for a pure trigger block without a triggerMode flag', () => { + getBlockSpy.mockImplementationOnce( + () => + ({ + category: 'triggers', + triggers: { enabled: true }, + subBlocks: [ + ['actionSelector', 'project-selector', 'basic'], + ['actionManualId', 'short-input', 'advanced'], + ['triggerSelector', 'project-selector', 'trigger'], + ['triggerManualId', 'short-input', 'trigger-advanced'], + ].map(([id, type, mode]) => ({ + id, + type, + canonicalParamId: 'resourceId', + mode, + })), + }) as never + ) + const input: Record = { + b1: makeBlock({ + type: 'pure-trigger-test', + subBlocks: { + actionManualId: stored('dormant-action'), + triggerSelector: stored('trigger-basic'), + }, + }), + } + + const { blocks, migrated } = backfillCanonicalModes(input) + + expect(migrated).toBe(true) + expect(blocks.b1.data?.canonicalModes).toMatchObject({ resourceId: 'basic' }) + }) + it('should add missing canonicalModes entry for knowledge block with basic value', () => { const input: Record = { b1: makeBlock({ @@ -481,17 +565,44 @@ describe('backfillCanonicalModes', () => { expect(modes.knowledgeBaseId).toBe('advanced') }) + it('should preserve legacy advancedMode selection when both canonical values are set', () => { + const input: Record = { + b1: makeBlock({ + type: 'slack', + advancedMode: true, + data: {}, + subBlocks: { + operation: stored('send'), + destinationType: stored('channel'), + channel: stored('C_BASIC'), + manualChannel: stored('C_ADVANCED'), + text: stored('Hello'), + }, + }), + } + + const paramsBeforeBackfill = extractBlockParams(input.b1) + const { blocks, migrated } = backfillCanonicalModes(input) + const paramsAfterBackfill = extractBlockParams(blocks.b1) + const secondPass = backfillCanonicalModes(blocks) + + expect(migrated).toBe(true) + expect(blocks.b1.data?.canonicalModes).toMatchObject({ channel: 'advanced' }) + expect(paramsBeforeBackfill.channel).toBe('C_ADVANCED') + expect(paramsAfterBackfill).toEqual(paramsBeforeBackfill) + expect(secondPass.migrated).toBe(false) + expect(secondPass.blocks.b1).toBe(blocks.b1) + }) + it('should not overwrite existing canonicalModes entries', () => { const input: Record = { b1: makeBlock({ type: 'knowledge', - data: { canonicalModes: { knowledgeBaseId: 'advanced', documentId: 'basic' } }, + advancedMode: true, + data: { canonicalModes: { knowledgeBaseId: 'basic', documentId: 'basic' } }, subBlocks: { - knowledgeBaseSelector: { - id: 'knowledgeBaseSelector', - type: 'knowledge-base-selector', - value: 'kb-uuid', - }, + knowledgeBaseSelector: stored('kb-uuid'), + manualKnowledgeBaseId: stored('kb-uuid-manual'), }, }), } @@ -500,7 +611,7 @@ describe('backfillCanonicalModes', () => { expect(migrated).toBe(false) const modes = blocks.b1.data?.canonicalModes as Record - expect(modes.knowledgeBaseId).toBe('advanced') + expect(modes.knowledgeBaseId).toBe('basic') }) it('should skip blocks with no canonical pairs in their config', () => { diff --git a/apps/sim/lib/workflows/migrations/subblock-migrations.ts b/apps/sim/lib/workflows/migrations/subblock-migrations.ts index b5de686bd89..5bc30083814 100644 --- a/apps/sim/lib/workflows/migrations/subblock-migrations.ts +++ b/apps/sim/lib/workflows/migrations/subblock-migrations.ts @@ -5,7 +5,11 @@ import { sanitizeMalformedSubBlocks } from '@/lib/workflows/sanitization/subbloc import { buildCanonicalIndex, buildSubBlockValues, + type CanonicalGroup, + type CanonicalMode, + getCanonicalSubBlocksForSurface, isCanonicalPair, + isPureTriggerBlockConfig, resolveCanonicalMode, } from '@/lib/workflows/subblocks/visibility' import { getBlock } from '@/blocks' @@ -285,6 +289,18 @@ export function migrateSubblockIds(blocks: Record): { return { blocks: result, migrated: anyMigrated } } +/** + * Resolves a missing per-pair mode without changing the legacy serializer's selection. + */ +function resolveBackfilledCanonicalMode( + block: Pick, + group: CanonicalGroup, + values: Record +): CanonicalMode { + if (block.advancedMode === true) return 'advanced' + return resolveCanonicalMode(group, values) +} + /** * Backfills missing `canonicalModes` entries in block data. * @@ -307,7 +323,12 @@ export function backfillCanonicalModes(blocks: Record): { continue } - const canonicalIndex = buildCanonicalIndex(blockConfig.subBlocks) + const canonicalIndex = buildCanonicalIndex( + getCanonicalSubBlocksForSurface( + blockConfig.subBlocks, + Boolean(block.triggerMode) || isPureTriggerBlockConfig(blockConfig) + ) + ) const pairs = Object.values(canonicalIndex.groupsById).filter(isCanonicalPair) if (pairs.length === 0) { result[blockId] = block @@ -322,7 +343,7 @@ export function backfillCanonicalModes(blocks: Record): { for (const group of pairs) { if (existing[group.canonicalId] != null) continue - const resolved = resolveCanonicalMode(group, values) + const resolved = resolveBackfilledCanonicalMode(block, group, values) if (!patched) patched = { ...existing } patched[group.canonicalId] = resolved } diff --git a/apps/sim/lib/workflows/search-replace/indexer.test.ts b/apps/sim/lib/workflows/search-replace/indexer.test.ts index 8b8509107fc..e00d454a283 100644 --- a/apps/sim/lib/workflows/search-replace/indexer.test.ts +++ b/apps/sim/lib/workflows/search-replace/indexer.test.ts @@ -12,6 +12,7 @@ import { SEARCH_REPLACE_BLOCK_CONFIGS, } from '@/lib/workflows/search-replace/search-replace.fixtures' import { WORKFLOW_SEARCH_SUBFLOW_FIELD_IDS } from '@/lib/workflows/search-replace/subflow-fields' +import type { SubBlockConfig } from '@/blocks/types' /** * Uses the real tool registry. Nothing here imports it directly — the dependency @@ -24,6 +25,33 @@ import { WORKFLOW_SEARCH_SUBFLOW_FIELD_IDS } from '@/lib/workflows/search-replac */ vi.unmock('@/tools/registry') +const mixedCredentialSelectorSubBlocks: SubBlockConfig[] = [ + { + id: 'credential', + title: 'Credential', + type: 'oauth-input', + canonicalParamId: 'oauthCredential', + mode: 'basic', + paramVisibility: 'user-only', + }, + { + id: 'triggerCredentials', + title: 'Trigger Credential', + type: 'oauth-input', + canonicalParamId: 'oauthCredential', + mode: 'trigger', + paramVisibility: 'user-only', + }, + { + id: 'channel', + title: 'Channel', + type: 'channel-selector', + selectorKey: 'slack.channels', + dependsOn: ['oauthCredential'], + paramVisibility: 'user-or-llm', + }, +] + describe('indexWorkflowSearchMatches', () => { it('marks generic tool-param fallbacks as non-authoritative', () => { expect( @@ -720,6 +748,41 @@ describe('indexWorkflowSearchMatches', () => { expect(triggerManualMatches).toEqual([]) }) + it('does not put a dormant trigger credential in an action selector context', () => { + const workflow = createSearchReplaceWorkflowFixture() + workflow.blocks['surface-1'] = { + id: 'surface-1', + type: 'custom', + name: 'Surface Block', + position: { x: 0, y: 0 }, + enabled: true, + outputs: {}, + data: { canonicalModes: { oauthCredential: 'basic' } }, + subBlocks: { + credential: { id: 'credential', type: 'oauth-input', value: '' }, + triggerCredentials: { + id: 'triggerCredentials', + type: 'oauth-input', + value: 'dormant-trigger', + }, + channel: { id: 'channel', type: 'channel-selector', value: 'C123' }, + }, + } + + const matches = indexWorkflowSearchMatches({ + workflow, + query: 'C123', + mode: 'resource', + blockConfigs: { + ...SEARCH_REPLACE_BLOCK_CONFIGS, + custom: { subBlocks: mixedCredentialSelectorSubBlocks }, + }, + }).filter((match) => match.blockId === 'surface-1') + + expect(matches).toHaveLength(1) + expect(matches[0].resource?.selectorContext).toBeUndefined() + }) + it('does not index fixed-choice dropdown values as text replacements', () => { const workflow = createSearchReplaceWorkflowFixture() workflow.blocks['dropdown-1'] = { @@ -1758,6 +1821,57 @@ describe('indexWorkflowSearchMatches', () => { ]) }) + it('does not use a trigger-only alias in an Agent tool selector context', () => { + const workflow = createSearchReplaceWorkflowFixture() + workflow.blocks['tool-input-1'] = { + id: 'tool-input-1', + type: 'custom', + name: 'Tool Input Block', + position: { x: 0, y: 0 }, + enabled: true, + outputs: {}, + data: { canonicalModes: { '0:oauthCredential': 'basic' } }, + subBlocks: { + tools: { + id: 'tools', + type: 'tool-input', + value: [ + { + type: 'slack', + toolId: 'slack_message', + operation: 'send', + title: 'Slack message', + params: { + credential: '', + triggerCredentials: 'dormant-trigger', + channel: 'COLD', + text: 'message', + }, + }, + ], + }, + }, + } + + const matches = indexWorkflowSearchMatches({ + workflow, + query: 'COLD', + mode: 'resource', + workspaceId: 'workspace-1', + workflowId: 'workflow-1', + blockConfigs: { + ...SEARCH_REPLACE_BLOCK_CONFIGS, + custom: { + subBlocks: [{ id: 'tools', title: 'Tools', type: 'tool-input' }], + }, + slack: { subBlocks: mixedCredentialSelectorSubBlocks }, + }, + }).filter((match) => match.kind === 'selector-resource') + + expect(matches).toHaveLength(1) + expect(matches[0].resource?.selectorContext).not.toHaveProperty('oauthCredential') + }) + it('indexes tool-input titles as non-editable display labels', () => { const workflow = createSearchReplaceWorkflowFixture() workflow.blocks['tool-title-1'] = { diff --git a/apps/sim/lib/workflows/search-replace/indexer.ts b/apps/sim/lib/workflows/search-replace/indexer.ts index 2d0fdb23b7f..6304b78b0b2 100644 --- a/apps/sim/lib/workflows/search-replace/indexer.ts +++ b/apps/sim/lib/workflows/search-replace/indexer.ts @@ -29,6 +29,8 @@ import { buildSubBlockValues, type CanonicalModeOverrides, evaluateSubBlockCondition, + getCanonicalSubBlocksForSurface, + isPureTriggerBlockConfig, isSubBlockFeatureEnabled, isSubBlockHidden, isSubBlockVisibleForMode, @@ -37,7 +39,6 @@ import { parseDependsOn, resolveDependencyValue, scopeCanonicalModesForTool, - shouldUseSubBlockForTriggerModeCanonicalIndex, } from '@/lib/workflows/subblocks/visibility' import { isSyntheticToolSubBlockId } from '@/lib/workflows/tool-input/synthetic-subblocks' import { type ParsedStoredTool, parseStoredToolInputValue } from '@/lib/workflows/tool-input/types' @@ -774,9 +775,11 @@ export function getToolInputParamConfigs({ }) } - const toolCanonicalIndex = buildCanonicalIndex( - blockConfig?.subBlocks ?? subBlocksResult.subBlocks + const actionToolSubBlocks = getCanonicalSubBlocksForSurface( + blockConfig?.subBlocks ?? subBlocksResult.subBlocks, + false ) + const toolCanonicalIndex = buildCanonicalIndex(actionToolSubBlocks) const visibleSubBlocks = subBlocksResult.subBlocks.filter((subBlock) => isToolParamVisibleForReactiveCondition({ subBlockConfig: subBlock, @@ -786,7 +789,7 @@ export function getToolInputParamConfigs({ credentialTypeById, }) ) - const allToolSubBlocks = blockConfig?.subBlocks ?? subBlocksResult.subBlocks + const allToolSubBlocks = actionToolSubBlocks const getDependentValuePaths = (changedSubBlockId: string): WorkflowSearchValuePath[] => getWorkflowSearchDependentClears(allToolSubBlocks, changedSubBlockId).map((clear) => [ 'params', @@ -1260,9 +1263,10 @@ export function indexWorkflowSearchMatches( for (const block of Object.values(workflow.blocks)) { const blockConfig = blockConfigs[block.type] ?? getBlock(block.type) const subBlockConfigs = blockConfig?.subBlocks ?? [] - const canonicalSubBlockConfigs = block.triggerMode - ? subBlockConfigs.filter(shouldUseSubBlockForTriggerModeCanonicalIndex) - : subBlockConfigs + const canonicalSubBlockConfigs = getCanonicalSubBlocksForSurface( + subBlockConfigs, + Boolean(block.triggerMode) || isPureTriggerBlockConfig(blockConfig) + ) const configsById = new Map(subBlockConfigs.map((subBlock) => [subBlock.id, subBlock])) const canonicalIndex = buildCanonicalIndex(canonicalSubBlockConfigs) const subBlockValues = buildSubBlockValues(block.subBlocks ?? {}) diff --git a/apps/sim/lib/workflows/subblocks/context.test.ts b/apps/sim/lib/workflows/subblocks/context.test.ts index 991520f9963..0e652647c83 100644 --- a/apps/sim/lib/workflows/subblocks/context.test.ts +++ b/apps/sim/lib/workflows/subblocks/context.test.ts @@ -74,6 +74,27 @@ describe('buildSelectorContextFromBlock', () => { ).toBe('kb-advanced') }) + it('resolves selector context only from the active trigger surface', () => { + const subBlocks = { + credential: { value: null }, + manualCredential: { value: 'stale-action-credential' }, + triggerCredentials: { value: 'trigger-credential' }, + } + + expect( + buildSelectorContextFromBlock('google_calendar', subBlocks, { + canonicalModes: { oauthCredential: 'advanced' }, + triggerMode: true, + }).oauthCredential + ).toBe('trigger-credential') + + expect( + buildSelectorContextFromBlock('google_calendar', subBlocks, { + canonicalModes: { oauthCredential: 'advanced' }, + }).oauthCredential + ).toBe('stale-action-credential') + }) + it('should skip null/empty values', () => { const ctx = buildSelectorContextFromBlock('knowledge', { knowledgeBaseSelector: { diff --git a/apps/sim/lib/workflows/subblocks/context.ts b/apps/sim/lib/workflows/subblocks/context.ts index 44552000d36..991a4e42991 100644 --- a/apps/sim/lib/workflows/subblocks/context.ts +++ b/apps/sim/lib/workflows/subblocks/context.ts @@ -5,6 +5,8 @@ import { buildCanonicalIndex, buildSubBlockValues, type CanonicalModeOverrides, + getCanonicalSubBlocksForSurface, + isPureTriggerBlockConfig, resolveActiveCanonicalValue, } from './visibility' @@ -54,7 +56,12 @@ export const SELECTOR_CONTEXT_FIELDS = new Set([ export function buildSelectorContextFromBlock( blockType: string, subBlocks: Record, - opts?: { workflowId?: string; workspaceId?: string; canonicalModes?: CanonicalModeOverrides } + opts?: { + workflowId?: string + workspaceId?: string + canonicalModes?: CanonicalModeOverrides + triggerMode?: boolean + } ): SelectorContext { const context: SelectorContext = {} if (opts?.workflowId) context.workflowId = opts.workflowId @@ -63,7 +70,13 @@ export function buildSelectorContextFromBlock( const blockConfig = getBlock(blockType) if (!blockConfig) return context - const canonicalIndex = buildCanonicalIndex(blockConfig.subBlocks) + const activeSubBlocks = getCanonicalSubBlocksForSurface( + blockConfig.subBlocks, + opts?.triggerMode === true || isPureTriggerBlockConfig(blockConfig) + ) + const activeSubBlockIds = new Set(activeSubBlocks.map((subBlock) => subBlock.id)) + const configuredSubBlockIds = new Set(blockConfig.subBlocks.map((subBlock) => subBlock.id)) + const canonicalIndex = buildCanonicalIndex(activeSubBlocks) const values = buildSubBlockValues(subBlocks) const resolvedGroups = new Set() @@ -77,6 +90,8 @@ export function buildSelectorContextFromBlock( } for (const [subBlockId, subBlock] of Object.entries(subBlocks)) { + if (configuredSubBlockIds.has(subBlockId) && !activeSubBlockIds.has(subBlockId)) continue + const canonicalId = canonicalIndex.canonicalIdBySubBlockId[subBlockId] if (canonicalId) { // A canonical group resolves to its ACTIVE member only (no last-write-wins between a diff --git a/apps/sim/lib/workflows/subblocks/visibility.test.ts b/apps/sim/lib/workflows/subblocks/visibility.test.ts index 8d6a87224b1..5e573cde9cc 100644 --- a/apps/sim/lib/workflows/subblocks/visibility.test.ts +++ b/apps/sim/lib/workflows/subblocks/visibility.test.ts @@ -1,13 +1,286 @@ /** * @vitest-environment node */ -import { describe, expect, it } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { GoogleCalendarBlock } from '@/blocks/blocks/google_calendar' +import { useWorkflowRegistry } from '@/stores/workflows/registry/store' +import { useSubBlockStore } from '@/stores/workflows/subblock/store' +import { useWorkflowStore } from '@/stores/workflows/workflow/store' +import { tableNewRowTrigger } from '@/triggers/table/poller' import { + buildCanonicalIndex, evaluateSubBlockCondition, + getCanonicalSubBlocksForSurface, reindexToolCanonicalModes, + resolveDependencyValue, scopeCanonicalModesForTool, } from './visibility' +const tableTriggerMocks = vi.hoisted(() => ({ fetchQuery: vi.fn() })) + +vi.mock('@/app/_shell/providers/get-query-client', () => ({ + getQueryClient: () => ({ fetchQuery: tableTriggerMocks.fetchQuery }), +})) + +const canonicalIndex = buildCanonicalIndex([ + { + id: 'resourceSelector', + type: 'project-selector', + canonicalParamId: 'resourceId', + mode: 'basic', + }, + { + id: 'triggerResourceSelector', + type: 'project-selector', + canonicalParamId: 'resourceId', + mode: 'trigger', + }, + { + id: 'manualResourceId', + type: 'short-input', + canonicalParamId: 'resourceId', + mode: 'advanced', + }, + { + id: 'triggerManualResourceId', + type: 'short-input', + canonicalParamId: 'resourceId', + mode: 'trigger-advanced', + }, +] as Parameters[0]) + +describe('resolveDependencyValue', () => { + it.each([ + ['Basic', 'null', 'basic', null], + ['Basic', 'undefined', 'basic', undefined], + ['Basic', 'empty string', 'basic', ''], + ['Basic', 'empty array', 'basic', []], + ['Advanced', 'null', 'advanced', null], + ['Advanced', 'undefined', 'advanced', undefined], + ['Advanced', 'empty string', 'advanced', ''], + ['Advanced', 'empty array', 'advanced', []], + ] as const)( + 'keeps an explicitly cleared %s value (%s) instead of using the dormant side', + (_, _clearKind, mode, cleared) => { + const values = + mode === 'basic' + ? { resourceSelector: cleared, manualResourceId: 'stale-advanced' } + : { resourceSelector: 'stale-basic', manualResourceId: cleared } + + expect( + resolveDependencyValue('resourceId', values, canonicalIndex, { resourceId: mode }) + ).toEqual(cleared) + } + ) + + it('prefers the exact dependency member before another member in the selected mode', () => { + const values = { + resourceSelector: 'regular-basic', + triggerResourceSelector: null, + manualResourceId: 'stale-advanced', + } + + expect( + resolveDependencyValue('triggerResourceSelector', values, canonicalIndex, { + resourceId: 'basic', + }) + ).toBeNull() + }) + + it('prefers a populated same-mode alias over an empty primary member for a canonical key', () => { + const values = { + resourceSelector: null, + triggerResourceSelector: 'trigger-basic', + manualResourceId: 'stale-advanced', + } + + expect( + resolveDependencyValue('resourceId', values, canonicalIndex, { resourceId: 'basic' }) + ).toBe('trigger-basic') + }) + + it('never falls back to the opposite mode when the selected side is absent', () => { + expect( + resolveDependencyValue('resourceId', { manualResourceId: 'stale-advanced' }, canonicalIndex, { + resourceId: 'basic', + }) + ).toBeUndefined() + }) + + it.each([ + { + name: 'uses a direct value when the selected member is absent', + dependency: 'resourceSelector', + values: { resourceId: 'legacy-direct', manualResourceId: 'inactive-advanced' }, + overrides: { resourceId: 'basic' as const }, + expected: 'legacy-direct', + }, + { + name: 'does not expose an inactive member through its exact key', + dependency: 'manualResourceId', + values: { resourceId: 'legacy-direct', manualResourceId: 'inactive-advanced' }, + overrides: { resourceId: 'basic' as const }, + expected: undefined, + }, + { + name: 'lets an active clear remove a stale direct value', + dependency: 'resourceId', + values: { + resourceId: 'legacy-direct', + resourceSelector: null, + manualResourceId: 'inactive-advanced', + }, + overrides: { resourceId: 'basic' as const }, + expected: null, + }, + { + name: 'lets the active member replace a stale direct value', + dependency: 'resourceId', + values: { + resourceId: 'legacy-direct', + resourceSelector: 'current-basic', + manualResourceId: 'inactive-advanced', + }, + overrides: { resourceId: 'basic' as const }, + expected: 'current-basic', + }, + { + name: 'preserves a direct-only legacy value without a mode', + dependency: 'resourceId', + values: { resourceId: 'legacy-direct' }, + overrides: undefined, + expected: 'legacy-direct', + }, + { + name: 'preserves the legacy direct fallback after an inferred clear', + dependency: 'resourceId', + values: { resourceId: 'legacy-direct', resourceSelector: null }, + overrides: undefined, + expected: 'legacy-direct', + }, + ])('$name', ({ dependency, values, overrides, expected }) => { + expect(resolveDependencyValue(dependency, values, canonicalIndex, overrides)).toEqual(expected) + }) + + it.each([ + ['Basic only', { resourceSelector: 'basic' }, 'basic'], + ['Advanced only', { manualResourceId: 'advanced' }, 'advanced'], + ['both modes populated', { resourceSelector: 'basic', manualResourceId: 'advanced' }, 'basic'], + ['neither mode populated', {}, undefined], + ])('preserves legacy value inference with no explicit mode: %s', (_, values, expected) => { + expect(resolveDependencyValue('resourceId', values, canonicalIndex)).toBe(expected) + }) + + it('preserves legacy inference when the stored override is invalid', () => { + expect( + resolveDependencyValue('resourceId', { manualResourceId: 'advanced' }, canonicalIndex, { + resourceId: 'invalid' as 'basic', + }) + ).toBe('advanced') + }) + + it('uses the real Google Calendar trigger credential despite an action-only mode override', () => { + const triggerIndex = buildCanonicalIndex( + getCanonicalSubBlocksForSurface(GoogleCalendarBlock.subBlocks, true) + ) + + expect( + resolveDependencyValue( + 'triggerCredentials', + { triggerCredentials: 'trigger-credential', manualCredential: 'stale-action-manual' }, + triggerIndex, + { oauthCredential: 'advanced' } + ) + ).toBe('trigger-credential') + }) +}) + +describe('getCanonicalSubBlocksForSurface', () => { + it.each([ + ['action', false, ['resourceSelector', 'manualResourceId']], + ['trigger', true, ['triggerResourceSelector', 'triggerManualResourceId']], + ])('keeps only the %s canonical aliases', (_, triggerSurface, expectedIds) => { + const subBlocks = [ + ...Object.keys(canonicalIndex.canonicalIdBySubBlockId).map((id) => ({ + id, + type: 'short-input' as const, + canonicalParamId: 'resourceId', + mode: id.startsWith('trigger') + ? id.includes('Manual') + ? ('trigger-advanced' as const) + : ('trigger' as const) + : id.includes('manual') + ? ('advanced' as const) + : ('basic' as const), + })), + { id: 'triggerConfig', type: 'trigger-config' as const }, + { id: 'operation', type: 'dropdown' as const }, + ] + + expect( + getCanonicalSubBlocksForSurface(subBlocks, triggerSurface).map((subBlock) => subBlock.id) + ).toEqual(triggerSurface ? [...expectedIds, 'triggerConfig'] : [...expectedIds, 'operation']) + }) +}) + +describe('table trigger column options', () => { + afterEach(() => { + tableTriggerMocks.fetchQuery.mockReset() + useWorkflowRegistry.setState({ + activeWorkflowId: null, + hydration: { + phase: 'idle', + workspaceId: null, + workflowId: null, + requestId: null, + error: null, + }, + }) + useSubBlockStore.setState({ workflowValues: {} }) + useWorkflowStore.setState({ blocks: {} }) + }) + + it('does not use a dormant Advanced table after the explicit Basic value is cleared', async () => { + const workflowId = 'workflow-1' + const blockId = 'table-trigger-1' + useWorkflowRegistry.setState({ + activeWorkflowId: workflowId, + hydration: { + phase: 'ready', + workspaceId: 'workspace-1', + workflowId, + requestId: null, + error: null, + }, + }) + useSubBlockStore.setState({ + workflowValues: { + [workflowId]: { + [blockId]: { tableSelector: null, manualTableId: 'dormant-advanced' }, + }, + }, + }) + useWorkflowStore.setState({ + blocks: { + [blockId]: { + data: { canonicalModes: { tableId: 'basic' } }, + } as never, + }, + }) + tableTriggerMocks.fetchQuery.mockResolvedValue([ + { id: 'dormant-advanced', schema: { columns: [{ name: 'should-not-load' }] } }, + ]) + + const fetchOptions = tableNewRowTrigger.subBlocks.find( + (subBlock) => subBlock.id === 'watchColumns' + )?.fetchOptions + + expect(fetchOptions).toBeDefined() + await expect(fetchOptions?.(blockId)).resolves.toEqual([]) + expect(tableTriggerMocks.fetchQuery).not.toHaveBeenCalled() + }) +}) + describe('evaluateSubBlockCondition', () => { describe('simple value matching', () => { it.concurrent('returns true when field value matches condition value', () => { @@ -237,6 +510,24 @@ describe('scopeCanonicalModesForTool', () => { expect(scopeCanonicalModesForTool(overrides, 0, 'table')).toEqual({ tableId: 'basic' }) }) + it.concurrent( + 'merges a legacy tool baseline with index-scoped overrides and lets the index win collisions', + () => { + const overrides = { + 'table:tableId': 'advanced' as const, + 'table:databaseId': 'basic' as const, + '0:tableId': 'basic' as const, + '0:schemaId': 'advanced' as const, + } + + expect(scopeCanonicalModesForTool(overrides, 0, 'table')).toEqual({ + tableId: 'basic', + databaseId: 'basic', + schemaId: 'advanced', + }) + } + ) + it.concurrent('does not fall back when no legacyToolType is given', () => { expect(scopeCanonicalModesForTool({ 'table:tableId': 'advanced' }, 0)).toBeUndefined() }) diff --git a/apps/sim/lib/workflows/subblocks/visibility.ts b/apps/sim/lib/workflows/subblocks/visibility.ts index ef588530a03..13e7d650653 100644 --- a/apps/sim/lib/workflows/subblocks/visibility.ts +++ b/apps/sim/lib/workflows/subblocks/visibility.ts @@ -281,10 +281,10 @@ function extractPrefixedModes( * `type` — so that two tool entries of the SAME type (e.g. two Table tools on one Agent block) get * independent canonical modes instead of colliding on a shared `${toolType}:${canonicalId}` key. * - * Falls back to the legacy `${legacyToolType}:` prefix (the pre-instance-scoping format) when no - * index-scoped key matches, so an override saved before this scoping change isn't silently dropped - - * it keeps applying (type-shared, the old behavior) until the user re-toggles it explicitly, at which - * point it's rewritten under the new index-scoped key. + * Uses the legacy `${legacyToolType}:` prefix (the pre-instance-scoping format) as a baseline, so an + * override saved before this scoping change isn't silently dropped. Index-scoped entries override + * that baseline per canonical id, allowing a tool whose modes were only partially rewritten to keep + * the remaining legacy choices until the user re-toggles them. * * Returns `undefined` when there are no overrides, no `toolIndex`, and no legacy match. */ @@ -296,8 +296,9 @@ export function scopeCanonicalModesForTool( if (!overrides) return undefined const scoped = toolIndex !== undefined ? extractPrefixedModes(overrides, `${toolIndex}:`) : undefined - if (scoped) return scoped - return legacyToolType ? extractPrefixedModes(overrides, `${legacyToolType}:`) : undefined + const legacy = legacyToolType ? extractPrefixedModes(overrides, `${legacyToolType}:`) : undefined + if (!scoped) return legacy + return legacy ? { ...legacy, ...scoped } : scoped } const INDEX_SCOPED_KEY = /^(\d+):(.+)$/ @@ -462,6 +463,21 @@ export function shouldUseSubBlockForTriggerModeCanonicalIndex( return isTriggerModeSubBlock(subBlock) || isTriggerConfigSubBlock(subBlock) } +/** + * Project a block's canonical members onto the active action or trigger surface. + * + * Canonical aliases may fall back within the selected surface, but a hidden action/trigger twin + * must never satisfy a dependency on the other surface. + */ +export function getCanonicalSubBlocksForSurface( + subBlocks: SubBlockConfig[], + triggerSurface: boolean +): SubBlockConfig[] { + return triggerSurface + ? subBlocks.filter(shouldUseSubBlockForTriggerModeCanonicalIndex) + : subBlocks.filter((subBlock) => !shouldUseSubBlockForTriggerModeCanonicalIndex(subBlock)) +} + export function isPureTriggerBlockConfig(blockConfig?: TriggerVisibilityBlockConfig): boolean { return Boolean(blockConfig?.triggers?.enabled && blockConfig.category === 'triggers') } @@ -481,7 +497,12 @@ export function isSubBlockVisibleForTriggerMode( } /** - * Resolve the dependency value for a dependsOn key, honoring canonical swaps. + * Resolve a dependency through its canonical Basic/Advanced group. + * + * A valid persisted mode is authoritative: exact member reads preserve explicit clears, canonical + * reads may use another populated member on the same side (notably trigger aliases), and the + * opposite side is never consulted. Without a valid mode, the historical value-based fallback is + * preserved for workflows saved before canonical modes existed. */ export function resolveDependencyValue( dependencyKey: string, @@ -500,6 +521,42 @@ export function resolveDependencyValue( const group = canonicalIndex.groupsById[canonicalId] if (!group) return values[dependencyKey] + const explicitMode = overrides?.[canonicalId] + const hasValidExplicitMode = + (explicitMode === 'basic' && Boolean(group.basicId)) || + (explicitMode === 'advanced' && group.advancedIds.length > 0) + const memberIds = Object.entries(canonicalIndex.canonicalIdBySubBlockId) + .filter(([, memberCanonicalId]) => memberCanonicalId === canonicalId) + .map(([memberId]) => memberId) + + if (hasValidExplicitMode) { + const activeMemberIds = + explicitMode === 'advanced' + ? group.advancedIds + : [group.basicId, ...memberIds.filter((id) => !group.advancedIds.includes(id))].filter( + (id, index, ids): id is string => Boolean(id) && ids.indexOf(id) === index + ) + + if (activeMemberIds.includes(dependencyKey) && Object.hasOwn(values, dependencyKey)) { + return values[dependencyKey] + } + + for (const memberId of activeMemberIds) { + if (Object.hasOwn(values, memberId) && isNonEmptyValue(values[memberId])) { + return values[memberId] + } + } + + for (const memberId of activeMemberIds) { + if (Object.hasOwn(values, memberId)) return values[memberId] + } + + if (dependencyKey === canonicalId || activeMemberIds.includes(dependencyKey)) { + return values[canonicalId] + } + return undefined + } + const { basicValue, advancedValue } = getCanonicalValues(group, values) const mode = resolveCanonicalMode(group, values, overrides) const canonicalResult = diff --git a/apps/sim/providers/utils.test.ts b/apps/sim/providers/utils.test.ts index e2723a0c2ba..1eb7ace7829 100644 --- a/apps/sim/providers/utils.test.ts +++ b/apps/sim/providers/utils.test.ts @@ -1618,6 +1618,12 @@ describe('transformBlockTool multi-instance unique IDs', () => { canonicalParamId: 'tableId', mode: 'advanced', }, + { + id: 'triggerManualTableId', + type: 'short-input', + canonicalParamId: 'tableId', + mode: 'trigger-advanced', + }, ], tools: { access: ['table_query_rows', 'table_insert_row'], @@ -1742,14 +1748,56 @@ describe('transformBlockTool multi-instance unique IDs', () => { expect(result?.id).toBe('table_query_rows_tbl_direct') }) - it('preserves the canonical table id when advanced mode is active', async () => { - const result = await transformTable( - { tableId: 'tbl_advanced', tableSelector: 'tbl_basic' }, - { '0:tableId': 'advanced' }, - 0 - ) - expect(result?.id).toBe('table_query_rows_tbl_advanced') - expect(result?.paramsTransform?.(result.params)).toEqual({ tableId: 'tbl_advanced' }) + it.each([ + [ + 'drops a stale direct id when the selected member is explicitly cleared', + { tableId: 'tbl_stale', tableSelector: 'tbl_inactive', manualTableId: '' }, + { '0:tableId': 'advanced' as const }, + 0, + 'table_query_rows', + {}, + ], + [ + 'does not execute a trigger-only alias as an Agent tool parameter', + { manualTableId: '', triggerManualTableId: 'tbl_trigger' }, + { '0:tableId': 'advanced' as const }, + 0, + 'table_query_rows', + {}, + ], + [ + 'keeps a legacy direct id when only the inactive raw member is present', + { tableId: 'tbl_advanced', tableSelector: 'tbl_inactive' }, + { '0:tableId': 'advanced' as const }, + 0, + 'table_query_rows_tbl_advanced', + { tableId: 'tbl_advanced' }, + ], + [ + 'prefers a selected raw member over a stale direct id', + { + tableId: 'tbl_stale', + tableSelector: 'tbl_current', + manualTableId: 'tbl_inactive', + }, + { '0:tableId': 'basic' as const }, + 0, + 'table_query_rows_tbl_current', + { tableId: 'tbl_current' }, + ], + [ + 'preserves a legacy type-scoped mode when no index-scoped mode exists', + { tableSelector: 'tbl_basic', manualTableId: 'tbl_advanced' }, + { 'table:tableId': 'advanced' as const }, + undefined, + 'table_query_rows_tbl_advanced', + { tableId: 'tbl_advanced' }, + ], + ] as const)('%s', async (_, params, modes, toolIndex, expectedId, expectedParams) => { + const result = await transformTable(params, modes, toolIndex) + + expect(result?.id).toBe(expectedId) + expect(result?.paramsTransform?.(result.params)).toEqual(expectedParams) }) it('falls back to the base tool id when no table is selected', async () => { diff --git a/apps/sim/providers/utils.ts b/apps/sim/providers/utils.ts index 1cb25c7da2d..041540ef02a 100644 --- a/apps/sim/providers/utils.ts +++ b/apps/sim/providers/utils.ts @@ -1,6 +1,5 @@ import { createLogger, type Logger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' -import { omit } from '@sim/utils/object' import type OpenAI from 'openai' import type { BillingAttributionSnapshot } from '@/lib/billing/core/billing-attribution' import { formatCreditCost } from '@/lib/billing/credits/conversion' @@ -17,11 +16,14 @@ import { buildCanonicalIndex, type CanonicalGroup, type CanonicalModeOverrides, + getCanonicalSubBlocksForSurface, isCanonicalPair, resolveActiveCanonicalValue, + resolveCanonicalMode, scopeCanonicalModesForTool, } from '@/lib/workflows/subblocks/visibility' import { assembleCustomBlockInputMapping, isCustomBlockType } from '@/blocks/custom/build-config' +import type { SubBlockConfig } from '@/blocks/types' import { isCustomTool } from '@/executor/constants' import { getComputerUseModels, @@ -509,8 +511,10 @@ export function extractAndParseJSON(content: string): any { * canonical id — like the unique-tool-id suffix below — must resolve it first. * Mode selection mirrors {@link transformBlockTool}'s execution-time * `paramsTransform` so the resolved id matches the params the tool actually runs - * with. When the active selector has no value, the original canonical value is - * preserved for direct-id callers and nested tools in advanced mode. + * with. A direct canonical value is preserved for legacy callers unless the + * selected mode has a modern selector/manual member key. An inactive raw member + * cannot displace that legacy value, while a present selected member remains + * authoritative even when it was explicitly cleared. * * @returns The params with canonical resource ids resolved (non-destructive) */ @@ -525,11 +529,16 @@ function resolveCanonicalResourceParams( // Route through the canonical SOT: an explicit scoped override wins, else the value heuristic - // no `?? 'basic'` (which ignored an advanced-only value when basic was empty). const explicitMode = scopedCanonicalModes?.[group.canonicalId] - const chosen = resolveActiveCanonicalValue( - group, - params, - explicitMode ? { [group.canonicalId]: explicitMode } : undefined + const overrides = explicitMode ? { [group.canonicalId]: explicitMode } : undefined + const activeMode = resolveCanonicalMode(group, params, overrides) + const activeSourceIds = + activeMode === 'advanced' ? group.advancedIds : group.basicId ? [group.basicId] : [] + const hasActiveCanonicalMember = activeSourceIds.some((sourceId) => + Object.hasOwn(params, sourceId) ) + if (hasActiveCanonicalMember) delete resolved[group.canonicalId] + + const chosen = resolveActiveCanonicalValue(group, params, overrides) if (chosen !== undefined) resolved[group.canonicalId] = chosen } return resolved @@ -777,12 +786,20 @@ export async function transformBlockTool( const userProvidedParams = block.params || {} - const canonicalGroups: CanonicalGroup[] = blockDef?.subBlocks - ? Object.values(buildCanonicalIndex(blockDef.subBlocks).groupsById).filter(isCanonicalPair) - : [] + const allSubBlocks = (blockDef?.subBlocks ?? []) as SubBlockConfig[] + const actionSubBlocks = getCanonicalSubBlocksForSurface(allSubBlocks, false) + const actionSubBlockIds = new Set(actionSubBlocks.map((subBlock) => subBlock.id)) + const inactiveSurfaceIds = allSubBlocks + .filter((subBlock) => !actionSubBlockIds.has(subBlock.id)) + .map((subBlock) => subBlock.id) + const canonicalGroups: CanonicalGroup[] = Object.values( + buildCanonicalIndex(actionSubBlocks).groupsById + ).filter(isCanonicalPair) + const actionUserProvidedParams = { ...userProvidedParams } + for (const subBlockId of inactiveSurfaceIds) delete actionUserProvidedParams[subBlockId] const resolvedResourceParams = resolveCanonicalResourceParams( - userProvidedParams, + actionUserProvidedParams, canonicalGroups, scopedCanonicalModes ) @@ -834,23 +851,31 @@ export async function transformBlockTool( | undefined const blockInputDefs = blockDef?.inputs as Record | undefined - const needsTransform = blockParamsFn || blockInputDefs || canonicalGroups.length > 0 + const needsTransform = + blockParamsFn || blockInputDefs || canonicalGroups.length > 0 || inactiveSurfaceIds.length > 0 const paramsTransform = needsTransform ? (params: Record): Record => { let result = { ...params } + for (const subBlockId of inactiveSurfaceIds) delete result[subBlockId] for (const group of canonicalGroups) { // Route through the canonical SOT: an explicit scoped override wins, else the value // heuristic - no `?? 'basic'` (which dropped an advanced-only value when basic was empty). const explicitMode = scopedCanonicalModes?.[group.canonicalId] - const chosen = resolveActiveCanonicalValue( - group, - result, - explicitMode ? { [group.canonicalId]: explicitMode } : undefined + const overrides = explicitMode ? { [group.canonicalId]: explicitMode } : undefined + const activeMode = resolveCanonicalMode(group, result, overrides) + const activeSourceIds = + activeMode === 'advanced' ? group.advancedIds : group.basicId ? [group.basicId] : [] + const hasActiveCanonicalMember = activeSourceIds.some((sourceId) => + Object.hasOwn(result, sourceId) ) + const chosen = resolveActiveCanonicalValue(group, result, overrides) const sourceIds = [group.basicId, ...group.advancedIds].filter(Boolean) as string[] - result = omit(result, sourceIds) + const keysToRemove = hasActiveCanonicalMember + ? [...sourceIds, group.canonicalId] + : sourceIds + for (const sourceId of keysToRemove) delete result[sourceId] if (chosen !== undefined) { result[group.canonicalId] = chosen diff --git a/apps/sim/serializer/field-analysis.test.ts b/apps/sim/serializer/field-analysis.test.ts index 6123810cde6..bb0dc8aac53 100644 --- a/apps/sim/serializer/field-analysis.test.ts +++ b/apps/sim/serializer/field-analysis.test.ts @@ -7,6 +7,7 @@ */ import { blocksMock, toolsMetadataMock, toolsUtilsMock } from '@sim/testing/mocks' import { describe, expect, it, vi } from 'vitest' +import { GoogleCalendarBlock } from '@/blocks/blocks/google_calendar' const { svcConfig } = vi.hoisted(() => ({ svcConfig: { value: null as any } })) @@ -214,4 +215,84 @@ describe('extractBlockParams', () => { expect(params.credential).toBeUndefined() expect(params.manualCredential).toBeUndefined() }) + + it.each([ + { + name: 'explicit action Basic clear', + overrides: { + data: { canonicalModes: { oauthCredential: 'basic' } }, + }, + values: ['', 'action-advanced', 'dormant-trigger'], + expected: '', + }, + { + name: 'legacy action inference', + overrides: {}, + values: ['', 'action-advanced', 'dormant-trigger'], + expected: 'action-advanced', + }, + { + name: 'trigger surface with an action-only explicit Advanced mode', + overrides: { + triggerMode: true, + data: { canonicalModes: { oauthCredential: 'advanced' } }, + }, + values: ['dormant-action', 'dormant-action-advanced', 'trigger-basic'], + expected: 'trigger-basic', + }, + ])('uses the active Google Calendar surface for $name', ({ overrides, values, expected }) => { + svcConfig.value = GoogleCalendarBlock + const [credential, manualCredential, triggerCredentials] = values + + const params = extractBlockParams( + block({ + type: 'svc', + ...overrides, + subBlocks: { + credential: { value: credential }, + manualCredential: { value: manualCredential }, + triggerCredentials: { value: triggerCredentials }, + }, + }) + ) + + expect(params.oauthCredential).toBe(expected) + expect(params.credential).toBeUndefined() + expect(params.manualCredential).toBeUndefined() + expect(params.triggerCredentials).toBeUndefined() + }) + + it('uses trigger canonical pairs for a pure trigger block without a triggerMode flag', () => { + svcConfig.value = config( + [ + ['actionSelector', 'project-selector', 'basic'], + ['actionManualId', 'short-input', 'advanced'], + ['triggerSelector', 'project-selector', 'trigger'], + ['triggerManualId', 'short-input', 'trigger-advanced'], + ].map(([id, type, mode]) => ({ + id, + type, + canonicalParamId: 'resourceId', + mode, + })), + { category: 'triggers', triggers: { enabled: true } } + ) + + const params = extractBlockParams( + block({ + type: 'svc', + data: { canonicalModes: { resourceId: 'basic' } }, + subBlocks: { + actionSelector: { value: 'dormant-action' }, + actionManualId: { value: 'dormant-action-advanced' }, + triggerSelector: { value: 'trigger-basic' }, + triggerManualId: { value: 'trigger-advanced' }, + }, + }) + ) + + expect(params.resourceId).toBe('trigger-basic') + expect(params.triggerSelector).toBeUndefined() + expect(params.triggerManualId).toBeUndefined() + }) }) diff --git a/apps/sim/serializer/index.ts b/apps/sim/serializer/index.ts index 5c4a6a53dc4..fc12fc4c87a 100644 --- a/apps/sim/serializer/index.ts +++ b/apps/sim/serializer/index.ts @@ -8,9 +8,11 @@ import { buildCanonicalIndex, buildSubBlockValues, evaluateSubBlockCondition, + getCanonicalSubBlocksForSurface, getCanonicalValues, isCanonicalPair, isNonEmptyValue, + isPureTriggerBlockConfig, isSubBlockHidden, isToolInputOnlySubBlock, resolveCanonicalMode, @@ -511,7 +513,12 @@ export function extractBlockParams(block: BlockState): Record { isCustomBlock && blockConfig.subBlocks.some((config) => !RESERVED_PARAMS.has(config.id)) const isTriggerContext = block.triggerMode ?? false const isTriggerCategory = blockConfig.category === 'triggers' - const canonicalIndex = buildCanonicalIndex(blockConfig.subBlocks) + const canonicalIndex = buildCanonicalIndex( + getCanonicalSubBlocksForSurface( + blockConfig.subBlocks, + isTriggerContext || isPureTriggerBlockConfig(blockConfig) + ) + ) const allValues = buildSubBlockValues(block.subBlocks) Object.entries(block.subBlocks).forEach(([id, subBlock]) => { @@ -587,7 +594,7 @@ export function extractBlockParams(block: BlockState): Record { const { basicValue, advancedValue } = getCanonicalValues(group, params) const hasExplicitOverride = canonicalModeOverrides?.[group.canonicalId] != null const pairMode = - hasExplicitOverride || !legacyAdvancedMode + hasExplicitOverride || !legacyAdvancedMode || !isCanonicalPair(group) ? resolveCanonicalMode(group, allValues, canonicalModeOverrides) : 'advanced' const chosen = pairMode === 'advanced' ? advancedValue : basicValue @@ -648,7 +655,12 @@ export function collectBlockFieldIssues( const displayAdvancedOptions = block.advancedMode ?? false const isTriggerContext = block.triggerMode ?? false const isTriggerCategory = blockConfig.category === 'triggers' - const canonicalIndex = buildCanonicalIndex(blockConfig.subBlocks || []) + const canonicalIndex = buildCanonicalIndex( + getCanonicalSubBlocksForSurface( + blockConfig.subBlocks || [], + isTriggerContext || isPureTriggerBlockConfig(blockConfig) + ) + ) const canonicalModeOverrides = block.data?.canonicalModes const allValues = buildSubBlockValues(block.subBlocks) diff --git a/apps/sim/tools/params-resolver.test.ts b/apps/sim/tools/params-resolver.test.ts index 7adcfc50be0..2018f9a58d6 100644 --- a/apps/sim/tools/params-resolver.test.ts +++ b/apps/sim/tools/params-resolver.test.ts @@ -42,4 +42,47 @@ describe('buildPreviewContextValues', () => { }) expect(result.knowledgeBaseId).toBe('kb-basic') }) + + it.each([ + ['basic', { knowledgeBaseSelector: null, manualKnowledgeBaseId: 'stale-advanced' }, null], + ['advanced', { knowledgeBaseSelector: 'stale-basic', manualKnowledgeBaseId: '' }, ''], + ] as const)( + 'keeps a cleared %s value instead of previewing the dormant mode', + (mode, clearedValues, expected) => { + const result = buildPreviewContextValues(clearedValues, { + blockType: 'knowledge', + subBlocks: [], + canonicalIndex, + values: clearedValues, + overrides: { knowledgeBaseId: mode }, + }) + + expect(result.knowledgeBaseId).toBe(expected) + } + ) + + it('drops a stale direct canonical parameter when a modern member is explicitly cleared', () => { + const params = { knowledgeBaseId: 'legacy-direct', knowledgeBaseSelector: null } + const result = buildPreviewContextValues(params, { + blockType: 'knowledge', + subBlocks: [], + canonicalIndex, + values: params, + overrides: { knowledgeBaseId: 'basic' }, + }) + + expect(result.knowledgeBaseId).toBeNull() + }) + + it('preserves the legacy direct fallback when no mode has been persisted', () => { + const params = { knowledgeBaseId: 'legacy-direct', knowledgeBaseSelector: null } + const result = buildPreviewContextValues(params, { + blockType: 'knowledge', + subBlocks: [], + canonicalIndex, + values: params, + }) + + expect(result.knowledgeBaseId).toBe('legacy-direct') + }) }) diff --git a/apps/sim/tools/params-resolver.ts b/apps/sim/tools/params-resolver.ts index d0bd4e83097..c6114c61abb 100644 --- a/apps/sim/tools/params-resolver.ts +++ b/apps/sim/tools/params-resolver.ts @@ -3,7 +3,7 @@ import { type CanonicalIndex, type CanonicalModeOverrides, evaluateSubBlockCondition, - getCanonicalValues, + getCanonicalSubBlocksForSurface, isCanonicalPair, reindexToolCanonicalModes, resolveCanonicalMode, @@ -18,6 +18,7 @@ export { type CanonicalIndex, type CanonicalModeOverrides, evaluateSubBlockCondition, + getCanonicalSubBlocksForSurface, isCanonicalPair, reindexToolCanonicalModes, resolveCanonicalMode, @@ -50,10 +51,12 @@ export function buildPreviewContextValues( for (const [canonicalId, group] of Object.entries(context.canonicalIndex.groupsById)) { if (isCanonicalPair(group)) { - const mode = resolveCanonicalMode(group, context.values, context.overrides) - const { basicValue, advancedValue } = getCanonicalValues(group, context.values) - result[canonicalId] = - mode === 'advanced' ? (advancedValue ?? basicValue) : (basicValue ?? advancedValue) + result[canonicalId] = resolveDependencyValue( + canonicalId, + context.values, + context.canonicalIndex, + context.overrides + ) } } diff --git a/apps/sim/tools/params.ts b/apps/sim/tools/params.ts index 08b3a8b31a6..f73f1ce57a9 100644 --- a/apps/sim/tools/params.ts +++ b/apps/sim/tools/params.ts @@ -4,6 +4,7 @@ import { buildCanonicalIndex, type CanonicalModeOverrides, evaluateSubBlockCondition, + getCanonicalSubBlocksForSurface, isCanonicalPair, isSubBlockFeatureEnabled, isSubBlockHidden, @@ -1049,7 +1050,8 @@ export function getSubBlocksForToolInput( } const allSubBlocks = blockConfig.subBlocks as BlockSubBlockConfig[] - const canonicalIndex = buildCanonicalIndex(allSubBlocks) + const actionSubBlocks = getCanonicalSubBlocksForSurface(allSubBlocks, false) + const canonicalIndex = buildCanonicalIndex(actionSubBlocks) // Build values for condition evaluation const values = currentValues || {} @@ -1072,7 +1074,7 @@ export function getSubBlocksForToolInput( const filtered: BlockSubBlockConfig[] = [] - for (const sb of allSubBlocks) { + for (const sb of actionSubBlocks) { // Skip excluded types if (EXCLUDED_SUBBLOCK_TYPES.has(sb.type)) continue @@ -1147,7 +1149,7 @@ export function getSubBlocksForToolInput( const mode = resolveCanonicalMode(group, valuesWithOperation, canonicalModeOverrides) if (mode === 'advanced') { // Find the advanced variant - const advancedSb = allSubBlocks.find((s) => group.advancedIds.includes(s.id)) + const advancedSb = actionSubBlocks.find((s) => group.advancedIds.includes(s.id)) if (advancedSb) { filtered.push({ ...advancedSb, paramVisibility: visibility }) } @@ -1156,7 +1158,7 @@ export function getSubBlocksForToolInput( if (group.basicId === sb.id) { filtered.push({ ...sb, paramVisibility: visibility }) } else { - const basicSb = allSubBlocks.find((s) => s.id === group.basicId) + const basicSb = actionSubBlocks.find((s) => s.id === group.basicId) if (basicSb) { filtered.push({ ...basicSb, paramVisibility: visibility }) } diff --git a/apps/sim/triggers/editor-state.ts b/apps/sim/triggers/editor-state.ts index 0480039ec72..2b6c306aee2 100644 --- a/apps/sim/triggers/editor-state.ts +++ b/apps/sim/triggers/editor-state.ts @@ -39,6 +39,14 @@ export async function readBlockValues( return useSubBlockStore.getState().workflowValues[activeWorkflowId]?.[blockId] } +/** The persisted Basic/Advanced choices for `blockId` in the open workflow. */ +export async function readBlockCanonicalModes( + blockId: string +): Promise | undefined> { + const { useWorkflowStore } = await import('@/stores/workflows/workflow/store') + return useWorkflowStore.getState().blocks[blockId]?.data?.canonicalModes +} + /** The active workspace's workflows, for trigger sub-blocks that select other workflows. */ export async function readWorkspaceWorkflowOptions(options?: { excludeActiveWorkflow?: boolean diff --git a/apps/sim/triggers/table/poller.ts b/apps/sim/triggers/table/poller.ts index 8a35e886426..d41da2e1c57 100644 --- a/apps/sim/triggers/table/poller.ts +++ b/apps/sim/triggers/table/poller.ts @@ -2,18 +2,31 @@ import { TableIcon } from '@/components/icons' import { requestJson } from '@/lib/api/client/request' import { listTablesContract } from '@/lib/api/contracts/tables' import type { TableDefinition } from '@/lib/table' +import { buildCanonicalIndex, resolveDependencyValue } from '@/lib/workflows/subblocks/visibility' import { getQueryClient } from '@/app/_shell/providers/get-query-client' import { tableKeys } from '@/hooks/queries/utils/table-keys' -import { readActiveWorkflowContext, readBlockValues } from '@/triggers/editor-state' +import { + readActiveWorkflowContext, + readBlockCanonicalModes, + readBlockValues, +} from '@/triggers/editor-state' import type { TriggerConfig } from '@/triggers/types' async function fetchTableColumns(blockId: string): Promise> { const { activeWorkflowId, workspaceId } = await readActiveWorkflowContext() if (!activeWorkflowId || !workspaceId) return [] - const blockValues = await readBlockValues(blockId) - const tableId = (blockValues?.tableSelector as string) || (blockValues?.manualTableId as string) - if (!tableId) return [] + const [blockValues, canonicalModes] = await Promise.all([ + readBlockValues(blockId), + readBlockCanonicalModes(blockId), + ]) + const tableId = resolveDependencyValue( + 'tableId', + blockValues ?? {}, + TABLE_TRIGGER_CANONICAL_INDEX, + canonicalModes + ) + if (typeof tableId !== 'string' || !tableId) return [] const tables = await getQueryClient().fetchQuery({ queryKey: tableKeys.list(workspaceId), @@ -158,3 +171,5 @@ export const tableNewRowTrigger: TriggerConfig = { }, }, } + +const TABLE_TRIGGER_CANONICAL_INDEX = buildCanonicalIndex(tableNewRowTrigger.subBlocks)