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
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:
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user