From 55b01023acff0935e7f6c4e652b3c1f051f1f262 Mon Sep 17 00:00:00 2001 From: Anjola Adeuyi <57623705+anjola-adeuyi@users.noreply.github.com> Date: Tue, 23 Jun 2026 17:28:06 +0200 Subject: [PATCH 1/6] feat(chat): add request options to agent requests --- .../chat/__tests__/connectChat-test.ts | 182 +++++++++++++++++- .../src/connectors/chat/connectChat.ts | 64 +++++- 2 files changed, 233 insertions(+), 13 deletions(-) diff --git a/packages/instantsearch.js/src/connectors/chat/__tests__/connectChat-test.ts b/packages/instantsearch.js/src/connectors/chat/__tests__/connectChat-test.ts index 5ae6f095ef..6bd270ccb9 100644 --- a/packages/instantsearch.js/src/connectors/chat/__tests__/connectChat-test.ts +++ b/packages/instantsearch.js/src/connectors/chat/__tests__/connectChat-test.ts @@ -30,7 +30,7 @@ describe('connectChat', () => { ...(!('agentId' in widgetParams) ? { agentId: 'agentId' } : {}), disableTriggerValidation: true, ...widgetParams, - }); + } as ChatConnectorParams); const helper = algoliasearchHelper(createSearchClient(), ''); @@ -70,6 +70,52 @@ describe('connectChat', () => { }) ); }); + + it('types requestOptions as agentId-only', () => { + const assertChatConnectorParams = (_params: ChatConnectorParams) => + undefined; + const customChat = undefined as unknown as Chat; + + assertChatConnectorParams({ + agentId: 'agentId', + requestOptions: { + queryParameters: { cache: false }, + headers: { 'x-algolia-referer': 'chat-widget' }, + }, + }); + + assertChatConnectorParams({ + agentId: 'agentId', + transport: { api: 'https://custom.api' }, + }); + + // @ts-expect-error requestOptions is only valid with agentId + assertChatConnectorParams({ + transport: { api: 'https://custom.api' }, + requestOptions: { + queryParameters: { cache: false }, + }, + }); + + // @ts-expect-error requestOptions is not valid when a custom transport is provided + assertChatConnectorParams({ + agentId: 'agentId', + transport: { api: 'https://custom.api' }, + requestOptions: { + queryParameters: { cache: false }, + }, + }); + + assertChatConnectorParams({ + // @ts-expect-error requestOptions is not valid with a custom chat instance + chat: customChat, + requestOptions: { + queryParameters: { cache: false }, + }, + }); + + expect(true).toBe(true); + }); }); describe('getWidgetRenderState', () => { @@ -1060,8 +1106,9 @@ data: [DONE]`, }); function getRequestPayload() { - const [, init] = fetchMock.mock.calls[0]; + const [url, init] = fetchMock.mock.calls[0]; return { + url: String(url), headers: init.headers as Record, body: JSON.parse(init.body as string), }; @@ -1108,6 +1155,78 @@ data: [DONE]`, ); }); + it('sends persistent query parameters on agent requests', async () => { + const { widget } = getInitializedWidget({ + agentId: 'agentId', + requestOptions: { + queryParameters: { + cache: false, + hitsPerPage: 4, + explain: true, + userToken: 'user-1', + }, + }, + }); + + await widget.chatInstance.sendMessage({ text: 'hello' }); + + const { url } = getRequestPayload(); + const searchParams = new URL(url).searchParams; + expect(searchParams.get('compatibilityMode')).toBe('ai-sdk-5'); + expect(searchParams.get('cache')).toBe('false'); + expect(searchParams.get('hitsPerPage')).toBe('4'); + expect(searchParams.get('explain')).toBe('true'); + expect(searchParams.get('userToken')).toBe('user-1'); + }); + + it('sends persistent headers on agent requests', async () => { + const { widget } = getInitializedWidget({ + agentId: 'agentId', + requestOptions: { + headers: { + 'x-algolia-referer': 'chat-widget', + 'x-session-id': 'session-1', + }, + }, + }); + + await widget.chatInstance.sendMessage({ text: 'hello' }); + + const { headers } = getRequestPayload(); + expect(headers).toEqual( + expect.objectContaining({ + 'x-algolia-application-id': 'appId', + 'x-algolia-api-key': 'apiKey', + 'x-algolia-referer': 'chat-widget', + 'x-session-id': 'session-1', + }) + ); + }); + + it('sends persistent Headers instance on agent requests', async () => { + const { widget } = getInitializedWidget({ + agentId: 'agentId', + requestOptions: { + headers: new Headers({ + 'x-algolia-referer': 'chat-widget', + 'x-session-id': 'session-1', + }), + }, + }); + + await widget.chatInstance.sendMessage({ text: 'hello' }); + + const { headers } = getRequestPayload(); + expect(headers).toEqual( + expect.objectContaining({ + 'x-algolia-application-id': 'appId', + 'x-algolia-api-key': 'apiKey', + 'x-algolia-referer': 'chat-widget', + 'x-session-id': 'session-1', + }) + ); + }); + it('does not register `chat` on the search client user-agent', () => { const addAlgoliaAgent = jest.fn(); const client = Object.assign(createSearchClient(), { @@ -1147,6 +1266,41 @@ data: [DONE]`, }); }); + it('lets per-call headers override persistent headers for one request', async () => { + const { widget } = getInitializedWidget({ + agentId: 'agentId', + requestOptions: { + headers: { + 'x-algolia-referer': 'chat-widget', + }, + }, + }); + + await widget.chatInstance.sendMessage( + { text: 'hello' }, + { headers: { 'x-algolia-referer': 'prompt-suggestions' } } + ); + await widget.chatInstance.sendMessage({ text: 'follow-up' }); + + const firstHeaders = fetchMock.mock.calls[0][1].headers as Record< + string, + string + >; + const secondHeaders = fetchMock.mock.calls[1][1].headers as Record< + string, + string + >; + + expect(firstHeaders).toHaveProperty( + 'x-algolia-referer', + 'prompt-suggestions' + ); + expect(secondHeaders).toHaveProperty( + 'x-algolia-referer', + 'chat-widget' + ); + }); + it('does not carry over the x-algolia-referer to follow-up messages', async () => { const { widget } = getInitializedWidget({ agentId: 'agentId' }); @@ -1172,13 +1326,33 @@ data: [DONE]`, expect(secondHeaders).not.toHaveProperty('x-algolia-referer'); }); + it('forces cache=false when regenerating with persistent cache query parameter', async () => { + const { widget } = getInitializedWidget({ + agentId: 'agentId', + requestOptions: { + queryParameters: { + cache: true, + }, + }, + }); + + await widget.chatInstance.regenerate(); + + const { url } = getRequestPayload(); + expect(new URL(url).searchParams.get('cache')).toBe('false'); + }); + it('does not duplicate transport metadata in the request body', async () => { const { widget } = getInitializedWidget({ agentId: 'agentId' }); await widget.chatInstance.sendMessage({ text: 'hello' }); const { body } = getRequestPayload(); - expect(Object.keys(body).sort()).toEqual(['id', 'messageId', 'messages']); + expect(Object.keys(body).sort()).toEqual([ + 'id', + 'messageId', + 'messages', + ]); expect(body).not.toHaveProperty('headers'); expect(body).not.toHaveProperty('api'); expect(body).not.toHaveProperty('credentials'); @@ -1260,7 +1434,7 @@ data: [DONE]`, const widget = makeWidget({ ...params, transport: { api: 'http://unused' }, - }); + } as ChatConnectorParams); return { widget, renderFn }; } diff --git a/packages/instantsearch.js/src/connectors/chat/connectChat.ts b/packages/instantsearch.js/src/connectors/chat/connectChat.ts index b6d1a671c7..b5097b4f9b 100644 --- a/packages/instantsearch.js/src/connectors/chat/connectChat.ts +++ b/packages/instantsearch.js/src/connectors/chat/connectChat.ts @@ -122,18 +122,41 @@ export type ChatInitWithoutTransport = Omit< 'transport' >; -export type ChatTransport = { - transport?: ConstructorParameters[0]; -} & ( +export type ChatAgentRequestOptions = { + queryParameters?: Record; + headers?: Record | Headers; +}; + +export type ChatTransport = | { agentId: string; + transport?: never; + requestOptions?: ChatAgentRequestOptions; /** * Whether to enable feedback (thumbs up/down) on assistant messages. */ feedback?: boolean; } - | { agentId?: undefined; feedback?: never } -); + | { + agentId: string; + transport?: ConstructorParameters[0]; + feedback?: boolean; + requestOptions?: never; + } + | { + agentId?: undefined; + transport?: ConstructorParameters[0]; + feedback?: never; + requestOptions?: never; + }; + +export type ChatCustomInstance = { + chat: Chat; + agentId?: undefined; + transport?: ConstructorParameters[0]; + feedback?: never; + requestOptions?: never; +}; export type ApplyFiltersParams = { query?: string; @@ -144,7 +167,7 @@ export type ChatInit = ChatInitWithoutTransport & ChatTransport; export type ChatConnectorParams = ( - | { chat: Chat } + | ChatCustomInstance | ChatInit ) & { /** @@ -473,20 +496,43 @@ export default (function connectChat( ); } - const baseApi = `https://${appId}.algolia.net/agent-studio/1/agents/${agentId}/completions?compatibilityMode=ai-sdk-5`; + const createApi = ({ bypassCache = false } = {}) => { + const api = new URL( + `https://${appId}.algolia.net/agent-studio/1/agents/${agentId}/completions` + ); + api.searchParams.set('compatibilityMode', 'ai-sdk-5'); + Object.entries(options.requestOptions?.queryParameters || {}).forEach( + ([key, value]) => { + api.searchParams.set(key, String(value)); + } + ); + if (bypassCache) { + api.searchParams.set('cache', 'false'); + } + return api.toString(); + }; + const baseApi = createApi(); transport = new DefaultChatTransport({ api: baseApi, headers: { 'x-algolia-application-id': appId, 'x-algolia-api-key': apiKey, 'x-algolia-agent': `${getAlgoliaAgent(client)}; chat`, + ...(options.requestOptions?.headers instanceof Headers + ? Object.fromEntries(options.requestOptions.headers.entries()) + : options.requestOptions?.headers), }, - prepareSendMessagesRequest: ({ id, messages, trigger, messageId }) => { + prepareSendMessagesRequest: ({ + id, + messages, + trigger, + messageId, + }) => { return { // Bypass cache when regenerating to ensure fresh responses api: trigger === 'regenerate-message' - ? `${baseApi}&cache=false` + ? createApi({ bypassCache: true }) : baseApi, body: { id, From c358af980b393e91a87b927eb53c09a9baf1576a Mon Sep 17 00:00:00 2001 From: Anjola Adeuyi <57623705+anjola-adeuyi@users.noreply.github.com> Date: Wed, 24 Jun 2026 08:55:28 +0200 Subject: [PATCH 2/6] feat(chat): protect built-in request params from requestOptions override --- .../chat/__tests__/connectChat-test.ts | 42 +++++++++++++++++++ .../src/connectors/chat/connectChat.ts | 19 +++++++-- 2 files changed, 57 insertions(+), 4 deletions(-) diff --git a/packages/instantsearch.js/src/connectors/chat/__tests__/connectChat-test.ts b/packages/instantsearch.js/src/connectors/chat/__tests__/connectChat-test.ts index 6bd270ccb9..624a996540 100644 --- a/packages/instantsearch.js/src/connectors/chat/__tests__/connectChat-test.ts +++ b/packages/instantsearch.js/src/connectors/chat/__tests__/connectChat-test.ts @@ -1179,6 +1179,25 @@ data: [DONE]`, expect(searchParams.get('userToken')).toBe('user-1'); }); + it('keeps the built-in compatibility mode on agent requests', async () => { + const { widget } = getInitializedWidget({ + agentId: 'agentId', + requestOptions: { + queryParameters: { + compatibilityMode: 'custom', + userToken: 'user-1', + }, + }, + }); + + await widget.chatInstance.sendMessage({ text: 'hello' }); + + const { url } = getRequestPayload(); + const searchParams = new URL(url).searchParams; + expect(searchParams.get('compatibilityMode')).toBe('ai-sdk-5'); + expect(searchParams.get('userToken')).toBe('user-1'); + }); + it('sends persistent headers on agent requests', async () => { const { widget } = getInitializedWidget({ agentId: 'agentId', @@ -1227,6 +1246,29 @@ data: [DONE]`, ); }); + it('keeps the x-algolia-agent chat marker even when requestOptions tries to override it', async () => { + const { widget } = getInitializedWidget({ + agentId: 'agentId', + requestOptions: { + headers: { + 'x-algolia-application-id': 'spoofed-app', + 'x-algolia-api-key': 'spoofed-key', + 'x-algolia-agent': 'spoofed-agent', + 'x-algolia-referer': 'chat-widget', + }, + }, + }); + + await widget.chatInstance.sendMessage({ text: 'hello' }); + + const { headers } = getRequestPayload(); + expect(headers['x-algolia-application-id']).toBe('appId'); + expect(headers['x-algolia-api-key']).toBe('apiKey'); + expect(headers['x-algolia-agent']).toContain('; chat'); + expect(headers['x-algolia-agent']).not.toBe('spoofed-agent'); + expect(headers['x-algolia-referer']).toBe('chat-widget'); + }); + it('does not register `chat` on the search client user-agent', () => { const addAlgoliaAgent = jest.fn(); const client = Object.assign(createSearchClient(), { diff --git a/packages/instantsearch.js/src/connectors/chat/connectChat.ts b/packages/instantsearch.js/src/connectors/chat/connectChat.ts index b5097b4f9b..49e409e426 100644 --- a/packages/instantsearch.js/src/connectors/chat/connectChat.ts +++ b/packages/instantsearch.js/src/connectors/chat/connectChat.ts @@ -123,7 +123,13 @@ export type ChatInitWithoutTransport = Omit< >; export type ChatAgentRequestOptions = { + /** + * Query parameters to send with built-in Agent Studio completion requests. + */ queryParameters?: Record; + /** + * Headers to send with built-in Agent Studio completion requests. + */ headers?: Record | Headers; }; @@ -131,6 +137,9 @@ export type ChatTransport = | { agentId: string; transport?: never; + /** + * Request options to send with built-in Agent Studio completion requests. + */ requestOptions?: ChatAgentRequestOptions; /** * Whether to enable feedback (thumbs up/down) on assistant messages. @@ -500,12 +509,12 @@ export default (function connectChat( const api = new URL( `https://${appId}.algolia.net/agent-studio/1/agents/${agentId}/completions` ); - api.searchParams.set('compatibilityMode', 'ai-sdk-5'); Object.entries(options.requestOptions?.queryParameters || {}).forEach( ([key, value]) => { api.searchParams.set(key, String(value)); } ); + api.searchParams.set('compatibilityMode', 'ai-sdk-5'); if (bypassCache) { api.searchParams.set('cache', 'false'); } @@ -515,12 +524,14 @@ export default (function connectChat( transport = new DefaultChatTransport({ api: baseApi, headers: { - 'x-algolia-application-id': appId, - 'x-algolia-api-key': apiKey, - 'x-algolia-agent': `${getAlgoliaAgent(client)}; chat`, ...(options.requestOptions?.headers instanceof Headers ? Object.fromEntries(options.requestOptions.headers.entries()) : options.requestOptions?.headers), + // Preserve the required Algolia identity headers and chat agent + // marker, even when requestOptions.headers contains the same keys. + 'x-algolia-application-id': appId, + 'x-algolia-api-key': apiKey, + 'x-algolia-agent': `${getAlgoliaAgent(client)}; chat`, }, prepareSendMessagesRequest: ({ id, From 924d9fa2fe0798e5dcf56f2a7c01bfe221754004 Mon Sep 17 00:00:00 2001 From: Anjola Adeuyi <57623705+anjola-adeuyi@users.noreply.github.com> Date: Wed, 24 Jun 2026 11:21:08 +0200 Subject: [PATCH 3/6] chore: update bundlesize.config.json --- bundlesize.config.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bundlesize.config.json b/bundlesize.config.json index 7fe3d8f13f..e0b8c7045d 100644 --- a/bundlesize.config.json +++ b/bundlesize.config.json @@ -10,7 +10,7 @@ }, { "path": "./packages/instantsearch.js/dist/instantsearch.production.min.js", - "maxSize": "129 kB" + "maxSize": "130 kB" }, { "path": "./packages/instantsearch.js/dist/instantsearch.development.js", @@ -22,7 +22,7 @@ }, { "path": "packages/react-instantsearch/dist/umd/ReactInstantSearch.min.js", - "maxSize": "101.5 kB" + "maxSize": "102 kB" }, { "path": "packages/vue-instantsearch/vue2/umd/index.js", From 3f75d4797c6b5ab5fe0bcb5390d72fe920859a53 Mon Sep 17 00:00:00 2001 From: Anjola Adeuyi <57623705+anjola-adeuyi@users.noreply.github.com> Date: Wed, 24 Jun 2026 13:51:08 +0200 Subject: [PATCH 4/6] refactor(chat): simplify agent request options --- .../chat/__tests__/connectChat-test.ts | 25 +++++++++++-------- .../src/connectors/chat/connectChat.ts | 23 +++++++---------- 2 files changed, 24 insertions(+), 24 deletions(-) diff --git a/packages/instantsearch.js/src/connectors/chat/__tests__/connectChat-test.ts b/packages/instantsearch.js/src/connectors/chat/__tests__/connectChat-test.ts index 624a996540..3b71a97167 100644 --- a/packages/instantsearch.js/src/connectors/chat/__tests__/connectChat-test.ts +++ b/packages/instantsearch.js/src/connectors/chat/__tests__/connectChat-test.ts @@ -72,11 +72,12 @@ describe('connectChat', () => { }); it('types requestOptions as agentId-only', () => { - const assertChatConnectorParams = (_params: ChatConnectorParams) => - undefined; + const assertChatConnectorParams = ( + params: TParams + ) => params; const customChat = undefined as unknown as Chat; - assertChatConnectorParams({ + const agentParams = assertChatConnectorParams({ agentId: 'agentId', requestOptions: { queryParameters: { cache: false }, @@ -114,7 +115,12 @@ describe('connectChat', () => { }, }); - expect(true).toBe(true); + expect(agentParams.requestOptions?.queryParameters).toEqual({ + cache: false, + }); + expect(agentParams.requestOptions?.headers).toEqual({ + 'x-algolia-referer': 'chat-widget', + }); }); }); @@ -1466,17 +1472,16 @@ data: [DONE]`, return new Chat({ transport: createMockTransport() }); } - function createChatWidgetWithContext( - params: Omit, 'transport' | 'agentId'> & { - chat: Chat; - } - ) { + function createChatWidgetWithContext(params: { + chat: Chat; + context?: ChatConnectorParams['context']; + }) { const renderFn = jest.fn(); const makeWidget = connectChat(renderFn); const widget = makeWidget({ ...params, transport: { api: 'http://unused' }, - } as ChatConnectorParams); + }); return { widget, renderFn }; } diff --git a/packages/instantsearch.js/src/connectors/chat/connectChat.ts b/packages/instantsearch.js/src/connectors/chat/connectChat.ts index 49e409e426..487f2dcbc7 100644 --- a/packages/instantsearch.js/src/connectors/chat/connectChat.ts +++ b/packages/instantsearch.js/src/connectors/chat/connectChat.ts @@ -505,19 +505,17 @@ export default (function connectChat( ); } - const createApi = ({ bypassCache = false } = {}) => { + const createApi = (bypassCache = false) => { const api = new URL( `https://${appId}.algolia.net/agent-studio/1/agents/${agentId}/completions` ); - Object.entries(options.requestOptions?.queryParameters || {}).forEach( - ([key, value]) => { - api.searchParams.set(key, String(value)); - } - ); - api.searchParams.set('compatibilityMode', 'ai-sdk-5'); - if (bypassCache) { - api.searchParams.set('cache', 'false'); - } + api.search = new URLSearchParams( + Object.entries({ + ...options.requestOptions?.queryParameters, + compatibilityMode: 'ai-sdk-5', + ...(bypassCache ? { cache: false } : {}), + }).map(([key, value]) => [key, String(value)]) + ).toString(); return api.toString(); }; const baseApi = createApi(); @@ -541,10 +539,7 @@ export default (function connectChat( }) => { return { // Bypass cache when regenerating to ensure fresh responses - api: - trigger === 'regenerate-message' - ? createApi({ bypassCache: true }) - : baseApi, + api: trigger === 'regenerate-message' ? createApi(true) : baseApi, body: { id, messageId, From f83b6ab58bcd19c18680496b579fe84c6e4de981 Mon Sep 17 00:00:00 2001 From: Anjola Adeuyi <57623705+anjola-adeuyi@users.noreply.github.com> Date: Thu, 25 Jun 2026 12:26:31 +0200 Subject: [PATCH 5/6] test(chat): assert agent+transport params in requestOptions type test --- .../src/connectors/chat/__tests__/connectChat-test.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/instantsearch.js/src/connectors/chat/__tests__/connectChat-test.ts b/packages/instantsearch.js/src/connectors/chat/__tests__/connectChat-test.ts index 3b71a97167..fe332eb15b 100644 --- a/packages/instantsearch.js/src/connectors/chat/__tests__/connectChat-test.ts +++ b/packages/instantsearch.js/src/connectors/chat/__tests__/connectChat-test.ts @@ -85,7 +85,7 @@ describe('connectChat', () => { }, }); - assertChatConnectorParams({ + const legacyAgentWithTransportParams = assertChatConnectorParams({ agentId: 'agentId', transport: { api: 'https://custom.api' }, }); @@ -121,6 +121,10 @@ describe('connectChat', () => { expect(agentParams.requestOptions?.headers).toEqual({ 'x-algolia-referer': 'chat-widget', }); + expect(legacyAgentWithTransportParams).toEqual({ + agentId: 'agentId', + transport: { api: 'https://custom.api' }, + }); }); }); From 57dfee32e89ed4d4c090e4724a823a52ce6afe13 Mon Sep 17 00:00:00 2001 From: Anjola Adeuyi <57623705+anjola-adeuyi@users.noreply.github.com> Date: Thu, 25 Jun 2026 16:25:38 +0200 Subject: [PATCH 6/6] refactor(chat): let URLSearchParams coerce query values --- .../src/connectors/chat/connectChat.ts | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/packages/instantsearch.js/src/connectors/chat/connectChat.ts b/packages/instantsearch.js/src/connectors/chat/connectChat.ts index 487f2dcbc7..b5d60c3cf4 100644 --- a/packages/instantsearch.js/src/connectors/chat/connectChat.ts +++ b/packages/instantsearch.js/src/connectors/chat/connectChat.ts @@ -509,12 +509,14 @@ export default (function connectChat( const api = new URL( `https://${appId}.algolia.net/agent-studio/1/agents/${agentId}/completions` ); + const queryParameters: Record = { + ...options.requestOptions?.queryParameters, + compatibilityMode: 'ai-sdk-5', + ...(bypassCache ? { cache: false } : {}), + }; + api.search = new URLSearchParams( - Object.entries({ - ...options.requestOptions?.queryParameters, - compatibilityMode: 'ai-sdk-5', - ...(bypassCache ? { cache: false } : {}), - }).map(([key, value]) => [key, String(value)]) + queryParameters as Record ).toString(); return api.toString(); };