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:
Justin Blumencranz
2026-07-08 14:13:06 -07:00
committed by GitHub
co-authored by Claude Fable 5
parent 004955d934
commit fa82acd645
8 changed files with 270 additions and 58 deletions
@@ -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>
@@ -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,
@@ -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
}
+11 -1
View File
@@ -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', () => {
+73 -36
View File
@@ -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)