Files
simstudioai--sim/apps/sim/lib/workflows/search-replace/replacements.test.ts
T
wehub-resource-sync d25d482dc2
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
chore: import upstream snapshot with attribution
2026-07-13 13:20:55 +08:00

1721 lines
50 KiB
TypeScript

/**
* @vitest-environment node
*/
import { describe, expect, it } from 'vitest'
import { indexWorkflowSearchMatches } from '@/lib/workflows/search-replace/indexer'
import { buildWorkflowSearchReplacePlan } from '@/lib/workflows/search-replace/replacements'
import {
createSearchReplaceWorkflowFixture,
SEARCH_REPLACE_BLOCK_CONFIGS,
} from '@/lib/workflows/search-replace/search-replace.fixtures'
import { WORKFLOW_SEARCH_SUBFLOW_FIELD_IDS } from '@/lib/workflows/search-replace/subflow-fields'
describe('buildWorkflowSearchReplacePlan', () => {
it('replaces selected text ranges across blocks without touching unselected matches', () => {
const workflow = createSearchReplaceWorkflowFixture()
const matches = indexWorkflowSearchMatches({
workflow,
query: 'email',
mode: 'text',
blockConfigs: SEARCH_REPLACE_BLOCK_CONFIGS,
})
const selectedMatchIds = new Set(matches.slice(0, 2).map((match) => match.id))
const plan = buildWorkflowSearchReplacePlan({
blocks: workflow.blocks,
matches,
selectedMatchIds,
defaultReplacement: 'message',
})
expect(plan.conflicts).toEqual([])
expect(plan.updates).toHaveLength(1)
expect(plan.updates[0]).toMatchObject({
blockId: 'agent-1',
subBlockId: 'systemPrompt',
nextValue: 'message {{OLD_SECRET}} and then message again. Use <start.output>.',
})
})
it('skips non-editable display-label matches instead of writing labels into stored values', () => {
const workflow = createSearchReplaceWorkflowFixture()
workflow.blocks['dropdown-1'] = {
id: 'dropdown-1',
type: 'custom',
name: 'Dropdown Block',
position: { x: 0, y: 0 },
enabled: true,
outputs: {},
subBlocks: {
operation: { id: 'operation', type: 'dropdown', value: 'send_email' },
},
}
const matches = indexWorkflowSearchMatches({
workflow,
query: 'Email',
mode: 'text',
blockConfigs: {
...SEARCH_REPLACE_BLOCK_CONFIGS,
custom: {
subBlocks: [
{
id: 'operation',
title: 'Operation',
type: 'dropdown',
options: [{ id: 'send_email', label: 'Send Email' }],
},
],
},
},
}).filter((match) => match.blockId === 'dropdown-1')
const plan = buildWorkflowSearchReplacePlan({
blocks: workflow.blocks,
matches,
selectedMatchIds: new Set(matches.map((match) => match.id)),
defaultReplacement: 'Slack',
})
expect(plan.updates).toEqual([])
expect(plan.skipped).toEqual([
{ matchId: matches[0].id, reason: 'Display labels cannot be replaced' },
])
})
it('indexes dropdown display labels with the same casing shown by the editor', () => {
const workflow = createSearchReplaceWorkflowFixture()
workflow.blocks['file-1'] = {
id: 'file-1',
type: 'file_v4',
name: 'File',
position: { x: 0, y: 0 },
enabled: true,
outputs: {},
subBlocks: {
operation: { id: 'operation', type: 'dropdown', value: 'file_read' },
},
}
const matches = indexWorkflowSearchMatches({
workflow,
query: 'Read',
mode: 'text',
blockConfigs: {
...SEARCH_REPLACE_BLOCK_CONFIGS,
file_v4: {
subBlocks: [
{
id: 'operation',
title: 'Operation',
type: 'dropdown',
options: [{ id: 'file_read', label: 'Read' }],
},
],
},
},
}).filter((match) => match.blockId === 'file-1')
expect(matches).toEqual([
expect.objectContaining({
searchText: 'read',
rawValue: 'read',
range: { start: 0, end: 4 },
}),
])
})
it('replaces environment tokens while preserving surrounding text', () => {
const workflow = createSearchReplaceWorkflowFixture()
const matches = indexWorkflowSearchMatches({
workflow,
query: 'OLD_SECRET',
mode: 'resource',
blockConfigs: SEARCH_REPLACE_BLOCK_CONFIGS,
})
const selectedMatchIds = new Set(matches.map((match) => match.id))
const plan = buildWorkflowSearchReplacePlan({
blocks: workflow.blocks,
matches,
selectedMatchIds,
defaultReplacement: 'NEW_SECRET',
resourceReplacementOptions: [
{ kind: 'environment', value: '{{NEW_SECRET}}', label: '{{NEW_SECRET}}' },
],
})
expect(plan.conflicts).toEqual([])
expect(plan.updates).toEqual(
expect.arrayContaining([
expect.objectContaining({
blockId: 'agent-1',
subBlockId: 'systemPrompt',
nextValue: 'Email {{NEW_SECRET}} and then email again. Use <start.output>.',
}),
expect.objectContaining({
blockId: 'api-1',
subBlockId: 'headers',
nextValue: [
{ id: 'row-1', cells: { Key: 'Authorization', Value: 'Bearer {{NEW_SECRET}}' } },
],
}),
])
)
})
it('replaces exact structured resources in comma-separated values', () => {
const workflow = createSearchReplaceWorkflowFixture()
const matches = indexWorkflowSearchMatches({
workflow,
query: 'kb-old',
mode: 'resource',
blockConfigs: SEARCH_REPLACE_BLOCK_CONFIGS,
})
const plan = buildWorkflowSearchReplacePlan({
blocks: workflow.blocks,
matches,
selectedMatchIds: new Set(matches.map((match) => match.id)),
defaultReplacement: 'kb-new',
resourceReplacementOptions: [
{ kind: 'knowledge-base', value: 'kb-new', label: 'New Knowledge Base' },
],
})
expect(plan.updates).toHaveLength(1)
expect(plan.updates[0].nextValue).toBe('kb-new,kb-second')
})
it('replaces only the selected duplicate structured resource occurrence', () => {
const workflow = createSearchReplaceWorkflowFixture()
workflow.blocks['knowledge-1'].subBlocks.knowledgeBaseIds.value = 'kb-old,kb-old,kb-second'
const matches = indexWorkflowSearchMatches({
workflow,
query: 'kb-old',
mode: 'resource',
blockConfigs: SEARCH_REPLACE_BLOCK_CONFIGS,
}).filter((match) => match.kind === 'knowledge-base')
const plan = buildWorkflowSearchReplacePlan({
blocks: workflow.blocks,
matches,
selectedMatchIds: new Set([matches[0].id]),
defaultReplacement: 'kb-new',
resourceReplacementOptions: [
{ kind: 'knowledge-base', value: 'kb-new', label: 'New Knowledge Base' },
],
})
expect(plan.conflicts).toEqual([])
expect(plan.updates).toHaveLength(1)
expect(plan.updates[0].nextValue).toBe('kb-new,kb-old,kb-second')
})
it('replaces a selected duplicate structured resource when duplicates are separated', () => {
const workflow = createSearchReplaceWorkflowFixture()
workflow.blocks['knowledge-1'].subBlocks.knowledgeBaseIds.value = 'kb-old,kb-second,kb-old'
const matches = indexWorkflowSearchMatches({
workflow,
query: 'kb-old',
mode: 'resource',
blockConfigs: SEARCH_REPLACE_BLOCK_CONFIGS,
}).filter((match) => match.kind === 'knowledge-base')
const plan = buildWorkflowSearchReplacePlan({
blocks: workflow.blocks,
matches,
selectedMatchIds: new Set([matches[1].id]),
defaultReplacement: 'kb-new',
resourceReplacementOptions: [
{ kind: 'knowledge-base', value: 'kb-new', label: 'New Knowledge Base' },
],
})
expect(plan.conflicts).toEqual([])
expect(plan.updates).toHaveLength(1)
expect(plan.updates[0].nextValue).toBe('kb-old,kb-second,kb-new')
})
it('conflicts when a selected duplicate structured resource occurrence is removed', () => {
const workflow = createSearchReplaceWorkflowFixture()
workflow.blocks['knowledge-1'].subBlocks.knowledgeBaseIds.value = 'kb-old,kb-second,kb-old'
const matches = indexWorkflowSearchMatches({
workflow,
query: 'kb-old',
mode: 'resource',
blockConfigs: SEARCH_REPLACE_BLOCK_CONFIGS,
}).filter((match) => match.kind === 'knowledge-base')
workflow.blocks['knowledge-1'].subBlocks.knowledgeBaseIds.value = 'kb-old,kb-second'
const plan = buildWorkflowSearchReplacePlan({
blocks: workflow.blocks,
matches,
selectedMatchIds: new Set([matches[1].id]),
defaultReplacement: 'kb-new',
resourceReplacementOptions: [
{ kind: 'knowledge-base', value: 'kb-new', label: 'New Knowledge Base' },
],
})
expect(plan.updates).toEqual([])
expect(plan.conflicts).toEqual([
{ matchId: matches[1].id, reason: 'Target resource changed since search' },
])
})
it('replaces duplicate structured resources with blank comma segments consistently', () => {
const workflow = createSearchReplaceWorkflowFixture()
workflow.blocks['knowledge-1'].subBlocks.knowledgeBaseIds.value = 'kb-old,,kb-second,kb-old'
const matches = indexWorkflowSearchMatches({
workflow,
query: 'kb-old',
mode: 'resource',
blockConfigs: SEARCH_REPLACE_BLOCK_CONFIGS,
}).filter((match) => match.kind === 'knowledge-base')
const plan = buildWorkflowSearchReplacePlan({
blocks: workflow.blocks,
matches,
selectedMatchIds: new Set([matches[1].id]),
defaultReplacement: 'kb-new',
resourceReplacementOptions: [
{ kind: 'knowledge-base', value: 'kb-new', label: 'New Knowledge Base' },
],
})
expect(plan.conflicts).toEqual([])
expect(plan.updates).toHaveLength(1)
expect(plan.updates[0].nextValue).toBe('kb-old,,kb-second,kb-new')
})
it('replaces all compatible knowledge base references across blocks', () => {
const workflow = createSearchReplaceWorkflowFixture()
workflow.blocks['knowledge-2'] = {
...workflow.blocks['knowledge-1'],
id: 'knowledge-2',
name: 'Knowledge 2',
subBlocks: {
...workflow.blocks['knowledge-1'].subBlocks,
knowledgeBaseIds: {
id: 'knowledgeBaseIds',
type: 'knowledge-base-selector',
value: 'kb-old',
},
},
}
const matches = indexWorkflowSearchMatches({
workflow,
query: 'kb-old',
mode: 'resource',
blockConfigs: SEARCH_REPLACE_BLOCK_CONFIGS,
}).filter((match) => match.kind === 'knowledge-base')
const plan = buildWorkflowSearchReplacePlan({
blocks: workflow.blocks,
matches,
selectedMatchIds: new Set(matches.map((match) => match.id)),
defaultReplacement: 'kb-new',
resourceReplacementOptions: [
{ kind: 'knowledge-base', value: 'kb-new', label: 'New Knowledge Base' },
],
})
expect(plan.conflicts).toEqual([])
expect(plan.updates).toEqual(
expect.arrayContaining([
expect.objectContaining({ blockId: 'knowledge-1', nextValue: 'kb-new,kb-second' }),
expect.objectContaining({ blockId: 'knowledge-2', nextValue: 'kb-new' }),
])
)
})
it('replaces selector-backed workflow and knowledge document resources with scoped options', () => {
const workflow = createSearchReplaceWorkflowFixture()
workflow.blocks['workflow-tool-1'] = {
id: 'workflow-tool-1',
type: 'custom',
name: 'Workflow Tool',
position: { x: 0, y: 0 },
enabled: true,
outputs: {},
subBlocks: {
workflowId: {
id: 'workflowId',
type: 'workflow-selector',
value: 'workflow-old',
},
},
}
const blockConfigs = {
...SEARCH_REPLACE_BLOCK_CONFIGS,
custom: {
subBlocks: [
{
id: 'workflowId',
title: 'Workflow',
type: 'workflow-selector',
selectorKey: 'sim.workflows',
},
],
},
}
const workflowMatches = indexWorkflowSearchMatches({
workflow,
query: 'workflow-old',
mode: 'resource',
workspaceId: 'workspace-1',
workflowId: 'current-workflow',
blockConfigs,
}).filter((match) => match.kind === 'workflow')
const documentMatches = indexWorkflowSearchMatches({
workflow,
query: 'doc-old',
mode: 'resource',
workspaceId: 'workspace-1',
workflowId: 'current-workflow',
blockConfigs: SEARCH_REPLACE_BLOCK_CONFIGS,
}).filter((match) => match.kind === 'knowledge-document')
const workflowPlan = buildWorkflowSearchReplacePlan({
blocks: workflow.blocks,
matches: workflowMatches,
selectedMatchIds: new Set(workflowMatches.map((match) => match.id)),
defaultReplacement: 'workflow-new',
resourceReplacementOptions: [
{
kind: 'workflow',
value: 'workflow-new',
label: 'New Workflow',
resourceGroupKey: workflowMatches[0].resource?.resourceGroupKey,
},
],
})
const documentPlan = buildWorkflowSearchReplacePlan({
blocks: workflow.blocks,
matches: documentMatches,
selectedMatchIds: new Set(documentMatches.map((match) => match.id)),
defaultReplacement: 'doc-new',
resourceReplacementOptions: [
{
kind: 'knowledge-document',
value: 'doc-new',
label: 'New Document',
resourceGroupKey: documentMatches[0].resource?.resourceGroupKey,
},
],
})
expect(workflowPlan.conflicts).toEqual([])
expect(workflowPlan.updates).toEqual([
expect.objectContaining({
blockId: 'workflow-tool-1',
subBlockId: 'workflowId',
nextValue: 'workflow-new',
}),
])
expect(documentPlan.conflicts).toEqual([])
expect(documentPlan.updates).toEqual([
expect.objectContaining({
blockId: 'knowledge-1',
subBlockId: 'documentId',
nextValue: 'doc-new',
}),
])
})
it('replaces structured file resources stored as serialized tool input params', () => {
const workflow = createSearchReplaceWorkflowFixture()
workflow.blocks['tool-input-1'] = {
id: 'tool-input-1',
type: 'custom',
name: 'Tool Input Block',
position: { x: 0, y: 0 },
enabled: true,
outputs: {},
subBlocks: {
tools: {
id: 'tools',
type: 'tool-input',
value: [
{
type: 'slack',
toolId: 'slack_message',
operation: 'send',
title: 'Slack message',
params: {
authMethod: 'oauth',
credential: 'slack-credential',
text: 'message with file',
attachmentFiles: JSON.stringify({
name: 'contract.pdf',
key: 'file-key-old',
path: '/contract.pdf',
size: 12,
type: 'application/pdf',
}),
},
},
],
},
},
}
const matches = indexWorkflowSearchMatches({
workflow,
query: 'contract',
mode: 'all',
blockConfigs: {
...SEARCH_REPLACE_BLOCK_CONFIGS,
custom: {
subBlocks: [{ id: 'tools', title: 'Tools', type: 'tool-input' }],
},
},
}).filter((match) => match.kind === 'file')
const replacementFile = {
name: 'contract-v2.pdf',
key: 'file-key-new',
path: '/contract-v2.pdf',
size: 24,
type: 'application/pdf',
}
const plan = buildWorkflowSearchReplacePlan({
blocks: workflow.blocks,
matches,
selectedMatchIds: new Set(matches.map((match) => match.id)),
defaultReplacement: JSON.stringify(replacementFile),
resourceReplacementOptions: [
{ kind: 'file', value: JSON.stringify(replacementFile), label: replacementFile.name },
],
})
expect(plan.conflicts).toEqual([])
expect(plan.updates).toHaveLength(1)
expect(plan.updates[0].nextValue).toEqual([
expect.objectContaining({
params: expect.objectContaining({
attachmentFiles: JSON.stringify(replacementFile),
}),
}),
])
})
it('replaces one duplicate file occurrence in a serialized tool input file array', () => {
const workflow = createSearchReplaceWorkflowFixture()
const files = [
{
name: 'first.pdf',
key: 'file-key-old',
path: '/first.pdf',
size: 12,
type: 'application/pdf',
},
{
name: 'second.pdf',
key: 'file-key-other',
path: '/second.pdf',
size: 14,
type: 'application/pdf',
},
{
name: 'third.pdf',
key: 'file-key-old',
path: '/third.pdf',
size: 16,
type: 'application/pdf',
},
]
workflow.blocks['tool-input-1'] = {
id: 'tool-input-1',
type: 'custom',
name: 'Tool Input Block',
position: { x: 0, y: 0 },
enabled: true,
outputs: {},
subBlocks: {
tools: {
id: 'tools',
type: 'tool-input',
value: [
{
type: 'slack',
toolId: 'slack_message',
operation: 'send',
title: 'Slack message',
params: {
authMethod: 'oauth',
credential: 'slack-credential',
text: 'message with files',
attachmentFiles: JSON.stringify(files),
},
},
],
},
},
}
const matches = indexWorkflowSearchMatches({
workflow,
query: 'file-key-old',
mode: 'resource',
blockConfigs: {
...SEARCH_REPLACE_BLOCK_CONFIGS,
custom: {
subBlocks: [{ id: 'tools', title: 'Tools', type: 'tool-input' }],
},
},
}).filter((match) => match.kind === 'file')
const replacementFile = {
name: 'replacement.pdf',
key: 'file-key-new',
path: '/replacement.pdf',
size: 24,
type: 'application/pdf',
}
const plan = buildWorkflowSearchReplacePlan({
blocks: workflow.blocks,
matches,
selectedMatchIds: new Set([matches[1].id]),
defaultReplacement: JSON.stringify(replacementFile),
resourceReplacementOptions: [
{ kind: 'file', value: JSON.stringify(replacementFile), label: replacementFile.name },
],
})
const nextTools = plan.updates[0].nextValue as Array<{ params: { attachmentFiles: string } }>
expect(plan.conflicts).toEqual([])
expect(JSON.parse(nextTools[0].params.attachmentFiles)).toEqual([
files[0],
files[1],
replacementFile,
])
})
it('conflicts when a selected duplicate file occurrence is removed', () => {
const workflow = createSearchReplaceWorkflowFixture()
const files = [
{
name: 'first.pdf',
key: 'file-key-old',
path: '/first.pdf',
size: 12,
type: 'application/pdf',
},
{
name: 'second.pdf',
key: 'file-key-other',
path: '/second.pdf',
size: 14,
type: 'application/pdf',
},
{
name: 'third.pdf',
key: 'file-key-old',
path: '/third.pdf',
size: 16,
type: 'application/pdf',
},
]
workflow.blocks['tool-input-1'] = {
id: 'tool-input-1',
type: 'custom',
name: 'Tool Input Block',
position: { x: 0, y: 0 },
enabled: true,
outputs: {},
subBlocks: {
tools: {
id: 'tools',
type: 'tool-input',
value: [
{
type: 'slack',
toolId: 'slack_message',
operation: 'send',
title: 'Slack message',
params: {
authMethod: 'oauth',
credential: 'slack-credential',
text: 'message with files',
attachmentFiles: JSON.stringify(files),
},
},
],
},
},
}
const matches = indexWorkflowSearchMatches({
workflow,
query: 'file-key-old',
mode: 'resource',
blockConfigs: {
...SEARCH_REPLACE_BLOCK_CONFIGS,
custom: {
subBlocks: [{ id: 'tools', title: 'Tools', type: 'tool-input' }],
},
},
}).filter((match) => match.kind === 'file')
workflow.blocks['tool-input-1'].subBlocks.tools.value = [
{
type: 'slack',
toolId: 'slack_message',
operation: 'send',
title: 'Slack message',
params: {
authMethod: 'oauth',
credential: 'slack-credential',
text: 'message with files',
attachmentFiles: JSON.stringify([files[0], files[1]]),
},
},
]
const replacementFile = {
name: 'replacement.pdf',
key: 'file-key-new',
path: '/replacement.pdf',
size: 24,
type: 'application/pdf',
}
const plan = buildWorkflowSearchReplacePlan({
blocks: workflow.blocks,
matches,
selectedMatchIds: new Set([matches[1].id]),
defaultReplacement: JSON.stringify(replacementFile),
resourceReplacementOptions: [
{ kind: 'file', value: JSON.stringify(replacementFile), label: replacementFile.name },
],
})
expect(plan.updates).toEqual([])
expect(plan.conflicts).toEqual([
{ matchId: matches[1].id, reason: 'Target resource changed since search' },
])
})
it('conflicts when a selected duplicate file occurrence becomes a single file object', () => {
const workflow = createSearchReplaceWorkflowFixture()
const firstFile = {
name: 'first.pdf',
key: 'file-key-old',
path: '/first.pdf',
size: 12,
type: 'application/pdf',
}
const secondFile = {
name: 'second.pdf',
key: 'file-key-old',
path: '/second.pdf',
size: 14,
type: 'application/pdf',
}
workflow.blocks['tool-input-1'] = {
id: 'tool-input-1',
type: 'custom',
name: 'Tool Input Block',
position: { x: 0, y: 0 },
enabled: true,
outputs: {},
subBlocks: {
tools: {
id: 'tools',
type: 'tool-input',
value: [
{
type: 'slack',
toolId: 'slack_message',
operation: 'send',
title: 'Slack message',
params: {
authMethod: 'oauth',
credential: 'slack-credential',
text: 'message with files',
attachmentFiles: [firstFile, secondFile],
},
},
],
},
},
}
const matches = indexWorkflowSearchMatches({
workflow,
query: 'file-key-old',
mode: 'resource',
blockConfigs: {
...SEARCH_REPLACE_BLOCK_CONFIGS,
custom: {
subBlocks: [{ id: 'tools', title: 'Tools', type: 'tool-input' }],
},
},
}).filter((match) => match.kind === 'file')
workflow.blocks['tool-input-1'].subBlocks.tools.value = [
{
type: 'slack',
toolId: 'slack_message',
operation: 'send',
title: 'Slack message',
params: {
authMethod: 'oauth',
credential: 'slack-credential',
text: 'message with files',
attachmentFiles: firstFile,
},
},
]
const replacementFile = {
name: 'replacement.pdf',
key: 'file-key-new',
path: '/replacement.pdf',
size: 24,
type: 'application/pdf',
}
const plan = buildWorkflowSearchReplacePlan({
blocks: workflow.blocks,
matches,
selectedMatchIds: new Set([matches[1].id]),
defaultReplacement: JSON.stringify(replacementFile),
resourceReplacementOptions: [
{ kind: 'file', value: JSON.stringify(replacementFile), label: replacementFile.name },
],
})
expect(plan.updates).toEqual([])
expect(plan.conflicts).toEqual([
{ matchId: matches[1].id, reason: 'Target resource changed since search' },
])
})
it('clears nested tool-input dependents when replacing a parent resource', () => {
const workflow = createSearchReplaceWorkflowFixture()
workflow.blocks['tool-input-1'] = {
id: 'tool-input-1',
type: 'custom',
name: 'Tool Input Block',
position: { x: 0, y: 0 },
enabled: true,
outputs: {},
subBlocks: {
tools: {
id: 'tools',
type: 'tool-input',
value: [
{
type: 'workflow_input',
toolId: 'workflow_executor',
title: 'Workflow',
params: {
workflowId: 'workflow-old',
inputMapping: JSON.stringify({ customerEmail: 'old email value' }),
},
},
],
},
},
}
const matches = indexWorkflowSearchMatches({
workflow,
query: 'workflow-old',
mode: 'resource',
workspaceId: 'workspace-1',
workflowId: 'current-workflow',
blockConfigs: {
...SEARCH_REPLACE_BLOCK_CONFIGS,
custom: {
subBlocks: [{ id: 'tools', title: 'Tools', type: 'tool-input' }],
},
},
}).filter((match) => match.kind === 'workflow')
const plan = buildWorkflowSearchReplacePlan({
blocks: workflow.blocks,
matches,
selectedMatchIds: new Set(matches.map((match) => match.id)),
defaultReplacement: 'workflow-new',
resourceReplacementOptions: [
{
kind: 'workflow',
value: 'workflow-new',
label: 'New Workflow',
resourceGroupKey: matches[0].resource?.resourceGroupKey,
},
],
})
expect(plan.conflicts).toEqual([])
expect(plan.updates).toEqual([
expect.objectContaining({
subBlockId: 'tools',
nextValue: [
expect.objectContaining({
params: expect.objectContaining({
workflowId: 'workflow-new',
inputMapping: '',
}),
}),
],
}),
])
})
it('preserves selected nested dependent replacements when replacing a parent resource', () => {
const workflow = createSearchReplaceWorkflowFixture()
workflow.blocks['tool-input-1'] = {
id: 'tool-input-1',
type: 'custom',
name: 'Tool Input Block',
position: { x: 0, y: 0 },
enabled: true,
outputs: {},
subBlocks: {
tools: {
id: 'tools',
type: 'tool-input',
value: [
{
type: 'workflow_input',
toolId: 'workflow_executor',
title: 'Workflow',
params: {
workflowId: 'workflow-old',
inputMapping: JSON.stringify({ customerEmail: 'old email value' }),
},
},
],
},
},
}
const blockConfigs = {
...SEARCH_REPLACE_BLOCK_CONFIGS,
custom: {
subBlocks: [{ id: 'tools', title: 'Tools', type: 'tool-input' }],
},
}
const workflowMatches = indexWorkflowSearchMatches({
workflow,
query: 'workflow-old',
mode: 'resource',
workspaceId: 'workspace-1',
workflowId: 'current-workflow',
blockConfigs,
}).filter((match) => match.kind === 'workflow')
const mappingMatches = indexWorkflowSearchMatches({
workflow,
query: 'old email',
mode: 'text',
blockConfigs,
}).filter((match) => match.subBlockType === 'workflow-input-mapper')
const matches = [...workflowMatches, ...mappingMatches]
const plan = buildWorkflowSearchReplacePlan({
blocks: workflow.blocks,
matches,
selectedMatchIds: new Set(matches.map((match) => match.id)),
replacementByMatchId: {
[workflowMatches[0].id]: 'workflow-new',
[mappingMatches[0].id]: 'new email',
},
resourceReplacementOptions: [
{
kind: 'workflow',
value: 'workflow-new',
label: 'New Workflow',
resourceGroupKey: workflowMatches[0].resource?.resourceGroupKey,
},
],
})
const nextTools = plan.updates[0].nextValue as Array<{
params: { inputMapping: string; workflowId: string }
}>
expect(plan.conflicts).toEqual([])
expect(nextTools[0].params.workflowId).toBe('workflow-new')
expect(JSON.parse(nextTools[0].params.inputMapping)).toEqual({
customerEmail: 'new email value',
})
})
it('replaces serialized workflow-input mapper values without changing keys', () => {
const workflow = createSearchReplaceWorkflowFixture()
workflow.blocks['tool-input-1'] = {
id: 'tool-input-1',
type: 'custom',
name: 'Tool Input Block',
position: { x: 0, y: 0 },
enabled: true,
outputs: {},
subBlocks: {
tools: {
id: 'tools',
type: 'tool-input',
value: [
{
type: 'workflow_input',
toolId: 'workflow_executor',
title: 'Workflow',
params: {
workflowId: 'workflow-old',
inputMapping: JSON.stringify({ customerEmail: 'old email value' }),
},
},
],
},
},
}
const matches = indexWorkflowSearchMatches({
workflow,
query: 'old email',
mode: 'text',
blockConfigs: {
...SEARCH_REPLACE_BLOCK_CONFIGS,
custom: {
subBlocks: [{ id: 'tools', title: 'Tools', type: 'tool-input' }],
},
},
}).filter((match) => match.subBlockType === 'workflow-input-mapper')
const plan = buildWorkflowSearchReplacePlan({
blocks: workflow.blocks,
matches,
selectedMatchIds: new Set(matches.map((match) => match.id)),
defaultReplacement: 'new email',
})
const nextTools = plan.updates[0].nextValue as Array<{ params: { inputMapping: string } }>
expect(plan.conflicts).toEqual([])
expect(JSON.parse(nextTools[0].params.inputMapping)).toEqual({
customerEmail: 'new email value',
})
})
it('replaces object-valued fallback tool params without changing metadata', () => {
const workflow = createSearchReplaceWorkflowFixture()
workflow.blocks['tool-input-1'] = {
id: 'tool-input-1',
type: 'custom',
name: 'Tool Input Block',
position: { x: 0, y: 0 },
enabled: true,
outputs: {},
subBlocks: {
tools: {
id: 'tools',
type: 'tool-input',
value: [
{
type: 'mcp',
title: 'MCP tool',
params: {
payload: {
type: 'metadata-type',
filter: { status: 'old customer' },
},
},
},
],
},
},
}
const matches = indexWorkflowSearchMatches({
workflow,
query: 'old',
mode: 'text',
blockConfigs: {
...SEARCH_REPLACE_BLOCK_CONFIGS,
custom: {
subBlocks: [{ id: 'tools', title: 'Tools', type: 'tool-input' }],
},
},
}).filter((match) => match.blockId === 'tool-input-1')
const plan = buildWorkflowSearchReplacePlan({
blocks: workflow.blocks,
matches,
selectedMatchIds: new Set(matches.map((match) => match.id)),
defaultReplacement: 'new',
})
const nextTools = plan.updates[0].nextValue as Array<{
params: { payload: { type: string; filter: { status: string } } }
}>
expect(plan.conflicts).toEqual([])
expect(nextTools[0].params.payload).toEqual({
type: 'metadata-type',
filter: { status: 'new customer' },
})
})
it('replaces serialized JSON fallback tool param values without changing keys', () => {
const workflow = createSearchReplaceWorkflowFixture()
workflow.blocks['tool-input-1'] = {
id: 'tool-input-1',
type: 'custom',
name: 'Tool Input Block',
position: { x: 0, y: 0 },
enabled: true,
outputs: {},
subBlocks: {
tools: {
id: 'tools',
type: 'tool-input',
value: [
{
type: 'mcp',
title: 'MCP tool',
params: {
payload: JSON.stringify({ customer: { name: 'old customer' } }),
},
},
],
},
},
}
const matches = indexWorkflowSearchMatches({
workflow,
query: 'old',
mode: 'text',
blockConfigs: {
...SEARCH_REPLACE_BLOCK_CONFIGS,
custom: {
subBlocks: [{ id: 'tools', title: 'Tools', type: 'tool-input' }],
},
},
}).filter((match) => match.blockId === 'tool-input-1')
const plan = buildWorkflowSearchReplacePlan({
blocks: workflow.blocks,
matches,
selectedMatchIds: new Set(matches.map((match) => match.id)),
defaultReplacement: 'new',
})
const nextTools = plan.updates[0].nextValue as Array<{ params: { payload: string } }>
expect(plan.conflicts).toEqual([])
expect(JSON.parse(nextTools[0].params.payload)).toEqual({
customer: { name: 'new customer' },
})
})
it('replaces stringified variables-input values without changing metadata', () => {
const workflow = createSearchReplaceWorkflowFixture()
workflow.blocks['variables-1'] = {
id: 'variables-1',
type: 'custom',
name: 'Variables',
position: { x: 0, y: 0 },
enabled: true,
outputs: {},
subBlocks: {
assignments: {
id: 'assignments',
type: 'variables-input',
value: JSON.stringify([
{
id: 'assignment-id',
variableId: 'variable-id',
variableName: 'customer',
type: 'string',
value: 'old customer',
isExisting: true,
},
]),
},
},
}
const matches = indexWorkflowSearchMatches({
workflow,
query: 'old',
mode: 'text',
blockConfigs: {
...SEARCH_REPLACE_BLOCK_CONFIGS,
custom: {
subBlocks: [{ id: 'assignments', title: 'Variables', type: 'variables-input' }],
},
},
}).filter((match) => match.blockId === 'variables-1')
const plan = buildWorkflowSearchReplacePlan({
blocks: workflow.blocks,
matches,
selectedMatchIds: new Set(matches.map((match) => match.id)),
defaultReplacement: 'new',
})
const nextAssignments = JSON.parse(plan.updates[0].nextValue as string)
expect(plan.conflicts).toEqual([])
expect(nextAssignments).toEqual([
{
id: 'assignment-id',
variableId: 'variable-id',
variableName: 'customer',
type: 'string',
value: 'new customer',
isExisting: true,
},
])
})
it('replaces stringified table cell values without changing row metadata', () => {
const workflow = createSearchReplaceWorkflowFixture()
workflow.blocks['table-1'] = {
id: 'table-1',
type: 'custom',
name: 'Table',
position: { x: 0, y: 0 },
enabled: true,
outputs: {},
subBlocks: {
rows: {
id: 'rows',
type: 'table',
value: JSON.stringify([{ id: 'row-id', cells: { Name: 'old customer' } }]),
},
},
}
const matches = indexWorkflowSearchMatches({
workflow,
query: 'old',
mode: 'text',
blockConfigs: {
...SEARCH_REPLACE_BLOCK_CONFIGS,
custom: {
subBlocks: [{ id: 'rows', title: 'Rows', type: 'table', columns: ['Name'] }],
},
},
}).filter((match) => match.blockId === 'table-1')
const plan = buildWorkflowSearchReplacePlan({
blocks: workflow.blocks,
matches,
selectedMatchIds: new Set(matches.map((match) => match.id)),
defaultReplacement: 'new',
})
const nextRows = JSON.parse(plan.updates[0].nextValue as string)
expect(plan.conflicts).toEqual([])
expect(nextRows).toEqual([{ id: 'row-id', cells: { Name: 'new customer' } }])
})
it('allows replacing text matches with an empty string', () => {
const workflow = createSearchReplaceWorkflowFixture()
const matches = indexWorkflowSearchMatches({
workflow,
query: 'email',
mode: 'text',
blockConfigs: SEARCH_REPLACE_BLOCK_CONFIGS,
}).filter((match) => match.blockId === 'agent-1')
const plan = buildWorkflowSearchReplacePlan({
blocks: workflow.blocks,
matches,
selectedMatchIds: new Set([matches[0].id]),
defaultReplacement: '',
})
expect(plan.conflicts).toEqual([])
expect(plan.updates).toEqual([
expect.objectContaining({
nextValue: ' {{OLD_SECRET}} and then email again. Use <start.output>.',
}),
])
})
it('replaces loop and parallel subflow editor values', () => {
const workflow = createSearchReplaceWorkflowFixture()
workflow.blocks['parallel-1'] = {
id: 'parallel-1',
type: 'parallel',
name: 'Parallel 1',
position: { x: 0, y: 0 },
enabled: true,
outputs: {},
subBlocks: {},
data: {
parallelType: 'count',
count: 20,
},
}
workflow.blocks['loop-1'] = {
id: 'loop-1',
type: 'loop',
name: 'Loop 1',
position: { x: 0, y: 0 },
enabled: true,
outputs: {},
subBlocks: {},
data: {
loopType: 'forEach',
collection: "['item-2']",
},
}
const countMatches = indexWorkflowSearchMatches({
workflow,
query: '20',
mode: 'text',
blockConfigs: SEARCH_REPLACE_BLOCK_CONFIGS,
}).filter((match) => match.target.kind === 'subflow')
const collectionMatches = indexWorkflowSearchMatches({
workflow,
query: 'item-2',
mode: 'text',
blockConfigs: SEARCH_REPLACE_BLOCK_CONFIGS,
}).filter((match) => match.target.kind === 'subflow')
const countPlan = buildWorkflowSearchReplacePlan({
blocks: workflow.blocks,
matches: countMatches,
selectedMatchIds: new Set(countMatches.map((match) => match.id)),
defaultReplacement: '3',
})
const collectionPlan = buildWorkflowSearchReplacePlan({
blocks: workflow.blocks,
matches: collectionMatches,
selectedMatchIds: new Set(collectionMatches.map((match) => match.id)),
defaultReplacement: 'item-3',
})
expect(countPlan.conflicts).toEqual([])
expect(countPlan.subflowUpdates).toEqual([
expect.objectContaining({
blockId: 'parallel-1',
blockType: 'parallel',
fieldId: WORKFLOW_SEARCH_SUBFLOW_FIELD_IDS.batchSize,
previousValue: '20',
nextValue: 3,
}),
expect.objectContaining({
blockId: 'parallel-1',
blockType: 'parallel',
fieldId: WORKFLOW_SEARCH_SUBFLOW_FIELD_IDS.iterations,
previousValue: '20',
nextValue: 3,
}),
])
expect(collectionPlan.conflicts).toEqual([])
expect(collectionPlan.subflowUpdates).toEqual([
expect.objectContaining({
blockId: 'loop-1',
blockType: 'loop',
fieldId: WORKFLOW_SEARCH_SUBFLOW_FIELD_IDS.items,
previousValue: "['item-2']",
nextValue: "['item-3']",
}),
])
})
it('replaces JSON-backed tag value fields without touching tag metadata', () => {
const workflow = createSearchReplaceWorkflowFixture()
workflow.blocks['tag-block-1'] = {
id: 'tag-block-1',
type: 'custom',
name: 'Tag Block',
position: { x: 0, y: 0 },
enabled: true,
outputs: {},
subBlocks: {
tagFilters: {
id: 'tagFilters',
type: 'knowledge-tag-filters',
value: JSON.stringify([
{
id: 'filter-open',
tagName: 'Status',
fieldType: 'text',
operator: 'eq',
tagValue: 'open ticket',
collapsed: false,
},
]),
},
documentTags: {
id: 'documentTags',
type: 'document-tag-entry',
value: JSON.stringify([
{
id: 'tag-open',
tagName: 'Priority',
fieldType: 'text',
value: 'open escalation',
collapsed: false,
},
]),
},
},
}
const blockConfigs = {
...SEARCH_REPLACE_BLOCK_CONFIGS,
custom: {
subBlocks: [
{ id: 'tagFilters', title: 'Tag Filters', type: 'knowledge-tag-filters' },
{ id: 'documentTags', title: 'Document Tags', type: 'document-tag-entry' },
],
},
}
const matches = indexWorkflowSearchMatches({
workflow,
query: 'open',
mode: 'text',
blockConfigs,
}).filter((match) => match.blockId === 'tag-block-1')
const plan = buildWorkflowSearchReplacePlan({
blocks: workflow.blocks,
matches,
selectedMatchIds: new Set(matches.map((match) => match.id)),
defaultReplacement: 'resolved',
})
const tagFilterUpdate = plan.updates.find((update) => update.subBlockId === 'tagFilters')
const documentTagUpdate = plan.updates.find((update) => update.subBlockId === 'documentTags')
const nextTagFilterValue = JSON.parse(String(tagFilterUpdate?.nextValue))
const nextDocumentTagValue = JSON.parse(String(documentTagUpdate?.nextValue))
expect(plan.conflicts).toEqual([])
expect(nextTagFilterValue).toEqual([
{
id: 'filter-open',
tagName: 'Status',
fieldType: 'text',
operator: 'eq',
tagValue: 'resolved ticket',
collapsed: false,
},
])
expect(nextDocumentTagValue).toEqual([
{
id: 'tag-open',
tagName: 'Priority',
fieldType: 'text',
value: 'resolved escalation',
collapsed: false,
},
])
})
it('replaces JSON-backed condition branch values without touching branch metadata', () => {
const workflow = createSearchReplaceWorkflowFixture()
workflow.blocks['branch-1'] = {
id: 'branch-1',
type: 'custom',
name: 'Branch Block',
position: { x: 0, y: 0 },
enabled: true,
outputs: {},
subBlocks: {
conditions: {
id: 'conditions',
type: 'condition-input',
value: JSON.stringify([
{
id: 'branch-open',
title: 'if',
value: 'open ticket',
showTags: false,
showEnvVars: false,
},
]),
},
},
}
const blockConfigs = {
...SEARCH_REPLACE_BLOCK_CONFIGS,
custom: {
subBlocks: [{ id: 'conditions', title: 'Conditions', type: 'condition-input' }],
},
}
const matches = indexWorkflowSearchMatches({
workflow,
query: 'open',
mode: 'text',
blockConfigs,
}).filter((match) => match.blockId === 'branch-1')
const plan = buildWorkflowSearchReplacePlan({
blocks: workflow.blocks,
matches,
selectedMatchIds: new Set(matches.map((match) => match.id)),
defaultReplacement: 'resolved',
})
const branchUpdate = plan.updates.find((update) => update.subBlockId === 'conditions')
const nextValue = JSON.parse(String(branchUpdate?.nextValue))
expect(plan.conflicts).toEqual([])
expect(nextValue).toEqual([
{
id: 'branch-open',
title: 'if',
value: 'resolved ticket',
showTags: false,
showEnvVars: false,
},
])
})
it('replaces object-backed input mapping values without changing mapping keys', () => {
const workflow = createSearchReplaceWorkflowFixture()
workflow.blocks['mapping-1'] = {
id: 'mapping-1',
type: 'custom',
name: 'Mapping Block',
position: { x: 0, y: 0 },
enabled: true,
outputs: {},
subBlocks: {
inputMapping: {
id: 'inputMapping',
type: 'input-mapping',
value: { customerEmail: 'old email value' },
},
},
}
const blockConfigs = {
...SEARCH_REPLACE_BLOCK_CONFIGS,
custom: {
subBlocks: [{ id: 'inputMapping', title: 'Input Mapping', type: 'input-mapping' }],
},
}
const matches = indexWorkflowSearchMatches({
workflow,
query: 'old',
mode: 'text',
blockConfigs,
}).filter((match) => match.blockId === 'mapping-1')
const plan = buildWorkflowSearchReplacePlan({
blocks: workflow.blocks,
matches,
selectedMatchIds: new Set(matches.map((match) => match.id)),
defaultReplacement: 'new',
})
expect(plan.conflicts).toEqual([])
expect(plan.updates).toEqual([
expect.objectContaining({
subBlockId: 'inputMapping',
nextValue: { customerEmail: 'new email value' },
}),
])
})
it('replaces workspace file upload resources with the selected file object', () => {
const workflow = createSearchReplaceWorkflowFixture()
workflow.blocks['file-upload-1'] = {
id: 'file-upload-1',
type: 'custom',
name: 'File Upload Block',
position: { x: 0, y: 0 },
enabled: true,
outputs: {},
subBlocks: {
file: {
id: 'file',
type: 'file-upload',
value: {
name: 'old.csv',
path: '/workspace/ws-1/old-key',
key: 'old-key',
size: 42,
type: 'text/csv',
},
},
},
}
const blockConfigs = {
...SEARCH_REPLACE_BLOCK_CONFIGS,
custom: {
subBlocks: [{ id: 'file', title: 'File', type: 'file-upload' }],
},
}
const matches = indexWorkflowSearchMatches({
workflow,
query: 'old',
mode: 'resource',
blockConfigs,
}).filter((match) => match.blockId === 'file-upload-1')
const replacementFile = {
name: 'new.csv',
path: '/workspace/ws-1/new-key',
key: 'new-key',
size: 84,
type: 'text/csv',
}
const plan = buildWorkflowSearchReplacePlan({
blocks: workflow.blocks,
matches,
selectedMatchIds: new Set(matches.map((match) => match.id)),
defaultReplacement: JSON.stringify(replacementFile),
resourceReplacementOptions: [
{ kind: 'file', value: JSON.stringify(replacementFile), label: 'new.csv' },
],
})
expect(plan.conflicts).toEqual([])
expect(plan.updates).toEqual([
expect.objectContaining({
subBlockId: 'file',
nextValue: replacementFile,
}),
])
})
it('rejects invalid file upload replacement payloads', () => {
const workflow = createSearchReplaceWorkflowFixture()
workflow.blocks['file-upload-1'] = {
id: 'file-upload-1',
type: 'custom',
name: 'File Upload Block',
position: { x: 0, y: 0 },
enabled: true,
outputs: {},
subBlocks: {
file: {
id: 'file',
type: 'file-upload',
value: {
name: 'old.csv',
path: '/workspace/ws-1/old-key',
key: 'old-key',
},
},
},
}
const blockConfigs = {
...SEARCH_REPLACE_BLOCK_CONFIGS,
custom: {
subBlocks: [{ id: 'file', title: 'File', type: 'file-upload' }],
},
}
const matches = indexWorkflowSearchMatches({
workflow,
query: 'old',
mode: 'resource',
blockConfigs,
}).filter((match) => match.blockId === 'file-upload-1')
const plan = buildWorkflowSearchReplacePlan({
blocks: workflow.blocks,
matches,
selectedMatchIds: new Set(matches.map((match) => match.id)),
defaultReplacement: '"not-a-file-object"',
resourceReplacementOptions: [
{ kind: 'file', value: '"not-a-file-object"', label: 'Invalid file' },
],
})
expect(plan.updates).toEqual([])
expect(plan.conflicts).toEqual([
{
matchId: matches[0].id,
reason: 'Replacement file is no longer valid',
},
])
})
it('rejects invalid subflow iteration replacements', () => {
const workflow = createSearchReplaceWorkflowFixture()
workflow.blocks['parallel-1'] = {
id: 'parallel-1',
type: 'parallel',
name: 'Parallel 1',
position: { x: 0, y: 0 },
enabled: true,
outputs: {},
subBlocks: {},
data: {
parallelType: 'count',
count: 2,
},
}
const matches = indexWorkflowSearchMatches({
workflow,
query: '2',
mode: 'text',
blockConfigs: SEARCH_REPLACE_BLOCK_CONFIGS,
}).filter((match) => match.target.kind === 'subflow')
const plan = buildWorkflowSearchReplacePlan({
blocks: workflow.blocks,
matches,
selectedMatchIds: new Set(matches.map((match) => match.id)),
defaultReplacement: '25',
})
expect(plan.subflowUpdates).toEqual([])
expect(plan.conflicts).toEqual([
{
matchId: 'subflow-text:parallel-1:subflowBatchSize:0:0',
reason: 'Parallel batch size must be between 1 and 20',
},
])
})
it('rejects structured resource replacements that are not resolvable options', () => {
const workflow = createSearchReplaceWorkflowFixture()
const matches = indexWorkflowSearchMatches({
workflow,
query: 'kb-old',
mode: 'resource',
blockConfigs: SEARCH_REPLACE_BLOCK_CONFIGS,
})
const plan = buildWorkflowSearchReplacePlan({
blocks: workflow.blocks,
matches,
selectedMatchIds: new Set(matches.map((match) => match.id)),
defaultReplacement: 'missing-kb',
resourceReplacementOptions: [
{ kind: 'knowledge-base', value: 'kb-new', label: 'New Knowledge Base' },
],
})
expect(plan.updates).toEqual([])
expect(plan.conflicts).toEqual([
{ matchId: matches[0].id, reason: 'Choose a valid knowledge base replacement.' },
])
})
it('rejects stale matches without partial writes', () => {
const workflow = createSearchReplaceWorkflowFixture()
const matches = indexWorkflowSearchMatches({
workflow,
query: 'email',
mode: 'text',
blockConfigs: SEARCH_REPLACE_BLOCK_CONFIGS,
})
workflow.blocks['agent-1'].subBlocks.systemPrompt.value = 'changed'
const plan = buildWorkflowSearchReplacePlan({
blocks: workflow.blocks,
matches,
selectedMatchIds: new Set([matches[0].id]),
defaultReplacement: 'message',
})
expect(plan.updates).toEqual([])
expect(plan.conflicts).toEqual([
{ matchId: matches[0].id, reason: 'Target text changed since search' },
])
})
})