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
1501 lines
57 KiB
TypeScript
1501 lines
57 KiB
TypeScript
/**
|
|
* @vitest-environment node
|
|
*/
|
|
import { describe, expect, it, vi } from 'vitest'
|
|
import type { BlockConfig, SubBlockConfig } from '@/blocks/types'
|
|
|
|
// The indexer resolves a tool's params via the tool registry; stub it so the
|
|
// injected blockConfigs subBlocks drive resolution deterministically in tests.
|
|
vi.mock('@/tools/params', () => ({
|
|
getToolIdForOperation: () => undefined,
|
|
getToolParametersConfig: () => null,
|
|
getSubBlocksForToolInput: (
|
|
_toolId: string,
|
|
_type: string,
|
|
_values: unknown,
|
|
_modes: unknown,
|
|
provided?: { subBlocks?: SubBlockConfig[] }
|
|
) => ({ subBlocks: provided?.subBlocks ?? [] }),
|
|
formatParameterLabel: (label: string) => label,
|
|
}))
|
|
|
|
import type { SubBlockRecord } from '@/lib/workflows/persistence/remap-internal-ids'
|
|
import { getBlock } from '@/blocks/registry'
|
|
import { createForkBootstrapTransform } from '@/ee/workspace-forking/lib/remap/fork-bootstrap'
|
|
import {
|
|
applyDependentOverrides,
|
|
clearDependentsOnRemap,
|
|
collectClearedDependents,
|
|
createForkSubBlockTransform,
|
|
parseNestedDependentKey,
|
|
readTargetDraftDependentValue,
|
|
remapForkSubBlocks,
|
|
remapToolBlockResources,
|
|
scanWorkflowReferences,
|
|
} from '@/ee/workspace-forking/lib/remap/remap-references'
|
|
|
|
const blockConfigs: Record<string, { subBlocks: SubBlockConfig[] }> = {
|
|
testblock: {
|
|
subBlocks: [
|
|
{ id: 'credential', title: 'Credential', type: 'oauth-input', serviceId: 'gmail' },
|
|
{ id: 'knowledgeBaseId', title: 'KB', type: 'knowledge-base-selector' },
|
|
{ id: 'channel', title: 'Channel', type: 'channel-selector', serviceId: 'slack' },
|
|
],
|
|
},
|
|
}
|
|
|
|
describe('remapToolBlockResources', () => {
|
|
it('remaps nested credential + knowledge-base ids and leaves external selectors', () => {
|
|
const tool = {
|
|
type: 'testblock',
|
|
toolId: 'testblock_run',
|
|
params: { credential: 'cred-src', knowledgeBaseId: 'kb-src', channel: 'C123' },
|
|
}
|
|
const map: Record<string, string> = {
|
|
'credential:cred-src': 'cred-dst',
|
|
'knowledge-base:kb-src': 'kb-dst',
|
|
}
|
|
const result = remapToolBlockResources(tool, {
|
|
resolve: (kind, id) => map[`${kind}:${id}`] ?? null,
|
|
resolveFileKey: () => null,
|
|
clearUnresolved: false,
|
|
blockConfigs,
|
|
})
|
|
expect(result.params).toEqual({
|
|
credential: 'cred-dst',
|
|
knowledgeBaseId: 'kb-dst',
|
|
channel: 'C123',
|
|
})
|
|
})
|
|
|
|
it('clears unresolved copyable refs when clearUnresolved is set (fork)', () => {
|
|
const tool = {
|
|
type: 'testblock',
|
|
toolId: 'testblock_run',
|
|
params: { credential: 'cred-src', knowledgeBaseId: 'kb-src', channel: 'C123' },
|
|
}
|
|
const result = remapToolBlockResources(tool, {
|
|
resolve: () => null,
|
|
resolveFileKey: () => null,
|
|
clearUnresolved: true,
|
|
blockConfigs,
|
|
})
|
|
expect(result.params).toEqual({ credential: '', knowledgeBaseId: '', channel: 'C123' })
|
|
})
|
|
|
|
it('keeps unresolved refs and records them when not clearing (promote)', () => {
|
|
const tool = {
|
|
type: 'testblock',
|
|
toolId: 'testblock_run',
|
|
params: { credential: 'cred-src', channel: 'C123' },
|
|
}
|
|
const recorded: Array<{ kind: string; id: string; mapped: boolean }> = []
|
|
const result = remapToolBlockResources(tool, {
|
|
resolve: () => null,
|
|
resolveFileKey: () => null,
|
|
record: (kind, id, mapped) => recorded.push({ kind, id, mapped }),
|
|
clearUnresolved: false,
|
|
blockConfigs,
|
|
})
|
|
expect((result.params as Record<string, unknown>).credential).toBe('cred-src')
|
|
expect(recorded).toContainEqual({ kind: 'credential', id: 'cred-src', mapped: false })
|
|
})
|
|
|
|
it('returns the tool unchanged when it has no params', () => {
|
|
const tool = { type: 'testblock' }
|
|
expect(
|
|
remapToolBlockResources(tool, {
|
|
resolve: () => null,
|
|
resolveFileKey: () => null,
|
|
clearUnresolved: true,
|
|
blockConfigs,
|
|
})
|
|
).toBe(tool)
|
|
})
|
|
|
|
it('leaves an advanced-mode manualCredential id untouched (escape hatch)', () => {
|
|
const tool = {
|
|
type: 'testblock',
|
|
toolId: 'testblock_run',
|
|
params: { manualCredential: 'mc-src', knowledgeBaseId: 'kb-src' },
|
|
}
|
|
const result = remapToolBlockResources(tool, {
|
|
resolve: (kind, id) => (kind === 'knowledge-base' && id === 'kb-src' ? 'kb-dst' : null),
|
|
resolveFileKey: () => null,
|
|
clearUnresolved: true,
|
|
blockConfigs,
|
|
})
|
|
expect(result.params).toEqual({ manualCredential: 'mc-src', knowledgeBaseId: 'kb-dst' })
|
|
})
|
|
|
|
it('drops only the uncopied entry in a mixed multi-value field', () => {
|
|
const tool = {
|
|
type: 'testblock',
|
|
toolId: 'testblock_run',
|
|
params: { knowledgeBaseId: 'kb1,kb2' },
|
|
}
|
|
const result = remapToolBlockResources(tool, {
|
|
resolve: (_kind, id) => (id === 'kb1' ? 'kb1-dst' : null),
|
|
resolveFileKey: () => null,
|
|
clearUnresolved: true,
|
|
blockConfigs,
|
|
})
|
|
const value = (result.params as Record<string, string>).knowledgeBaseId
|
|
expect(value.split(',').filter(Boolean)).toEqual(['kb1-dst'])
|
|
})
|
|
|
|
it('resolves a credential param by id even when its config is filtered out (reactive)', () => {
|
|
// blockConfigs has no `credential` subBlock (simulating a reactive-gated field
|
|
// hidden from getToolInputParamConfigs); the raw id-scan must still catch it.
|
|
const tool = {
|
|
type: 'reactiveblock',
|
|
toolId: 'reactiveblock_run',
|
|
params: { credential: 'cred-src' },
|
|
}
|
|
const result = remapToolBlockResources(tool, {
|
|
resolve: (kind, id) => (kind === 'credential' && id === 'cred-src' ? 'cred-dst' : null),
|
|
resolveFileKey: () => null,
|
|
clearUnresolved: false,
|
|
blockConfigs: { reactiveblock: { subBlocks: [] } },
|
|
})
|
|
expect((result.params as Record<string, string>).credential).toBe('cred-dst')
|
|
})
|
|
|
|
it('clears a dependent tool param when its parent resource is remapped', () => {
|
|
const tool = {
|
|
type: 'depblock',
|
|
toolId: 'depblock_run',
|
|
params: { knowledgeBaseId: 'kb-src', documentId: 'doc-src' },
|
|
}
|
|
const result = remapToolBlockResources(tool, {
|
|
resolve: (kind, id) => (kind === 'knowledge-base' && id === 'kb-src' ? 'kb-dst' : null),
|
|
resolveFileKey: () => null,
|
|
clearUnresolved: false,
|
|
blockConfigs: {
|
|
depblock: {
|
|
subBlocks: [
|
|
{ id: 'knowledgeBaseId', title: 'KB', type: 'knowledge-base-selector' },
|
|
{
|
|
id: 'documentId',
|
|
title: 'Doc',
|
|
type: 'document-selector',
|
|
dependsOn: ['knowledgeBaseId'],
|
|
},
|
|
],
|
|
},
|
|
},
|
|
})
|
|
expect(result.params).toEqual({ knowledgeBaseId: 'kb-dst', documentId: '' })
|
|
})
|
|
|
|
it('remaps a nested documentId through the doc map when its document was copied', () => {
|
|
const tool = {
|
|
type: 'depblock',
|
|
toolId: 'depblock_run',
|
|
params: { knowledgeBaseId: 'kb-src', documentId: 'doc-src' },
|
|
}
|
|
const map: Record<string, string> = {
|
|
'knowledge-base:kb-src': 'kb-dst',
|
|
'knowledge-document:doc-src': 'doc-dst',
|
|
}
|
|
const result = remapToolBlockResources(tool, {
|
|
resolve: (kind, id) => map[`${kind}:${id}`] ?? null,
|
|
resolveFileKey: () => null,
|
|
clearUnresolved: true,
|
|
blockConfigs: {
|
|
depblock: {
|
|
subBlocks: [
|
|
{ id: 'knowledgeBaseId', title: 'KB', type: 'knowledge-base-selector' },
|
|
{
|
|
id: 'documentId',
|
|
title: 'Doc',
|
|
type: 'document-selector',
|
|
dependsOn: ['knowledgeBaseId'],
|
|
},
|
|
],
|
|
},
|
|
},
|
|
})
|
|
// documentId is remapped (not cleared as a dependent) because its document was copied.
|
|
expect(result.params).toEqual({ knowledgeBaseId: 'kb-dst', documentId: 'doc-dst' })
|
|
})
|
|
})
|
|
|
|
describe('remapForkSubBlocks', () => {
|
|
const subBlocks = (): SubBlockRecord => ({
|
|
credential: { id: 'credential', type: 'oauth-input', value: 'c-src' },
|
|
knowledgeBaseId: { id: 'knowledgeBaseId', type: 'knowledge-base-selector', value: 'kb-src' },
|
|
manualCredential: { id: 'manualCredential', type: 'short-input', value: 'mc-src' },
|
|
})
|
|
|
|
it('create mode: clears unresolved credentials and remaps copied resources', () => {
|
|
const result = remapForkSubBlocks(
|
|
subBlocks(),
|
|
(kind, id) => (kind === 'knowledge-base' && id === 'kb-src' ? 'kb-dst' : null),
|
|
'create'
|
|
)
|
|
expect(result.subBlocks.credential.value).toBe('')
|
|
expect(result.subBlocks.knowledgeBaseId.value).toBe('kb-dst')
|
|
expect(result.subBlocks.manualCredential.value).toBe('mc-src')
|
|
expect(result.references).toHaveLength(0)
|
|
})
|
|
|
|
it('promote mode: keeps + records the basic credential; manual id is escape hatch', () => {
|
|
const result = remapForkSubBlocks(
|
|
subBlocks(),
|
|
(kind, id) => (kind === 'knowledge-base' && id === 'kb-src' ? 'kb-dst' : null),
|
|
'promote'
|
|
)
|
|
// The basic credential is cleared (never carry an invalid cross-workspace id) but
|
|
// still surfaced as required so the sync blocks; the advanced manualCredential is an
|
|
// escape hatch - preserved verbatim, not recorded.
|
|
expect(result.subBlocks.credential.value).toBe('')
|
|
expect(result.subBlocks.manualCredential.value).toBe('mc-src')
|
|
expect(result.subBlocks.knowledgeBaseId.value).toBe('kb-dst')
|
|
const unmappedKinds = result.unmapped.map((r) => `${r.kind}:${r.sourceId}`)
|
|
expect(unmappedKinds).toContain('credential:c-src')
|
|
expect(unmappedKinds).not.toContain('credential:mc-src')
|
|
expect(result.unmapped.every((r) => r.kind !== 'knowledge-base')).toBe(true)
|
|
})
|
|
|
|
it('promote mode: rewrites {{ENV}} nested in an array-form tool param', () => {
|
|
const sb: SubBlockRecord = {
|
|
tools: {
|
|
id: 'tools',
|
|
type: 'tool-input',
|
|
value: [{ type: 'genericblock', params: { subject: 'Hi {{OLD}}' } }],
|
|
},
|
|
}
|
|
const result = remapForkSubBlocks(
|
|
sb,
|
|
(kind, id) => (kind === 'env-var' && id === 'OLD' ? 'NEW' : null),
|
|
'promote'
|
|
)
|
|
const tools = result.subBlocks.tools.value as Array<{ params: { subject: string } }>
|
|
expect(tools[0].params.subject).toBe('Hi {{NEW}}')
|
|
})
|
|
|
|
const fileSubBlock = (): SubBlockRecord => ({
|
|
file: {
|
|
id: 'file',
|
|
type: 'file-upload',
|
|
value: { key: 'workspace/SRC/a.png', name: 'a.png' },
|
|
},
|
|
})
|
|
|
|
it('promote mode: records an unmapped file-upload key as a file reference and clears it', () => {
|
|
const result = remapForkSubBlocks(fileSubBlock(), () => null, 'promote')
|
|
const keys = result.references.map((r) => `${r.kind}:${r.sourceId}`)
|
|
expect(keys).toContain('file:workspace/SRC/a.png')
|
|
// file refs are optional (not required), surfaced for the copy/clear decision.
|
|
expect(result.references.find((r) => r.kind === 'file')?.required).toBe(false)
|
|
expect(result.unmapped.map((r) => `${r.kind}:${r.sourceId}`)).toContain(
|
|
'file:workspace/SRC/a.png'
|
|
)
|
|
// An uncopied file key is dropped rather than carried cross-workspace.
|
|
expect(result.subBlocks.file.value).toBe('')
|
|
})
|
|
|
|
it('promote mode: remaps a file-upload key to the copied target and records it mapped', () => {
|
|
const result = remapForkSubBlocks(
|
|
fileSubBlock(),
|
|
(kind, id) =>
|
|
kind === 'file' && id === 'workspace/SRC/a.png' ? 'workspace/DST/a.png' : null,
|
|
'promote'
|
|
)
|
|
expect(result.references.map((r) => `${r.kind}:${r.sourceId}`)).toContain(
|
|
'file:workspace/SRC/a.png'
|
|
)
|
|
expect(result.unmapped).toHaveLength(0)
|
|
expect((result.subBlocks.file.value as { key: string }).key).toBe('workspace/DST/a.png')
|
|
})
|
|
|
|
it('create mode: does not record file references but still remaps copied files', () => {
|
|
const result = remapForkSubBlocks(
|
|
fileSubBlock(),
|
|
(kind, id) =>
|
|
kind === 'file' && id === 'workspace/SRC/a.png' ? 'workspace/DST/a.png' : null,
|
|
'create'
|
|
)
|
|
expect(result.references).toHaveLength(0)
|
|
expect((result.subBlocks.file.value as { key: string }).key).toBe('workspace/DST/a.png')
|
|
})
|
|
})
|
|
|
|
const blockWith = (subBlocks: SubBlockConfig[]): BlockConfig =>
|
|
({ name: 'Test', description: '', subBlocks, outputs: {} }) as unknown as BlockConfig
|
|
|
|
const entry = (id: string, type: string, value: unknown) => ({ id, type, value })
|
|
|
|
describe('createForkBootstrapTransform document-selector remap', () => {
|
|
const docBlock = () =>
|
|
blockWith([
|
|
{ id: 'knowledgeBaseId', title: 'KB', type: 'knowledge-base-selector' },
|
|
{ id: 'documentId', title: 'Doc', type: 'document-selector', dependsOn: ['knowledgeBaseId'] },
|
|
])
|
|
const subBlocks = (): SubBlockRecord => ({
|
|
knowledgeBaseId: { id: 'knowledgeBaseId', type: 'knowledge-base-selector', value: 'kb-src' },
|
|
documentId: { id: 'documentId', type: 'document-selector', value: 'doc-src' },
|
|
})
|
|
|
|
it('remaps documentId to the copied document (not cleared as a KB dependent)', () => {
|
|
vi.mocked(getBlock).mockReturnValue(docBlock())
|
|
const map: Record<string, string> = {
|
|
'knowledge-base:kb-src': 'kb-dst',
|
|
'knowledge-document:doc-src': 'doc-dst',
|
|
}
|
|
const transform = createForkBootstrapTransform((kind, id) => map[`${kind}:${id}`] ?? null)
|
|
const result = transform(subBlocks(), 'knowledge')
|
|
expect(result.knowledgeBaseId.value).toBe('kb-dst')
|
|
expect(result.documentId.value).toBe('doc-dst')
|
|
})
|
|
|
|
it('clears documentId when its parent KB was not copied', () => {
|
|
vi.mocked(getBlock).mockReturnValue(docBlock())
|
|
const transform = createForkBootstrapTransform(() => null)
|
|
const result = transform(subBlocks(), 'knowledge')
|
|
expect(result.knowledgeBaseId.value).toBe('')
|
|
expect(result.documentId.value).toBe('')
|
|
})
|
|
|
|
it('clears documentId when its KB was copied but the document was not', () => {
|
|
vi.mocked(getBlock).mockReturnValue(docBlock())
|
|
const transform = createForkBootstrapTransform((kind, id) =>
|
|
kind === 'knowledge-base' && id === 'kb-src' ? 'kb-dst' : null
|
|
)
|
|
const result = transform(subBlocks(), 'knowledge')
|
|
expect(result.knowledgeBaseId.value).toBe('kb-dst')
|
|
expect(result.documentId.value).toBe('')
|
|
})
|
|
})
|
|
|
|
describe('MCP block server remap follows the tool selection (optimistic verbatim)', () => {
|
|
// Shape of the real MCP block: tool depends on server, arguments depend on tool.
|
|
const mcpBlock = () =>
|
|
blockWith([
|
|
{ id: 'server', title: 'MCP Server', type: 'mcp-server-selector', required: true },
|
|
{
|
|
id: 'tool',
|
|
title: 'Tool',
|
|
type: 'mcp-tool-selector',
|
|
required: true,
|
|
dependsOn: ['server'],
|
|
},
|
|
{ id: 'arguments', title: '', type: 'mcp-dynamic-args', dependsOn: ['tool'] },
|
|
])
|
|
const mcpSubBlocks = (): SubBlockRecord => ({
|
|
server: { id: 'server', type: 'mcp-server-selector', value: 'mcp-src1' },
|
|
tool: { id: 'tool', type: 'mcp-tool-selector', value: 'mcp-src1-search_docs' },
|
|
arguments: { id: 'arguments', type: 'mcp-dynamic-args', value: '{"query":"hello"}' },
|
|
})
|
|
const mapServer = (kind: string, id: string) =>
|
|
kind === 'mcp-server' && id === 'mcp-src1' ? 'mcp-tgt9' : null
|
|
|
|
it('sync transform: keeps the tool (embedded server id swapped, name verbatim) and its arguments', () => {
|
|
// The same transform serves BOTH create- and replace-mode sync targets, so a freshly
|
|
// created target deploys with the tool intact instead of an empty required field.
|
|
vi.mocked(getBlock).mockReturnValue(mcpBlock())
|
|
const transform = createForkSubBlockTransform(mapServer)
|
|
const result = transform(mcpSubBlocks(), 'mcp')
|
|
expect(result.server.value).toBe('mcp-tgt9')
|
|
expect(result.tool.value).toBe('mcp-tgt9-search_docs')
|
|
expect(result.arguments.value).toBe('{"query":"hello"}')
|
|
})
|
|
|
|
it('keeps a bare tool name (no embedded server id) verbatim under the remapped server', () => {
|
|
vi.mocked(getBlock).mockReturnValue(mcpBlock())
|
|
const subBlocks = mcpSubBlocks()
|
|
subBlocks.tool = { id: 'tool', type: 'mcp-tool-selector', value: 'search_docs' }
|
|
const transform = createForkSubBlockTransform(mapServer)
|
|
const result = transform(subBlocks, 'mcp')
|
|
expect(result.server.value).toBe('mcp-tgt9')
|
|
expect(result.tool.value).toBe('search_docs')
|
|
expect(result.arguments.value).toBe('{"query":"hello"}')
|
|
})
|
|
|
|
it('sync transform: an UNMAPPED server is cleared and still clears tool + arguments (defense-in-depth)', () => {
|
|
// The zero-cleared-refs gate blocks a sync before this state can persist; the remap's
|
|
// clear-unresolved backstop must still never leave a tool under a cleared server.
|
|
vi.mocked(getBlock).mockReturnValue(mcpBlock())
|
|
const transform = createForkSubBlockTransform(() => null)
|
|
const result = transform(mcpSubBlocks(), 'mcp')
|
|
expect(result.server.value).toBe('')
|
|
expect(result.tool.value).toBe('')
|
|
expect(result.arguments.value).toBe('')
|
|
})
|
|
|
|
it('fork-create: an UNSELECTED server clears, and its tool + arguments clear with it', () => {
|
|
vi.mocked(getBlock).mockReturnValue(mcpBlock())
|
|
const transform = createForkBootstrapTransform(() => null)
|
|
const result = transform(mcpSubBlocks(), 'mcp')
|
|
expect(result.server.value).toBe('')
|
|
expect(result.tool.value).toBe('')
|
|
expect(result.arguments.value).toBe('')
|
|
})
|
|
|
|
it('fork-create: a COPIED server remaps and the tool selection + arguments follow it', () => {
|
|
// The fork resolver now carries `mcp-server` entries for copied external servers, so the
|
|
// MCP block is preserved end-to-end: server -> copied id, tool -> embedded id swapped
|
|
// (name verbatim, re-resolved by the child's first discovery), arguments untouched.
|
|
vi.mocked(getBlock).mockReturnValue(mcpBlock())
|
|
const transform = createForkBootstrapTransform(mapServer as never)
|
|
const result = transform(mcpSubBlocks(), 'mcp')
|
|
expect(result.server.value).toBe('mcp-tgt9')
|
|
expect(result.tool.value).toBe('mcp-tgt9-search_docs')
|
|
expect(result.arguments.value).toBe('{"query":"hello"}')
|
|
})
|
|
|
|
it('fork-create: a COPIED server rewrites an agent tool-input MCP entry (serverId + toolId)', () => {
|
|
vi.mocked(getBlock).mockReturnValue(
|
|
blockWith([{ id: 'tools', title: 'Tools', type: 'tool-input' }])
|
|
)
|
|
const transform = createForkBootstrapTransform(mapServer as never)
|
|
const result = transform(
|
|
{
|
|
tools: {
|
|
id: 'tools',
|
|
type: 'tool-input',
|
|
value: [
|
|
{
|
|
type: 'mcp',
|
|
title: 'Search Docs',
|
|
params: { serverId: 'mcp-src1', toolName: 'search_docs' },
|
|
toolId: 'mcp-src1-search_docs',
|
|
},
|
|
],
|
|
},
|
|
},
|
|
'agent'
|
|
)
|
|
const [tool] = result.tools.value as Array<{ params: Record<string, unknown>; toolId: string }>
|
|
expect(tool.params.serverId).toBe('mcp-tgt9')
|
|
expect(tool.params.toolName).toBe('search_docs')
|
|
expect(tool.toolId).toBe('mcp-tgt9-search_docs')
|
|
})
|
|
|
|
it(
|
|
'regression: dropping an unresolved custom-tool reindexes the surviving tool ' +
|
|
"canonicalModes so it doesn't inherit the dropped tool's old-index mode",
|
|
() => {
|
|
vi.mocked(getBlock).mockImplementation((type) => {
|
|
if (type === 'agent')
|
|
return blockWith([{ id: 'tools', title: 'Tools', type: 'tool-input' }])
|
|
if (type === 'table') return blockWith([])
|
|
return undefined as unknown as BlockConfig
|
|
})
|
|
const transform = createForkBootstrapTransform(() => null)
|
|
const onCanonicalModesChanged = vi.fn()
|
|
const result = transform(
|
|
{
|
|
tools: {
|
|
id: 'tools',
|
|
type: 'tool-input',
|
|
value: [
|
|
// Index 0: unresolved custom-tool - fork-create always clears unresolved, so
|
|
// this entry is dropped, shifting every later tool down by one.
|
|
{ type: 'custom-tool', title: 'Missing', customToolId: 'missing-tool' },
|
|
// Index 1 -> 0 after the drop.
|
|
{ type: 'table', operation: 'query_rows', params: {} },
|
|
],
|
|
},
|
|
},
|
|
'agent',
|
|
{ '1:tableId': 'advanced' },
|
|
onCanonicalModesChanged
|
|
)
|
|
const tools = result.tools.value as Array<{ type: string }>
|
|
expect(tools).toHaveLength(1)
|
|
expect(tools[0].type).toBe('table')
|
|
expect(onCanonicalModesChanged).toHaveBeenCalledWith({ '0:tableId': 'advanced' })
|
|
}
|
|
)
|
|
|
|
it('remap layer: the tool follow-rewrite is not registered as a remapped parent key', () => {
|
|
// Only `server` may drive dependent clears; the followed tool must not (its own
|
|
// dependent - arguments - is preserved with it).
|
|
const result = remapForkSubBlocks(mcpSubBlocks(), mapServer, 'promote')
|
|
expect(result.subBlocks.tool.value).toBe('mcp-tgt9-search_docs')
|
|
expect(result.remappedKeys).toEqual(new Set(['server']))
|
|
})
|
|
|
|
it('clearDependentsOnRemap: exemption applies ONLY to the mcp tool selector, not other kinds', () => {
|
|
// A knowledge-base parent remapped to a non-empty target still clears its
|
|
// document-selector dependent (regression guard for the mcp-only exemption).
|
|
vi.mocked(getBlock).mockReturnValue(
|
|
blockWith([
|
|
{ id: 'knowledgeBaseId', title: 'KB', type: 'knowledge-base-selector' },
|
|
{
|
|
id: 'documentId',
|
|
title: 'Doc',
|
|
type: 'document-selector',
|
|
dependsOn: ['knowledgeBaseId'],
|
|
},
|
|
])
|
|
)
|
|
const result = clearDependentsOnRemap(
|
|
{
|
|
knowledgeBaseId: {
|
|
id: 'knowledgeBaseId',
|
|
type: 'knowledge-base-selector',
|
|
value: 'kb-dst',
|
|
},
|
|
documentId: { id: 'documentId', type: 'document-selector', value: 'doc-src' },
|
|
},
|
|
'knowledge',
|
|
new Set(['knowledgeBaseId'])
|
|
)
|
|
expect(result.documentId.value).toBe('')
|
|
})
|
|
|
|
it('clearDependentsOnRemap: preserve holds when a SECOND remapped key also reaches the tool selector', () => {
|
|
// Synthetic config (no registry block wires this today): the tool selector hangs off BOTH a
|
|
// remapped mcp-server parent (preserve) and another remapped parent (no preserve). The
|
|
// selector-keyed preserve must win over the other key's clear, in either key order, while
|
|
// the other key's own non-exempt dependent still clears.
|
|
vi.mocked(getBlock).mockReturnValue(
|
|
blockWith([
|
|
{ id: 'server', title: 'MCP Server', type: 'mcp-server-selector' },
|
|
{ id: 'knowledgeBaseId', title: 'KB', type: 'knowledge-base-selector' },
|
|
{
|
|
id: 'tool',
|
|
title: 'Tool',
|
|
type: 'mcp-tool-selector',
|
|
dependsOn: ['server', 'knowledgeBaseId'],
|
|
},
|
|
{ id: 'arguments', title: '', type: 'mcp-dynamic-args', dependsOn: ['tool'] },
|
|
{
|
|
id: 'documentId',
|
|
title: 'Doc',
|
|
type: 'document-selector',
|
|
dependsOn: ['knowledgeBaseId'],
|
|
},
|
|
])
|
|
)
|
|
const subBlocks = (): SubBlockRecord => ({
|
|
server: { id: 'server', type: 'mcp-server-selector', value: 'mcp-tgt9' },
|
|
knowledgeBaseId: { id: 'knowledgeBaseId', type: 'knowledge-base-selector', value: 'kb-dst' },
|
|
tool: { id: 'tool', type: 'mcp-tool-selector', value: 'mcp-tgt9-search_docs' },
|
|
arguments: { id: 'arguments', type: 'mcp-dynamic-args', value: '{"query":"hello"}' },
|
|
documentId: { id: 'documentId', type: 'document-selector', value: 'doc-src' },
|
|
})
|
|
for (const keys of [
|
|
['server', 'knowledgeBaseId'],
|
|
['knowledgeBaseId', 'server'],
|
|
]) {
|
|
const result = clearDependentsOnRemap(subBlocks(), 'mcp', new Set(keys))
|
|
expect(result.tool.value).toBe('mcp-tgt9-search_docs')
|
|
expect(result.arguments.value).toBe('{"query":"hello"}')
|
|
expect(result.documentId.value).toBe('')
|
|
}
|
|
})
|
|
|
|
it('clearDependentsOnRemap: a CLEARED server alongside another remapped key still clears the tool', () => {
|
|
// Same two-key config, but the server was cleared (unmapped): no preserve applies anywhere,
|
|
// so the tool and its arguments clear as ordinary dependents.
|
|
vi.mocked(getBlock).mockReturnValue(
|
|
blockWith([
|
|
{ id: 'server', title: 'MCP Server', type: 'mcp-server-selector' },
|
|
{ id: 'knowledgeBaseId', title: 'KB', type: 'knowledge-base-selector' },
|
|
{
|
|
id: 'tool',
|
|
title: 'Tool',
|
|
type: 'mcp-tool-selector',
|
|
dependsOn: ['server', 'knowledgeBaseId'],
|
|
},
|
|
{ id: 'arguments', title: '', type: 'mcp-dynamic-args', dependsOn: ['tool'] },
|
|
])
|
|
)
|
|
const result = clearDependentsOnRemap(
|
|
{
|
|
server: { id: 'server', type: 'mcp-server-selector', value: '' },
|
|
knowledgeBaseId: {
|
|
id: 'knowledgeBaseId',
|
|
type: 'knowledge-base-selector',
|
|
value: 'kb-dst',
|
|
},
|
|
tool: { id: 'tool', type: 'mcp-tool-selector', value: 'mcp-src1-search_docs' },
|
|
arguments: { id: 'arguments', type: 'mcp-dynamic-args', value: '{"query":"hello"}' },
|
|
},
|
|
'mcp',
|
|
new Set(['server', 'knowledgeBaseId'])
|
|
)
|
|
expect(result.tool.value).toBe('')
|
|
expect(result.arguments.value).toBe('')
|
|
})
|
|
})
|
|
|
|
describe('tool-input MCP entry server remap rewrites embedded server metadata', () => {
|
|
const toolInputSubBlocks = (params: Record<string, unknown>): SubBlockRecord => ({
|
|
tools: {
|
|
id: 'tools',
|
|
type: 'tool-input',
|
|
value: [{ type: 'mcp', title: 'search', toolId: 'mcp-src1-search', params }],
|
|
},
|
|
})
|
|
const entryParams = () => ({
|
|
serverId: 'mcp-src1',
|
|
serverUrl: 'https://old.example/mcp',
|
|
toolName: 'search',
|
|
serverName: 'Old Server',
|
|
})
|
|
const mapServer = (kind: string, id: string) =>
|
|
kind === 'mcp-server' && id === 'mcp-src1' ? 'mcp-tgt9' : null
|
|
|
|
it('rewrites serverUrl/serverName from the mapped TARGET row; tool name verbatim, toolId rebuilt', () => {
|
|
const result = remapForkSubBlocks(toolInputSubBlocks(entryParams()), mapServer, 'promote', {
|
|
resolveMcpServerMeta: (targetServerId) =>
|
|
targetServerId === 'mcp-tgt9'
|
|
? { name: 'New Server', url: 'https://new.example/mcp' }
|
|
: undefined,
|
|
})
|
|
const [tool] = result.subBlocks.tools.value as Array<{
|
|
toolId: string
|
|
params: Record<string, unknown>
|
|
}>
|
|
expect(tool.params).toEqual({
|
|
serverId: 'mcp-tgt9',
|
|
serverUrl: 'https://new.example/mcp',
|
|
toolName: 'search',
|
|
serverName: 'New Server',
|
|
})
|
|
expect(tool.toolId).toBe('mcp-tgt9-search')
|
|
})
|
|
|
|
it('drops the stale serverUrl when the target server has no url', () => {
|
|
const result = remapForkSubBlocks(toolInputSubBlocks(entryParams()), mapServer, 'promote', {
|
|
resolveMcpServerMeta: () => ({ name: 'New Server', url: null }),
|
|
})
|
|
const [tool] = result.subBlocks.tools.value as Array<{ params: Record<string, unknown> }>
|
|
expect(tool.params).toEqual({
|
|
serverId: 'mcp-tgt9',
|
|
toolName: 'search',
|
|
serverName: 'New Server',
|
|
})
|
|
})
|
|
|
|
it('without a meta resolver (scan-only callers) the id remaps and metadata is left as-is', () => {
|
|
const result = remapForkSubBlocks(toolInputSubBlocks(entryParams()), mapServer, 'promote')
|
|
const [tool] = result.subBlocks.tools.value as Array<{
|
|
toolId: string
|
|
params: Record<string, unknown>
|
|
}>
|
|
expect(tool.params).toEqual({
|
|
serverId: 'mcp-tgt9',
|
|
serverUrl: 'https://old.example/mcp',
|
|
toolName: 'search',
|
|
serverName: 'Old Server',
|
|
})
|
|
expect(tool.toolId).toBe('mcp-tgt9-search')
|
|
})
|
|
|
|
it('threads the meta resolver through the sync transform', () => {
|
|
// Transform-level check: promote passes the batch-loaded target rows via options.
|
|
vi.mocked(getBlock).mockReturnValue(
|
|
blockWith([{ id: 'tools', title: 'Tools', type: 'tool-input' }])
|
|
)
|
|
const transform = createForkSubBlockTransform(mapServer, {
|
|
resolveMcpServerMeta: () => ({ name: 'New Server', url: 'https://new.example/mcp' }),
|
|
})
|
|
const result = transform(toolInputSubBlocks(entryParams()), 'agent')
|
|
const [tool] = result.tools.value as Array<{ params: Record<string, unknown> }>
|
|
expect(tool.params.serverUrl).toBe('https://new.example/mcp')
|
|
expect(tool.params.serverName).toBe('New Server')
|
|
})
|
|
})
|
|
|
|
describe('clearDependentsOnRemap canonical-pair gating', () => {
|
|
const kbCanonicalBlock = () =>
|
|
blockWith([
|
|
{
|
|
id: 'knowledgeBaseSelector',
|
|
title: 'KB',
|
|
type: 'knowledge-base-selector',
|
|
canonicalParamId: 'knowledgeBaseId',
|
|
mode: 'basic',
|
|
},
|
|
{
|
|
id: 'manualKnowledgeBaseId',
|
|
title: 'KB ID',
|
|
type: 'short-input',
|
|
canonicalParamId: 'knowledgeBaseId',
|
|
mode: 'advanced',
|
|
},
|
|
{
|
|
id: 'documentSelector',
|
|
title: 'Document',
|
|
type: 'document-selector',
|
|
dependsOn: ['knowledgeBaseSelector'],
|
|
},
|
|
])
|
|
|
|
it('does not clear a dependent when only the DORMANT basic selector was remapped (advanced active)', () => {
|
|
vi.mocked(getBlock).mockReturnValue(kbCanonicalBlock())
|
|
const subBlocks: SubBlockRecord = {
|
|
knowledgeBaseSelector: { type: 'knowledge-base-selector', value: '' },
|
|
manualKnowledgeBaseId: { type: 'short-input', value: 'kb-active' },
|
|
documentSelector: { type: 'document-selector', value: 'doc-1' },
|
|
}
|
|
const result = clearDependentsOnRemap(
|
|
subBlocks,
|
|
'knowledge',
|
|
new Set(['knowledgeBaseSelector']),
|
|
{
|
|
knowledgeBaseId: 'advanced',
|
|
}
|
|
)
|
|
// The active advanced parent is unchanged, so the dependent must be preserved.
|
|
expect(result.documentSelector.value).toBe('doc-1')
|
|
})
|
|
|
|
it('clears a dependent when the ACTIVE basic selector was remapped (basic active)', () => {
|
|
vi.mocked(getBlock).mockReturnValue(kbCanonicalBlock())
|
|
const subBlocks: SubBlockRecord = {
|
|
knowledgeBaseSelector: { type: 'knowledge-base-selector', value: 'kb-new' },
|
|
manualKnowledgeBaseId: { type: 'short-input', value: '' },
|
|
documentSelector: { type: 'document-selector', value: 'doc-1' },
|
|
}
|
|
const result = clearDependentsOnRemap(
|
|
subBlocks,
|
|
'knowledge',
|
|
new Set(['knowledgeBaseSelector']),
|
|
{
|
|
knowledgeBaseId: 'basic',
|
|
}
|
|
)
|
|
// Basic is active; its remap clears the dependent (unchanged behavior).
|
|
expect(result.documentSelector.value).toBe('')
|
|
})
|
|
})
|
|
|
|
describe('scanWorkflowReferences canonical-pair detection', () => {
|
|
const credBlock = () =>
|
|
blockWith([
|
|
{
|
|
id: 'credential',
|
|
title: 'Account',
|
|
type: 'oauth-input',
|
|
canonicalParamId: 'credential',
|
|
mode: 'basic',
|
|
},
|
|
{
|
|
id: 'manualCredential',
|
|
title: 'Account ID',
|
|
type: 'short-input',
|
|
canonicalParamId: 'credential',
|
|
mode: 'advanced',
|
|
},
|
|
])
|
|
// The advanced manualCredential is a short-input escape hatch (never scanned); the basic
|
|
// oauth-input is the detectable member, so the "active" assertion targets the basic mode.
|
|
const scanBlock = (canonicalModes?: Record<string, 'basic' | 'advanced'>) => ({
|
|
id: 'b1',
|
|
name: 'Send',
|
|
type: 'gmail',
|
|
canonicalModes,
|
|
subBlocks: {
|
|
credential: { id: 'credential', type: 'oauth-input', value: 'cred-stale' },
|
|
manualCredential: { id: 'manualCredential', type: 'short-input', value: 'cred-active' },
|
|
},
|
|
})
|
|
|
|
it('does not detect a DORMANT basic credential while advanced is active (no required ref / sync gate)', () => {
|
|
vi.mocked(getBlock).mockReturnValue(credBlock())
|
|
const scan = scanWorkflowReferences([scanBlock({ credential: 'advanced' })], () => null)
|
|
expect(scan.references.filter((ref) => ref.kind === 'credential')).toEqual([])
|
|
expect(scan.unmapped.filter((ref) => ref.kind === 'credential')).toEqual([])
|
|
})
|
|
|
|
it('detects the ACTIVE basic credential as a required reference (basic active)', () => {
|
|
vi.mocked(getBlock).mockReturnValue(credBlock())
|
|
const scan = scanWorkflowReferences([scanBlock({ credential: 'basic' })], () => null)
|
|
const creds = scan.references.filter((ref) => ref.kind === 'credential')
|
|
expect(creds).toHaveLength(1)
|
|
expect(creds[0].sourceId).toBe('cred-stale')
|
|
expect(creds[0].required).toBe(true)
|
|
})
|
|
|
|
it('skips DETECTION for a dormant member but still REWRITES its value (separation)', () => {
|
|
vi.mocked(getBlock).mockReturnValue(credBlock())
|
|
const result = remapForkSubBlocks(
|
|
{
|
|
credential: { id: 'credential', type: 'oauth-input', value: 'cred-stale' },
|
|
manualCredential: { id: 'manualCredential', type: 'short-input', value: 'cred-active' },
|
|
},
|
|
() => null,
|
|
'promote',
|
|
{ blockType: 'gmail', canonicalModes: { credential: 'advanced' } }
|
|
)
|
|
// Detection skipped (dormant basic), so it never gates sync...
|
|
expect(result.references.filter((ref) => ref.kind === 'credential')).toEqual([])
|
|
// ...but the dual-mode rewrite still cleared the unresolved dormant basic credential.
|
|
expect(result.subBlocks.credential.value).toBe('')
|
|
// The advanced escape-hatch id is preserved verbatim (not auto-remapped).
|
|
expect(result.subBlocks.manualCredential.value).toBe('cred-active')
|
|
})
|
|
})
|
|
|
|
describe('collectClearedDependents', () => {
|
|
it('flags a required dependent the target had set but the merge left empty', () => {
|
|
vi.mocked(getBlock).mockReturnValue(
|
|
blockWith([
|
|
{ id: 'credential', title: 'Credential', type: 'oauth-input' },
|
|
{
|
|
id: 'folder',
|
|
title: 'Label',
|
|
type: 'folder-selector',
|
|
dependsOn: ['credential'],
|
|
required: true,
|
|
},
|
|
])
|
|
)
|
|
const targetDraft: SubBlockRecord = {
|
|
credential: entry('credential', 'oauth-input', 'c-target'),
|
|
folder: entry('folder', 'folder-selector', 'INBOX'),
|
|
}
|
|
const merged: SubBlockRecord = {
|
|
credential: entry('credential', 'oauth-input', 'c-new'),
|
|
folder: entry('folder', 'folder-selector', ''),
|
|
}
|
|
expect(collectClearedDependents('gmail', 'b1', 'Send Email', targetDraft, merged)).toEqual([
|
|
{
|
|
blockId: 'b1',
|
|
blockName: 'Send Email',
|
|
subBlockKey: 'folder',
|
|
title: 'Label',
|
|
required: true,
|
|
},
|
|
])
|
|
})
|
|
|
|
it('returns an optional cleared dependent flagged required:false', () => {
|
|
vi.mocked(getBlock).mockReturnValue(
|
|
blockWith([
|
|
{ id: 'credential', title: 'Credential', type: 'oauth-input' },
|
|
{ id: 'folder', title: 'Label', type: 'folder-selector', dependsOn: ['credential'] },
|
|
])
|
|
)
|
|
const targetDraft: SubBlockRecord = {
|
|
credential: entry('credential', 'oauth-input', 'c-target'),
|
|
folder: entry('folder', 'folder-selector', 'INBOX'),
|
|
}
|
|
const merged: SubBlockRecord = {
|
|
credential: entry('credential', 'oauth-input', 'c-new'),
|
|
folder: entry('folder', 'folder-selector', ''),
|
|
}
|
|
expect(collectClearedDependents('gmail', 'b1', 'Send Email', targetDraft, merged)).toEqual([
|
|
{
|
|
blockId: 'b1',
|
|
blockName: 'Send Email',
|
|
subBlockKey: 'folder',
|
|
title: 'Label',
|
|
required: false,
|
|
},
|
|
])
|
|
})
|
|
|
|
it('does not flag a dependent the target never configured (only the source carried it)', () => {
|
|
vi.mocked(getBlock).mockReturnValue(
|
|
blockWith([
|
|
{ id: 'credential', title: 'Credential', type: 'oauth-input' },
|
|
{ id: 'folder', title: 'Label', type: 'folder-selector', dependsOn: ['credential'] },
|
|
])
|
|
)
|
|
// The target's fork never set this label, so the merge leaving it empty is not a loss -
|
|
// this is the pull case where the parent carried a filter the fork never had.
|
|
const targetDraft: SubBlockRecord = {
|
|
credential: entry('credential', 'oauth-input', 'c-target'),
|
|
folder: entry('folder', 'folder-selector', ''),
|
|
}
|
|
const merged: SubBlockRecord = {
|
|
credential: entry('credential', 'oauth-input', 'c-new'),
|
|
folder: entry('folder', 'folder-selector', ''),
|
|
}
|
|
expect(collectClearedDependents('gmail', 'b1', 'Send Email', targetDraft, merged)).toEqual([])
|
|
})
|
|
|
|
it('does not flag a dependent that ended up with a value (preserved or overridden)', () => {
|
|
vi.mocked(getBlock).mockReturnValue(
|
|
blockWith([
|
|
{ id: 'credential', title: 'Credential', type: 'oauth-input' },
|
|
{
|
|
id: 'folder',
|
|
title: 'Label',
|
|
type: 'folder-selector',
|
|
dependsOn: ['credential'],
|
|
required: true,
|
|
},
|
|
])
|
|
)
|
|
const targetDraft: SubBlockRecord = {
|
|
credential: entry('credential', 'oauth-input', 'c-target'),
|
|
folder: entry('folder', 'folder-selector', 'INBOX'),
|
|
}
|
|
const merged: SubBlockRecord = {
|
|
credential: entry('credential', 'oauth-input', 'c-target'),
|
|
folder: entry('folder', 'folder-selector', 'INBOX'),
|
|
}
|
|
expect(collectClearedDependents('gmail', 'b1', 'Send Email', targetDraft, merged)).toEqual([])
|
|
})
|
|
|
|
it('does not flag a cleared dependent gated off by its condition', () => {
|
|
vi.mocked(getBlock).mockReturnValue(
|
|
blockWith([
|
|
{ id: 'credential', title: 'Credential', type: 'oauth-input' },
|
|
{ id: 'operation', title: 'Operation', type: 'dropdown' },
|
|
{
|
|
id: 'folder',
|
|
title: 'Label',
|
|
type: 'folder-selector',
|
|
dependsOn: ['credential'],
|
|
required: true,
|
|
condition: { field: 'operation', value: 'read' },
|
|
},
|
|
])
|
|
)
|
|
// The operation is 'send', so the read-only folder field is inactive - a stale value
|
|
// it carried must not be flagged as required.
|
|
const targetDraft: SubBlockRecord = {
|
|
credential: entry('credential', 'oauth-input', 'c-target'),
|
|
operation: entry('operation', 'dropdown', 'send'),
|
|
folder: entry('folder', 'folder-selector', 'INBOX'),
|
|
}
|
|
const merged: SubBlockRecord = {
|
|
credential: entry('credential', 'oauth-input', 'c-new'),
|
|
operation: entry('operation', 'dropdown', 'send'),
|
|
folder: entry('folder', 'folder-selector', ''),
|
|
}
|
|
expect(collectClearedDependents('gmail', 'b1', 'Send Email', targetDraft, merged)).toEqual([])
|
|
})
|
|
|
|
it('flags a cleared dependent nested inside a tool-input tool', () => {
|
|
vi.mocked(getBlock).mockImplementation((type) => {
|
|
if (type === 'agent') return blockWith([{ id: 'tools', title: 'Tools', type: 'tool-input' }])
|
|
if (type === 'gmail')
|
|
return blockWith([
|
|
{ id: 'credential', title: 'Credential', type: 'oauth-input' },
|
|
{
|
|
id: 'folder',
|
|
title: 'Label',
|
|
type: 'folder-selector',
|
|
dependsOn: ['credential'],
|
|
required: true,
|
|
},
|
|
])
|
|
return undefined as unknown as BlockConfig
|
|
})
|
|
const targetDraft: SubBlockRecord = {
|
|
tools: entry('tools', 'tool-input', [
|
|
{ type: 'gmail', title: 'Gmail', params: { credential: 'c-target', folder: 'INBOX' } },
|
|
]),
|
|
}
|
|
const merged: SubBlockRecord = {
|
|
tools: entry('tools', 'tool-input', [
|
|
{ type: 'gmail', title: 'Gmail', params: { credential: 'c-new', folder: '' } },
|
|
]),
|
|
}
|
|
expect(collectClearedDependents('agent', 'b1', 'Agent', targetDraft, merged)).toEqual([
|
|
{
|
|
blockId: 'b1',
|
|
blockName: 'Agent',
|
|
subBlockKey: 'tools[0].folder',
|
|
title: 'Label',
|
|
toolName: 'Gmail',
|
|
required: true,
|
|
},
|
|
])
|
|
})
|
|
})
|
|
|
|
describe('applyDependentOverrides', () => {
|
|
const gmailConfig = () =>
|
|
blockWith([
|
|
{ id: 'credential', title: 'Credential', type: 'oauth-input' },
|
|
{
|
|
id: 'folder',
|
|
title: 'Label',
|
|
type: 'folder-selector',
|
|
dependsOn: ['credential'],
|
|
selectorKey: 'gmail.labels',
|
|
},
|
|
])
|
|
|
|
it('applies a top-level re-pick value', () => {
|
|
vi.mocked(getBlock).mockReturnValue(gmailConfig())
|
|
const subBlocks: SubBlockRecord = {
|
|
credential: entry('credential', 'oauth-input', 'c-new'),
|
|
folder: entry('folder', 'folder-selector', ''),
|
|
}
|
|
const result = applyDependentOverrides(subBlocks, 'gmail', new Map([['folder', 'Label_42']]))
|
|
expect((result.folder as { value: unknown }).value).toBe('Label_42')
|
|
})
|
|
|
|
it('rejects an override for a non-dependent / parent key (allowlist)', () => {
|
|
vi.mocked(getBlock).mockReturnValue(gmailConfig())
|
|
const subBlocks: SubBlockRecord = {
|
|
credential: entry('credential', 'oauth-input', 'c-new'),
|
|
folder: entry('folder', 'folder-selector', ''),
|
|
}
|
|
// 'credential' is a parent (no selectorKey) - must never be writable via override.
|
|
const result = applyDependentOverrides(subBlocks, 'gmail', new Map([['credential', 'evil']]))
|
|
expect(result).toBe(subBlocks)
|
|
expect((subBlocks.credential as { value: unknown }).value).toBe('c-new')
|
|
})
|
|
|
|
it('applies a nested tool-input re-pick onto the matching tool param', () => {
|
|
vi.mocked(getBlock).mockImplementation((type) => {
|
|
if (type === 'agent') return blockWith([{ id: 'tools', title: 'Tools', type: 'tool-input' }])
|
|
if (type === 'gmail') return gmailConfig()
|
|
return undefined as unknown as BlockConfig
|
|
})
|
|
const subBlocks: SubBlockRecord = {
|
|
tools: entry('tools', 'tool-input', [
|
|
{ type: 'gmail', title: 'Gmail', params: { credential: 'c-new', folder: '' } },
|
|
]),
|
|
}
|
|
const result = applyDependentOverrides(
|
|
subBlocks,
|
|
'agent',
|
|
new Map([['tools[0].folder', 'Label_99']])
|
|
)
|
|
const tools = (result.tools as { value: Array<{ params: { folder: string } }> }).value
|
|
expect(tools[0].params.folder).toBe('Label_99')
|
|
})
|
|
|
|
it('rejects a nested override for a non-allowlisted tool param', () => {
|
|
vi.mocked(getBlock).mockImplementation((type) => {
|
|
if (type === 'agent') return blockWith([{ id: 'tools', title: 'Tools', type: 'tool-input' }])
|
|
if (type === 'gmail') return gmailConfig()
|
|
return undefined as unknown as BlockConfig
|
|
})
|
|
const subBlocks: SubBlockRecord = {
|
|
tools: entry('tools', 'tool-input', [
|
|
{ type: 'gmail', title: 'Gmail', params: { credential: 'c-new', folder: '' } },
|
|
]),
|
|
}
|
|
// 'credential' inside the tool is a parent - not overridable.
|
|
const result = applyDependentOverrides(
|
|
subBlocks,
|
|
'agent',
|
|
new Map([['tools[0].credential', 'evil']])
|
|
)
|
|
expect(result).toBe(subBlocks)
|
|
})
|
|
|
|
it('ignores a nested override whose tool index is out of range', () => {
|
|
vi.mocked(getBlock).mockImplementation((type) => {
|
|
if (type === 'agent') return blockWith([{ id: 'tools', title: 'Tools', type: 'tool-input' }])
|
|
if (type === 'gmail') return gmailConfig()
|
|
return undefined as unknown as BlockConfig
|
|
})
|
|
const subBlocks: SubBlockRecord = {
|
|
tools: entry('tools', 'tool-input', [
|
|
{ type: 'gmail', title: 'Gmail', params: { credential: 'c-new', folder: '' } },
|
|
]),
|
|
}
|
|
const result = applyDependentOverrides(
|
|
subBlocks,
|
|
'agent',
|
|
new Map([['tools[5].folder', 'Label_99']])
|
|
)
|
|
expect(result).toBe(subBlocks)
|
|
})
|
|
})
|
|
|
|
describe('parseNestedDependentKey', () => {
|
|
it('parses a nested tool-input key with a numeric index', () => {
|
|
expect(parseNestedDependentKey('tools[0].folder')).toEqual({
|
|
toolInputId: 'tools',
|
|
index: 0,
|
|
paramId: 'folder',
|
|
})
|
|
expect(parseNestedDependentKey('tools[12].channel')).toEqual({
|
|
toolInputId: 'tools',
|
|
index: 12,
|
|
paramId: 'channel',
|
|
})
|
|
})
|
|
|
|
it('returns null for a plain top-level key', () => {
|
|
expect(parseNestedDependentKey('folder')).toBeNull()
|
|
expect(parseNestedDependentKey('credential')).toBeNull()
|
|
})
|
|
})
|
|
|
|
describe('readTargetDraftDependentValue', () => {
|
|
it('reads a top-level draft value', () => {
|
|
const draft: SubBlockRecord = { folder: { value: 'INBOX' } }
|
|
expect(readTargetDraftDependentValue(draft, undefined, 'folder')).toBe('INBOX')
|
|
})
|
|
|
|
it('returns empty for a missing or non-string top-level value', () => {
|
|
expect(readTargetDraftDependentValue({ folder: { value: 42 } }, undefined, 'folder')).toBe('')
|
|
expect(readTargetDraftDependentValue({}, undefined, 'folder')).toBe('')
|
|
expect(readTargetDraftDependentValue(undefined, undefined, 'folder')).toBe('')
|
|
})
|
|
|
|
it('reads the target draft nested param when the source/target tool types match at that index', () => {
|
|
const target: SubBlockRecord = {
|
|
tools: { value: [{ type: 'gmail', params: { folder: 'INBOX' } }] },
|
|
}
|
|
const source: SubBlockRecord = {
|
|
tools: { value: [{ type: 'gmail', params: { folder: 'SENT' } }] },
|
|
}
|
|
// Reads the TARGET draft's value (INBOX), gated on a same-type tool at the index.
|
|
expect(readTargetDraftDependentValue(target, source, 'tools[0].folder')).toBe('INBOX')
|
|
})
|
|
|
|
it('identity guard: returns empty when the target draft tool type differs from the source dependent tool', () => {
|
|
// The source dependent hangs off a Gmail tool at index 0, but the target draft holds a Slack
|
|
// tool there - its param value is not this field's value, so nothing is seeded.
|
|
const target: SubBlockRecord = {
|
|
tools: { value: [{ type: 'slack', params: { folder: 'INBOX' } }] },
|
|
}
|
|
const source: SubBlockRecord = {
|
|
tools: { value: [{ type: 'gmail', params: { folder: 'SENT' } }] },
|
|
}
|
|
expect(readTargetDraftDependentValue(target, source, 'tools[0].folder')).toBe('')
|
|
})
|
|
|
|
it('returns empty when the target draft has no tool at the index', () => {
|
|
const target: SubBlockRecord = { tools: { value: [] } }
|
|
const source: SubBlockRecord = {
|
|
tools: { value: [{ type: 'gmail', params: { folder: 'SENT' } }] },
|
|
}
|
|
expect(readTargetDraftDependentValue(target, source, 'tools[0].folder')).toBe('')
|
|
})
|
|
|
|
it('returns empty when the source tool type cannot be verified', () => {
|
|
const target: SubBlockRecord = {
|
|
tools: { value: [{ type: 'gmail', params: { folder: 'INBOX' } }] },
|
|
}
|
|
// No source subBlocks (or no tool at the index) -> identity unverifiable -> do not seed.
|
|
expect(readTargetDraftDependentValue(target, undefined, 'tools[0].folder')).toBe('')
|
|
expect(readTargetDraftDependentValue(target, { tools: { value: [] } }, 'tools[0].folder')).toBe(
|
|
''
|
|
)
|
|
})
|
|
|
|
it('handles the JSON-string stored tool array shape', () => {
|
|
const target: SubBlockRecord = {
|
|
tools: { value: JSON.stringify([{ type: 'gmail', params: { folder: 'INBOX' } }]) },
|
|
}
|
|
const source: SubBlockRecord = {
|
|
tools: { value: JSON.stringify([{ type: 'gmail', params: { folder: 'SENT' } }]) },
|
|
}
|
|
expect(readTargetDraftDependentValue(target, source, 'tools[0].folder')).toBe('INBOX')
|
|
})
|
|
})
|
|
|
|
/** The knowledge block's canonical shape: KB + document pairs, tag fields as KB dependents. */
|
|
const knowledgePairBlock = () =>
|
|
blockWith([
|
|
{
|
|
id: 'knowledgeBaseSelector',
|
|
title: 'KB',
|
|
type: 'knowledge-base-selector',
|
|
canonicalParamId: 'knowledgeBaseId',
|
|
mode: 'basic',
|
|
},
|
|
{
|
|
id: 'manualKnowledgeBaseId',
|
|
title: 'KB ID',
|
|
type: 'short-input',
|
|
canonicalParamId: 'knowledgeBaseId',
|
|
mode: 'advanced',
|
|
},
|
|
{
|
|
id: 'documentSelector',
|
|
title: 'Document',
|
|
type: 'document-selector',
|
|
dependsOn: ['knowledgeBaseSelector'],
|
|
},
|
|
{
|
|
id: 'tagFilters',
|
|
title: 'Tag Filters',
|
|
type: 'knowledge-tag-filters',
|
|
dependsOn: ['knowledgeBaseSelector'],
|
|
},
|
|
{
|
|
id: 'documentTags',
|
|
title: 'Document Tags',
|
|
type: 'document-tag-entry',
|
|
dependsOn: ['knowledgeBaseSelector'],
|
|
},
|
|
])
|
|
|
|
describe('canonical mode policy (fork/promote)', () => {
|
|
const copyMap: Record<string, string> = {
|
|
'knowledge-base:kb-src': 'kb-copy',
|
|
'knowledge-document:doc-src': 'doc-copy',
|
|
}
|
|
const resolveCopy = (kind: string, id: string) => copyMap[`${kind}:${id}`] ?? null
|
|
|
|
it('basic mode: remaps the selector + document, preserves tag fields, clears the dormant manual member', () => {
|
|
vi.mocked(getBlock).mockReturnValue(knowledgePairBlock())
|
|
const transform = createForkBootstrapTransform(resolveCopy as never)
|
|
const result = transform(
|
|
{
|
|
knowledgeBaseSelector: entry('knowledgeBaseSelector', 'knowledge-base-selector', 'kb-src'),
|
|
manualKnowledgeBaseId: entry('manualKnowledgeBaseId', 'short-input', 'stale-manual-kb'),
|
|
documentSelector: entry('documentSelector', 'document-selector', 'doc-src'),
|
|
tagFilters: entry('tagFilters', 'knowledge-tag-filters', '[{"tagName":"team"}]'),
|
|
documentTags: entry('documentTags', 'document-tag-entry', '[{"tagName":"team"}]'),
|
|
},
|
|
'knowledge',
|
|
{ knowledgeBaseId: 'basic' }
|
|
)
|
|
expect(result.knowledgeBaseSelector.value).toBe('kb-copy')
|
|
expect(result.documentSelector.value).toBe('doc-copy')
|
|
// Name/slot-based tag fields stay valid on the copy (tag definitions copy verbatim).
|
|
expect(result.tagFilters.value).toBe('[{"tagName":"team"}]')
|
|
expect(result.documentTags.value).toBe('[{"tagName":"team"}]')
|
|
// Only the active mode matters: the dormant manual member's stale value is cleared.
|
|
expect(result.manualKnowledgeBaseId.value).toBe('')
|
|
})
|
|
|
|
it('advanced (manual) mode: passes the manual value + its dependents through verbatim, clears the dormant selector', () => {
|
|
vi.mocked(getBlock).mockReturnValue(knowledgePairBlock())
|
|
const transform = createForkBootstrapTransform(resolveCopy as never)
|
|
const result = transform(
|
|
{
|
|
knowledgeBaseSelector: entry('knowledgeBaseSelector', 'knowledge-base-selector', 'kb-src'),
|
|
manualKnowledgeBaseId: entry('manualKnowledgeBaseId', 'short-input', 'kb-manual'),
|
|
documentSelector: entry('documentSelector', 'document-selector', 'doc-src'),
|
|
tagFilters: entry('tagFilters', 'knowledge-tag-filters', '[{"tagName":"team"}]'),
|
|
},
|
|
'knowledge',
|
|
{ knowledgeBaseId: 'advanced' }
|
|
)
|
|
// The manual value is user-owned: kept verbatim, never remapped.
|
|
expect(result.manualKnowledgeBaseId.value).toBe('kb-manual')
|
|
// Its dependents ride along verbatim too - no remap, no clear.
|
|
expect(result.documentSelector.value).toBe('doc-src')
|
|
expect(result.tagFilters.value).toBe('[{"tagName":"team"}]')
|
|
// The dormant basic selector is cleared outright (not remapped to the copy).
|
|
expect(result.knowledgeBaseSelector.value).toBe('')
|
|
})
|
|
|
|
it('advanced mode: nothing is detected as a reference (no mapping requirement)', () => {
|
|
vi.mocked(getBlock).mockReturnValue(knowledgePairBlock())
|
|
const scan = scanWorkflowReferences(
|
|
[
|
|
{
|
|
id: 'b1',
|
|
name: 'KB',
|
|
type: 'knowledge',
|
|
subBlocks: {
|
|
knowledgeBaseSelector: entry(
|
|
'knowledgeBaseSelector',
|
|
'knowledge-base-selector',
|
|
'kb-src'
|
|
),
|
|
manualKnowledgeBaseId: entry('manualKnowledgeBaseId', 'short-input', 'kb-manual'),
|
|
documentSelector: entry('documentSelector', 'document-selector', 'doc-src'),
|
|
},
|
|
canonicalModes: { knowledgeBaseId: 'advanced' },
|
|
},
|
|
],
|
|
() => null
|
|
)
|
|
expect(scan.references).toEqual([])
|
|
})
|
|
|
|
it('does not detect a condition-hidden subblock (its value never executes)', () => {
|
|
vi.mocked(getBlock).mockReturnValue(
|
|
blockWith([
|
|
{ id: 'mode', title: 'Mode', type: 'dropdown' },
|
|
{
|
|
id: 'cloudKb',
|
|
title: 'Cloud KB',
|
|
type: 'knowledge-base-selector',
|
|
condition: { field: 'mode', value: 'cloud' },
|
|
},
|
|
{
|
|
id: 'localKb',
|
|
title: 'Local KB',
|
|
type: 'knowledge-base-selector',
|
|
condition: { field: 'mode', value: 'local' },
|
|
},
|
|
])
|
|
)
|
|
const scan = scanWorkflowReferences(
|
|
[
|
|
{
|
|
id: 'b1',
|
|
name: 'Pi',
|
|
type: 'pi',
|
|
subBlocks: {
|
|
mode: entry('mode', 'dropdown', 'local'),
|
|
cloudKb: entry('cloudKb', 'knowledge-base-selector', 'kb-hidden'),
|
|
localKb: entry('localKb', 'knowledge-base-selector', 'kb-active'),
|
|
},
|
|
},
|
|
],
|
|
() => null
|
|
)
|
|
expect(scan.references.map((ref) => ref.sourceId)).toEqual(['kb-active'])
|
|
})
|
|
|
|
it('nested tool: remaps a canonical-keyed param (and both keys when aliased)', () => {
|
|
const tool = {
|
|
type: 'tblblock',
|
|
toolId: 'tblblock_run',
|
|
params: { tableId: 'tbl-src', tableSelector: 'tbl-src' },
|
|
}
|
|
const result = remapToolBlockResources(tool, {
|
|
resolve: (kind, id) => (kind === 'table' && id === 'tbl-src' ? 'tbl-dst' : null),
|
|
resolveFileKey: () => null,
|
|
clearUnresolved: true,
|
|
blockConfigs: {
|
|
tblblock: {
|
|
subBlocks: [
|
|
{
|
|
id: 'tableSelector',
|
|
title: 'Table',
|
|
type: 'table-selector',
|
|
canonicalParamId: 'tableId',
|
|
mode: 'basic',
|
|
},
|
|
{
|
|
id: 'manualTableId',
|
|
title: 'Table ID',
|
|
type: 'short-input',
|
|
canonicalParamId: 'tableId',
|
|
mode: 'advanced',
|
|
},
|
|
],
|
|
},
|
|
},
|
|
})
|
|
expect(result.params).toEqual({ tableId: 'tbl-dst', tableSelector: 'tbl-dst' })
|
|
})
|
|
|
|
it('nested tool: keeps a reference-shaped canonical value verbatim (user-owned)', () => {
|
|
const tool = {
|
|
type: 'tblblock',
|
|
toolId: 'tblblock_run',
|
|
params: { tableId: '<start.tableId>' },
|
|
}
|
|
const result = remapToolBlockResources(tool, {
|
|
resolve: () => null,
|
|
resolveFileKey: () => null,
|
|
clearUnresolved: true,
|
|
blockConfigs: {
|
|
tblblock: {
|
|
subBlocks: [
|
|
{
|
|
id: 'tableSelector',
|
|
title: 'Table',
|
|
type: 'table-selector',
|
|
canonicalParamId: 'tableId',
|
|
},
|
|
],
|
|
},
|
|
},
|
|
})
|
|
expect(result).toBe(tool)
|
|
})
|
|
|
|
it('nested tool: preserves tag filters under a remapped parent, clears them under a cleared parent', () => {
|
|
const kbToolConfigs = {
|
|
kbblock: {
|
|
subBlocks: [
|
|
{ id: 'knowledgeBaseId', title: 'KB', type: 'knowledge-base-selector' },
|
|
{
|
|
id: 'tagFilters',
|
|
title: 'Tag Filters',
|
|
type: 'knowledge-tag-filters',
|
|
dependsOn: ['knowledgeBaseId'],
|
|
},
|
|
] as SubBlockConfig[],
|
|
},
|
|
}
|
|
const tool = () => ({
|
|
type: 'kbblock',
|
|
toolId: 'kbblock_run',
|
|
params: { knowledgeBaseId: 'kb-src', tagFilters: '[{"tagName":"team"}]' },
|
|
})
|
|
const remapped = remapToolBlockResources(tool(), {
|
|
resolve: (kind, id) => (kind === 'knowledge-base' && id === 'kb-src' ? 'kb-dst' : null),
|
|
resolveFileKey: () => null,
|
|
clearUnresolved: true,
|
|
blockConfigs: kbToolConfigs,
|
|
})
|
|
expect(remapped.params).toEqual({
|
|
knowledgeBaseId: 'kb-dst',
|
|
tagFilters: '[{"tagName":"team"}]',
|
|
})
|
|
const cleared = remapToolBlockResources(tool(), {
|
|
resolve: () => null,
|
|
resolveFileKey: () => null,
|
|
clearUnresolved: true,
|
|
blockConfigs: kbToolConfigs,
|
|
})
|
|
expect(cleared.params).toEqual({ knowledgeBaseId: '', tagFilters: '' })
|
|
})
|
|
|
|
it('preserves a column selection under a COPIED table, clears it under a mapped one', () => {
|
|
const tableBlock = () =>
|
|
blockWith([
|
|
{
|
|
id: 'tableSelector',
|
|
title: 'Table',
|
|
type: 'table-selector',
|
|
canonicalParamId: 'tableId',
|
|
mode: 'basic',
|
|
},
|
|
{
|
|
id: 'manualTableId',
|
|
title: 'Table ID',
|
|
type: 'short-input',
|
|
canonicalParamId: 'tableId',
|
|
mode: 'advanced',
|
|
},
|
|
{
|
|
id: 'conflictColumnSelector',
|
|
title: 'Conflict Column',
|
|
type: 'column-selector',
|
|
dependsOn: ['tableSelector'],
|
|
},
|
|
])
|
|
const subBlocks = (): SubBlockRecord => ({
|
|
tableSelector: entry('tableSelector', 'table-selector', 'tbl-src'),
|
|
conflictColumnSelector: entry('conflictColumnSelector', 'column-selector', 'col_a'),
|
|
})
|
|
vi.mocked(getBlock).mockReturnValue(tableBlock())
|
|
// Fork-create: the table is a COPY (identical column ids) - the column pick survives.
|
|
const forkTransform = createForkBootstrapTransform(((kind: string, id: string) =>
|
|
kind === 'table' && id === 'tbl-src' ? 'tbl-copy' : null) as never)
|
|
const forked = forkTransform(subBlocks(), 'table')
|
|
expect(forked.tableSelector.value).toBe('tbl-copy')
|
|
expect(forked.conflictColumnSelector.value).toBe('col_a')
|
|
// Promote onto a MAPPED (different) table: column ids differ - the pick clears (re-pick flow).
|
|
const mappedTransform = createForkSubBlockTransform((kind, id) =>
|
|
kind === 'table' && id === 'tbl-src' ? 'tbl-mapped' : null
|
|
)
|
|
const mapped = mappedTransform(subBlocks(), 'table')
|
|
expect(mapped.tableSelector.value).toBe('tbl-mapped')
|
|
expect(mapped.conflictColumnSelector.value).toBe('')
|
|
})
|
|
|
|
it('collectClearedDependents skips a dormant canonical member (only the active mode matters)', () => {
|
|
vi.mocked(getBlock).mockReturnValue(knowledgePairBlock())
|
|
const targetDraft: SubBlockRecord = {
|
|
knowledgeBaseSelector: entry('knowledgeBaseSelector', 'knowledge-base-selector', 'kb-old'),
|
|
manualKnowledgeBaseId: entry('manualKnowledgeBaseId', 'short-input', 'stale-manual'),
|
|
documentSelector: entry('documentSelector', 'document-selector', 'doc-old'),
|
|
}
|
|
// The merge cleared the dormant manual member and the document; only the document (an
|
|
// active dependent) is flagged - the dormant manual slot is not a lost configuration.
|
|
const merged: SubBlockRecord = {
|
|
knowledgeBaseSelector: entry('knowledgeBaseSelector', 'knowledge-base-selector', 'kb-new'),
|
|
manualKnowledgeBaseId: entry('manualKnowledgeBaseId', 'short-input', ''),
|
|
documentSelector: entry('documentSelector', 'document-selector', ''),
|
|
}
|
|
const fields = collectClearedDependents('knowledge', 'b1', 'KB', targetDraft, merged, {
|
|
knowledgeBaseId: 'basic',
|
|
})
|
|
expect(fields.map((field) => field.subBlockKey)).toEqual(['documentSelector'])
|
|
})
|
|
})
|