Skip to content

Commit 034e740

Browse files
feat(chat): hydrate the comparison table from the selected records (#7262)
The chat records store only ingested tool outputs holding `hits`, so the `algolia_compare_products` table could only be filled after the agent re-searched the products the shopper had just selected — a redundant round-trip, since the compare connector already attaches those records to its user message (`metadata.turnContext.selected_products`). `collectChatRecords` now also merges that selection, in message order, so a record the agent does re-fetch later still wins. Malformed payloads are ignored, and only user messages count. Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent d8069fa commit 034e740

8 files changed

Lines changed: 132 additions & 19 deletions

File tree

‎packages/instantsearch-ui-components/src/components/chat/tools/CompareProductsTool.tsx‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,9 @@ import type { ComparisonTableTranslations } from './createGroundedComparisonTabl
1515
* The agent triggers this client-side tool when it detects a comparison
1616
* request. Its arguments name ONLY the products (by objectID) and the attribute
1717
* *keys* to compare — never the values. Every cell is hydrated from the chat
18-
* records store (`context.records`, filled by the search tools), so the model
19-
* physically cannot type (and therefore cannot hallucinate) a price or spec.
18+
* records store (`context.records`, filled by the shopper's comparison
19+
* selection and by the search tools), so the model physically cannot type
20+
* (and therefore cannot hallucinate) a price or spec.
2021
* This is the "grounded table" fix from the agentic-evals comparison study,
2122
* promoted from the display-block prototype to a first-class tool.
2223
*

‎packages/instantsearch-ui-components/src/components/chat/types.ts‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -683,8 +683,9 @@ export type ClientSideToolContext<
683683
> = ChatComponentContext<TMessage> & {
684684
message: ChatToolMessage;
685685
/**
686-
* The records the chat's tools have fetched. A tool handed plain object IDs
687-
* hydrates them with `records.get(objectID)`.
686+
* The records the conversation holds (fetched by tools or selected for a
687+
* comparison). A tool handed plain object IDs hydrates them with
688+
* `records.get(objectID)`.
688689
*/
689690
records?: ChatRecordsStore;
690691
insightsEventContext?: ChatInsightsEventContext;

‎packages/instantsearch-ui-components/src/lib/utils/__tests__/chatRecords-test.ts‎

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,68 @@ describe('createChatRecordsStore', () => {
135135
});
136136

137137
describe('collectChatRecords', () => {
138+
const compareMessage = (id: string, selectedProducts: unknown) =>
139+
({
140+
id,
141+
role: 'user',
142+
parts: [
143+
{ type: 'text', text: 'Compare these products: Runner vs Trail' },
144+
],
145+
metadata: { turnContext: { selected_products: selectedProducts } },
146+
}) as ChatMessageBase;
147+
148+
test('collects the records a comparison selection carries', () => {
149+
// The compare connector attaches the selected records to its user message
150+
// (JSON-encoded, per the Agent Studio contract), so the comparison table
151+
// can be built from them without the agent searching for them again.
152+
const store = collectChatRecords([
153+
compareMessage(
154+
'1',
155+
JSON.stringify([
156+
{ objectID: '1', name: 'Runner', price: 120 },
157+
{ objectID: '3', name: 'Trail', price: 140 },
158+
])
159+
),
160+
]);
161+
162+
expect(store.getAll()).toEqual({
163+
1: { objectID: '1', name: 'Runner', price: 120 },
164+
3: { objectID: '3', name: 'Trail', price: 140 },
165+
});
166+
});
167+
168+
test('lets a record the agent re-fetched after the selection win', () => {
169+
const store = collectChatRecords([
170+
compareMessage('1', JSON.stringify([{ objectID: '1', name: 'Runner' }])),
171+
assistantMessage('2', [
172+
searchPart('search', [{ objectID: '1', name: 'Runner Pro' }]),
173+
]),
174+
]);
175+
176+
expect(store.get('1')).toEqual({ objectID: '1', name: 'Runner Pro' });
177+
});
178+
179+
test('ignores a malformed or absent selection', () => {
180+
const store = collectChatRecords([
181+
compareMessage('1', 'not json'),
182+
compareMessage('2', JSON.stringify({ objectID: 'not-an-array' })),
183+
compareMessage('3', JSON.stringify(['1', null, { name: 'no objectID' }])),
184+
compareMessage('4', undefined),
185+
{ id: '5', role: 'user', parts: [{ type: 'text', text: 'hi' }] },
186+
// Only the user's own selection counts, not a look-alike on a reply.
187+
{
188+
...assistantMessage('6', []),
189+
metadata: {
190+
turnContext: {
191+
selected_products: JSON.stringify([{ objectID: '9' }]),
192+
},
193+
},
194+
},
195+
]);
196+
197+
expect(store.getAll()).toEqual({});
198+
});
199+
138200
test('combines the records of every tool call of the conversation', () => {
139201
const store = collectChatRecords([
140202
assistantMessage('1', [

‎packages/instantsearch-ui-components/src/lib/utils/chatRecords.ts‎

Lines changed: 49 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,8 @@ export type ChatRecord = Record<string, unknown> & { objectID: string };
88
export type ChatRecords = Record<string, ChatRecord>;
99

1010
/**
11-
* The records the chat's tools have fetched, keyed by `objectID`: the shared
11+
* The records the conversation holds, keyed by `objectID`: what the chat's
12+
* tools fetched plus what the shopper selected for a comparison — the shared
1213
* lookup between the tools that search and the tools handed only identifiers.
1314
*/
1415
export type ChatRecordsStore = {
@@ -51,19 +52,64 @@ export function createChatRecordsStore(): ChatRecordsStore {
5152
};
5253
}
5354

55+
/**
56+
* Turn-context key under which the compare connector attaches the shopper's
57+
* selection to its user message (`metadata.turnContext.selected_products`), as
58+
* a JSON-encoded array of records — the Agent Studio comparison contract.
59+
*/
60+
const SELECTED_PRODUCTS_KEY = 'selected_products';
61+
62+
/**
63+
* The records a user message carries as a comparison selection, or `[]` when
64+
* it carries none. The payload is client-authored, so anything unexpected
65+
* (not JSON, not an array) is treated as "no records" rather than thrown.
66+
*/
67+
function selectedRecords(message: ChatMessageBase): ChatRecord[] {
68+
if (message.role !== 'user') {
69+
return [];
70+
}
71+
72+
const turnContext = (
73+
message.metadata as
74+
| { turnContext?: Record<string, unknown> | null }
75+
| undefined
76+
| null
77+
)?.turnContext;
78+
const raw = turnContext?.[SELECTED_PRODUCTS_KEY];
79+
80+
if (typeof raw !== 'string') {
81+
return [];
82+
}
83+
84+
try {
85+
const parsed: unknown = JSON.parse(raw);
86+
return Array.isArray(parsed) ? (parsed as ChatRecord[]) : [];
87+
} catch {
88+
return [];
89+
}
90+
}
91+
5492
/**
5593
* Collects into `store` the records of every completed tool call in `messages`
56-
* whose output holds `hits`. Matching on the output rather than on a tool name
57-
* means any tool that fetches records contributes them by returning them.
94+
* whose output holds `hits`, plus the records a user message carries as a
95+
* comparison selection. Matching on the output rather than on a tool name
96+
* means any tool that fetches records contributes them by returning them; the
97+
* selection counts because the compare flow already hands the agent the very
98+
* records it is asked to compare, so the table can be built from them without
99+
* a search round-trip.
58100
*
59101
* Idempotent, so it can re-run over the whole conversation on every update:
60102
* streaming deltas and a conversation restored from storage are one code path.
103+
* Later messages win per `objectID`, so a record the agent re-fetched after the
104+
* selection replaces the selected copy.
61105
*/
62106
export function collectChatRecords(
63107
messages: ChatMessageBase[] | undefined,
64108
store: ChatRecordsStore = createChatRecordsStore()
65109
): ChatRecordsStore {
66110
messages?.forEach((message) => {
111+
store.merge(selectedRecords(message));
112+
67113
message.parts.forEach((part) => {
68114
if (!isPartTool(part) || part.state !== 'output-available') {
69115
return;

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

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -125,9 +125,10 @@ export type ChatRenderState<TUiMessage extends UIMessage = UIMessage> = {
125125
*/
126126
tools: ClientSideTools;
127127
/**
128-
* The records the chat's tools have fetched, keyed by `objectID`: every tool
129-
* call that returned `hits` contributes them, last write winning. Attached to
130-
* every tool too, which is how a `layoutComponent` reads it.
128+
* The records the conversation holds, keyed by `objectID`: every tool call
129+
* that returned `hits` contributes them, as does the selection a `compare`
130+
* widget attaches to its message, last write winning. Attached to every tool
131+
* too, which is how a `layoutComponent` reads it.
131132
*/
132133
records: ChatRecordsStore;
133134
/**

‎packages/instantsearch.js/src/connectors/compare/connectCompare.ts‎

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -138,11 +138,11 @@ export function getComparisonPlaceholderMessage(
138138
}
139139

140140
/**
141-
* Builds the default comparison message. It names each selected item so the
142-
* agent can ground the comparison: it re-retrieves the records with
143-
* `algolia_search_index` and renders them with the builtin
144-
* `algolia_compare_products` tool. Works with the default shopping assistant
145-
* prompt — no agent configuration required.
141+
* Builds the default comparison message. It names each selected item; the
142+
* records themselves travel in the turn context (`selected_products`), where
143+
* both the agent and the builtin `algolia_compare_products` table read them,
144+
* so no search round-trip is needed to render the comparison. Works with the
145+
* default shopping assistant prompt — no agent configuration required.
146146
*/
147147
export function getDefaultComparisonMessage(items: CompareItem[]): string {
148148
const labels = items.map((item) => {

‎packages/instantsearch.js/src/widgets/chat/compare-products-tool.tsx‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,9 @@ import type { ComparisonTableTranslations } from 'instantsearch-ui-components';
1414
*
1515
* Registered by default in the chat widget so the agent can trigger a grounded
1616
* side-by-side comparison: its tool call names only the product objectIDs and
17-
* the attribute keys, and every cell is hydrated client-side from the real
18-
* `algolia_search_index` hits. The model never types a value, so it cannot
17+
* the attribute keys, and every cell is hydrated client-side from real records
18+
* (the shopper's selection and `algolia_search_index` hits). The model never
19+
* types a value, so it cannot
1920
* hallucinate one. See `instantsearch-ui-components` `CompareProductsTool` for
2021
* the contract.
2122
*/

‎packages/react-instantsearch/src/widgets/chat/tools/CompareProductsTool.tsx‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,9 @@ import type {
1313
*
1414
* Registered by default in the chat widget so the agent can trigger a grounded
1515
* side-by-side comparison: its tool call names only the product objectIDs and
16-
* the attribute keys, and every cell is hydrated client-side from the real
17-
* `algolia_search_index` hits. The model never types a value, so it cannot
16+
* the attribute keys, and every cell is hydrated client-side from real records
17+
* (the shopper's selection and `algolia_search_index` hits). The model never
18+
* types a value, so it cannot
1819
* hallucinate one. See `instantsearch-ui-components` `CompareProductsTool` for
1920
* the contract, and `comparison-eval/README.md` for the study behind it.
2021
*/

0 commit comments

Comments
 (0)