Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion bundlesize.config.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,9 @@ export type ChatMessageProps = ComponentProps<'article'> & {
translations?: Partial<ChatMessageTranslations>;
};

// 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 });

Expand Down Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
*/

import { createSearchClient } from '@instantsearch/mocks';
import { waitFor } from '@testing-library/dom';
import algoliasearchHelper from 'algoliasearch-helper';

import {
Expand Down Expand Up @@ -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();
Expand Down
12 changes: 10 additions & 2 deletions packages/instantsearch.js/src/connectors/chat/connectChat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -390,7 +390,15 @@ export default (function connectChat<TWidgetParams extends UnknownWidgetParams>(
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__) {
Expand Down
102 changes: 102 additions & 0 deletions packages/instantsearch.js/src/widgets/chat/__tests__/chat.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -28,4 +30,104 @@
`);
});
});

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) => `<div>${hit.name}</div>` },

Check warning on line 48 in packages/instantsearch.js/src/widgets/chat/__tests__/chat.test.tsx

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

packages/instantsearch.js/src/widgets/chat/__tests__/chat.test.tsx#L48

This template literal looks like HTML and has interpolated variables.
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) => `<div>${hit.name}</div>` },
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();
});
});
});
8 changes: 6 additions & 2 deletions packages/instantsearch.js/src/widgets/chat/chat.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,6 @@ function createCarouselTool<
nbHits?: number;
}
| undefined;

const items = output?.hits || [];

const MemoedHeaderComponent = useMemo(() => {
Expand Down Expand Up @@ -499,7 +498,12 @@ const createRenderer = <THit extends RecordWithObjectID = RecordWithObjectID>({

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];
}
Comment on lines +504 to +506
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we did the shim in the InstantSearch.js widget, but not the React InstantSearch widget, is that on purpose?

Copy link
Copy Markdown
Contributor Author

@kombucha kombucha Feb 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It should be handled for the react flavour too.
The tools config is passed down through a few layers of component and is finally handled in packages/instantsearch-ui-components/src/components/chat/ChatMessage.tsx which I updated 👍

Copy link
Copy Markdown
Contributor

@Haroenv Haroenv Feb 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

makes sense! This probably changes with #6874 that's just for @shaejaz to take in account

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this would remain the same since you still need to map a tool into one with a layoutComponent in the js chat widget (and not in react) for it to be consumable in ui-components.

I'll try to see if this can be avoided in my PR tho.


toolsForUi[key] = {
...connectorTool,
Expand Down
Original file line number Diff line number Diff line change
@@ -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 }) => (
<div data-testid={`item-${item.objectID}`}>{item.name}</div>
);

describe('createCarouselTool', () => {
describe('SearchLayoutComponent', () => {
test('renders Agent Studio search tool calls', () => {
const tool = createCarouselTool<TestHit>(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(
<LayoutComponent
message={message}
applyFilters={jest.fn()}
onClose={jest.fn()}
indexUiState={{}}
addToolResult={jest.fn()}
setIndexUiState={jest.fn()}
/>
);

expect(screen.getByText('Product 1')).toBeInTheDocument();
expect(screen.getByText('Product 2')).toBeInTheDocument();
});

test('renders Algolia MCP Server search tool calls', () => {
const tool = createCarouselTool<TestHit>(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(
<LayoutComponent
message={message}
applyFilters={jest.fn()}
onClose={jest.fn()}
indexUiState={{}}
addToolResult={jest.fn()}
setIndexUiState={jest.fn()}
/>
);

expect(screen.getByText('MCP Product 1')).toBeInTheDocument();
expect(screen.getByText('MCP Product 2')).toBeInTheDocument();
});
});
});
1 change: 1 addition & 0 deletions tests/utils/jest-environment-jsdom.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down