Skip to content

Commit 8438de3

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

File tree

12 files changed

+339
-19
lines changed

12 files changed

+339
-19
lines changed

packages/instantsearch-ui-components/__tests__/types.test.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,13 @@ function delint(sourceFile: ts.SourceFile) {
1515
}> = [];
1616
const fileName = sourceFile.fileName.replace('.d.ts', '');
1717

18+
// Files containing components are in PascalCase (except for icons)
19+
const isCamelCaseFileName =
20+
fileName.charAt(0).toLowerCase() + fileName.charAt(0);
21+
1822
if (fileName === 'icons') {
1923
validateIcons(sourceFile, report);
20-
} else {
24+
} else if (!isCamelCaseFileName) {
2125
validateComponents(sourceFile, fileName, report);
2226
}
2327

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;
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import { parseSearchToolOutput } from '../searchToolHelpers';
2+
3+
describe('parseSearchToolOutput', () => {
4+
it.each([
5+
['null', null],
6+
['undefined', undefined],
7+
['empty string', ''],
8+
['empty results', { results: [] }],
9+
])('returns undefined for empty inputs: %s', (_, input) => {
10+
expect(parseSearchToolOutput(input)).toBeUndefined();
11+
});
12+
13+
it('returns undefined for non-object input', () => {
14+
expect(parseSearchToolOutput('string')).toBeUndefined();
15+
});
16+
17+
it('returns direct output format unchanged', () => {
18+
const output = { hits: [{ objectID: '1' }], nbHits: 1 };
19+
expect(parseSearchToolOutput(output)).toEqual(output);
20+
});
21+
22+
it('unwraps Algolia MCP Server format (results array)', () => {
23+
const innerResult = { hits: [{ objectID: '1' }], nbHits: 1 };
24+
const mcpFormat = { results: [innerResult] };
25+
26+
expect(parseSearchToolOutput(mcpFormat)).toEqual(innerResult);
27+
});
28+
});
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import type { RecordWithObjectID } from '../../types';
2+
3+
export type SearchToolOutput<TObject> = {
4+
hits?: Array<RecordWithObjectID<TObject>>;
5+
nbHits?: number;
6+
};
7+
8+
export function parseSearchToolOutput<TObject>(
9+
output: unknown
10+
): SearchToolOutput<TObject> | undefined {
11+
if (!output || typeof output !== 'object') {
12+
return undefined;
13+
}
14+
15+
// Format returned by Algolia MCP Server search tool
16+
if ('results' in output && Array.isArray(output?.results)) {
17+
output = output.results[0];
18+
}
19+
20+
return output as SearchToolOutput<TObject>;
21+
}

packages/instantsearch-ui-components/src/components/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ export * from './chat/ChatPromptSuggestions';
1212
export * from './chat/ChatToggleButton';
1313
export * from './chat/icons';
1414
export * from './chat/types';
15+
export * from './chat/searchToolHelpers';
1516
export * from './FrequentlyBoughtTogether';
1617
export * from './Highlight';
1718
export * from './Hits';

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: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
ChevronRightIcon,
77
createButtonComponent,
88
createChatComponent,
9+
parseSearchToolOutput,
910
} from 'instantsearch-ui-components';
1011
import { Fragment, h, render } from 'preact';
1112
import { useMemo } from 'preact/hooks';
@@ -101,13 +102,7 @@ function createCarouselTool<
101102
}
102103
| undefined;
103104

104-
const output = message?.output as
105-
| {
106-
hits?: Array<RecordWithObjectID<THit>>;
107-
nbHits?: number;
108-
}
109-
| undefined;
110-
105+
const output = parseSearchToolOutput<THit>(message?.output);
111106
const items = output?.hits || [];
112107

113108
const MemoedHeaderComponent = useMemo(() => {
@@ -499,7 +494,12 @@ const createRenderer = <THit extends RecordWithObjectID = RecordWithObjectID>({
499494

500495
const toolsForUi: ClientSideTools = {};
501496
Object.entries(toolsFromConnector).forEach(([key, connectorTool]) => {
502-
const widgetTool = tools[key];
497+
let widgetTool = tools[key];
498+
499+
// Compatibility shim with Algolia MCP Server search tool
500+
if (!widgetTool && key.startsWith(`${SearchIndexToolType}_`)) {
501+
widgetTool = tools[SearchIndexToolType];
502+
}
503503

504504
toolsForUi[key] = {
505505
...connectorTool,

packages/react-instantsearch/src/widgets/chat/tools/SearchIndexTool.tsx

Lines changed: 2 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import {
33
ChevronRightIcon,
44
ArrowRightIcon,
55
createButtonComponent,
6+
parseSearchToolOutput,
67
} from 'instantsearch-ui-components';
78
import React, { createElement } from 'react';
89

@@ -47,13 +48,7 @@ function createCarouselTool<TObject extends RecordWithObjectID>(
4748
}
4849
| undefined;
4950

50-
const output = message?.output as
51-
| {
52-
hits?: Array<RecordWithObjectID<TObject>>;
53-
nbHits?: number;
54-
}
55-
| undefined;
56-
51+
const output = parseSearchToolOutput<TObject>(message?.output);
5752
const items = output?.hits || [];
5853

5954
const MemoedHeaderComponent = React.useMemo(() => {

0 commit comments

Comments
 (0)