diff --git a/apps/docs/components/ui/icon-mapping.ts b/apps/docs/components/ui/icon-mapping.ts index c3d7d42fce5..bf4284b3408 100644 --- a/apps/docs/components/ui/icon-mapping.ts +++ b/apps/docs/components/ui/icon-mapping.ts @@ -117,6 +117,7 @@ import { HexIcon, HubspotIcon, HuggingFaceIcon, + HumanInTheLoopIcon, HunterIOIcon, IAMIcon, IcypeasIcon, @@ -405,6 +406,8 @@ export const blockTypeToIconMap: Record = { hex: HexIcon, hubspot: HubspotIcon, huggingface: HuggingFaceIcon, + human_in_the_loop: HumanInTheLoopIcon, + human_in_the_loop_v2: HumanInTheLoopIcon, hunter: HunterIOIcon, iam: IAMIcon, icypeas: IcypeasIcon, diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/checkbox-list/checkbox-list.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/checkbox-list/checkbox-list.tsx index 57cb912e660..7618767f066 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/checkbox-list/checkbox-list.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/checkbox-list/checkbox-list.tsx @@ -1,9 +1,11 @@ +import { useCallback } from 'react' import { Checkbox, Label, Tooltip } from '@sim/emcn' import { CircleInfo } from '@sim/emcn/icons' import { formatDisplayText } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/formatted-text' import { getWorkflowSearchLabelHighlight } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/workflow-search-highlight' import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-value' import { useActiveSearchTarget } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/providers/active-search-target-provider' +import { useSubBlockStore } from '@/stores/workflows/subblock/store' interface CheckboxListOption { label: string @@ -21,81 +23,25 @@ interface CheckboxListProps { disabled?: boolean } -interface CheckboxItemProps { - blockId: string - subBlockId: string - option: CheckboxListOption - index: number - isPreview: boolean - subBlockValues?: Record - disabled: boolean +/** The stored selections, tolerating the `null` a never-touched field holds. */ +function readSelections(value: unknown): Record { + if (!value || typeof value !== 'object' || Array.isArray(value)) return {} + return value as Record } /** - * Individual checkbox item component that calls useSubBlockValue hook at top level. + * A group of boolean options collected under one field. * - * @remarks - * A `null` store value means the user has never toggled the checkbox, in which - * case we fall back to `option.defaultChecked` for the displayed state. Any - * explicit boolean (including `false`) takes precedence over the default. + * The whole group is stored as a single `{ optionId: boolean }` record under the + * sub-block's OWN id — the same one-sub-block-one-store-key rule every other control + * follows. Each option id is a tool param name; `expandSubBlockValueToParams` performs + * that projection at the two boundaries where sub-block values become tool params. + * + * Writing each option id as its own top-level store key (what this did before) meant the + * sub-block wrote keys the block never declared: the canvas serializer dropped all of + * them, and inside an agent tool row the writes landed on the parent agent block instead + * of the tool's own params. */ -function CheckboxItem({ - blockId, - subBlockId, - option, - index, - isPreview, - subBlockValues, - disabled, -}: CheckboxItemProps) { - const activeSearchTarget = useActiveSearchTarget() - const [storeValue, setStoreValue] = useSubBlockValue(blockId, option.id) - const workflowSearchHighlight = getWorkflowSearchLabelHighlight({ - activeSearchTarget, - blockId, - subBlockId, - valuePath: ['options', index], - label: option.label, - }) - - const previewValue = isPreview && subBlockValues ? subBlockValues[option.id]?.value : undefined - const rawValue = isPreview ? previewValue : storeValue - const effectiveValue = rawValue ?? option.defaultChecked ?? false - - const handleChange = (checked: boolean) => { - if (!isPreview && !disabled) { - setStoreValue(checked) - } - } - - return ( -
- - - {option.description && ( - - - - - -

{option.description}

-
-
- )} -
- ) -} - export function CheckboxList({ blockId, subBlockId, @@ -104,20 +50,67 @@ export function CheckboxList({ subBlockValues, disabled = false, }: CheckboxListProps) { + const activeSearchTarget = useActiveSearchTarget() + const [storeValue, setStoreValue] = useSubBlockValue>(blockId, subBlockId) + + const previewValue = isPreview && subBlockValues ? subBlockValues[subBlockId]?.value : undefined + const selections = readSelections(isPreview ? previewValue : storeValue) + + const handleChange = useCallback( + (optionId: string, checked: boolean) => { + if (isPreview || disabled) return + // Merge onto the value the STORE holds right now, not the one captured at render. + // Every option in the group shares one key, so two toggles landing before React + // rerenders would otherwise both build from the same stale record and the second + // write would drop the first. The store updates synchronously, so reading it here + // always sees the preceding toggle. + const current = useSubBlockStore.getState().getValue(blockId, subBlockId) + setStoreValue({ ...readSelections(current), [optionId]: checked }) + }, + [blockId, subBlockId, setStoreValue, isPreview, disabled] + ) + return (
- {options.map((option, index) => ( - - ))} + {options.map((option, index) => { + // A `null`/absent entry means the user has never toggled this option, so the + // declared default decides. An explicit `false` always wins over the default. + const checked = selections[option.id] ?? option.defaultChecked ?? false + const workflowSearchHighlight = getWorkflowSearchLabelHighlight({ + activeSearchTarget, + blockId, + subBlockId, + valuePath: ['options', index], + label: option.label, + }) + + return ( +
+ handleChange(option.id, Boolean(next))} + disabled={isPreview || disabled} + /> + + {option.description && ( + + + + + +

{option.description}

+
+
+ )} +
+ ) + })}
) } diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/index.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/index.ts index 362fc0c79d9..135575edcc8 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/index.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/index.ts @@ -38,5 +38,6 @@ export { Text } from './text' export { TimeInput } from './time-input' export { ToolInput } from './tool-input' export { VariablesInput } from './variables-input' +export { WorkflowInputMapper } from './workflow-input-mapper' export { WorkflowOutputSelector } from './workflow-output-selector' export { WorkflowSelectorInput } from './workflow-selector' diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/mcp-dynamic-args/mcp-dynamic-args.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/mcp-dynamic-args/mcp-dynamic-args.tsx index 08addbf7ab4..46d82cc8b87 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/mcp-dynamic-args/mcp-dynamic-args.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/mcp-dynamic-args/mcp-dynamic-args.tsx @@ -11,6 +11,11 @@ import { resolvePreviewContextValue } from '@/app/workspace/[workspaceId]/w/[wor import { useActiveSearchTarget } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/providers/active-search-target-provider' import type { SubBlockConfig } from '@/blocks/types' import { useMcpTools } from '@/hooks/mcp/use-mcp-tools' +import { + type JsonSchemaProperty, + jsonSchemaType, + subBlockTypeForJsonSchema, +} from '@/tools/param-shape' import { formatParameterLabel } from '@/tools/params' const logger = createLogger('McpDynamicArgs') @@ -36,8 +41,8 @@ function isPrimitiveEnum( */ function requiresJsonValue(paramSchema: any): boolean { return ( - paramSchema.type === 'object' || - paramSchema.type === 'array' || + jsonSchemaType(paramSchema) === 'object' || + jsonSchemaType(paramSchema) === 'array' || (Array.isArray(paramSchema.enum) && !isPrimitiveEnum(paramSchema.enum)) ) } @@ -81,7 +86,7 @@ function createParamConfig( inputType: 'long-input' | 'short-input' ): SubBlockConfig { const placeholder = - paramSchema.type === 'array' + jsonSchemaType(paramSchema) === 'array' ? `Enter JSON array, e.g. ["item1", "item2"] or comma-separated values` : paramSchema.description || `Enter ${formatParameterLabel(paramName).toLowerCase()}` @@ -215,24 +220,17 @@ export function McpDynamicArgs({ [currentArgs, setToolArgs, disabled] ) - const getInputType = (paramSchema: any) => { - if (Array.isArray(paramSchema.enum)) { - return isPrimitiveEnum(paramSchema.enum) ? 'dropdown' : 'long-input' - } - if (paramSchema.type === 'boolean') return 'switch' - if (paramSchema.type === 'number' || paramSchema.type === 'integer') { - if (paramSchema.minimum !== undefined && paramSchema.maximum !== undefined) { - return 'slider' - } - return 'short-input' - } - if (paramSchema.type === 'string') { - if (paramSchema.format === 'date-time') return 'short-input' - if (paramSchema.maxLength && paramSchema.maxLength > 100) return 'long-input' - return 'short-input' - } - if (paramSchema.type === 'array' || paramSchema.type === 'object') return 'long-input' - return 'short-input' + /** + * Which control collects a schema property, decided by the shared map so an MCP tool + * renders the same way here as it does in an agent block's tool row. + * + * `code` maps onto this surface's `long-input`: that branch carries JSON-draft + * handling built for storing every argument in one object, which a plain code editor + * would not preserve. The control differs; the decision does not. + */ + const getInputType = (paramSchema: JsonSchemaProperty) => { + const type = subBlockTypeForJsonSchema(paramSchema) + return type === 'code' ? 'long-input' : type } const renderParameterInput = (paramName: string, paramSchema: any) => { @@ -264,7 +262,12 @@ export function McpDynamicArgs({ label: String(option), value: String(option), })) - const selectedLabel = value ? String(value) : '' + // Options are stringified members, so a decoded value has to be stringified back + // to match one. Presence of the key — not truthiness — decides whether anything is + // selected, because `0`, `false` and a literal `null` enum member are all real + // selections that would otherwise render as empty. + const dropdownValue = Object.hasOwn(current, paramName) ? String(value) : '' + const selectedLabel = dropdownValue const workflowSearchHighlight = getWorkflowSearchLabelHighlight({ activeSearchTarget, blockId, @@ -277,14 +280,18 @@ export function McpDynamicArgs({
{ - const matchedOption = dropdownOptions.find( - (opt: { label: string; value: string }) => opt.value === selectedValue + // Persist the ENUM MEMBER, not the string the combobox works in. The + // options are stringified for display, so writing `selectedValue` back + // would send '1' for `1`, 'true' for `true` and 'null' for `null` — the + // server then rejects the argument or reads it as a different value. + const memberIndex = (paramSchema.enum as unknown[]).findIndex( + (member) => String(member) === selectedValue ) - if (matchedOption) { - updateParameter(paramName, selectedValue) + if (memberIndex !== -1) { + updateParameter(paramName, (paramSchema.enum as unknown[])[memberIndex]) } }} placeholder={`Select ${formatParameterLabel(paramName).toLowerCase()}`} @@ -315,11 +322,11 @@ export function McpDynamicArgs({ value={[currentValue]} min={minValue} max={maxValue} - step={paramSchema.type === 'integer' ? 1 : 0.1} + step={jsonSchemaType(paramSchema) === 'integer' ? 1 : 0.1} onValueChange={(newValue) => updateParameter( paramName, - paramSchema.type === 'integer' ? Math.round(newValue[0]) : newValue[0] + jsonSchemaType(paramSchema) === 'integer' ? Math.round(newValue[0]) : newValue[0] ) } disabled={disabled} @@ -333,7 +340,7 @@ export function McpDynamicArgs({ top: '24px', }} > - {paramSchema.type === 'integer' + {jsonSchemaType(paramSchema) === 'integer' ? Math.round(currentValue).toString() : Number(currentValue).toFixed(1)}
@@ -383,7 +390,7 @@ export function McpDynamicArgs({ updateParameter(paramName, JSON.parse(newValue)) clearDraft() } catch { - if (paramSchema.type === 'array' && !looksLikeJsonLiteral(newValue)) { + if (jsonSchemaType(paramSchema) === 'array' && !looksLikeJsonLiteral(newValue)) { updateParameter(paramName, newValue) clearDraft() return @@ -406,7 +413,8 @@ export function McpDynamicArgs({ paramSchema.format === 'password' || paramName.toLowerCase().includes('password') || paramName.toLowerCase().includes('token') - const isNumeric = paramSchema.type === 'number' || paramSchema.type === 'integer' + const numericType = jsonSchemaType(paramSchema) + const isNumeric = numericType === 'number' || numericType === 'integer' const config = createParamConfig(paramName, paramSchema, 'short-input') return ( @@ -424,7 +432,7 @@ export function McpDynamicArgs({ if (isNumeric && processedValue !== '' && !hasTag) { processedValue = - paramSchema.type === 'integer' + jsonSchemaType(paramSchema) === 'integer' ? Number.parseInt(processedValue) : Number.parseFloat(processedValue) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tag-dropdown/tag-dropdown.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tag-dropdown/tag-dropdown.tsx index a3cdb695183..4e1c2f9f0e6 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tag-dropdown/tag-dropdown.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tag-dropdown/tag-dropdown.tsx @@ -30,7 +30,7 @@ import { useWorkflowReferenceScope } from '@/app/workspace/[workspaceId]/w/[work import { getBlock } from '@/blocks' import { BlockTile } from '@/blocks/block-tile' import type { BlockConfig } from '@/blocks/types' -import { normalizeName } from '@/executor/constants' +import { isHumanInTheLoopBlock, normalizeName } from '@/executor/constants' import type { Variable } from '@/stores/variables/types' import type { BlockState } from '@/stores/workflows/workflow/types' @@ -1208,8 +1208,8 @@ export const TagDropdown: React.FC = ({ if (!accessibleBlock) continue // Skip the current block - blocks cannot reference their own outputs - // Exception: human_in_the_loop blocks can reference their own outputs (url, resumeEndpoint) - if (accessibleBlockId === blockId && accessibleBlock.type !== 'human_in_the_loop') continue + // Exception: Human blocks can reference their own outputs (url, resumeEndpoint) + if (accessibleBlockId === blockId && !isHumanInTheLoopBlock(accessibleBlock.type)) continue const blockConfig = getBlock(accessibleBlock.type) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/components/tools/credential-selector.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/components/tools/credential-selector.tsx deleted file mode 100644 index e30cfca4d5e..00000000000 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/components/tools/credential-selector.tsx +++ /dev/null @@ -1,317 +0,0 @@ -'use client' - -import { useCallback, useEffect, useMemo, useRef, useState } from 'react' -import { Button, Combobox } from '@sim/emcn' -import { SquareArrowUpRight } from '@sim/emcn/icons' -import { useParams } from 'next/navigation' -import { consumeOAuthReturnContext, writeOAuthReturnContext } from '@/lib/credentials/client-state' -import { - getCanonicalScopesForProvider, - getProviderIdFromServiceId, - getServiceConfigByProviderId, - OAUTH_PROVIDERS, - type OAuthProvider, - type OAuthService, - parseProvider, -} from '@/lib/oauth' -import { getMissingRequiredScopes } from '@/lib/oauth/utils' -import { ConnectOAuthModal } from '@/app/workspace/[workspaceId]/components/connect-oauth-modal' -import { formatDisplayText } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/formatted-text' -import { getWorkflowSearchLabelHighlight } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/workflow-search-highlight' -import { useActiveSearchTarget } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/providers/active-search-target-provider' -import { BrandIcon } from '@/blocks/brand-icon' -import { useWorkspaceCredential } from '@/hooks/queries/credentials' -import { useOAuthCredentials } from '@/hooks/queries/oauth/oauth-credentials' -import { useWorkflowMap } from '@/hooks/queries/workflows' -import { useCredentialRefreshTriggers } from '@/hooks/use-credential-refresh-triggers' -import { useWorkflowRegistry } from '@/stores/workflows/registry/store' - -const getProviderIcon = (providerName: OAuthProvider) => { - const { baseProvider } = parseProvider(providerName) - const baseProviderConfig = OAUTH_PROVIDERS[baseProvider] - - if (!baseProviderConfig) { - return - } - return -} - -const getProviderName = (providerName: OAuthProvider) => { - const serviceConfig = getServiceConfigByProviderId(providerName) - if (serviceConfig) { - return serviceConfig.name - } - - const { baseProvider } = parseProvider(providerName) - const baseProviderConfig = OAUTH_PROVIDERS[baseProvider] - - if (baseProviderConfig) { - return baseProviderConfig.name - } - - return providerName - .split('-') - .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) - .join(' ') -} - -interface ToolCredentialSelectorProps { - blockId: string - subBlockId: string - value: string - onChange: (value: string) => void - provider: OAuthProvider - requiredScopes?: string[] - label?: string - serviceId: OAuthService - disabled?: boolean -} - -const EMPTY_SCOPES: string[] = [] - -export function ToolCredentialSelector({ - blockId, - subBlockId, - value, - onChange, - provider, - requiredScopes = EMPTY_SCOPES, - label, - serviceId, - disabled = false, -}: ToolCredentialSelectorProps) { - const activeSearchTarget = useActiveSearchTarget() - const params = useParams() - const workspaceId = (params?.workspaceId as string) || '' - const onChangeRef = useRef(onChange) - onChangeRef.current = onChange - const [showConnectModal, setShowConnectModal] = useState(false) - const [showOAuthModal, setShowOAuthModal] = useState(false) - const [editingInputValue, setEditingInputValue] = useState('') - const [isEditing, setIsEditing] = useState(false) - const activeWorkflowId = useWorkflowRegistry((s) => s.activeWorkflowId) - const { data: workflowMap = {} } = useWorkflowMap(workspaceId) - const effectiveWorkflowId = - activeWorkflowId && workflowMap[activeWorkflowId] ? activeWorkflowId : undefined - - const selectedId = value || '' - const effectiveLabel = label || `Select ${getProviderName(provider)} account` - - const effectiveProviderId = useMemo(() => getProviderIdFromServiceId(serviceId), [serviceId]) - - const { - data: credentials = [], - isFetching: credentialsLoading, - refetch: refetchCredentials, - } = useOAuthCredentials(effectiveProviderId, { - enabled: Boolean(effectiveProviderId), - workspaceId, - workflowId: effectiveWorkflowId, - }) - - const selectedCredential = useMemo( - () => credentials.find((cred) => cred.id === selectedId), - [credentials, selectedId] - ) - - const { data: inaccessibleCredential } = useWorkspaceCredential( - selectedId || undefined, - Boolean(selectedId) && !selectedCredential && !credentialsLoading && Boolean(workspaceId) - ) - const inaccessibleCredentialName = inaccessibleCredential?.displayName ?? null - - const resolvedLabel = useMemo(() => { - if (selectedCredential) return selectedCredential.name - if (inaccessibleCredentialName) return inaccessibleCredentialName - return '' - }, [selectedCredential, inaccessibleCredentialName]) - - const inputValue = isEditing ? editingInputValue : resolvedLabel - - useCredentialRefreshTriggers(refetchCredentials, effectiveProviderId, workspaceId) - - const handleOpenChange = useCallback( - (isOpen: boolean) => { - if (isOpen) { - void refetchCredentials() - } - }, - [refetchCredentials] - ) - - const hasSelection = Boolean(selectedCredential) - const missingRequiredScopes = hasSelection - ? getMissingRequiredScopes(selectedCredential!, requiredScopes || []) - : [] - - const needsUpdate = - hasSelection && missingRequiredScopes.length > 0 && !disabled && !credentialsLoading - - useEffect(() => { - if (showOAuthModal && selectedId && !selectedCredential && !credentialsLoading) { - consumeOAuthReturnContext() - setShowOAuthModal(false) - } - }, [showOAuthModal, selectedId, selectedCredential, credentialsLoading]) - - const handleSelect = useCallback( - (credentialId: string) => { - onChange(credentialId) - setIsEditing(false) - }, - [onChange] - ) - - const handleAddCredential = useCallback(() => { - setShowConnectModal(true) - }, []) - - const comboboxOptions = useMemo(() => { - const options = credentials.map((cred) => ({ - label: cred.name, - value: cred.id, - })) - - options.push({ - label: - credentials.length > 0 - ? `Connect another ${getProviderName(provider)} account` - : `Connect ${getProviderName(provider)} account`, - value: '__connect_account__', - }) - - return options - }, [credentials, provider]) - - const selectedCredentialProvider = selectedCredential?.provider ?? provider - const workflowSearchHighlight = getWorkflowSearchLabelHighlight({ - activeSearchTarget, - blockId, - subBlockId, - valuePath: [], - label: inputValue, - }) - - const overlayContent = useMemo(() => { - if (!inputValue) return null - - return ( -
-
- {getProviderIcon(selectedCredentialProvider)} -
- - {formatDisplayText(inputValue, { workflowSearchHighlight })} - -
- ) - }, [inputValue, selectedCredentialProvider, workflowSearchHighlight]) - - const handleComboboxChange = useCallback( - (newValue: string) => { - if (newValue === '__connect_account__') { - handleAddCredential() - return - } - - const matchedCred = credentials.find((c) => c.id === newValue) - if (matchedCred) { - handleSelect(newValue) - return - } - - setIsEditing(true) - setEditingInputValue(newValue) - }, - [credentials, handleAddCredential, handleSelect] - ) - - return ( -
- - - {needsUpdate && ( -
-
- - Additional permissions required -
- -
- )} - - {showConnectModal && ( - !open && setShowConnectModal(false)} - provider={provider} - serviceId={serviceId} - providerId={effectiveProviderId} - requiredScopes={getCanonicalScopesForProvider(effectiveProviderId)} - workspaceId={workspaceId} - workflowId={effectiveWorkflowId || ''} - /> - )} - - {showOAuthModal && selectedCredential && ( - { - if (!open) { - consumeOAuthReturnContext() - setShowOAuthModal(false) - } - }} - provider={provider} - toolName={getProviderName(provider)} - requiredScopes={getCanonicalScopesForProvider(effectiveProviderId)} - newScopes={missingRequiredScopes} - serviceId={serviceId} - // A reauthorize must return to the authorization server that issued - // the credential — deriving it from the service id would send a - // sandbox user to production, where they cannot sign in at all. - providerId={selectedCredential.provider} - reconnectTarget={{ - workspaceId, - credentialId: selectedCredential.id, - displayName: selectedCredential.name, - }} - /> - )} -
- ) -} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/components/tools/parameter.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/components/tools/parameter.tsx deleted file mode 100644 index e7840a8bf4a..00000000000 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/components/tools/parameter.tsx +++ /dev/null @@ -1,185 +0,0 @@ -'use client' - -import type React from 'react' -import { useRef, useState } from 'react' -import { Button, cn, Input, Label, Tooltip } from '@sim/emcn' -import { ArrowLeftRight, ArrowUp } from '@sim/emcn/icons' -import type { WandControlHandlers } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/sub-block' - -/** - * Props for a generic parameter with label component - */ -export interface ParameterWithLabelProps { - paramId: string - title: string - isRequired: boolean - visibility: string - wandConfig?: { - enabled: boolean - prompt?: string - placeholder?: string - } - canonicalToggle?: { - mode: 'basic' | 'advanced' - disabled?: boolean - onToggle?: () => void - } - disabled: boolean - isPreview: boolean - children: (wandControlRef: React.MutableRefObject) => React.ReactNode -} - -/** - * Generic wrapper component for parameters that manages wand state and renders label + input - */ -export function ParameterWithLabel({ - paramId, - title, - isRequired, - visibility, - wandConfig, - canonicalToggle, - disabled, - isPreview, - children, -}: ParameterWithLabelProps) { - const [isSearchActive, setIsSearchActive] = useState(false) - const [searchQuery, setSearchQuery] = useState('') - const searchInputRef = useRef(null) - const wandControlRef = useRef(null) - - const isWandEnabled = wandConfig?.enabled ?? false - const showWand = isWandEnabled && !isPreview && !disabled - - const handleSearchClick = (): void => { - setIsSearchActive(true) - setTimeout(() => { - searchInputRef.current?.focus() - }, 0) - } - - const handleSearchBlur = (): void => { - if (!searchQuery.trim() && !wandControlRef.current?.isWandStreaming) { - setIsSearchActive(false) - } - } - - const handleSearchChange = (value: string): void => { - setSearchQuery(value) - } - - const handleSearchSubmit = (): void => { - if (searchQuery.trim() && wandControlRef.current) { - wandControlRef.current.onWandTrigger(searchQuery) - setSearchQuery('') - setIsSearchActive(false) - } - } - - const handleSearchCancel = (): void => { - setSearchQuery('') - setIsSearchActive(false) - } - - const isStreaming = wandControlRef.current?.isWandStreaming ?? false - - return ( -
-
- -
- {showWand && - (!isSearchActive ? ( - - ) : ( -
- ) => - handleSearchChange(e.target.value) - } - onBlur={(e: React.FocusEvent) => { - const relatedTarget = e.relatedTarget as HTMLElement | null - if (relatedTarget?.closest('button')) return - handleSearchBlur() - }} - onKeyDown={(e: React.KeyboardEvent) => { - if (e.key === 'Enter' && searchQuery.trim() && !isStreaming) { - handleSearchSubmit() - } else if (e.key === 'Escape') { - handleSearchCancel() - } - }} - disabled={isStreaming} - className={cn( - 'h-5 min-w-[80px] flex-1 text-xs', - isStreaming && 'text-muted-foreground' - )} - placeholder='Generate with AI...' - /> - -
- ))} - {canonicalToggle && !isPreview && ( - - - - - -

- {canonicalToggle.mode === 'advanced' - ? 'Switch to selector' - : 'Switch to manual ID'} -

-
-
- )} -
-
-
{children(wandControlRef)}
-
- ) -} 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 e19bac41ca3..0b5a92b1103 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 @@ -12,6 +12,7 @@ import { SubBlock } from '@/app/workspace/[workspaceId]/w/[workflowId]/component import type { SubBlockConfig as BlockSubBlockConfig } from '@/blocks/types' import { useWorkflowRegistry } from '@/stores/workflows/registry/store' import { useSubBlockStore } from '@/stores/workflows/subblock/store' +import { decodeToolParamValue, getSubBlockValueShape } from '@/tools/param-shape' interface ToolSubBlockRendererProps { blockId: string @@ -31,23 +32,6 @@ interface ToolSubBlockRendererProps { } } -/** - * SubBlock types whose store values are objects/arrays/non-strings. - * tool.params stores strings (via JSON.stringify), so when syncing - * back to the store we parse them to restore the native shape. - */ -const OBJECT_SUBBLOCK_TYPES = new Set(['file-upload', 'table', 'grouped-checkbox-list']) - -/** - * Whether this subblock's store value is a non-string. Covers the always-object - * types above plus any `multiSelect` control, whose value is an array — without - * this a multi-select's JSON string is rendered as a single literal chip and the - * next edit persists a nested-encoded value. - */ -function holdsObjectValue(subBlock: { type: string; multiSelect?: boolean }): boolean { - return OBJECT_SUBBLOCK_TYPES.has(subBlock.type) || Boolean(subBlock.multiSelect) -} - /** * Bridges the subblock store with StoredTool.params via a synthetic store key, * then delegates all rendering to SubBlock for full parity. @@ -66,27 +50,29 @@ export function ToolSubBlockRenderer({ }: ToolSubBlockRendererProps) { const syntheticId = buildToolSubBlockId(subBlockId, toolIndex, effectiveParamId) const toolParamValue = toolParams?.[effectiveParamId] ?? '' - const isObjectType = holdsObjectValue(subBlock) + const valueShape = getSubBlockValueShape(subBlock) const syncedRef = useRef(null) const onParamChangeRef = useRef(onParamChange) onParamChangeRef.current = onParamChange + /** + * Hydrates the sub-block store from the stringified `tool.params` value, decoding it + * back to the shape this sub-block writes. + * + * `syncedRef` holds the ENCODED form, so the store subscription below compares like + * with like and treats a hydrate as a no-op rather than an edit. Without the decode a + * `switch` set to off hydrated the literal `'false'`, and `checked={Boolean(value)}` + * rendered it back on after every remount. + */ const pushParamValueToStore = useCallback( (rawValue: string) => { syncedRef.current = rawValue - if (isObjectType && rawValue) { - try { - const parsed = JSON.parse(rawValue) - if (typeof parsed === 'object' && parsed !== null) { - useSubBlockStore.getState().setValue(blockId, syntheticId, parsed) - return - } - } catch {} - } - useSubBlockStore.getState().setValue(blockId, syntheticId, rawValue) + useSubBlockStore + .getState() + .setValue(blockId, syntheticId, decodeToolParamValue(rawValue, valueShape)) }, - [blockId, syntheticId, isObjectType] + [blockId, syntheticId, valueShape] ) const pushParamValueToStoreRef = useRef(pushParamValueToStore) 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 0511dbaea84..6a78d2ccf23 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 @@ -6,12 +6,10 @@ import { type ComboboxOption, type ComboboxOptionGroup, cn, - Loader, Popover, PopoverContent, PopoverItem, PopoverTrigger, - Switch, Tooltip, } from '@sim/emcn' import { ArrowLeft, ChevronRight, Server, Wrench, X } from '@sim/emcn/icons' @@ -25,27 +23,19 @@ import { getMcpToolIssue as validateMcpTool, } from '@/lib/mcp/tool-validation' import type { McpToolSchema } from '@/lib/mcp/types' -import { getProviderIdFromServiceId, type OAuthProvider, type OAuthService } from '@/lib/oauth' import { NO_DENIED_OPERATIONS, OPERATION_SUBBLOCK_ID, } from '@/lib/permission-groups/operation-access' -import { extractInputFieldsFromBlocks } from '@/lib/workflows/input-format' import { resolveStoredToolName } from '@/lib/workflows/subblocks/display' import { buildToolSubBlockId } from '@/lib/workflows/tool-input/synthetic-subblocks' import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider' import { McpServerFormModal } from '@/app/workspace/[workspaceId]/settings/components/mcp/components/mcp-server-form-modal/mcp-server-form-modal' -import { - LongInput, - ShortInput, -} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components' import { formatDisplayText } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/formatted-text' import { type CustomTool, CustomToolModal, } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/components/custom-tool-modal/custom-tool-modal' -import { ToolCredentialSelector } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/components/tools/credential-selector' -import { ParameterWithLabel } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/components/tools/parameter' import { ToolSubBlockRenderer } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/components/tools/sub-block-renderer' import { clearDependentToolParams } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/param-dependents' import type { StoredTool } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/types' @@ -55,12 +45,8 @@ import { isMcpToolAlreadySelected, isWorkflowAlreadySelected, } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/utils' -import { - getActiveWorkflowSearchHighlight, - getWorkflowSearchLabelHighlight, -} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/workflow-search-highlight' +import { getActiveWorkflowSearchHighlight } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/workflow-search-highlight' import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-value' -import type { WandControlHandlers } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/sub-block' import { ActiveSearchTargetProvider, useActiveSearchTarget, @@ -86,7 +72,7 @@ import { useMcpServers, useStoredMcpTools, } from '@/hooks/queries/mcp' -import { useWorkflowState, useWorkflows } from '@/hooks/queries/workflows' +import { useWorkflows } from '@/hooks/queries/workflows' import { useAvailableEnvVarKeys } from '@/hooks/use-available-env-vars' import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow' import { useOperationAccess } from '@/hooks/use-operation-access' @@ -96,132 +82,28 @@ import { getProviderFromModel, supportsToolUsageControl } from '@/providers/util import type { ActiveSearchTarget } from '@/stores/panel/editor/store' import { useSubBlockStore } from '@/stores/workflows/subblock/store' import { useWorkflowStore } from '@/stores/workflows/workflow/store' +import { getToolMetadata } from '@/tools/metadata' +import { buildSubBlocksFromJsonSchema, encodeToolParamValue } from '@/tools/param-shape' import { formatParameterLabel, getSubBlocksForToolInput, getToolIdForOperation, - getToolParametersConfig, - isPasswordParameter, + isUserFacingToolParam, type SubBlocksForToolInput, - type ToolParameterConfig, } from '@/tools/params' import { buildCanonicalIndex, - buildPreviewContextValues, type CanonicalIndex, type CanonicalModeOverrides, - evaluateSubBlockCondition, isCanonicalPair, reindexToolCanonicalModes, resolveCanonicalMode, resolveDependencyValue, - type SubBlockCondition, scopeCanonicalModesForTool, } from '@/tools/params-resolver' const logger = createLogger('ToolInput') -/** - * Renders the input for workflow_executor's inputMapping parameter. - * This is a special case that doesn't map to any SubBlockConfig, so it's kept here. - */ -function WorkflowInputMapperInput({ - blockId, - paramId, - value, - onChange, - disabled, - workflowId, -}: { - blockId: string - paramId: string - value: string - onChange: (value: string) => void - disabled: boolean - workflowId: string -}) { - const activeSearchTarget = useActiveSearchTarget() - const { data: workflowState, isLoading } = useWorkflowState(workflowId) - const inputFields = useMemo( - () => (workflowState?.blocks ? extractInputFieldsFromBlocks(workflowState.blocks) : []), - [workflowState?.blocks] - ) - - const parsedValue = useMemo(() => { - try { - return value ? JSON.parse(value) : {} - } catch { - return {} - } - }, [value]) - - const handleFieldChange = useCallback( - (fieldName: string, fieldValue: string) => { - const newValue = { ...parsedValue, [fieldName]: fieldValue } - onChange(JSON.stringify(newValue)) - }, - [parsedValue, onChange] - ) - - if (!workflowId) { - return ( -
- Select a workflow to configure its inputs -
- ) - } - - if (isLoading) { - return ( -
- -
- ) - } - - if (inputFields.length === 0) { - return ( -
- This workflow has no custom input fields -
- ) - } - - return ( -
- {inputFields.map((field: { name: string; type: string }) => { - const syntheticId = `${paramId}-${field.name}` - const fieldActiveSearchTarget = - activeSearchTarget?.valuePath[0] === field.name - ? { - ...activeSearchTarget, - subBlockId: syntheticId, - canonicalSubBlockId: syntheticId, - valuePath: [], - } - : null - return ( - - handleFieldChange(field.name, newValue)} - disabled={disabled} - config={{ - id: syntheticId, - type: 'short-input', - title: field.name, - }} - /> - - ) - })} -
- ) -} - function WorkflowToolDeployBadge({ workflowId, onDeploySuccess, @@ -387,10 +269,9 @@ function getOperationOptions(block: BlockConfig | undefined): { label: string; i return block.tools.access.map((toolId) => { try { - const toolParams = getToolParametersConfig(toolId) return { id: toolId, - label: toolParams?.toolConfig?.name || toolId, + label: getToolMetadata(toolId)?.name || toolId, } } catch (error) { logger.error(`Error getting tool config for ${toolId}:`, error) @@ -834,18 +715,22 @@ export const ToolInput = memo(function ToolInput({ if (isToolAlreadySelected(toolId, toolBlock.type)) return - const toolParams = getToolParametersConfig(toolId, toolBlock.type, undefined, toolBlock) - if (!toolParams) return + const initialSubBlocks = getSubBlocksForToolInput( + toolId, + toolBlock.type, + undefined, + {}, + toolBlock + ) + if (!initialSubBlocks) return const initialParams: Record = {} - toolParams.userInputParameters.forEach((param) => { - if (param.uiComponent?.value && !initialParams[param.id]) { - const defaultValue = - typeof param.uiComponent.value === 'function' - ? param.uiComponent.value() - : param.uiComponent.value - initialParams[param.id] = defaultValue + initialSubBlocks.subBlocks.forEach((sb) => { + if (initialParams[sb.id] !== undefined) return + const seeded = sb.value ? sb.value({}) : sb.defaultValue + if (seeded !== undefined && seeded !== null) { + initialParams[sb.id] = encodeToolParamValue(seeded) } }) @@ -1038,13 +923,17 @@ export const ToolInput = memo(function ToolInput({ return } - const toolParams = getToolParametersConfig(newToolId, tool.type) + const newToolConfig = getToolMetadata(newToolId) - if (!toolParams) { + if (!newToolConfig) { return } - const newParamIds = new Set(toolParams.userInputParameters.map((p) => p.id)) + const newParamIds = new Set( + Object.entries(newToolConfig.params ?? {}) + .filter(([, param]) => isUserFacingToolParam(param)) + .map(([paramId]) => paramId) + ) const preservedParams: Record = {} Object.entries(tool.params || {}).forEach(([paramId, value]) => { @@ -1175,15 +1064,6 @@ export const ToolInput = memo(function ToolInput({ setDragOverIndex(null) } - const evaluateParameterCondition = (param: ToolParameterConfig, tool: StoredTool): boolean => { - if (!('uiComponent' in param) || !param.uiComponent?.condition) return true - const currentValues: Record = { operation: tool.operation, ...tool.params } - return evaluateSubBlockCondition( - param.uiComponent.condition as SubBlockCondition, - currentValues - ) - } - const getParamActiveSearchTarget = ( toolIndex: number | undefined, paramId: string, @@ -1210,193 +1090,6 @@ export const ToolInput = memo(function ToolInput({ valuePath: [toolIndex, 'title'], }) - /** - * Renders a parameter input for custom tools, MCP tools, and legacy registry - * tools that don't have SubBlockConfig definitions. - * - * Registry tools with subBlocks use ToolSubBlockRenderer instead. - */ - const renderParameterInput = ( - param: ToolParameterConfig, - value: string, - onChange: (value: string) => void, - toolIndex?: number, - currentToolParams?: Record, - wandControlRef?: React.MutableRefObject - ) => { - const uniqueSubBlockId = - toolIndex !== undefined - ? buildToolSubBlockId(subBlockId, toolIndex, param.id) - : `${subBlockId}-${param.id}` - const paramActiveSearchTarget = getParamActiveSearchTarget( - toolIndex, - param.id, - uniqueSubBlockId - ) - const uiComponent = param.uiComponent - - const content = (() => { - if (!uiComponent) { - return ( - - ) - } - - switch (uiComponent.type) { - case 'dropdown': { - const options = - (uiComponent.options as { id?: string; label: string; value?: string }[] | undefined) - ?.filter((option) => (option.id ?? option.value) !== '') - .map((option) => ({ - label: option.label, - value: option.id ?? option.value ?? '', - })) || [] - const selectedLabel = options.find((option) => option.value === value)?.label ?? '' - const workflowSearchHighlight = getWorkflowSearchLabelHighlight({ - activeSearchTarget: paramActiveSearchTarget, - blockId, - subBlockId: uniqueSubBlockId, - valuePath: [], - label: selectedLabel, - }) - return ( - - {formatDisplayText(selectedLabel, { workflowSearchHighlight })} - - ) : undefined - } - /> - ) - } - - case 'switch': - return ( - onChange(checked ? 'true' : 'false')} - /> - ) - - case 'long-input': - return ( - - ) - - case 'short-input': - return ( - - ) - - case 'oauth-input': - return ( - - ) - - case 'workflow-input-mapper': { - const selectedWorkflowId = currentToolParams?.workflowId || '' - return ( - - ) - } - - default: - return ( - - ) - } - })() - - return ( - - {content} - - ) - } - /** * Generates grouped options for the tool selection combobox. * @@ -1746,19 +1439,6 @@ export const ToolInput = memo(function ToolInput({ '' : tool.toolId || '' - const toolParams = - !isCustomTool && !isMcpTool && currentToolId - ? getToolParametersConfig( - currentToolId, - tool.type, - { - operation: tool.operation, - ...tool.params, - }, - toolBlock ?? undefined - ) - : null - const toolScopedOverrides = scopeCanonicalModesForTool( canonicalModeOverrides, toolIndex, @@ -1784,76 +1464,31 @@ export const ToolInput = memo(function ToolInput({ buildCanonicalIndex(toolBlock.subBlocks) : null - const toolContextValues = toolCanonicalIndex - ? buildPreviewContextValues(tool.params || {}, { - blockType: tool.type, - subBlocks: toolBlock!.subBlocks, - canonicalIndex: toolCanonicalIndex, - values: { operation: tool.operation, ...tool.params }, - overrides: toolScopedOverrides, - }) - : tool.params || {} - - const resolvedCustomTool = isCustomTool - ? resolveCustomToolFromReference(tool, customTools) - : null - - const customToolSchema = isCustomTool ? tool.schema || resolvedCustomTool?.schema : null - const customToolParams = - isCustomTool && customToolSchema?.function?.parameters?.properties - ? Object.entries(customToolSchema.function.parameters.properties || {}).map( - ([paramId, param]: [string, any]) => ({ - id: paramId, - type: param.type || 'string', - description: param.description || '', - visibility: (customToolSchema.function.parameters.required?.includes(paramId) - ? 'user-or-llm' - : 'user-only') as 'user-or-llm' | 'user-only' | 'llm-only' | 'hidden', - }) - ) - : [] - const mcpTool = isMcpTool ? mcpTools.find((t) => t.id === tool.toolId) : null const mcpToolSchema = isMcpTool ? tool.schema || mcpTool?.inputSchema : null - const mcpToolParams = - isMcpTool && mcpToolSchema?.properties - ? Object.entries(mcpToolSchema.properties || {}).map( - ([paramId, param]: [string, any]) => ({ - id: paramId, - type: param.type || 'string', - description: param.description || '', - visibility: (mcpToolSchema.required?.includes(paramId) - ? 'user-or-llm' - : 'user-only') as 'user-or-llm' | 'user-only' | 'llm-only' | 'hidden', - }) - ) - : [] // Canonical name wins; stored title only when nothing resolves // (same policy as the canvas summary — see resolveStoredToolName). const toolDisplayName = resolveStoredToolName(tool, { customTools, mcpToolNamesById }) ?? 'Unknown Tool' - const useSubBlocks = !isCustomTool && !isMcpTool && subBlocksResult?.subBlocks?.length - const displayParams: ToolParameterConfig[] = isCustomTool - ? customToolParams - : isMcpTool - ? mcpToolParams - : toolParams?.userInputParameters || [] - const displaySubBlocks: BlockSubBlockConfig[] = useSubBlocks - ? subBlocksResult!.subBlocks.filter( + /** + * Every field this tool row renders, as `SubBlockConfig`s. A registry tool's + * come from its block (with params it does not surface synthesized from their + * declared type); an MCP tool's are derived from its JSON Schema. Both then + * render through the one canonical sub-block renderer. + */ + const displaySubBlocks: BlockSubBlockConfig[] = isMcpTool + ? buildSubBlocksFromJsonSchema(mcpToolSchema ?? undefined, formatParameterLabel) + : (subBlocksResult?.subBlocks ?? []).filter( (sb) => !sb.reactiveCondition || toolCredential?.type === sb.reactiveCondition.requiredType ) - : [] const hasOperations = !isCustomTool && !isMcpTool && hasMultipleOperations(toolBlock ?? undefined) - const hasParams = useSubBlocks - ? displaySubBlocks.length > 0 - : displayParams.filter((param) => evaluateParameterCondition(param, tool)).length > 0 - const hasToolBody = hasOperations || hasParams + const hasToolBody = hasOperations || displaySubBlocks.length > 0 const isSearchExpanded = activeSearchTarget?.subBlockId === subBlockId && @@ -2139,8 +1774,6 @@ export const ToolInput = memo(function ToolInput({ })()} {(() => { - const renderedElements: React.ReactNode[] = [] - const renderSubBlock = (sb: BlockSubBlockConfig): React.ReactNode => { const effectiveParamId = sb.id const canonicalId = toolCanonicalIndex?.canonicalIdBySubBlockId[sb.id] @@ -2172,10 +1805,6 @@ export const ToolInput = memo(function ToolInput({ } : undefined - const sbWithTitle = sb.title - ? sb - : { ...sb, title: formatParameterLabel(effectiveParamId) } - return ( 0) { - const coveredParamIds = new Set( - displaySubBlocks.flatMap((sb) => { - const ids = [sb.id] - if (sb.canonicalParamId) ids.push(sb.canonicalParamId) - const cId = toolCanonicalIndex?.canonicalIdBySubBlockId[sb.id] - if (cId) { - const group = toolCanonicalIndex?.groupsById[cId] - if (group) { - if (group.basicId) ids.push(group.basicId) - ids.push(...group.advancedIds) - } - } - return ids - }) - ) - - for (const sb of displaySubBlocks) { - renderedElements.push(renderSubBlock(sb)) - } - - const uncoveredParams = displayParams.filter( - (param) => - !coveredParamIds.has(param.id) && evaluateParameterCondition(param, tool) - ) - - uncoveredParams.forEach((param) => { - renderedElements.push( - - {(wandControlRef: React.MutableRefObject) => - renderParameterInput( - param, - tool.params?.[param.id] || '', - (value) => handleParamChange(toolIndex, param.id, value), - toolIndex, - toolContextValues as Record, - wandControlRef - ) - } - - ) - }) - - return
{renderedElements}
- } - - const filteredParams = displayParams.filter((param) => - evaluateParameterCondition(param, tool) + return ( +
+ {displaySubBlocks.map(renderSubBlock)} +
) - - filteredParams.forEach((param) => { - renderedElements.push( - - {(wandControlRef: React.MutableRefObject) => - renderParameterInput( - param, - tool.params?.[param.id] || '', - (value) => handleParamChange(toolIndex, param.id, value), - toolIndex, - toolContextValues as Record, - wandControlRef - ) - } - - ) - }) - - return renderedElements })()} )} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/workflow-input-mapper/index.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/workflow-input-mapper/index.ts new file mode 100644 index 00000000000..de8cce306e1 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/workflow-input-mapper/index.ts @@ -0,0 +1 @@ +export { WorkflowInputMapper } from './workflow-input-mapper' diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/workflow-input-mapper/workflow-input-mapper.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/workflow-input-mapper/workflow-input-mapper.tsx new file mode 100644 index 00000000000..ad497ee2a4f --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/workflow-input-mapper/workflow-input-mapper.tsx @@ -0,0 +1,132 @@ +'use client' + +import { useCallback, useMemo } from 'react' +import { Loader } from '@sim/emcn' +import { extractInputFieldsFromBlocks } from '@/lib/workflows/input-format' +import { ShortInput } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/short-input' +import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-value' +import { + ActiveSearchTargetProvider, + useActiveSearchTarget, +} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/providers/active-search-target-provider' +import type { SubBlockConfig } from '@/blocks/types' +import { useWorkflowState } from '@/hooks/queries/workflows' + +interface WorkflowInputMapperProps { + blockId: string + subBlock: SubBlockConfig + isPreview?: boolean + previewValue?: string | null + disabled?: boolean + /** Sibling values, used to read the selected `workflowId` this mapping targets. */ + contextValues?: Record +} + +/** + * Collects a child workflow's input fields into the single JSON object that + * `workflow_executor` takes as `inputMapping`. + * + * Only reachable through a `context: 'tool-input'` sub-block: on the canvas a child + * workflow's inputs travel through the `input` variable instead, so this control has no + * canvas surface. It nonetheless lives here rather than inside `tool-input` so a tool + * row builds its fields from sub-blocks alone, with no control the canonical renderer + * cannot render. + */ +export function WorkflowInputMapper({ + blockId, + subBlock, + isPreview = false, + previewValue, + disabled = false, + contextValues, +}: WorkflowInputMapperProps) { + const activeSearchTarget = useActiveSearchTarget() + const [storeValue, setStoreValue] = useSubBlockValue(blockId, subBlock.id) + + const workflowId = typeof contextValues?.workflowId === 'string' ? contextValues.workflowId : '' + const value = (isPreview ? previewValue : storeValue) ?? '' + + const { data: workflowState, isLoading } = useWorkflowState(workflowId) + const inputFields = useMemo( + () => (workflowState?.blocks ? extractInputFieldsFromBlocks(workflowState.blocks) : []), + [workflowState?.blocks] + ) + + const parsedValue = useMemo((): Record => { + if (!value) return {} + try { + const parsed: unknown = JSON.parse(value) + return parsed && typeof parsed === 'object' && !Array.isArray(parsed) + ? (parsed as Record) + : {} + } catch { + return {} + } + }, [value]) + + const handleFieldChange = useCallback( + (fieldName: string, fieldValue: string) => { + if (isPreview || disabled) return + setStoreValue(JSON.stringify({ ...parsedValue, [fieldName]: fieldValue })) + }, + [parsedValue, setStoreValue, isPreview, disabled] + ) + + if (!workflowId) { + return ( +
+ Select a workflow to configure its inputs +
+ ) + } + + if (isLoading) { + return ( +
+ +
+ ) + } + + if (inputFields.length === 0) { + return ( +
+ This workflow has no custom input fields +
+ ) + } + + return ( +
+ {inputFields.map((field: { name: string; type: string }) => { + const syntheticId = `${subBlock.id}-${field.name}` + const fieldActiveSearchTarget = + activeSearchTarget?.valuePath[0] === field.name + ? { + ...activeSearchTarget, + subBlockId: syntheticId, + canonicalSubBlockId: syntheticId, + valuePath: [], + } + : null + return ( + + handleFieldChange(field.name, newValue)} + disabled={disabled || isPreview} + config={{ + id: syntheticId, + type: 'short-input', + title: field.name, + }} + /> + + ) + })} +
+ ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/sub-block.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/sub-block.tsx index 29ab3160493..4578240507a 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/sub-block.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/sub-block.tsx @@ -47,6 +47,7 @@ import { TimeInput, ToolInput, VariablesInput, + WorkflowInputMapper, WorkflowOutputSelector, WorkflowSelectorInput, } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components' @@ -1028,6 +1029,18 @@ function SubBlockComponent({ /> ) + case 'workflow-input-mapper': + return ( + + ) + case 'variables-input': return ( p.length > 0) : undefined - // Only send a completion value when the user actually checked the box; an - // empty/untouched checkbox must omit the field (not send `false`), so - // update_task doesn't silently un-complete a task and search_tasks doesn't - // implicitly filter to incomplete tasks. - const completedValue = - Array.isArray(params.completed) && params.completed.length > 0 - ? params.completed.includes('completed') - : undefined + // Only send a completion value when the user actually set the toggle; an + // untouched field must omit it (not send `false`), so update_task doesn't + // silently un-complete a task and search_tasks doesn't implicitly filter to + // incomplete tasks. + const completedValue = typeof params.completed === 'boolean' ? params.completed : undefined const baseParams = { accessToken: oauthCredential?.accessToken, @@ -634,7 +635,7 @@ Return ONLY the date string in YYYY-MM-DD format - no explanations, no quotes, n assignee: { type: 'string', description: 'Assignee user GID' }, due_on: { type: 'string', description: 'Due date (YYYY-MM-DD)' }, projects: { type: 'string', description: 'Project GIDs' }, - completed: { type: 'array', description: 'Completion status' }, + completed: { type: 'boolean', description: 'Completion status' }, searchText: { type: 'string', description: 'Search text' }, commentText: { type: 'string', description: 'Comment text' }, createProject_workspace: { diff --git a/apps/sim/blocks/blocks/human_in_the_loop.ts b/apps/sim/blocks/blocks/human_in_the_loop.ts index ae6aa80c297..14cd5b62850 100644 --- a/apps/sim/blocks/blocks/human_in_the_loop.ts +++ b/apps/sim/blocks/blocks/human_in_the_loop.ts @@ -5,6 +5,8 @@ import type { ResponseBlockOutput } from '@/tools/response/types' export const HumanInTheLoopBlock: BlockConfig = { type: 'human_in_the_loop', name: 'Human', + hideFromToolbar: true, + sunset: { status: 'legacy', replacedBy: 'human_in_the_loop_v2' }, description: 'Pause workflow execution and wait for human input', longDescription: 'Combines response and start functionality. Sends structured responses and allows workflow to resume from this point.', @@ -187,3 +189,24 @@ export const HumanInTheLoopBlock: BlockConfig = { submittedAt: { type: 'string', description: 'ISO timestamp when the workflow was resumed' }, }, } + +/** + * The Human block, with its notification tools executed the way every other surface + * executes a block tool. + * + * v1 handed a notification tool its stored sub-block values verbatim: no canonical + * basic/advanced resolution, so a channel chosen in advanced mode arrived under + * `manualChannel` and the tool's declared `channel` was never set; and no + * `tools.config.params` transform, so the block's own mapping never ran. v2 applies + * both, which changes what a configured tool receives — hence a new version rather + * than a fix in place. + * + * Everything else — sub-blocks, outputs, pause/resume, the resume page — is identical, + * so it is inherited rather than restated. + */ +export const HumanInTheLoopV2Block: BlockConfig = { + ...HumanInTheLoopBlock, + type: 'human_in_the_loop_v2', + hideFromToolbar: false, + sunset: undefined, +} diff --git a/apps/sim/blocks/blocks/pinecone.ts b/apps/sim/blocks/blocks/pinecone.ts index a5148327b39..429684cc4b2 100644 --- a/apps/sim/blocks/blocks/pinecone.ts +++ b/apps/sim/blocks/blocks/pinecone.ts @@ -305,8 +305,8 @@ export const PineconeBlock: BlockConfig = { title: 'Options', type: 'checkbox-list', options: [ - { id: 'includeValues', label: 'Include Values' }, - { id: 'includeMetadata', label: 'Include Metadata' }, + { id: 'includeValues', label: 'Include Values', defaultChecked: true }, + { id: 'includeMetadata', label: 'Include Metadata', defaultChecked: true }, ], condition: { field: 'operation', value: 'search_vector' }, }, diff --git a/apps/sim/blocks/blocks/workflow.ts b/apps/sim/blocks/blocks/workflow.ts index 2b499584b6f..63e5124c486 100644 --- a/apps/sim/blocks/blocks/workflow.ts +++ b/apps/sim/blocks/blocks/workflow.ts @@ -46,6 +46,22 @@ export const WorkflowBlock: BlockConfig = { description: 'This variable will be available as start.input in the child workflow', required: false, }, + { + /** + * Only meaningful when this block is used as an agent tool: on the canvas the + * child's inputs are wired through `input`, but a tool row has to collect them + * per-field. `context: 'tool-input'` keeps it off the canvas, and declaring it + * here is what lets the tool row build its fields from sub-blocks alone instead + * of a hard-coded `workflow_executor` branch. + */ + id: 'inputMapping', + title: 'Workflow Inputs', + type: 'workflow-input-mapper', + context: 'tool-input', + dependsOn: ['workflowId'], + condition: { field: 'workflowId', value: '', not: true }, + required: false, + }, ], tools: { access: ['workflow_executor'], diff --git a/apps/sim/blocks/custom/build-config.test.ts b/apps/sim/blocks/custom/build-config.test.ts index 637a0467ef4..1d5079f88a3 100644 --- a/apps/sim/blocks/custom/build-config.test.ts +++ b/apps/sim/blocks/custom/build-config.test.ts @@ -4,6 +4,7 @@ import { describe, expect, it } from 'vitest' import type { WorkflowInputField } from '@/lib/workflows/input-format' import { + assembleCustomBlockInputMapping, buildCustomBlockConfig, CUSTOM_BLOCK_TILE_COLOR, type CustomBlockRow, @@ -178,3 +179,77 @@ describe('sourceWorkspaceName', () => { ).toBeUndefined() }) }) + +describe('assembleCustomBlockInputMapping', () => { + const fieldSubBlocks = [ + { id: 'flag', name: 'flag', type: 'boolean' }, + { id: 'payload', name: 'payload', type: 'object' }, + { id: 'name', name: 'name', type: 'string' }, + ] + + it("decodes a tool row's stringified boolean before handing it to the child", () => { + expect(JSON.parse(assembleCustomBlockInputMapping({ flag: 'false' }, fieldSubBlocks))).toEqual({ + flag: false, + }) + expect(JSON.parse(assembleCustomBlockInputMapping({ flag: 'true' }, fieldSubBlocks))).toEqual({ + flag: true, + }) + }) + + it('leaves a text field alone even when it holds a boolean-looking string', () => { + expect(JSON.parse(assembleCustomBlockInputMapping({ name: 'false' }, fieldSubBlocks))).toEqual({ + name: 'false', + }) + }) + + it('still drops reserved keys and untouched fields', () => { + expect( + JSON.parse( + assembleCustomBlockInputMapping( + { flag: '', name: '', workflowId: 'wf_1', inputMapping: '{}' }, + fieldSubBlocks + ) + ) + ).toEqual({}) + }) + + it('keeps a canvas value that is already typed', () => { + expect(JSON.parse(assembleCustomBlockInputMapping({ flag: false }, fieldSubBlocks))).toEqual({ + flag: false, + }) + }) +}) + +describe('assembleCustomBlockInputMapping field decoding', () => { + const inputFields = [ + { id: 'flag', name: 'flag', type: 'boolean' }, + { id: 'count', name: 'count', type: 'number' }, + { id: 'body', name: 'body', type: 'object' }, + { id: 'note', name: 'note', type: 'string' }, + ] + + it('decodes on the DECLARED field type, not the control it renders as', () => { + // `number` collects in a text field and `object` in a code editor — both store + // strings, so keying on the control would decode neither. + expect( + JSON.parse( + assembleCustomBlockInputMapping( + { flag: 'false', count: '3', body: '{"a":1}', note: 'false' }, + inputFields + ) + ) + ).toEqual({ flag: false, count: 3, body: { a: 1 }, note: 'false' }) + }) + + it('leaves canvas values, which are already typed, untouched', () => { + expect( + JSON.parse(assembleCustomBlockInputMapping({ flag: false, count: 3 }, inputFields)) + ).toEqual({ flag: false, count: 3 }) + }) + + it('passes values through when no fields are known', () => { + expect(JSON.parse(assembleCustomBlockInputMapping({ flag: 'false' }))).toEqual({ + flag: 'false', + }) + }) +}) diff --git a/apps/sim/blocks/custom/build-config.ts b/apps/sim/blocks/custom/build-config.ts index bbe0e99fb6d..f7e20d82bdb 100644 --- a/apps/sim/blocks/custom/build-config.ts +++ b/apps/sim/blocks/custom/build-config.ts @@ -1,6 +1,10 @@ -import type { SubBlockType } from '@sim/workflow-types/blocks' import type { WorkflowInputField } from '@/lib/workflows/input-format' import type { BlockConfig, BlockIcon, SubBlockConfig } from '@/blocks/types' +import { + decodeToolParams, + getToolParamValueShape, + subBlockTypeForValueType, +} from '@/tools/param-shape' /** * The block-type prefix that identifies a custom (deploy-as-block) block. Shared @@ -97,9 +101,21 @@ export function isReservedOutputName(name: string): boolean { * stable id. Shared by the hidden `inputMapping` sub-block (canvas serialization) * and the agent-tool transform, so both paths assemble the mapping identically. */ -export function assembleCustomBlockInputMapping(params: Record): string { +export function assembleCustomBlockInputMapping( + params: Record, + inputFields: readonly CustomBlockInputFieldType[] | undefined = [] +): string { + // A tool row stringifies every value, so a `boolean` field toggled off arrives as the + // string 'false' and would reach the child workflow as a truthy string. Keyed on the + // field's DECLARED type rather than the control it renders as: a `number` collects in + // a text field and an `object` in a code editor, both of which store strings, so asking + // the control would answer `'string'` and decode nothing. + const shapes = new Map( + inputFields.map((field) => [field.id ?? field.name, getToolParamValueShape(field.type)]) + ) + const decoded = decodeToolParams(params, shapes) const mapping: Record = {} - for (const [key, val] of Object.entries(params)) { + for (const [key, val] of Object.entries(decoded)) { if (RESERVED_PARAMS.has(key)) continue if (val === undefined || val === '') continue mapping[key] = val @@ -107,53 +123,30 @@ export function assembleCustomBlockInputMapping(params: Record) return JSON.stringify(mapping) } -/** Map a Start input field type to the editor sub-block type used to collect it. */ /** - * The sub-block a Start input field becomes on the canvas. Exported so any surface that has to - * render or reason about a custom block's inputs derives the field's KIND from here instead of - * re-deriving it — the fork sync modal renders its own controls but must agree with this about - * what each field is. - */ -export function subBlockTypeForField(fieldType: string): SubBlockType { - switch (fieldType) { - case 'boolean': - return 'switch' - case 'object': - case 'array': - return 'code' - case 'file[]': - return 'file-upload' - default: - return 'short-input' - } -} - -/** - * Synthesize a `BlockConfig` for a published custom block from its DB row and the - * live-derived Start input fields. Shared by the client (real icon + per-field - * editors) and the server (placeholder icon + `inputFields: []`, since the - * `inputMapping` wiring is schema-agnostic). + * The editable field sub-blocks a custom block exposes, one per Start input. * - * Execution reuses the `workflow_executor` tool: the bound `workflowId` and the - * assembled `inputMapping` are hidden, baked sub-blocks; each Start input becomes - * its own editable sub-block whose value is collected into `inputMapping`. - * `` inside those values resolve at execution exactly like the - * `workflow_input` block. + * Split out because the server overlay builds its configs with `inputFields: []` (the + * live derivation is not free), so on the execution path `blockDef.subBlocks` carries + * none of them. The agent-tool transform has the authoritative fields from the block's + * binding and rebuilds them here rather than trusting the overlay. * * The sub-block id is the field's stable id (`field.id`), NOT its display name, so * renaming a Start input in the source workflow and redeploying never orphans a - * consumer's placed value. The name is shown as the sub-block title and is what - * the child workflow ultimately receives — the id→name remap happens at execution - * in `WorkflowBlockHandler` against the loaded child's current field names. Legacy - * fields without an id fall back to keying on the name. + * consumer's placed value. Legacy fields without an id fall back to keying on the name. */ -export function buildCustomBlockConfig( - row: CustomBlockRow, - inputFields: WorkflowInputField[], - opts: { icon: BlockIcon; bgColor?: string; hideFromToolbar?: boolean } -): BlockConfig { - const fieldSubBlocks: SubBlockConfig[] = inputFields.map((field) => { - const type = subBlockTypeForField(field.type) +/** The parts of a Start input field that decide how its stored value decodes. */ +export interface CustomBlockInputFieldType { + id?: string + name: string + type: string +} + +function buildCustomBlockFieldSubBlocks( + inputFields: readonly WorkflowInputField[] +): SubBlockConfig[] { + return inputFields.map((field) => { + const type = subBlockTypeForValueType(field.type) const sub: SubBlockConfig = { id: field.id ?? field.name, title: field.name, @@ -168,6 +161,28 @@ export function buildCustomBlockConfig( if (field.type === 'file[]') sub.multiple = true return sub }) +} + +/** + * Synthesize a `BlockConfig` for a published custom block from its DB row and the + * live-derived Start input fields. Shared by the client (real icon + per-field + * editors) and the server (placeholder icon + `inputFields: []`, since the + * `inputMapping` wiring is schema-agnostic). + * + * Execution reuses the `workflow_executor` tool: the bound `workflowId` and the + * assembled `inputMapping` are hidden, baked sub-blocks; each Start input becomes + * its own editable sub-block whose value is collected into `inputMapping`. + * `` inside those values resolve at execution exactly like the + * `workflow_input` block. The name is shown as the sub-block title and is what the + * child workflow ultimately receives — the id→name remap happens at execution in + * `WorkflowBlockHandler` against the loaded child's current field names. + */ +export function buildCustomBlockConfig( + row: CustomBlockRow, + inputFields: WorkflowInputField[], + opts: { icon: BlockIcon; bgColor?: string; hideFromToolbar?: boolean } +): BlockConfig { + const fieldSubBlocks = buildCustomBlockFieldSubBlocks(inputFields) return { type: row.type, @@ -199,7 +214,7 @@ export function buildCustomBlockConfig( type: 'code', language: 'json', hidden: true, - value: (params) => assembleCustomBlockInputMapping(params), + value: (params) => assembleCustomBlockInputMapping(params, inputFields), }, ...fieldSubBlocks, ], diff --git a/apps/sim/blocks/registry-maps.ts b/apps/sim/blocks/registry-maps.ts index e3cb3d2d425..06ee6083309 100644 --- a/apps/sim/blocks/registry-maps.ts +++ b/apps/sim/blocks/registry-maps.ts @@ -147,7 +147,7 @@ import { HarmonicBlock, HarmonicBlockMeta } from '@/blocks/blocks/harmonic' import { HexBlock, HexBlockMeta } from '@/blocks/blocks/hex' import { HubSpotBlock, HubSpotBlockMeta } from '@/blocks/blocks/hubspot' import { HuggingFaceBlock, HuggingFaceBlockMeta } from '@/blocks/blocks/huggingface' -import { HumanInTheLoopBlock } from '@/blocks/blocks/human_in_the_loop' +import { HumanInTheLoopBlock, HumanInTheLoopV2Block } from '@/blocks/blocks/human_in_the_loop' import { HunterBlock, HunterBlockMeta } from '@/blocks/blocks/hunter' import { IAMBlock, IAMBlockMeta } from '@/blocks/blocks/iam' import { IcypeasBlock, IcypeasBlockMeta } from '@/blocks/blocks/icypeas' @@ -516,6 +516,7 @@ export const BLOCK_REGISTRY: Record = { hubspot: HubSpotBlock, huggingface: HuggingFaceBlock, human_in_the_loop: HumanInTheLoopBlock, + human_in_the_loop_v2: HumanInTheLoopV2Block, hunter: HunterBlock, iam: IAMBlock, icypeas: IcypeasBlock, diff --git a/apps/sim/ee/workspace-forking/components/fork-sync/custom-block-input-control.test.ts b/apps/sim/ee/workspace-forking/components/fork-sync/custom-block-input-control.test.ts index 07af1577779..8081273a35c 100644 --- a/apps/sim/ee/workspace-forking/components/fork-sync/custom-block-input-control.test.ts +++ b/apps/sim/ee/workspace-forking/components/fork-sync/custom-block-input-control.test.ts @@ -2,7 +2,6 @@ * @vitest-environment node */ import { describe, expect, it } from 'vitest' -import { subBlockTypeForField } from '@/blocks/custom/build-config' import { CUSTOM_BLOCK_BOOLEAN_FALSE, CUSTOM_BLOCK_BOOLEAN_TRUE, @@ -11,10 +10,11 @@ import { customBlockInputControl, isForkSyncConfigurableField, } from '@/ee/workspace-forking/components/fork-sync/custom-block-input-control' +import { subBlockTypeForValueType } from '@/tools/param-shape' describe('customBlockInputControl', () => { it('matches how the canvas renders each field type', () => { - // Mirrors `subBlockTypeForField`: a field configured here must behave the way it will + // Mirrors `subBlockTypeForValueType`: a field configured here must behave the way it will // once the block is open in the editor. expect(customBlockInputControl('boolean')).toBe('switch') expect(customBlockInputControl('object')).toBe('textarea') @@ -30,7 +30,7 @@ describe('customBlockInputControl', () => { }) it('stays in step with the canvas mapping for every declared field type', () => { - // The union a Start field can declare. Deriving from `subBlockTypeForField` means a type + // The union a Start field can declare. Deriving from `subBlockTypeForValueType` means a type // added there surfaces here instead of silently falling through to a text box — which is // exactly how `file[]` came to be mis-rendered. const byCanvasKind = { @@ -40,7 +40,7 @@ describe('customBlockInputControl', () => { } as const for (const fieldType of ['string', 'number', 'boolean', 'object', 'array', 'file[]']) { - const canvasKind = subBlockTypeForField(fieldType) as keyof typeof byCanvasKind + const canvasKind = subBlockTypeForValueType(fieldType) as keyof typeof byCanvasKind expect(customBlockInputControl(fieldType)).toBe(byCanvasKind[canvasKind] ?? 'input') } }) diff --git a/apps/sim/ee/workspace-forking/components/fork-sync/custom-block-input-control.ts b/apps/sim/ee/workspace-forking/components/fork-sync/custom-block-input-control.ts index 996bcf8e496..e6d97ca047c 100644 --- a/apps/sim/ee/workspace-forking/components/fork-sync/custom-block-input-control.ts +++ b/apps/sim/ee/workspace-forking/components/fork-sync/custom-block-input-control.ts @@ -1,10 +1,10 @@ import type { ForkDependentReconfig } from '@/lib/api/contracts/workspace-fork' -import { subBlockTypeForField } from '@/blocks/custom/build-config' +import { subBlockTypeForValueType } from '@/tools/param-shape' /** * Which control the sync modal renders for a repointed custom block's input. * - * Derived from `subBlockTypeForField` — the same function that decides what the field becomes + * Derived from `subBlockTypeForValueType` — the same function that decides what the field becomes * on the canvas — rather than re-reading the raw field type. The modal cannot reuse the canvas * sub-block renderer (that one is bound to the workflow store, by workflow and block id), so it * draws its own controls; taking the field's KIND from one place is what stops the two drifting @@ -17,7 +17,7 @@ import { subBlockTypeForField } from '@/blocks/custom/build-config' export type CustomBlockInputControl = 'switch' | 'textarea' | 'input' | 'unsupported' export function customBlockInputControl(fieldType: string | undefined): CustomBlockInputControl { - switch (subBlockTypeForField(fieldType ?? '')) { + switch (subBlockTypeForValueType(fieldType ?? '')) { // Stored as a real boolean on the canvas, so it must be toggled rather than typed — a text // field would persist the string `'true'`. case 'switch': diff --git a/apps/sim/ee/workspace-forking/lib/copy/cleanup-failed.test.ts b/apps/sim/ee/workspace-forking/lib/copy/cleanup-failed.test.ts index e36cfc54c22..bd591e6e5ea 100644 --- a/apps/sim/ee/workspace-forking/lib/copy/cleanup-failed.test.ts +++ b/apps/sim/ee/workspace-forking/lib/copy/cleanup-failed.test.ts @@ -24,7 +24,6 @@ vi.mock('@/lib/workflows/persistence/utils', () => ({ // remap module never pulls the full registry (this file only exercises top-level selectors). vi.mock('@/tools/params', () => ({ getToolIdForOperation: () => undefined, - getToolParametersConfig: () => null, getSubBlocksForToolInput: ( _toolId: string, _type: string, 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 ed8afc7c651..e1da0f5538d 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 @@ -1299,10 +1299,10 @@ describe('collectForkDependentReconfigs — nested tool params follow ParameterV }) it('fails closed when an authoritative entry carries no visibility', () => { - // The real resolver's `uncoveredParams` branch builds its config via - // `buildToolInputSearchConfig`, which does NOT copy `paramVisibility` - so the map holds - // the key with an `undefined` value. That must fall back to the block-level `required`, - // not be read as "not user-only". + // The resolver now sets `paramVisibility` on every authoritative config, so this state + // should be unreachable. The guard stays anyway: reading a missing visibility as + // "not user-only" would withhold a target's redeploy, so it must fall back to the + // block-level `required`. agentWithJiraTool() mockGetToolInputParamConfigs.mockReturnValue([ { 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 f0401b4c9ad..d021847a89f 100644 --- a/apps/sim/ee/workspace-forking/lib/mapping/dependent-reconfigs.ts +++ b/apps/sim/ee/workspace-forking/lib/mapping/dependent-reconfigs.ts @@ -98,8 +98,9 @@ interface EmitAnchoredParams { * * Two cases fall back to the block-level `required`, failing closed: a param absent from * the map (custom-tool / MCP generic fallback, or an unresolvable tool id), and a param - * present with an `undefined` value — the resolver's `buildToolInputSearchConfig` branch - * does not copy `paramVisibility`, so an authoritative entry can still carry none. + * present with an `undefined` value. Every authoritative config now carries a resolved + * visibility, so the second case should be unreachable — it stays a guard rather than an + * assertion because reading it wrong silently withholds a target's redeploy. */ paramVisibilityById?: Map out: ForkDependentReconfig[] diff --git a/apps/sim/ee/workspace-forking/lib/promote/cleared-refs.test.ts b/apps/sim/ee/workspace-forking/lib/promote/cleared-refs.test.ts index e988d239cec..12624c717a2 100644 --- a/apps/sim/ee/workspace-forking/lib/promote/cleared-refs.test.ts +++ b/apps/sim/ee/workspace-forking/lib/promote/cleared-refs.test.ts @@ -8,7 +8,6 @@ import type { SubBlockConfig } from '@/blocks/types' // remap module never pulls the full registry (these cases use top-level selectors / dependents). vi.mock('@/tools/params', () => ({ getToolIdForOperation: () => undefined, - getToolParametersConfig: () => null, getSubBlocksForToolInput: ( _toolId: string, _type: string, 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 dc5bbd80d9a..92a1113241c 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 @@ -23,7 +23,6 @@ const { mockGetToolIdForOperation, mockGetSubBlocksForToolInput } = vi.hoisted(( vi.mock('@/tools/params', () => ({ getToolIdForOperation: mockGetToolIdForOperation, - getToolParametersConfig: () => null, getSubBlocksForToolInput: mockGetSubBlocksForToolInput, formatParameterLabel: (label: string) => label, })) diff --git a/apps/sim/executor/constants.ts b/apps/sim/executor/constants.ts index c7667f65a73..140e3cadd5f 100644 --- a/apps/sim/executor/constants.ts +++ b/apps/sim/executor/constants.ts @@ -38,6 +38,7 @@ export enum BlockType { RESPONSE = 'response', HUMAN_IN_THE_LOOP = 'human_in_the_loop', + HUMAN_IN_THE_LOOP_V2 = 'human_in_the_loop_v2', WORKFLOW = 'workflow', WORKFLOW_INPUT = 'workflow_input', @@ -52,6 +53,25 @@ export enum BlockType { SENTINEL_END = 'sentinel_end', } +/** + * Every Human block version. + * + * v2 exists because its notification tools run through the same param transform an + * agent block applies — canonical basic/advanced resolution and the block's own + * `tools.config.params` function — which changes what a configured tool receives. A + * single predicate keeps the two versions from drifting apart at the ten sites that + * ask "is this the Human block?". + */ +export const HUMAN_IN_THE_LOOP_BLOCK_TYPES: readonly string[] = [ + BlockType.HUMAN_IN_THE_LOOP, + BlockType.HUMAN_IN_THE_LOOP_V2, +] + +/** Whether a block type is any version of the Human block. */ +export function isHumanInTheLoopBlock(blockType: string | undefined | null): boolean { + return typeof blockType === 'string' && HUMAN_IN_THE_LOOP_BLOCK_TYPES.includes(blockType) +} + export const TRIGGER_BLOCK_TYPES = [ BlockType.START_TRIGGER, BlockType.STARTER, diff --git a/apps/sim/executor/dag/construction/nodes.ts b/apps/sim/executor/dag/construction/nodes.ts index a8ac2284826..51e66acc5a2 100644 --- a/apps/sim/executor/dag/construction/nodes.ts +++ b/apps/sim/executor/dag/construction/nodes.ts @@ -1,4 +1,4 @@ -import { BlockType, isMetadataOnlyBlockType } from '@/executor/constants' +import { isHumanInTheLoopBlock, isMetadataOnlyBlockType } from '@/executor/constants' import type { DAG } from '@/executor/dag/builder' import { buildBranchNodeId } from '@/executor/utils/subflow-utils' import type { SerializedBlock, SerializedWorkflow } from '@/serializer/types' @@ -108,7 +108,7 @@ export class NodeConstructor { subflowType: 'parallel', branchIndex: 0, branchTotal: 1, - isPauseResponse: block.metadata?.id === BlockType.HUMAN_IN_THE_LOOP, + isPauseResponse: isHumanInTheLoopBlock(block.metadata?.id), originalBlockId: block.id, }, }) @@ -121,7 +121,7 @@ export class NodeConstructor { ): void { const isLoopNode = blocksInLoops.has(block.id) const loopId = isLoopNode ? this.findLoopIdForBlock(block.id, dag) : undefined - const isPauseBlock = block.metadata?.id === BlockType.HUMAN_IN_THE_LOOP + const isPauseBlock = isHumanInTheLoopBlock(block.metadata?.id) dag.nodes.set(block.id, { id: block.id, diff --git a/apps/sim/executor/execution/block-executor.ts b/apps/sim/executor/execution/block-executor.ts index 313989475aa..be84046151a 100644 --- a/apps/sim/executor/execution/block-executor.ts +++ b/apps/sim/executor/execution/block-executor.ts @@ -23,6 +23,7 @@ import { CHILD_TRACE_DISABLED_OUTPUT_KEY, DEFAULTS, EDGE, + isHumanInTheLoopBlock, isSentinelBlockType, isWorkflowBlockType, } from '@/executor/constants' @@ -180,7 +181,7 @@ export class BlockExecutor { } let cleanupSelfReference: (() => void) | undefined - if (block.metadata?.id === BlockType.HUMAN_IN_THE_LOOP) { + if (isHumanInTheLoopBlock(block.metadata?.id)) { cleanupSelfReference = this.preparePauseResumeSelfReference( blockCtx, node, diff --git a/apps/sim/executor/handlers/agent/agent-handler.ts b/apps/sim/executor/handlers/agent/agent-handler.ts index fa346f84466..ef359254ea6 100644 --- a/apps/sim/executor/handlers/agent/agent-handler.ts +++ b/apps/sim/executor/handlers/agent/agent-handler.ts @@ -92,6 +92,7 @@ import { import type { ProviderToolConfig } from '@/providers/types' import { getProviderFromModel, transformBlockTool } from '@/providers/utils' import type { SerializedBlock } from '@/serializer/types' +import { buildJsonSchemaParamShapes, decodeToolParams } from '@/tools/param-shape' import { filterSchemaForLLM, type ToolSchema, ToolSchemaEnrichmentError } from '@/tools/params' import { getTool } from '@/tools/utils' import { getToolAsync } from '@/tools/utils.server' @@ -859,9 +860,14 @@ export class AgentBlockHandler implements BlockHandler { const formattedParams = formattedTool.params ?? {} if (isCustomBlockType(tool.type)) { + // Same sub-blocks the raw copy was assembled with, so both sides decode alike and + // the projection keeps the shape the provenance registry compares. return { ...formattedParams, - inputMapping: assembleCustomBlockInputMapping(projectedParams), + inputMapping: assembleCustomBlockInputMapping( + projectedParams, + formattedTool.customBlockInputFields + ), } } @@ -871,10 +877,15 @@ export class AgentBlockHandler implements BlockHandler { Object.hasOwn(projectedParams, key) ? projectedParams[key] : formattedParams[key], ]) ) - if (tool.type === 'mcp' || tool.type === 'custom-tool') return alignedParams - - const blockInputs = tool.type ? getBlock(tool.type)?.inputs : undefined - return prepareResolvedSecretProjectedInputs(alignedParams, blockInputs, formattedParams) + // An MCP tool has no block, so its only structured keys are the ones its own + // `paramsTransform` decodes. A custom tool has neither. + const blockInputs = + tool.type && tool.type !== 'mcp' && tool.type !== 'custom-tool' + ? getBlock(tool.type)?.inputs + : undefined + return prepareResolvedSecretProjectedInputs(alignedParams, blockInputs, formattedParams, { + additionalStructuredKeys: formattedTool.jsonShapedParamKeys, + }) } private async createCustomTool( @@ -1337,12 +1348,23 @@ export class AgentBlockHandler implements BlockHandler { const filteredSchema = filterSchemaForLLM(config.schema, config.userProvidedParams) const toolId = createMcpToolId(config.serverId, config.toolName) + // An MCP tool row renders its arguments through the same sub-block controls a block + // tool uses, so its stored values are stringified the same way and need the same + // decode. The shapes come from the tool's own JSON Schema, which is what chose the + // controls in the first place. + const paramShapes = buildJsonSchemaParamShapes(config.schema) + const jsonShapedParamKeys = [...paramShapes] + .filter(([, shape]) => shape === 'json') + .map(([paramId]) => paramId) + return { id: toolId, description: config.description, parameters: filteredSchema, params: config.userProvidedParams, usageControl: config.usageControl || 'auto', + paramsTransform: (params: Record) => decodeToolParams(params, paramShapes), + ...(jsonShapedParamKeys.length > 0 && { jsonShapedParamKeys }), } } diff --git a/apps/sim/executor/handlers/human-in-the-loop/human-in-the-loop-handler.ts b/apps/sim/executor/handlers/human-in-the-loop/human-in-the-loop-handler.ts index 1b019b01805..7cb94a0290a 100644 --- a/apps/sim/executor/handlers/human-in-the-loop/human-in-the-loop-handler.ts +++ b/apps/sim/executor/handlers/human-in-the-loop/human-in-the-loop-handler.ts @@ -1,6 +1,8 @@ import { createLogger } from '@sim/logger' import { isRecordLike } from '@sim/utils/object' import { getBaseUrl } from '@/lib/core/utils/urls' +import type { CanonicalGroup } from '@/lib/workflows/subblocks/visibility' +import { getBlock } from '@/blocks/registry' import type { BlockOutput } from '@/blocks/types' import { BlockType, @@ -8,6 +10,7 @@ import { buildResumeUiUrl, type FieldType, HTTP, + isHumanInTheLoopBlock, normalizeName, PAUSE_RESUME, } from '@/executor/constants' @@ -19,8 +22,15 @@ import type { BlockHandler, ExecutionContext, PauseMetadata } from '@/executor/t import { collectBlockData } from '@/executor/utils/block-data' import { convertBuilderDataToJson, convertPropertyValue } from '@/executor/utils/builder-data' import { parseObjectStrings } from '@/executor/utils/json' +import { buildBlockToolParamsTransform } from '@/providers/utils' import type { SerializedBlock } from '@/serializer/types' import { executeTool } from '@/tools' +import { + buildCanonicalIndex, + isCanonicalPair, + scopeCanonicalModesForTool, +} from '@/tools/params-resolver' +import { getTool } from '@/tools/utils' const logger = createLogger('HumanInTheLoopBlockHandler') @@ -60,7 +70,7 @@ interface NotificationToolResult { export class HumanInTheLoopBlockHandler implements BlockHandler { canHandle(block: SerializedBlock): boolean { - return block.metadata?.id === BlockType.HUMAN_IN_THE_LOOP + return isHumanInTheLoopBlock(block.metadata?.id) } async execute( @@ -379,6 +389,67 @@ export class HumanInTheLoopBlockHandler implements BlockHandler { return { ...defaultHeaders, ...headerObj } } + /** + * The arguments a configured notification tool runs with. + * + * v2 applies the same pipeline every other surface uses for a block tool: canonical + * basic/advanced resolution, the stringified-value decode, the block's own + * `tools.config.params` mapping, and the `json`/`array` input parse. v1 handed the + * tool its stored sub-block values verbatim, so a channel picked in advanced mode + * arrived under `manualChannel` and the tool's declared `channel` was never set. + * + * v1 keeps that behavior deliberately — changing what an already-configured + * notification sends is why v2 exists as a separate block rather than a fix in place. + */ + private prepareNotificationToolParams( + block: SerializedBlock, + toolConfig: any, + toolId: string, + toolIndex: number + ): Record { + const storedParams = (toolConfig.params ?? {}) as Record + const notificationBlock = toolConfig.type ? getBlock(toolConfig.type) : undefined + + if (block.metadata?.id !== BlockType.HUMAN_IN_THE_LOOP_V2) { + return storedParams + } + + const subBlocks = notificationBlock?.subBlocks ?? [] + // canonical-index-unscoped: a notification tool resolves against its stored params, + // which only ever hold action-surface values. Pairs only, matching `transformBlockTool` + // — a single-member group has no inactive side to collapse away. + const canonicalGroups = ( + Object.values(buildCanonicalIndex(subBlocks).groupsById) as CanonicalGroup[] + ).filter(isCanonicalPair) + + const { paramsTransform } = buildBlockToolParamsTransform({ + blockSubBlocks: subBlocks, + blockParamsFn: notificationBlock?.tools?.config?.params, + blockInputDefs: notificationBlock?.inputs, + toolParams: getTool(toolId)?.params, + canonicalGroups, + scopedCanonicalModes: scopeCanonicalModesForTool( + block.canonicalModes, + toolIndex, + toolConfig.type + ), + }) + + if (!paramsTransform) return storedParams + + try { + return paramsTransform(storedParams) + } catch (error) { + // Mirrors `prepareToolExecution`: a transform that throws must not take the + // notification down with it, so the raw params still go out. + logger.warn('Notification tool params transform failed, using raw params', { + toolId, + error, + }) + return storedParams + } + } + private async executeNotificationTools( ctx: ExecutionContext, block: SerializedBlock, @@ -441,77 +512,86 @@ export class HumanInTheLoopBlockHandler implements BlockHandler { blockNameMappingWithPause[normalizeName(pauseBlockName)] = pauseBlockId } - const notificationPromises = tools.map>(async (toolConfig) => { - const startTime = Date.now() - try { - const toolId = toolConfig.toolId - if (!toolId) { - logger.warn('Notification tool missing toolId', { toolConfig }) - return { - toolId: 'unknown', - title: toolConfig.title, - operation: toolConfig.operation, - success: false, + const notificationPromises = tools.map>( + async (toolConfig, index) => { + const startTime = Date.now() + try { + const toolId = toolConfig.toolId + if (!toolId) { + logger.warn('Notification tool missing toolId', { toolConfig }) + return { + toolId: 'unknown', + title: toolConfig.title, + operation: toolConfig.operation, + success: false, + } } - } - const toolParams = { - ...toolConfig.params, - _pauseContext: { - resumeApiUrl: context.resumeLinks?.apiUrl, - resumeUiUrl: context.resumeLinks?.uiUrl, - executionId: context.executionId, - workflowId: context.workflowId, - contextId: context.resumeLinks?.contextId, - inputFormat: context.inputFormat, - responseStructure: context.responseStructure, - operation: context.operation, - }, - _context: { - workflowId: ctx.workflowId, - workspaceId: ctx.workspaceId, - userId: ctx.userId, - isDeployedContext: ctx.isDeployedContext, - enforceCredentialAccess: ctx.enforceCredentialAccess, - }, - blockData: blockDataWithPause, - blockNameMapping: blockNameMappingWithPause, - } + const preparedParams = this.prepareNotificationToolParams( + block, + toolConfig, + toolId, + index + ) + + const toolParams = { + ...preparedParams, + _pauseContext: { + resumeApiUrl: context.resumeLinks?.apiUrl, + resumeUiUrl: context.resumeLinks?.uiUrl, + executionId: context.executionId, + workflowId: context.workflowId, + contextId: context.resumeLinks?.contextId, + inputFormat: context.inputFormat, + responseStructure: context.responseStructure, + operation: context.operation, + }, + _context: { + workflowId: ctx.workflowId, + workspaceId: ctx.workspaceId, + userId: ctx.userId, + isDeployedContext: ctx.isDeployedContext, + enforceCredentialAccess: ctx.enforceCredentialAccess, + }, + blockData: blockDataWithPause, + blockNameMapping: blockNameMappingWithPause, + } - const result = await executeTool(toolId, toolParams, { executionContext: ctx }) - const durationMs = Date.now() - startTime + const result = await executeTool(toolId, toolParams, { executionContext: ctx }) + const durationMs = Date.now() - startTime + + if (!result.success) { + logger.warn('Notification tool execution failed', { + toolId, + error: result.error, + }) + return { + toolId, + title: toolConfig.title, + operation: toolConfig.operation, + success: false, + durationMs, + } + } - if (!result.success) { - logger.warn('Notification tool execution failed', { - toolId, - error: result.error, - }) return { toolId, title: toolConfig.title, operation: toolConfig.operation, - success: false, + success: true, durationMs, } - } - - return { - toolId, - title: toolConfig.title, - operation: toolConfig.operation, - success: true, - durationMs, - } - } catch (error) { - logger.error('Error executing notification tool', { error, toolConfig }) - return { - toolId: toolConfig.toolId || 'unknown', - title: toolConfig.title, - operation: toolConfig.operation, - success: false, + } catch (error) { + logger.error('Error executing notification tool', { error, toolConfig }) + return { + toolId: toolConfig.toolId || 'unknown', + title: toolConfig.title, + operation: toolConfig.operation, + success: false, + } } } - }) + ) return Promise.all(notificationPromises) } diff --git a/apps/sim/executor/handlers/human-in-the-loop/notification-params.test.ts b/apps/sim/executor/handlers/human-in-the-loop/notification-params.test.ts new file mode 100644 index 00000000000..bcccb4fa925 --- /dev/null +++ b/apps/sim/executor/handlers/human-in-the-loop/notification-params.test.ts @@ -0,0 +1,120 @@ +/** + * @vitest-environment node + * + * What a configured notification tool actually receives, per Human block version. + * + * v1 handed the tool its stored sub-block values verbatim. v2 runs the same pipeline + * every other surface uses for a block tool — canonical basic/advanced resolution, the + * stringified-value decode, and the block's own `tools.config.params` mapping. That + * difference is the entire reason v2 exists as a separate block. + */ +import { describe, expect, it, vi } from 'vitest' + +const notifierBlock = { + name: 'Notifier', + description: '', + category: 'tools', + subBlocks: [ + { id: 'channel', type: 'channel-selector', canonicalParamId: 'channel', mode: 'basic' }, + { id: 'manualChannel', type: 'short-input', canonicalParamId: 'channel', mode: 'advanced' }, + { id: 'silent', type: 'switch' }, + ], + tools: { + access: ['notifier_send'], + config: { params: (p: any) => ({ resolvedChannel: p.channel, quiet: p.silent === true }) }, + }, + inputs: {}, + outputs: {}, +} + +vi.mock('@/blocks/registry', () => ({ + getBlock: (type: string) => (type === 'notifier' ? notifierBlock : undefined), +})) + +vi.mock('@/tools/utils', () => ({ + getTool: () => ({ + id: 'notifier_send', + params: { channel: { type: 'string' }, silent: { type: 'boolean' } }, + }), +})) + +const executed: Array> = [] +vi.mock('@/tools', () => ({ + executeTool: async (_toolId: string, params: Record) => { + executed.push(params) + return { success: true, output: {} } + }, +})) + +import { PAUSE_RESUME } from '@/executor/constants' +import { HumanInTheLoopBlockHandler } from '@/executor/handlers/human-in-the-loop/human-in-the-loop-handler' + +/** Runs one notification whose channel was configured in ADVANCED mode. */ +async function runNotification(blockTypeId: string): Promise> { + executed.length = 0 + + const block = { + id: 'b1', + metadata: { id: blockTypeId }, + position: { x: 0, y: 0 }, + config: { tool: '', params: {} }, + inputs: {}, + outputs: {}, + enabled: true, + canonicalModes: { '0:channel': 'advanced' }, + } as never + + await new HumanInTheLoopBlockHandler().execute( + { workflowId: 'w', executionId: 'e', blockStates: new Map() } as never, + block, + { + operation: PAUSE_RESUME.OPERATION.HUMAN, + notification: [ + { + type: 'notifier', + toolId: 'notifier_send', + title: 'Notify', + params: { channel: '', manualChannel: 'C123', silent: 'false' }, + }, + ], + } + ) + + const params = { ...executed[0] } + for (const key of ['_pauseContext', '_context', 'blockData', 'blockNameMapping']) { + delete params[key] + } + return params +} + +describe('Human block notification params', () => { + it('v2 resolves the canonical pair, decodes the switch, and runs the block params fn', async () => { + expect(await runNotification('human_in_the_loop_v2')).toEqual({ + // Advanced mode collapsed onto the id the tool declares. + channel: 'C123', + // The stringified switch became a real boolean. + silent: false, + // The block's own mapping ran. + resolvedChannel: 'C123', + quiet: false, + }) + }) + + it('v1 keeps handing the tool its raw stored values', async () => { + // Deliberately unchanged: altering what an already-configured v1 notification sends + // is exactly the break v2 exists to avoid. + expect(await runNotification('human_in_the_loop')).toEqual({ + channel: '', + manualChannel: 'C123', + silent: 'false', + }) + }) + + it('handles both versions', () => { + const handler = new HumanInTheLoopBlockHandler() + for (const id of ['human_in_the_loop', 'human_in_the_loop_v2']) { + expect(handler.canHandle({ metadata: { id } } as never)).toBe(true) + } + expect(handler.canHandle({ metadata: { id: 'agent' } } as never)).toBe(false) + }) +}) diff --git a/apps/sim/executor/handlers/pi/local/sim-tools.ts b/apps/sim/executor/handlers/pi/local/sim-tools.ts index 876bb7e99ee..acb590d644c 100644 --- a/apps/sim/executor/handlers/pi/local/sim-tools.ts +++ b/apps/sim/executor/handlers/pi/local/sim-tools.ts @@ -122,7 +122,32 @@ function buildSimToolSpec( properties: {}, }, execute: async (args) => { - const params = mergeToolParameters(preseededParams, args as Record) + /** + * The same transform the agent block applies before executing a tool: canonical + * basic/advanced resolution, the block's own `params` function, and the decode + * that turns the tool row's stringified values back into the shapes the tool + * declares. Skipping it here left Pi local tools receiving a raw selector id + * where the tool expected a resolved one, and `'false'` where it expected `false`. + * + * A failure keeps the raw params, matching `prepareToolExecution`. The projected + * copy is only transformed when the raw one was, so the two stay comparable. + */ + let transformApplied = false + const applyParamsTransform = (input: Record): Record => { + if (!provider.paramsTransform) return input + try { + const transformed = provider.paramsTransform(input) + transformApplied = true + return transformed + } catch (error) { + logger.warn('paramsTransform failed for Pi local tool, using raw params', { error }) + return input + } + } + + const params = applyParamsTransform( + mergeToolParameters(preseededParams, args as Record) + ) const registry = ctx.resolvedSecretTraceRegistry const sourcePath = ['tools', String(toolIndex), 'params'] as const const toolCallRegistry = registry?.forkForInputPaths([sourcePath], { @@ -142,10 +167,27 @@ function buildSimToolSpec( if (!inputProjection.complete || !projectedTool) { return unavailableToolResult() } - const projectedParams = mergeToolParameters( + const mergedProjectedParams = mergeToolParameters( projectedTool.params || {}, args as Record ) + + // The projected copy has to go through the SAME transform, or its shape diverges + // from the executed params and the comparison below reads that as a provenance + // failure. If the transform succeeded for the real params but throws here, fail + // closed rather than pairing transformed params with untransformed projected ones + // — mirrors `prepareToolExecution`'s `tool-params-transform-failed`. + let projectedParams = mergedProjectedParams + if (transformApplied && provider.paramsTransform) { + try { + projectedParams = provider.paramsTransform(mergedProjectedParams) + } catch (error) { + logger.warn('paramsTransform failed for the Pi local tool projection', { error }) + toolCallRegistry.markIncomplete('tool-params-transform-failed') + return unavailableToolResult() + } + } + toolCallRegistry.recordTransformedInputProjection(params, projectedParams) if (!toolCallRegistry.isComplete()) return unavailableToolResult() } diff --git a/apps/sim/executor/utils/resolved-secret-input-projection.ts b/apps/sim/executor/utils/resolved-secret-input-projection.ts index b10954192df..6b6fee355af 100644 --- a/apps/sim/executor/utils/resolved-secret-input-projection.ts +++ b/apps/sim/executor/utils/resolved-secret-input-projection.ts @@ -138,23 +138,35 @@ function projectFileReferenceLeaves( } /** - * Makes only schema-declared structured inputs parseable on a private placeholder projection. + * Makes only structured inputs parseable on a private placeholder projection. * Raw execution inputs are never passed to or changed by this helper. + * + * A key qualifies either by declaring `json`/`array` in the block's `inputs`, or by + * appearing in `additionalStructuredKeys` — the params an agent tool's + * `paramsTransform` decodes from a JSON string. Both end up as an object on the real + * execution params, so both must keep the projected copy in the same shape. */ export function prepareResolvedSecretProjectedInputs( inputs: Record, inputSchemas: Record | undefined, rawInputs?: Record, - options: { preserveFileDescriptorGrammar?: boolean } = {} + options: { + preserveFileDescriptorGrammar?: boolean + additionalStructuredKeys?: readonly string[] + } = {} ): Record { - if (!inputSchemas) return inputs - const prepared = { ...inputs } - for (const [key, inputSchema] of Object.entries(inputSchemas)) { + const structuredKeys = new Set(options.additionalStructuredKeys ?? []) + for (const [key, inputSchema] of Object.entries(inputSchemas ?? {})) { const inputType = inputSchema && typeof inputSchema === 'object' ? (inputSchema as { type?: unknown }).type : inputSchema - if (inputType !== 'json' && inputType !== 'array') continue + if (inputType === 'json' || inputType === 'array') structuredKeys.add(key) + } + + if (structuredKeys.size === 0) return inputs + const prepared = { ...inputs } + for (const key of structuredKeys) { const value = prepared[key] if (typeof value === 'string') { const placeholder = canonicalPlaceholder(value) diff --git a/apps/sim/lib/integrations/icon-mapping.ts b/apps/sim/lib/integrations/icon-mapping.ts index 17e8aab8cc8..1927f934f5f 100644 --- a/apps/sim/lib/integrations/icon-mapping.ts +++ b/apps/sim/lib/integrations/icon-mapping.ts @@ -116,6 +116,7 @@ import { HexIcon, HubspotIcon, HuggingFaceIcon, + HumanInTheLoopIcon, HunterIOIcon, IAMIcon, IcypeasIcon, @@ -394,6 +395,7 @@ export const blockTypeToIconMap: Record = { hex: HexIcon, hubspot: HubspotIcon, huggingface: HuggingFaceIcon, + human_in_the_loop_v2: HumanInTheLoopIcon, hunter: HunterIOIcon, iam: IAMIcon, icypeas: IcypeasIcon, diff --git a/apps/sim/lib/permission-groups/block-successors.generated.ts b/apps/sim/lib/permission-groups/block-successors.generated.ts index f7a2a702ad9..d4f7c30c858 100644 --- a/apps/sim/lib/permission-groups/block-successors.generated.ts +++ b/apps/sim/lib/permission-groups/block-successors.generated.ts @@ -24,6 +24,7 @@ export const BLOCK_ACCESS_SUCCESSORS: Record = { google_sheets: 'google_sheets_v2', google_slides: 'google_slides_v2', grain: 'grain_v2', + human_in_the_loop: 'human_in_the_loop_v2', image_generator: 'image_generator_v2', input_trigger: 'start_trigger', intercom: 'intercom_v2', diff --git a/apps/sim/lib/workflows/application/read-workflow-copilot-metadata.ts b/apps/sim/lib/workflows/application/read-workflow-copilot-metadata.ts index ef6c2af4151..62e2a4f9de9 100644 --- a/apps/sim/lib/workflows/application/read-workflow-copilot-metadata.ts +++ b/apps/sim/lib/workflows/application/read-workflow-copilot-metadata.ts @@ -13,7 +13,7 @@ import { loadWorkflowFromNormalizedTables } from '@/lib/workflows/persistence/ut import { resolveTriggerRunOptions, toPublicRunOption } from '@/lib/workflows/triggers/run-options' import { hasTriggerCapability } from '@/lib/workflows/triggers/trigger-utils' import { getBlock } from '@/blocks/registry' -import { normalizeName } from '@/executor/constants' +import { isHumanInTheLoopBlock, normalizeName } from '@/executor/constants' const MAX_COPILOT_BLOCK_IDS = 100 @@ -240,7 +240,7 @@ export const readCopilotWorkflowUpstreamReferences = defineAuthorizedWorkflowUse for (const accessibleBlockId of accessibleIds) { const block = blocks[accessibleBlockId] if (!block?.type) continue - const canSelfReference = block.type === 'approval' || block.type === 'human_in_the_loop' + const canSelfReference = block.type === 'approval' || isHumanInTheLoopBlock(block.type) if (accessibleBlockId === blockId && !canSelfReference) continue const blockName = block.name || block.type diff --git a/apps/sim/lib/workflows/blocks/block-outputs.ts b/apps/sim/lib/workflows/blocks/block-outputs.ts index b36f9ec3ac3..3556a5150c0 100644 --- a/apps/sim/lib/workflows/blocks/block-outputs.ts +++ b/apps/sim/lib/workflows/blocks/block-outputs.ts @@ -22,6 +22,7 @@ import { type OutputCondition, type OutputFieldDefinition, } from '@/blocks/types' +import { isHumanInTheLoopBlock } from '@/executor/constants' import { getToolOutputsMetadata } from '@/tools/metadata-outputs' import { getTrigger, isTriggerValid } from '@/triggers' @@ -322,7 +323,7 @@ export function getBlockOutputs( return getUnifiedStartOutputs(subBlocks) } - if (blockType === 'human_in_the_loop') { + if (isHumanInTheLoopBlock(blockType)) { // Start with block config outputs (respects hiddenFromDisplay via filterOutputsByCondition) const baseOutputs = filterOutputsByCondition( { ...(blockConfig.outputs || {}) } as OutputDefinition, diff --git a/apps/sim/lib/workflows/blocks/block-reference-tags.ts b/apps/sim/lib/workflows/blocks/block-reference-tags.ts index f48fb20a504..cfeadb3518a 100644 --- a/apps/sim/lib/workflows/blocks/block-reference-tags.ts +++ b/apps/sim/lib/workflows/blocks/block-reference-tags.ts @@ -2,7 +2,7 @@ import { getEffectiveBlockOutputPaths } from '@/lib/workflows/blocks/block-outpu import { hasTriggerCapability } from '@/lib/workflows/triggers/trigger-utils' import { TRIGGER_TYPES } from '@/lib/workflows/triggers/triggers' import { getBlock } from '@/blocks' -import { normalizeName } from '@/executor/constants' +import { isHumanInTheLoopBlock, normalizeName } from '@/executor/constants' interface ReferenceableBlock { id: string @@ -61,7 +61,7 @@ export function getBlockReferenceTags({ const allTags = outputPaths.map((path) => `${normalizedBlockName}.${path}`) let blockTags: string[] - if (block.type === 'human_in_the_loop' && block.id === currentBlockId) { + if (isHumanInTheLoopBlock(block.type) && block.id === currentBlockId) { blockTags = allTags.filter((tag) => tag.endsWith('.url') || tag.endsWith('.resumeEndpoint')) } else if (allTags.length === 0) { blockTags = [normalizedBlockName] diff --git a/apps/sim/lib/workflows/blocks/flatten-outputs.ts b/apps/sim/lib/workflows/blocks/flatten-outputs.ts index 44d393b6323..7c3feb35739 100644 --- a/apps/sim/lib/workflows/blocks/flatten-outputs.ts +++ b/apps/sim/lib/workflows/blocks/flatten-outputs.ts @@ -10,12 +10,17 @@ import { isRecordLike } from '@sim/utils/object' import { getEffectiveBlockOutputs } from '@/lib/workflows/blocks/block-outputs' +import { HUMAN_IN_THE_LOOP_BLOCK_TYPES } from '@/executor/constants' /** * Block types whose "outputs" are really workflow inputs (Start/starter) or flow * control and should never appear in an output picker. */ -export const EXCLUDED_OUTPUT_TYPES = new Set(['starter', 'start_trigger', 'human_in_the_loop']) +export const EXCLUDED_OUTPUT_TYPES = new Set([ + 'starter', + 'start_trigger', + ...HUMAN_IN_THE_LOOP_BLOCK_TYPES, +]) export interface FlattenedBlockOutput { blockId: string diff --git a/apps/sim/lib/workflows/blocks/retry-eligibility.ts b/apps/sim/lib/workflows/blocks/retry-eligibility.ts index a9c0d16c235..9a176af2d6f 100644 --- a/apps/sim/lib/workflows/blocks/retry-eligibility.ts +++ b/apps/sim/lib/workflows/blocks/retry-eligibility.ts @@ -1,4 +1,8 @@ -import { BlockType, isMetadataOnlyBlockType, isSentinelBlockType } from '@/executor/constants' +import { + isHumanInTheLoopBlock, + isMetadataOnlyBlockType, + isSentinelBlockType, +} from '@/executor/constants' interface RetryEligibilityInput { blockType: string | undefined @@ -26,7 +30,7 @@ export function isRetryEligibleBlock({ triggerMode, }: RetryEligibilityInput): boolean { if (!blockType) return false - if (blockType === BlockType.HUMAN_IN_THE_LOOP) return false + if (isHumanInTheLoopBlock(blockType)) return false if (triggerMode === true || category === 'triggers') return false return !isSentinelBlockType(blockType) && !isMetadataOnlyBlockType(blockType) } diff --git a/apps/sim/lib/workflows/executor/human-in-the-loop-manager.ts b/apps/sim/lib/workflows/executor/human-in-the-loop-manager.ts index b3b30634324..4cf96093dc2 100644 --- a/apps/sim/lib/workflows/executor/human-in-the-loop-manager.ts +++ b/apps/sim/lib/workflows/executor/human-in-the-loop-manager.ts @@ -1317,7 +1317,9 @@ export class PauseResumeManager { ) if (blockLogIndex !== -1) { // Filter output for logging using shared utility - // 'resume' is redundant with url/resumeEndpoint so we filter it out + // 'resume' is redundant with url/resumeEndpoint so we filter it out. + // The type is only used to read the block's `outputs` for `hiddenFromDisplay`, + // and v2 inherits that map from v1 verbatim — so both versions filter alike. const filteredOutput = filterOutputForLog('human_in_the_loop', mergedOutput, { additionalHiddenKeys: ['resume'], }) diff --git a/apps/sim/lib/workflows/search-replace/indexer-selector-context.test.ts b/apps/sim/lib/workflows/search-replace/indexer-selector-context.test.ts index 9200811ef8a..9b0c4bebcc0 100644 --- a/apps/sim/lib/workflows/search-replace/indexer-selector-context.test.ts +++ b/apps/sim/lib/workflows/search-replace/indexer-selector-context.test.ts @@ -11,46 +11,43 @@ vi.mock('@/tools/params', () => ({ formatParameterLabel: (id: string) => id, getToolIdForOperation: () => 'test_list', getSubBlocksForToolInput, - getToolParametersConfig: () => ({ - userInputParameters: [ - { - id: 'credential', - type: 'string', - required: true, - visibility: 'user-only', - uiComponent: { - type: 'short-input', - canonicalParamId: 'oauthCredential', - }, - }, - { - id: 'resourceId', - type: 'string', - required: true, - visibility: 'user-only', - uiComponent: { - type: 'dropdown', - selectorKey: 'gmail.labels', - dependsOn: ['credential'], - }, - }, - ], - }), })) import { getToolInputParamConfigs } from '@/lib/workflows/search-replace/indexer' -describe('tool-input selector fallback context', () => { +/** + * The credential and the selector that depends on it, in the shape + * `getSubBlocksForToolInput` now returns for every user-facing param — whether the + * block declares the sub-block or it was synthesized from the param's declared type. + */ +const SELECTOR_SUB_BLOCKS = [ + { + id: 'credential', + title: 'Credential', + type: 'short-input', + canonicalParamId: 'oauthCredential', + }, + { + id: 'resourceId', + title: 'Resource', + type: 'dropdown', + selectorKey: 'gmail.labels', + dependsOn: ['credential'], + }, +] + +describe('tool-input selector context', () => { beforeEach(() => getSubBlocksForToolInput.mockReset()) it.each([ - ['without generated sub-blocks', null], + ['from the tool params alone', SELECTOR_SUB_BLOCKS], [ - 'with generated sub-blocks', - { subBlocks: [{ id: 'message', title: 'Message', type: 'short-input' }] }, + 'alongside an unrelated block sub-block', + [...SELECTOR_SUB_BLOCKS, { id: 'message', title: 'Message', type: 'short-input' }], ], - ])('includes sibling display parameters $0', (_state, subBlocksResult) => { - getSubBlocksForToolInput.mockReturnValue(subBlocksResult) + ])('resolves a selector dependency %s', (_state, subBlocks) => { + getSubBlocksForToolInput.mockReturnValue({ subBlocks }) + const configs = getToolInputParamConfigs({ tool: { type: 'test', @@ -67,4 +64,15 @@ describe('tool-input selector fallback context', () => { oauthCredential: 'credential-1', }) }) + + it('returns the generic fallback when the tool has no registry definition', () => { + getSubBlocksForToolInput.mockReturnValue(null) + + const configs = getToolInputParamConfigs({ + tool: { type: 'test', operation: 'list', params: { message: 'hello' } }, + }) + + expect(configs.map((config) => config.paramId)).toEqual(['message']) + expect(configs[0].authoritative).toBe(false) + }) }) diff --git a/apps/sim/lib/workflows/search-replace/indexer.ts b/apps/sim/lib/workflows/search-replace/indexer.ts index c3c0f59cace..575d2e96db6 100644 --- a/apps/sim/lib/workflows/search-replace/indexer.ts +++ b/apps/sim/lib/workflows/search-replace/indexer.ts @@ -54,8 +54,6 @@ import { formatParameterLabel, getSubBlocksForToolInput, getToolIdForOperation, - getToolParametersConfig, - type ToolParameterConfig, } from '@/tools/params' /** @@ -177,8 +175,11 @@ function looksLikeStructuredString(value: string): boolean { ) } -function getFallbackToolParamType(value: unknown, paramType?: string): SubBlockType { - if (paramType === 'object') return 'workflow-input-mapper' +/** + * The searchable shape of a value belonging to a tool with no registry definition — a + * custom or MCP tool, where nothing declares a type. Inferred from the value itself. + */ +function getFallbackToolParamType(value: unknown): SubBlockType { if (isRecordLike(value)) return 'workflow-input-mapper' if (typeof value !== 'string') return DEFAULT_SUBBLOCK_TYPE as SubBlockType @@ -657,34 +658,6 @@ function addTextMatches({ }) } -function buildToolInputSearchConfig(param: ToolParameterConfig): WorkflowSearchSubBlockConfig { - const uiComponent = param.uiComponent - return { - id: param.id, - title: uiComponent?.title ?? param.id, - type: (uiComponent?.type ?? getFallbackToolParamType(undefined, param.type)) as SubBlockType, - placeholder: uiComponent?.placeholder, - condition: uiComponent?.condition as SubBlockConfig['condition'], - serviceId: uiComponent?.serviceId, - selectorKey: uiComponent?.selectorKey, - requiredScopes: uiComponent?.requiredScopes, - mimeType: uiComponent?.mimeType, - canonicalParamId: uiComponent?.canonicalParamId, - mode: uiComponent?.mode, - password: uiComponent?.password, - dependsOn: uiComponent?.dependsOn, - } -} - -function isVisibleToolParameter(param: ToolParameterConfig, values: Record) { - if (param.visibility === 'hidden' || param.visibility === 'llm-only') return false - const condition = param.uiComponent?.condition - return ( - !condition || - evaluateSubBlockCondition(condition as Parameters[0], values) - ) -} - export interface ResolvedToolInputParamConfig { paramId: string config: WorkflowSearchSubBlockConfig @@ -762,41 +735,22 @@ export function getToolInputParamConfigs({ scopedCanonicalModes, blockConfig?.subBlocks ? { subBlocks: blockConfig.subBlocks } : undefined ) - const toolParams = getToolParametersConfig(toolId, tool.type, values) - const displayParams = toolParams?.userInputParameters ?? [] - - if (!toolParams && !subBlocksResult) return genericFallback() - - if (!subBlocksResult?.subBlocks.length) { - const fallbackConfigs = displayParams - .filter((param) => isVisibleToolParameter(param, values)) - .map((param) => ({ param, config: buildToolInputSearchConfig(param) })) - const contextConfigs = fallbackConfigs.map(({ config }) => config) - const fallbackCanonicalIndex = buildCanonicalIndex(contextConfigs) - return fallbackConfigs.map(({ param, config }) => { - return { - paramId: param.id, - authoritative: true, - config, - value: parseToolParamValue(toolParamValues[param.id], config.type), - selectorContext: - config.selectorKey || config.dependsOn - ? buildSelectorContext({ - subBlockConfig: config, - subBlockValues: values, - contextConfigs, - canonicalIndex: fallbackCanonicalIndex, - canonicalModes: scopedCanonicalModes, - }) - : undefined, - } - }) - } + if (!subBlocksResult) return genericFallback() + + /** + * The block's own sub-blocks plus the ones synthesized for params it does not declare. + * Selector and `dependsOn` resolution needs every sibling a value could be keyed by, + * including the ones filtered out of the rendered list by a failing condition. + */ + const blockSubBlocks = blockConfig?.subBlocks ?? [] + const blockSubBlockIds = new Set(blockSubBlocks.map((subBlock) => subBlock.id)) + const allToolSubBlocks = [ + ...blockSubBlocks, + ...subBlocksResult.subBlocks.filter((subBlock) => !blockSubBlockIds.has(subBlock.id)), + ] // canonical-index-unscoped: a nested tool's params are always the action surface - const toolCanonicalIndex = buildCanonicalIndex( - blockConfig?.subBlocks ?? subBlocksResult.subBlocks - ) + const toolCanonicalIndex = buildCanonicalIndex(allToolSubBlocks) const visibleSubBlocks = subBlocksResult.subBlocks.filter((subBlock) => isToolParamVisibleForReactiveCondition({ subBlockConfig: subBlock, @@ -806,40 +760,13 @@ export function getToolInputParamConfigs({ credentialTypeById, }) ) - const allToolSubBlocks = blockConfig?.subBlocks ?? subBlocksResult.subBlocks - const displayParamConfigs = displayParams.map((param) => buildToolInputSearchConfig(param)) - const displayConfigById = new Map(displayParamConfigs.map((config) => [config.id, config])) const getDependentValuePaths = (changedSubBlockId: string): WorkflowSearchValuePath[] => getTransitiveSubBlockDependents(allToolSubBlocks, [changedSubBlockId]).map((clear) => [ 'params', clear.subBlockId, ]) - const coveredParamIds = new Set( - visibleSubBlocks.flatMap((subBlock) => { - const ids = [subBlock.id] - if (subBlock.canonicalParamId) ids.push(subBlock.canonicalParamId) - const canonicalId = toolCanonicalIndex.canonicalIdBySubBlockId[subBlock.id] - if (canonicalId) { - const group = toolCanonicalIndex.groupsById[canonicalId] - if (group) { - if (group.basicId) ids.push(group.basicId) - ids.push(...group.advancedIds) - } - } - return ids - }) - ) - const toolSubBlockIds = new Set(allToolSubBlocks.map((config) => config.id)) - const combinedContextConfigs = [ - ...allToolSubBlocks, - ...displayParamConfigs.filter( - (config) => !coveredParamIds.has(config.id) && !toolSubBlockIds.has(config.id) - ), - ] - const combinedCanonicalIndex = buildCanonicalIndex(combinedContextConfigs) - - const subBlockParams = visibleSubBlocks.map((config) => ({ + return visibleSubBlocks.map((config) => ({ paramId: config.id, authoritative: true, config, @@ -856,29 +783,6 @@ export function getToolInputParamConfigs({ }) : undefined, })) - const uncoveredParams = displayParams - .filter((param) => !coveredParamIds.has(param.id) && isVisibleToolParameter(param, values)) - .map((param) => { - const config = displayConfigById.get(param.id) ?? buildToolInputSearchConfig(param) - return { - paramId: param.id, - authoritative: true, - config, - value: parseToolParamValue(toolParamValues[param.id], config.type), - selectorContext: - config.selectorKey || config.dependsOn - ? buildSelectorContext({ - subBlockConfig: config, - subBlockValues: values, - contextConfigs: combinedContextConfigs, - canonicalIndex: combinedCanonicalIndex, - canonicalModes: scopedCanonicalModes, - }) - : undefined, - } - }) - - return [...subBlockParams, ...uncoveredParams] } function buildSelectorContext({ diff --git a/apps/sim/lib/workflows/search-replace/json-value-fields.ts b/apps/sim/lib/workflows/search-replace/json-value-fields.ts index 2b4c8a0dbbf..37b78cc08f7 100644 --- a/apps/sim/lib/workflows/search-replace/json-value-fields.ts +++ b/apps/sim/lib/workflows/search-replace/json-value-fields.ts @@ -4,6 +4,7 @@ import type { WorkflowSearchValuePath, } from '@/lib/workflows/search-replace/types' import { getValueAtPath, setValueAtPath } from '@/lib/workflows/search-replace/value-walker' +import { holdsObjectValue } from '@/tools/param-shape' const SEARCHABLE_JSON_ARRAY_VALUE_FIELDS: Partial>> = { 'condition-input': { @@ -29,12 +30,6 @@ const SEARCHABLE_JSON_OBJECT_VALUE_FIELDS: Partial> 'workflow-input-mapper': 'Value', } -const SERIALIZED_SUBBLOCK_VALUE_TYPES = new Set([ - 'file-upload', - 'grouped-checkbox-list', - 'table', -]) - export interface SearchableJsonStringLeaf { path: WorkflowSearchValuePath value: string @@ -120,8 +115,7 @@ export function shouldParseSerializedSubBlockValue( ): subBlockType is SubBlockType { return Boolean( subBlockType && - (isSearchableJsonValueSubBlock(subBlockType) || - SERIALIZED_SUBBLOCK_VALUE_TYPES.has(subBlockType)) + (isSearchableJsonValueSubBlock(subBlockType) || holdsObjectValue({ type: subBlockType })) ) } diff --git a/apps/sim/lib/workflows/tool-input/synthetic-subblocks.ts b/apps/sim/lib/workflows/tool-input/synthetic-subblocks.ts index 59d948a36c6..194bbf93be0 100644 --- a/apps/sim/lib/workflows/tool-input/synthetic-subblocks.ts +++ b/apps/sim/lib/workflows/tool-input/synthetic-subblocks.ts @@ -1,3 +1,5 @@ +import { encodeToolParamValue } from '@/tools/param-shape' + const TOOL_SUBBLOCK_INFIX = '-tool-' const SYNTHETIC_TOOL_SUBBLOCK_RE = new RegExp(`${TOOL_SUBBLOCK_INFIX}\\d+-`) @@ -48,12 +50,7 @@ export function resolveToolParamSync( ): ToolParamSyncAction { if (storeValue === undefined) return { action: 'reproject' } - const stringified = - storeValue === null - ? '' - : typeof storeValue === 'string' - ? storeValue - : JSON.stringify(storeValue) + const stringified = encodeToolParamValue(storeValue) if (stringified === syncedValue) return { action: 'noop' } return { action: 'mirror', value: stringified } diff --git a/apps/sim/providers/types.ts b/apps/sim/providers/types.ts index 2d2f2c6c9be..4bebf5102ca 100644 --- a/apps/sim/providers/types.ts +++ b/apps/sim/providers/types.ts @@ -1,4 +1,5 @@ import type { BillingAttributionSnapshot } from '@/lib/billing/core/billing-attribution' +import type { CustomBlockInputFieldType } from '@/blocks/custom/build-config' import type { ProviderTimingSegment, StreamingExecution, UserFile } from '@/executor/types' export type ProviderId = @@ -139,6 +140,25 @@ export interface ProviderToolConfig { modelBlockedParams?: string[] /** Block-level params transformer — converts SubBlock values to tool-ready params */ paramsTransform?: (params: Record) => Record + /** + * Params {@link ProviderToolConfig.paramsTransform} decodes from a JSON string into + * an object or array. + * + * The resolved-secret projection must give these keys the same treatment it gives a + * `json`/`array` block input: a projected copy holds `{{NAME}}` placeholders that are + * not valid JSON, so without this the real params parse to an object while the + * projected ones stay a string, and the shape divergence silently marks the + * provenance registry incomplete. + */ + jsonShapedParamKeys?: readonly string[] + /** + * A custom (deploy-as-block) block's Start input fields, resolved from its binding + * rather than the block config — the server overlay builds those with `inputFields: []`. + * + * The resolved-secret projection reassembles `inputMapping` and must decode it against + * the identical fields, or its shape diverges from the executed copy. + */ + customBlockInputFields?: readonly CustomBlockInputFieldType[] } export interface Message { diff --git a/apps/sim/providers/utils.test.ts b/apps/sim/providers/utils.test.ts index c7dc6067af7..72cc7080427 100644 --- a/apps/sim/providers/utils.test.ts +++ b/apps/sim/providers/utils.test.ts @@ -2117,3 +2117,195 @@ describe('findProviderFromModel', () => { expect(getProviderFromModel('whisper-1')).toBe('ollama') }) }) + +describe('transformBlockTool param decoding', () => { + /** + * `StoredTool.params` stringifies every value, so a tool row hands a block the same + * shapes the canvas does only if `paramsTransform` decodes them back. These pin the + * two halves of that: which declaration decides a param's shape, and where in the + * transform the decode happens. + */ + const buildHarness = ( + subBlocks: Array>, + toolParams: Record, + paramsFn?: (params: Record) => Record, + inputs: Record = {} + ) => { + const blockDef = { + type: 'fixture', + inputs, + subBlocks, + tools: { + access: ['fixture_tool'], + ...(paramsFn ? { config: { params: paramsFn } } : {}), + }, + } + return { + getAllBlocks: () => [blockDef], + getTool: (id: string) => ({ + id, + name: 'Fixture', + description: 'Fixture tool', + params: toolParams, + }), + } + } + + const transformFixture = async ( + harness: ReturnType, + params: Record + ) => { + const result = await transformBlockTool( + { type: 'fixture', params }, + { getAllBlocks: harness.getAllBlocks, getTool: harness.getTool } + ) + return result?.paramsTransform?.(params as Record) + } + + it('decodes a boolean param the block does not surface as a sub-block', async () => { + // The reported Jira bug: `includeAttachments` is declared boolean on the tool and + // has no sub-block, so it used to arrive as the truthy string 'false'. + const harness = buildHarness([], { includeAttachments: { type: 'boolean' } }) + + expect(await transformFixture(harness, { includeAttachments: 'false' })).toEqual({ + includeAttachments: false, + }) + expect(await transformFixture(harness, { includeAttachments: 'true' })).toEqual({ + includeAttachments: true, + }) + }) + + it('decodes before the block params function reads the value', async () => { + // Mirrors microsoft_teams, which consumes the flag inside `params` — a decode + // placed after it would see an already-emitted `true` and be a no-op. + const harness = buildHarness( + [{ id: 'includeAttachments', type: 'switch' }], + { includeAttachments: { type: 'boolean' } }, + (params) => (params.includeAttachments ? { includeAttachments: true } : {}) + ) + + expect(await transformFixture(harness, { includeAttachments: 'false' })).toEqual({ + includeAttachments: false, + }) + expect(await transformFixture(harness, { includeAttachments: 'true' })).toEqual({ + includeAttachments: true, + }) + }) + + it('leaves a dropdown-backed boolean as the string its params function compares', async () => { + // Jira's `deleteSubtasks`. A dropdown stores a string on the canvas too, so + // re-keying the decode off the tool's declared type would invert this flag. + const harness = buildHarness( + [ + { + id: 'deleteSubtasks', + type: 'dropdown', + options: [ + { label: 'No', id: 'false' }, + { label: 'Yes', id: 'true' }, + ], + }, + ], + { deleteSubtasks: { type: 'boolean' } }, + (params) => ({ deleteSubtasks: params.deleteSubtasks === 'true' }) + ) + + expect(await transformFixture(harness, { deleteSubtasks: 'true' })).toMatchObject({ + deleteSubtasks: true, + }) + expect(await transformFixture(harness, { deleteSubtasks: 'false' })).toMatchObject({ + deleteSubtasks: false, + }) + }) + + it('decodes a canonical pair once, under its canonical id', async () => { + const harness = buildHarness( + [ + { id: 'flagBasic', type: 'switch', canonicalParamId: 'flag', mode: 'basic' }, + { id: 'flagAdvanced', type: 'switch', canonicalParamId: 'flag', mode: 'advanced' }, + ], + { flag: { type: 'boolean' } } + ) + + expect(await transformFixture(harness, { flagBasic: 'false' })).toEqual({ flag: false }) + }) + + it('leaves a model-supplied typed value untouched', async () => { + const harness = buildHarness([], { includeAttachments: { type: 'boolean' } }) + expect(await transformFixture(harness, { includeAttachments: true })).toEqual({ + includeAttachments: true, + }) + }) + + it("leaves '' alone so the model's value still wins", async () => { + const harness = buildHarness([], { flag: { type: 'boolean' }, count: { type: 'number' } }) + expect(await transformFixture(harness, { flag: '', count: '' })).toEqual({ + flag: '', + count: '', + }) + }) + + it('parses a json param the block inputs never declared', async () => { + const harness = buildHarness([], { body: { type: 'json' } }) + expect(await transformFixture(harness, { body: '{"a":1}' })).toEqual({ body: { a: 1 } }) + }) + + it('keeps parsing a json block input that names no tool param', async () => { + // The `inputs` loop stays: it is the same one the canvas runs, and it covers keys + // the tool does not declare. + const harness = buildHarness([], {}, undefined, { extra: { type: 'json' } }) + expect(await transformFixture(harness, { extra: '{"a":1}' })).toEqual({ extra: { a: 1 } }) + }) + + it('does not double-parse a value the decode already handled', async () => { + const harness = buildHarness( + [{ id: 'files', type: 'file-upload' }], + { files: { type: 'file[]' } }, + undefined, + { + files: { type: 'array' }, + } + ) + expect(await transformFixture(harness, { files: '[{"name":"a.txt"}]' })).toEqual({ + files: [{ name: 'a.txt' }], + }) + }) + + it('never throws on a malformed value', async () => { + const harness = buildHarness([], { body: { type: 'json' }, count: { type: 'number' } }) + expect(await transformFixture(harness, { body: '{bad', count: '' })).toEqual({ + body: '{bad', + count: '', + }) + }) + + it('expands a checkbox-list onto its option params in a tool row', async () => { + const harness = buildHarness( + [ + { + id: 'scanOptions', + type: 'checkbox-list', + options: [ + { label: 'Gather Links', id: 'gatherLinks' }, + { label: 'No Cache', id: 'noCache' }, + ], + }, + ], + { gatherLinks: { type: 'boolean' }, noCache: { type: 'boolean' } } + ) + + const result = await transformFixture(harness, { + scanOptions: '{"gatherLinks":true,"noCache":false}', + }) + + expect(result).toEqual({ gatherLinks: true, noCache: false }) + }) + + it('reports the json-shaped keys so the secret projection keeps the same shape', async () => { + const result = await transformBlockTool( + { type: 'fixture', params: {} }, + buildHarness([], { body: { type: 'json' }, name: { type: 'string' } }) + ) + expect(result?.jsonShapedParamKeys).toEqual(['body']) + }) +}) diff --git a/apps/sim/providers/utils.ts b/apps/sim/providers/utils.ts index d838e0ef217..d58f9222f32 100644 --- a/apps/sim/providers/utils.ts +++ b/apps/sim/providers/utils.ts @@ -22,6 +22,7 @@ import { 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, @@ -59,6 +60,7 @@ import { import type { ProviderId, ProviderToolConfig } from '@/providers/types' import { useProvidersStore } from '@/stores/providers/store' import { mergeToolParameters } from '@/tools/merge-params' +import { buildToolParamShapes, decodeToolParams } from '@/tools/param-shape' import type { WorkflowToolExecutionContext } from '@/tools/types' const logger = createLogger('ProviderUtils') @@ -579,6 +581,117 @@ function buildCustomBlockInputMappingSchema( } } +type BlockToolParamsFn = (params: Record) => Record + +/** + * Builds the transform that turns a tool row's stored sub-block values into the + * arguments a block's tool actually expects. + * + * Four steps, in an order each of which is load-bearing: + * + * 1. Collapse canonical basic/advanced pairs onto the canonical id, so a value stored + * under `manualChannel` becomes the `channel` the tool declares. + * 2. Decode the stringified values back to their real shapes, and expand a + * `checkbox-list` onto its option params. After the collapse so a pair is decoded + * once under its canonical id, and BEFORE the block's `params` function because + * several blocks consume the value inside it — `if (includeAttachments)` on the + * string `'false'` is the bug this closes. + * 3. Run the block's own `tools.config.params` mapping. + * 4. Parse `json`/`array` block inputs, the same loop `GenericBlockHandler` runs on the + * canvas; it covers keys the tool itself does not declare. + * + * Shared so every surface that executes a block tool applies the identical pipeline — + * the agent block, Pi's local tools, and the Human block v2. The Human block v1 ran + * none of it, which is why it needed a new version rather than a fix in place. + */ +export function buildBlockToolParamsTransform(config: { + blockSubBlocks: SubBlockConfig[] | undefined + blockParamsFn: BlockToolParamsFn | undefined + blockInputDefs: Record | undefined + toolParams: Record | undefined + canonicalGroups: CanonicalGroup[] + scopedCanonicalModes: CanonicalModeOverrides | undefined +}): { + paramsTransform: BlockToolParamsFn | undefined + jsonShapedParamKeys: string[] +} { + const { + blockSubBlocks, + blockParamsFn, + blockInputDefs, + toolParams, + canonicalGroups, + scopedCanonicalModes, + } = config + + /** + * The value shape of every key this tool can receive. Keyed by the sub-block that + * produced the encoding — not by the tool's declared type — because a `dropdown` + * collecting a `boolean` param stores a string on the canvas too, and the block's + * `params` function compares it as one. + */ + const paramShapes = buildToolParamShapes(blockSubBlocks ?? [], toolParams) + + const needsTransform = + blockParamsFn || blockInputDefs || canonicalGroups.length > 0 || paramShapes.size > 0 + + const paramsTransform = needsTransform + ? (params: Record): Record => { + let result = { ...params } + + 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 sourceIds = [group.basicId, ...group.advancedIds].filter(Boolean) as string[] + result = omit(result, sourceIds) + + if (chosen !== undefined) { + result[group.canonicalId] = chosen + } + } + + result = decodeToolParams(result, paramShapes, blockSubBlocks ?? []) + + if (blockParamsFn) { + const transformed = blockParamsFn(result) + result = { ...result, ...transformed } + } + + if (blockInputDefs) { + for (const [key, schema] of Object.entries(blockInputDefs)) { + const value = result[key] + if (typeof value === 'string' && value.trim().length > 0) { + const inputType = + typeof schema === 'object' && schema ? (schema as { type?: unknown }).type : schema + if (inputType === 'json' || inputType === 'array') { + try { + result[key] = JSON.parse(value.trim()) + } catch { + // Not valid JSON — keep as string + } + } + } + } + } + + return result + } + : undefined + + const jsonShapedParamKeys = [...paramShapes] + .filter(([, shape]) => shape === 'json') + .map(([paramId]) => paramId) + + return { paramsTransform, jsonShapedParamKeys } +} + /** * Transforms a block tool into a provider tool config with operation selection * @@ -695,7 +808,11 @@ export async function transformBlockTool( logger.warn('deployed_block_executor tool not registered') return null } - const inputMapping = assembleCustomBlockInputMapping(block.params || {}) + // From the BINDING, not `blockDef.subBlocks`: the server overlay builds custom-block + // configs with `inputFields: []`, so on the execution path the block config carries no + // field sub-blocks and the decode would silently no-op, handing the child workflow the + // string 'false' for a boolean input. + const inputMapping = assembleCustomBlockInputMapping(block.params || {}, binding.inputFields) // A `file[]` field is omitted from the model schema (the model can't synthesize // upload descriptors). If such a field is REQUIRED and the user hasn't // pre-filled it on the block, no invocation could ever satisfy the child's @@ -721,6 +838,9 @@ export async function transformBlockTool( blockType: block.type, inputMapping, }, + // The projection has to assemble its copy from the same fields, or the two mappings + // decode differently and the provenance comparison reads that as a shape divergence. + customBlockInputFields: binding.inputFields, parameters: buildCustomBlockInputMappingSchema( blockDef.name, binding.inputFields, @@ -830,58 +950,14 @@ export async function transformBlockTool( } } - const blockParamsFn = blockDef?.tools?.config?.params as - | ((p: Record) => Record) - | undefined - const blockInputDefs = blockDef?.inputs as Record | undefined - - const needsTransform = blockParamsFn || blockInputDefs || canonicalGroups.length > 0 - const paramsTransform = needsTransform - ? (params: Record): Record => { - let result = { ...params } - - 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 sourceIds = [group.basicId, ...group.advancedIds].filter(Boolean) as string[] - result = omit(result, sourceIds) - - if (chosen !== undefined) { - result[group.canonicalId] = chosen - } - } - - if (blockParamsFn) { - const transformed = blockParamsFn(result) - result = { ...result, ...transformed } - } - - if (blockInputDefs) { - for (const [key, schema] of Object.entries(blockInputDefs)) { - const value = result[key] - if (typeof value === 'string' && value.trim().length > 0) { - const inputType = typeof schema === 'object' ? schema.type : schema - if (inputType === 'json' || inputType === 'array') { - try { - result[key] = JSON.parse(value.trim()) - } catch { - // Not valid JSON — keep as string - } - } - } - } - } - - return result - } - : undefined + const { paramsTransform, jsonShapedParamKeys } = buildBlockToolParamsTransform({ + blockSubBlocks: blockDef?.subBlocks, + blockParamsFn: blockDef?.tools?.config?.params as BlockToolParamsFn | undefined, + blockInputDefs: blockDef?.inputs as Record | undefined, + toolParams: toolConfig.params, + canonicalGroups, + scopedCanonicalModes, + }) const providerTool: ProviderToolConfig = { id: toolConfig.id, @@ -890,6 +966,7 @@ export async function transformBlockTool( parameters: llmSchema, modelBlockedParams, paramsTransform, + ...(jsonShapedParamKeys.length > 0 && { jsonShapedParamKeys }), } // A tool that rewrote its own description from a bound param already names that resource, so the diff --git a/apps/sim/serializer/checkbox-list.test.ts b/apps/sim/serializer/checkbox-list.test.ts new file mode 100644 index 00000000000..cc1a553f08d --- /dev/null +++ b/apps/sim/serializer/checkbox-list.test.ts @@ -0,0 +1,91 @@ +/** + * @vitest-environment node + * + * A `checkbox-list` groups several boolean tool params behind one field. Its stored + * value therefore projects onto its OPTION ids, not onto its own id — which no tool + * declares. + * + * Before this projection existed the control wrote each option id as its own top-level + * store key, and this loop dropped every one of them because no sub-block config + * matched. `jina.gatherLinks` and `pinecone.includeMetadata` never reached their tools + * on any surface. + */ +import { toolsMetadataMock, toolsUtilsMock } from '@sim/testing/mocks' +import { describe, expect, it, vi } from 'vitest' + +vi.mock('@/blocks', async () => { + const { createMockGetBlock } = await import('@sim/testing/mocks') + return { + getBlock: createMockGetBlock({ + fixture: { + name: 'Fixture', + description: '', + category: 'tools', + subBlocks: [ + { + id: 'scanOptions', + title: 'Options', + type: 'checkbox-list', + options: [ + { label: 'Gather Links', id: 'gatherLinks' }, + { label: 'No Cache', id: 'noCache' }, + { label: 'Include Values', id: 'includeValues', defaultChecked: true }, + ], + }, + ], + tools: { access: ['fixture_tool'] }, + inputs: {}, + outputs: {}, + }, + }), + } +}) + +vi.mock('@/tools/metadata', () => toolsMetadataMock) +vi.mock('@/tools/utils', () => toolsUtilsMock) + +import { Serializer } from '@/serializer' + +function serializeOptions(value: unknown): Record { + const blocks = { + b1: { + id: 'b1', + type: 'fixture', + name: 'Fixture', + position: { x: 0, y: 0 }, + enabled: true, + outputs: {}, + subBlocks: { scanOptions: { id: 'scanOptions', type: 'checkbox-list', value } }, + }, + } as never + + return new Serializer().serializeWorkflow(blocks, [], {}, {}).blocks[0].config.params +} + +describe('checkbox-list serialization', () => { + it('projects each ticked option onto its own tool param', () => { + const params = serializeOptions({ gatherLinks: true, noCache: false }) + + expect(params.gatherLinks).toBe(true) + expect(params.noCache).toBe(false) + }) + + it('never emits the container id, which no tool declares', () => { + expect(serializeOptions({ gatherLinks: true })).not.toHaveProperty('scanOptions') + }) + + it('omits an untouched option instead of sending false', () => { + const params = serializeOptions(null) + + expect(params).not.toHaveProperty('gatherLinks') + expect(params).not.toHaveProperty('noCache') + }) + + it('sends an option that declares a default even when untouched', () => { + expect(serializeOptions(null).includeValues).toBe(true) + }) + + it('lets an explicit choice override that default', () => { + expect(serializeOptions({ includeValues: false }).includeValues).toBe(false) + }) +}) diff --git a/apps/sim/serializer/index.ts b/apps/sim/serializer/index.ts index c12ccf99932..c5239d0709e 100644 --- a/apps/sim/serializer/index.ts +++ b/apps/sim/serializer/index.ts @@ -22,6 +22,7 @@ import type { SerializedBlock, SerializedWorkflow } from '@/serializer/types' import type { BlockState, Loop, Parallel } from '@/stores/workflows/workflow/types' import { generateLoopBlocks, generateParallelBlocks } from '@/stores/workflows/workflow/utils' import { getToolParams } from '@/tools/metadata' +import { expandSubBlockValueToParams } from '@/tools/param-shape' const logger = createLogger('Serializer') @@ -422,7 +423,12 @@ export class Serializer { subBlocks[subBlock.id] = { id: subBlock.id, type: subBlock.type, - value: serializedBlock.config.params[subBlock.id] ?? null, + // A checkbox-list serializes to one param per OPTION, so its own id holds + // nothing — rebuild the record from those params to keep the round trip lossless. + value: + collectSubBlockValueFromParams(subBlock, serializedBlock.config.params) ?? + serializedBlock.config.params[subBlock.id] ?? + null, } }) @@ -448,6 +454,27 @@ export class Serializer { } } +/** + * The stored value of a sub-block whose params are spread across several keys, i.e. the + * inverse of {@link expandSubBlockValueToParams}. `undefined` for every other sub-block, + * which reads its value straight off its own id. + */ +function collectSubBlockValueFromParams( + subBlock: SubBlockConfig, + params: Record +): Record | undefined { + if (!expandSubBlockValueToParams(subBlock, {})) return undefined + + const options = Array.isArray(subBlock.options) ? subBlock.options : [] + const value: Record = {} + for (const option of options) { + if (!option || typeof option !== 'object' || !('id' in option) || !option.id) continue + const optionId = String(option.id) + if (Object.hasOwn(params, optionId)) value[optionId] = params[optionId] + } + return Object.keys(value).length > 0 ? value : undefined +} + /** A canonical pair where the active member is empty but an inactive member holds a value that will be silently dropped. */ export interface InactiveModeValue { canonicalId: string @@ -564,7 +591,17 @@ export function extractBlockParams(block: BlockState): Record { isLegacyAgentField || isCustomBlockInputField) ) { - params[id] = subBlock.value + // A checkbox-list groups several boolean params behind one field, so it projects + // onto its option ids rather than its own id — which no tool declares. + const expanded = matchingConfigs.reduce | null>( + (found, config) => found ?? expandSubBlockValueToParams(config, subBlock.value), + null + ) + if (expanded) { + Object.assign(params, expanded) + } else { + params[id] = subBlock.value + } } }) diff --git a/apps/sim/tools/param-shape.test.ts b/apps/sim/tools/param-shape.test.ts new file mode 100644 index 00000000000..b4a43fa9410 --- /dev/null +++ b/apps/sim/tools/param-shape.test.ts @@ -0,0 +1,570 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { + buildJsonSchemaParamShapes, + buildSubBlockForToolParam, + buildSubBlocksFromJsonSchema, + buildToolParamShapes, + decodeToolParams, + decodeToolParamValue, + encodeToolParamValue, + expandSubBlockValueToParams, + getSubBlockValueShape, + getToolParamValueShape, + subBlockTypeForValueType, + type ToolParamValueShape, +} from '@/tools/param-shape' + +const ALL_SHAPES: ToolParamValueShape[] = ['string', 'number', 'boolean', 'json'] + +describe('subBlockTypeForValueType', () => { + it.each([ + ['string', 'short-input'], + ['any', 'short-input'], + ['number', 'short-input'], + ['boolean', 'switch'], + ['json', 'code'], + ['array', 'code'], + ['object', 'code'], + ['file', 'file-upload'], + ['file[]', 'file-upload'], + ])('maps %s to %s', (paramType, expected) => { + expect(subBlockTypeForValueType(paramType)).toBe(expected) + }) + + it('falls back to a text field for an unrecognized declaration', () => { + expect(subBlockTypeForValueType('something-new')).toBe('short-input') + expect(subBlockTypeForValueType(undefined)).toBe('short-input') + }) + + it('never synthesizes a control whose required config a tool param cannot supply', () => { + const synthesized = ['string', 'number', 'boolean', 'json', 'array', 'object', 'file', 'file[]'] + .map(subBlockTypeForValueType) + .filter((type, index, all) => all.indexOf(type) === index) + + // A slider needs min/max and a dropdown needs options; neither exists on a + // ToolConfig param, so a bounded control must never be produced from one. + expect(synthesized).not.toContain('slider') + expect(synthesized).not.toContain('dropdown') + }) +}) + +describe('getSubBlockValueShape', () => { + it.each([ + ['switch', 'boolean'], + ['slider', 'number'], + ['file-upload', 'json'], + ['table', 'json'], + ['checkbox-list', 'json'], + ['grouped-checkbox-list', 'json'], + ['short-input', 'string'], + ['dropdown', 'string'], + ['code', 'string'], + ] as const)('reports %s as %s', (type, expected) => { + expect(getSubBlockValueShape({ type })).toBe(expected) + }) + + it('treats any multiSelect control as an array', () => { + expect(getSubBlockValueShape({ type: 'dropdown', multiSelect: true })).toBe('json') + }) + + it('treats a checkbox-list as the record of selections it now stores', () => { + expect(getSubBlockValueShape({ type: 'checkbox-list' })).toBe('json') + }) +}) + +describe('decodeToolParamValue', () => { + it('leaves an already-typed value untouched for every shape', () => { + const typed = [false, true, 0, 42, [], {}, null, undefined] + for (const shape of ALL_SHAPES) { + for (const value of typed) { + expect(decodeToolParamValue(value, shape)).toBe(value) + } + } + }) + + it('is idempotent', () => { + const inputs = ['', 'false', 'true', '0', '5', 'yes', '[1,2]', '{"a":1}', 'null', '{bad'] + for (const shape of ALL_SHAPES) { + for (const raw of inputs) { + const once = decodeToolParamValue(raw, shape) + expect(decodeToolParamValue(once, shape)).toEqual(once) + } + } + }) + + it("preserves '' as the untouched-field sentinel for every shape", () => { + for (const shape of ALL_SHAPES) { + expect(decodeToolParamValue('', shape)).toBe('') + } + }) + + it('never modifies a string-shaped value', () => { + for (const raw of ['false', '0', '[1,2]', '{"a":1}', 'anything']) { + expect(decodeToolParamValue(raw, 'string')).toBe(raw) + } + }) + + it.each([ + ['true', true], + ['True', true], + [' FALSE ', false], + ['false', false], + ])('decodes the boolean token %s', (raw, expected) => { + expect(decodeToolParamValue(raw, 'boolean')).toBe(expected) + }) + + it('leaves a boolean token the encoder never produces as a string', () => { + for (const raw of ['yes', 'on', '1', '0', '', '{{FLAG}}']) { + expect(decodeToolParamValue(raw, 'boolean')).toBe(raw) + } + }) + + it.each([ + ['5', 5], + [' 7 ', 7], + ['0', 0], + ['-1.5', -1.5], + ['1e3', 1000], + ])('decodes the number %s', (raw, expected) => { + expect(decodeToolParamValue(raw, 'number')).toBe(expected) + }) + + it('never produces NaN', () => { + for (const raw of ['abc', '', '{{LIMIT}}', '1,2']) { + expect(decodeToolParamValue(raw, 'number')).toBe(raw) + } + }) + + it('decodes json only when it parses to an object or array', () => { + expect(decodeToolParamValue('[1,2]', 'json')).toEqual([1, 2]) + expect(decodeToolParamValue('{"a":1}', 'json')).toEqual({ a: 1 }) + for (const raw of ['5', 'true', 'null', '"text"', '{bad', '']) { + expect(decodeToolParamValue(raw, 'json')).toBe(raw) + } + }) + + it('never throws', () => { + const hostile: unknown[] = ['{bad', Symbol('x'), Number.NaN, () => {}, new Date(0)] + for (const shape of ALL_SHAPES) { + for (const value of hostile) { + expect(() => decodeToolParamValue(value, shape)).not.toThrow() + } + } + }) + + it('round-trips every sub-block value shape through the encoder', () => { + const cases: Array<[{ type: string; multiSelect?: boolean }, unknown]> = [ + [{ type: 'switch' }, false], + [{ type: 'switch' }, true], + [{ type: 'slider' }, 0], + [{ type: 'file-upload' }, [{ name: 'a.txt', key: 'k' }]], + [{ type: 'table' }, [{ cells: { Key: 'k' } }]], + [{ type: 'dropdown', multiSelect: true }, ['a', 'b']], + [{ type: 'short-input' }, 'plain'], + ] + + for (const [subBlock, value] of cases) { + const shape = getSubBlockValueShape(subBlock as { type: never }) + expect(decodeToolParamValue(encodeToolParamValue(value), shape)).toEqual(value) + } + }) +}) + +describe('buildToolParamShapes', () => { + it('lets the sub-block win over the tool declaration', () => { + // Jira's `deleteSubtasks`: a dropdown of 'true'/'false' backing a boolean param, + // whose block `params` function compares it with `=== 'true'`. Decoding it to a + // real boolean would silently invert the flag. + const shapes = buildToolParamShapes([{ id: 'deleteSubtasks', type: 'dropdown' }], { + deleteSubtasks: { type: 'boolean' }, + }) + expect(shapes.get('deleteSubtasks')).toBe('string') + }) + + it('uses the declared type when no sub-block collects the param', () => { + const shapes = buildToolParamShapes([], { includeAttachments: { type: 'boolean' } }) + expect(shapes.get('includeAttachments')).toBe('boolean') + }) + + it('resolves a canonical id to the shape of the sub-block that collects it', () => { + const shapes = buildToolParamShapes( + [ + { id: 'toggleBasic', type: 'switch', canonicalParamId: 'flag' }, + { id: 'toggleAdvanced', type: 'short-input', canonicalParamId: 'flag' }, + ], + { flag: { type: 'boolean' } } + ) + expect(shapes.get('flag')).toBe('boolean') + }) + + it('prefers json when a canonical pair encodes two different ways', () => { + // A file pair: an uploaded descriptor array on one side, a bare reference string + // on the other. `json` handles both, because it keeps a non-object untouched. + const shapes = buildToolParamShapes( + [ + { id: 'attachmentFiles', type: 'file-upload', canonicalParamId: 'files' }, + { id: 'fileReferences', type: 'short-input', canonicalParamId: 'files' }, + ], + { files: { type: 'file[]' } } + ) + expect(shapes.get('files')).toBe('json') + expect(decodeToolParamValue('file_abc123', 'json')).toBe('file_abc123') + }) + + it('shapes a sub-block the tool does not declare, since its params fn still reads it', () => { + const shapes = buildToolParamShapes([{ id: 'notifyToggle', type: 'switch' }], {}) + expect(shapes.get('notifyToggle')).toBe('boolean') + }) +}) + +describe('decodeToolParams', () => { + it('decodes only the keys it has a shape for', () => { + const shapes = buildToolParamShapes([], { + flag: { type: 'boolean' }, + count: { type: 'number' }, + body: { type: 'json' }, + name: { type: 'string' }, + }) + + expect( + decodeToolParams( + { flag: 'false', count: '3', body: '{"a":1}', name: 'false', unknown: 'false' }, + shapes + ) + ).toEqual({ flag: false, count: 3, body: { a: 1 }, name: 'false', unknown: 'false' }) + }) +}) + +describe('getToolParamValueShape', () => { + it.each([ + ['boolean', 'boolean'], + ['number', 'number'], + ['json', 'json'], + ['array', 'json'], + ['object', 'json'], + ['file', 'json'], + ['file[]', 'json'], + ['string', 'string'], + ['any', 'string'], + [undefined, 'string'], + ])('maps %s to %s', (paramType, expected) => { + expect(getToolParamValueShape(paramType)).toBe(expected) + }) +}) + +describe('buildSubBlockForToolParam', () => { + it('carries the param description as the placeholder on a text field', () => { + const subBlock = buildSubBlockForToolParam( + 'issueKey', + { type: 'string', required: true, visibility: 'user-or-llm', description: 'e.g. PROJ-123' }, + 'Issue Key', + false + ) + expect(subBlock).toMatchObject({ + id: 'issueKey', + title: 'Issue Key', + type: 'short-input', + required: true, + paramVisibility: 'user-or-llm', + placeholder: 'e.g. PROJ-123', + }) + }) + + it('marks a credential-shaped param as a password field', () => { + expect(buildSubBlockForToolParam('apiKey', { type: 'string' }, 'API Key', true).password).toBe( + true + ) + }) + + it('never sets password on a control that would silently ignore it', () => { + expect( + buildSubBlockForToolParam('secretPayload', { type: 'json' }, 'Secret Payload', true).password + ).toBeUndefined() + }) + + it('defaults visibility the same way the registry does', () => { + expect( + buildSubBlockForToolParam('a', { type: 'string', required: true }, 'A', false) + ).toMatchObject({ paramVisibility: 'user-or-llm' }) + expect(buildSubBlockForToolParam('b', { type: 'string' }, 'B', false)).toMatchObject({ + paramVisibility: 'user-only', + }) + }) + + it('configures a file field for one or many', () => { + expect(buildSubBlockForToolParam('f', { type: 'file' }, 'F', false)).toMatchObject({ + type: 'file-upload', + multiple: false, + acceptedTypes: '*', + }) + expect(buildSubBlockForToolParam('f', { type: 'file[]' }, 'F', false).multiple).toBe(true) + }) + + it('never carries a condition, so a synthesized field renders unconditionally', () => { + expect(buildSubBlockForToolParam('a', { type: 'string' }, 'A', false).condition).toBeUndefined() + }) +}) + +describe('buildSubBlocksFromJsonSchema', () => { + const identity = (id: string) => id + + it('uses the constraints a JSON Schema carries and a tool param cannot', () => { + const subBlocks = buildSubBlocksFromJsonSchema( + { + properties: { + flag: { type: 'boolean' }, + mode: { type: 'string', enum: ['fast', 'slow'] }, + bounded: { type: 'integer', minimum: 1, maximum: 10 }, + unbounded: { type: 'number' }, + body: { type: 'object' }, + note: { type: 'string', maxLength: 500 }, + name: { type: 'string' }, + }, + required: ['flag'], + }, + identity + ) + const byId = new Map(subBlocks.map((sb) => [sb.id, sb])) + + expect(byId.get('flag')).toMatchObject({ type: 'switch', paramVisibility: 'user-or-llm' }) + expect(byId.get('mode')).toMatchObject({ + type: 'dropdown', + options: [ + { label: 'fast', id: 'fast' }, + { label: 'slow', id: 'slow' }, + ], + }) + expect(byId.get('bounded')).toMatchObject({ type: 'slider', min: 1, max: 10, integer: true }) + expect(byId.get('unbounded')).toMatchObject({ type: 'short-input' }) + expect(byId.get('body')).toMatchObject({ type: 'code', language: 'json' }) + expect(byId.get('note')).toMatchObject({ type: 'long-input' }) + expect(byId.get('name')).toMatchObject({ type: 'short-input', paramVisibility: 'user-only' }) + }) + + it('tolerates a nullable union type', () => { + const [subBlock] = buildSubBlocksFromJsonSchema( + { properties: { flag: { type: ['boolean', 'null'] } } }, + identity + ) + expect(subBlock.type).toBe('switch') + }) + + it('tolerates a schema an MCP server sent with the wrong shapes', () => { + expect(() => + buildSubBlocksFromJsonSchema( + { + properties: { a: null, b: 'nope', c: { type: 5, minimum: 'x' } } as never, + required: 'all' as never, + }, + identity + ) + ).not.toThrow() + }) + + it('returns nothing for a schema with no properties', () => { + expect(buildSubBlocksFromJsonSchema(undefined, identity)).toEqual([]) + expect(buildSubBlocksFromJsonSchema({}, identity)).toEqual([]) + }) + + it('decodes a scalar as its control stores it, and a structured value as JSON', () => { + const schema = { + properties: { flag: { type: 'boolean' }, body: { type: 'object' }, name: { type: 'string' } }, + } + const shapes = buildJsonSchemaParamShapes(schema) + const byId = new Map(buildSubBlocksFromJsonSchema(schema, identity).map((sb) => [sb.id, sb])) + + // A scalar control writes its own type, so the two agree. + for (const paramId of ['flag', 'name']) { + expect(getSubBlockValueShape(byId.get(paramId)!)).toBe(shapes.get(paramId)) + } + + // A structured value deliberately does NOT: it renders in a code editor, whose store + // value is the raw JSON text, but the tool needs it parsed. Deriving the shape from + // the control here is what left MCP object args undecoded. + expect(byId.get('body')!.type).toBe('code') + expect(getSubBlockValueShape(byId.get('body')!)).toBe('string') + expect(shapes.get('body')).toBe('json') + }) +}) + +describe('expandSubBlockValueToParams', () => { + const options = [ + { label: 'Gather Links', id: 'gatherLinks' }, + { label: 'No Cache', id: 'noCache' }, + { label: 'Include Values', id: 'includeValues', defaultChecked: true }, + ] + const subBlock = { type: 'checkbox-list' as const, options } + + it('projects a checkbox-list onto one param per option', () => { + expect(expandSubBlockValueToParams(subBlock, { gatherLinks: true, noCache: false })).toEqual({ + gatherLinks: true, + noCache: false, + includeValues: true, + }) + }) + + it('omits an untouched option rather than sending false', () => { + // Asana un-completes a task on an explicit `false`, so "never touched" has to stay + // absent. Only an option declaring a default is sent without user input. + expect(expandSubBlockValueToParams(subBlock, null)).toEqual({ includeValues: true }) + expect(expandSubBlockValueToParams(subBlock, {})).toEqual({ includeValues: true }) + }) + + it('lets an explicit choice override a declared default', () => { + expect(expandSubBlockValueToParams(subBlock, { includeValues: false })).toEqual({ + includeValues: false, + }) + }) + + it('never emits an option the block does not declare', () => { + expect( + expandSubBlockValueToParams(subBlock, { gatherLinks: true, staleOption: true }) + ).not.toHaveProperty('staleOption') + }) + + it('returns null for every other sub-block type, leaving one-key behavior alone', () => { + for (const type of ['short-input', 'switch', 'table', 'file-upload', 'dropdown'] as const) { + expect(expandSubBlockValueToParams({ type, options: undefined }, 'x')).toBeNull() + } + }) + + it('tolerates a malformed stored value', () => { + for (const value of ['nonsense', 42, [], undefined]) { + expect(() => expandSubBlockValueToParams(subBlock, value)).not.toThrow() + } + }) +}) + +describe('decodeToolParams with a checkbox-list', () => { + const checkboxSubBlock = { + id: 'readUrlOptions', + type: 'checkbox-list' as const, + options: [ + { label: 'Gather Links', id: 'gatherLinks' }, + { label: 'No Cache', id: 'noCache' }, + ], + } + + it('decodes the stringified record and expands it onto the tool params', () => { + const shapes = buildToolParamShapes([checkboxSubBlock], { + gatherLinks: { type: 'boolean' }, + noCache: { type: 'boolean' }, + }) + + expect( + decodeToolParams({ readUrlOptions: '{"gatherLinks":true,"noCache":false}' }, shapes, [ + checkboxSubBlock, + ]) + ).toEqual({ gatherLinks: true, noCache: false }) + }) + + it('drops the container key, which no tool declares', () => { + const shapes = buildToolParamShapes([checkboxSubBlock], {}) + const result = decodeToolParams({ readUrlOptions: '{"gatherLinks":true}' }, shapes, [ + checkboxSubBlock, + ]) + expect(result).not.toHaveProperty('readUrlOptions') + }) +}) + +describe('buildJsonSchemaParamShapes', () => { + it('reads the shape from the schema, not the control it renders as', () => { + // An `object` renders in a code editor whose store value is raw JSON text, so + // asking the control would answer 'string' and the MCP server would be handed + // undecoded text. + const shapes = buildJsonSchemaParamShapes({ + properties: { + obj: { type: 'object' }, + arr: { type: 'array' }, + flag: { type: 'boolean' }, + count: { type: 'integer' }, + name: { type: 'string' }, + }, + }) + + expect(Object.fromEntries(shapes)).toEqual({ + obj: 'json', + arr: 'json', + flag: 'boolean', + count: 'number', + name: 'string', + }) + }) + + it('normalizes a nullable union the same way the control does', () => { + const schema = { + properties: { + nullableObj: { type: ['object', 'null'] }, + nullableInt: { type: ['integer', 'null'], minimum: 1, maximum: 10 }, + }, + } + const shapes = buildJsonSchemaParamShapes(schema) + const controls = new Map( + buildSubBlocksFromJsonSchema(schema, (id) => id).map((sb) => [sb.id, sb.type]) + ) + + expect(shapes.get('nullableObj')).toBe('json') + expect(controls.get('nullableObj')).toBe('code') + expect(shapes.get('nullableInt')).toBe('number') + expect(controls.get('nullableInt')).toBe('slider') + }) + + it('reads an enum from its declared type before its members', () => { + // A dropdown stores `String(option)`, so a numeric enum read as text would send + // '1' where the server expects 1. + const shapes = buildJsonSchemaParamShapes({ + properties: { + declaredInt: { type: 'integer', enum: [1, 2, 3] }, + declaredBool: { type: 'boolean', enum: [true, false] }, + declaredStr: { type: 'string', enum: ['a', 'b'] }, + }, + }) + + expect(shapes.get('declaredInt')).toBe('number') + expect(shapes.get('declaredBool')).toBe('boolean') + expect(shapes.get('declaredStr')).toBe('string') + }) + + it('reads an untyped enum from its members only to tell text from JSON', () => { + const shapes = buildJsonSchemaParamShapes({ + properties: { primitive: { enum: ['x', 1, null] }, structured: { enum: [{ a: 1 }] } }, + }) + + // A structured member renders in a JSON editor; anything else renders as a dropdown, + // which stores text. The dropdown persists the member itself, so nothing has to + // reverse `String(member)` afterwards. + expect(shapes.get('primitive')).toBe('string') + expect(shapes.get('structured')).toBe('json') + }) + + it('round-trips a numeric enum through the dropdown it renders as', () => { + const schema = { properties: { n: { type: 'integer', enum: [1, 2, 3] } } } + const [subBlock] = buildSubBlocksFromJsonSchema(schema, (id) => id) + + expect(subBlock.type).toBe('dropdown') + expect(decodeToolParamValue('2', buildJsonSchemaParamShapes(schema).get('n')!)).toBe(2) + }) + + it('normalizes a property the server sent as something other than an object', () => { + // `properties: { foo: null }` is malformed but arrives over the wire. + const shapes = buildJsonSchemaParamShapes({ + properties: { a: null, b: true, c: 'text', d: 42 }, + } as unknown as Parameters[0]) + + expect([...shapes.values()]).toEqual(['string', 'string', 'string', 'string']) + }) + + it('normalizes a legacy string left by a control that has since changed type', () => { + // A union-typed property used to render as a text field and now renders as a switch; + // its stored 'false' must not tick the box. + const shapes = buildJsonSchemaParamShapes({ + properties: { flag: { type: ['boolean', 'null'] }, plain: { type: 'boolean' } }, + }) + + expect(decodeToolParamValue('false', shapes.get('flag')!)).toBe(false) + expect(decodeToolParamValue(true, shapes.get('plain')!)).toBe(true) + }) +}) diff --git a/apps/sim/tools/param-shape.ts b/apps/sim/tools/param-shape.ts new file mode 100644 index 00000000000..2a4509d2daf --- /dev/null +++ b/apps/sim/tools/param-shape.ts @@ -0,0 +1,554 @@ +import { createLogger } from '@sim/logger' +import type { SubBlockType } from '@sim/workflow-types/blocks' +import type { SubBlockConfig } from '@/blocks/types' +import type { ParameterVisibility } from '@/tools/types' + +/** + * The single source of truth for "what shape does this tool param's value have, and + * which sub-block collects it". + * + * Deliberately a leaf module alongside `@/tools/merge-params`, under the same import + * discipline: nothing here may import `@/tools/utils`, `@/tools/registry`, + * `@/tools/params`, or `@/tools/metadata`. Those pull the tool registry (or its 4MB + * generated metadata) into the graph of every caller, and this module is imported by + * client components, the executor, and the search indexer alike. All inputs arrive as + * arguments; nothing is looked up. + * + * Before this existed the same two questions were answered independently in four + * places (`tool-input`'s `renderParameterInput`, `getToolParametersConfig`'s + * `uiComponent`, the search indexer's `getFallbackToolParamType`, and + * `mcp-dynamic-args`'s `getInputType`), which is why a `boolean` tool param could + * render as a switch on one surface and a text box on another. + */ + +const logger = createLogger('ToolParamShape') + +/** + * How a tool param's value is represented once it leaves the sub-block store. + * + * `StoredTool.params` is `Record`, so every value crossing that + * boundary is stringified. This is the type information needed to reverse that. + */ +export type ToolParamValueShape = 'string' | 'number' | 'boolean' | 'json' + +/** + * The sub-block that collects a value of the given type. + * + * Shared by the two places a value type has to become a control: a tool param the block + * does not surface as a sub-block of its own, and a custom block's Start input field. + * Those vocabularies overlap entirely, and the two maps have to agree — a `boolean` + * cannot be a switch on one path and a text box on the other. + * + * Each entry is the simplest control whose required configuration the declaration + * actually carries. `dropdown` (needs `options`), `slider` (needs `min`/`max`), the + * `*-selector` family (needs `selectorKey`), `oauth-input` (needs `serviceId` and + * `requiredScopes`), and `table` (needs `columns`) are all deliberately absent: a + * `ToolConfig` param declaration has no field to supply them, and synthesizing one + * without its configuration produces a control that silently misbehaves — a bounded + * slider invents a 0-100 range and pre-fills a value the user never chose. + */ +const SUBBLOCK_TYPE_BY_VALUE_TYPE: Record = { + string: 'short-input', + number: 'short-input', + boolean: 'switch', + json: 'code', + array: 'code', + object: 'code', + file: 'file-upload', + 'file[]': 'file-upload', + any: 'short-input', +} + +/** Sub-block types whose store value is an object or array rather than a scalar. */ +const OBJECT_VALUED_SUBBLOCK_TYPES = new Set([ + 'checkbox-list', + 'file-upload', + 'grouped-checkbox-list', + 'table', +]) + +/** + * The sub-block type that collects a declared value type. + * + * Falls back to `short-input` for an unrecognized declaration, so an unknown type + * degrades to a plain text field rather than rendering nothing. + */ +export function subBlockTypeForValueType(valueType: string | undefined): SubBlockType { + return (valueType && SUBBLOCK_TYPE_BY_VALUE_TYPE[valueType]) || 'short-input' +} + +/** + * The value shape a sub-block writes to the store. + * + * This is the primary key for decoding, because it is what the encoder + * (`resolveToolParamSync`) was keyed by — a decoder must be the exact inverse of its + * encoder. Keying off the tool's declared type instead would corrupt every + * `dropdown`-backed boolean: a dropdown's value is a string on the canvas too, so the + * ~225 `params.x === 'true'` comparisons inside block `tools.config.params` functions + * are correct there and must keep receiving a string. + * + * A `checkbox-list` holds one record of `{ optionId: boolean }` under its own key, like + * every other multi-value control. Its options then project onto separate tool params — + * see {@link expandSubBlockValueToParams}. + */ +export function getSubBlockValueShape( + subBlock: Pick & { multiSelect?: boolean } +): ToolParamValueShape { + if (subBlock.type === 'switch') return 'boolean' + if (subBlock.type === 'slider') return 'number' + if (OBJECT_VALUED_SUBBLOCK_TYPES.has(subBlock.type)) return 'json' + if (subBlock.multiSelect) return 'json' + return 'string' +} + +/** + * The tool params a sub-block's stored value projects onto, when it is not the plain + * one-sub-block-one-param case. + * + * A `checkbox-list` is the only control that groups SEVERAL boolean tool params behind + * one field — jina's "Options" collects `gatherLinks`, `noCache`, `jsonResponse` and six + * more. Its option ids are param names, which is also how a param resolves back to it. + * + * Returning the params here rather than letting the control write them directly is what + * keeps the sub-block invariant intact: one sub-block owns exactly one store key. When + * the control wrote each option id as its own top-level key instead, the canvas + * serializer dropped every one of them (no matching sub-block config) and a tool row + * never mirrored them at all. + * + * `null` means no projection — the caller keeps its normal one-key behavior. + */ +export function expandSubBlockValueToParams( + subBlock: Pick, + value: unknown +): Record | null { + if (subBlock.type !== 'checkbox-list') return null + + const options = Array.isArray(subBlock.options) ? subBlock.options : [] + const selections = value && typeof value === 'object' && !Array.isArray(value) ? value : {} + + const params: Record = {} + for (const option of options) { + if (!option || typeof option !== 'object' || !('id' in option) || !option.id) continue + const optionId = String(option.id) + const selected = (selections as Record)[optionId] + const fallback = (option as { defaultChecked?: boolean }).defaultChecked + + // An option the user never touched is OMITTED, not sent as `false`. Several tools + // distinguish the two — Asana's `update_task` un-completes a task on an explicit + // `false` and must leave it alone otherwise. A declared `defaultChecked` is a real + // choice the field is displaying, so that does get sent. + if (typeof selected === 'boolean') { + params[optionId] = selected + } else if (typeof fallback === 'boolean') { + params[optionId] = fallback + } + } + return params +} + +/** Whether a sub-block's store value must be JSON-encoded to cross the `tool.params` boundary. */ +export function holdsObjectValue( + subBlock: Pick & { multiSelect?: boolean } +): boolean { + return getSubBlockValueShape(subBlock) === 'json' +} + +/** + * The value shape implied by a tool param's declared type. + * + * Only correct for a param with no sub-block of its own — a synthesized field, whose + * sub-block type this module chose. Where a real sub-block exists, + * {@link getSubBlockValueShape} wins. + */ +export function getToolParamValueShape(paramType: string | undefined): ToolParamValueShape { + switch (paramType) { + case 'boolean': + return 'boolean' + case 'number': + return 'number' + case 'json': + case 'array': + case 'object': + case 'file': + case 'file[]': + return 'json' + default: + return 'string' + } +} + +/** + * Encodes a sub-block store value for storage in `StoredTool.params`. + * + * Paired with {@link decodeToolParamValue} in this file so the two cannot drift. + */ +export function encodeToolParamValue(storeValue: unknown): string { + if (storeValue === null || storeValue === undefined) return '' + if (typeof storeValue === 'string') return storeValue + return JSON.stringify(storeValue) +} + +/** + * Restores a `StoredTool.params` string to the value shape the tool and the block's + * `tools.config.params` function expect — the same shape the canvas would deliver. + * + * Total by construction. Every branch keeps the original value on failure: + * + * - A non-string passes through untouched, which is the whole idempotency story. Model + * arguments arrive already typed, `tools.config.params` output is already typed, and + * `paramsTransform` runs twice (once for execution, once for the secret-provenance + * projection), so decoding must be a fixed point. + * - `''` stays `''` for every shape. It is the "untouched field" sentinel that + * `isNonEmpty`, `mergeToolParameters`, `createLLMToolSchema`, and + * `validateRequiredParametersAfterMerge` all key off; turning it into `false`/`0` + * would suppress the model's value or trip required-param validation. + * - `'json'` accepts only an object or an array, so a bare `'null'` never reads as a + * cleared field and a `'5'` on a mis-declared param stays recognizable. This makes + * the function the exact inverse of {@link encodeToolParamValue}. + * - A boolean token the encoder never produces (`'yes'`, an unresolved + * `''`) stays a string rather than becoming a silent `true`. + * - Nothing throws. A throw here is caught and downgraded by `prepareToolExecution`, + * and on the projection pass it marks the resolved-secret registry incomplete — + * trading a type mismatch for a silent loss of provenance tracking. + */ +export function decodeToolParamValue(raw: unknown, shape: ToolParamValueShape): unknown { + if (typeof raw !== 'string' || raw === '') return raw + + switch (shape) { + case 'boolean': { + const normalized = raw.trim().toLowerCase() + if (normalized === 'true') return true + if (normalized === 'false') return false + return raw + } + case 'number': { + const parsed = Number(raw.trim()) + return Number.isFinite(parsed) ? parsed : raw + } + case 'json': { + try { + const parsed: unknown = JSON.parse(raw.trim()) + if (typeof parsed !== 'object' || parsed === null) return raw + return parsed + } catch (error) { + logger.warn('Tool param declared as JSON did not parse; passing through as text', { + errorName: error instanceof Error ? error.name : 'UnknownError', + }) + return raw + } + } + default: + return raw + } +} + +/** + * The value shape of every key a block-based tool can receive, keyed by the id the + * value is stored under. + * + * A sub-block wins over the tool's declaration, because the sub-block is what produced + * the encoding. A canonical pair resolves under its canonical id, since by the time + * this map is consulted the pair has already collapsed onto that key. Anything with + * neither a sub-block nor a declaration is absent from the map and left alone. + */ +export function buildToolParamShapes( + subBlocks: readonly (Pick & { + multiSelect?: boolean + })[], + toolParams: Record | undefined +): Map { + const shapes = new Map() + + for (const [paramId, param] of Object.entries(toolParams ?? {})) { + shapes.set(paramId, getToolParamValueShape(param.type)) + } + + /** + * Sub-block shapes, resolved before they overwrite the declarations so a canonical id + * carrying two members reaches one answer. First member wins, except that `'json'` + * beats any other shape: the two sides of a file pair encode differently (an uploaded + * descriptor array versus a bare reference string), and `'json'` handles both — it + * keeps a value that is not an object or array exactly as it found it. + */ + const fromSubBlocks = new Map() + const claim = (id: string, shape: ToolParamValueShape): void => { + const existing = fromSubBlocks.get(id) + if (existing === undefined || (shape === 'json' && existing !== 'json')) { + fromSubBlocks.set(id, shape) + } + } + + for (const subBlock of subBlocks) { + const shape = getSubBlockValueShape(subBlock) + claim(subBlock.id, shape) + if (subBlock.canonicalParamId) claim(subBlock.canonicalParamId, shape) + } + + for (const [id, shape] of fromSubBlocks) shapes.set(id, shape) + + return shapes +} + +/** + * Decodes every stringified value in a tool's params back to the shape the tool and + * the block's `tools.config.params` function expect. + */ +export function decodeToolParams( + params: Record, + shapes: ReadonlyMap, + /** + * Sub-blocks whose value projects onto several params rather than one — currently + * only `checkbox-list`. Omitted where no such projection is possible (an MCP or + * custom tool has no sub-blocks). + */ + projectingSubBlocks: readonly Pick[] = [] +): Record { + const decoded: Record = { ...params } + for (const [key, value] of Object.entries(decoded)) { + const shape = shapes.get(key) + if (shape) decoded[key] = decodeToolParamValue(value, shape) + } + + for (const subBlock of projectingSubBlocks) { + if (!Object.hasOwn(decoded, subBlock.id)) continue + const expanded = expandSubBlockValueToParams(subBlock, decoded[subBlock.id]) + if (!expanded) continue + delete decoded[subBlock.id] + Object.assign(decoded, expanded) + } + + return decoded +} + +/** + * A `SubBlockConfig` for a tool param the block does not surface as a sub-block. + * + * The 87 `user-only` params in this position have no other channel — they are excluded + * from the model's schema — so they must remain settable. The `user-or-llm` ones the + * model can also fill, but the user must still be able to override. + * + * `condition` is deliberately absent. A param whose sub-block exists but whose + * condition currently fails is claimed by its sub-block and never reaches here, so an + * unconditional field matches the behavior these params already have. + */ +export function buildSubBlockForToolParam( + paramId: string, + param: { + type?: string + required?: boolean + visibility?: ParameterVisibility + description?: string + }, + title: string, + isPassword: boolean +): SubBlockConfig { + const type = subBlockTypeForValueType(param.type) + const subBlock: SubBlockConfig = { + id: paramId, + title, + type, + required: param.required === true, + paramVisibility: param.visibility ?? (param.required ? 'user-or-llm' : 'user-only'), + } + + if (type === 'code') { + subBlock.language = 'json' + } + + if (type === 'file-upload') { + subBlock.acceptedTypes = '*' + subBlock.multiple = param.type === 'file[]' + } + + if (type === 'short-input') { + if (param.description) subBlock.placeholder = param.description + if (isPassword) subBlock.password = true + } + + return subBlock +} + +/** + * The sub-block collecting a JSON Schema property, for MCP and custom tools. + * + * A JSON Schema carries constraints a `ToolConfig` param declaration cannot, so this + * map is legitimately richer than {@link subBlockTypeForValueType}: `enum` supplies a + * dropdown's options and `minimum`/`maximum` supply a slider's bounds. Shared with the + * MCP block's own args editor so the same tool renders identically on both surfaces. + */ +export function subBlockTypeForJsonSchema(property: JsonSchemaProperty): SubBlockType { + if (Array.isArray(property.enum)) { + // A non-primitive member cannot be an option label, so the whole enum falls back + // to free text. `null` is a legal, renderable member. + return property.enum.every((option) => option === null || typeof option !== 'object') + ? 'dropdown' + : 'long-input' + } + + const type = jsonSchemaType(property) + if (type === 'boolean') return 'switch' + if (type === 'number' || type === 'integer') { + return finiteNumber(property.minimum) !== undefined && + finiteNumber(property.maximum) !== undefined + ? 'slider' + : 'short-input' + } + if (type === 'array' || type === 'object') return 'code' + + const maxLength = finiteNumber(property.maxLength) + if (type === 'string' && maxLength !== undefined && maxLength > 100) return 'long-input' + + return 'short-input' +} + +/** + * The value shape a JSON Schema property's control writes, for MCP and custom tools. + * + * Read from the schema, NOT from the control {@link subBlockTypeForJsonSchema} picks: an + * `object` renders in a code editor, whose store value is the raw JSON text, so asking + * the control would answer `'string'` and the argument would reach the MCP server + * undecoded. The same holds for a non-primitive enum, which renders as free text. + */ +function getJsonSchemaValueShape(property: JsonSchemaProperty): ToolParamValueShape { + const type = jsonSchemaType(property) + if (type === 'boolean') return 'boolean' + if (type === 'number' || type === 'integer') return 'number' + if (type === 'object' || type === 'array') return 'json' + if (type === 'string') return 'string' + + // Read AFTER the declared type, not before: the dropdown an enum renders as stores + // `String(option)`, so `{ type: 'integer', enum: [1, 2] }` read as text would send + // `'1'` where the server expects `1`. With no declared type only a structured member + // is informative — it renders as free JSON text rather than a dropdown. + if (Array.isArray(property.enum)) { + return property.enum.some((member) => member !== null && typeof member === 'object') + ? 'json' + : 'string' + } + + return 'string' +} + +/** The value shape of every argument an MCP or custom tool's schema declares. */ +export function buildJsonSchemaParamShapes( + schema: JsonSchemaObject | undefined +): Map { + const shapes = new Map() + for (const [paramId, property] of jsonSchemaProperties(schema)) { + shapes.set(paramId, getJsonSchemaValueShape(property)) + } + return shapes +} + +/** + * The subset of JSON Schema this module reads. + * + * Every field is `unknown` because an MCP server supplies these over the wire and may + * send anything — including the legal-but-awkward `type: ['string', 'null']`. The + * readers below narrow rather than trusting the declaration. + */ +export interface JsonSchemaProperty { + type?: unknown + description?: unknown + enum?: unknown + minimum?: unknown + maximum?: unknown + maxLength?: unknown + [key: string]: unknown +} + +export interface JsonSchemaObject { + properties?: unknown + required?: unknown +} + +/** The declared properties of an untrusted schema, as `paramId -> property` pairs. */ +function jsonSchemaProperties( + schema: JsonSchemaObject | undefined +): Array<[string, JsonSchemaProperty]> { + const { properties } = schema ?? {} + if (!properties || typeof properties !== 'object' || Array.isArray(properties)) return [] + return Object.entries(properties as Record).map(([paramId, property]) => [ + paramId, + property && typeof property === 'object' && !Array.isArray(property) + ? (property as JsonSchemaProperty) + : {}, + ]) +} + +/** The required param names of an untrusted schema. */ +function jsonSchemaRequired(schema: JsonSchemaObject | undefined): Set { + const { required } = schema ?? {} + if (!Array.isArray(required)) return new Set() + return new Set(required.filter((entry): entry is string => typeof entry === 'string')) +} + +/** + * The declared type, tolerating a union such as `['string', 'null']`. + * + * Exported so every reader of a schema property normalizes it the same way — a control + * chosen from the normalized type but a value handled from the raw one is how a nullable + * object ends up in a JSON editor that persists it as raw text. + */ +export function jsonSchemaType(property: JsonSchemaProperty): string | undefined { + const { type } = property + if (typeof type === 'string') return type + if (Array.isArray(type)) { + const named = type.find((entry) => typeof entry === 'string' && entry !== 'null') + return typeof named === 'string' ? named : undefined + } + return undefined +} + +function finiteNumber(value: unknown): number | undefined { + return typeof value === 'number' && Number.isFinite(value) ? value : undefined +} + +/** + * `SubBlockConfig`s for an MCP or custom tool's arguments, so they render through the + * canonical sub-block renderer rather than a parallel one. + * + * A required property is `user-or-llm` and an optional one is `user-only`, preserving + * the visibility the tool row assigned these params before this existed. + */ +export function buildSubBlocksFromJsonSchema( + schema: JsonSchemaObject | undefined, + formatTitle: (paramId: string) => string +): SubBlockConfig[] { + const required = jsonSchemaRequired(schema) + + return jsonSchemaProperties(schema).map(([paramId, property]) => { + const type = subBlockTypeForJsonSchema(property) + const isRequired = required.has(paramId) + const subBlock: SubBlockConfig = { + id: paramId, + title: formatTitle(paramId), + type, + required: isRequired, + paramVisibility: isRequired ? 'user-or-llm' : 'user-only', + } + + if (type === 'dropdown' && Array.isArray(property.enum)) { + subBlock.options = property.enum.map((option) => ({ + label: String(option), + id: String(option), + })) + } + if (type === 'slider') { + subBlock.min = finiteNumber(property.minimum) + subBlock.max = finiteNumber(property.maximum) + subBlock.integer = jsonSchemaType(property) === 'integer' + } + if (type === 'code') { + subBlock.language = 'json' + } + if ( + (type === 'short-input' || type === 'long-input') && + typeof property.description === 'string' + ) { + subBlock.placeholder = property.description + } + + return subBlock + }) +} diff --git a/apps/sim/tools/params.test.ts b/apps/sim/tools/params.test.ts index 43b31ee325b..d271d413139 100644 --- a/apps/sim/tools/params.test.ts +++ b/apps/sim/tools/params.test.ts @@ -8,9 +8,7 @@ import { filterSchemaForLLM, formatParameterLabel, getSubBlocksForToolInput, - getToolParametersConfig, isPasswordParameter, - type ToolParameterConfig, type ToolSchema, ToolSchemaEnrichmentError, type ValidationResult, @@ -78,6 +76,38 @@ const getToolSpy = vi.spyOn(toolMetadata, 'getToolMetadata').mockImplementation( params: {}, } } + if (toolId === 'bool_tool') { + return { + ...mockToolConfig, + id: 'bool_tool', + params: { + includeAttachments: { + type: 'boolean', + required: false, + visibility: 'user-or-llm' as ParameterVisibility, + description: 'Download attachment file contents', + }, + payload: { + type: 'json', + required: false, + visibility: 'user-or-llm' as ParameterVisibility, + }, + }, + } + } + if (toolId === 'checkbox_tool') { + return { + ...mockToolConfig, + id: 'checkbox_tool', + params: { + completed: { + type: 'boolean', + required: false, + visibility: 'user-or-llm' as ParameterVisibility, + }, + }, + } + } return null }) as unknown as typeof toolMetadata.getToolMetadata) @@ -86,24 +116,6 @@ afterAll(() => { }) describe('Tool Parameters Utils', () => { - describe('getToolParametersConfig', () => { - it.concurrent('should return tool parameters configuration', () => { - const result = getToolParametersConfig('test_tool') - - expect(result).toBeDefined() - expect(result?.toolConfig).toEqual(mockToolConfig) - expect(result?.allParameters).toHaveLength(4) - expect(result?.userInputParameters).toHaveLength(4) // apiKey, message, channel, timeout (all have visibility) - expect(result?.requiredParameters).toHaveLength(2) // apiKey, message (both required: true) - expect(result?.optionalParameters).toHaveLength(2) // channel, timeout (both user-only + required: false) - }) - - it.concurrent('should return null for non-existent tool', () => { - const result = getToolParametersConfig('non_existent_tool') - expect(result).toBeNull() - }) - }) - describe('createLLMToolSchema', () => { it('preserves structured object properties and nested array item constraints', async () => { const structuredTool = { @@ -1011,26 +1023,6 @@ describe('Tool Parameters Utils', () => { expect(Array.isArray(result.missingParams)).toBe(true) expect(result.missingParams.every((param) => typeof param === 'string')).toBe(true) }) - - it.concurrent('should have properly typed ToolParameterConfig', () => { - const config = getToolParametersConfig('test_tool') - expect(config).toBeDefined() - - if (config) { - config.allParameters.forEach((param: ToolParameterConfig) => { - expect(typeof param.id).toBe('string') - expect(typeof param.type).toBe('string') - expect(typeof param.required).toBe('boolean') - expect( - ['user-or-llm', 'user-only', 'llm-only', 'hidden'].includes(param.visibility!) - ).toBe(true) - if (param.description) expect(typeof param.description).toBe('string') - if (param.uiComponent) { - expect(typeof param.uiComponent.type).toBe('string') - } - }) - } - }) }) }) @@ -1046,24 +1038,6 @@ describe('custom block agent-tool rendering', () => { ], } as any - describe('getToolParametersConfig', () => { - it('surfaces the field sub-blocks, never workflowId/inputMapping', () => { - const result = getToolParametersConfig( - 'workflow_executor', - 'custom_block_abc', - undefined, - customBlockConfig - ) - expect(result).not.toBeNull() - const ids = result!.userInputParameters.map((p) => p.id) - expect(ids).toEqual(['field-question', 'field-files']) - expect(ids).not.toContain('workflowId') - expect(ids).not.toContain('inputMapping') - expect(result!.userInputParameters.every((p) => p.visibility === 'user-or-llm')).toBe(true) - expect(result!.requiredParameters.map((p) => p.id)).toEqual(['field-question']) - }) - }) - describe('getSubBlocksForToolInput', () => { it('returns field sub-blocks as user-or-llm and drops reserved/hidden wiring', () => { const result = getSubBlocksForToolInput( @@ -1079,3 +1053,107 @@ describe('custom block agent-tool rendering', () => { }) }) }) + +describe('getSubBlocksForToolInput synthesis', () => { + it('synthesizes a field for every user-facing param the block does not declare', () => { + const result = getSubBlocksForToolInput('test_tool', 'test_block', undefined, undefined, { + subBlocks: [{ id: 'message', title: 'Message', type: 'long-input' }], + } as any) + + expect(result).not.toBeNull() + const byId = new Map(result!.subBlocks.map((sb) => [sb.id, sb])) + + // Declared by the block: kept verbatim, never re-synthesized as a short-input. + expect(byId.get('message')?.type).toBe('long-input') + // Not declared: synthesized from the param's own type. + expect(byId.get('apiKey')?.type).toBe('short-input') + expect(byId.get('apiKey')?.password).toBe(true) + expect(byId.get('timeout')?.type).toBe('short-input') + expect(byId.get('timeout')?.paramVisibility).toBe('user-only') + expect(result!.subBlocks).toHaveLength(4) + }) + + it('maps a boolean param to a switch rather than a text box', () => { + const result = getSubBlocksForToolInput('bool_tool', 'bool_block', undefined, undefined, { + subBlocks: [], + } as any) + const byId = new Map(result!.subBlocks.map((sb) => [sb.id, sb])) + expect(byId.get('includeAttachments')?.type).toBe('switch') + expect(byId.get('payload')?.type).toBe('code') + expect(byId.get('payload')?.language).toBe('json') + }) + + it('does not resurrect a param whose sub-block exists but whose condition fails', () => { + const result = getSubBlocksForToolInput( + 'test_tool', + 'test_block', + { operation: 'other' }, + undefined, + { + subBlocks: [ + { + id: 'message', + title: 'Message', + type: 'long-input', + condition: { field: 'operation', value: 'send' }, + }, + ], + } as any + ) + + expect(result!.subBlocks.map((sb) => sb.id)).not.toContain('message') + }) + + it('does not synthesize a param already claimed by a canonical group member', () => { + const result = getSubBlocksForToolInput('test_tool', 'test_block', undefined, undefined, { + subBlocks: [ + { + id: 'channelSelector', + type: 'channel-selector', + canonicalParamId: 'channel', + mode: 'basic', + }, + { id: 'manualChannel', type: 'short-input', canonicalParamId: 'channel', mode: 'advanced' }, + ], + } as any) + + expect(result!.subBlocks.map((sb) => sb.id)).not.toContain('channel') + }) + + it('does not synthesize a boolean claimed by a checkbox-list option', () => { + const result = getSubBlocksForToolInput( + 'checkbox_tool', + 'checkbox_block', + undefined, + undefined, + { + subBlocks: [ + { + id: 'filters', + type: 'checkbox-list', + options: [{ label: 'Completed', id: 'completed' }], + }, + ], + } as any + ) + expect(result!.subBlocks.map((sb) => sb.id)).not.toContain('completed') + }) + + it('still returns fields for a block that declares no sub-blocks at all', () => { + const result = getSubBlocksForToolInput('test_tool', 'bare_block', undefined, undefined, { + subBlocks: [], + } as any) + + expect(result).not.toBeNull() + expect(result!.subBlocks.map((sb) => sb.id).sort()).toEqual([ + 'apiKey', + 'channel', + 'message', + 'timeout', + ]) + }) + + it('returns null for an unknown tool', () => { + expect(getSubBlocksForToolInput('non_existent_tool', 'test_block')).toBeNull() + }) +}) diff --git a/apps/sim/tools/params.ts b/apps/sim/tools/params.ts index 6575f68749f..7666075e19b 100644 --- a/apps/sim/tools/params.ts +++ b/apps/sim/tools/params.ts @@ -14,10 +14,11 @@ import { isCustomBlockType, RESERVED_PARAMS } from '@/blocks/custom/build-config import type { BlockConfig as AppBlockConfig, SubBlockConfig as BlockSubBlockConfig, - GenerationType, + SubBlockType, } from '@/blocks/types' import { isNonEmpty } from '@/tools/merge-params' import { getToolMetadata, type ToolMetadata } from '@/tools/metadata' +import { buildSubBlockForToolParam } from '@/tools/param-shape' import { safeAssign } from '@/tools/safe-assign' import type { ExecutableToolConfig, @@ -31,84 +32,6 @@ import type { const logger = createLogger('ToolsParams') type ToolParamDefinition = ToolConfig['params'][string] -// Tag/Value Parsing Utilities - -interface Option { - label: string - value: string -} - -interface ComponentCondition { - field: string - value: string | number | boolean | Array - not?: boolean -} - -interface UIComponentConfig { - type: string - options?: Option[] - placeholder?: string - password?: boolean - condition?: ComponentCondition - title?: string - value?: unknown - serviceId?: string - selectorKey?: BlockSubBlockConfig['selectorKey'] - requiredScopes?: string[] - mimeType?: string - columns?: string[] - min?: number - max?: number - step?: number - integer?: boolean - language?: string - generationType?: string - acceptedTypes?: string[] - multiple?: boolean - multiSelect?: boolean - maxSize?: number - dependsOn?: string[] | { all?: string[]; any?: string[] } - /** Canonical parameter ID if this is part of a canonical group */ - canonicalParamId?: string - /** The mode of the source subblock (basic/advanced/both) */ - mode?: 'basic' | 'advanced' | 'both' | 'trigger' | 'trigger-advanced' - /** The actual subblock ID this config was derived from */ - actualSubBlockId?: string - /** Wand configuration for AI assistance */ - wandConfig?: { - enabled: boolean - prompt: string - generationType?: GenerationType - placeholder?: string - maintainHistory?: boolean - } -} - -interface SubBlockConfig { - id: string - type: string - title?: string - options?: Option[] - placeholder?: string - password?: boolean - condition?: ComponentCondition - value?: unknown - serviceId?: string - requiredScopes?: string[] - mimeType?: string - columns?: string[] - min?: number - max?: number - step?: number - integer?: boolean - language?: string - generationType?: string - acceptedTypes?: string[] - multiple?: boolean - maxSize?: number - dependsOn?: string[] -} - type ToolInputBlockConfig = Pick interface SchemaProperty { @@ -173,26 +96,6 @@ export interface ValidationResult { missingParams: string[] } -export interface ToolParameterConfig { - id: string - type: string - required?: boolean // Required for tool execution - visibility?: ParameterVisibility // Controls who can/must provide this parameter - userProvided?: boolean // User filled this parameter - description?: string - default?: unknown - // UI component information from block config - uiComponent?: UIComponentConfig -} - -export interface ToolWithParameters { - toolConfig: ToolMetadata - allParameters: ToolParameterConfig[] - userInputParameters: ToolParameterConfig[] // Parameters shown to user - requiredParameters: ToolParameterConfig[] // Must be filled by user or LLM - optionalParameters: ToolParameterConfig[] // Nice to have, shown to user -} - let blockConfigCache: Record | null = null function getBlockConfigurations(): Record { @@ -247,271 +150,6 @@ export function getToolIdForOperation( return block.tools.access[0] } -function resolveSubBlockForParam( - paramId: string, - subBlocks: BlockSubBlockConfig[], - valuesWithOperation: Record, - paramType: string -): BlockSubBlockConfig | undefined { - const blockSubBlocks = subBlocks - - // First pass: find subblock with matching condition - let fallbackMatch: BlockSubBlockConfig | undefined - - for (const sb of blockSubBlocks) { - const matches = sb.id === paramId || sb.canonicalParamId === paramId - if (!matches) continue - - // Remember first match as fallback (for condition-based filtering in UI) - if (!fallbackMatch) fallbackMatch = sb - - if ( - !sb.condition || - evaluateSubBlockCondition(sb.condition as SubBlockCondition, valuesWithOperation) - ) { - return sb - } - } - - // Return fallback so its condition can be used for UI filtering - if (fallbackMatch) return fallbackMatch - - // Check if boolean param is part of a checkbox-list - if (paramType === 'boolean') { - return blockSubBlocks.find( - (sb) => - sb.type === 'checkbox-list' && - Array.isArray(sb.options) && - (sb.options as Array<{ id?: string }>).some((opt) => opt.id === paramId) - ) - } - - return undefined -} - -/** Map a custom-block field sub-block type to a tool-parameter type. */ -function customFieldParamType(subBlockType: string): string { - switch (subBlockType) { - case 'switch': - return 'boolean' - case 'file-upload': - return 'file[]' - case 'code': - return 'json' - default: - return 'string' - } -} - -/** - * Gets all parameters for a tool, categorized by their usage - * Also includes UI component information from block configurations - */ -export function getToolParametersConfig( - toolId: string, - blockType?: string, - currentValues?: Record, - blockConfigOverride?: Pick -): ToolWithParameters | null { - try { - const toolConfig = getToolMetadata(toolId) - if (!toolConfig) { - logger.warn(`Tool not found: ${toolId}`) - return null - } - - // Validate that toolConfig has required properties - if (!toolConfig.params || typeof toolConfig.params !== 'object') { - logger.warn(`Tool ${toolId} has invalid params configuration`) - return null - } - - // Custom (deploy-as-block) blocks resolve to `workflow_executor`, but their - // editable inputs are their own per-field sub-blocks — not the generic - // workflowId/inputMapping. Surface those so the tool panel renders the block's - // real fields (and never the workflow-executor fields as "uncovered" params). - // MUST run before the `workflow_executor` branch below. Read subBlocks from the - // fresh, overlay-aware `blockConfigOverride` — the module `getBlockConfigurations` - // cache can miss async-hydrated custom blocks. - if (blockType && isCustomBlockType(blockType)) { - const blockConfig = blockConfigOverride ?? getBlockConfigurations()[blockType] - const fieldSubBlocks = ( - (blockConfig?.subBlocks as BlockSubBlockConfig[] | undefined) ?? [] - ).filter((sb) => !sb.hidden && !RESERVED_PARAMS.has(sb.id)) - const parameters: ToolParameterConfig[] = fieldSubBlocks.map((sb) => ({ - id: sb.id, - type: customFieldParamType(sb.type), - required: sb.required === true, - visibility: 'user-or-llm', - description: sb.description, - uiComponent: { - type: sb.type, - title: sb.title, - placeholder: sb.placeholder, - language: sb.language, - multiple: sb.multiple, - }, - })) - return { - toolConfig, - allParameters: parameters, - userInputParameters: parameters, - requiredParameters: parameters.filter((param) => param.required), - optionalParameters: parameters.filter((param) => !param.required), - } - } - - // Special handling for workflow_executor tool - if (toolId === 'workflow_executor') { - const parameters: ToolParameterConfig[] = [ - { - id: 'workflowId', - type: 'string', - required: true, - visibility: 'user-only', - description: 'The ID of the workflow to execute', - uiComponent: { - type: 'workflow-selector', - placeholder: 'Select workflow to execute', - selectorKey: 'sim.workflows', - }, - }, - { - id: 'inputMapping', - type: 'object', - required: false, - visibility: 'user-or-llm', - description: 'Map inputs to the selected workflow', - uiComponent: { - type: 'workflow-input-mapper', - title: 'Workflow Inputs', - condition: { - field: 'workflowId', - value: '', - not: true, // Show when workflowId is not empty - }, - dependsOn: ['workflowId'], - }, - }, - ] - - return { - toolConfig, - allParameters: parameters, - userInputParameters: parameters.filter( - (param) => param.visibility === 'user-or-llm' || param.visibility === 'user-only' - ), - requiredParameters: parameters.filter((param) => param.required), - optionalParameters: parameters.filter( - (param) => param.visibility === 'user-only' && !param.required - ), - } - } - - // Get block configuration for UI component information - let blockConfig: ToolInputBlockConfig | null = null - if (blockType) { - const blockConfigs = getBlockConfigurations() - blockConfig = blockConfigs[blockType] || null - } - - // Build values for condition evaluation - // Operation should come from currentValues if provided, otherwise extract from toolId - const values = currentValues || {} - const valuesWithOperation = { ...values } - if (valuesWithOperation.operation === undefined) { - // Fallback: extract operation from tool ID (e.g., 'slack_message' -> 'message') - const parts = toolId.split('_') - valuesWithOperation.operation = - parts.length >= 3 ? parts.slice(2).join('_') : parts[parts.length - 1] - } - - // Convert tool params to our standard format with UI component info - const allParameters: ToolParameterConfig[] = Object.entries(toolConfig.params).map( - ([paramId, param]) => { - const toolParam: ToolParameterConfig = { - id: paramId, - type: param.type, - required: param.required ?? false, - visibility: param.visibility ?? (param.required ? 'user-or-llm' : 'user-only'), - description: param.description, - default: param.default, - } - - if (blockConfig) { - const subBlock = resolveSubBlockForParam( - paramId, - blockConfig.subBlocks || [], - valuesWithOperation, - param.type - ) - - if (subBlock) { - if (isSubBlockHidden(subBlock)) { - toolParam.visibility = 'hidden' - } - - toolParam.uiComponent = { - type: subBlock.type, - options: subBlock.options as Option[] | undefined, - placeholder: subBlock.placeholder, - password: subBlock.password, - condition: subBlock.condition as ComponentCondition | undefined, - title: subBlock.title, - value: subBlock.value, - serviceId: subBlock.serviceId, - selectorKey: subBlock.selectorKey, - requiredScopes: subBlock.requiredScopes, - mimeType: subBlock.mimeType, - columns: subBlock.columns, - min: subBlock.min, - max: subBlock.max, - step: subBlock.step, - integer: subBlock.integer, - language: subBlock.language, - generationType: subBlock.generationType, - acceptedTypes: subBlock.acceptedTypes ? [subBlock.acceptedTypes] : undefined, - multiple: subBlock.multiple, - maxSize: subBlock.maxSize, - dependsOn: subBlock.dependsOn, - canonicalParamId: subBlock.canonicalParamId, - mode: subBlock.mode, - actualSubBlockId: subBlock.id, - wandConfig: subBlock.wandConfig, - } - } - } - - return toolParam - } - ) - - // Parameters that should be shown to the user for input - const userInputParameters = allParameters.filter( - (param) => param.visibility === 'user-or-llm' || param.visibility === 'user-only' - ) - - // Parameters that are required (must be filled by user or LLM) - const requiredParameters = allParameters.filter((param) => param.required) - - // Parameters that are optional but can be provided by user - const optionalParameters = allParameters.filter( - (param) => param.visibility === 'user-only' && !param.required - ) - - return { - toolConfig, - allParameters, - userInputParameters, - requiredParameters, - optionalParameters, - } - } catch (error) { - logger.error('Error getting tool parameters config:', error) - return null - } -} - /** * Creates a tool schema for LLM with user-provided parameters excluded */ @@ -909,6 +547,28 @@ export function validateToolParameters( } } +/** + * A tool param's effective visibility. + * + * An undeclared visibility means the param is the user's to fill when it is optional, + * and either party's when the tool requires it. Only 59 of 28,612 registry params rely + * on this default, but the rule is duplicated wherever visibility is read, so it lives + * here once. + */ +export function resolveToolParamVisibility( + param: Pick +): ParameterVisibility { + return param.visibility ?? (param.required ? 'user-or-llm' : 'user-only') +} + +/** Whether a tool param is offered to the user in a tool row. */ +export function isUserFacingToolParam( + param: Pick +): boolean { + const visibility = resolveToolParamVisibility(param) + return visibility === 'user-or-llm' || visibility === 'user-only' +} + /** * Helper to check if a parameter should be treated as a password field */ @@ -997,13 +657,25 @@ const EXCLUDED_SUBBLOCK_TYPES = new Set([ 'mcp-server-selector', 'mcp-tool-selector', 'mcp-dynamic-args', - 'input-mapping', 'variables-input', 'messages-input', 'router-input', 'text', ]) +/** + * Canvas controls that have a tool-input counterpart collecting the same param. + * + * A tool row is a different surface with different affordances — it has no canvas + * references to offer and far less room — so a couple of controls have a simpler + * sibling. Declaring the swap keeps the sub-block the single source of truth for the + * param: without it, the type would have to be excluded here and the field + * reintroduced by a hard-coded per-tool branch, which is what this replaced. + */ +const TOOL_INPUT_SUBBLOCK_TYPE_SUBSTITUTIONS: Record = { + 'input-mapping': 'workflow-input-mapper', +} + export interface SubBlocksForToolInput { toolConfig: ToolMetadata subBlocks: BlockSubBlockConfig[] @@ -1011,12 +683,62 @@ export interface SubBlocksForToolInput { } /** - * Returns filtered SubBlockConfig[] for rendering in tool-input context. - * Uses subblock definitions as the primary source of UI metadata, - * getting all features (wandConfig, rich conditions, dependsOn, etc.) for free. + * Every sub-block id a tool param could resolve to, so a param backed by one is never + * also synthesized as a bare field. + * + * Built from the block's FULL sub-block list, before any visibility or condition + * filtering. A param whose sub-block exists but whose `condition` currently fails must + * stay hidden rather than reappear as an unconditional text box, so a param is claimed + * by its sub-block's existence, never by that sub-block surviving the filter. + */ +function buildClaimedParamIds( + allSubBlocks: BlockSubBlockConfig[], + canonicalIndex: ReturnType +): Set { + const claimed = new Set() + + for (const sb of allSubBlocks) { + claimed.add(sb.id) + if (sb.canonicalParamId) claimed.add(sb.canonicalParamId) + + const canonicalId = canonicalIndex.canonicalIdBySubBlockId[sb.id] + if (canonicalId) { + claimed.add(canonicalId) + const group = canonicalIndex.groupsById[canonicalId] + if (group) { + if (group.basicId) claimed.add(group.basicId) + for (const advancedId of group.advancedIds) claimed.add(advancedId) + } + } + + // A checkbox-list holds one boolean per option id, so those ids are the + // param names it collects. + if ( + (sb.type === 'checkbox-list' || sb.type === 'grouped-checkbox-list') && + Array.isArray(sb.options) + ) { + for (const option of sb.options) { + if (option && typeof option === 'object' && 'id' in option && option.id) { + claimed.add(String(option.id)) + } + } + } + } + + return claimed +} + +/** + * The complete, ordered set of fields a tool exposes for configuration in a tool row. + * + * The block's own sub-blocks come first and are the primary source of UI metadata — + * conditions, `dependsOn`, `selectorKey`, wand config, canonical basic/advanced pairs + * all come along for free. Params the block does not surface get a `SubBlockConfig` + * synthesized from their declared type, so they render through the same canonical + * renderer instead of a parallel one that ignored their type and produced a text box. * - * For blocks without paramVisibility annotations, falls back to inferring - * visibility from the tool's param definitions. + * Synthesis is not optional: ~87 `user-only` params have no sub-block, and those are + * excluded from the model's schema, so a rendered field is their only channel. */ export function getSubBlocksForToolInput( toolId: string, @@ -1034,16 +756,13 @@ export function getSubBlocksForToolInput( const blockConfigs = getBlockConfigurations() const blockConfig = blockConfigOverride ?? blockConfigs[blockType] - if (!blockConfig?.subBlocks?.length) { - return null - } // Custom (deploy-as-block) blocks: render their own editable field sub-blocks // as `user-or-llm` (the hidden workflowId/inputMapping wiring is filtered by // RESERVED_PARAMS — `isSubBlockHidden` does NOT honor `hidden: true`, so the // explicit reserved filter is what keeps them out). if (blockType && isCustomBlockType(blockType)) { - const fieldSubBlocks = (blockConfig.subBlocks as BlockSubBlockConfig[]) + const fieldSubBlocks = ((blockConfig?.subBlocks ?? []) as BlockSubBlockConfig[]) .filter((sb) => !sb.hidden && !RESERVED_PARAMS.has(sb.id)) .map((sb) => ({ ...sb, paramVisibility: 'user-or-llm' as ParameterVisibility })) return { @@ -1053,7 +772,7 @@ export function getSubBlocksForToolInput( } } - const allSubBlocks = blockConfig.subBlocks as BlockSubBlockConfig[] + const allSubBlocks = (blockConfig?.subBlocks ?? []) as BlockSubBlockConfig[] const canonicalIndex = buildCanonicalIndex(allSubBlocks) // Build values for condition evaluation @@ -1068,8 +787,7 @@ export function getSubBlocksForToolInput( // Build a map of tool param IDs to their resolved visibility const toolParamVisibility: Record = {} for (const [paramId, param] of Object.entries(toolConfig.params || {})) { - toolParamVisibility[paramId] = - param.visibility ?? (param.required ? 'user-or-llm' : 'user-only') + toolParamVisibility[paramId] = resolveToolParamVisibility(param) } // Track which canonical groups we've already included (to avoid duplicates) @@ -1077,7 +795,15 @@ export function getSubBlocksForToolInput( const filtered: BlockSubBlockConfig[] = [] - for (const sb of allSubBlocks) { + /** Applies the tool-input counterpart of a canvas-only control, if it has one. */ + const forToolInput = (subBlock: BlockSubBlockConfig): BlockSubBlockConfig => { + const substitute = TOOL_INPUT_SUBBLOCK_TYPE_SUBSTITUTIONS[subBlock.type] + return substitute ? { ...subBlock, type: substitute } : subBlock + } + + for (const original of allSubBlocks) { + const sb = forToolInput(original) + // Skip excluded types if (EXCLUDED_SUBBLOCK_TYPES.has(sb.type)) continue @@ -1154,7 +880,7 @@ export function getSubBlocksForToolInput( // Find the advanced variant const advancedSb = allSubBlocks.find((s) => group.advancedIds.includes(s.id)) if (advancedSb) { - filtered.push({ ...advancedSb, paramVisibility: visibility }) + filtered.push({ ...forToolInput(advancedSb), paramVisibility: visibility }) } } else { // Include basic variant (current sb if it's the basic one) @@ -1163,7 +889,7 @@ export function getSubBlocksForToolInput( } else { const basicSb = allSubBlocks.find((s) => s.id === group.basicId) if (basicSb) { - filtered.push({ ...basicSb, paramVisibility: visibility }) + filtered.push({ ...forToolInput(basicSb), paramVisibility: visibility }) } } } @@ -1175,6 +901,31 @@ export function getSubBlocksForToolInput( filtered.push({ ...sb, paramVisibility: visibility }) } + // A handful of sub-blocks declare no `title` (`function.language`, `router.routes`, + // …). On the canvas that is deliberate, but a tool row labels every field, so fall + // back to the formatted param id — the label the old renderer produced. + for (const [index, sb] of filtered.entries()) { + if (!sb.title) filtered[index] = { ...sb, title: formatParameterLabel(sb.id) } + } + + const claimedParamIds = buildClaimedParamIds(allSubBlocks, canonicalIndex) + + for (const [paramId, param] of Object.entries(toolConfig.params || {})) { + if (claimedParamIds.has(paramId)) continue + + const visibility = toolParamVisibility[paramId] + if (visibility === 'hidden' || visibility === 'llm-only') continue + + filtered.push( + buildSubBlockForToolParam( + paramId, + param, + formatParameterLabel(paramId), + isPasswordParameter(paramId) + ) + ) + } + return { toolConfig, subBlocks: filtered, diff --git a/apps/sim/tools/pinecone/search_vector.ts b/apps/sim/tools/pinecone/search_vector.ts index c4854a40ab4..44f1eefa176 100644 --- a/apps/sim/tools/pinecone/search_vector.ts +++ b/apps/sim/tools/pinecone/search_vector.ts @@ -76,8 +76,10 @@ export const searchVectorTool: ToolConfig