mirror of
https://github.com/simstudioai/sim.git
synced 2026-08-30 17:05:18 +08:00
fix(search): match block references by the name the canvas shows (#6779)
* fix(search): match block references by the name the canvas shows A block reference stores its target as the block's normalized name - lowercased with whitespace and dots stripped - so a block titled "Send Email" is written `<sendemail.content>`. Workflow search indexed that token as-is, so its searchable text never contained the block's actual name. Searching a name the way it reads on the card therefore found the block itself and none of its references, while the run-together form found the references and not the block. No single query could find both, and the run-together form is the one nothing in the UI ever shows. Resolve a reference's prefix back through the same helper that produced it, so the reference is searched under the name the block is titled with. `rawValue` is untouched, so the stored form keeps matching and the highlight and replace paths, which key off it, are unaffected. Environment references, system prefixes like `loop`, and references left behind by a deleted block resolve to no name and stay exactly as written. * fix(search): keep the dot-free name on a legacy reference-prefix collision Creating or renaming a block enforces uniqueness at the normalized level, but legacy workflows can still hold two names that collide only now that `normalizeName` strips dots. `BlockResolver` settles that tie by letting the dot-free name keep ownership of the key, so previously working references never change targets. The prefix map took whichever block was iterated last instead, so search could name a reference after the dotted block while execution resolved it to the dot-free one - search reporting the wrong block, which is what this is meant to stop. Mirror the resolver's rule so both agree.
This commit is contained in:
committed by
GitHub
parent
2732ab71e9
commit
5d172b4e94
@@ -127,6 +127,113 @@ describe('indexWorkflowSearchMatches', () => {
|
||||
expect(matches.some((match) => match.target.kind === 'block-name')).toBe(false)
|
||||
})
|
||||
|
||||
describe('block references search under the name the canvas shows', () => {
|
||||
/**
|
||||
* The panel's own pipeline: index everything, then keep what the query
|
||||
* matches. Block references resolve no label of their own, so they reach the
|
||||
* filter with `displayLabel` fallen back to the raw token, as the hydration
|
||||
* hook leaves them.
|
||||
*/
|
||||
function findReferenceMatches(query: string) {
|
||||
const workflow = createSearchReplaceWorkflowFixture()
|
||||
workflow.blocks['agent-1'].subBlocks.systemPrompt.value =
|
||||
'Summarize <api1.output> and <deletedblock.output>, then loop <loop.index>.'
|
||||
|
||||
return indexWorkflowSearchMatches({
|
||||
workflow,
|
||||
query,
|
||||
mode: 'all',
|
||||
includeResourceMatchesWithoutQuery: true,
|
||||
blockConfigs: SEARCH_REPLACE_BLOCK_CONFIGS,
|
||||
})
|
||||
.filter((match) => match.kind === 'workflow-reference')
|
||||
.filter((match) =>
|
||||
workflowSearchMatchMatchesQuery({ ...match, displayLabel: match.rawValue }, query)
|
||||
)
|
||||
}
|
||||
|
||||
it('matches a reference by the spaced block name', () => {
|
||||
expect(findReferenceMatches('API 1').map((match) => match.rawValue)).toEqual([
|
||||
'<api1.output>',
|
||||
])
|
||||
})
|
||||
|
||||
it('still matches a reference by the token as stored', () => {
|
||||
expect(findReferenceMatches('api1').map((match) => match.rawValue)).toEqual(['<api1.output>'])
|
||||
})
|
||||
|
||||
it('reads the resolved name back as the block is titled', () => {
|
||||
const [match] = findReferenceMatches('API 1')
|
||||
|
||||
expect(match.searchText).toBe('API 1.output')
|
||||
expect(match.rawValue).toBe('<api1.output>')
|
||||
expect(match.range).toEqual({ start: 10, end: 23 })
|
||||
})
|
||||
|
||||
it('leaves a prefix that names no block as written', () => {
|
||||
const matches = indexWorkflowSearchMatches({
|
||||
workflow: (() => {
|
||||
const workflow = createSearchReplaceWorkflowFixture()
|
||||
workflow.blocks['agent-1'].subBlocks.systemPrompt.value =
|
||||
'Summarize <api1.output> and <deletedblock.output>, then loop <loop.index>.'
|
||||
return workflow
|
||||
})(),
|
||||
mode: 'all',
|
||||
includeResourceMatchesWithoutQuery: true,
|
||||
blockConfigs: SEARCH_REPLACE_BLOCK_CONFIGS,
|
||||
})
|
||||
|
||||
expect(
|
||||
matches
|
||||
.filter((match) => match.kind === 'workflow-reference')
|
||||
.map((match) => match.searchText)
|
||||
).toEqual(['API 1.output', 'deletedblock.output', 'loop.index'])
|
||||
})
|
||||
|
||||
it('leaves an environment reference keyed by its variable name', () => {
|
||||
const matches = indexWorkflowSearchMatches({
|
||||
workflow: createSearchReplaceWorkflowFixture(),
|
||||
mode: 'all',
|
||||
includeResourceMatchesWithoutQuery: true,
|
||||
blockConfigs: SEARCH_REPLACE_BLOCK_CONFIGS,
|
||||
})
|
||||
|
||||
expect(
|
||||
matches.filter((match) => match.kind === 'environment').map((match) => match.searchText)
|
||||
).toEqual(['OLD_SECRET', 'OLD_SECRET'])
|
||||
})
|
||||
|
||||
/**
|
||||
* Legacy workflows can hold two names that collide only now that
|
||||
* `normalizeName` strips dots. `BlockResolver` gives the key to the dot-free
|
||||
* name whichever order the blocks arrive in, so search has to name the same
|
||||
* block or it would label the reference with a title that block does not own
|
||||
* at execution time.
|
||||
*/
|
||||
it.each([
|
||||
['dotted first', ['Hunter.io 1', 'Hunterio 1']],
|
||||
['dot-free first', ['Hunterio 1', 'Hunter.io 1']],
|
||||
])('names a legacy dot collision after the dot-free block (%s)', (_order, names) => {
|
||||
const workflow = createSearchReplaceWorkflowFixture()
|
||||
workflow.blocks['knowledge-1'].name = names[0]
|
||||
workflow.blocks['api-1'].name = names[1]
|
||||
workflow.blocks['agent-1'].subBlocks.systemPrompt.value = 'Read <hunterio1.email>.'
|
||||
|
||||
const matches = indexWorkflowSearchMatches({
|
||||
workflow,
|
||||
mode: 'all',
|
||||
includeResourceMatchesWithoutQuery: true,
|
||||
blockConfigs: SEARCH_REPLACE_BLOCK_CONFIGS,
|
||||
})
|
||||
|
||||
expect(
|
||||
matches
|
||||
.filter((match) => match.kind === 'workflow-reference')
|
||||
.map((match) => match.searchText)
|
||||
).toEqual(['Hunterio 1.email'])
|
||||
})
|
||||
})
|
||||
|
||||
it('does not index internal row metadata in structured subblock values', () => {
|
||||
const workflow = createSearchReplaceWorkflowFixture()
|
||||
|
||||
|
||||
@@ -10,10 +10,12 @@ import {
|
||||
shouldParseSerializedSubBlockValue,
|
||||
} from '@/lib/workflows/search-replace/json-value-fields'
|
||||
import {
|
||||
buildBlockNamesByReferencePrefix,
|
||||
getResourceKindForSubBlock,
|
||||
matchesSearchText,
|
||||
parseInlineReferences,
|
||||
parseStructuredResourceReferences,
|
||||
resolveInlineReferenceSearchText,
|
||||
} from '@/lib/workflows/search-replace/resources'
|
||||
import { getWorkflowSearchSubflowFields } from '@/lib/workflows/search-replace/subflow-fields'
|
||||
import type {
|
||||
@@ -937,6 +939,7 @@ function addToolInputMatches({
|
||||
blockConfigs,
|
||||
customTools,
|
||||
mcpToolNamesById,
|
||||
blockNamesByReferencePrefix,
|
||||
}: {
|
||||
matches: WorkflowSearchMatch[]
|
||||
block: WorkflowSearchBlockState
|
||||
@@ -958,6 +961,7 @@ function addToolInputMatches({
|
||||
blockConfigs?: WorkflowSearchIndexerOptions['blockConfigs']
|
||||
customTools?: WorkflowSearchIndexerOptions['customTools']
|
||||
mcpToolNamesById?: WorkflowSearchIndexerOptions['mcpToolNamesById']
|
||||
blockNamesByReferencePrefix: ReadonlyMap<string, string>
|
||||
}) {
|
||||
const parentCanonicalModes = getSearchCanonicalModes(block)
|
||||
|
||||
@@ -1058,7 +1062,11 @@ function addToolInputMatches({
|
||||
for (const leaf of getSearchableStringLeaves(paramValue, subBlockType, 'reference')) {
|
||||
const inlineReferences = parseInlineReferences(leaf.value)
|
||||
inlineReferences.forEach((reference, referenceIndex) => {
|
||||
const searchable = `${reference.rawValue} ${reference.searchText}`
|
||||
const searchText = resolveInlineReferenceSearchText(
|
||||
reference,
|
||||
blockNamesByReferencePrefix
|
||||
)
|
||||
const searchable = `${reference.rawValue} ${reference.searchText} ${searchText}`
|
||||
if (
|
||||
!includeResourceMatchesWithoutQuery &&
|
||||
!matchesSearchText(searchable, query, caseSensitive)
|
||||
@@ -1088,7 +1096,7 @@ function addToolInputMatches({
|
||||
target: { kind: 'subblock' },
|
||||
kind: reference.kind,
|
||||
rawValue: reference.rawValue,
|
||||
searchText: reference.searchText,
|
||||
searchText,
|
||||
range: reference.range,
|
||||
dependentValuePaths: nestedDependentValuePaths,
|
||||
resource: reference.resource,
|
||||
@@ -1250,6 +1258,7 @@ export function indexWorkflowSearchMatches(
|
||||
|
||||
const matches: WorkflowSearchMatch[] = []
|
||||
const resourceQueryEnabled = includeResourceMatchesWithoutQuery || Boolean(query)
|
||||
const blockNamesByReferencePrefix = buildBlockNamesByReferencePrefix(workflow.blocks)
|
||||
|
||||
for (const block of Object.values(workflow.blocks)) {
|
||||
const blockConfig = blockConfigs[block.type] ?? getBlock(block.type)
|
||||
@@ -1383,6 +1392,7 @@ export function indexWorkflowSearchMatches(
|
||||
blockConfigs,
|
||||
customTools,
|
||||
mcpToolNamesById,
|
||||
blockNamesByReferencePrefix,
|
||||
})
|
||||
continue
|
||||
}
|
||||
@@ -1471,7 +1481,11 @@ export function indexWorkflowSearchMatches(
|
||||
for (const leaf of referenceLeaves) {
|
||||
const inlineReferences = parseInlineReferences(leaf.value)
|
||||
inlineReferences.forEach((reference, referenceIndex) => {
|
||||
const searchable = `${reference.rawValue} ${reference.searchText}`
|
||||
const searchText = resolveInlineReferenceSearchText(
|
||||
reference,
|
||||
blockNamesByReferencePrefix
|
||||
)
|
||||
const searchable = `${reference.rawValue} ${reference.searchText} ${searchText}`
|
||||
if (
|
||||
!includeResourceMatchesWithoutQuery &&
|
||||
!matchesSearchText(searchable, query, caseSensitive)
|
||||
@@ -1499,7 +1513,7 @@ export function indexWorkflowSearchMatches(
|
||||
target: { kind: 'subblock' },
|
||||
kind: reference.kind,
|
||||
rawValue: reference.rawValue,
|
||||
searchText: reference.searchText,
|
||||
searchText,
|
||||
range: reference.range,
|
||||
resource: reference.resource,
|
||||
editable,
|
||||
|
||||
@@ -8,6 +8,7 @@ import type {
|
||||
WorkflowSearchResourceMeta,
|
||||
} from '@/lib/workflows/search-replace/types'
|
||||
import type { SubBlockConfig } from '@/blocks/types'
|
||||
import { normalizeName, REFERENCE } from '@/executor/constants'
|
||||
import { createEnvVarPattern, createReferencePattern } from '@/executor/utils/reference-validation'
|
||||
import type { SelectorContext } from '@/hooks/selectors/types'
|
||||
|
||||
@@ -67,6 +68,70 @@ export function parseInlineReferences(value: string): ParsedInlineReference[] {
|
||||
return references.sort((a, b) => a.range.start - b.range.start)
|
||||
}
|
||||
|
||||
/**
|
||||
* Indexes a workflow's block names by the prefix their references carry, so a
|
||||
* parsed reference can be read back as the name the canvas shows.
|
||||
*
|
||||
* Creating or renaming a block enforces uniqueness at the normalized level, but
|
||||
* legacy workflows can still hold two names that collide only now that
|
||||
* `normalizeName` strips dots. `BlockResolver` settles that tie by letting the
|
||||
* dot-free name keep ownership of the key, so previously working references
|
||||
* never change targets; this mirrors that rule rather than taking whichever
|
||||
* block happens to be iterated last, so search names the block a reference
|
||||
* actually resolves to at execution time.
|
||||
*
|
||||
* Blank names are skipped rather than mapped to an empty prefix.
|
||||
*/
|
||||
export function buildBlockNamesByReferencePrefix(
|
||||
blocks: Record<string, { name?: string }>
|
||||
): Map<string, string> {
|
||||
const namesByPrefix = new Map<string, string>()
|
||||
|
||||
for (const block of Object.values(blocks)) {
|
||||
if (typeof block.name !== 'string') continue
|
||||
const prefix = normalizeName(block.name)
|
||||
if (!prefix) continue
|
||||
|
||||
const incumbent = namesByPrefix.get(prefix)
|
||||
if (incumbent === undefined || incumbent.includes(REFERENCE.PATH_DELIMITER)) {
|
||||
namesByPrefix.set(prefix, block.name)
|
||||
}
|
||||
}
|
||||
|
||||
return namesByPrefix
|
||||
}
|
||||
|
||||
/**
|
||||
* Rewrites a block reference's search text into the name the block is shown
|
||||
* under, so searching reads the same as the canvas does.
|
||||
*
|
||||
* A reference stores its target as `normalizeName(block.name)` - lowercased with
|
||||
* whitespace and dots stripped - so a block headed "Send Email" is written
|
||||
* `<sendemail.content>`. Searching the two words the card shows found the block
|
||||
* itself but none of its references; only the run-together form found those.
|
||||
*
|
||||
* Only the prefix is rewritten. What follows it is the block's output path, not
|
||||
* a name. A prefix that names no block - a system prefix like `loop`, or a
|
||||
* reference left behind by a deleted block - is left exactly as written, and so
|
||||
* is an environment reference, whose search text is its key rather than a name.
|
||||
*/
|
||||
export function resolveInlineReferenceSearchText(
|
||||
reference: ParsedInlineReference,
|
||||
blockNamesByReferencePrefix: ReadonlyMap<string, string>
|
||||
): string {
|
||||
if (reference.kind !== 'workflow-reference') return reference.searchText
|
||||
|
||||
const delimiterIndex = reference.searchText.indexOf(REFERENCE.PATH_DELIMITER)
|
||||
const prefix =
|
||||
delimiterIndex === -1 ? reference.searchText : reference.searchText.slice(0, delimiterIndex)
|
||||
const blockName = blockNamesByReferencePrefix.get(normalizeName(prefix))
|
||||
if (!blockName || blockName === prefix) return reference.searchText
|
||||
|
||||
return delimiterIndex === -1
|
||||
? blockName
|
||||
: `${blockName}${reference.searchText.slice(delimiterIndex)}`
|
||||
}
|
||||
|
||||
export function parseStructuredResourceReferences(
|
||||
value: unknown,
|
||||
subBlockConfig?: Pick<SubBlockConfig, 'type' | 'serviceId' | 'selectorKey' | 'requiredScopes'>,
|
||||
|
||||
Reference in New Issue
Block a user