diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/general/components/version-description-modal.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/general/components/version-description-modal.tsx index 0501948f850..510a4996992 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/general/components/version-description-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/general/components/version-description-modal.tsx @@ -89,7 +89,7 @@ export function VersionDescriptionModal({ return ( <> !openState && handleCloseAttempt()}> - + Version Description diff --git a/apps/sim/lib/workflows/comparison/compare.ts b/apps/sim/lib/workflows/comparison/compare.ts index f739f3f20d1..ee0e6e170ad 100644 --- a/apps/sim/lib/workflows/comparison/compare.ts +++ b/apps/sim/lib/workflows/comparison/compare.ts @@ -14,7 +14,10 @@ import { normalizeVariables, sanitizeVariable, } from './normalize' -import { formatValueForDisplay, resolveValueForDisplay } from './resolve-values' +import { formatValueForDisplay, resolveFieldLabel, resolveValueForDisplay } from './resolve-values' + +const MAX_CHANGES_PER_BLOCK = 6 +const MAX_EDGE_DETAILS = 3 const logger = createLogger('WorkflowComparison') @@ -45,10 +48,22 @@ export interface WorkflowDiffSummary { addedBlocks: Array<{ id: string; type: string; name?: string }> removedBlocks: Array<{ id: string; type: string; name?: string }> modifiedBlocks: Array<{ id: string; type: string; name?: string; changes: FieldChange[] }> - edgeChanges: { added: number; removed: number } + edgeChanges: { + added: number + removed: number + addedDetails: Array<{ sourceName: string; targetName: string }> + removedDetails: Array<{ sourceName: string; targetName: string }> + } loopChanges: { added: number; removed: number; modified: number } parallelChanges: { added: number; removed: number; modified: number } - variableChanges: { added: number; removed: number; modified: number } + variableChanges: { + added: number + removed: number + modified: number + addedNames: string[] + removedNames: string[] + modifiedNames: string[] + } hasChanges: boolean } @@ -63,10 +78,17 @@ export function generateWorkflowDiffSummary( addedBlocks: [], removedBlocks: [], modifiedBlocks: [], - edgeChanges: { added: 0, removed: 0 }, + edgeChanges: { added: 0, removed: 0, addedDetails: [], removedDetails: [] }, loopChanges: { added: 0, removed: 0, modified: 0 }, parallelChanges: { added: 0, removed: 0, modified: 0 }, - variableChanges: { added: 0, removed: 0, modified: 0 }, + variableChanges: { + added: 0, + removed: 0, + modified: 0, + addedNames: [], + removedNames: [], + modifiedNames: [], + }, hasChanges: false, } @@ -79,10 +101,28 @@ export function generateWorkflowDiffSummary( name: block.name, }) } - result.edgeChanges.added = (currentState.edges || []).length + + const edges = currentState.edges || [] + result.edgeChanges.added = edges.length + for (const edge of edges) { + const sourceBlock = currentBlocks[edge.source] + const targetBlock = currentBlocks[edge.target] + result.edgeChanges.addedDetails.push({ + sourceName: sourceBlock?.name || sourceBlock?.type || edge.source, + targetName: targetBlock?.name || targetBlock?.type || edge.target, + }) + } + result.loopChanges.added = Object.keys(currentState.loops || {}).length result.parallelChanges.added = Object.keys(currentState.parallels || {}).length - result.variableChanges.added = Object.keys(currentState.variables || {}).length + + const variables = currentState.variables || {} + const varEntries = Object.entries(variables) + result.variableChanges.added = varEntries.length + for (const [id, variable] of varEntries) { + result.variableChanges.addedNames.push((variable as { name?: string }).name || id) + } + result.hasChanges = true return result } @@ -121,7 +161,6 @@ export function generateWorkflowDiffSummary( const previousBlock = previousBlocks[id] const changes: FieldChange[] = [] - // Use shared helpers for block field extraction (single source of truth) const { blockRest: currentRest, normalizedData: currentDataRest, @@ -156,8 +195,6 @@ export function generateWorkflowDiffSummary( newValue: currentBlock.enabled, }) } - // Check other block properties (boolean fields) - // Use !! to normalize: null/undefined/false are all equivalent (falsy) const blockFields = ['horizontalHandles', 'advancedMode', 'triggerMode', 'locked'] as const for (const field of blockFields) { if (!!currentBlock[field] !== !!previousBlock[field]) { @@ -169,15 +206,27 @@ export function generateWorkflowDiffSummary( } } if (normalizedStringify(currentDataRest) !== normalizedStringify(previousDataRest)) { - changes.push({ field: 'data', oldValue: previousDataRest, newValue: currentDataRest }) + const allDataKeys = new Set([ + ...Object.keys(currentDataRest), + ...Object.keys(previousDataRest), + ]) + for (const key of allDataKeys) { + if ( + normalizedStringify(currentDataRest[key]) !== normalizedStringify(previousDataRest[key]) + ) { + changes.push({ + field: `data.${key}`, + oldValue: previousDataRest[key] ?? null, + newValue: currentDataRest[key] ?? null, + }) + } + } } } - // Normalize trigger config values for both states before comparison const normalizedCurrentSubs = normalizeTriggerConfigValues(currentSubBlocks) const normalizedPreviousSubs = normalizeTriggerConfigValues(previousSubBlocks) - // Compare subBlocks using shared helper for filtering (single source of truth) const allSubBlockIds = filterSubBlockIds([ ...new Set([...Object.keys(normalizedCurrentSubs), ...Object.keys(normalizedPreviousSubs)]), ]) @@ -195,11 +244,9 @@ export function generateWorkflowDiffSummary( continue } - // Use shared helper for subBlock value normalization (single source of truth) const currentValue = normalizeSubBlockValue(subId, currentSub.value) const previousValue = normalizeSubBlockValue(subId, previousSub.value) - // For string values, compare directly to catch even small text changes if (typeof currentValue === 'string' && typeof previousValue === 'string') { if (currentValue !== previousValue) { changes.push({ field: subId, oldValue: previousSub.value, newValue: currentSub.value }) @@ -212,7 +259,6 @@ export function generateWorkflowDiffSummary( } } - // Use shared helper for subBlock REST extraction (single source of truth) const currentSubRest = extractSubBlockRest(currentSub) const previousSubRest = extractSubBlockRest(previousSub) @@ -240,11 +286,30 @@ export function generateWorkflowDiffSummary( const currentEdgeSet = new Set(currentEdges.map(normalizedStringify)) const previousEdgeSet = new Set(previousEdges.map(normalizedStringify)) - for (const edge of currentEdgeSet) { - if (!previousEdgeSet.has(edge)) result.edgeChanges.added++ + const resolveBlockName = (blockId: string): string => { + const block = currentBlocks[blockId] || previousBlocks[blockId] + return block?.name || block?.type || blockId + } + + for (const edgeStr of currentEdgeSet) { + if (!previousEdgeSet.has(edgeStr)) { + result.edgeChanges.added++ + const edge = JSON.parse(edgeStr) as { source: string; target: string } + result.edgeChanges.addedDetails.push({ + sourceName: resolveBlockName(edge.source), + targetName: resolveBlockName(edge.target), + }) + } } - for (const edge of previousEdgeSet) { - if (!currentEdgeSet.has(edge)) result.edgeChanges.removed++ + for (const edgeStr of previousEdgeSet) { + if (!currentEdgeSet.has(edgeStr)) { + result.edgeChanges.removed++ + const edge = JSON.parse(edgeStr) as { source: string; target: string } + result.edgeChanges.removedDetails.push({ + sourceName: resolveBlockName(edge.source), + targetName: resolveBlockName(edge.target), + }) + } } const currentLoops = currentState.loops || {} @@ -296,8 +361,18 @@ export function generateWorkflowDiffSummary( const currentVarIds = Object.keys(currentVars) const previousVarIds = Object.keys(previousVars) - result.variableChanges.added = currentVarIds.filter((id) => !previousVarIds.includes(id)).length - result.variableChanges.removed = previousVarIds.filter((id) => !currentVarIds.includes(id)).length + for (const id of currentVarIds) { + if (!previousVarIds.includes(id)) { + result.variableChanges.added++ + result.variableChanges.addedNames.push(currentVars[id].name || id) + } + } + for (const id of previousVarIds) { + if (!currentVarIds.includes(id)) { + result.variableChanges.removed++ + result.variableChanges.removedNames.push(previousVars[id].name || id) + } + } for (const id of currentVarIds) { if (!previousVarIds.includes(id)) continue @@ -305,6 +380,7 @@ export function generateWorkflowDiffSummary( const previousVar = normalizeValue(sanitizeVariable(previousVars[id])) if (normalizedStringify(currentVar) !== normalizedStringify(previousVar)) { result.variableChanges.modified++ + result.variableChanges.modifiedNames.push(currentVars[id].name || id) } } @@ -349,56 +425,24 @@ export function formatDiffSummaryForDescription(summary: WorkflowDiffSummary): s for (const block of summary.modifiedBlocks) { const name = block.name || block.type - for (const change of block.changes.slice(0, 3)) { + const meaningfulChanges = block.changes.filter((c) => !c.field.endsWith('.properties')) + for (const change of meaningfulChanges.slice(0, MAX_CHANGES_PER_BLOCK)) { + const fieldLabel = resolveFieldLabel(block.type, change.field) const oldStr = formatValueForDisplay(change.oldValue) const newStr = formatValueForDisplay(change.newValue) - changes.push(`Modified ${name}: ${change.field} changed from "${oldStr}" to "${newStr}"`) + changes.push(`Modified ${name}: ${fieldLabel} changed from "${oldStr}" to "${newStr}"`) } - if (block.changes.length > 3) { - changes.push(` ...and ${block.changes.length - 3} more changes in ${name}`) + if (meaningfulChanges.length > MAX_CHANGES_PER_BLOCK) { + changes.push( + ` ...and ${meaningfulChanges.length - MAX_CHANGES_PER_BLOCK} more changes in ${name}` + ) } } - if (summary.edgeChanges.added > 0) { - changes.push(`Added ${summary.edgeChanges.added} connection(s)`) - } - if (summary.edgeChanges.removed > 0) { - changes.push(`Removed ${summary.edgeChanges.removed} connection(s)`) - } - - if (summary.loopChanges.added > 0) { - changes.push(`Added ${summary.loopChanges.added} loop(s)`) - } - if (summary.loopChanges.removed > 0) { - changes.push(`Removed ${summary.loopChanges.removed} loop(s)`) - } - if (summary.loopChanges.modified > 0) { - changes.push(`Modified ${summary.loopChanges.modified} loop(s)`) - } - - if (summary.parallelChanges.added > 0) { - changes.push(`Added ${summary.parallelChanges.added} parallel group(s)`) - } - if (summary.parallelChanges.removed > 0) { - changes.push(`Removed ${summary.parallelChanges.removed} parallel group(s)`) - } - if (summary.parallelChanges.modified > 0) { - changes.push(`Modified ${summary.parallelChanges.modified} parallel group(s)`) - } - - const varChanges: string[] = [] - if (summary.variableChanges.added > 0) { - varChanges.push(`${summary.variableChanges.added} added`) - } - if (summary.variableChanges.removed > 0) { - varChanges.push(`${summary.variableChanges.removed} removed`) - } - if (summary.variableChanges.modified > 0) { - varChanges.push(`${summary.variableChanges.modified} modified`) - } - if (varChanges.length > 0) { - changes.push(`Variables: ${varChanges.join(', ')}`) - } + formatEdgeChanges(summary, changes) + formatCountChanges(summary.loopChanges, 'loop', changes) + formatCountChanges(summary.parallelChanges, 'parallel group', changes) + formatVariableChanges(summary, changes) return changes.join('\n') } @@ -437,8 +481,9 @@ export async function formatDiffSummaryForDescriptionAsync( const modifiedBlockPromises = summary.modifiedBlocks.map(async (block) => { const name = block.name || block.type const blockChanges: string[] = [] + const meaningfulChanges = block.changes.filter((c) => !c.field.endsWith('.properties')) - const changesToProcess = block.changes.slice(0, 3) + const changesToProcess = meaningfulChanges.slice(0, MAX_CHANGES_PER_BLOCK) const resolvedChanges = await Promise.all( changesToProcess.map(async (change) => { const context = { @@ -455,7 +500,7 @@ export async function formatDiffSummaryForDescriptionAsync( ]) return { - field: change.field, + field: resolveFieldLabel(block.type, change.field), oldLabel: oldResolved.displayLabel, newLabel: newResolved.displayLabel, } @@ -468,8 +513,10 @@ export async function formatDiffSummaryForDescriptionAsync( ) } - if (block.changes.length > 3) { - blockChanges.push(` ...and ${block.changes.length - 3} more changes in ${name}`) + if (meaningfulChanges.length > MAX_CHANGES_PER_BLOCK) { + blockChanges.push( + ` ...and ${meaningfulChanges.length - MAX_CHANGES_PER_BLOCK} more changes in ${name}` + ) } return blockChanges @@ -480,52 +527,95 @@ export async function formatDiffSummaryForDescriptionAsync( changes.push(...blockChanges) } - if (summary.edgeChanges.added > 0) { - changes.push(`Added ${summary.edgeChanges.added} connection(s)`) - } - if (summary.edgeChanges.removed > 0) { - changes.push(`Removed ${summary.edgeChanges.removed} connection(s)`) - } + formatEdgeChanges(summary, changes) + formatCountChanges(summary.loopChanges, 'loop', changes) + formatCountChanges(summary.parallelChanges, 'parallel group', changes) + formatVariableChanges(summary, changes) - if (summary.loopChanges.added > 0) { - changes.push(`Added ${summary.loopChanges.added} loop(s)`) - } - if (summary.loopChanges.removed > 0) { - changes.push(`Removed ${summary.loopChanges.removed} loop(s)`) - } - if (summary.loopChanges.modified > 0) { - changes.push(`Modified ${summary.loopChanges.modified} loop(s)`) - } + logger.info('Generated async diff description', { + workflowId, + changeCount: changes.length, + modifiedBlocks: summary.modifiedBlocks.length, + }) + + return changes.join('\n') +} - if (summary.parallelChanges.added > 0) { - changes.push(`Added ${summary.parallelChanges.added} parallel group(s)`) +function formatEdgeDetailList( + edges: Array<{ sourceName: string; targetName: string }>, + total: number, + verb: string, + changes: string[] +): void { + if (edges.length === 0) { + changes.push(`${verb} ${total} connection(s)`) + return } - if (summary.parallelChanges.removed > 0) { - changes.push(`Removed ${summary.parallelChanges.removed} parallel group(s)`) + for (const edge of edges.slice(0, MAX_EDGE_DETAILS)) { + changes.push(`${verb} connection: ${edge.sourceName} -> ${edge.targetName}`) } - if (summary.parallelChanges.modified > 0) { - changes.push(`Modified ${summary.parallelChanges.modified} parallel group(s)`) + if (total > MAX_EDGE_DETAILS) { + changes.push(` ...and ${total - MAX_EDGE_DETAILS} more ${verb.toLowerCase()} connection(s)`) } +} - const varChanges: string[] = [] - if (summary.variableChanges.added > 0) { - varChanges.push(`${summary.variableChanges.added} added`) - } - if (summary.variableChanges.removed > 0) { - varChanges.push(`${summary.variableChanges.removed} removed`) - } - if (summary.variableChanges.modified > 0) { - varChanges.push(`${summary.variableChanges.modified} modified`) +function formatEdgeChanges(summary: WorkflowDiffSummary, changes: string[]): void { + if (summary.edgeChanges.added > 0) { + formatEdgeDetailList( + summary.edgeChanges.addedDetails ?? [], + summary.edgeChanges.added, + 'Added', + changes + ) } - if (varChanges.length > 0) { - changes.push(`Variables: ${varChanges.join(', ')}`) + if (summary.edgeChanges.removed > 0) { + formatEdgeDetailList( + summary.edgeChanges.removedDetails ?? [], + summary.edgeChanges.removed, + 'Removed', + changes + ) } +} - logger.info('Generated async diff description', { - workflowId, - changeCount: changes.length, - modifiedBlocks: summary.modifiedBlocks.length, - }) +function formatCountChanges( + counts: { added: number; removed: number; modified: number }, + label: string, + changes: string[] +): void { + if (counts.added > 0) changes.push(`Added ${counts.added} ${label}(s)`) + if (counts.removed > 0) changes.push(`Removed ${counts.removed} ${label}(s)`) + if (counts.modified > 0) changes.push(`Modified ${counts.modified} ${label}(s)`) +} - return changes.join('\n') +function formatVariableChanges(summary: WorkflowDiffSummary, changes: string[]): void { + const categories = [ + { + count: summary.variableChanges.added, + names: summary.variableChanges.addedNames ?? [], + verb: 'added', + }, + { + count: summary.variableChanges.removed, + names: summary.variableChanges.removedNames ?? [], + verb: 'removed', + }, + { + count: summary.variableChanges.modified, + names: summary.variableChanges.modifiedNames ?? [], + verb: 'modified', + }, + ] as const + + const varParts: string[] = [] + for (const { count, names, verb } of categories) { + if (count > 0) { + varParts.push( + names.length > 0 ? `${verb} ${names.map((n) => `"${n}"`).join(', ')}` : `${count} ${verb}` + ) + } + } + if (varParts.length > 0) { + changes.push(`Variables: ${varParts.join(', ')}`) + } } diff --git a/apps/sim/lib/workflows/comparison/format-description.test.ts b/apps/sim/lib/workflows/comparison/format-description.test.ts new file mode 100644 index 00000000000..f186a9d5a4f --- /dev/null +++ b/apps/sim/lib/workflows/comparison/format-description.test.ts @@ -0,0 +1,864 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockGetBlock } = vi.hoisted(() => ({ + mockGetBlock: vi.fn(), +})) + +vi.mock('@/lib/workflows/subblocks/visibility', () => ({ + isNonEmptyValue: (v: unknown) => v !== null && v !== undefined && v !== '', +})) + +vi.mock('@/triggers/constants', () => ({ + SYSTEM_SUBBLOCK_IDS: [], + TRIGGER_RUNTIME_SUBBLOCK_IDS: [], +})) + +vi.mock('@/blocks/types', () => ({ + SELECTOR_TYPES_HYDRATION_REQUIRED: [], +})) + +vi.mock('@/executor/constants', () => ({ + CREDENTIAL_SET: { PREFIX: 'cred_set_' }, + isUuid: (v: string) => /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(v), +})) + +vi.mock('@/blocks/registry', () => ({ + getBlock: mockGetBlock, + getAllBlocks: () => ({}), + getAllBlockTypes: () => [], + registry: {}, +})) + +vi.mock('@/lib/workflows/subblocks/context', () => ({ + buildSelectorContextFromBlock: vi.fn(() => ({})), +})) + +vi.mock('@/hooks/queries/credential-sets', () => ({ + fetchCredentialSetById: vi.fn(), +})) + +vi.mock('@/hooks/queries/oauth/oauth-credentials', () => ({ + fetchOAuthCredentialDetail: vi.fn(() => []), +})) + +vi.mock('@/hooks/selectors/registry', () => ({ + getSelectorDefinition: vi.fn(() => ({ fetchList: vi.fn(() => []) })), +})) + +vi.mock('@/hooks/selectors/resolution', () => ({ + resolveSelectorForSubBlock: vi.fn(), +})) + +import { WorkflowBuilder } from '@sim/testing' +import type { WorkflowDiffSummary } from '@/lib/workflows/comparison/compare' +import { + formatDiffSummaryForDescription, + formatDiffSummaryForDescriptionAsync, + generateWorkflowDiffSummary, +} from '@/lib/workflows/comparison/compare' +import { formatValueForDisplay, resolveFieldLabel } from '@/lib/workflows/comparison/resolve-values' + +function emptyDiffSummary(overrides: Partial = {}): WorkflowDiffSummary { + return { + addedBlocks: [], + removedBlocks: [], + modifiedBlocks: [], + edgeChanges: { added: 0, removed: 0, addedDetails: [], removedDetails: [] }, + loopChanges: { added: 0, removed: 0, modified: 0 }, + parallelChanges: { added: 0, removed: 0, modified: 0 }, + variableChanges: { + added: 0, + removed: 0, + modified: 0, + addedNames: [], + removedNames: [], + modifiedNames: [], + }, + hasChanges: false, + ...overrides, + } +} + +beforeEach(() => { + vi.clearAllMocks() +}) + +describe('resolveFieldLabel', () => { + it('resolves subBlock id to its title', () => { + mockGetBlock.mockReturnValue({ + subBlocks: [ + { id: 'systemPrompt', title: 'System Prompt' }, + { id: 'model', title: 'Model' }, + ], + }) + expect(resolveFieldLabel('agent', 'systemPrompt')).toBe('System Prompt') + expect(resolveFieldLabel('agent', 'model')).toBe('Model') + }) + + it('falls back to raw id when block not found', () => { + mockGetBlock.mockReturnValue(null) + expect(resolveFieldLabel('unknown_type', 'someField')).toBe('someField') + }) + + it('falls back to raw id when subBlock not found', () => { + mockGetBlock.mockReturnValue({ subBlocks: [{ id: 'other', title: 'Other' }] }) + expect(resolveFieldLabel('agent', 'missingField')).toBe('missingField') + }) + + it('converts data.* fields to Title Case', () => { + expect(resolveFieldLabel('agent', 'data.loopType')).toBe('Loop Type') + expect(resolveFieldLabel('agent', 'data.canonicalModes')).toBe('Canonical Modes') + expect(resolveFieldLabel('agent', 'data.isStarter')).toBe('Is Starter') + }) +}) + +describe('formatValueForDisplay', () => { + it('handles null/undefined', () => { + expect(formatValueForDisplay(null)).toBe('(none)') + expect(formatValueForDisplay(undefined)).toBe('(none)') + }) + + it('handles booleans', () => { + expect(formatValueForDisplay(true)).toBe('enabled') + expect(formatValueForDisplay(false)).toBe('disabled') + }) + + it('truncates long strings', () => { + const longStr = 'a'.repeat(60) + expect(formatValueForDisplay(longStr)).toBe(`${'a'.repeat(50)}...`) + }) + + it('handles empty string', () => { + expect(formatValueForDisplay('')).toBe('(empty)') + }) +}) + +describe('formatDiffSummaryForDescription', () => { + it('returns no-changes message for empty diff', () => { + const result = formatDiffSummaryForDescription(emptyDiffSummary()) + expect(result).toBe('No structural changes detected (configuration may have changed)') + }) + + it('uses human-readable field labels for modified blocks', () => { + mockGetBlock.mockReturnValue({ + subBlocks: [ + { id: 'systemPrompt', title: 'System Prompt' }, + { id: 'model', title: 'Model' }, + ], + }) + + const summary = emptyDiffSummary({ + hasChanges: true, + modifiedBlocks: [ + { + id: 'block-1', + type: 'agent', + name: 'My Agent', + changes: [ + { field: 'systemPrompt', oldValue: 'You are helpful', newValue: 'You are an expert' }, + { field: 'model', oldValue: 'gpt-4o', newValue: 'claude-sonnet-4-5' }, + ], + }, + ], + }) + + const result = formatDiffSummaryForDescription(summary) + expect(result).toContain( + 'Modified My Agent: System Prompt changed from "You are helpful" to "You are an expert"' + ) + expect(result).toContain( + 'Modified My Agent: Model changed from "gpt-4o" to "claude-sonnet-4-5"' + ) + expect(result).not.toContain('systemPrompt') + expect(result).not.toContain('model changed') + }) + + it('filters out .properties changes', () => { + mockGetBlock.mockReturnValue({ subBlocks: [] }) + + const summary = emptyDiffSummary({ + hasChanges: true, + modifiedBlocks: [ + { + id: 'block-1', + type: 'agent', + name: 'Agent', + changes: [ + { field: 'systemPrompt', oldValue: 'old', newValue: 'new' }, + { + field: 'systemPrompt.properties', + oldValue: { some: 'meta' }, + newValue: { some: 'other' }, + }, + { field: 'model.properties', oldValue: {}, newValue: { x: 1 } }, + ], + }, + ], + }) + + const result = formatDiffSummaryForDescription(summary) + expect(result).toContain('systemPrompt changed') + expect(result).not.toContain('.properties') + expect(result).not.toContain('model.properties') + }) + + it('respects MAX_CHANGES_PER_BLOCK limit of 6', () => { + mockGetBlock.mockReturnValue({ subBlocks: [] }) + + const changes = Array.from({ length: 8 }, (_, i) => ({ + field: `field${i}`, + oldValue: `old${i}`, + newValue: `new${i}`, + })) + + const summary = emptyDiffSummary({ + hasChanges: true, + modifiedBlocks: [{ id: 'b1', type: 'agent', name: 'Agent', changes }], + }) + + const result = formatDiffSummaryForDescription(summary) + const lines = result.split('\n') + const modifiedLines = lines.filter((l) => l.startsWith('Modified')) + expect(modifiedLines).toHaveLength(6) + expect(result).toContain('...and 2 more changes in Agent') + }) + + it('shows edge changes with block names', () => { + const summary = emptyDiffSummary({ + hasChanges: true, + edgeChanges: { + added: 2, + removed: 1, + addedDetails: [ + { sourceName: 'My Agent', targetName: 'Slack' }, + { sourceName: 'Router', targetName: 'Gmail' }, + ], + removedDetails: [{ sourceName: 'Function', targetName: 'Webhook' }], + }, + }) + + const result = formatDiffSummaryForDescription(summary) + expect(result).toContain('Added connection: My Agent -> Slack') + expect(result).toContain('Added connection: Router -> Gmail') + expect(result).toContain('Removed connection: Function -> Webhook') + }) + + it('truncates edge details beyond MAX_EDGE_DETAILS', () => { + const summary = emptyDiffSummary({ + hasChanges: true, + edgeChanges: { + added: 5, + removed: 0, + addedDetails: [ + { sourceName: 'A', targetName: 'B' }, + { sourceName: 'C', targetName: 'D' }, + { sourceName: 'E', targetName: 'F' }, + { sourceName: 'G', targetName: 'H' }, + { sourceName: 'I', targetName: 'J' }, + ], + removedDetails: [], + }, + }) + + const result = formatDiffSummaryForDescription(summary) + const connectionLines = result.split('\n').filter((l) => l.startsWith('Added connection')) + expect(connectionLines).toHaveLength(3) + expect(result).toContain('...and 2 more added connection(s)') + }) + + it('shows variable changes with names', () => { + const summary = emptyDiffSummary({ + hasChanges: true, + variableChanges: { + added: 2, + removed: 1, + modified: 1, + addedNames: ['counter', 'apiKey'], + removedNames: ['oldVar'], + modifiedNames: ['threshold'], + }, + }) + + const result = formatDiffSummaryForDescription(summary) + expect(result).toContain( + 'Variables: added "counter", "apiKey", removed "oldVar", modified "threshold"' + ) + }) + + it('handles data.* fields with Title Case labels', () => { + mockGetBlock.mockReturnValue({ subBlocks: [] }) + + const summary = emptyDiffSummary({ + hasChanges: true, + modifiedBlocks: [ + { + id: 'b1', + type: 'agent', + name: 'Agent', + changes: [ + { field: 'data.loopType', oldValue: 'for', newValue: 'forEach' }, + { field: 'data.isStarter', oldValue: true, newValue: false }, + ], + }, + ], + }) + + const result = formatDiffSummaryForDescription(summary) + expect(result).toContain('Modified Agent: Loop Type changed from "for" to "forEach"') + expect(result).toContain('Modified Agent: Is Starter changed from "enabled" to "disabled"') + }) + + it('formats a realistic multi-block workflow change', () => { + mockGetBlock.mockImplementation((type: string) => { + if (type === 'agent') { + return { + subBlocks: [ + { id: 'systemPrompt', title: 'System Prompt' }, + { id: 'model', title: 'Model' }, + { id: 'temperature', title: 'Temperature' }, + ], + } + } + if (type === 'slack') { + return { + subBlocks: [ + { + id: 'operation', + title: 'Operation', + type: 'dropdown', + options: [ + { id: 'slack_send_message', label: 'Send Message' }, + { id: 'slack_list_channels', label: 'List Channels' }, + ], + }, + { id: 'channel', title: 'Channel' }, + { id: 'credential', title: 'Slack Account' }, + ], + } + } + return null + }) + + const summary = emptyDiffSummary({ + hasChanges: true, + addedBlocks: [{ id: 'b3', type: 'gmail', name: 'Gmail Notifications' }], + removedBlocks: [{ id: 'b4', type: 'function', name: 'Legacy Transform' }], + modifiedBlocks: [ + { + id: 'b1', + type: 'agent', + name: 'AI Assistant', + changes: [ + { field: 'model', oldValue: 'gpt-4o', newValue: 'claude-sonnet-4-5' }, + { field: 'temperature', oldValue: '0.7', newValue: '0.3' }, + ], + }, + { + id: 'b2', + type: 'slack', + name: 'Slack Alert', + changes: [{ field: 'channel', oldValue: '#general', newValue: '#alerts' }], + }, + ], + edgeChanges: { + added: 1, + removed: 0, + addedDetails: [{ sourceName: 'AI Assistant', targetName: 'Gmail Notifications' }], + removedDetails: [], + }, + variableChanges: { + added: 1, + removed: 0, + modified: 0, + addedNames: ['errorCount'], + removedNames: [], + modifiedNames: [], + }, + }) + + const result = formatDiffSummaryForDescription(summary) + + expect(result).toContain('Added block: Gmail Notifications (gmail)') + expect(result).toContain('Removed block: Legacy Transform (function)') + expect(result).toContain( + 'Modified AI Assistant: Model changed from "gpt-4o" to "claude-sonnet-4-5"' + ) + expect(result).toContain('Modified AI Assistant: Temperature changed from "0.7" to "0.3"') + expect(result).toContain('Modified Slack Alert: Channel changed from "#general" to "#alerts"') + expect(result).toContain('Added connection: AI Assistant -> Gmail Notifications') + expect(result).toContain('Variables: added "errorCount"') + }) +}) + +describe('formatDiffSummaryForDescriptionAsync', () => { + it('resolves dropdown values to labels', async () => { + mockGetBlock.mockReturnValue({ + subBlocks: [ + { + id: 'operation', + title: 'Operation', + type: 'dropdown', + options: [ + { id: 'calendly_get_current_user', label: 'Get Current User' }, + { id: 'calendly_list_event_types', label: 'List Event Types' }, + ], + }, + ], + }) + + const summary = emptyDiffSummary({ + hasChanges: true, + modifiedBlocks: [ + { + id: 'b1', + type: 'calendly', + name: 'Calendly', + changes: [ + { + field: 'operation', + oldValue: 'calendly_get_current_user', + newValue: 'calendly_list_event_types', + }, + ], + }, + ], + }) + + const mockState = { blocks: {} } as any + const result = await formatDiffSummaryForDescriptionAsync(summary, mockState, 'wf-1') + expect(result).toContain( + 'Modified Calendly: Operation changed from "Get Current User" to "List Event Types"' + ) + expect(result).not.toContain('calendly_get_current_user') + }) + + it('uses field titles in async path', async () => { + mockGetBlock.mockReturnValue({ + subBlocks: [{ id: 'systemPrompt', title: 'System Prompt' }], + }) + + const summary = emptyDiffSummary({ + hasChanges: true, + modifiedBlocks: [ + { + id: 'b1', + type: 'agent', + name: 'Agent', + changes: [{ field: 'systemPrompt', oldValue: 'Be helpful', newValue: 'Be concise' }], + }, + ], + }) + + const mockState = { blocks: {} } as any + const result = await formatDiffSummaryForDescriptionAsync(summary, mockState, 'wf-1') + expect(result).toContain('System Prompt') + expect(result).not.toContain('systemPrompt') + }) + + it('filters .properties changes in async path', async () => { + mockGetBlock.mockReturnValue({ subBlocks: [] }) + + const summary = emptyDiffSummary({ + hasChanges: true, + modifiedBlocks: [ + { + id: 'b1', + type: 'agent', + name: 'Agent', + changes: [ + { field: 'prompt', oldValue: 'old', newValue: 'new' }, + { field: 'prompt.properties', oldValue: {}, newValue: { x: 1 } }, + ], + }, + ], + }) + + const mockState = { blocks: {} } as any + const result = await formatDiffSummaryForDescriptionAsync(summary, mockState, 'wf-1') + expect(result).not.toContain('.properties') + }) +}) + +describe('end-to-end: generateWorkflowDiffSummary + formatDiffSummaryForDescription', () => { + beforeEach(() => { + mockGetBlock.mockReturnValue(null) + }) + + it('detects added and removed blocks between two workflow versions', () => { + const previous = new WorkflowBuilder() + .addStarter('start') + .addAgent('agent-1', undefined, 'Summarizer') + .connect('start', 'agent-1') + .build() + + const current = new WorkflowBuilder() + .addStarter('start') + .addAgent('agent-1', undefined, 'Summarizer') + .addFunction('func-1', undefined, 'Formatter') + .connect('start', 'agent-1') + .connect('agent-1', 'func-1') + .build() + + const summary = generateWorkflowDiffSummary(current, previous) + const result = formatDiffSummaryForDescription(summary) + + expect(result).toContain('Added block: Formatter (function)') + expect(result).toContain('Added connection: Summarizer -> Formatter') + expect(result).not.toContain('Removed') + }) + + it('detects block removal and edge removal', () => { + const previous = new WorkflowBuilder() + .addStarter('start') + .addAgent('agent-1', undefined, 'Classifier') + .addFunction('func-1', undefined, 'Logger') + .connect('start', 'agent-1') + .connect('agent-1', 'func-1') + .build() + + const current = new WorkflowBuilder() + .addStarter('start') + .addAgent('agent-1', undefined, 'Classifier') + .connect('start', 'agent-1') + .build() + + const summary = generateWorkflowDiffSummary(current, previous) + const result = formatDiffSummaryForDescription(summary) + + expect(result).toContain('Removed block: Logger (function)') + expect(result).toContain('Removed connection: Classifier -> Logger') + expect(result).not.toContain('Added block') + }) + + it('detects subBlock value changes on modified blocks', () => { + const previous = new WorkflowBuilder() + .addStarter('start') + .addAgent('agent-1', undefined, 'Writer') + .connect('start', 'agent-1') + .build() + previous.blocks['agent-1'].subBlocks = { + systemPrompt: { id: 'systemPrompt', value: 'You are a helpful assistant' }, + model: { id: 'model', value: 'gpt-4o' }, + } + + const current = new WorkflowBuilder() + .addStarter('start') + .addAgent('agent-1', undefined, 'Writer') + .connect('start', 'agent-1') + .build() + current.blocks['agent-1'].subBlocks = { + systemPrompt: { id: 'systemPrompt', value: 'You are a concise writer' }, + model: { id: 'model', value: 'claude-sonnet-4-5' }, + } + + mockGetBlock.mockReturnValue({ + subBlocks: [ + { id: 'systemPrompt', title: 'System Prompt' }, + { id: 'model', title: 'Model' }, + ], + }) + + const summary = generateWorkflowDiffSummary(current, previous) + const result = formatDiffSummaryForDescription(summary) + + expect(result).toContain( + 'Modified Writer: System Prompt changed from "You are a helpful assistant" to "You are a concise writer"' + ) + expect(result).toContain('Modified Writer: Model changed from "gpt-4o" to "claude-sonnet-4-5"') + }) + + it('detects loop addition with correct count', () => { + const previous = new WorkflowBuilder() + .addStarter('start') + .addFunction('func-1', undefined, 'Process') + .connect('start', 'func-1') + .build() + + const current = new WorkflowBuilder() + .addStarter('start') + .addFunction('func-1', undefined, 'Process') + .addLoop('loop-1', undefined, { iterations: 5, loopType: 'for' }) + .addLoopChild('loop-1', 'loop-body', 'function') + .connect('start', 'func-1') + .connect('func-1', 'loop-1') + .build() + + const summary = generateWorkflowDiffSummary(current, previous) + const result = formatDiffSummaryForDescription(summary) + + expect(result).toContain('Added block: Loop (loop)') + expect(result).toContain('Added block: loop-body (function)') + expect(result).toContain('Added 1 loop(s)') + expect(result).toContain('Added connection: Process -> Loop') + }) + + it('detects loop removal', () => { + const previous = new WorkflowBuilder() + .addStarter('start') + .addLoop('loop-1', undefined, { iterations: 3, loopType: 'for' }) + .addLoopChild('loop-1', 'loop-body', 'agent') + .connect('start', 'loop-1') + .build() + + const current = new WorkflowBuilder() + .addStarter('start') + .addAgent('agent-1', undefined, 'Direct Agent') + .connect('start', 'agent-1') + .build() + + const summary = generateWorkflowDiffSummary(current, previous) + const result = formatDiffSummaryForDescription(summary) + + expect(result).toContain('Removed block: Loop (loop)') + expect(result).toContain('Removed 1 loop(s)') + expect(result).toContain('Added block: Direct Agent (agent)') + }) + + it('detects loop modification when iterations change', () => { + const previous = new WorkflowBuilder() + .addStarter('start') + .addLoop('loop-1', undefined, { iterations: 3, loopType: 'for' }) + .addLoopChild('loop-1', 'loop-body', 'function') + .connect('start', 'loop-1') + .build() + + const current = new WorkflowBuilder() + .addStarter('start') + .addLoop('loop-1', undefined, { iterations: 10, loopType: 'for' }) + .addLoopChild('loop-1', 'loop-body', 'function') + .connect('start', 'loop-1') + .build() + + const summary = generateWorkflowDiffSummary(current, previous) + const result = formatDiffSummaryForDescription(summary) + + expect(result).toContain('Modified 1 loop(s)') + }) + + it('detects parallel addition', () => { + const previous = new WorkflowBuilder() + .addStarter('start') + .addFunction('func-1', undefined, 'Sequencer') + .connect('start', 'func-1') + .build() + + const current = new WorkflowBuilder() + .addStarter('start') + .addParallel('par-1', undefined, { count: 3, parallelType: 'count' }) + .addParallelChild('par-1', 'par-task-1', 'agent') + .addParallelChild('par-1', 'par-task-2', 'function') + .connect('start', 'par-1') + .build() + + const summary = generateWorkflowDiffSummary(current, previous) + const result = formatDiffSummaryForDescription(summary) + + expect(result).toContain('Added block: Parallel (parallel)') + expect(result).toContain('Added 1 parallel group(s)') + expect(result).toContain('Removed block: Sequencer (function)') + }) + + it('detects parallel removal', () => { + const previous = new WorkflowBuilder() + .addStarter('start') + .addParallel('par-1', undefined, { count: 2 }) + .addParallelChild('par-1', 'par-task', 'function') + .connect('start', 'par-1') + .build() + + const current = new WorkflowBuilder() + .addStarter('start') + .addFunction('func-1', undefined, 'Simple Step') + .connect('start', 'func-1') + .build() + + const summary = generateWorkflowDiffSummary(current, previous) + const result = formatDiffSummaryForDescription(summary) + + expect(result).toContain('Removed block: Parallel (parallel)') + expect(result).toContain('Removed 1 parallel group(s)') + expect(result).toContain('Added block: Simple Step (function)') + }) + + it('detects parallel modification when count changes', () => { + const previous = new WorkflowBuilder() + .addStarter('start') + .addParallel('par-1', undefined, { count: 2, parallelType: 'count' }) + .addParallelChild('par-1', 'par-task', 'function') + .connect('start', 'par-1') + .build() + + const current = new WorkflowBuilder() + .addStarter('start') + .addParallel('par-1', undefined, { count: 5, parallelType: 'count' }) + .addParallelChild('par-1', 'par-task', 'function') + .connect('start', 'par-1') + .build() + + const summary = generateWorkflowDiffSummary(current, previous) + const result = formatDiffSummaryForDescription(summary) + + expect(result).toContain('Modified 1 parallel group(s)') + }) + + it('detects variable additions and removals with names', () => { + const previous = new WorkflowBuilder().addStarter('start').build() + previous.variables = { + v1: { id: 'v1', name: 'retryCount', type: 'number', value: 3 }, + v2: { id: 'v2', name: 'apiEndpoint', type: 'string', value: 'https://api.example.com' }, + } + + const current = new WorkflowBuilder().addStarter('start').build() + current.variables = { + v1: { id: 'v1', name: 'retryCount', type: 'number', value: 5 }, + v3: { id: 'v3', name: 'timeout', type: 'number', value: 30 }, + } + + const summary = generateWorkflowDiffSummary(current, previous) + const result = formatDiffSummaryForDescription(summary) + + expect(result).toContain('Variables:') + expect(result).toContain('added "timeout"') + expect(result).toContain('removed "apiEndpoint"') + expect(result).toContain('modified "retryCount"') + }) + + it('produces no-change message for identical workflows', () => { + const workflow = new WorkflowBuilder() + .addStarter('start') + .addAgent('agent-1', undefined, 'Agent') + .connect('start', 'agent-1') + .build() + + const summary = generateWorkflowDiffSummary(workflow, workflow) + const result = formatDiffSummaryForDescription(summary) + + expect(result).toBe('No structural changes detected (configuration may have changed)') + }) + + it('handles complex scenario: loop replaced with parallel + new connections + variables', () => { + const previous = new WorkflowBuilder() + .addStarter('start') + .addLoop('loop-1', undefined, { iterations: 5 }) + .addLoopChild('loop-1', 'loop-task', 'agent') + .addFunction('sink', undefined, 'Output') + .connect('start', 'loop-1') + .connect('loop-1', 'sink') + .build() + previous.variables = { + v1: { id: 'v1', name: 'batchSize', type: 'number', value: 10 }, + } + + const current = new WorkflowBuilder() + .addStarter('start') + .addParallel('par-1', undefined, { count: 3 }) + .addParallelChild('par-1', 'par-task', 'agent') + .addFunction('sink', undefined, 'Output') + .addAgent('agg', undefined, 'Aggregator') + .connect('start', 'par-1') + .connect('par-1', 'agg') + .connect('agg', 'sink') + .build() + current.variables = { + v1: { id: 'v1', name: 'batchSize', type: 'number', value: 25 }, + v2: { id: 'v2', name: 'concurrency', type: 'number', value: 3 }, + } + + const summary = generateWorkflowDiffSummary(current, previous) + const result = formatDiffSummaryForDescription(summary) + + expect(result).toContain('Added block: Parallel (parallel)') + expect(result).toContain('Added block: Aggregator (agent)') + expect(result).toContain('Removed block: Loop (loop)') + expect(result).toContain('Added 1 parallel group(s)') + expect(result).toContain('Removed 1 loop(s)') + expect(result).toContain('added "concurrency"') + expect(result).toContain('modified "batchSize"') + + const lines = result.split('\n') + expect(lines.length).toBeGreaterThanOrEqual(7) + }) + + it('detects edge rewiring without block changes', () => { + const previous = new WorkflowBuilder() + .addStarter('start') + .addAgent('a', undefined, 'Agent A') + .addAgent('b', undefined, 'Agent B') + .addFunction('sink', undefined, 'Output') + .connect('start', 'a') + .connect('a', 'sink') + .connect('start', 'b') + .connect('b', 'sink') + .build() + + const current = new WorkflowBuilder() + .addStarter('start') + .addAgent('a', undefined, 'Agent A') + .addAgent('b', undefined, 'Agent B') + .addFunction('sink', undefined, 'Output') + .connect('start', 'a') + .connect('a', 'b') + .connect('b', 'sink') + .build() + + const summary = generateWorkflowDiffSummary(current, previous) + const result = formatDiffSummaryForDescription(summary) + + expect(summary.addedBlocks).toHaveLength(0) + expect(summary.removedBlocks).toHaveLength(0) + expect(result).toContain('Added connection: Agent A -> Agent B') + expect(result).toContain('Removed connection:') + expect(result).not.toContain('Added block') + expect(result).not.toContain('Removed block') + }) + + it('detects data field changes with human-readable labels', () => { + const previous = new WorkflowBuilder() + .addStarter('start') + .addBlock('custom-1', 'function', undefined, 'Processor') + .connect('start', 'custom-1') + .build() + previous.blocks['custom-1'].data = { isStarter: true, retryPolicy: 'linear' } + + const current = new WorkflowBuilder() + .addStarter('start') + .addBlock('custom-1', 'function', undefined, 'Processor') + .connect('start', 'custom-1') + .build() + current.blocks['custom-1'].data = { isStarter: false, retryPolicy: 'exponential' } + + const summary = generateWorkflowDiffSummary(current, previous) + const result = formatDiffSummaryForDescription(summary) + + expect(result).toContain('Is Starter') + expect(result).toContain('Retry Policy') + expect(result).toContain('enabled') + expect(result).toContain('disabled') + expect(result).toContain('linear') + expect(result).toContain('exponential') + }) + + it('detects loop type change via loop config modification', () => { + const previous = new WorkflowBuilder() + .addStarter('start') + .addLoop('loop-1', undefined, { iterations: 3, loopType: 'for' }) + .addLoopChild('loop-1', 'loop-body', 'function') + .connect('start', 'loop-1') + .build() + + const current = new WorkflowBuilder() + .addStarter('start') + .addLoop('loop-1', undefined, { iterations: 3, loopType: 'forEach' }) + .addLoopChild('loop-1', 'loop-body', 'function') + .connect('start', 'loop-1') + .build() + + const summary = generateWorkflowDiffSummary(current, previous) + const result = formatDiffSummaryForDescription(summary) + + expect(result).toContain('Modified 1 loop(s)') + }) +}) diff --git a/apps/sim/lib/workflows/comparison/resolve-values.ts b/apps/sim/lib/workflows/comparison/resolve-values.ts index 2fe7f24a34d..e66d76b8bce 100644 --- a/apps/sim/lib/workflows/comparison/resolve-values.ts +++ b/apps/sim/lib/workflows/comparison/resolve-values.ts @@ -9,6 +9,7 @@ import { getSelectorDefinition } from '@/hooks/selectors/registry' import { resolveSelectorForSubBlock } from '@/hooks/selectors/resolution' import type { SelectorContext, SelectorKey } from '@/hooks/selectors/types' import type { WorkflowState } from '@/stores/workflows/workflow/types' +import { formatParameterLabel } from '@/tools/params' const logger = createLogger('ResolveValues') @@ -126,6 +127,33 @@ function extractMcpToolName(toolId: string): string { return withoutPrefix } +/** + * Resolves a subBlock field ID to its human-readable title. + * Falls back to the raw ID if the block or subBlock is not found. + */ +export function resolveFieldLabel(blockType: string, subBlockId: string): string { + if (subBlockId.startsWith('data.')) { + return formatParameterLabel(subBlockId.slice(5)) + } + const blockConfig = getBlock(blockType) + if (!blockConfig) return subBlockId + const subBlockConfig = blockConfig.subBlocks.find((sb) => sb.id === subBlockId) + return subBlockConfig?.title ?? subBlockId +} + +/** + * Resolves a dropdown option ID to its human-readable label. + * Returns null if the subBlock is not a dropdown or the value is not found. + */ +function resolveDropdownLabel(subBlockConfig: SubBlockConfig, value: string): string | null { + if (subBlockConfig.type !== 'dropdown') return null + if (!subBlockConfig.options) return null + const options = + typeof subBlockConfig.options === 'function' ? subBlockConfig.options() : subBlockConfig.options + const match = options.find((opt) => opt.id === value) + return match?.label ?? null +} + /** * Formats a value for display in diff descriptions. */ @@ -138,7 +166,10 @@ export function formatValueForDisplay(value: unknown): string { if (typeof value === 'boolean') return value ? 'enabled' : 'disabled' if (typeof value === 'number') return String(value) if (Array.isArray(value)) return `[${value.length} items]` - if (typeof value === 'object') return `${JSON.stringify(value).slice(0, 50)}...` + if (typeof value === 'object') { + const json = JSON.stringify(value) + return json.length > 50 ? `${json.slice(0, 50)}...` : json + } return String(value) } @@ -165,7 +196,6 @@ export async function resolveValueForDisplay( value: unknown, context: ResolutionContext ): Promise { - // Non-string or empty values can't be resolved if (typeof value !== 'string' || !value) { return { original: value, @@ -190,9 +220,8 @@ export async function resolveValueForDisplay( ) : { workflowId: context.workflowId, workspaceId: context.workspaceId } - // Credential fields (oauth-input or credential subBlockId) const isCredentialField = - subBlockConfig?.type === 'oauth-input' || context.subBlockId === 'credential' + subBlockConfig.type === 'oauth-input' || context.subBlockId === 'credential' if (isCredentialField && (value.startsWith(CREDENTIAL_SET.PREFIX) || isUuid(value))) { const label = await resolveCredential(value, context.workflowId) @@ -202,8 +231,7 @@ export async function resolveValueForDisplay( return { original: value, displayLabel: semanticFallback, resolved: true } } - // Workflow selector - if (subBlockConfig?.type === 'workflow-selector' && isUuid(value)) { + if (subBlockConfig.type === 'workflow-selector' && isUuid(value)) { const label = await resolveWorkflow(value, selectorCtx.workspaceId) if (label) { return { original: value, displayLabel: label, resolved: true } @@ -211,15 +239,27 @@ export async function resolveValueForDisplay( return { original: value, displayLabel: semanticFallback, resolved: true } } - // MCP tool selector - if (subBlockConfig?.type === 'mcp-tool-selector') { + if (subBlockConfig.type === 'mcp-tool-selector') { const toolName = extractMcpToolName(value) return { original: value, displayLabel: toolName, resolved: true } } - // Selector types that require hydration (file-selector, sheet-selector, etc.) - // These support external service IDs like Google Drive file IDs - if (subBlockConfig && SELECTOR_TYPES_HYDRATION_REQUIRED.includes(subBlockConfig.type)) { + if (subBlockConfig.type === 'dropdown') { + try { + const label = resolveDropdownLabel(subBlockConfig, value) + if (label) { + return { original: value, displayLabel: label, resolved: true } + } + } catch (error) { + logger.warn('Failed to resolve dropdown label', { + value, + subBlockId: context.subBlockId, + error, + }) + } + } + + if (SELECTOR_TYPES_HYDRATION_REQUIRED.includes(subBlockConfig.type)) { const resolution = resolveSelectorForSubBlock(subBlockConfig, selectorCtx) if (resolution?.key) { @@ -228,22 +268,17 @@ export async function resolveValueForDisplay( return { original: value, displayLabel: label, resolved: true } } } - // If resolution failed for a hydration-required type, use semantic fallback return { original: value, displayLabel: semanticFallback, resolved: true } } - // For fields without specific subBlock types, use pattern matching - // UUID fallback if (isUuid(value)) { return { original: value, displayLabel: semanticFallback, resolved: true } } - // Slack-style IDs (channels: C..., users: U.../W...) get semantic fallback if (/^C[A-Z0-9]{8,}$/.test(value) || /^[UW][A-Z0-9]{8,}$/.test(value)) { return { original: value, displayLabel: semanticFallback, resolved: true } } - // Credential set prefix without credential field type if (value.startsWith(CREDENTIAL_SET.PREFIX)) { const label = await resolveCredential(value, context.workflowId) if (label) { diff --git a/bun.lock b/bun.lock index f8bde9a6cf3..e05bc532f5e 100644 --- a/bun.lock +++ b/bun.lock @@ -1,5 +1,6 @@ { "lockfileVersion": 1, + "configVersion": 0, "workspaces": { "": { "name": "simstudio",