chore: import upstream snapshot with attribution
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 13:20:55 +08:00
commit d25d482dc2
13754 changed files with 4996608 additions and 0 deletions
@@ -0,0 +1,159 @@
/**
* @vitest-environment node
*/
import { describe, expect, it, vi } from 'vitest'
vi.unmock('@/blocks/registry')
import { getAllBlocks } from '@/blocks/registry'
import { buildSelectorContextFromBlock, SELECTOR_CONTEXT_FIELDS } from './context'
import { buildCanonicalIndex, isCanonicalPair } from './visibility'
describe('buildSelectorContextFromBlock', () => {
it('should extract knowledgeBaseId from knowledgeBaseSelector via canonical mapping', () => {
const ctx = buildSelectorContextFromBlock('knowledge', {
operation: { id: 'operation', type: 'dropdown', value: 'search' },
knowledgeBaseSelector: {
id: 'knowledgeBaseSelector',
type: 'knowledge-base-selector',
value: 'kb-uuid-123',
},
})
expect(ctx.knowledgeBaseId).toBe('kb-uuid-123')
})
it('should extract knowledgeBaseId from manualKnowledgeBaseId via canonical mapping', () => {
const ctx = buildSelectorContextFromBlock('knowledge', {
operation: { id: 'operation', type: 'dropdown', value: 'search' },
manualKnowledgeBaseId: {
id: 'manualKnowledgeBaseId',
type: 'short-input',
value: 'manual-kb-id',
},
})
expect(ctx.knowledgeBaseId).toBe('manual-kb-id')
})
it('resolves the ACTIVE member when both basic and advanced hold values (no last-write-wins)', () => {
const subBlocks = {
operation: { id: 'operation', type: 'dropdown', value: 'search' },
knowledgeBaseSelector: {
id: 'knowledgeBaseSelector',
type: 'knowledge-base-selector',
value: 'kb-basic',
},
manualKnowledgeBaseId: {
id: 'manualKnowledgeBaseId',
type: 'short-input',
value: 'kb-advanced',
},
}
// No override: the value heuristic keeps basic (matches a default-basic migrated block).
expect(buildSelectorContextFromBlock('knowledge', subBlocks).knowledgeBaseId).toBe('kb-basic')
// Explicit advanced toggle: the active member wins (the dormant basic value never leaks).
expect(
buildSelectorContextFromBlock('knowledge', subBlocks, {
canonicalModes: { knowledgeBaseId: 'advanced' },
}).knowledgeBaseId
).toBe('kb-advanced')
})
it('should skip null/empty values', () => {
const ctx = buildSelectorContextFromBlock('knowledge', {
knowledgeBaseSelector: {
id: 'knowledgeBaseSelector',
type: 'knowledge-base-selector',
value: '',
},
})
expect(ctx.knowledgeBaseId).toBeUndefined()
})
it('should return empty context for unknown block types', () => {
const ctx = buildSelectorContextFromBlock('nonexistent_block', {
foo: { id: 'foo', type: 'short-input', value: 'bar' },
})
expect(ctx).toEqual({})
})
it('should pass through workflowId from opts', () => {
const ctx = buildSelectorContextFromBlock(
'knowledge',
{ operation: { id: 'operation', type: 'dropdown', value: 'search' } },
{ workflowId: 'wf-123' }
)
expect(ctx.workflowId).toBe('wf-123')
})
it('should pass through workspaceId from opts', () => {
const ctx = buildSelectorContextFromBlock(
'knowledge',
{ operation: { id: 'operation', type: 'dropdown', value: 'search' } },
{ workspaceId: 'ws-123' }
)
expect(ctx.workspaceId).toBe('ws-123')
})
it('should ignore subblock keys not in SELECTOR_CONTEXT_FIELDS', () => {
const ctx = buildSelectorContextFromBlock('knowledge', {
operation: { id: 'operation', type: 'dropdown', value: 'search' },
query: { id: 'query', type: 'short-input', value: 'some search query' },
})
expect((ctx as Record<string, unknown>).query).toBeUndefined()
expect((ctx as Record<string, unknown>).operation).toBeUndefined()
})
})
describe('SELECTOR_CONTEXT_FIELDS validation', () => {
it('every entry must be a canonicalParamId (if a canonical pair exists) or a direct subblock ID', () => {
const allCanonicalParamIds = new Set<string>()
const allSubBlockIds = new Set<string>()
const idsInCanonicalPairs = new Set<string>()
for (const block of getAllBlocks()) {
const index = buildCanonicalIndex(block.subBlocks)
for (const sb of block.subBlocks) {
allSubBlockIds.add(sb.id)
if (sb.canonicalParamId) {
allCanonicalParamIds.add(sb.canonicalParamId)
}
}
for (const group of Object.values(index.groupsById)) {
if (!isCanonicalPair(group)) continue
if (group.basicId) idsInCanonicalPairs.add(group.basicId)
for (const advId of group.advancedIds) idsInCanonicalPairs.add(advId)
}
}
const errors: string[] = []
for (const field of SELECTOR_CONTEXT_FIELDS) {
const f = field as string
if (allCanonicalParamIds.has(f)) continue
if (idsInCanonicalPairs.has(f)) {
errors.push(
`"${f}" is a member subblock ID inside a canonical pair — use the canonicalParamId instead`
)
continue
}
if (!allSubBlockIds.has(f)) {
errors.push(`"${f}" is not a canonicalParamId or subblock ID in any block definition`)
}
}
if (errors.length > 0) {
throw new Error(`SELECTOR_CONTEXT_FIELDS validation failed:\n${errors.join('\n')}`)
}
})
})
@@ -0,0 +1,87 @@
import { getBlock } from '@/blocks'
import type { SelectorContext } from '@/hooks/selectors/types'
import type { SubBlockState } from '@/stores/workflows/workflow/types'
import {
buildCanonicalIndex,
buildSubBlockValues,
type CanonicalModeOverrides,
resolveActiveCanonicalValue,
} from './visibility'
/**
* Canonical param IDs (or raw subblock IDs) that correspond to SelectorContext fields.
* A subblock's resolved canonical key is set on the context only if it appears here.
*/
export const SELECTOR_CONTEXT_FIELDS = new Set<keyof SelectorContext>([
'oauthCredential',
'domain',
'teamId',
'projectId',
'knowledgeBaseId',
'planId',
'siteId',
'collectionId',
'spreadsheetId',
'driveId',
'fileId',
'baseId',
'datasetId',
'serviceDeskId',
'impersonateUserEmail',
'boardId',
'awsAccessKeyId',
'awsSecretAccessKey',
'awsRegion',
'logGroupName',
'tableId',
])
/**
* Builds a SelectorContext from a block's subBlocks using the canonical index.
*
* Iterates all subblocks, resolves each through canonicalIdBySubBlockId to get
* the canonical key, then checks it against SELECTOR_CONTEXT_FIELDS.
* This avoids hardcoding subblock IDs and automatically handles basic/advanced
* renames.
*/
export function buildSelectorContextFromBlock(
blockType: string,
subBlocks: Record<string, SubBlockState | { value?: unknown }>,
opts?: { workflowId?: string; workspaceId?: string; canonicalModes?: CanonicalModeOverrides }
): SelectorContext {
const context: SelectorContext = {}
if (opts?.workflowId) context.workflowId = opts.workflowId
if (opts?.workspaceId) context.workspaceId = opts.workspaceId
const blockConfig = getBlock(blockType)
if (!blockConfig) return context
const canonicalIndex = buildCanonicalIndex(blockConfig.subBlocks)
const values = buildSubBlockValues(subBlocks)
const resolvedGroups = new Set<string>()
const setField = (key: string, value: unknown) => {
if (value === null || value === undefined) return
const strValue = typeof value === 'string' ? value : String(value)
if (!strValue) return
if (SELECTOR_CONTEXT_FIELDS.has(key as keyof SelectorContext)) {
context[key as keyof SelectorContext] = strValue
}
}
for (const [subBlockId, subBlock] of Object.entries(subBlocks)) {
const canonicalId = canonicalIndex.canonicalIdBySubBlockId[subBlockId]
if (canonicalId) {
// A canonical group resolves to its ACTIVE member only (no last-write-wins between a
// basic/advanced pair when both hold values), honoring an explicit mode override.
if (resolvedGroups.has(canonicalId)) continue
resolvedGroups.add(canonicalId)
const group = canonicalIndex.groupsById[canonicalId]
setField(canonicalId, resolveActiveCanonicalValue(group, values, opts?.canonicalModes))
continue
}
setField(subBlockId, subBlock?.value)
}
return context
}
@@ -0,0 +1,235 @@
/**
* @vitest-environment node
*/
import { describe, expect, it, vi } from 'vitest'
vi.mock('@/blocks', () => ({
getBlock: (type: string) => {
if (type === 'slack') return { name: 'Slack' }
if (type === 'workflow' || type === 'workflow_input') return { name: 'Workflow' }
return undefined
},
}))
import {
getDisplayValue,
resolveDropdownLabel,
resolveFilterFieldLabel,
resolveSkillsLabel,
resolveToolsLabel,
resolveVariablesLabel,
resolveWorkflowMultiSelectLabel,
resolveWorkflowSelectionLabel,
summarizeNames,
} from '@/lib/workflows/subblocks/display'
import type { SubBlockConfig } from '@/blocks/types'
const workflowSelector = { id: 'workflowId', type: 'workflow-selector' } as SubBlockConfig
const workflowMulti = {
id: 'workflowIds',
type: 'dropdown',
multiSelect: true,
} as SubBlockConfig
const variablesInput = { id: 'variables', type: 'variables-input' } as SubBlockConfig
const toolInput = { id: 'tools', type: 'tool-input' } as SubBlockConfig
const skillInput = { id: 'skills', type: 'skill-input' } as SubBlockConfig
describe('summarizeNames', () => {
it('formats 0, 1, 2, and 2+N name lists', () => {
expect(summarizeNames([])).toBeNull()
expect(summarizeNames(['A'])).toBe('A')
expect(summarizeNames(['A', 'B'])).toBe('A, B')
expect(summarizeNames(['A', 'B', 'C', 'D'])).toBe('A, B +2')
})
})
describe('workflow selection labels', () => {
const lookup = { workflowMap: { 'wf-1': { name: 'Billing' } }, ready: true }
it('resolves a single workflow selection to its name', () => {
expect(resolveWorkflowSelectionLabel(workflowSelector, 'wf-1', lookup)).toBe('Billing')
})
it('labels missing workflows as deleted only after the lookup is ready', () => {
expect(resolveWorkflowSelectionLabel(workflowSelector, 'wf-gone', lookup)).toBe(
'Deleted Workflow'
)
expect(
resolveWorkflowSelectionLabel(workflowSelector, 'wf-gone', { ...lookup, ready: false })
).toBeNull()
})
it('summarizes multi-select workflow ids with the deleted fallback', () => {
expect(resolveWorkflowMultiSelectLabel(workflowMulti, ['wf-1', 'wf-gone'], lookup)).toBe(
'Billing, Deleted Workflow'
)
expect(
resolveWorkflowMultiSelectLabel(workflowMulti, ['wf-1'], { ...lookup, ready: false })
).toBeNull()
})
it('matches multi-select subblocks by canonicalParamId as well as id', () => {
const canonical = {
id: 'workflowSelector',
type: 'dropdown',
multiSelect: true,
canonicalParamId: 'workflowIds',
} as SubBlockConfig
expect(resolveWorkflowMultiSelectLabel(canonical, ['wf-1'], lookup)).toBe('Billing')
})
})
describe('resolveVariablesLabel', () => {
it('resolves variable ids to live names and falls back to stored names', () => {
const variables = [{ id: 'var-1', name: 'apiKey' }]
expect(
resolveVariablesLabel(
variablesInput,
[
{ variableId: 'var-1', value: 1 },
{ variableName: 'region', value: 2 },
],
variables
)
).toBe('apiKey, region')
})
})
describe('resolveToolsLabel', () => {
it('resolves titles, custom tools by id, schema names, and registry blocks', () => {
const customTools = [{ id: 'ct-1', title: 'My Tool' }]
expect(
resolveToolsLabel(
toolInput,
[
{ title: 'Explicit' },
{ type: 'custom-tool', customToolId: 'ct-1' },
{ schema: { function: { name: 'inline_fn' } } },
{ type: 'slack' },
],
customTools
)
).toBe('Explicit, My Tool +2')
})
it('skips unresolvable entries instead of inventing labels', () => {
expect(resolveToolsLabel(toolInput, [{ type: 'custom-tool', customToolId: 'gone' }], [])).toBe(
null
)
})
it('ignores the stored title on registry-backed tools so state edits cannot rename them', () => {
const slackName = resolveToolsLabel(toolInput, [{ type: 'slack' }], [])
expect(slackName).not.toBe(null)
expect(resolveToolsLabel(toolInput, [{ type: 'slack', title: 'Renamed By Copilot' }], [])).toBe(
slackName
)
})
it('falls back to the stored title, then the raw type id, for unresolvable block types', () => {
expect(
resolveToolsLabel(toolInput, [{ type: 'custom_block_gone', title: 'Invoice Parser' }], [])
).toBe('Invoice Parser')
expect(resolveToolsLabel(toolInput, [{ type: 'custom_block_gone' }], [])).toBe(
'custom_block_gone'
)
})
it('renders the static label for workflow-as-tool entries regardless of stored title', () => {
expect(
resolveToolsLabel(toolInput, [{ type: 'workflow', title: 'Renamed By Copilot' }], [])
).toBe('Workflow')
expect(resolveToolsLabel(toolInput, [{ type: 'workflow_input' }], [])).toBe('Workflow')
})
it('prefers the live MCP tool name over the stored title', () => {
expect(
resolveToolsLabel(
toolInput,
[{ type: 'mcp', toolId: 'mcp-1', title: 'Renamed By Copilot' }],
[],
new Map([['mcp-1', 'Live MCP Name']])
)
).toBe('Live MCP Name')
expect(
resolveToolsLabel(toolInput, [{ type: 'mcp', toolId: 'mcp-1', title: 'Snapshot' }], [])
).toBe('Snapshot')
})
it('prefers the custom tool record over the stored title', () => {
expect(
resolveToolsLabel(
toolInput,
[{ type: 'custom-tool', customToolId: 'ct-1', title: 'Renamed By Copilot' }],
[{ id: 'ct-1', title: 'My Tool' }]
)
).toBe('My Tool')
})
})
describe('resolveSkillsLabel', () => {
it('prefers live skill names and falls back to the stored name', () => {
const skills = [{ id: 'sk-1', name: 'Research' }]
expect(
resolveSkillsLabel(
skillInput,
[{ skillId: 'sk-1' }, { skillId: 'sk-deleted', name: 'Old Name' }],
skills
)
).toBe('Research, Old Name')
})
it('never renders raw skill ids', () => {
expect(resolveSkillsLabel(skillInput, [{ skillId: 'sk-unknown' }], [])).toBeNull()
})
})
describe('resolveDropdownLabel', () => {
const dropdown = {
id: 'mode',
type: 'dropdown',
options: [{ id: 'opt-1', label: 'Option One' }, 'literal'],
} as SubBlockConfig
it('resolves option ids and string options to labels', () => {
expect(resolveDropdownLabel(dropdown, 'opt-1')).toBe('Option One')
expect(resolveDropdownLabel(dropdown, 'literal')).toBe('literal')
expect(resolveDropdownLabel(dropdown, 'missing')).toBeNull()
})
})
describe('resolveFilterFieldLabel', () => {
const filterField = { id: 'filter', type: 'short-input' } as SubBlockConfig
it('renders compact JSON for filter fields and truncates long values', () => {
expect(resolveFilterFieldLabel(filterField, '{"a":1}')).toBe('{"a":1}')
const long = JSON.stringify({ column: 'status', operator: 'contains', value: 'running' })
expect(resolveFilterFieldLabel(filterField, long)).toBe(`${long.slice(0, 32)}...`)
})
it('returns null for non-filter subblocks and non-JSON values', () => {
expect(
resolveFilterFieldLabel({ id: 'other', type: 'short-input' } as SubBlockConfig, '{"a":1}')
).toBeNull()
expect(resolveFilterFieldLabel(filterField, 'plain text')).toBeNull()
})
})
describe('getDisplayValue', () => {
it('handles empty, scalar, and object values', () => {
expect(getDisplayValue(null)).toBe('-')
expect(getDisplayValue('hello')).toBe('hello')
expect(getDisplayValue({ a: 1 })).toBe('a: 1')
})
it('summarizes name-bearing arrays', () => {
expect(
getDisplayValue([
{ variableName: 'one', variableId: 'v1', value: 1 },
{ variableName: 'two', variableId: 'v2', value: 2 },
{ variableName: 'three', variableId: 'v3', value: 3 },
])
).toBe('one, two +1')
expect(getDisplayValue(['a', 'b'])).toBe('a, b')
})
})
+546
View File
@@ -0,0 +1,546 @@
/**
* Pure display helpers for collapsed subblock rows.
*
* Shared by the canvas editor (`workflow-block.tsx`, hook-fed data) and the
* read-only preview (`preview-workflow/.../block.tsx`, prop/store-fed data).
* Every resolver takes plain data instead of hooks so both surfaces run the
* exact same logic and cannot drift.
*/
import { isRecordLike } from '@sim/utils/object'
import { truncate } from '@sim/utils/string'
import type { FilterRule, SortRule } from '@/lib/table/types'
import { DELETED_WORKFLOW_LABEL } from '@/app/workspace/[workspaceId]/logs/utils'
import { getBlock } from '@/blocks'
import type { SubBlockConfig } from '@/blocks/types'
/**
* Joins display names as "A", "A, B", or "A, B +N".
* Returns null for an empty list so callers can fall through.
*/
export function summarizeNames(names: string[]): string | null {
if (names.length === 0) return null
if (names.length === 1) return names[0]
if (names.length === 2) return `${names[0]}, ${names[1]}`
return `${names[0]}, ${names[1]} +${names.length - 2}`
}
interface WorkflowTableRow {
id: string
cells: Record<string, string>
}
interface FieldFormat {
id: string
name: string
type?: string
value?: string
collapsed?: boolean
}
interface TagFilterItem {
id: string
tagName: string
fieldType?: string
operator?: string
tagValue: string
}
interface DocumentTagItem {
id: string
tagName: string
fieldType?: string
value: string
}
const isTableRowArray = (value: unknown): value is WorkflowTableRow[] => {
if (!Array.isArray(value) || value.length === 0) return false
const firstItem = value[0]
return (
typeof firstItem === 'object' &&
firstItem !== null &&
'id' in firstItem &&
'cells' in firstItem &&
typeof firstItem.cells === 'object'
)
}
const isFieldFormatArray = (value: unknown): value is FieldFormat[] => {
if (!Array.isArray(value) || value.length === 0) return false
const firstItem = value[0]
return (
typeof firstItem === 'object' &&
firstItem !== null &&
'id' in firstItem &&
'name' in firstItem &&
typeof firstItem.name === 'string'
)
}
/** Type guard for variable assignments arrays (variables-input subblocks). */
const isVariableAssignmentsArray = (
value: unknown
): value is Array<{ id?: string; variableId?: string; variableName?: string; value: unknown }> => {
return (
Array.isArray(value) &&
value.length > 0 &&
value.every(
(item) =>
typeof item === 'object' &&
item !== null &&
('variableName' in item || 'variableId' in item)
)
)
}
const isMessagesArray = (value: unknown): value is Array<{ role: string; content: string }> => {
return (
Array.isArray(value) &&
value.length > 0 &&
value.every(
(item) =>
typeof item === 'object' &&
item !== null &&
'role' in item &&
'content' in item &&
typeof item.role === 'string' &&
typeof item.content === 'string'
)
)
}
const isTagFilterArray = (value: unknown): value is TagFilterItem[] => {
if (!Array.isArray(value) || value.length === 0) return false
const firstItem = value[0]
return (
typeof firstItem === 'object' &&
firstItem !== null &&
'tagName' in firstItem &&
'tagValue' in firstItem &&
typeof firstItem.tagName === 'string'
)
}
const isDocumentTagArray = (value: unknown): value is DocumentTagItem[] => {
if (!Array.isArray(value) || value.length === 0) return false
const firstItem = value[0]
return (
typeof firstItem === 'object' &&
firstItem !== null &&
'tagName' in firstItem &&
'value' in firstItem &&
!('tagValue' in firstItem) &&
typeof firstItem.tagName === 'string'
)
}
const isFilterConditionArray = (value: unknown): value is FilterRule[] => {
if (!Array.isArray(value) || value.length === 0) return false
const firstItem = value[0]
return (
typeof firstItem === 'object' &&
firstItem !== null &&
'column' in firstItem &&
'operator' in firstItem &&
'logicalOperator' in firstItem &&
typeof firstItem.column === 'string'
)
}
const isSortConditionArray = (value: unknown): value is SortRule[] => {
if (!Array.isArray(value) || value.length === 0) return false
const firstItem = value[0]
return (
typeof firstItem === 'object' &&
firstItem !== null &&
'column' in firstItem &&
'direction' in firstItem &&
typeof firstItem.column === 'string' &&
(firstItem.direction === 'asc' || firstItem.direction === 'desc')
)
}
/**
* Attempts to parse a JSON string, returning the parsed value or the
* original value if parsing fails.
*/
const tryParseJson = (value: unknown): unknown => {
if (typeof value !== 'string') return value
try {
const trimmed = value.trim()
if (
(trimmed.startsWith('[') && trimmed.endsWith(']')) ||
(trimmed.startsWith('{') && trimmed.endsWith('}'))
) {
return JSON.parse(trimmed)
}
} catch {
return value
}
return value
}
/**
* Formats a subblock value for display, intelligently handling nested
* objects and arrays.
*/
export const getDisplayValue = (value: unknown): string => {
if (value == null || value === '') return '-'
const parsedValue = tryParseJson(value)
if (isMessagesArray(parsedValue)) {
const firstMessage = parsedValue[0]
if (!firstMessage?.content || firstMessage.content.trim() === '') return '-'
const content = firstMessage.content.trim()
return truncate(content, 50)
}
if (isVariableAssignmentsArray(parsedValue)) {
const names = parsedValue.map((a) => a.variableName).filter((name): name is string => !!name)
return summarizeNames(names) ?? '-'
}
if (isTagFilterArray(parsedValue)) {
const names = parsedValue
.filter((f) => typeof f.tagName === 'string' && f.tagName.trim() !== '')
.map((f) => f.tagName)
return summarizeNames(names) ?? '-'
}
if (isDocumentTagArray(parsedValue)) {
const names = parsedValue
.filter((t) => typeof t.tagName === 'string' && t.tagName.trim() !== '')
.map((t) => t.tagName)
return summarizeNames(names) ?? '-'
}
if (isFilterConditionArray(parsedValue)) {
const opLabels: Record<string, string> = {
eq: '=',
ne: '≠',
gt: '>',
gte: '≥',
lt: '<',
lte: '≤',
contains: '~',
in: 'in',
}
const names = parsedValue
.filter((c) => typeof c.column === 'string' && c.column.trim() !== '')
.map((c) => `${c.column} ${opLabels[c.operator] || c.operator} ${c.value || '?'}`)
return summarizeNames(names) ?? '-'
}
if (isSortConditionArray(parsedValue)) {
const names = parsedValue
.filter((c) => typeof c.column === 'string' && c.column.trim() !== '')
.map((c) => `${c.column} ${c.direction === 'desc' ? '↓' : '↑'}`)
return summarizeNames(names) ?? '-'
}
if (isTableRowArray(parsedValue)) {
const nonEmptyRows = parsedValue.filter((row) => {
const cellValues = Object.values(row.cells)
return cellValues.some((cell) => cell && cell.trim() !== '')
})
if (nonEmptyRows.length === 0) return '-'
if (nonEmptyRows.length === 1) {
const firstRow = nonEmptyRows[0]
const cellEntries = Object.entries(firstRow.cells).filter(([, val]) => val?.trim())
if (cellEntries.length === 0) return '-'
const preview = cellEntries
.slice(0, 2)
.map(([key, val]) => `${key}: ${val}`)
.join(', ')
return cellEntries.length > 2 ? `${preview}...` : preview
}
return `${nonEmptyRows.length} rows`
}
if (isFieldFormatArray(parsedValue)) {
const names = parsedValue
.filter((field) => typeof field.name === 'string' && field.name.trim() !== '')
.map((field) => field.name)
return summarizeNames(names) ?? '-'
}
if (isRecordLike(parsedValue)) {
const entries = Object.entries(parsedValue).filter(
([, val]) => val !== null && val !== undefined && val !== ''
)
if (entries.length === 0) return '-'
if (entries.length === 1) {
const [key, val] = entries[0]
const valStr = String(val).slice(0, 30)
return `${key}: ${valStr}${String(val).length > 30 ? '...' : ''}`
}
const preview = entries
.slice(0, 2)
.map(([key]) => key)
.join(', ')
return entries.length > 2 ? `${preview} +${entries.length - 2}` : preview
}
if (Array.isArray(parsedValue)) {
const getItemDisplayValue = (item: unknown): string => {
if (typeof item === 'object' && item !== null) {
const obj = item as Record<string, unknown>
return String(obj.title || obj.name || obj.label || obj.id || JSON.stringify(item))
}
return String(item)
}
const names = parsedValue
.filter((item) => item !== null && item !== undefined && item !== '')
.map(getItemDisplayValue)
return summarizeNames(names) ?? '-'
}
const stringValue = String(value)
if (stringValue === '[object Object]') {
try {
const json = JSON.stringify(parsedValue)
if (json.length <= 40) return json
return truncate(json, 37)
} catch {
return '-'
}
}
return stringValue.trim().length > 0 ? stringValue : '-'
}
/**
* Workflow id -> metadata lookup for the workflow selector resolvers.
* `ready` gates resolution so missing entries only render as deleted once
* the lookup has actually loaded.
*/
interface WorkflowNameLookup {
workflowMap: Record<string, { name: string }>
ready: boolean
}
/**
* Resolves filter/sort builder subblocks to a compact single-line JSON
* preview. Returns null for other subblocks; callers use a non-null result
* to apply monospace styling.
*/
export function resolveFilterFieldLabel(
subBlock: SubBlockConfig | undefined,
rawValue: unknown
): string | null {
const isFilterField =
subBlock?.id === 'filter' || subBlock?.id === 'filterCriteria' || subBlock?.id === 'sort'
if (!isFilterField || !rawValue) return null
const parsedValue = tryParseJson(rawValue)
if (!isRecordLike(parsedValue) && !Array.isArray(parsedValue)) return null
try {
const jsonStr = JSON.stringify(parsedValue, null, 0)
return jsonStr.length <= 35 ? jsonStr : truncate(jsonStr, 32)
} catch {
return null
}
}
/**
* Resolves a static dropdown/combobox value to its option label.
* Returns null if not a dropdown/combobox or no matching option is found.
*/
export function resolveDropdownLabel(
subBlock: SubBlockConfig | undefined,
rawValue: unknown
): string | null {
if (!subBlock || (subBlock.type !== 'dropdown' && subBlock.type !== 'combobox')) return null
if (!rawValue || typeof rawValue !== 'string') return null
const options = typeof subBlock.options === 'function' ? subBlock.options() : subBlock.options
if (!options) return null
const option = options.find((opt) =>
typeof opt === 'string' ? opt === rawValue : opt.id === rawValue
)
if (!option) return null
return typeof option === 'string' ? option : option.label
}
/** Resolves a workflow-selector value to the workflow's name. */
export function resolveWorkflowSelectionLabel(
subBlock: SubBlockConfig | undefined,
rawValue: unknown,
lookup: WorkflowNameLookup
): string | null {
if (subBlock?.type !== 'workflow-selector') return null
if (!rawValue || typeof rawValue !== 'string') return null
if (!lookup.ready) return null
return lookup.workflowMap[rawValue]?.name ?? DELETED_WORKFLOW_LABEL
}
/**
* Resolves multi-select workflow dropdowns (e.g. the Sim trigger's workflow
* scope) to a workflow-name summary.
*/
export function resolveWorkflowMultiSelectLabel(
subBlock: SubBlockConfig | undefined,
rawValue: unknown,
lookup: WorkflowNameLookup
): string | null {
const isWorkflowMultiSelect =
subBlock?.type === 'dropdown' &&
subBlock.multiSelect &&
(subBlock.id === 'workflowIds' || subBlock.canonicalParamId === 'workflowIds')
if (!isWorkflowMultiSelect) return null
if (!Array.isArray(rawValue) || rawValue.length === 0) return null
if (!lookup.ready) return null
const names = rawValue
.filter((id): id is string => typeof id === 'string' && id.length > 0)
.map((id) => lookup.workflowMap[id]?.name ?? DELETED_WORKFLOW_LABEL)
return summarizeNames(names)
}
/** Resolves a variables-input value to a variable-name summary. */
export function resolveVariablesLabel(
subBlock: SubBlockConfig | undefined,
rawValue: unknown,
variables: Array<{ id: string; name: string }>
): string | null {
if (subBlock?.type !== 'variables-input') return null
if (!isVariableAssignmentsArray(rawValue)) return null
const names = rawValue
.map((assignment) => {
if (assignment.variableId) {
return variables.find((variable) => variable.id === assignment.variableId)?.name
}
if (assignment.variableName) return assignment.variableName
return null
})
.filter((name): name is string => !!name)
return summarizeNames(names)
}
/** Custom-tool record shape needed to resolve a stored custom-tool reference. */
export interface StoredCustomToolRecord {
id: string
title?: string
schema?: { function?: { name?: string } }
}
export interface ResolveStoredToolNameOptions {
customTools?: StoredCustomToolRecord[]
/** Live MCP tool names keyed by composite tool id (`createMcpToolId`). */
mcpToolNamesById?: ReadonlyMap<string, string>
/** Block-config lookup; overridable so snapshot views can scope configs. */
getBlockConfig?: (type: string) => { name?: string } | undefined
}
/**
* Resolves one stored tool entry to its display name. Canonical sources win —
* the block registry (including the custom-block overlay), the custom-tool
* record, live MCP server data — so edits to the entry's mutable `title` in
* workflow state cannot relabel a tool that has a canonical name. The stored
* title is the fallback for entries with no resolvable source (deleted custom
* blocks, MCP entries while server data is unavailable, legacy inline custom
* tools where the title is the identity), ahead of the raw type id.
*/
export function resolveStoredToolName(
tool: unknown,
options: ResolveStoredToolNameOptions = {}
): string | null {
if (!tool || typeof tool !== 'object') return null
const t = tool as Record<string, unknown>
const { customTools = [], mcpToolNamesById, getBlockConfig = getBlock } = options
const storedTitle = typeof t.title === 'string' && t.title ? t.title : null
const schema = t.schema as { function?: { name?: string } } | undefined
const schemaName = schema?.function?.name || null
if (t.type === 'custom-tool') {
if (typeof t.customToolId === 'string') {
const record = customTools.find((candidate) => candidate.id === t.customToolId)
if (record?.title) return record.title
if (record?.schema?.function?.name) return record.schema.function.name
}
return storedTitle || schemaName
}
if (t.type === 'mcp') {
if (typeof t.toolId === 'string') {
const liveName = mcpToolNamesById?.get(t.toolId)
if (liveName) return liveName
}
return storedTitle
}
if (typeof t.type === 'string' && t.type) {
const blockConfig = getBlockConfig(t.type)
if (blockConfig?.name) return blockConfig.name
return storedTitle || t.type
}
if (storedTitle) return storedTitle
if (schemaName) return schemaName
const fn = t.function as { name?: string } | undefined
if (fn?.name) return fn.name
return null
}
/**
* Resolves a tool-input value to a tool-name summary via
* {@link resolveStoredToolName}. Unresolvable entries are skipped.
*/
export function resolveToolsLabel(
subBlock: SubBlockConfig | undefined,
rawValue: unknown,
customTools: StoredCustomToolRecord[],
mcpToolNamesById?: ReadonlyMap<string, string>
): string | null {
if (subBlock?.type !== 'tool-input') return null
if (!Array.isArray(rawValue) || rawValue.length === 0) return null
const names = rawValue
.map((tool: unknown) => resolveStoredToolName(tool, { customTools, mcpToolNamesById }))
.filter((name): name is string => !!name)
return summarizeNames(names)
}
/**
* Resolves a skill-input value to a skill-name summary: the live skill name
* when the skill still exists, otherwise the name stored alongside the
* reference. Unresolvable entries are skipped rather than shown as raw ids.
*/
export function resolveSkillsLabel(
subBlock: SubBlockConfig | undefined,
rawValue: unknown,
skills: Array<{ id: string; name: string }>
): string | null {
if (subBlock?.type !== 'skill-input') return null
if (!Array.isArray(rawValue) || rawValue.length === 0) return null
const names = rawValue
.map((skill: unknown) => {
if (!skill || typeof skill !== 'object') return null
const s = skill as { skillId?: string; name?: string }
if (s.skillId) {
const found = skills.find((candidate) => candidate.id === s.skillId)
if (found?.name) return found.name
}
if (typeof s.name === 'string' && s.name) return s.name
return null
})
.filter((name): name is string => !!name)
return summarizeNames(names)
}
@@ -0,0 +1,32 @@
import { getQueryClient } from '@/app/_shell/providers/get-query-client'
import { getWorkflowListQueryOptions } from '@/hooks/queries/utils/workflow-list-query'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
interface SubBlockOption {
label: string
id: string
}
/**
* Loads the active workspace's workflows for multi-select subblocks
* (`fetchOptions`). Set `excludeActiveWorkflow` for surfaces where selecting
* the current workflow is meaningless (e.g. the Sim trigger never receives
* events about itself).
*/
export async function fetchWorkspaceWorkflowOptions(options?: {
excludeActiveWorkflow?: boolean
}): Promise<SubBlockOption[]> {
const registry = useWorkflowRegistry.getState()
const workspaceId = registry.hydration.workspaceId
if (!workspaceId) return []
const workflows = await getQueryClient().fetchQuery(
getWorkflowListQueryOptions(workspaceId, 'active')
)
return workflows
.filter(
(workflow) => !options?.excludeActiveWorkflow || workflow.id !== registry.activeWorkflowId
)
.map((workflow) => ({ id: workflow.id, label: workflow.name }))
}
@@ -0,0 +1,328 @@
/**
* @vitest-environment node
*/
import { describe, expect, it } from 'vitest'
import {
evaluateSubBlockCondition,
reindexToolCanonicalModes,
scopeCanonicalModesForTool,
} from './visibility'
describe('evaluateSubBlockCondition', () => {
describe('simple value matching', () => {
it.concurrent('returns true when field value matches condition value', () => {
const condition = { field: 'operation', value: 'create_booking' }
const values = { operation: 'create_booking' }
expect(evaluateSubBlockCondition(condition, values)).toBe(true)
})
it.concurrent('returns false when field value does not match condition value', () => {
const condition = { field: 'operation', value: 'create_booking' }
const values = { operation: 'cancel_booking' }
expect(evaluateSubBlockCondition(condition, values)).toBe(false)
})
it.concurrent('returns false when field is missing', () => {
const condition = { field: 'operation', value: 'create_booking' }
const values = {}
expect(evaluateSubBlockCondition(condition, values)).toBe(false)
})
it.concurrent('returns false when field is undefined', () => {
const condition = { field: 'operation', value: 'create_booking' }
const values = { operation: undefined }
expect(evaluateSubBlockCondition(condition, values)).toBe(false)
})
it.concurrent('returns false when field is null', () => {
const condition = { field: 'operation', value: 'create_booking' }
const values = { operation: null }
expect(evaluateSubBlockCondition(condition, values)).toBe(false)
})
})
describe('array value matching', () => {
it.concurrent('returns true when field value is in condition array', () => {
const condition = { field: 'operation', value: ['create_booking', 'update_booking'] }
const values = { operation: 'create_booking' }
expect(evaluateSubBlockCondition(condition, values)).toBe(true)
})
it.concurrent('returns true for second array value', () => {
const condition = { field: 'operation', value: ['create_booking', 'update_booking'] }
const values = { operation: 'update_booking' }
expect(evaluateSubBlockCondition(condition, values)).toBe(true)
})
it.concurrent('returns false when field value is not in condition array', () => {
const condition = { field: 'operation', value: ['create_booking', 'update_booking'] }
const values = { operation: 'cancel_booking' }
expect(evaluateSubBlockCondition(condition, values)).toBe(false)
})
it.concurrent('returns false when field is undefined with array condition', () => {
const condition = { field: 'operation', value: ['create_booking', 'update_booking'] }
const values = { operation: undefined }
expect(evaluateSubBlockCondition(condition, values)).toBe(false)
})
it.concurrent('returns false when field is null with array condition', () => {
const condition = { field: 'operation', value: ['create_booking', 'update_booking'] }
const values = { operation: null }
expect(evaluateSubBlockCondition(condition, values)).toBe(false)
})
})
describe('negation with not flag', () => {
it.concurrent('returns false when field matches but not is true', () => {
const condition = { field: 'operation', value: 'create_booking', not: true }
const values = { operation: 'create_booking' }
expect(evaluateSubBlockCondition(condition, values)).toBe(false)
})
it.concurrent('returns true when field does not match and not is true', () => {
const condition = { field: 'operation', value: 'create_booking', not: true }
const values = { operation: 'cancel_booking' }
expect(evaluateSubBlockCondition(condition, values)).toBe(true)
})
it.concurrent('returns true when field is not in array and not is true', () => {
const condition = {
field: 'operation',
value: ['create_booking', 'update_booking'],
not: true,
}
const values = { operation: 'cancel_booking' }
expect(evaluateSubBlockCondition(condition, values)).toBe(true)
})
it.concurrent('returns false when field is in array and not is true', () => {
const condition = {
field: 'operation',
value: ['create_booking', 'update_booking'],
not: true,
}
const values = { operation: 'create_booking' }
expect(evaluateSubBlockCondition(condition, values)).toBe(false)
})
})
describe('compound conditions with and', () => {
it.concurrent('returns true when both conditions match', () => {
const condition = {
field: 'operation',
value: 'create_booking',
and: { field: 'hasEmail', value: true },
}
const values = { operation: 'create_booking', hasEmail: true }
expect(evaluateSubBlockCondition(condition, values)).toBe(true)
})
it.concurrent('returns false when first condition matches but and condition fails', () => {
const condition = {
field: 'operation',
value: 'create_booking',
and: { field: 'hasEmail', value: true },
}
const values = { operation: 'create_booking', hasEmail: false }
expect(evaluateSubBlockCondition(condition, values)).toBe(false)
})
it.concurrent('returns false when first condition fails but and condition matches', () => {
const condition = {
field: 'operation',
value: 'create_booking',
and: { field: 'hasEmail', value: true },
}
const values = { operation: 'cancel_booking', hasEmail: true }
expect(evaluateSubBlockCondition(condition, values)).toBe(false)
})
it.concurrent('returns false when both conditions fail', () => {
const condition = {
field: 'operation',
value: 'create_booking',
and: { field: 'hasEmail', value: true },
}
const values = { operation: 'cancel_booking', hasEmail: false }
expect(evaluateSubBlockCondition(condition, values)).toBe(false)
})
})
describe('edge cases', () => {
it.concurrent('returns true when condition is undefined', () => {
expect(evaluateSubBlockCondition(undefined, { operation: 'anything' })).toBe(true)
})
it.concurrent('handles function conditions', () => {
const condition = () => ({ field: 'operation', value: 'create_booking' })
const values = { operation: 'create_booking' }
expect(evaluateSubBlockCondition(condition, values)).toBe(true)
})
it.concurrent('passes current values into function conditions', () => {
const condition = (values?: Record<string, unknown>) => ({
field: 'model',
value: typeof values?.model === 'string' ? values.model : '__no_model_selected__',
})
const values = { model: 'ollama/gemma3:4b' }
expect(evaluateSubBlockCondition(condition, values)).toBe(true)
})
it.concurrent('handles boolean values', () => {
const condition = { field: 'enabled', value: true }
const values = { enabled: true }
expect(evaluateSubBlockCondition(condition, values)).toBe(true)
})
it.concurrent('handles numeric values', () => {
const condition = { field: 'count', value: 5 }
const values = { count: 5 }
expect(evaluateSubBlockCondition(condition, values)).toBe(true)
})
})
})
describe('scopeCanonicalModesForTool', () => {
it.concurrent('returns undefined when there are no overrides', () => {
expect(scopeCanonicalModesForTool(undefined, 0)).toBeUndefined()
})
it.concurrent('returns undefined when toolIndex is undefined', () => {
expect(scopeCanonicalModesForTool({ '0:tableId': 'advanced' }, undefined)).toBeUndefined()
})
it.concurrent('strips the toolIndex prefix for the matching tool instance', () => {
const overrides = { '0:tableId': 'advanced', '1:tableId': 'basic' }
expect(scopeCanonicalModesForTool(overrides, 0)).toEqual({ tableId: 'advanced' })
expect(scopeCanonicalModesForTool(overrides, 1)).toEqual({ tableId: 'basic' })
})
it.concurrent(
'keeps two same-type tool instances independent (regression: two Table tools on one Agent block used to share a mode)',
() => {
const overrides = { '0:tableId': 'advanced', '1:tableId': 'basic' }
// Both tools have type "table" and canonicalId "tableId" - only toolIndex disambiguates them.
expect(scopeCanonicalModesForTool(overrides, 0)).toEqual({ tableId: 'advanced' })
expect(scopeCanonicalModesForTool(overrides, 1)).toEqual({ tableId: 'basic' })
}
)
it.concurrent('returns undefined when no keys match the given toolIndex prefix', () => {
expect(scopeCanonicalModesForTool({ '1:tableId': 'advanced' }, 0)).toBeUndefined()
})
it.concurrent('ignores falsy override values', () => {
expect(
scopeCanonicalModesForTool({ '0:tableId': undefined as unknown as 'advanced' }, 0)
).toBeUndefined()
})
it.concurrent(
'falls back to the legacy toolType-scoped prefix when no index-scoped key matches',
() => {
// Saved before per-instance scoping shipped - must not be silently dropped.
const legacyOverrides = { 'table:tableId': 'advanced' as const }
expect(scopeCanonicalModesForTool(legacyOverrides, 0, 'table')).toEqual({
tableId: 'advanced',
})
expect(scopeCanonicalModesForTool(legacyOverrides, 3, 'table')).toEqual({
tableId: 'advanced',
})
}
)
it.concurrent('prefers an index-scoped key over the legacy type-scoped fallback', () => {
const overrides = { 'table:tableId': 'advanced' as const, '0:tableId': 'basic' as const }
expect(scopeCanonicalModesForTool(overrides, 0, 'table')).toEqual({ tableId: 'basic' })
})
it.concurrent('does not fall back when no legacyToolType is given', () => {
expect(scopeCanonicalModesForTool({ 'table:tableId': 'advanced' }, 0)).toBeUndefined()
})
})
describe('reindexToolCanonicalModes', () => {
// Generic over T - only object identity matters, so a plain marker object stands in for a
// real StoredTool/fork-parsed-tool.
const tool = (label: string) => ({ label })
it.concurrent('returns undefined when there are no overrides', () => {
expect(reindexToolCanonicalModes([tool('a')], [tool('a')], undefined)).toBeUndefined()
})
it.concurrent('returns undefined when every tool keeps its index', () => {
const a = tool('a')
const b = tool('b')
expect(reindexToolCanonicalModes([a, b], [a, b], { '0:tableId': 'advanced' })).toBeUndefined()
})
it.concurrent('re-keys a surviving tool overrides to its new index after a removal', () => {
const a = tool('a')
const b = tool('b')
const c = tool('c')
// Remove `a` (index 0): b shifts 1->0, c shifts 2->1.
const result = reindexToolCanonicalModes([a, b, c], [b, c], {
'1:tableId': 'advanced',
'2:tableId': 'basic',
})
expect(result).toEqual({ '0:tableId': 'advanced', '1:tableId': 'basic' })
})
it.concurrent('re-keys overrides after a drag reorder (swap)', () => {
const a = tool('a')
const b = tool('b')
// Swap a and b: a moves 0->1, b moves 1->0. A naive sequential re-key would have one
// write clobber the other since both use the same canonicalId; this must resolve both
// from the ORIGINAL snapshot into one atomic result.
const result = reindexToolCanonicalModes([a, b], [b, a], {
'0:tableId': 'advanced',
'1:tableId': 'basic',
})
expect(result).toEqual({ '1:tableId': 'advanced', '0:tableId': 'basic' })
})
it.concurrent(
'regression: drops a removed tool old key so a later tool cannot inherit it',
() => {
const a = tool('a')
const b = tool('b')
// Remove `b` (index 1): nothing survives at index 1 in the result, so a future tool
// appended back into that slot won't silently inherit `b`'s old advanced mode.
const result = reindexToolCanonicalModes([a, b], [a], { '1:tableId': 'advanced' })
expect(result).toEqual({})
}
)
it.concurrent('drops a stale index key with no corresponding old-array position', () => {
// Simulates leftover pollution from before this fix (or an earlier missed clear):
// index 5 doesn't correspond to any tool in `oldTools` at all.
const a = tool('a')
const result = reindexToolCanonicalModes([a], [a], {
'5:tableId': 'advanced',
'0:tableId': 'basic',
})
expect(result).toEqual({ '0:tableId': 'basic' })
})
it.concurrent('carries a legacy (non-index-scoped) key through unchanged', () => {
const a = tool('a')
const b = tool('b')
// `table:tableId` isn't tied to any array position - removing/reordering tools must not
// touch it.
const result = reindexToolCanonicalModes([a, b], [b], {
'0:tableId': 'advanced',
'table:tableId': 'basic',
})
expect(result).toEqual({ 'table:tableId': 'basic' })
})
it.concurrent('ignores falsy override values', () => {
const a = tool('a')
const b = tool('b')
const result = reindexToolCanonicalModes([a, b], [b, a], {
'0:tableId': undefined as unknown as 'advanced',
})
expect(result).toBeUndefined()
})
})
@@ -0,0 +1,540 @@
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<string, CanonicalGroup>
canonicalIdBySubBlockId: Record<string, string>
}
export interface SubBlockCondition {
field: string
value: string | number | boolean | Array<string | number | boolean> | 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<string, unknown>
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<string, { value?: unknown } | null | undefined>
): Record<string, unknown> {
return Object.entries(subBlocks).reduce<Record<string, unknown>>((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<string, CanonicalGroup> = {}
const canonicalIdBySubBlockId: Record<string, string> = {}
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<string, 'basic' | 'advanced'> {
const index = buildCanonicalIndex(subBlocks)
const modes: Record<string, 'basic' | 'advanced'> = {}
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<string, unknown>,
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<string, unknown>) => SubBlockCondition)
| undefined,
values: Record<string, unknown>
): 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<string, unknown>
): 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<string, unknown>,
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<number, number>,
overrides: CanonicalModeOverrides | undefined
): Record<string, 'basic' | 'advanced'> | undefined {
if (!overrides) return undefined
let changed = false
const result: Record<string, 'basic' | 'advanced'> = {}
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<T>(
oldTools: readonly T[],
newTools: readonly T[],
overrides: CanonicalModeOverrides | undefined
): Record<string, 'basic' | 'advanced'> | undefined {
const newIndexByRef = new Map(newTools.map((tool, index) => [tool, index]))
const newIndexByOldIndex = new Map<number, number>()
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<string, unknown>,
canonicalIndex: CanonicalIndex
): boolean {
const checkedCanonical = new Set<string>()
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<string, unknown>,
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<SubBlockConfig, 'mode'>): boolean {
return subBlock.mode === 'trigger' || subBlock.mode === 'trigger-advanced'
}
export function isTriggerConfigSubBlock(subBlock: Pick<SubBlockConfig, 'type'>): boolean {
return String(subBlock.type) === 'trigger-config'
}
export function shouldUseSubBlockForTriggerModeCanonicalIndex(
subBlock: Pick<SubBlockConfig, 'mode' | 'type'>
): 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<SubBlockConfig, 'mode' | 'type'>,
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<string, unknown>,
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
}