import { getEnv, isTruthy } from '@/lib/core/config/env' import { isHosted } from '@/lib/core/config/env-flags' import type { SubBlockConfig } from '@/blocks/types' export type CanonicalMode = 'basic' | 'advanced' export interface CanonicalGroup { canonicalId: string basicId?: string advancedIds: string[] } export interface CanonicalIndex { groupsById: Record canonicalIdBySubBlockId: Record } export interface SubBlockCondition { field: string value: string | number | boolean | Array | undefined not?: boolean and?: SubBlockCondition } export interface CanonicalModeOverrides { [canonicalId: string]: CanonicalMode | undefined } export interface CanonicalValueSelection { basicValue: unknown advancedValue: unknown advancedSourceId?: string } interface TriggerVisibilityBlockConfig { category?: string triggers?: { enabled?: boolean } } export function parseDependsOn(dependsOn: SubBlockConfig['dependsOn']): { allFields: string[] anyFields: string[] allDependsOnFields: string[] } { if (!dependsOn) { return { allFields: [], anyFields: [], allDependsOnFields: [] } } if (Array.isArray(dependsOn)) { return { allFields: dependsOn, anyFields: [], allDependsOnFields: dependsOn } } const allFields = dependsOn.all || [] const anyFields = dependsOn.any || [] return { allFields, anyFields, allDependsOnFields: [...allFields, ...anyFields], } } export function normalizeDependencyValue(rawValue: unknown): unknown { if (rawValue === null || rawValue === undefined) return null if (typeof rawValue === 'object') { if (Array.isArray(rawValue)) { if (rawValue.length === 0) return null return rawValue.map((item) => normalizeDependencyValue(item)) } const record = rawValue as Record if ('value' in record) return normalizeDependencyValue(record.value) if ('id' in record) return record.id return record } return rawValue } /** * Build a flat map of subblock values keyed by subblock id. */ export function buildSubBlockValues( subBlocks: Record ): Record { return Object.entries(subBlocks).reduce>((acc, [key, subBlock]) => { acc[key] = subBlock?.value return acc }, {}) } /** * Build canonical group indices for a block's subblocks. */ export function buildCanonicalIndex(subBlocks: SubBlockConfig[]): CanonicalIndex { const groupsById: Record = {} const canonicalIdBySubBlockId: Record = {} subBlocks.forEach((subBlock) => { if (!subBlock.canonicalParamId) return const canonicalId = subBlock.canonicalParamId if (!groupsById[canonicalId]) { groupsById[canonicalId] = { canonicalId, advancedIds: [] } } const group = groupsById[canonicalId] if (subBlock.mode === 'advanced' || subBlock.mode === 'trigger-advanced') { // Deduplicate: trigger spreads may repeat the same advanced ID as the regular block if (!group.advancedIds.includes(subBlock.id)) { group.advancedIds.push(subBlock.id) } } else { // A trigger-mode subblock must not overwrite a basicId already claimed by a non-trigger subblock. // Blocks spread their trigger's subBlocks after their own, so the regular subblock always wins. if (!group.basicId || subBlock.mode !== 'trigger') { group.basicId = subBlock.id } } canonicalIdBySubBlockId[subBlock.id] = canonicalId }) return { groupsById, canonicalIdBySubBlockId } } /** * Resolve if a canonical group is a swap pair (basic + advanced). */ export function isCanonicalPair(group?: CanonicalGroup): boolean { return Boolean(group?.basicId && group?.advancedIds?.length) } /** * Builds default canonical mode overrides for a block's subblocks. * All canonical pairs default to `'basic'`. */ export function buildDefaultCanonicalModes( subBlocks: SubBlockConfig[] ): Record { const index = buildCanonicalIndex(subBlocks) const modes: Record = {} for (const group of Object.values(index.groupsById)) { if (isCanonicalPair(group)) { modes[group.canonicalId] = 'basic' } } return modes } /** * Determine the active mode for a canonical group. */ export function resolveCanonicalMode( group: CanonicalGroup, values: Record, overrides?: CanonicalModeOverrides ): CanonicalMode { const override = overrides?.[group.canonicalId] if (override === 'advanced' && group.advancedIds.length > 0) return 'advanced' if (override === 'basic' && group.basicId) return 'basic' const { basicValue, advancedValue } = getCanonicalValues(group, values) const hasBasic = isNonEmptyValue(basicValue) const hasAdvanced = isNonEmptyValue(advancedValue) if (!group.basicId) return 'advanced' if (!hasBasic && hasAdvanced) return 'advanced' return 'basic' } /** * Evaluate a subblock condition against a map of raw values. */ export function evaluateSubBlockCondition( condition: | SubBlockCondition | ((values?: Record) => SubBlockCondition) | undefined, values: Record ): boolean { if (!condition) return true const actual = typeof condition === 'function' ? condition(values) : condition const fieldValue = values[actual.field] const valueMatch = Array.isArray(actual.value) ? fieldValue != null && (actual.not ? !actual.value.includes(fieldValue as any) : actual.value.includes(fieldValue as any)) : actual.not ? fieldValue !== actual.value : fieldValue === actual.value const andMatch = !actual.and ? true : (() => { const andFieldValue = values[actual.and!.field] const andValueMatch = Array.isArray(actual.and!.value) ? andFieldValue != null && (actual.and!.not ? !actual.and!.value.includes(andFieldValue as any) : actual.and!.value.includes(andFieldValue as any)) : actual.and!.not ? andFieldValue !== actual.and!.value : andFieldValue === actual.and!.value return andValueMatch })() return valueMatch && andMatch } /** * Check if a value is considered set for advanced visibility/selection. */ export function isNonEmptyValue(value: unknown): boolean { if (value === null || value === undefined) return false if (typeof value === 'string') return value.trim().length > 0 if (Array.isArray(value)) return value.length > 0 return true } /** * Resolve basic and advanced values for a canonical group. */ export function getCanonicalValues( group: CanonicalGroup, values: Record ): CanonicalValueSelection { const basicValue = group.basicId ? values[group.basicId] : undefined let advancedValue: unknown let advancedSourceId: string | undefined group.advancedIds.forEach((advancedId) => { if (advancedValue !== undefined) return const candidate = values[advancedId] if (isNonEmptyValue(candidate)) { advancedValue = candidate advancedSourceId = advancedId } }) return { basicValue, advancedValue, advancedSourceId } } /** * Resolve the ACTIVE canonical member's value for a group: the basic value in basic mode, the * advanced value in advanced mode (per {@link resolveCanonicalMode} - honoring an explicit * override, then the value heuristic). Strict: returns ONLY the active member's value with no * cross-mode fallback, so a dormant mode's stale value can never leak. The single source of truth * for "what value is live for this canonical pair" - use it instead of basic-first `||` / * `?? 'basic'` reads or last-write-wins scans. */ export function resolveActiveCanonicalValue( group: CanonicalGroup, values: Record, overrides?: CanonicalModeOverrides ): unknown { const mode = resolveCanonicalMode(group, values, overrides) const { basicValue, advancedValue } = getCanonicalValues(group, values) return mode === 'advanced' ? advancedValue : basicValue } /** Extract override entries matching a `${prefix}` key into a bare-`canonicalId`-keyed object. */ function extractPrefixedModes( overrides: CanonicalModeOverrides, prefix: string ): CanonicalModeOverrides | undefined { let scoped: CanonicalModeOverrides | undefined for (const [key, value] of Object.entries(overrides)) { if (key.startsWith(prefix) && value) { scoped = scoped ?? {} scoped[key.slice(prefix.length)] = value } } return scoped } /** * Strip the `${toolIndex}:` prefix from canonical-mode override keys, returning the overrides for a * nested tool keyed by bare `canonicalId`. An agent block stores its nested tools' modes scoped as * `${toolIndex}:${canonicalId}` — keyed by the tool's position in the `tool-input` array, not its * `type` — so that two tool entries of the SAME type (e.g. two Table tools on one Agent block) get * independent canonical modes instead of colliding on a shared `${toolType}:${canonicalId}` key. * * Falls back to the legacy `${legacyToolType}:` prefix (the pre-instance-scoping format) when no * index-scoped key matches, so an override saved before this scoping change isn't silently dropped - * it keeps applying (type-shared, the old behavior) until the user re-toggles it explicitly, at which * point it's rewritten under the new index-scoped key. * * Returns `undefined` when there are no overrides, no `toolIndex`, and no legacy match. */ export function scopeCanonicalModesForTool( overrides: CanonicalModeOverrides | undefined, toolIndex: number | undefined, legacyToolType?: string ): CanonicalModeOverrides | undefined { if (!overrides) return undefined const scoped = toolIndex !== undefined ? extractPrefixedModes(overrides, `${toolIndex}:`) : undefined if (scoped) return scoped return legacyToolType ? extractPrefixedModes(overrides, `${legacyToolType}:`) : undefined } const INDEX_SCOPED_KEY = /^(\d+):(.+)$/ /** * Canonical-mode overrides are keyed by a tool's position in its `tool-input` array * (`${toolIndex}:${canonicalId}`), so anything that reorders or removes tools - the editor * (drag-reorder, remove, delete), fork/promote copy (dropping an unresolved custom-tool/MCP * entry) - must carry each surviving tool's overrides to its new position and DROP the * vacated index. Otherwise a saved basic/advanced choice can attach to whichever DIFFERENT * tool later lands on that old index (e.g. a newly-added tool, always appended at the end, * can refill a slot a removal just freed). * * Returns the full replacement `canonicalModes` object (for an atomic whole-map write - a * per-key merge can't drop a key, and sequential per-key writes can clobber each other when * two tools swap positions), or `undefined` when nothing needs to change. A legacy, * non-index-scoped key (the `${toolType}:` fallback format) isn't tied to any array position, * so it's carried over unchanged. */ export function reindexCanonicalModesByPosition( newIndexByOldIndex: ReadonlyMap, overrides: CanonicalModeOverrides | undefined ): Record | undefined { if (!overrides) return undefined let changed = false const result: Record = {} for (const [key, mode] of Object.entries(overrides)) { if (!mode) continue const match = INDEX_SCOPED_KEY.exec(key) if (!match) { result[key] = mode continue } const newIndex = newIndexByOldIndex.get(Number(match[1])) if (newIndex === undefined) { changed = true // Tool removed (or an already-stale index) - drop the key. continue } if (newIndex !== Number(match[1])) changed = true result[`${newIndex}:${match[2]}`] = mode } return changed ? result : undefined } /** * {@link reindexCanonicalModesByPosition}, diffing `oldTools` against `newTools` by OBJECT * IDENTITY to derive the old-index -> new-index map. Callers must not clone the tool objects * they keep (only filter/splice/reorder the array itself) - a kept-but-cloned tool (e.g. a * `{ ...tool, someField: x }` spread) won't match its old reference and will be treated as * removed. Use {@link reindexCanonicalModesByPosition} directly when a caller's remap can * clone kept entries (fork/promote does, to rewrite a remapped id) and already knows each * surviving old index's new position by other means (e.g. tracking it during the same pass * that builds the new array, rather than post-hoc identity comparison). */ export function reindexToolCanonicalModes( oldTools: readonly T[], newTools: readonly T[], overrides: CanonicalModeOverrides | undefined ): Record | undefined { const newIndexByRef = new Map(newTools.map((tool, index) => [tool, index])) const newIndexByOldIndex = new Map() oldTools.forEach((tool, oldIndex) => { const newIndex = newIndexByRef.get(tool) if (newIndex !== undefined) newIndexByOldIndex.set(oldIndex, newIndex) }) return reindexCanonicalModesByPosition(newIndexByOldIndex, overrides) } /** * Check if a block has any standalone advanced-only fields (not part of canonical pairs). * These require the block-level advanced mode toggle to be visible. */ export function hasStandaloneAdvancedFields( subBlocks: SubBlockConfig[], canonicalIndex: CanonicalIndex ): boolean { for (const subBlock of subBlocks) { if (!isStandaloneAdvancedMode(subBlock.mode)) continue if (!canonicalIndex.canonicalIdBySubBlockId[subBlock.id]) return true } return false } /** * True for the modes that make a field advanced-only when it is not part of a * canonical basic/advanced pair: a standalone `advanced` field, or a standalone * `trigger-advanced` field (an advanced option on a trigger block). Both are * hidden until the block-level advanced toggle is on. */ export function isStandaloneAdvancedMode(mode: SubBlockConfig['mode']): boolean { return mode === 'advanced' || mode === 'trigger-advanced' } /** * Check if any advanced-only or canonical advanced values are present. */ export function hasAdvancedValues( subBlocks: SubBlockConfig[], values: Record, canonicalIndex: CanonicalIndex ): boolean { const checkedCanonical = new Set() for (const subBlock of subBlocks) { const canonicalId = canonicalIndex.canonicalIdBySubBlockId[subBlock.id] if (canonicalId) { const group = canonicalIndex.groupsById[canonicalId] if (group && isCanonicalPair(group) && !checkedCanonical.has(canonicalId)) { checkedCanonical.add(canonicalId) const { advancedValue } = getCanonicalValues(group, values) if (isNonEmptyValue(advancedValue)) return true } continue } if (isStandaloneAdvancedMode(subBlock.mode) && isNonEmptyValue(values[subBlock.id])) { return true } } return false } /** * Determine whether a subblock is visible based on mode and canonical swaps. */ export function isSubBlockVisibleForMode( subBlock: SubBlockConfig, displayAdvancedOptions: boolean, canonicalIndex: CanonicalIndex, values: Record, overrides?: CanonicalModeOverrides ): boolean { const canonicalId = canonicalIndex.canonicalIdBySubBlockId[subBlock.id] const group = canonicalId ? canonicalIndex.groupsById[canonicalId] : undefined if (group && isCanonicalPair(group)) { const mode = resolveCanonicalMode(group, values, overrides) if (mode === 'advanced') return group.advancedIds.includes(subBlock.id) return group.basicId === subBlock.id } if (subBlock.mode === 'basic' && displayAdvancedOptions) return false // Standalone advanced-only fields (`advanced` or a trigger's `trigger-advanced`) // hide until the block-level advanced toggle is on. if (isStandaloneAdvancedMode(subBlock.mode) && !displayAdvancedOptions) return false return true } export function isTriggerModeSubBlock(subBlock: Pick): boolean { return subBlock.mode === 'trigger' || subBlock.mode === 'trigger-advanced' } export function isTriggerConfigSubBlock(subBlock: Pick): boolean { return String(subBlock.type) === 'trigger-config' } export function shouldUseSubBlockForTriggerModeCanonicalIndex( subBlock: Pick ): boolean { return isTriggerModeSubBlock(subBlock) || isTriggerConfigSubBlock(subBlock) } export function isPureTriggerBlockConfig(blockConfig?: TriggerVisibilityBlockConfig): boolean { return Boolean(blockConfig?.triggers?.enabled && blockConfig.category === 'triggers') } export function isSubBlockVisibleForTriggerMode( subBlock: Pick, displayTriggerMode: boolean, blockConfig?: TriggerVisibilityBlockConfig ): boolean { if (isTriggerConfigSubBlock(subBlock)) { return displayTriggerMode || isPureTriggerBlockConfig(blockConfig) } if (isTriggerModeSubBlock(subBlock)) return displayTriggerMode if (displayTriggerMode) return false return true } /** * Resolve the dependency value for a dependsOn key, honoring canonical swaps. */ export function resolveDependencyValue( dependencyKey: string, values: Record, canonicalIndex: CanonicalIndex, overrides?: CanonicalModeOverrides ): unknown { const canonicalId = canonicalIndex.groupsById[dependencyKey]?.canonicalId || canonicalIndex.canonicalIdBySubBlockId[dependencyKey] if (!canonicalId) { return values[dependencyKey] } const group = canonicalIndex.groupsById[canonicalId] if (!group) return values[dependencyKey] const { basicValue, advancedValue } = getCanonicalValues(group, values) const mode = resolveCanonicalMode(group, values, overrides) const canonicalResult = mode === 'advanced' ? (advancedValue ?? basicValue) : (basicValue ?? advancedValue) if (canonicalResult != null) return canonicalResult for (const [memberId, memberCanonicalId] of Object.entries( canonicalIndex.canonicalIdBySubBlockId )) { if (memberCanonicalId === canonicalId && isNonEmptyValue(values[memberId])) { return values[memberId] } } return values[dependencyKey] } /** * Check if a subblock is gated by a feature flag. */ export function isSubBlockFeatureEnabled(subBlock: SubBlockConfig): boolean { if (!subBlock.showWhenEnvSet) return true return isTruthy(getEnv(subBlock.showWhenEnvSet)) } /** * Check if a subblock should be hidden based on environment conditions. * Covers two cases: * - `hideWhenHosted`: hidden when running on hosted Sim (tool API key fields) * - `hideWhenEnvSet`: hidden when a specific NEXT_PUBLIC_ env var is truthy * (credential fields hidden when the deployment provides them server-side) */ export function isSubBlockHidden(subBlock: SubBlockConfig): boolean { if (subBlock.hideWhenHosted && isHosted) return true if (subBlock.hideWhenEnvSet && isTruthy(getEnv(subBlock.hideWhenEnvSet))) return true return false }