diff --git a/packages/instantsearch-ui-components/src/components/chat/ChatMessage.tsx b/packages/instantsearch-ui-components/src/components/chat/ChatMessage.tsx
index 81f64182fe..e2e5f3ef07 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 { collectChatRecords } from '../../lib/utils/chatRecords';
import { createButtonComponent } from '../Button';
@@ -258,9 +258,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,
@@ -469,24 +466,16 @@ 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]
+ 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 8f3140c76e..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,
@@ -593,14 +603,6 @@ export function createChatMessagesComponent({
isReasoningPartActive(parts, index)
) ?? false)
: false;
- const showLoader = getShowLoader(
- status,
- lastPart,
- 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 +624,12 @@ export function createChatMessagesComponent({
onClose,
};
+ const showLoader = getShowLoader(
+ context,
+ assistantMessageProps?.showReasoning,
+ hasActiveReasoning
+ );
+
const showEmpty =
messages.length === 0 && !showLoader && !isClearing && status !== 'error';
@@ -744,25 +752,77 @@ export function createChatMessagesComponent({
};
}
-const getShowLoader = (
- status: ChatStatus,
- lastPart: ChatMessageBase['parts'][number] | undefined,
- tools: ClientSideTools,
+/**
+ * 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,
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;
// 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?.({
+ ...context,
+ message: lastPart,
+ parentMessage: lastMessage,
+ }) === 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 de912f6402..5bdeedc2ba 100644
--- a/packages/instantsearch-ui-components/src/components/chat/types.ts
+++ b/packages/instantsearch-ui-components/src/components/chat/types.ts
@@ -673,9 +673,56 @@ 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 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.
+ *
+ * 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?: (context: ClientSideToolShouldRenderContext) => boolean;
addToolResult: AddToolResult;
/** Attached by the connector, one per chat; reaches `layoutComponent`. */
records?: ChatRecordsStore;
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..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
@@ -1,4 +1,6 @@
-import { getApplyFiltersParamsFromToolInput } from '../chat';
+import { warnCache } from '../../../warn';
+import { findTool, getApplyFiltersParamsFromToolInput } from '../chat';
+import { startsWith } from '../startsWith';
describe('getApplyFiltersParamsFromToolInput', () => {
test('returns nothing to refine when input is undefined', () => {
@@ -117,3 +119,113 @@ describe('getApplyFiltersParamsFromToolInput', () => {
).toBeUndefined();
});
});
+
+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('only strips a leading `tool-`', () => {
+ const tool = { name: 'my-tool-thing' };
+
+ expect(findTool('tool-my-tool-thing', { 'my-tool-thing': tool })).toBe(
+ tool
+ );
+ });
+
+ 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 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('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();
+ });
+
+ 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: 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 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 fb1a06c5f4..053ed16759 100644
--- a/packages/instantsearch-ui-components/src/lib/utils/chat.ts
+++ b/packages/instantsearch-ui-components/src/lib/utils/chat.ts
@@ -1,11 +1,11 @@
+import { warn } from '../../warn';
+
import { startsWith } from './startsWith';
import type { ChatMessageBase } from '../../components';
import type {
ApplyFiltersParams,
ChatToolMessage,
- ClientSideTool,
- ClientSideTools,
SearchToolInput,
SearchToolQuery,
} from '../../components/chat/types';
@@ -50,18 +50,86 @@ export function isReasoningPartActive(
);
}
-export const findTool = (
+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.
+ *
+ * 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,
- 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;
+
+ 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();
+
+ const registered = prefixes.map((key) => `"${key}"`).join(', ');
+
+ warn(
+ prefixes.length === 0,
+ `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}".`
+ );
+ }
+
+ 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-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/__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 60f02fa5c8..b716123a68 100644
--- a/packages/instantsearch.js/src/connectors/chat/connectChat.ts
+++ b/packages/instantsearch.js/src/connectors/chat/connectChat.ts
@@ -1,13 +1,18 @@
import {
collectChatRecords,
createChatRecordsStore,
+ findTool,
} from 'instantsearch-ui-components';
import {
DefaultChatTransport,
lastAssistantMessageIsCompleteWithToolCalls,
} from '../../lib/ai-lite';
-import { Chat, SearchIndexToolType } from '../../lib/chat';
+import {
+ Chat,
+ matchesSearchIndexToolName,
+ SearchIndexToolType,
+} from '../../lib/chat';
import {
checkRendering,
clearRefinements,
@@ -466,7 +471,7 @@ export default (function connectChat(
const {
resume = false,
- tools = {},
+ tools: tools_ = {},
type = 'chat',
persistence,
context,
@@ -482,13 +487,23 @@ 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);
+ // 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 = '';
@@ -735,12 +750,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 {
@@ -756,7 +771,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/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 d05ec73d2b..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';
@@ -50,6 +50,7 @@ import type {
ChatLayoutOwnProps,
ChatMessageActionProps,
ChatMessageBase,
+ ClientSideToolShouldRenderContext,
ChatMessageErrorProps,
ChatMessageLoaderProps,
ChatMessageProps,
@@ -82,9 +83,23 @@ 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({
+ parentMessage,
+}: ClientSideToolShouldRenderContext) {
+ return (
+ (parentMessage.metadata as { displayResultsEnabled?: boolean } | undefined)
+ ?.displayResultsEnabled !== true
+ );
+}
+
function mergeToolOptions<
TTool extends {
streamInput?: boolean;
+ shouldRender?: unknown;
templates?: { layout?: unknown };
},
>(
@@ -99,7 +114,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 &&
@@ -107,10 +123,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;
@@ -123,11 +148,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: {} },
@@ -621,12 +647,10 @@ const createRenderer = ({
const toolsForUi: ClientSideTools = {};
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];
- }
+ // 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/packages/react-instantsearch/src/widgets/Chat.tsx b/packages/react-instantsearch/src/widgets/Chat.tsx
index 7ce13a056d..d622f71cb8 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,
+ ClientSideToolShouldRenderContext,
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,23 @@ 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({
+ parentMessage,
+}: ClientSideToolShouldRenderContext) {
+ return (
+ (parentMessage.metadata as { displayResultsEnabled?: boolean } | undefined)
+ ?.displayResultsEnabled !== true
+ );
+}
+
function mergeToolOptions<
TTool extends {
streamInput?: boolean;
+ shouldRender?: unknown;
layoutComponent?: unknown;
},
>(
@@ -92,7 +108,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 +117,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;
diff --git a/tests/common/widgets/chat/options.tsx b/tests/common/widgets/chat/options.tsx
index 616df04fc2..f5eb6960ad 100644
--- a/tests/common/widgets/chat/options.tsx
+++ b/tests/common/widgets/chat/options.tsx
@@ -1285,6 +1285,328 @@ 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 = jest.fn(
+ ({ parentMessage }: any) => parentMessage.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();
+ // 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('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();
+
+ 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({});
+
+ // `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();
@@ -2221,6 +2543,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();