diff --git a/bundlesize.config.json b/bundlesize.config.json index 77d60d4124f..6f469180f1a 100644 --- a/bundlesize.config.json +++ b/bundlesize.config.json @@ -14,7 +14,7 @@ }, { "path": "./packages/instantsearch.js/dist/instantsearch.development.js", - "maxSize": "240.50 kB" + "maxSize": "241 kB" }, { "path": "packages/react-instantsearch-core/dist/umd/ReactInstantSearchCore.min.js", diff --git a/packages/instantsearch-ui-components/src/components/chat/ChatMessage.tsx b/packages/instantsearch-ui-components/src/components/chat/ChatMessage.tsx index 327555d0ae2..862a68973b9 100644 --- a/packages/instantsearch-ui-components/src/components/chat/ChatMessage.tsx +++ b/packages/instantsearch-ui-components/src/components/chat/ChatMessage.tsx @@ -144,6 +144,9 @@ export type ChatMessageProps = ComponentProps<'article'> & { translations?: Partial; }; +// Keep in sync with packages/instantsearch.js/src/lib/chat/index.ts +const SearchIndexToolType = 'algolia_search_index'; + export function createChatMessageComponent({ createElement }: Renderer) { const Button = createButtonComponent({ createElement }); @@ -208,7 +211,12 @@ export function createChatMessageComponent({ createElement }: Renderer) { } if (startsWith(part.type, 'tool-')) { const toolName = part.type.replace('tool-', ''); - const tool = tools[toolName]; + let tool = tools[toolName]; + + // Compatibility shim with Algolia MCP Server search tool + if (!tool && startsWith(toolName, `${SearchIndexToolType}_`)) { + tool = tools[SearchIndexToolType]; + } if (tool) { const ToolLayoutComponent = tool.layoutComponent; 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 a625be2988d..6aaa828df54 100644 --- a/packages/instantsearch.js/src/connectors/chat/__tests__/connectChat-test.ts +++ b/packages/instantsearch.js/src/connectors/chat/__tests__/connectChat-test.ts @@ -3,6 +3,7 @@ */ import { createSearchClient } from '@instantsearch/mocks'; +import { waitFor } from '@testing-library/dom'; import algoliasearchHelper from 'algoliasearch-helper'; import { @@ -392,6 +393,58 @@ describe('connectChat', () => { }); }); + describe('default chat instance', () => { + it('adds a compatibility layer for Algolia MCP Server search tool', async () => { + const onSearchToolCall = 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": "algolia_search_index_movies", "input": {"query": "Toy Story", "attributesToRetrieve": ["year"], "hitsPerPage": 1}} + +data: {"type":"tool-output-available","toolCallId":"call_1","output":{"results":[{"hits":[]}]}} + +data: {"type": "finish-step"} + +data: {"type": "finish"} + +data: [DONE]`, + { + headers: { 'Content-Type': 'text/event-stream' }, + } + ) + ), + }, + tools: { algolia_search_index: { onToolCall: onSearchToolCall } }, + }); + + const { chatInstance } = widget; + + // Simulate sending a message that triggers the tool call + await chatInstance.sendMessage({ + id: 'message-id', + role: 'user', + parts: [{ type: 'text', text: 'Trigger tool call' }], + }); + + await waitFor(() => { + expect(onSearchToolCall).toHaveBeenCalledWith( + expect.objectContaining({ + toolCallId: 'call_1', + toolName: 'algolia_search_index_movies', + }) + ); + }); + }); + }); + describe('transport configuration', () => { it('throws error when neither agentId nor transport is provided', () => { const renderFn = jest.fn(); diff --git a/packages/instantsearch.js/src/connectors/chat/connectChat.ts b/packages/instantsearch.js/src/connectors/chat/connectChat.ts index f987f537318..6f52d80769f 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 } from '../../lib/chat'; +import { Chat, SearchIndexToolType } from '../../lib/chat'; import { checkRendering, clearRefinements, @@ -390,7 +390,15 @@ export default (function connectChat( transport, sendAutomaticallyWhen: lastAssistantMessageIsCompleteWithToolCalls, onToolCall({ toolCall }) { - const tool = tools[toolCall.toolName]; + let tool = tools[toolCall.toolName]; + + // Compatibility shim with Algolia MCP Server search tool + if ( + !tool && + toolCall.toolName.startsWith(`${SearchIndexToolType}_`) + ) { + tool = tools[SearchIndexToolType]; + } if (!tool) { if (__DEV__) { diff --git a/packages/instantsearch.js/src/widgets/chat/__tests__/chat.test.tsx b/packages/instantsearch.js/src/widgets/chat/__tests__/chat.test.tsx index 6e3f335e2de..406ef33e921 100644 --- a/packages/instantsearch.js/src/widgets/chat/__tests__/chat.test.tsx +++ b/packages/instantsearch.js/src/widgets/chat/__tests__/chat.test.tsx @@ -3,6 +3,8 @@ */ /** @jsx h */ import { createSearchClient } from '@instantsearch/mocks'; +import { wait } from '@instantsearch/testutils/wait'; +import { screen } from '@testing-library/dom'; import instantsearch from '../../../index.es'; import chat from '../chat'; @@ -28,4 +30,104 @@ describe('chat', () => { `); }); }); + + describe('search tool compatibility', () => { + test('renders search results from Agent Studio', async () => { + const container = document.createElement('div'); + document.body.appendChild(container); + + const search = instantsearch({ + indexName: 'indexName', + searchClient: createSearchClient(), + }); + + search.addWidgets([ + chat({ + container, + agentId: 'test-agent-id', + templates: { item: (hit) => `
${hit.name}
` }, + messages: [ + { + id: 'assistant-message-id', + role: 'assistant', + parts: [ + { + type: 'tool-algolia_search_index', + toolCallId: 'tool-call-1', + state: 'output-available', + input: { query: 'products' }, + output: { + hits: [ + { objectID: '1', name: 'Product 1', __position: 1 }, + { objectID: '2', name: 'Product 2', __position: 2 }, + ], + nbHits: 100, + }, + }, + ], + }, + ], + }), + ]); + + search.start(); + await wait(0); + + expect(screen.getByText('Product 1')).toBeInTheDocument(); + expect(screen.getByText('Product 2')).toBeInTheDocument(); + }); + + test('renders search results from Algolia MCP Server', async () => { + const container = document.createElement('div'); + document.body.appendChild(container); + + const search = instantsearch({ + indexName: 'indexName', + searchClient: createSearchClient(), + }); + + search.addWidgets([ + chat({ + container, + agentId: 'test-agent-id', + templates: { item: (hit) => `
${hit.name}
` }, + messages: [ + { + id: 'assistant-message-id', + role: 'assistant', + parts: [ + { + type: 'tool-algolia_search_index_products', + toolCallId: 'tool-call-1', + state: 'output-available', + input: { query: 'products' }, + output: { + hits: [ + { + objectID: '1', + name: 'MCP Product 1', + __position: 1, + }, + { + objectID: '2', + name: 'MCP Product 2', + __position: 2, + }, + ], + nbHits: 50, + }, + }, + ], + }, + ], + }), + ]); + + search.start(); + await wait(0); + + expect(screen.getByText('MCP Product 1')).toBeInTheDocument(); + expect(screen.getByText('MCP Product 2')).toBeInTheDocument(); + }); + }); }); diff --git a/packages/instantsearch.js/src/widgets/chat/chat.tsx b/packages/instantsearch.js/src/widgets/chat/chat.tsx index 5b6115d533a..14336e9f703 100644 --- a/packages/instantsearch.js/src/widgets/chat/chat.tsx +++ b/packages/instantsearch.js/src/widgets/chat/chat.tsx @@ -107,7 +107,6 @@ function createCarouselTool< nbHits?: number; } | undefined; - const items = output?.hits || []; const MemoedHeaderComponent = useMemo(() => { @@ -499,7 +498,12 @@ const createRenderer = ({ const toolsForUi: ClientSideTools = {}; Object.entries(toolsFromConnector).forEach(([key, connectorTool]) => { - const widgetTool = tools[key]; + let widgetTool = tools[key]; + + // Compatibility shim with Algolia MCP Server search tool + if (!widgetTool && key.startsWith(`${SearchIndexToolType}_`)) { + widgetTool = tools[SearchIndexToolType]; + } toolsForUi[key] = { ...connectorTool, diff --git a/packages/react-instantsearch/src/widgets/chat/tools/__tests__/SearchIndexTool.test.tsx b/packages/react-instantsearch/src/widgets/chat/tools/__tests__/SearchIndexTool.test.tsx new file mode 100644 index 00000000000..5eaa02550d8 --- /dev/null +++ b/packages/react-instantsearch/src/widgets/chat/tools/__tests__/SearchIndexTool.test.tsx @@ -0,0 +1,90 @@ +/** + * @jest-environment @instantsearch/testutils/jest-environment-jsdom.ts + */ + +import { render, screen } from '@testing-library/react'; +import React from 'react'; + +import { createCarouselTool } from '../SearchIndexTool'; + +import type { ClientSideToolComponentProps } from 'instantsearch-ui-components'; + +type TestHit = { + objectID: string; + name: string; + __position: number; +}; + +const mockItemComponent = ({ item }: { item: TestHit }) => ( +
{item.name}
+); + +describe('createCarouselTool', () => { + describe('SearchLayoutComponent', () => { + test('renders Agent Studio search tool calls', () => { + const tool = createCarouselTool(false, mockItemComponent); + const LayoutComponent = tool.layoutComponent!; + + const message: ClientSideToolComponentProps['message'] = { + type: 'tool-algolia_search_index', + state: 'output-available', + toolCallId: 'test-call-id', + input: { query: 'test', number_of_results: 3 }, + output: { + hits: [ + { objectID: '1', name: 'Product 1', __position: 1 }, + { objectID: '2', name: 'Product 2', __position: 2 }, + ], + nbHits: 100, + }, + }; + + render( + + ); + + expect(screen.getByText('Product 1')).toBeInTheDocument(); + expect(screen.getByText('Product 2')).toBeInTheDocument(); + }); + + test('renders Algolia MCP Server search tool calls', () => { + const tool = createCarouselTool(false, mockItemComponent); + const LayoutComponent = tool.layoutComponent!; + + const message: ClientSideToolComponentProps['message'] = { + type: 'tool-algolia_search_index_products', + state: 'output-available', + toolCallId: 'test-call-id', + input: { query: 'test' }, + output: { + hits: [ + { objectID: '1', name: 'MCP Product 1', __position: 1 }, + { objectID: '2', name: 'MCP Product 2', __position: 2 }, + ], + nbHits: 50, + }, + }; + + render( + + ); + + expect(screen.getByText('MCP Product 1')).toBeInTheDocument(); + expect(screen.getByText('MCP Product 2')).toBeInTheDocument(); + }); + }); +}); diff --git a/tests/utils/jest-environment-jsdom.ts b/tests/utils/jest-environment-jsdom.ts index 4427d7fc6d4..9c19586fce3 100644 --- a/tests/utils/jest-environment-jsdom.ts +++ b/tests/utils/jest-environment-jsdom.ts @@ -18,6 +18,7 @@ export default class Fixed extends JSDOMEnv { this.global.TextEncoder = TextEncoder; this.global.TextDecoder = TextDecoder as typeof global.TextDecoder; this.global.ReadableStream = ReadableStream; + this.global.Response = Response; } async setup() {