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();