Skip to content

Commit b6ada7f

Browse files
committed
feat(chat): add compatibility with algolia mcp search tool [DASH-2294]
1 parent 23e0d9c commit b6ada7f

File tree

7 files changed

+275
-5
lines changed

7 files changed

+275
-5
lines changed

packages/instantsearch-ui-components/src/components/chat/ChatMessage.tsx

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,9 @@ export type ChatMessageProps = ComponentProps<'article'> & {
144144
translations?: Partial<ChatMessageTranslations>;
145145
};
146146

147+
// Keep in sync with packages/instantsearch.js/src/lib/chat/index.ts
148+
const SearchIndexToolType = 'algolia_search_index';
149+
147150
export function createChatMessageComponent({ createElement }: Renderer) {
148151
const Button = createButtonComponent({ createElement });
149152

@@ -208,7 +211,12 @@ export function createChatMessageComponent({ createElement }: Renderer) {
208211
}
209212
if (startsWith(part.type, 'tool-')) {
210213
const toolName = part.type.replace('tool-', '');
211-
const tool = tools[toolName];
214+
let tool = tools[toolName];
215+
216+
// Compatibility shim with Algolia MCP Server search tool
217+
if (!tool && startsWith(toolName, `${SearchIndexToolType}_`)) {
218+
tool = tools[SearchIndexToolType];
219+
}
212220

213221
if (tool) {
214222
const ToolLayoutComponent = tool.layoutComponent;

packages/instantsearch.js/src/connectors/chat/__tests__/connectChat-test.ts

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
*/
44

55
import { createSearchClient } from '@instantsearch/mocks';
6+
import { waitFor } from '@testing-library/dom';
67
import algoliasearchHelper from 'algoliasearch-helper';
78

89
import {
@@ -392,6 +393,58 @@ describe('connectChat', () => {
392393
});
393394
});
394395

396+
describe('default chat instance', () => {
397+
it('adds a compatibility layer for Algolia MCP Server search tool', async () => {
398+
const onSearchToolCall = jest.fn();
399+
400+
const { widget } = getInitializedWidget({
401+
agentId: undefined,
402+
transport: {
403+
fetch: () =>
404+
Promise.resolve(
405+
new Response(
406+
`data: {"type": "start", "messageId": "test-id"}
407+
408+
data: {"type": "start-step"}
409+
410+
data: {"type": "tool-input-available", "toolCallId": "call_1", "toolName": "algolia_search_index_movies", "input": {"query": "Toy Story", "attributesToRetrieve": ["year"], "hitsPerPage": 1}}
411+
412+
data: {"type":"tool-output-available","toolCallId":"call_1","output":{"results":[{"hits":[]}]}}
413+
414+
data: {"type": "finish-step"}
415+
416+
data: {"type": "finish"}
417+
418+
data: [DONE]`,
419+
{
420+
headers: { 'Content-Type': 'text/event-stream' },
421+
}
422+
)
423+
),
424+
},
425+
tools: { algolia_search_index: { onToolCall: onSearchToolCall } },
426+
});
427+
428+
const { chatInstance } = widget;
429+
430+
// Simulate sending a message that triggers the tool call
431+
await chatInstance.sendMessage({
432+
id: 'message-id',
433+
role: 'user',
434+
parts: [{ type: 'text', text: 'Trigger tool call' }],
435+
});
436+
437+
await waitFor(() => {
438+
expect(onSearchToolCall).toHaveBeenCalledWith(
439+
expect.objectContaining({
440+
toolCallId: 'call_1',
441+
toolName: 'algolia_search_index_movies',
442+
})
443+
);
444+
});
445+
});
446+
});
447+
395448
describe('transport configuration', () => {
396449
it('throws error when neither agentId nor transport is provided', () => {
397450
const renderFn = jest.fn();

packages/instantsearch.js/src/connectors/chat/connectChat.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import {
22
DefaultChatTransport,
33
lastAssistantMessageIsCompleteWithToolCalls,
44
} from '../../lib/ai-lite';
5-
import { Chat } from '../../lib/chat';
5+
import { Chat, SearchIndexToolType } from '../../lib/chat';
66
import {
77
checkRendering,
88
clearRefinements,
@@ -390,7 +390,15 @@ export default (function connectChat<TWidgetParams extends UnknownWidgetParams>(
390390
transport,
391391
sendAutomaticallyWhen: lastAssistantMessageIsCompleteWithToolCalls,
392392
onToolCall({ toolCall }) {
393-
const tool = tools[toolCall.toolName];
393+
let tool = tools[toolCall.toolName];
394+
395+
// Compatibility shim with Algolia MCP Server search tool
396+
if (
397+
!tool &&
398+
toolCall.toolName.startsWith(`${SearchIndexToolType}_`)
399+
) {
400+
tool = tools[SearchIndexToolType];
401+
}
394402

395403
if (!tool) {
396404
if (__DEV__) {

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

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@
33
*/
44
/** @jsx h */
55
import { createSearchClient } from '@instantsearch/mocks';
6+
import { wait } from '@instantsearch/testutils/wait';
7+
import { screen } from '@testing-library/dom';
68

79
import instantsearch from '../../../index.es';
810
import chat from '../chat';
@@ -28,4 +30,108 @@ describe('chat', () => {
2830
`);
2931
});
3032
});
33+
34+
describe('search tool compatibility', () => {
35+
test('renders search results from Agent Studio', async () => {
36+
const container = document.createElement('div');
37+
document.body.appendChild(container);
38+
39+
const search = instantsearch({
40+
indexName: 'indexName',
41+
searchClient: createSearchClient(),
42+
});
43+
44+
search.addWidgets([
45+
chat({
46+
container,
47+
agentId: 'test-agent-id',
48+
templates: { item: (hit) => `<div>${hit.name}</div>` },
49+
messages: [
50+
{
51+
id: 'assistant-message-id',
52+
role: 'assistant',
53+
parts: [
54+
{
55+
type: 'tool-algolia_search_index',
56+
toolCallId: 'tool-call-1',
57+
state: 'output-available',
58+
input: { query: 'products' },
59+
output: {
60+
hits: [
61+
{ objectID: '1', name: 'Product 1', __position: 1 },
62+
{ objectID: '2', name: 'Product 2', __position: 2 },
63+
],
64+
nbHits: 100,
65+
},
66+
},
67+
],
68+
},
69+
],
70+
}),
71+
]);
72+
73+
search.start();
74+
await wait(0);
75+
76+
expect(screen.getByText('Product 1')).toBeInTheDocument();
77+
expect(screen.getByText('Product 2')).toBeInTheDocument();
78+
});
79+
80+
test('renders search results from Algolia MCP Server', async () => {
81+
const container = document.createElement('div');
82+
document.body.appendChild(container);
83+
84+
const search = instantsearch({
85+
indexName: 'indexName',
86+
searchClient: createSearchClient(),
87+
});
88+
89+
search.addWidgets([
90+
chat({
91+
container,
92+
agentId: 'test-agent-id',
93+
templates: { item: (hit) => `<div>${hit.name}</div>` },
94+
messages: [
95+
{
96+
id: 'assistant-message-id',
97+
role: 'assistant',
98+
parts: [
99+
{
100+
type: 'tool-algolia_search_index_products',
101+
toolCallId: 'tool-call-1',
102+
state: 'output-available',
103+
input: { query: 'products' },
104+
output: {
105+
results: [
106+
{
107+
hits: [
108+
{
109+
objectID: '1',
110+
name: 'MCP Product 1',
111+
__position: 1,
112+
},
113+
{
114+
objectID: '2',
115+
name: 'MCP Product 2',
116+
__position: 2,
117+
},
118+
],
119+
nbHits: 50,
120+
},
121+
],
122+
},
123+
},
124+
],
125+
},
126+
],
127+
}),
128+
]);
129+
130+
search.start();
131+
await wait(0);
132+
133+
expect(screen.getByText('MCP Product 1')).toBeInTheDocument();
134+
expect(screen.getByText('MCP Product 2')).toBeInTheDocument();
135+
});
136+
});
31137
});

packages/instantsearch.js/src/widgets/chat/chat.tsx

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -107,7 +107,6 @@ function createCarouselTool<
107107
nbHits?: number;
108108
}
109109
| undefined;
110-
111110
const items = output?.hits || [];
112111

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

500499
const toolsForUi: ClientSideTools = {};
501500
Object.entries(toolsFromConnector).forEach(([key, connectorTool]) => {
502-
const widgetTool = tools[key];
501+
let widgetTool = tools[key];
502+
503+
// Compatibility shim with Algolia MCP Server search tool
504+
if (!widgetTool && key.startsWith(`${SearchIndexToolType}_`)) {
505+
widgetTool = tools[SearchIndexToolType];
506+
}
503507

504508
toolsForUi[key] = {
505509
...connectorTool,
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
/**
2+
* @jest-environment @instantsearch/testutils/jest-environment-jsdom.ts
3+
*/
4+
5+
import { render, screen } from '@testing-library/react';
6+
import React from 'react';
7+
8+
import { createCarouselTool } from '../SearchIndexTool';
9+
10+
import type { ClientSideToolComponentProps } from 'instantsearch-ui-components';
11+
12+
type TestHit = {
13+
objectID: string;
14+
name: string;
15+
__position: number;
16+
};
17+
18+
const mockItemComponent = ({ item }: { item: TestHit }) => (
19+
<div data-testid={`item-${item.objectID}`}>{item.name}</div>
20+
);
21+
22+
describe('createCarouselTool', () => {
23+
describe('SearchLayoutComponent', () => {
24+
test('renders Agent Studio search tool calls', () => {
25+
const tool = createCarouselTool<TestHit>(false, mockItemComponent);
26+
const LayoutComponent = tool.layoutComponent!;
27+
28+
const message: ClientSideToolComponentProps['message'] = {
29+
type: 'tool-algolia_search_index',
30+
state: 'output-available',
31+
toolCallId: 'test-call-id',
32+
input: { query: 'test', number_of_results: 3 },
33+
output: {
34+
hits: [
35+
{ objectID: '1', name: 'Product 1', __position: 1 },
36+
{ objectID: '2', name: 'Product 2', __position: 2 },
37+
],
38+
nbHits: 100,
39+
},
40+
};
41+
42+
render(
43+
<LayoutComponent
44+
message={message}
45+
applyFilters={jest.fn()}
46+
onClose={jest.fn()}
47+
indexUiState={{}}
48+
addToolResult={jest.fn()}
49+
setIndexUiState={jest.fn()}
50+
/>
51+
);
52+
53+
expect(screen.getByText('Product 1')).toBeInTheDocument();
54+
expect(screen.getByText('Product 2')).toBeInTheDocument();
55+
});
56+
57+
test('renders Algolia MCP Server search tool calls', () => {
58+
const tool = createCarouselTool<TestHit>(false, mockItemComponent);
59+
const LayoutComponent = tool.layoutComponent!;
60+
61+
const message: ClientSideToolComponentProps['message'] = {
62+
type: 'tool-algolia_search_index_products',
63+
state: 'output-available',
64+
toolCallId: 'test-call-id',
65+
input: { query: 'test' },
66+
output: {
67+
hits: [
68+
{ objectID: '1', name: 'MCP Product 1', __position: 1 },
69+
{ objectID: '2', name: 'MCP Product 2', __position: 2 },
70+
],
71+
nbHits: 50,
72+
},
73+
};
74+
75+
render(
76+
<LayoutComponent
77+
message={message}
78+
applyFilters={jest.fn()}
79+
onClose={jest.fn()}
80+
indexUiState={{}}
81+
addToolResult={jest.fn()}
82+
setIndexUiState={jest.fn()}
83+
/>
84+
);
85+
86+
expect(screen.getByText('MCP Product 1')).toBeInTheDocument();
87+
expect(screen.getByText('MCP Product 2')).toBeInTheDocument();
88+
});
89+
});
90+
});

tests/utils/jest-environment-jsdom.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ export default class Fixed extends JSDOMEnv {
1818
this.global.TextEncoder = TextEncoder;
1919
this.global.TextDecoder = TextDecoder as typeof global.TextDecoder;
2020
this.global.ReadableStream = ReadableStream;
21+
this.global.Response = Response;
2122
}
2223

2324
async setup() {

0 commit comments

Comments
 (0)