Skip to content
Draft
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
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -469,24 +466,16 @@ export function createChatMessageComponent({
return <span key={`${message.id}-${index}`}>{markdown}</span>;
}
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;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -481,6 +490,7 @@ export function createChatMessagesComponent({
props.message,
props.isCurrentMessage,
props.status,
shouldRenderVerdicts,
props.context.maximized,
props.context.open,
instantSearchStatus,
Expand Down Expand Up @@ -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.
Expand All @@ -622,6 +624,12 @@ export function createChatMessagesComponent({
onClose,
};

const showLoader = getShowLoader(
context,
assistantMessageProps?.showReasoning,
hasActiveReasoning
);

const showEmpty =
messages.length === 0 && !showLoader && !isClearing && status !== 'error';

Expand Down Expand Up @@ -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 = <TMessage extends ChatMessageBase>(
context: ChatComponentContext<TMessage>,
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 = <TMessage extends ChatMessageBase>(
context: ChatComponentContext<TMessage>,
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;
Expand Down
47 changes: 47 additions & 0 deletions packages/instantsearch-ui-components/src/components/chat/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<TMessage> & {
/**
* 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;
Expand Down
Original file line number Diff line number Diff line change
@@ -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', () => {
Expand Down Expand Up @@ -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".'
);
});
});
Loading
Loading