From 6d82c063c53259e5512e5121c084a5f8d357d092 Mon Sep 17 00:00:00 2001 From: Shahmir Ejaz Date: Tue, 18 Aug 2026 12:58:18 +0100 Subject: [PATCH 1/6] init --- .../src/components/chat/ChatMessage.tsx | 27 ++-------- .../src/components/chat/ChatMessages.tsx | 21 ++++++-- .../src/components/chat/types.ts | 9 ++++ .../src/connectors/chat/connectChat.ts | 22 ++++++--- .../src/widgets/chat/chat.tsx | 49 +++++++++++++++---- .../react-instantsearch/src/widgets/Chat.tsx | 38 +++++++++++--- 6 files changed, 114 insertions(+), 52 deletions(-) diff --git a/packages/instantsearch-ui-components/src/components/chat/ChatMessage.tsx b/packages/instantsearch-ui-components/src/components/chat/ChatMessage.tsx index d361742408..0f0d3f83e0 100644 --- a/packages/instantsearch-ui-components/src/components/chat/ChatMessage.tsx +++ b/packages/instantsearch-ui-components/src/components/chat/ChatMessage.tsx @@ -2,7 +2,7 @@ import { compiler } from 'markdown-to-jsx'; import { cx, startsWith } from '../../lib'; -import { isReasoningPartActive } from '../../lib/utils/chat'; +import { findTool, isReasoningPartActive } from '../../lib/utils/chat'; import { createButtonComponent } from '../Button'; import { @@ -224,9 +224,6 @@ export type ChatMessageProps< parseMarkdown?: boolean; }; -// Keep in sync with packages/instantsearch.js/src/lib/chat/index.ts -const SearchIndexToolType = 'algolia_search_index'; - export function createChatMessageComponent({ createElement, Fragment, @@ -403,25 +400,11 @@ export function createChatMessageComponent({ return {markdown}; } if (startsWith(part.type, 'tool-')) { - const toolName = part.type.replace('tool-', ''); - let tool = tools[toolName] as MessageScopedClientSideTool | undefined; - - // Compatibility shim with Algolia MCP Server search tool - if (!tool && startsWith(toolName, `${SearchIndexToolType}_`)) { - tool = tools[SearchIndexToolType] as - | MessageScopedClientSideTool - | undefined; - } - - const displayResultsEnabled = - (message.metadata as { displayResultsEnabled?: boolean } | undefined) - ?.displayResultsEnabled === true; + const tool = findTool(part.type, tools) as + | MessageScopedClientSideTool + | undefined; - if ( - displayResultsEnabled && - tool && - tool === tools[SearchIndexToolType] - ) { + if (tool?.shouldRender?.(message, part as ChatToolMessage) === false) { return null; } diff --git a/packages/instantsearch-ui-components/src/components/chat/ChatMessages.tsx b/packages/instantsearch-ui-components/src/components/chat/ChatMessages.tsx index 6c21daa9d6..5a98a83471 100644 --- a/packages/instantsearch-ui-components/src/components/chat/ChatMessages.tsx +++ b/packages/instantsearch-ui-components/src/components/chat/ChatMessages.tsx @@ -545,7 +545,6 @@ export function createChatMessagesComponent({ }; const lastMessage = messages[messages.length - 1]; - const lastPart = lastMessage?.parts?.[lastMessage.parts.length - 1]; // The scan slices the remaining parts per candidate, and only the loader reads // it, so skip it entirely while the opt-in is off. const hasActiveReasoning = assistantMessageProps?.showReasoning @@ -555,7 +554,7 @@ export function createChatMessagesComponent({ : false; const showLoader = getShowLoader( status, - lastPart, + lastMessage, tools, assistantMessageProps?.showReasoning, hasActiveReasoning @@ -681,7 +680,7 @@ export function createChatMessagesComponent({ const getShowLoader = ( status: ChatStatus, - lastPart: ChatMessageBase['parts'][number] | undefined, + lastMessage: ChatMessageBase | undefined, tools: ClientSideTools, showReasoning: boolean | undefined, hasActiveReasoning: boolean @@ -689,15 +688,27 @@ const getShowLoader = ( if (status !== 'submitted' && status !== 'streaming') return false; if (status === 'submitted') return true; + const lastPart = lastMessage?.parts?.[lastMessage.parts.length - 1]; + if (!lastPart) return true; // An active disclosure carries its own progress affordance, so the loader would // double it. Settled reasoning still shows it: the answer has not started. if (showReasoning && hasActiveReasoning) return false; if (isPartText(lastPart)) return false; - if (isPartTool(lastPart) && lastPart.state === 'input-streaming') { + if (isPartTool(lastPart)) { const tool = findTool(lastPart.type, tools); - return !tool?.streamInput; + + // A part the tool declines to render leaves nothing on screen, so the turn + // still reads as in progress — keep the loader up rather than letting a + // settled-but-hidden part terminate it. + if (lastMessage && tool?.shouldRender?.(lastMessage, lastPart) === false) { + return true; + } + + if (lastPart.state === 'input-streaming') { + return !tool?.streamInput; + } } return true; diff --git a/packages/instantsearch-ui-components/src/components/chat/types.ts b/packages/instantsearch-ui-components/src/components/chat/types.ts index 607c986f5f..d9732f38a5 100644 --- a/packages/instantsearch-ui-components/src/components/chat/types.ts +++ b/packages/instantsearch-ui-components/src/components/chat/types.ts @@ -536,6 +536,15 @@ export type ChatInsightsEventContext = { export type ClientSideTool = { layoutComponent?: ClientSideToolComponent; streamInput?: boolean; + /** + * Whether this tool call should render, for a given message and tool part. + * + * Returning `false` skips the part entirely and keeps the loader visible, so + * a tool can defer to another one that renders the same turn — for example a + * search tool stepping aside for a richer display tool. Omitted means always + * render. + */ + shouldRender?: (message: ChatMessageBase, part: ChatToolMessage) => boolean; addToolResult: AddToolResult; sendEvent?: SendEventForHits; insightsEventContext?: ChatInsightsEventContext; diff --git a/packages/instantsearch.js/src/connectors/chat/connectChat.ts b/packages/instantsearch.js/src/connectors/chat/connectChat.ts index 5d965a3f21..ff7da7d925 100644 --- a/packages/instantsearch.js/src/connectors/chat/connectChat.ts +++ b/packages/instantsearch.js/src/connectors/chat/connectChat.ts @@ -2,7 +2,7 @@ import { DefaultChatTransport, lastAssistantMessageIsCompleteWithToolCalls, } from '../../lib/ai-lite'; -import { Chat, SearchIndexToolType } from '../../lib/chat'; +import { Chat } from '../../lib/chat'; import { checkRendering, clearRefinements, @@ -469,13 +469,19 @@ export default (function connectChat( 'chat' in options ); - // Compatibility shim with Algolia MCP Server search tool, which suffixes - // the tool name with the index name (`searchIndex_products`). - const resolveTool = (toolName: string) => - tools[toolName] || - (toolName.startsWith(`${SearchIndexToolType}_`) - ? tools[SearchIndexToolType] - : undefined); + // Compatibility shim with tool names suffixed by the index name, as the + // Algolia MCP Server does (`algolia_search_index_products`). + const resolveTool = (toolName: string) => { + if (tools[toolName]) { + return tools[toolName]; + } + + const prefixedKey = Object.keys(tools).find((toolKey) => + toolName.startsWith(`${toolKey}_`) + ); + + return prefixedKey ? tools[prefixedKey] : undefined; + }; let _chatInstance: Chat; let input = ''; diff --git a/packages/instantsearch.js/src/widgets/chat/chat.tsx b/packages/instantsearch.js/src/widgets/chat/chat.tsx index 3b3ac5d062..c40a22b03e 100644 --- a/packages/instantsearch.js/src/widgets/chat/chat.tsx +++ b/packages/instantsearch.js/src/widgets/chat/chat.tsx @@ -81,9 +81,21 @@ function getDefinedProperties(obj: T): Partial { ) as Partial; } +/** + * Whether the search tool renders its own results, i.e. the agent did not hand + * the turn to the display-results tool. Set on the message by the backend. + */ +function isDisplayResultsDisabled(message: ChatMessageBase) { + return ( + (message.metadata as { displayResultsEnabled?: boolean } | undefined) + ?.displayResultsEnabled !== true + ); +} + function mergeToolOptions< TTool extends { streamInput?: boolean; + shouldRender?: unknown; templates?: { layout?: unknown }; }, >( @@ -98,7 +110,8 @@ function mergeToolOptions< Object.keys(userTools).forEach((toolName) => { const userTool = userTools[toolName]; - const defaultStreamInput = defaultTools[toolName]?.streamInput; + const defaultTool = defaultTools[toolName]; + const defaultStreamInput = defaultTool?.streamInput; if ( userTool.templates?.layout !== undefined && @@ -106,10 +119,19 @@ function mergeToolOptions< defaultStreamInput !== undefined ) { tools[toolName] = { - ...userTool, + ...tools[toolName], streamInput: defaultStreamInput, }; } + + // Overriding a tool's rendering shouldn't opt it out of the conditions + // under which the default renders at all. + if (userTool.shouldRender === undefined && defaultTool?.shouldRender) { + tools[toolName] = { + ...tools[toolName], + shouldRender: defaultTool.shouldRender, + }; + } }); return tools; @@ -122,11 +144,12 @@ function createDefaultTools< getSearchPageURL?: (nextUiState: IndexUiState) => string ): UserClientSideToolsWithTemplate { return { - [SearchIndexToolType]: createCarouselTool( - true, - templates, - getSearchPageURL - ), + [SearchIndexToolType]: { + ...createCarouselTool(true, templates, getSearchPageURL), + // The agent decides per turn whether the richer display-results tool + // takes over the rendering of the search results. + shouldRender: isDisplayResultsDisabled, + }, [RecommendToolType]: createCarouselTool(false, templates, getSearchPageURL), [DisplayResultsToolType]: createDisplayResultsTool(templates), [MemorizeToolType]: { templates: {} }, @@ -608,9 +631,15 @@ const createRenderer = ({ Object.entries(toolsFromConnector).forEach(([key, connectorTool]) => { let widgetTool = tools[key]; - // Compatibility shim with Algolia MCP Server search tool - if (!widgetTool && key.startsWith(`${SearchIndexToolType}_`)) { - widgetTool = tools[SearchIndexToolType]; + // Compatibility shim with tool names suffixed by the index name, as the + // Algolia MCP Server does (`algolia_search_index_products`). + if (!widgetTool) { + const prefixedKey = Object.keys(tools).find((toolKey) => + key.startsWith(`${toolKey}_`) + ); + if (prefixedKey) { + widgetTool = tools[prefixedKey]; + } } let layoutComponent: diff --git a/packages/react-instantsearch/src/widgets/Chat.tsx b/packages/react-instantsearch/src/widgets/Chat.tsx index 7ce13a056d..dcbb60213e 100644 --- a/packages/react-instantsearch/src/widgets/Chat.tsx +++ b/packages/react-instantsearch/src/widgets/Chat.tsx @@ -34,6 +34,7 @@ export { import type { Pragma, + ChatMessageBase, ChatProps as ChatUiProps, ChatLayoutOwnProps, RecommendComponentProps, @@ -58,11 +59,12 @@ export function createDefaultTools( getSearchPageURL?: (nextUiState: IndexUiState) => string ): UserClientSideTools { return { - [SearchIndexToolType]: createCarouselTool( - true, - itemComponent, - getSearchPageURL - ), + [SearchIndexToolType]: { + ...createCarouselTool(true, itemComponent, getSearchPageURL), + // The agent decides per turn whether the richer display-results tool + // takes over the rendering of the search results. + shouldRender: isDisplayResultsDisabled, + }, [RecommendToolType]: createCarouselTool( false, itemComponent, @@ -75,9 +77,21 @@ export function createDefaultTools( }; } +/** + * Whether the search tool renders its own results, i.e. the agent did not hand + * the turn to the display-results tool. Set on the message by the backend. + */ +function isDisplayResultsDisabled(message: ChatMessageBase) { + return ( + (message.metadata as { displayResultsEnabled?: boolean } | undefined) + ?.displayResultsEnabled !== true + ); +} + function mergeToolOptions< TTool extends { streamInput?: boolean; + shouldRender?: unknown; layoutComponent?: unknown; }, >( @@ -92,7 +106,8 @@ function mergeToolOptions< Object.keys(userTools).forEach((toolName) => { const userTool = userTools[toolName]; - const defaultStreamInput = defaultTools[toolName]?.streamInput; + const defaultTool = defaultTools[toolName]; + const defaultStreamInput = defaultTool?.streamInput; if ( userTool.layoutComponent !== undefined && @@ -100,10 +115,19 @@ function mergeToolOptions< defaultStreamInput !== undefined ) { tools[toolName] = { - ...userTool, + ...tools[toolName], streamInput: defaultStreamInput, }; } + + // Overriding a tool's rendering shouldn't opt it out of the conditions + // under which the default renders at all. + if (userTool.shouldRender === undefined && defaultTool?.shouldRender) { + tools[toolName] = { + ...tools[toolName], + shouldRender: defaultTool.shouldRender, + }; + } }); return tools; From a8f8578d973ab83e0461234dabf5a012df618f28 Mon Sep 17 00:00:00 2001 From: Shahmir Ejaz Date: Tue, 18 Aug 2026 12:58:28 +0100 Subject: [PATCH 2/6] add tests --- tests/common/widgets/chat/options.tsx | 210 ++++++++++++++++++++++++++ 1 file changed, 210 insertions(+) diff --git a/tests/common/widgets/chat/options.tsx b/tests/common/widgets/chat/options.tsx index beb7e0a39e..a6e9bb22dc 100644 --- a/tests/common/widgets/chat/options.tsx +++ b/tests/common/widgets/chat/options.tsx @@ -1284,6 +1284,142 @@ export function createOptionsTests( ); }); + test('skips a tool part its own `shouldRender` opts out of', async () => { + const searchClient = createSearchClient(); + + const chat = new Chat({ + messages: [ + { + id: '1', + role: 'assistant', + metadata: { hideHello: true }, + parts: [ + { + type: 'tool-hello', + toolCallId: '1', + input: { text: 'hello' }, + state: 'output-available', + output: 'hello', + }, + ], + }, + ] as any, + id: 'chat-id', + }); + + const shouldRender = (message: any) => + message.metadata?.hideHello !== true; + + await setup({ + instantSearchOptions: { + indexName: 'indexName', + searchClient, + }, + widgetParams: { + javascript: { + ...createDefaultWidgetParams(chat), + tools: { + hello: { + shouldRender, + templates: { + layout: + '
The message said hello!
', + }, + }, + }, + }, + react: { + ...createDefaultWidgetParams(chat), + tools: { + hello: { + shouldRender, + layoutComponent: () => ( +
The message said hello!
+ ), + }, + }, + }, + vue: {}, + }, + }); + + await openChat(act); + + expect(document.querySelector('#tool-content')).not.toBeInTheDocument(); + }); + + test('shows loader during streaming when the last part is a tool that does not render', async () => { + const searchClient = createSearchClient(); + const chat = new Chat({}); + + // `streamInput` alone would hide the loader, but the part renders + // nothing, so the turn must still read as in progress. + const tool = { shouldRender: () => false, streamInput: true }; + + await setup({ + instantSearchOptions: { + indexName: 'indexName', + searchClient, + }, + widgetParams: { + javascript: { + ...createDefaultWidgetParams(chat), + tools: { + hello: { + ...tool, + templates: { + layout: '
streaming...
', + }, + }, + }, + }, + react: { + ...createDefaultWidgetParams(chat), + tools: { + hello: { + ...tool, + layoutComponent: () => ( +
streaming...
+ ), + }, + }, + }, + vue: {}, + }, + }); + + await openChat(act); + + await act(async () => { + chat._state.messages = [ + { + id: '1', + role: 'user', + parts: [{ type: 'text', text: 'Hello' }], + }, + { + id: '2', + role: 'assistant', + parts: [ + { + type: 'tool-hello', + toolCallId: '1', + state: 'input-streaming', + input: undefined, + }, + ], + }, + ] as any; + chat._state.status = 'streaming'; + await wait(0); + }); + + expect(document.querySelector('#tool-content')).not.toBeInTheDocument(); + expect( + document.querySelector('.ais-ChatMessageLoader') + ).toBeInTheDocument(); + }); + test('renders with custom algolia search tool', async () => { const searchClient = createSearchClient(); @@ -2158,6 +2294,80 @@ export function createOptionsTests( ).not.toBeInTheDocument(); }); + test('keeps skipping an overridden search index tool when the display results tool needs to be rendered', async () => { + const searchClient = createSearchClient(); + + const chat = new Chat({ + messages: [ + { + id: '1', + role: 'assistant', + metadata: { displayResultsEnabled: true }, + parts: [ + { + type: `tool-${SearchIndexToolType}`, + toolCallId: '1', + input: { query: 'test' }, + state: 'output-available', + output: { hits: [{ objectID: '1' }] }, + }, + { + type: `tool-${DisplayResultsToolType}`, + toolCallId: '2', + input: { + groups: [ + { title: 'Picks', results: [{ objectID: '1' }] }, + ], + }, + state: 'output-available', + output: { status: 'success' }, + }, + ], + }, + ] as any, + id: 'chat-id', + }); + + await setup({ + instantSearchOptions: { + indexName: 'indexName', + searchClient, + }, + widgetParams: { + javascript: { + ...createDefaultWidgetParams(chat), + tools: { + [SearchIndexToolType]: { + templates: { + layout: '
custom search
', + }, + }, + }, + }, + react: { + ...createDefaultWidgetParams(chat), + tools: { + [SearchIndexToolType]: { + layoutComponent: () => ( +
custom search
+ ), + }, + }, + }, + vue: {}, + }, + }); + + await openChat(act); + + expect( + document.querySelector('.ais-ChatToolDisplayResults') + ).toBeInTheDocument(); + expect( + document.querySelector('#tool-content') + ).not.toBeInTheDocument(); + }); + test('skips the MCP-shimmed search index tool when the display results tool needs to be rendered', async () => { const searchClient = createSearchClient(); From 44f18eebe13b3d98b6be95d58d11900c84860ee4 Mon Sep 17 00:00:00 2001 From: Shahmir Ejaz Date: Wed, 19 Aug 2026 14:56:14 +0100 Subject: [PATCH 3/6] use chat context --- .../src/components/chat/ChatMessage.tsx | 8 ++++- .../src/components/chat/ChatMessages.tsx | 32 +++++++++++-------- .../src/components/chat/types.ts | 26 +++++++++++++-- .../src/widgets/chat/chat.tsx | 7 ++-- .../react-instantsearch/src/widgets/Chat.tsx | 8 +++-- tests/common/widgets/chat/options.tsx | 19 +++++++++-- 6 files changed, 77 insertions(+), 23 deletions(-) diff --git a/packages/instantsearch-ui-components/src/components/chat/ChatMessage.tsx b/packages/instantsearch-ui-components/src/components/chat/ChatMessage.tsx index d0ba4d2e85..e2e5f3ef07 100644 --- a/packages/instantsearch-ui-components/src/components/chat/ChatMessage.tsx +++ b/packages/instantsearch-ui-components/src/components/chat/ChatMessage.tsx @@ -470,7 +470,13 @@ export function createChatMessageComponent({ | MessageScopedClientSideTool | undefined; - if (tool?.shouldRender?.(message, part as ChatToolMessage) === false) { + if ( + tool?.shouldRender?.({ + ...context, + message: part as ChatToolMessage, + parentMessage: message, + }) === false + ) { return null; } diff --git a/packages/instantsearch-ui-components/src/components/chat/ChatMessages.tsx b/packages/instantsearch-ui-components/src/components/chat/ChatMessages.tsx index 999c0c9df7..a1ad54d72b 100644 --- a/packages/instantsearch-ui-components/src/components/chat/ChatMessages.tsx +++ b/packages/instantsearch-ui-components/src/components/chat/ChatMessages.tsx @@ -593,14 +593,6 @@ export function createChatMessagesComponent({ isReasoningPartActive(parts, index) ) ?? false) : false; - const showLoader = getShowLoader( - status, - lastMessage, - tools, - assistantMessageProps?.showReasoning, - hasActiveReasoning - ); - // The shared context handed to every overridable chat component, so custom // components can read the current chat state and common callbacks from a // single, consistent place. @@ -622,6 +614,12 @@ export function createChatMessagesComponent({ onClose, }; + const showLoader = getShowLoader( + context, + assistantMessageProps?.showReasoning, + hasActiveReasoning + ); + const showEmpty = messages.length === 0 && !showLoader && !isClearing && status !== 'error'; @@ -744,16 +742,17 @@ export function createChatMessagesComponent({ }; } -const getShowLoader = ( - status: ChatStatus, - lastMessage: ChatMessageBase | undefined, - tools: ClientSideTools, +const getShowLoader = ( + context: ChatComponentContext, showReasoning: boolean | undefined, hasActiveReasoning: boolean ): boolean => { + const { status, messages, tools } = context; + if (status !== 'submitted' && status !== 'streaming') return false; if (status === 'submitted') return true; + const lastMessage = messages[messages.length - 1]; const lastPart = lastMessage?.parts?.[lastMessage.parts.length - 1]; if (!lastPart) return true; @@ -768,7 +767,14 @@ const getShowLoader = ( // A part the tool declines to render leaves nothing on screen, so the turn // still reads as in progress — keep the loader up rather than letting a // settled-but-hidden part terminate it. - if (lastMessage && tool?.shouldRender?.(lastMessage, lastPart) === false) { + if ( + lastMessage && + tool?.shouldRender?.({ + ...context, + message: lastPart, + parentMessage: lastMessage, + }) === false + ) { return true; } diff --git a/packages/instantsearch-ui-components/src/components/chat/types.ts b/packages/instantsearch-ui-components/src/components/chat/types.ts index 893794b89b..488411d5df 100644 --- a/packages/instantsearch-ui-components/src/components/chat/types.ts +++ b/packages/instantsearch-ui-components/src/components/chat/types.ts @@ -673,18 +673,40 @@ export type ChatInsightsEventContext = { instantSearchStatus?: 'idle' | 'loading' | 'stalled' | 'error'; }; +/** + * The `context` a tool's `shouldRender` predicate receives: the shared + * `ChatComponentContext`, the tool part under consideration, and the chat + * message that part belongs to. + * + * Narrower than `ClientSideToolContext` on purpose. The predicate decides + * whether anything renders at all, and it also runs from the loader, which has + * none of the render-time callbacks a layout component is handed. + */ +export type ClientSideToolShouldRenderContext< + TMessage extends ChatMessageBase = ChatMessageBase, +> = ChatComponentContext & { + /** + * The tool part being considered for rendering. + */ + message: ChatToolMessage; + /** + * The chat message the tool part belongs to. + */ + parentMessage: TMessage; +}; + export type ClientSideTool = { layoutComponent?: ClientSideToolComponent; streamInput?: boolean; /** - * Whether this tool call should render, for a given message and tool part. + * Whether this tool call should render. * * Returning `false` skips the part entirely and keeps the loader visible, so * a tool can defer to another one that renders the same turn — for example a * search tool stepping aside for a richer display tool. Omitted means always * render. */ - shouldRender?: (message: ChatMessageBase, part: ChatToolMessage) => boolean; + shouldRender?: (context: ClientSideToolShouldRenderContext) => boolean; addToolResult: AddToolResult; /** Attached by the connector, one per chat; reaches `layoutComponent`. */ records?: ChatRecordsStore; diff --git a/packages/instantsearch.js/src/widgets/chat/chat.tsx b/packages/instantsearch.js/src/widgets/chat/chat.tsx index d441edc445..17c01b7c6a 100644 --- a/packages/instantsearch.js/src/widgets/chat/chat.tsx +++ b/packages/instantsearch.js/src/widgets/chat/chat.tsx @@ -50,6 +50,7 @@ import type { ChatLayoutOwnProps, ChatMessageActionProps, ChatMessageBase, + ClientSideToolShouldRenderContext, ChatMessageErrorProps, ChatMessageLoaderProps, ChatMessageProps, @@ -86,9 +87,11 @@ function getDefinedProperties(obj: T): Partial { * Whether the search tool renders its own results, i.e. the agent did not hand * the turn to the display-results tool. Set on the message by the backend. */ -function isDisplayResultsDisabled(message: ChatMessageBase) { +function isDisplayResultsDisabled({ + parentMessage, +}: ClientSideToolShouldRenderContext) { return ( - (message.metadata as { displayResultsEnabled?: boolean } | undefined) + (parentMessage.metadata as { displayResultsEnabled?: boolean } | undefined) ?.displayResultsEnabled !== true ); } diff --git a/packages/react-instantsearch/src/widgets/Chat.tsx b/packages/react-instantsearch/src/widgets/Chat.tsx index dcbb60213e..d622f71cb8 100644 --- a/packages/react-instantsearch/src/widgets/Chat.tsx +++ b/packages/react-instantsearch/src/widgets/Chat.tsx @@ -34,7 +34,7 @@ export { import type { Pragma, - ChatMessageBase, + ClientSideToolShouldRenderContext, ChatProps as ChatUiProps, ChatLayoutOwnProps, RecommendComponentProps, @@ -81,9 +81,11 @@ export function createDefaultTools( * Whether the search tool renders its own results, i.e. the agent did not hand * the turn to the display-results tool. Set on the message by the backend. */ -function isDisplayResultsDisabled(message: ChatMessageBase) { +function isDisplayResultsDisabled({ + parentMessage, +}: ClientSideToolShouldRenderContext) { return ( - (message.metadata as { displayResultsEnabled?: boolean } | undefined) + (parentMessage.metadata as { displayResultsEnabled?: boolean } | undefined) ?.displayResultsEnabled !== true ); } diff --git a/tests/common/widgets/chat/options.tsx b/tests/common/widgets/chat/options.tsx index 90fde00e93..25bb8a9d97 100644 --- a/tests/common/widgets/chat/options.tsx +++ b/tests/common/widgets/chat/options.tsx @@ -1308,8 +1308,9 @@ export function createOptionsTests( id: 'chat-id', }); - const shouldRender = (message: any) => - message.metadata?.hideHello !== true; + const shouldRender = jest.fn( + ({ parentMessage }: any) => parentMessage.metadata?.hideHello !== true + ); await setup({ instantSearchOptions: { @@ -1347,6 +1348,20 @@ export function createOptionsTests( await openChat(act); expect(document.querySelector('#tool-content')).not.toBeInTheDocument(); + // The predicate reads from the same shared `context` every other + // overridable chat component receives, plus the tool part and the + // message it belongs to. + expect(shouldRender).toHaveBeenCalledWith( + expect.objectContaining({ + messages: expect.any(Array), + status: expect.any(String), + tools: expect.any(Object), + message: expect.objectContaining({ type: 'tool-hello' }), + parentMessage: expect.objectContaining({ + metadata: { hideHello: true }, + }), + }) + ); }); test('shows loader during streaming when the last part is a tool that does not render', async () => { From 37a787e16e2a906616a6413b3bc92d3b800b22bd Mon Sep 17 00:00:00 2001 From: Shahmir Ejaz Date: Thu, 20 Aug 2026 11:28:39 +0100 Subject: [PATCH 4/6] address comments --- .../src/components/chat/ChatMessages.tsx | 42 +++++++++ .../src/lib/utils/__tests__/chat-test.ts | 43 ++++++++- .../src/lib/utils/chat.ts | 48 +++++++--- .../src/lib/utils/index.ts | 2 +- .../src/connectors/chat/connectChat.ts | 21 +---- tests/common/widgets/chat/options.tsx | 87 +++++++++++++++++++ 6 files changed, 212 insertions(+), 31 deletions(-) diff --git a/packages/instantsearch-ui-components/src/components/chat/ChatMessages.tsx b/packages/instantsearch-ui-components/src/components/chat/ChatMessages.tsx index a1ad54d72b..331dbc43ba 100644 --- a/packages/instantsearch-ui-components/src/components/chat/ChatMessages.tsx +++ b/packages/instantsearch-ui-components/src/components/chat/ChatMessages.tsx @@ -447,6 +447,15 @@ export function createChatMessagesComponent({ const showReasoning = messageProps?.showReasoning; const parseMarkdown = messageProps?.parseMarkdown; const textComponent = messageProps?.textComponent; + // A completed row is memoized against its own message, but `shouldRender` + // reads the whole `context`: a predicate can hide an older tool result once a + // newer message arrives. Track the verdicts themselves rather than + // `context.messages`, so the row re-renders exactly when one flips instead of + // on every streaming delta. + const shouldRenderVerdicts = getShouldRenderVerdicts( + props.context, + props.message + ); // Custom text components receive the conversation, so their completed rows // must update with it. Keep the default renderer's streaming optimization. const textComponentMessages = textComponent @@ -481,6 +490,7 @@ export function createChatMessagesComponent({ props.message, props.isCurrentMessage, props.status, + shouldRenderVerdicts, props.context.maximized, props.context.open, instantSearchStatus, @@ -742,6 +752,38 @@ export function createChatMessagesComponent({ }; } +/** + * A stable signature of every `shouldRender` verdict in a message, so a memoized + * row can be invalidated when a verdict changes. `undefined` when no tool part + * in the message declares the predicate. + */ +const getShouldRenderVerdicts = ( + context: ChatComponentContext, + message: TMessage +): string | undefined => { + let verdicts: string | undefined; + + message.parts?.forEach((part, index) => { + if (!isPartTool(part)) { + return; + } + + const shouldRender = findTool(part.type, context.tools)?.shouldRender; + + if (!shouldRender) { + return; + } + + verdicts = `${verdicts ?? ''}${index}:${shouldRender({ + ...context, + message: part, + parentMessage: message, + })};`; + }); + + return verdicts; +}; + const getShowLoader = ( context: ChatComponentContext, showReasoning: boolean | undefined, diff --git a/packages/instantsearch-ui-components/src/lib/utils/__tests__/chat-test.ts b/packages/instantsearch-ui-components/src/lib/utils/__tests__/chat-test.ts index 1b2d45034e..98f915ba9d 100644 --- a/packages/instantsearch-ui-components/src/lib/utils/__tests__/chat-test.ts +++ b/packages/instantsearch-ui-components/src/lib/utils/__tests__/chat-test.ts @@ -1,4 +1,4 @@ -import { getApplyFiltersParamsFromToolInput } from '../chat'; +import { findTool, getApplyFiltersParamsFromToolInput } from '../chat'; describe('getApplyFiltersParamsFromToolInput', () => { test('returns nothing to refine when input is undefined', () => { @@ -117,3 +117,44 @@ describe('getApplyFiltersParamsFromToolInput', () => { ).toBeUndefined(); }); }); + +describe('findTool', () => { + const foo = { name: 'foo' }; + const fooBar = { name: 'foo_bar' }; + + test('resolves an exact match from a part type or a bare tool name', () => { + expect(findTool('tool-foo', { foo })).toBe(foo); + expect(findTool('foo', { foo })).toBe(foo); + }); + + test('resolves a name suffixed by the index name', () => { + expect(findTool('tool-foo_products', { foo })).toBe(foo); + }); + + test('prefers the longest match over registration order', () => { + expect(findTool('tool-foo_bar_products', { foo, foo_bar: fooBar })).toBe( + fooBar + ); + expect(findTool('tool-foo_bar_products', { foo_bar: fooBar, foo })).toBe( + fooBar + ); + }); + + test('prefers an exact match over a shorter prefix', () => { + expect(findTool('tool-foo_bar', { foo, foo_bar: fooBar })).toBe(fooBar); + }); + + test('only strips a leading `tool-`', () => { + const tool = { name: 'my-tool-thing' }; + + expect(findTool('tool-my-tool-thing', { 'my-tool-thing': tool })).toBe( + tool + ); + }); + + test('returns undefined when nothing matches', () => { + expect(findTool('tool-other', { foo })).toBeUndefined(); + // A shared prefix is not a match without the `_` separator. + expect(findTool('tool-foobar', { foo })).toBeUndefined(); + }); +}); diff --git a/packages/instantsearch-ui-components/src/lib/utils/chat.ts b/packages/instantsearch-ui-components/src/lib/utils/chat.ts index fb1a06c5f4..97a6177eb8 100644 --- a/packages/instantsearch-ui-components/src/lib/utils/chat.ts +++ b/packages/instantsearch-ui-components/src/lib/utils/chat.ts @@ -4,8 +4,6 @@ import type { ChatMessageBase } from '../../components'; import type { ApplyFiltersParams, ChatToolMessage, - ClientSideTool, - ClientSideTools, SearchToolInput, SearchToolQuery, } from '../../components/chat/types'; @@ -50,18 +48,44 @@ export function isReasoningPartActive( ); } -export const findTool = ( +const TOOL_PART_PREFIX = 'tool-'; + +/** + * Resolves the tool a message part belongs to, from either a part type + * (`tool-algolia_search_index`) or a bare tool name. + * + * Generic over the tool shape so the renderer, the loader and the connector — + * which hold different subsets of the tool contract — all resolve names the same + * way. + */ +export const findTool = ( partType: string, - tools: ClientSideTools -): ClientSideTool | undefined => { - const toolName = partType.replace('tool-', ''); - let tool: ClientSideTool | undefined = tools[toolName]; - if (!tool) { - tool = Object.entries(tools).find(([key]) => - startsWith(toolName, `${key}_`) - )?.[1]; + tools: Record +): TTool | undefined => { + const toolName = startsWith(partType, TOOL_PART_PREFIX) + ? partType.slice(TOOL_PART_PREFIX.length) + : partType; + + if (tools[toolName]) { + return tools[toolName]; } - return tool; + + // Compatibility shim for tool names suffixed by the index name, as the Algolia + // MCP Server does (`algolia_search_index_products`). The longest matching key + // wins, so registering both `foo` and `foo_bar` resolves `foo_bar_products` to + // `foo_bar` — otherwise the winner would depend on registration order. + let match: string | undefined; + + Object.keys(tools).forEach((key) => { + if ( + startsWith(toolName, `${key}_`) && + (match === undefined || key.length > match.length) + ) { + match = key; + } + }); + + return match === undefined ? undefined : tools[match]; }; const FACET_KEY_PREFIX = 'facet_'; diff --git a/packages/instantsearch-ui-components/src/lib/utils/index.ts b/packages/instantsearch-ui-components/src/lib/utils/index.ts index 61df1b6d54..cb9694132f 100644 --- a/packages/instantsearch-ui-components/src/lib/utils/index.ts +++ b/packages/instantsearch-ui-components/src/lib/utils/index.ts @@ -1,4 +1,4 @@ -export { getApplyFiltersParamsFromToolInput } from './chat'; +export { findTool, getApplyFiltersParamsFromToolInput } from './chat'; export { collectChatRecords, createChatRecordsStore, diff --git a/packages/instantsearch.js/src/connectors/chat/connectChat.ts b/packages/instantsearch.js/src/connectors/chat/connectChat.ts index 8018465110..e556e18e8f 100644 --- a/packages/instantsearch.js/src/connectors/chat/connectChat.ts +++ b/packages/instantsearch.js/src/connectors/chat/connectChat.ts @@ -1,6 +1,7 @@ import { collectChatRecords, createChatRecordsStore, + findTool, } from 'instantsearch-ui-components'; import { @@ -482,20 +483,6 @@ export default (function connectChat( 'chat' in options ); - // Compatibility shim with tool names suffixed by the index name, as the - // Algolia MCP Server does (`algolia_search_index_products`). - const resolveTool = (toolName: string) => { - if (tools[toolName]) { - return tools[toolName]; - } - - const prefixedKey = Object.keys(tools).find((toolKey) => - toolName.startsWith(`${toolKey}_`) - ); - - return prefixedKey ? tools[prefixedKey] : undefined; - }; - let _chatInstance: Chat; let input = ''; let open = false; @@ -741,12 +728,12 @@ export default (function connectChat( sendAutomaticallyWhen, transport, shouldRepairToolInput(toolName) { - const tool = resolveTool(toolName); + const tool = findTool(toolName, tools); if (!tool) return true; return Boolean(tool.streamInput); }, resolveCancelledToolOutput({ toolName, toolCallId, input }) { - const cancelOutput = resolveTool(toolName)?.cancelOutput; + const cancelOutput = findTool(toolName, tools)?.cancelOutput; if (!cancelOutput) return undefined; try { @@ -762,7 +749,7 @@ export default (function connectChat( } }, onToolCall: (({ toolCall }, submitToolResult) => { - const tool = resolveTool(toolCall.toolName); + const tool = findTool(toolCall.toolName, tools); if (!tool) { if (__DEV__) { diff --git a/tests/common/widgets/chat/options.tsx b/tests/common/widgets/chat/options.tsx index 25bb8a9d97..ec9daac243 100644 --- a/tests/common/widgets/chat/options.tsx +++ b/tests/common/widgets/chat/options.tsx @@ -1364,6 +1364,93 @@ export function createOptionsTests( ); }); + test('re-evaluates `shouldRender` of an older message when the chat changes', async () => { + const searchClient = createSearchClient(); + + const helloMessage = { + id: '1', + role: 'assistant', + parts: [ + { + type: 'tool-hello', + toolCallId: '1', + input: { text: 'hello' }, + state: 'output-available', + output: 'hello', + }, + ], + }; + + const followUp = { + id: '2', + role: 'user', + parts: [{ type: 'text', text: 'Hi' }], + }; + + const chat = new Chat({ + messages: [helloMessage, followUp] as any, + id: 'chat-id', + }); + + // Reads the conversation rather than its own message, so the verdict + // flips while the message — and its position in the list — stay put. + const shouldRender = ({ messages }: any) => messages.length < 3; + + await setup({ + instantSearchOptions: { + indexName: 'indexName', + searchClient, + }, + widgetParams: { + javascript: { + ...createDefaultWidgetParams(chat), + tools: { + hello: { + shouldRender, + templates: { + layout: + '
The message said hello!
', + }, + }, + }, + }, + react: { + ...createDefaultWidgetParams(chat), + tools: { + hello: { + shouldRender, + layoutComponent: () => ( +
The message said hello!
+ ), + }, + }, + }, + vue: {}, + }, + }); + + await openChat(act); + + expect(document.querySelector('#tool-content')).toBeInTheDocument(); + + // The same message objects, so only the conversation around them changed — + // a row memoized on its own message alone would stay visible. + await act(async () => { + chat._state.messages = [ + helloMessage, + followUp, + { + id: '3', + role: 'assistant', + parts: [{ type: 'text', text: 'Hi there' }], + }, + ] as any; + await wait(0); + }); + + expect(document.querySelector('#tool-content')).not.toBeInTheDocument(); + }); + test('shows loader during streaming when the last part is a tool that does not render', async () => { const searchClient = createSearchClient(); const chat = new Chat({}); From 90500c4c02ed4b0060d2fb8dcf194e356996826c Mon Sep 17 00:00:00 2001 From: Haroen Viaene Date: Thu, 20 Aug 2026 17:11:19 +0200 Subject: [PATCH 5/6] feat(chat): let tools claim the names a server derives from them Resolving a tool part by prefix is a guess: `a_b` is ambiguous between the tool `a_b` and the tool `a` addressing `b`, and no naming rule tells those apart. Preferring the shorter key renders `foo_bar_products` with `foo` when both `foo` and `foo_bar` are registered; preferring the longer one hands `search_index` on the `products` index to `search_index_products`. Which one wins is the registration site's call, not something the resolver can infer. Add `matchesToolName` to the tool contract, so a tool opts in to the names a server derives from it. `findTool` resolves an exact registration first and otherwise only considers tools that claim the name, so two overlapping registrations no longer collide at all. Among claimants the most specific one wins and a dev warning names the conflict, so the winner never depends on registration order. The Algolia MCP Server's per-index search tool names are declared in `connectChat` rather than guessed: it is the Algolia-specific layer both flavors and the renderer read their tools from, and it covers users who go through the connector without the widget's default tools. That leaves `instantsearch-ui-components` with no tool-name knowledge at all. `chat.tsx` held a third copy of the resolution rule, still first-match-wins and unreachable since the connector keys its tools the same way the widget does; it goes through `findTool` now. A dev warning fires when a name goes unresolved while a registered tool is a prefix of it, pointing at `matchesToolName`. Co-Authored-By: Claude Opus 5 (1M context) --- .../src/components/chat/types.ts | 16 ++++ .../src/lib/utils/__tests__/chat-test.ts | 92 ++++++++++++++++--- .../src/lib/utils/chat.ts | 76 +++++++++++---- .../chat/__tests__/connectChat-test.ts | 92 +++++++++++++++++++ .../src/connectors/chat/connectChat.ts | 26 +++++- .../instantsearch.js/src/lib/chat/index.ts | 11 +++ .../src/widgets/chat/chat.tsx | 18 +--- tests/common/widgets/chat/options.tsx | 84 +++++++++++++++++ 8 files changed, 368 insertions(+), 47 deletions(-) diff --git a/packages/instantsearch-ui-components/src/components/chat/types.ts b/packages/instantsearch-ui-components/src/components/chat/types.ts index 488411d5df..5bdeedc2ba 100644 --- a/packages/instantsearch-ui-components/src/components/chat/types.ts +++ b/packages/instantsearch-ui-components/src/components/chat/types.ts @@ -698,6 +698,22 @@ export type ClientSideToolShouldRenderContext< export type ClientSideTool = { layoutComponent?: ClientSideToolComponent; streamInput?: boolean; + /** + * Whether this tool also handles a tool call sent under `toolName`. + * + * Only consulted when no tool is registered under that exact name, so it can + * never shadow another registration. Declare it when the server derives the + * name it sends from the registered one: the Algolia MCP Server exposes the + * search tool once per index and appends the index name, so + * `algolia_search_index` has to answer to `algolia_search_index_products` + * too. + * + * Omitted means the tool only handles its own name. That is deliberate — + * `a_b` is ambiguous between the tool `a_b` and the tool `a` addressing `b`, + * so which of two overlapping names wins is the registration site's call, not + * something the resolver can infer. + */ + matchesToolName?: (toolName: string) => boolean; /** * Whether this tool call should render. * diff --git a/packages/instantsearch-ui-components/src/lib/utils/__tests__/chat-test.ts b/packages/instantsearch-ui-components/src/lib/utils/__tests__/chat-test.ts index 98f915ba9d..674a94e937 100644 --- a/packages/instantsearch-ui-components/src/lib/utils/__tests__/chat-test.ts +++ b/packages/instantsearch-ui-components/src/lib/utils/__tests__/chat-test.ts @@ -1,4 +1,6 @@ +import { warnCache } from '../../../warn'; import { findTool, getApplyFiltersParamsFromToolInput } from '../chat'; +import { startsWith } from '../startsWith'; describe('getApplyFiltersParamsFromToolInput', () => { test('returns nothing to refine when input is undefined', () => { @@ -121,40 +123,100 @@ describe('getApplyFiltersParamsFromToolInput', () => { describe('findTool', () => { const foo = { name: 'foo' }; const fooBar = { name: 'foo_bar' }; + // Opts in to the names a server derives from `foo`, the way the MCP Server + // suffixes the search tool with the index name. + const suffixedFoo = { + name: 'foo', + matchesToolName: (toolName: string) => startsWith(toolName, 'foo_'), + }; + + beforeEach(() => { + warnCache.current = {}; + (global.console.warn as jest.Mock).mockClear(); + }); test('resolves an exact match from a part type or a bare tool name', () => { expect(findTool('tool-foo', { foo })).toBe(foo); expect(findTool('foo', { foo })).toBe(foo); }); - test('resolves a name suffixed by the index name', () => { - expect(findTool('tool-foo_products', { foo })).toBe(foo); - }); + test('only strips a leading `tool-`', () => { + const tool = { name: 'my-tool-thing' }; - test('prefers the longest match over registration order', () => { - expect(findTool('tool-foo_bar_products', { foo, foo_bar: fooBar })).toBe( - fooBar + expect(findTool('tool-my-tool-thing', { 'my-tool-thing': tool })).toBe( + tool ); - expect(findTool('tool-foo_bar_products', { foo_bar: fooBar, foo })).toBe( - fooBar + }); + + test('resolves a derived name only for a tool that claims it', () => { + expect(findTool('tool-foo_products', { foo: suffixedFoo })).toBe( + suffixedFoo ); + expect(findTool('tool-foo_products', { foo })).toBeUndefined(); }); - test('prefers an exact match over a shorter prefix', () => { - expect(findTool('tool-foo_bar', { foo, foo_bar: fooBar })).toBe(fooBar); + test('prefers an exact registration over a claim', () => { + expect( + findTool('tool-foo_bar', { foo: suffixedFoo, foo_bar: fooBar }) + ).toBe(fooBar); + expect( + findTool('tool-foo_bar', { foo_bar: fooBar, foo: suffixedFoo }) + ).toBe(fooBar); }); - test('only strips a leading `tool-`', () => { - const tool = { name: 'my-tool-thing' }; + test('registering overlapping names is unambiguous', () => { + // Neither `foo` nor `foo_bar` claims names beyond its own, so registration + // order cannot decide which one renders `foo_bar_products`. + expect( + findTool('tool-foo_bar_products', { foo, foo_bar: fooBar }) + ).toBeUndefined(); + expect( + findTool('tool-foo_bar_products', { foo_bar: fooBar, foo }) + ).toBeUndefined(); + }); - expect(findTool('tool-my-tool-thing', { 'my-tool-thing': tool })).toBe( - tool + test('resolves the most specific claim, whatever the registration order', () => { + const suffixedFooBar = { + name: 'foo_bar', + matchesToolName: (toolName: string) => startsWith(toolName, 'foo_bar_'), + }; + const tools = { foo: suffixedFoo, foo_bar: suffixedFooBar }; + + expect(findTool('tool-foo_bar_products', tools)).toBe(suffixedFooBar); + expect( + findTool('tool-foo_bar_products', { + foo_bar: suffixedFooBar, + foo: suffixedFoo, + }) + ).toBe(suffixedFooBar); + }); + + test('warns when several unrelated tools claim the same name', () => { + const other = { + name: 'other', + matchesToolName: (toolName: string) => startsWith(toolName, 'foo_'), + }; + + // A conflict the resolver cannot arbitrate: it settles it deterministically + // and says so, rather than letting registration order decide silently. + expect(findTool('tool-foo_products', { foo: suffixedFoo, other })).toBe( + other + ); + expect(global.console.warn).toHaveBeenCalledWith( + '[instantsearch-ui-components] Multiple tools claim "foo_products" through `matchesToolName`: "other", "foo". "other" handles it.' ); }); test('returns undefined when nothing matches', () => { expect(findTool('tool-other', { foo })).toBeUndefined(); // A shared prefix is not a match without the `_` separator. - expect(findTool('tool-foobar', { foo })).toBeUndefined(); + expect(findTool('tool-foobar', { foo: suffixedFoo })).toBeUndefined(); + }); + + test('points at `matchesToolName` when a registered name is a prefix', () => { + expect(findTool('tool-foo_products', { foo })).toBeUndefined(); + expect(global.console.warn).toHaveBeenCalledWith( + '[instantsearch-ui-components] No tool is registered for "foo_products". The registered "foo" is a prefix of it, but a prefix alone doesn\'t resolve: declare `matchesToolName` on it to also handle "foo_products".' + ); }); }); diff --git a/packages/instantsearch-ui-components/src/lib/utils/chat.ts b/packages/instantsearch-ui-components/src/lib/utils/chat.ts index 97a6177eb8..d7a65be8ab 100644 --- a/packages/instantsearch-ui-components/src/lib/utils/chat.ts +++ b/packages/instantsearch-ui-components/src/lib/utils/chat.ts @@ -1,3 +1,5 @@ +import { warn } from '../../warn'; + import { startsWith } from './startsWith'; import type { ChatMessageBase } from '../../components'; @@ -50,13 +52,30 @@ export function isReasoningPartActive( const TOOL_PART_PREFIX = 'tool-'; +type ToolNameMatcher = { + matchesToolName?: (toolName: string) => boolean; +}; + /** * Resolves the tool a message part belongs to, from either a part type * (`tool-algolia_search_index`) or a bare tool name. * - * Generic over the tool shape so the renderer, the loader and the connector — - * which hold different subsets of the tool contract — all resolve names the same - * way. + * A tool registered under the exact name always wins. Failing that, only tools + * that opted in through `matchesToolName` can claim the name, which is how a + * server that derives the name it sends from the registered one is supported — + * the Algolia MCP Server exposes the search tool once per index and appends the + * index name (`algolia_search_index_products`). + * + * Claiming is explicit rather than inferred from the name because `a_b` is + * genuinely ambiguous between the tool `a_b` and the tool `a` addressing `b`. + * No naming rule tells those apart, so guessing picks the wrong tool for + * somebody: preferring the shorter key breaks `foo_bar` when `foo` is also + * registered, preferring the longer one breaks `search_index` on the `products` + * index when `search_index_products` is also registered. + * + * Generic over the tool shape so the renderer, the loader, the widget and the + * connector — which hold different subsets of the tool contract — all resolve + * names the same way. */ export const findTool = ( partType: string, @@ -70,22 +89,45 @@ export const findTool = ( return tools[toolName]; } - // Compatibility shim for tool names suffixed by the index name, as the Algolia - // MCP Server does (`algolia_search_index_products`). The longest matching key - // wins, so registering both `foo` and `foo_bar` resolves `foo_bar_products` to - // `foo_bar` — otherwise the winner would depend on registration order. - let match: string | undefined; - - Object.keys(tools).forEach((key) => { - if ( - startsWith(toolName, `${key}_`) && - (match === undefined || key.length > match.length) - ) { - match = key; + const claimants = Object.keys(tools).filter((key) => + Boolean( + (tools[key] as ToolNameMatcher | undefined)?.matchesToolName?.(toolName) + ) + ); + + if (claimants.length === 0) { + if (__DEV__) { + const prefixes = Object.keys(tools) + .filter((key) => startsWith(toolName, `${key}_`)) + .sort(); + + warn( + prefixes.length === 0, + `No tool is registered for "${toolName}". The registered ${prefixes + .map((key) => `"${key}"`) + .join( + ', ' + )} is a prefix of it, but a prefix alone doesn't resolve: declare \`matchesToolName\` on it to also handle "${toolName}".` + ); } - }); - return match === undefined ? undefined : tools[match]; + return undefined; + } + + // Sorted rather than first-found, so the winner never depends on the order + // tools were registered in: the most specific claim wins, ties by name. + claimants.sort((a, b) => b.length - a.length || (a < b ? -1 : 1)); + + if (__DEV__) { + warn( + claimants.length === 1, + `Multiple tools claim "${toolName}" through \`matchesToolName\`: ${claimants + .map((key) => `"${key}"`) + .join(', ')}. "${claimants[0]}" handles it.` + ); + } + + return tools[claimants[0]]; }; const FACET_KEY_PREFIX = 'facet_'; diff --git a/packages/instantsearch.js/src/connectors/chat/__tests__/connectChat-test.ts b/packages/instantsearch.js/src/connectors/chat/__tests__/connectChat-test.ts index ce0c047dc5..f6593fb684 100644 --- a/packages/instantsearch.js/src/connectors/chat/__tests__/connectChat-test.ts +++ b/packages/instantsearch.js/src/connectors/chat/__tests__/connectChat-test.ts @@ -1739,6 +1739,98 @@ data: [DONE]`, }); }); + it('lets a tool claim the names a server derives from it', async () => { + const onToolCall = jest.fn(); + + const { widget } = getInitializedWidget({ + agentId: undefined, + transport: { + fetch: () => + Promise.resolve( + new Response( + `data: {"type": "start", "messageId": "test-id"} + +data: {"type": "start-step"} + +data: {"type": "tool-input-available", "toolCallId": "call_1", "toolName": "my_tool_movies", "input": {}} + +data: {"type":"tool-output-available","toolCallId":"call_1","output":{}} + +data: {"type": "finish-step"} + +data: {"type": "finish"} + +data: [DONE]`, + { + headers: { 'Content-Type': 'text/event-stream' }, + } + ) + ), + }, + tools: { + my_tool: { + onToolCall, + matchesToolName: (toolName: string) => + toolName.startsWith('my_tool_'), + }, + }, + }); + + await widget.chatInstance.sendMessage({ + id: 'message-id', + role: 'user', + parts: [{ type: 'text', text: 'Trigger tool call' }], + }); + + await waitFor(() => { + expect(onToolCall).toHaveBeenCalledWith( + expect.objectContaining({ toolName: 'my_tool_movies' }) + ); + }); + }); + + it('does not resolve a derived name for a tool that does not claim it', async () => { + const onToolCall = jest.fn(); + + const { widget } = getInitializedWidget({ + agentId: undefined, + transport: { + fetch: () => + Promise.resolve( + new Response( + `data: {"type": "start", "messageId": "test-id"} + +data: {"type": "start-step"} + +data: {"type": "tool-input-available", "toolCallId": "call_1", "toolName": "my_tool_movies", "input": {}} + +data: {"type":"tool-output-available","toolCallId":"call_1","output":{}} + +data: {"type": "finish-step"} + +data: {"type": "finish"} + +data: [DONE]`, + { + headers: { 'Content-Type': 'text/event-stream' }, + } + ) + ), + }, + // `my_tool` and `my_tool_movies` are two different tools as far as the + // registry is concerned, so registration order can't decide this. + tools: { my_tool: { onToolCall } }, + }); + + await widget.chatInstance.sendMessage({ + id: 'message-id', + role: 'user', + parts: [{ type: 'text', text: 'Trigger tool call' }], + }); + + expect(onToolCall).not.toHaveBeenCalled(); + }); + it('streams tool input parts from tool-input-delta without tool-input-available', async () => { const { widget } = getInitializedWidget({ agentId: undefined, diff --git a/packages/instantsearch.js/src/connectors/chat/connectChat.ts b/packages/instantsearch.js/src/connectors/chat/connectChat.ts index e556e18e8f..b716123a68 100644 --- a/packages/instantsearch.js/src/connectors/chat/connectChat.ts +++ b/packages/instantsearch.js/src/connectors/chat/connectChat.ts @@ -8,7 +8,11 @@ import { DefaultChatTransport, lastAssistantMessageIsCompleteWithToolCalls, } from '../../lib/ai-lite'; -import { Chat } from '../../lib/chat'; +import { + Chat, + matchesSearchIndexToolName, + SearchIndexToolType, +} from '../../lib/chat'; import { checkRendering, clearRefinements, @@ -467,7 +471,7 @@ export default (function connectChat( const { resume = false, - tools = {}, + tools: tools_ = {}, type = 'chat', persistence, context, @@ -483,6 +487,24 @@ export default (function connectChat( 'chat' in options ); + // The Algolia MCP Server exposes the search tool once per index and names it + // after the index (`algolia_search_index_products`). That naming convention + // is Algolia's, so it's declared here rather than guessed by the resolver: + // `findTool` only lets a tool answer to a name it claims. An explicit + // `matchesToolName` wins, and so does a tool registered under the derived + // name itself. + const tools = + tools_[SearchIndexToolType] && + tools_[SearchIndexToolType].matchesToolName === undefined + ? { + ...tools_, + [SearchIndexToolType]: { + ...tools_[SearchIndexToolType], + matchesToolName: matchesSearchIndexToolName, + }, + } + : tools_; + let _chatInstance: Chat; let input = ''; let open = false; diff --git a/packages/instantsearch.js/src/lib/chat/index.ts b/packages/instantsearch.js/src/lib/chat/index.ts index 9047b677b3..f66cbc5176 100644 --- a/packages/instantsearch.js/src/lib/chat/index.ts +++ b/packages/instantsearch.js/src/lib/chat/index.ts @@ -13,3 +13,14 @@ export const MemorizeToolType = 'algolia_memorize'; export const MemorySearchToolType = 'algolia_memory_search'; export const PonderToolType = 'algolia_ponder'; export const DisplayResultsToolType = 'algolia_display_results'; + +/** + * Whether `toolName` is the search tool as the Algolia MCP Server exposes it: + * one tool per index, named after the index it searches + * (`algolia_search_index_products`). + * + * Meant to be passed as a tool's `matchesToolName`, so the suffix is only ever + * interpreted for the tool that actually gets named that way. + */ +export const matchesSearchIndexToolName = (toolName: string) => + toolName.startsWith(`${SearchIndexToolType}_`); diff --git a/packages/instantsearch.js/src/widgets/chat/chat.tsx b/packages/instantsearch.js/src/widgets/chat/chat.tsx index 17c01b7c6a..e0c59409dd 100644 --- a/packages/instantsearch.js/src/widgets/chat/chat.tsx +++ b/packages/instantsearch.js/src/widgets/chat/chat.tsx @@ -1,6 +1,6 @@ /** @jsx h */ -import { createChatComponent } from 'instantsearch-ui-components'; +import { createChatComponent, findTool } from 'instantsearch-ui-components'; import { Fragment, h, render } from 'preact'; import { useEffect, useMemo, useState } from 'preact/hooks'; @@ -647,18 +647,10 @@ const createRenderer = ({ const toolsForUi: ClientSideTools = {}; Object.entries(toolsFromConnector).forEach(([key, connectorTool]) => { - let widgetTool = tools[key]; - - // Compatibility shim with tool names suffixed by the index name, as the - // Algolia MCP Server does (`algolia_search_index_products`). - if (!widgetTool) { - const prefixedKey = Object.keys(tools).find((toolKey) => - key.startsWith(`${toolKey}_`) - ); - if (prefixedKey) { - widgetTool = tools[prefixedKey]; - } - } + // The connector keys its tools the same way the widget does, so this is + // an exact hit today. Going through `findTool` keeps the widget on the + // same resolution rule as the renderer, the loader and the connector. + const widgetTool = findTool(key, tools); let layoutComponent: | ((props: ClientSideToolComponentProps) => JSX.Element) diff --git a/tests/common/widgets/chat/options.tsx b/tests/common/widgets/chat/options.tsx index ec9daac243..f5eb6960ad 100644 --- a/tests/common/widgets/chat/options.tsx +++ b/tests/common/widgets/chat/options.tsx @@ -1364,6 +1364,90 @@ export function createOptionsTests( ); }); + test('renders a tool part under a name the tool claims with `matchesToolName`', async () => { + const searchClient = createSearchClient(); + + const chat = new Chat({ + messages: [ + { + id: '1', + role: 'assistant', + parts: [ + { + type: 'tool-hello_products', + toolCallId: '1', + input: { text: 'hello' }, + state: 'output-available', + output: 'hello', + }, + { + type: 'tool-goodbye_products', + toolCallId: '2', + input: {}, + state: 'output-available', + output: 'goodbye', + }, + ], + }, + ] as any, + id: 'chat-id', + }); + + // Only `hello` opts in to the names a server derives from it, so + // `goodbye_products` stays unresolved even though `goodbye` is a prefix + // of it. Which of two overlapping names wins is the registration + // site's call, not the resolver's guess. + const matchesToolName = (toolName: string) => + toolName.startsWith('hello_'); + + await setup({ + instantSearchOptions: { + indexName: 'indexName', + searchClient, + }, + widgetParams: { + javascript: { + ...createDefaultWidgetParams(chat), + tools: { + hello: { + matchesToolName, + templates: { + layout: '
Hello!
', + }, + }, + goodbye: { + templates: { + layout: '
Goodbye!
', + }, + }, + }, + }, + react: { + ...createDefaultWidgetParams(chat), + tools: { + hello: { + matchesToolName, + layoutComponent: () =>
Hello!
, + }, + goodbye: { + layoutComponent: () => ( +
Goodbye!
+ ), + }, + }, + }, + vue: {}, + }, + }); + + await openChat(act); + + expect(document.querySelector('#tool-content')).toBeInTheDocument(); + expect( + document.querySelector('#other-tool-content') + ).not.toBeInTheDocument(); + }); + test('re-evaluates `shouldRender` of an older message when the chat changes', async () => { const searchClient = createSearchClient(); From 28ebb061d2a1a90ac7a6b38d001554831f3d9190 Mon Sep 17 00:00:00 2001 From: Haroen Viaene Date: Thu, 20 Aug 2026 17:20:30 +0200 Subject: [PATCH 6/6] fix(chat): pluralize the unresolved-tool-name warning MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The message named every registered prefix but always read "The registered … is a prefix of it", which is wrong as soon as two tools qualify. Branch on the count, and point at "the tool that should handle" it rather than "it", which no longer refers to a single tool. Co-Authored-By: Claude Opus 5 (1M context) --- .../src/lib/utils/__tests__/chat-test.ts | 11 ++++++++++- .../src/lib/utils/chat.ts | 12 +++++++----- 2 files changed, 17 insertions(+), 6 deletions(-) diff --git a/packages/instantsearch-ui-components/src/lib/utils/__tests__/chat-test.ts b/packages/instantsearch-ui-components/src/lib/utils/__tests__/chat-test.ts index 674a94e937..a2cc9f524e 100644 --- a/packages/instantsearch-ui-components/src/lib/utils/__tests__/chat-test.ts +++ b/packages/instantsearch-ui-components/src/lib/utils/__tests__/chat-test.ts @@ -216,7 +216,16 @@ describe('findTool', () => { test('points at `matchesToolName` when a registered name is a prefix', () => { expect(findTool('tool-foo_products', { foo })).toBeUndefined(); expect(global.console.warn).toHaveBeenCalledWith( - '[instantsearch-ui-components] No tool is registered for "foo_products". The registered "foo" is a prefix of it, but a prefix alone doesn\'t resolve: declare `matchesToolName` on it to also handle "foo_products".' + '[instantsearch-ui-components] No tool is registered for "foo_products". The registered tool "foo" is a prefix of it, but a prefix alone doesn\'t resolve: declare `matchesToolName` on the tool that should handle "foo_products".' + ); + }); + + test('lists every registered prefix of an unresolved name', () => { + expect( + findTool('tool-foo_bar_products', { foo_bar: fooBar, foo }) + ).toBeUndefined(); + expect(global.console.warn).toHaveBeenCalledWith( + '[instantsearch-ui-components] No tool is registered for "foo_bar_products". The registered tools "foo", "foo_bar" are prefixes of it, but a prefix alone doesn\'t resolve: declare `matchesToolName` on the tool that should handle "foo_bar_products".' ); }); }); diff --git a/packages/instantsearch-ui-components/src/lib/utils/chat.ts b/packages/instantsearch-ui-components/src/lib/utils/chat.ts index d7a65be8ab..053ed16759 100644 --- a/packages/instantsearch-ui-components/src/lib/utils/chat.ts +++ b/packages/instantsearch-ui-components/src/lib/utils/chat.ts @@ -101,13 +101,15 @@ export const findTool = ( .filter((key) => startsWith(toolName, `${key}_`)) .sort(); + const registered = prefixes.map((key) => `"${key}"`).join(', '); + warn( prefixes.length === 0, - `No tool is registered for "${toolName}". The registered ${prefixes - .map((key) => `"${key}"`) - .join( - ', ' - )} is a prefix of it, but a prefix alone doesn't resolve: declare \`matchesToolName\` on it to also handle "${toolName}".` + `No tool is registered for "${toolName}". ${ + prefixes.length > 1 + ? `The registered tools ${registered} are prefixes of it` + : `The registered tool ${registered} is a prefix of it` + }, but a prefix alone doesn't resolve: declare \`matchesToolName\` on the tool that should handle "${toolName}".` ); }