diff --git a/spec/src/modules/agent.js b/spec/src/modules/agent.js index 7810d0a3..5c355408 100644 --- a/spec/src/modules/agent.js +++ b/spec/src/modules/agent.js @@ -93,6 +93,141 @@ describe(`ConstructorIO - Agent${bundledDescriptionSuffix}`, () => { expect(url).not.contain(' '); expect(url).contain(encodeURIComponentRFC3986(intentWithSpaces)); }); + + it('should include threadId parameter when provided', () => { + const url = createAgentUrl( + 'running shoes', + { ...defaultParameters, threadId: 'test-thread-id' }, + defaultOptions, + ); + const requestedUrlParams = qs.parse(url.split('?')?.[1]); + + expect(requestedUrlParams).to.have.property('thread_id').to.equal('test-thread-id'); + }); + + it('should include guard parameter when provided as true', () => { + const url = createAgentUrl( + 'running shoes', + { ...defaultParameters, guard: true }, + defaultOptions, + ); + const requestedUrlParams = qs.parse(url.split('?')?.[1]); + + expect(requestedUrlParams).to.have.property('guard').to.equal('true'); + }); + + it('should include guard parameter when provided as false', () => { + const url = createAgentUrl( + 'running shoes', + { ...defaultParameters, guard: false }, + defaultOptions, + ); + const requestedUrlParams = qs.parse(url.split('?')?.[1]); + + expect(requestedUrlParams).to.have.property('guard').to.equal('false'); + }); + + it('should include numResultsPerEvent parameter when provided', () => { + const url = createAgentUrl( + 'running shoes', + { ...defaultParameters, numResultsPerEvent: 5 }, + defaultOptions, + ); + const requestedUrlParams = qs.parse(url.split('?')?.[1]); + + expect(requestedUrlParams).to.have.property('num_results_per_event').to.equal('5'); + }); + + it('should include numResultEvents parameter when provided', () => { + const url = createAgentUrl( + 'running shoes', + { ...defaultParameters, numResultEvents: 3 }, + defaultOptions, + ); + const requestedUrlParams = qs.parse(url.split('?')?.[1]); + + expect(requestedUrlParams).to.have.property('num_result_events').to.equal('3'); + }); + + it('should include numResultsPerPage parameter when provided', () => { + const url = createAgentUrl( + 'running shoes', + { ...defaultParameters, numResultsPerPage: 10 }, + defaultOptions, + ); + const requestedUrlParams = qs.parse(url.split('?')?.[1]); + + expect(requestedUrlParams).to.have.property('num_results_per_page').to.equal('10'); + }); + + it('should include preFilterExpression as JSON string when provided as object', () => { + const preFilterExpression = { and: [{ name: 'brand', value: 'Nike' }] }; + const url = createAgentUrl( + 'running shoes', + { ...defaultParameters, preFilterExpression }, + defaultOptions, + ); + const requestedUrlParams = qs.parse(url.split('?')?.[1]); + + expect(requestedUrlParams).to.have.property('pre_filter_expression').to.equal(JSON.stringify(preFilterExpression)); + }); + + it('should include qsParam as JSON string when provided as object', () => { + const qsParam = { section: 'Products' }; + const url = createAgentUrl( + 'running shoes', + { ...defaultParameters, qsParam }, + defaultOptions, + ); + const requestedUrlParams = qs.parse(url.split('?')?.[1]); + + expect(requestedUrlParams).to.have.property('qs').to.equal(JSON.stringify(qsParam)); + }); + + it('should include fmtOptions when provided', () => { + const fmtOptions = { fields: ['title', 'image_url'], hidden_fields: ['price'] }; + const url = createAgentUrl( + 'running shoes', + { ...defaultParameters, fmtOptions }, + defaultOptions, + ); + const requestedUrlParams = qs.parse(url.split('?')?.[1]); + + expect(requestedUrlParams).to.have.property('fmt_options'); + expect(requestedUrlParams.fmt_options).to.have.property('fields'); + expect(requestedUrlParams.fmt_options).to.have.property('hidden_fields'); + }); + + it('should include user segments when provided in options', () => { + const url = createAgentUrl('running shoes', defaultParameters, { + ...defaultOptions, + segments: ['segment1', 'segment2'], + }); + const requestedUrlParams = qs.parse(url.split('?')?.[1]); + + expect(requestedUrlParams).to.have.property('us'); + }); + + it('should include userId when provided in options', () => { + const url = createAgentUrl('running shoes', defaultParameters, { + ...defaultOptions, + userId: 'user-123', + }); + const requestedUrlParams = qs.parse(url.split('?')?.[1]); + + expect(requestedUrlParams).to.have.property('ui').to.equal('user-123'); + }); + + it('should include testCells when provided in options', () => { + const url = createAgentUrl('running shoes', defaultParameters, { + ...defaultOptions, + testCells: { cellA: 'variant1', cellB: 'variant2' }, + }); + const requestedUrlParams = qs.parse(url.split('?')?.[1]); + + expect(requestedUrlParams).to.have.property('ef-cellA').to.equal('variant1'); + expect(requestedUrlParams).to.have.property('ef-cellB').to.equal('variant2'); + }); }); // setupEventListeners util Tests @@ -196,6 +331,42 @@ describe(`ConstructorIO - Agent${bundledDescriptionSuffix}`, () => { done(); }); }); + + it('should enqueue MESSAGE event data into the stream', (done) => { + const eventType = Agent.EventTypes.MESSAGE; + const eventData = { text: 'Here are some recommendations' }; + + setupEventListeners(mockEventSource, mockStreamController, Agent.EventTypes); + + const messageCallback = mockEventSource.addEventListener + .getCalls() + .find((call) => call.args[0] === eventType).args[1]; + + messageCallback({ data: JSON.stringify(eventData) }); + + setImmediate(() => { + expect(mockStreamController.enqueue.calledWith({ type: eventType, data: eventData })).to.be.true; + done(); + }); + }); + + it('should enqueue FOLLOW_UP_QUESTIONS event data into the stream', (done) => { + const eventType = Agent.EventTypes.FOLLOW_UP_QUESTIONS; + const eventData = { questions: ['What size?', 'What color?'] }; + + setupEventListeners(mockEventSource, mockStreamController, Agent.EventTypes); + + const followUpCallback = mockEventSource.addEventListener + .getCalls() + .find((call) => call.args[0] === eventType).args[1]; + + followUpCallback({ data: JSON.stringify(eventData) }); + + setImmediate(() => { + expect(mockStreamController.enqueue.calledWith({ type: eventType, data: eventData })).to.be.true; + done(); + }); + }); }); describe('getAgentResultsStream', () => { @@ -265,4 +436,5 @@ describe(`ConstructorIO - Agent${bundledDescriptionSuffix}`, () => { reader.cancel(); }); }); + }); diff --git a/src/modules/agent.js b/src/modules/agent.js index 3358938c..6dbed5e3 100644 --- a/src/modules/agent.js +++ b/src/modules/agent.js @@ -1,6 +1,7 @@ -const { cleanParams, trimNonBreakingSpaces, encodeURIComponentRFC3986, stringify } = require('../utils/helpers'); +const { cleanParams, trimNonBreakingSpaces, encodeURIComponentRFC3986, stringify, isNil } = require('../utils/helpers'); // Create URL from supplied intent (term) and parameters +// eslint-disable-next-line complexity function createAgentUrl(intent, parameters, options) { const { apiKey, @@ -26,7 +27,7 @@ function createAgentUrl(intent, parameters, options) { } // Validate domain is provided - if (!parameters.domain || typeof parameters.domain !== 'string') { + if (!parameters || !parameters.domain || typeof parameters.domain !== 'string') { throw new Error('parameters.domain is a required parameter of type string'); } @@ -48,7 +49,17 @@ function createAgentUrl(intent, parameters, options) { } if (parameters) { - const { domain, numResultsPerPage } = parameters; + const { + domain, + numResultsPerPage, + threadId, + guard, + numResultsPerEvent, + numResultEvents, + qsParam, + preFilterExpression, + fmtOptions, + } = parameters; // Pull domain from parameters if (domain) { @@ -59,6 +70,41 @@ function createAgentUrl(intent, parameters, options) { if (numResultsPerPage) { queryParams.num_results_per_page = numResultsPerPage; } + + // Pull thread_id from parameters + if (threadId) { + queryParams.thread_id = threadId; + } + + // Pull guard from parameters + if (!isNil(guard)) { + queryParams.guard = guard; + } + + // Pull num_results_per_event from parameters + if (numResultsPerEvent) { + queryParams.num_results_per_event = numResultsPerEvent; + } + + // Pull num_result_events from parameters + if (numResultEvents) { + queryParams.num_result_events = numResultEvents; + } + + // Pull qsParam from parameters + if (qsParam) { + queryParams.qs = JSON.stringify(qsParam); + } + + // Pull pre_filter_expression from parameters + if (preFilterExpression) { + queryParams.pre_filter_expression = JSON.stringify(preFilterExpression); + } + + // Pull fmt_options from parameters + if (fmtOptions) { + queryParams.fmt_options = fmtOptions; + } } // eslint-disable-next-line no-underscore-dangle @@ -124,6 +170,8 @@ class Agent { RECIPE_INSTRUCTIONS: 'recipe_instructions', // Represents recipe instructions SERVER_ERROR: 'server_error', // Server Error event IMAGE_META: 'image_meta', // This event type is used for enhancing recommendations with media content such as images + MESSAGE: 'message', // Represents a textual message from the agent + FOLLOW_UP_QUESTIONS: 'follow_up_questions', // Represents follow-up question suggestions END: 'end', // Represents the end of data stream }; @@ -133,9 +181,18 @@ class Agent { * @function getAgentResultsStream * @description Retrieve a stream of agent results from Constructor.io API * @param {string} intent - Intent to use to perform an intent based recommendations - * @param {object} [parameters] - Additional parameters to refine result set - * @param {string} [parameters.domain] - domain name e.g. swimming sports gear, groceries - * @param {number} [parameters.numResultsPerPage] - The total number of results to return + * @param {object} parameters - Additional parameters to refine result set + * @param {string} parameters.domain - Domain name (e.g. "groceries", "recipes") + * @param {string} [parameters.threadId] - Conversation thread ID for multi-turn dialogue + * @param {boolean} [parameters.guard] - Enable content moderation + * @param {number} [parameters.numResultsPerEvent] - Max products per search_result event + * @param {number} [parameters.numResultEvents] - Max number of search_result events + * @param {number} [parameters.numResultsPerPage] - Deprecated: use numResultsPerEvent instead + * @param {object} [parameters.preFilterExpression] - Faceting expression to scope search results. Please refer to https://docs.constructor.com/reference/configuration-collections + * @param {object} [parameters.fmtOptions] - The format options used to refine result groups. Please refer to https://docs.constructor.com/reference/v1-asa-retrieve-intent#query-params for details + * @param {string[]} [parameters.fmtOptions.fields] - Product fields to return + * @param {string[]} [parameters.fmtOptions.hidden_fields] - Hidden fields to return + * @param {object} [parameters.qsParam] - Parameters listed above can be serialized into a JSON object and parsed through this parameter. Please refer to https://docs.constructor.com/reference/v1-asa-retrieve-intent#query-params * @returns {ReadableStream} Returns a ReadableStream. * @example * const readableStream = constructorio.agent.getAgentResultsStream('I want to get shoes', { diff --git a/src/types/agent.d.ts b/src/types/agent.d.ts index eeb63f25..88f80c90 100644 --- a/src/types/agent.d.ts +++ b/src/types/agent.d.ts @@ -1,13 +1,23 @@ import { ConstructorClientOptions, + FmtOptions, + FilterExpression, } from '.'; export default Agent; export interface IAgentParameters { domain: string; + /** @deprecated Use numResultsPerEvent instead */ numResultsPerPage?: number; filters?: Record; + threadId?: string; + guard?: boolean; + numResultsPerEvent?: number; + numResultEvents?: number; + qsParam?: Record; + preFilterExpression?: FilterExpression; + fmtOptions?: Pick; } declare class Agent { diff --git a/src/types/index.d.ts b/src/types/index.d.ts index f81eaac7..998e7784 100644 --- a/src/types/index.d.ts +++ b/src/types/index.d.ts @@ -6,6 +6,7 @@ export default ConstructorIO; export * from './search'; export * from './autocomplete'; export * from './quizzes'; +export * from './agent'; export * from './recommendations'; export * from './browse'; export * from './tracker';