mirror of
https://github.com/simstudioai/sim.git
synced 2026-09-24 15:45:35 +08:00
fix(workflow): render agent tool chip names from static sources, not stored title (#5501)
* fix(workflow): render agent tool chip names from static sources, not stored title Tool entries in an agent block's inputs carry a mutable `title` in workflow state, which copilot edits (edit_workflow) could rewrite to change what the UI displays. Derive chip names from static/canonical sources instead: the block registry name for integration tools (raw type id if unregistered), the custom-tool record over the stored snapshot, the live MCP tool name, and a static literal for workflow-as-tool. The stored `title` still persists in state; the UI just no longer reads it for registry-backed tools. Covers both render paths: the expanded tool-input chips and the collapsed canvas summary (resolveToolsLabel, also used by workflow preview). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(workflow): cover workflow-as-tool and custom-tool fallbacks in static naming Address review findings: resolveToolsLabel now returns the static 'Workflow' label for workflow/workflow_input entries instead of falling through to the stored title, matching the panel chip; the panel's custom-tool name priority now matches resolveToolsLabel (record title, record schema function name, then stored title); and the display-name comment no longer overstates the guarantee. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(workflow): resolve panel chip names via unfiltered block registry The panel resolved integration tool names through toolBlocks, which is permission- and toolbar-filtered, so a stored tool whose type was filtered out of the picker fell back to the raw type id while the canvas summary (getBlock) still showed the registry name. Use getBlock directly for the display name so both paths agree; toolBlocks still drives the chip icon, params, and picker. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(workflow): resolve MCP tool names from live server data in canvas summaries resolveToolsLabel preferred the stored title for mcp entries, so canvas and preview summaries could show a state-edited name while the config panel showed the live MCP tool name. Accept an optional mcpTools list (matched by toolId, same as the panel) and pass the already-fetched MCP tool data from workflow-block; the stored title remains the fallback while server data is unavailable. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * refactor(workflow): single shared tool-name resolver; fix overlay, fallback, and search regressions Consolidate the per-surface name logic into one resolveStoredToolName in display.ts (canonical source -> stored title -> raw type id) used by the panel chips, the canvas summary, and the search indexer, fixing the regressions the per-renderer patches introduced: - custom-block tools (async registry overlay) no longer render raw custom_block_<uuid> ids pre-hydration or after deletion; the canvas memo and panel picker now subscribe to the overlay version so labels recompute when custom blocks hydrate - unresolvable block types fall back to the stored title before the raw type id, instead of always showing the id - the search indexer now indexes the resolved display name (the title entry was already read-only), so search text and highlights match what the chips render instead of the mutable stored title - redundant hardcoded 'Workflow' labels removed (registry names cover workflow/workflow_input); MCP lookups use a shared Map keyed by composite tool id instead of per-entry array scans; panel chip chrome falls back to the unfiltered registry for picker-hidden types Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(workflow): thread custom-tool and MCP data into the search indexer; fix blockConfigs type The search index now resolves tool names with the same data as the chip renderers: the single caller passes customTools and a live MCP name map into indexWorkflowSearchMatches, so indexed names match the panel for custom-tool references and MCP entries, not just registry-backed types. Also declare the optional name on WorkflowSearchIndexerOptions blockConfigs entries, fixing the next build type error. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(workflow): recompute search index when the custom-block overlay hydrates The matches memo resolves tool names through getBlock, so it must carry the overlay version dep like the other getBlock-derived memos; without it, find-in-workflow could index stale custom-block names until an unrelated dep changed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
004955d934
commit
fa82acd645
+21
-7
@@ -27,6 +27,7 @@ import {
|
||||
import type { McpToolSchema } from '@/lib/mcp/types'
|
||||
import { getProviderIdFromServiceId, type OAuthProvider, type OAuthService } from '@/lib/oauth'
|
||||
import { extractInputFieldsFromBlocks } from '@/lib/workflows/input-format'
|
||||
import { resolveStoredToolName } from '@/lib/workflows/subblocks/display'
|
||||
import { buildToolSubBlockId } from '@/lib/workflows/tool-input/synthetic-subblocks'
|
||||
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
|
||||
import { McpServerFormModal } from '@/app/workspace/[workspaceId]/settings/components/mcp/components/mcp-server-form-modal/mcp-server-form-modal'
|
||||
@@ -59,7 +60,8 @@ import {
|
||||
ActiveSearchTargetProvider,
|
||||
useActiveSearchTarget,
|
||||
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/providers/active-search-target-provider'
|
||||
import { getAllBlocks } from '@/blocks'
|
||||
import { getAllBlocks, getBlock } from '@/blocks'
|
||||
import { useCustomBlockOverlayVersion } from '@/blocks/custom/client-overlay'
|
||||
import { getTileIconColorClass } from '@/blocks/icon-color'
|
||||
import type { SubBlockConfig as BlockSubBlockConfig } from '@/blocks/types'
|
||||
import { BUILT_IN_TOOL_TYPES } from '@/blocks/utils'
|
||||
@@ -543,6 +545,13 @@ export const ToolInput = memo(function ToolInput({
|
||||
const { data: customTools = [] } = useCustomTools(shouldFetchCustomTools ? workspaceId : '')
|
||||
|
||||
const { mcpTools, isLoading: mcpLoading } = useMcpTools(workspaceId)
|
||||
const mcpToolNamesById = useMemo(() => {
|
||||
const names = new Map<string, string>()
|
||||
for (const t of mcpTools) {
|
||||
if (!names.has(t.id)) names.set(t.id, t.name)
|
||||
}
|
||||
return names
|
||||
}, [mcpTools])
|
||||
|
||||
const { data: mcpServers = [], isLoading: mcpServersLoading } = useMcpServers(workspaceId)
|
||||
const { data: storedMcpTools = [] } = useStoredMcpTools(workspaceId)
|
||||
@@ -642,6 +651,7 @@ export const ToolInput = memo(function ToolInput({
|
||||
|
||||
const { filterBlocks, config: permissionConfig } = usePermissionConfig()
|
||||
|
||||
const customBlockOverlayVersion = useCustomBlockOverlayVersion()
|
||||
const toolBlocks = useMemo(() => {
|
||||
const allToolBlocks = getAllBlocks().filter(
|
||||
(block) =>
|
||||
@@ -659,7 +669,7 @@ export const ToolInput = memo(function ToolInput({
|
||||
block.type !== 'file'
|
||||
)
|
||||
return filterBlocks(allToolBlocks)
|
||||
}, [filterBlocks])
|
||||
}, [filterBlocks, customBlockOverlayVersion])
|
||||
|
||||
const hasBackfilledRef = useRef(false)
|
||||
useEffect(() => {
|
||||
@@ -1662,9 +1672,11 @@ export const ToolInput = memo(function ToolInput({
|
||||
const isCustomTool = tool.type === 'custom-tool'
|
||||
const isMcpTool = tool.type === 'mcp'
|
||||
const isWorkflowTool = tool.type === 'workflow'
|
||||
// Fall back to the unfiltered registry so chips for types hidden
|
||||
// from the picker (permissions, hideFromToolbar) keep their chrome.
|
||||
const toolBlock =
|
||||
!isCustomTool && !isMcpTool
|
||||
? toolBlocks.find((block) => block.type === tool.type)
|
||||
? (toolBlocks.find((block) => block.type === tool.type) ?? getBlock(tool.type))
|
||||
: null
|
||||
|
||||
const currentToolId =
|
||||
@@ -1713,9 +1725,6 @@ export const ToolInput = memo(function ToolInput({
|
||||
? resolveCustomToolFromReference(tool, customTools)
|
||||
: null
|
||||
|
||||
const customToolTitle = isCustomTool
|
||||
? tool.title || resolvedCustomTool?.title || 'Unknown Tool'
|
||||
: null
|
||||
const customToolSchema = isCustomTool ? tool.schema || resolvedCustomTool?.schema : null
|
||||
const customToolParams =
|
||||
isCustomTool && customToolSchema?.function?.parameters?.properties
|
||||
@@ -1747,6 +1756,11 @@ export const ToolInput = memo(function ToolInput({
|
||||
)
|
||||
: []
|
||||
|
||||
// Canonical name wins; stored title only when nothing resolves
|
||||
// (same policy as the canvas summary — see resolveStoredToolName).
|
||||
const toolDisplayName =
|
||||
resolveStoredToolName(tool, { customTools, mcpToolNamesById }) ?? 'Unknown Tool'
|
||||
|
||||
const useSubBlocks = !isCustomTool && !isMcpTool && subBlocksResult?.subBlocks?.length
|
||||
const displayParams: ToolParameterConfig[] = isCustomTool
|
||||
? customToolParams
|
||||
@@ -1854,7 +1868,7 @@ export const ToolInput = memo(function ToolInput({
|
||||
)}
|
||||
</div>
|
||||
<span className='truncate font-medium text-[var(--text-primary)] text-small'>
|
||||
{formatDisplayText((isCustomTool ? customToolTitle : tool.title) ?? '', {
|
||||
{formatDisplayText(toolDisplayName ?? '', {
|
||||
workflowSearchHighlight: getToolTitleSearchHighlight(toolIndex),
|
||||
})}
|
||||
</span>
|
||||
|
||||
+19
@@ -35,7 +35,10 @@ import {
|
||||
} from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/float'
|
||||
import { useCurrentWorkflow } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-current-workflow'
|
||||
import { getBlock } from '@/blocks'
|
||||
import { useCustomBlockOverlayVersion } from '@/blocks/custom/client-overlay'
|
||||
import { useMcpTools } from '@/hooks/mcp/use-mcp-tools'
|
||||
import { useWorkspaceCredentials } from '@/hooks/queries/credentials'
|
||||
import { useCustomTools } from '@/hooks/queries/custom-tools'
|
||||
import { useFolderMap } from '@/hooks/queries/folders'
|
||||
import { isWorkflowEffectivelyLocked } from '@/hooks/queries/utils/folder-tree'
|
||||
import { useWorkflowMap } from '@/hooks/queries/workflows'
|
||||
@@ -170,6 +173,15 @@ export function WorkflowSearchReplace() {
|
||||
const prevIsOpenRef = useRef(false)
|
||||
const afterReplaceIndexRef = useRef<number | null>(null)
|
||||
const { data: workspaceCredentials } = useWorkspaceCredentials({ workspaceId, enabled: isOpen })
|
||||
const { data: customTools = [] } = useCustomTools(isOpen && workspaceId ? workspaceId : '')
|
||||
const { mcpTools } = useMcpTools(isOpen && workspaceId ? workspaceId : '')
|
||||
const mcpToolNamesById = useMemo(() => {
|
||||
const names = new Map<string, string>()
|
||||
for (const t of mcpTools) {
|
||||
if (!names.has(t.id)) names.set(t.id, t.name)
|
||||
}
|
||||
return names
|
||||
}, [mcpTools])
|
||||
|
||||
useRegisterGlobalCommands([
|
||||
createCommand({
|
||||
@@ -202,6 +214,8 @@ export function WorkflowSearchReplace() {
|
||||
[workspaceCredentials]
|
||||
)
|
||||
|
||||
/** Overlay version invalidates getBlock-resolved tool names in the index. */
|
||||
const customBlockOverlayVersion = useCustomBlockOverlayVersion()
|
||||
const matches = useMemo(
|
||||
() =>
|
||||
indexWorkflowSearchMatches({
|
||||
@@ -215,10 +229,15 @@ export function WorkflowSearchReplace() {
|
||||
workspaceId,
|
||||
workflowId,
|
||||
credentialTypeById,
|
||||
customTools,
|
||||
mcpToolNamesById,
|
||||
}),
|
||||
[
|
||||
currentWorkflow.isSnapshotView,
|
||||
credentialTypeById,
|
||||
customBlockOverlayVersion,
|
||||
customTools,
|
||||
mcpToolNamesById,
|
||||
query,
|
||||
readonlyReason,
|
||||
searchBlocks,
|
||||
|
||||
+25
-10
@@ -44,6 +44,7 @@ import {
|
||||
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/utils'
|
||||
import { useBlockVisual } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks'
|
||||
import { useBlockDimensions } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-block-dimensions'
|
||||
import { useCustomBlockOverlayVersion } from '@/blocks/custom/client-overlay'
|
||||
import { SELECTOR_TYPES_HYDRATION_REQUIRED, type SubBlockConfig } from '@/blocks/types'
|
||||
import { getDependsOnFields } from '@/blocks/utils'
|
||||
import { useKnowledgeBase } from '@/hooks/kb/use-knowledge'
|
||||
@@ -68,6 +69,9 @@ const logger = createLogger('WorkflowBlock')
|
||||
/** Stable empty object to avoid creating new references */
|
||||
const EMPTY_SUBBLOCK_VALUES = {} as Record<string, any>
|
||||
|
||||
/** Stable empty map for rows that never resolve MCP tool names */
|
||||
const EMPTY_MCP_TOOL_NAMES: ReadonlyMap<string, string> = new Map()
|
||||
|
||||
interface SubBlockRowProps {
|
||||
title: string
|
||||
value?: string
|
||||
@@ -274,17 +278,23 @@ const SubBlockRow = memo(function SubBlockRow({
|
||||
}, [subBlock?.type, rawValue, mcpServers])
|
||||
|
||||
const { data: mcpToolsData = [] } = useMcpToolsQuery(workspaceId || '')
|
||||
const mcpToolNamesById = useMemo(() => {
|
||||
if (subBlock?.type !== 'mcp-tool-selector' && subBlock?.type !== 'tool-input') {
|
||||
return EMPTY_MCP_TOOL_NAMES
|
||||
}
|
||||
const names = new Map<string, string>()
|
||||
for (const t of mcpToolsData) {
|
||||
const toolId = createMcpToolId(t.serverId, t.name)
|
||||
if (!names.has(toolId)) names.set(toolId, t.name)
|
||||
}
|
||||
return names
|
||||
}, [subBlock?.type, mcpToolsData])
|
||||
const mcpToolDisplayName = useMemo(() => {
|
||||
if (subBlock?.type !== 'mcp-tool-selector' || typeof rawValue !== 'string') {
|
||||
return null
|
||||
}
|
||||
|
||||
const tool = mcpToolsData.find((t) => {
|
||||
const toolId = createMcpToolId(t.serverId, t.name)
|
||||
return toolId === rawValue
|
||||
})
|
||||
return tool?.name ?? null
|
||||
}, [subBlock?.type, rawValue, mcpToolsData])
|
||||
return mcpToolNamesById.get(rawValue) ?? null
|
||||
}, [subBlock?.type, rawValue, mcpToolNamesById])
|
||||
|
||||
const { data: tables = [] } = useTablesList(workspaceId || '')
|
||||
const tableDisplayName = useMemo(() => {
|
||||
@@ -329,11 +339,16 @@ const SubBlockRow = memo(function SubBlockRow({
|
||||
[subBlock, rawValue, workflowVariables]
|
||||
)
|
||||
|
||||
/** Hydrates tool references to display names. */
|
||||
/**
|
||||
* Hydrates tool references to display names. The overlay version is a dep
|
||||
* because resolveToolsLabel reads getBlock, whose custom-block results
|
||||
* change when the client overlay hydrates (see client-overlay.ts).
|
||||
*/
|
||||
const { data: customTools = [] } = useCustomTools(workspaceId || '')
|
||||
const customBlockOverlayVersion = useCustomBlockOverlayVersion()
|
||||
const toolsDisplayValue = useMemo(
|
||||
() => resolveToolsLabel(subBlock, rawValue, customTools),
|
||||
[subBlock, rawValue, customTools]
|
||||
() => resolveToolsLabel(subBlock, rawValue, customTools, mcpToolNamesById),
|
||||
[subBlock, rawValue, customTools, mcpToolNamesById, customBlockOverlayVersion]
|
||||
)
|
||||
|
||||
const filterDisplayValue = useMemo(
|
||||
|
||||
@@ -1387,6 +1387,10 @@ describe('indexWorkflowSearchMatches', () => {
|
||||
custom: {
|
||||
subBlocks: [{ id: 'tools', title: 'Tools', type: 'tool-input' }],
|
||||
},
|
||||
native: {
|
||||
name: 'Customer Mailer',
|
||||
subBlocks: [],
|
||||
},
|
||||
},
|
||||
}).filter((match) => match.blockId === 'tool-input-1')
|
||||
|
||||
@@ -1395,7 +1399,7 @@ describe('indexWorkflowSearchMatches', () => {
|
||||
expect.objectContaining({
|
||||
subBlockId: 'tools',
|
||||
valuePath: [0, 'title'],
|
||||
searchText: 'Customer notifier',
|
||||
searchText: 'Customer Mailer',
|
||||
}),
|
||||
expect.objectContaining({
|
||||
subBlockId: 'tools',
|
||||
@@ -1404,6 +1408,7 @@ describe('indexWorkflowSearchMatches', () => {
|
||||
}),
|
||||
])
|
||||
)
|
||||
expect(matches.some((match) => match.searchText === 'Customer notifier')).toBe(false)
|
||||
expect(matches.some((match) => match.valuePath.includes('toolId'))).toBe(false)
|
||||
expect(matches.some((match) => match.valuePath.includes('operation'))).toBe(false)
|
||||
expect(matches.some((match) => match.valuePath.includes('credentialId'))).toBe(false)
|
||||
@@ -1418,6 +1423,50 @@ describe('indexWorkflowSearchMatches', () => {
|
||||
expect(matches.some((match) => match.valuePath.includes('schema'))).toBe(false)
|
||||
})
|
||||
|
||||
it('indexes canonical MCP and custom-tool names over mutated stored titles', () => {
|
||||
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', toolId: 'mcp-tool-1', title: 'Mutated Title', params: {} },
|
||||
{ type: 'custom-tool', customToolId: 'ct-1', title: 'Mutated Title', params: {} },
|
||||
],
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
const matches = indexWorkflowSearchMatches({
|
||||
workflow,
|
||||
query: 'customer',
|
||||
mode: 'text',
|
||||
blockConfigs: {
|
||||
...SEARCH_REPLACE_BLOCK_CONFIGS,
|
||||
custom: {
|
||||
subBlocks: [{ id: 'tools', title: 'Tools', type: 'tool-input' }],
|
||||
},
|
||||
},
|
||||
customTools: [{ id: 'ct-1', title: 'Customer Records' }],
|
||||
mcpToolNamesById: new Map([['mcp-tool-1', 'customer_lookup']]),
|
||||
}).filter((match) => match.blockId === 'tool-input-1')
|
||||
|
||||
expect(matches).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ valuePath: [0, 'title'], searchText: 'customer_lookup' }),
|
||||
expect.objectContaining({ valuePath: [1, 'title'], searchText: 'Customer Records' }),
|
||||
])
|
||||
)
|
||||
expect(matches.some((match) => match.searchText === 'Mutated Title')).toBe(false)
|
||||
})
|
||||
|
||||
it('indexes explicit secret tool params for intentional replacement', () => {
|
||||
const workflow = createSearchReplaceWorkflowFixture()
|
||||
workflow.blocks['tool-input-1'] = {
|
||||
|
||||
@@ -23,6 +23,7 @@ import type {
|
||||
} from '@/lib/workflows/search-replace/types'
|
||||
import { pathToKey, walkStringValues } from '@/lib/workflows/search-replace/value-walker'
|
||||
import { SELECTOR_CONTEXT_FIELDS } from '@/lib/workflows/subblocks/context'
|
||||
import { resolveStoredToolName } from '@/lib/workflows/subblocks/display'
|
||||
import {
|
||||
buildCanonicalIndex,
|
||||
buildSubBlockValues,
|
||||
@@ -931,6 +932,8 @@ function addToolInputMatches({
|
||||
workflowId,
|
||||
credentialTypeById,
|
||||
blockConfigs,
|
||||
customTools,
|
||||
mcpToolNamesById,
|
||||
}: {
|
||||
matches: WorkflowSearchMatch[]
|
||||
block: WorkflowSearchBlockState
|
||||
@@ -950,11 +953,20 @@ function addToolInputMatches({
|
||||
workflowId?: string
|
||||
credentialTypeById?: Record<string, string | undefined>
|
||||
blockConfigs?: WorkflowSearchIndexerOptions['blockConfigs']
|
||||
customTools?: WorkflowSearchIndexerOptions['customTools']
|
||||
mcpToolNamesById?: WorkflowSearchIndexerOptions['mcpToolNamesById']
|
||||
}) {
|
||||
const parentCanonicalModes = getSearchCanonicalModes(block)
|
||||
|
||||
parseStoredToolInputValue(value).forEach((tool, toolIndex) => {
|
||||
if (mode !== 'resource' && tool.title) {
|
||||
// Index the resolved display name (not the stored mutable title) so
|
||||
// search text and highlights match what the tool chip actually renders.
|
||||
const toolDisplayName = resolveStoredToolName(tool, {
|
||||
customTools,
|
||||
mcpToolNamesById,
|
||||
getBlockConfig: (type) => blockConfigs?.[type] ?? getBlock(type),
|
||||
})
|
||||
if (mode !== 'resource' && toolDisplayName) {
|
||||
addTextMatches({
|
||||
matches,
|
||||
idPrefix: 'tool-input-title',
|
||||
@@ -963,7 +975,7 @@ function addToolInputMatches({
|
||||
canonicalSubBlockId,
|
||||
subBlockType: 'tool-input',
|
||||
fieldTitle: 'Tool',
|
||||
value: tool.title,
|
||||
value: toolDisplayName,
|
||||
valuePath: [toolIndex, 'title'],
|
||||
target: { kind: 'subblock' },
|
||||
query,
|
||||
@@ -1228,6 +1240,8 @@ export function indexWorkflowSearchMatches(
|
||||
workflowId,
|
||||
blockConfigs = {},
|
||||
credentialTypeById,
|
||||
customTools,
|
||||
mcpToolNamesById,
|
||||
} = options
|
||||
|
||||
const matches: WorkflowSearchMatch[] = []
|
||||
@@ -1363,6 +1377,8 @@ export function indexWorkflowSearchMatches(
|
||||
workflowId,
|
||||
credentialTypeById,
|
||||
blockConfigs,
|
||||
customTools,
|
||||
mcpToolNamesById,
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import type {
|
||||
WorkflowSearchSubflowEditableValue,
|
||||
WorkflowSearchSubflowFieldId,
|
||||
} from '@/lib/workflows/search-replace/subflow-fields'
|
||||
import type { StoredCustomToolRecord } from '@/lib/workflows/subblocks/display'
|
||||
import type { SubBlockConfig } from '@/blocks/types'
|
||||
import type { SelectorContext } from '@/hooks/selectors/types'
|
||||
import type { BlockState, SubBlockState } from '@/stores/workflows/workflow/types'
|
||||
@@ -96,10 +97,19 @@ export interface WorkflowSearchIndexerOptions {
|
||||
workflowId?: string
|
||||
blockConfigs?: Record<
|
||||
string,
|
||||
| { subBlocks?: SubBlockConfig[]; triggers?: { enabled?: boolean }; category?: string }
|
||||
| {
|
||||
name?: string
|
||||
subBlocks?: SubBlockConfig[]
|
||||
triggers?: { enabled?: boolean }
|
||||
category?: string
|
||||
}
|
||||
| undefined
|
||||
>
|
||||
credentialTypeById?: Record<string, string | undefined>
|
||||
/** Custom-tool records so indexed tool names match the rendered chips. */
|
||||
customTools?: StoredCustomToolRecord[]
|
||||
/** Live MCP tool names keyed by composite tool id, same as the chip renderers. */
|
||||
mcpToolNamesById?: ReadonlyMap<string, string>
|
||||
}
|
||||
|
||||
export interface WorkflowSearchReplacementOption {
|
||||
|
||||
@@ -4,7 +4,11 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('@/blocks', () => ({
|
||||
getBlock: (type: string) => (type === 'slack' ? { name: 'Slack' } : undefined),
|
||||
getBlock: (type: string) => {
|
||||
if (type === 'slack') return { name: 'Slack' }
|
||||
if (type === 'workflow' || type === 'workflow_input') return { name: 'Workflow' }
|
||||
return undefined
|
||||
},
|
||||
}))
|
||||
|
||||
import {
|
||||
@@ -113,6 +117,54 @@ describe('resolveToolsLabel', () => {
|
||||
null
|
||||
)
|
||||
})
|
||||
|
||||
it('ignores the stored title on registry-backed tools so state edits cannot rename them', () => {
|
||||
const slackName = resolveToolsLabel(toolInput, [{ type: 'slack' }], [])
|
||||
expect(slackName).not.toBe(null)
|
||||
expect(resolveToolsLabel(toolInput, [{ type: 'slack', title: 'Renamed By Copilot' }], [])).toBe(
|
||||
slackName
|
||||
)
|
||||
})
|
||||
|
||||
it('falls back to the stored title, then the raw type id, for unresolvable block types', () => {
|
||||
expect(
|
||||
resolveToolsLabel(toolInput, [{ type: 'custom_block_gone', title: 'Invoice Parser' }], [])
|
||||
).toBe('Invoice Parser')
|
||||
expect(resolveToolsLabel(toolInput, [{ type: 'custom_block_gone' }], [])).toBe(
|
||||
'custom_block_gone'
|
||||
)
|
||||
})
|
||||
|
||||
it('renders the static label for workflow-as-tool entries regardless of stored title', () => {
|
||||
expect(
|
||||
resolveToolsLabel(toolInput, [{ type: 'workflow', title: 'Renamed By Copilot' }], [])
|
||||
).toBe('Workflow')
|
||||
expect(resolveToolsLabel(toolInput, [{ type: 'workflow_input' }], [])).toBe('Workflow')
|
||||
})
|
||||
|
||||
it('prefers the live MCP tool name over the stored title', () => {
|
||||
expect(
|
||||
resolveToolsLabel(
|
||||
toolInput,
|
||||
[{ type: 'mcp', toolId: 'mcp-1', title: 'Renamed By Copilot' }],
|
||||
[],
|
||||
new Map([['mcp-1', 'Live MCP Name']])
|
||||
)
|
||||
).toBe('Live MCP Name')
|
||||
expect(
|
||||
resolveToolsLabel(toolInput, [{ type: 'mcp', toolId: 'mcp-1', title: 'Snapshot' }], [])
|
||||
).toBe('Snapshot')
|
||||
})
|
||||
|
||||
it('prefers the custom tool record over the stored title', () => {
|
||||
expect(
|
||||
resolveToolsLabel(
|
||||
toolInput,
|
||||
[{ type: 'custom-tool', customToolId: 'ct-1', title: 'Renamed By Copilot' }],
|
||||
[{ id: 'ct-1', title: 'My Tool' }]
|
||||
)
|
||||
).toBe('My Tool')
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolveSkillsLabel', () => {
|
||||
|
||||
@@ -426,52 +426,89 @@ export function resolveVariablesLabel(
|
||||
return summarizeNames(names)
|
||||
}
|
||||
|
||||
/** Custom-tool record shape needed to resolve a stored custom-tool reference. */
|
||||
export interface StoredCustomToolRecord {
|
||||
id: string
|
||||
title?: string
|
||||
schema?: { function?: { name?: string } }
|
||||
}
|
||||
|
||||
export interface ResolveStoredToolNameOptions {
|
||||
customTools?: StoredCustomToolRecord[]
|
||||
/** Live MCP tool names keyed by composite tool id (`createMcpToolId`). */
|
||||
mcpToolNamesById?: ReadonlyMap<string, string>
|
||||
/** Block-config lookup; overridable so snapshot views can scope configs. */
|
||||
getBlockConfig?: (type: string) => { name?: string } | undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves a tool-input value to a tool-name summary. Stored tool entries
|
||||
* come in several historical shapes, checked in priority order: explicit
|
||||
* title, custom tool referenced by id, inline schema name, OpenAI function
|
||||
* name, then the block registry.
|
||||
* Resolves one stored tool entry to its display name. Canonical sources win —
|
||||
* the block registry (including the custom-block overlay), the custom-tool
|
||||
* record, live MCP server data — so edits to the entry's mutable `title` in
|
||||
* workflow state cannot relabel a tool that has a canonical name. The stored
|
||||
* title is the fallback for entries with no resolvable source (deleted custom
|
||||
* blocks, MCP entries while server data is unavailable, legacy inline custom
|
||||
* tools where the title is the identity), ahead of the raw type id.
|
||||
*/
|
||||
export function resolveStoredToolName(
|
||||
tool: unknown,
|
||||
options: ResolveStoredToolNameOptions = {}
|
||||
): string | null {
|
||||
if (!tool || typeof tool !== 'object') return null
|
||||
const t = tool as Record<string, unknown>
|
||||
const { customTools = [], mcpToolNamesById, getBlockConfig = getBlock } = options
|
||||
|
||||
const storedTitle = typeof t.title === 'string' && t.title ? t.title : null
|
||||
const schema = t.schema as { function?: { name?: string } } | undefined
|
||||
const schemaName = schema?.function?.name || null
|
||||
|
||||
if (t.type === 'custom-tool') {
|
||||
if (typeof t.customToolId === 'string') {
|
||||
const record = customTools.find((candidate) => candidate.id === t.customToolId)
|
||||
if (record?.title) return record.title
|
||||
if (record?.schema?.function?.name) return record.schema.function.name
|
||||
}
|
||||
return storedTitle || schemaName
|
||||
}
|
||||
|
||||
if (t.type === 'mcp') {
|
||||
if (typeof t.toolId === 'string') {
|
||||
const liveName = mcpToolNamesById?.get(t.toolId)
|
||||
if (liveName) return liveName
|
||||
}
|
||||
return storedTitle
|
||||
}
|
||||
|
||||
if (typeof t.type === 'string' && t.type) {
|
||||
const blockConfig = getBlockConfig(t.type)
|
||||
if (blockConfig?.name) return blockConfig.name
|
||||
return storedTitle || t.type
|
||||
}
|
||||
|
||||
if (storedTitle) return storedTitle
|
||||
if (schemaName) return schemaName
|
||||
|
||||
const fn = t.function as { name?: string } | undefined
|
||||
if (fn?.name) return fn.name
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves a tool-input value to a tool-name summary via
|
||||
* {@link resolveStoredToolName}. Unresolvable entries are skipped.
|
||||
*/
|
||||
export function resolveToolsLabel(
|
||||
subBlock: SubBlockConfig | undefined,
|
||||
rawValue: unknown,
|
||||
customTools: Array<{ id: string; title?: string; schema?: { function?: { name?: string } } }>
|
||||
customTools: StoredCustomToolRecord[],
|
||||
mcpToolNamesById?: ReadonlyMap<string, string>
|
||||
): string | null {
|
||||
if (subBlock?.type !== 'tool-input') return null
|
||||
if (!Array.isArray(rawValue) || rawValue.length === 0) return null
|
||||
|
||||
const names = rawValue
|
||||
.map((tool: unknown) => {
|
||||
if (!tool || typeof tool !== 'object') return null
|
||||
const t = tool as Record<string, unknown>
|
||||
|
||||
if (typeof t.title === 'string' && t.title) return t.title
|
||||
|
||||
if (t.type === 'custom-tool' && typeof t.customToolId === 'string') {
|
||||
const customTool = customTools.find((candidate) => candidate.id === t.customToolId)
|
||||
if (customTool?.title) return customTool.title
|
||||
if (customTool?.schema?.function?.name) return customTool.schema.function.name
|
||||
}
|
||||
|
||||
const schema = t.schema as { function?: { name?: string } } | undefined
|
||||
if (schema?.function?.name) return schema.function.name
|
||||
|
||||
const fn = t.function as { name?: string } | undefined
|
||||
if (fn?.name) return fn.name
|
||||
|
||||
if (
|
||||
typeof t.type === 'string' &&
|
||||
t.type !== 'custom-tool' &&
|
||||
t.type !== 'mcp' &&
|
||||
t.type !== 'workflow' &&
|
||||
t.type !== 'workflow_input'
|
||||
) {
|
||||
const blockConfig = getBlock(t.type)
|
||||
if (blockConfig?.name) return blockConfig.name
|
||||
}
|
||||
|
||||
return null
|
||||
})
|
||||
.map((tool: unknown) => resolveStoredToolName(tool, { customTools, mcpToolNamesById }))
|
||||
.filter((name): name is string => !!name)
|
||||
|
||||
return summarizeNames(names)
|
||||
|
||||
Reference in New Issue
Block a user