@servicetitan/titan-chatbot-api 6.0.0 → 6.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -318,6 +318,183 @@ describe('[ChatbotUiBackendStore]', () => {
318
318
  });
319
319
  });
320
320
 
321
+ test('should use per-request timeoutMs when provided', async () => {
322
+ mockChatbotSession();
323
+ chatbotApi.postMessage.mockImplementation(
324
+ () =>
325
+ new Promise(resolve =>
326
+ setTimeout(
327
+ () =>
328
+ resolve(ModelsMocks.mockBotMessage({ answer: 'late answer' })),
329
+ 200
330
+ )
331
+ )
332
+ );
333
+
334
+ await runStore();
335
+ await chatUiStore.sendMessageText('user question');
336
+ const message = chatUiStore.messages.at(-1)! as ChatMessageModelText;
337
+
338
+ const p = runChatUiEventListener(store.handleMessageSend, [message, undefined, 50]);
339
+ await jest.advanceTimersByTimeAsync(50);
340
+ await p;
341
+
342
+ expect(chatUiStore.isError).toBe(true);
343
+ expect(log.error).toHaveBeenCalledWith(
344
+ expect.objectContaining({
345
+ code: 'TitanChatbot_FailedToSendMessage',
346
+ message: 'Failed to send message',
347
+ })
348
+ );
349
+ });
350
+
351
+ test('should fall back to global timeout when no per-request timeoutMs', async () => {
352
+ mockChatbotSession();
353
+ chatbotApi.postMessage.mockImplementation(
354
+ () =>
355
+ new Promise(resolve =>
356
+ setTimeout(
357
+ () =>
358
+ resolve(ModelsMocks.mockBotMessage({ answer: 'late answer' })),
359
+ 200
360
+ )
361
+ )
362
+ );
363
+ chatUiStore.setCustomizationContext({ timeouts: { chatbotRequestTimeoutMs: 100 } });
364
+
365
+ await runStore();
366
+ await chatUiStore.sendMessageText('user question');
367
+ const message = chatUiStore.messages.at(-1)! as ChatMessageModelText;
368
+
369
+ // No per-request timeoutMs — should use global (100ms)
370
+ const p = runChatUiEventListener(store.handleMessageSend, [message, undefined]);
371
+ await jest.advanceTimersByTimeAsync(100);
372
+ await p;
373
+
374
+ expect(chatUiStore.isError).toBe(true);
375
+ expect(log.error).toHaveBeenCalledWith(
376
+ expect.objectContaining({ code: 'TitanChatbot_FailedToSendMessage' })
377
+ );
378
+ });
379
+
380
+ test('should fall back to DEFAULT timeout when neither per-request nor global timeout is set', async () => {
381
+ mockChatbotSession();
382
+ chatbotApi.postMessage.mockImplementation(
383
+ () =>
384
+ new Promise(resolve =>
385
+ setTimeout(
386
+ () =>
387
+ resolve(ModelsMocks.mockBotMessage({ answer: 'late answer' })),
388
+ 40000
389
+ )
390
+ )
391
+ );
392
+
393
+ await runStore();
394
+ await chatUiStore.sendMessageText('user question');
395
+ const message = chatUiStore.messages.at(-1)! as ChatMessageModelText;
396
+
397
+ // No per-request timeoutMs, no global timeout — should use DEFAULT (31000ms)
398
+ const p = runChatUiEventListener(store.handleMessageSend, [message, undefined]);
399
+ await jest.runOnlyPendingTimersAsync();
400
+ await p;
401
+
402
+ expect(chatUiStore.isError).toBe(true);
403
+ expect(log.error).toHaveBeenCalledWith(
404
+ expect.objectContaining({ code: 'TitanChatbot_FailedToSendMessage' })
405
+ );
406
+ });
407
+
408
+ test('should not abort store-level controller when per-request timeout fires', async () => {
409
+ mockChatbotSession();
410
+
411
+ /*
412
+ * First message: slow — will time out at 50ms
413
+ * Second message: fast — should succeed despite the first timing out
414
+ */
415
+ const fastAnswer = ModelsMocks.mockBotMessage({ answer: 'fast answer' });
416
+
417
+ chatbotApi.postMessage
418
+ .mockImplementationOnce(() => new Promise(() => {})) // never resolves — times out
419
+ .mockResolvedValueOnce(fastAnswer);
420
+
421
+ await runStore();
422
+
423
+ await chatUiStore.sendMessageText('slow question');
424
+ const slowMessage = chatUiStore.messages.at(-1)! as ChatMessageModelText;
425
+
426
+ // Start slow message with a short per-request timeout
427
+ const p1 = runChatUiEventListener(store.handleMessageSend, [
428
+ slowMessage,
429
+ undefined,
430
+ 50,
431
+ ]);
432
+ await jest.advanceTimersByTimeAsync(50);
433
+ await p1;
434
+
435
+ expect(chatUiStore.isError).toBe(true);
436
+ expect(store.abortController.signal.aborted).toBe(false); // store-level signal must remain intact
437
+
438
+ // A subsequent message should still succeed because the store controller was not aborted
439
+ chatUiStore.resetError(ChatRunState.Started);
440
+ await chatUiStore.sendMessageText('fast question');
441
+ const fastMessage = chatUiStore.messages.at(-1)! as ChatMessageModelText;
442
+ await runChatUiEventListener(store.handleMessageSend, [fastMessage]);
443
+
444
+ expect(chatUiStore.isError).toBe(false);
445
+ expect(chatUiStore.messages.at(-1)! as ChatMessageModelText).toMatchObject({
446
+ message: 'fast answer',
447
+ });
448
+ });
449
+
450
+ test('should preserve per-request timeoutMs on retry', async () => {
451
+ mockChatbotSession();
452
+ chatbotApi.postMessage.mockImplementation(
453
+ () =>
454
+ new Promise(resolve =>
455
+ setTimeout(
456
+ () =>
457
+ resolve(ModelsMocks.mockBotMessage({ answer: 'late answer' })),
458
+ 200
459
+ )
460
+ )
461
+ );
462
+
463
+ await runStore();
464
+ await chatUiStore.sendMessageText('user question');
465
+ const message = chatUiStore.messages.at(-1)! as ChatMessageModelText;
466
+
467
+ // Initial send fails due to per-request timeout; timeoutMs is persisted on the message
468
+ const p1 = runChatUiEventListener(store.handleMessageSend, [
469
+ message,
470
+ undefined,
471
+ 50,
472
+ ]);
473
+ await jest.advanceTimersByTimeAsync(50);
474
+ await p1;
475
+ expect(chatUiStore.isError).toBe(true);
476
+
477
+ // Retry should reuse the persisted timeoutMs (50ms), not fall back to default (31000ms)
478
+ chatbotApi.postMessage.mockImplementation(
479
+ () =>
480
+ new Promise(resolve =>
481
+ setTimeout(
482
+ () =>
483
+ resolve(ModelsMocks.mockBotMessage({ answer: 'late answer' })),
484
+ 200
485
+ )
486
+ )
487
+ );
488
+ const p2 = runChatUiEventListener(store.handleMessageSendRetry, [message]);
489
+ await jest.advanceTimersByTimeAsync(50);
490
+ await p2;
491
+
492
+ // If retry had fallen back to default 31000ms, it would not have timed out in 50ms
493
+ expect(log.error).toHaveBeenCalledWith(
494
+ expect.objectContaining({ code: 'TitanChatbot_FailedToSendMessage' })
495
+ );
496
+ });
497
+
321
498
  test('should send message retry', async () => {
322
499
  mockChatbotSession();
323
500
  const spyMessage = jest.spyOn(chatbotApi, 'postMessage');
@@ -153,6 +153,24 @@ describe('[ChatbotUiStore]', () => {
153
153
  await checkScrollTriggered();
154
154
  });
155
155
 
156
+ test('should sendMessageText with timeoutMs option', async () => {
157
+ mockEmit();
158
+ const messageText = 'Hello, world!';
159
+ await store.sendMessageText(messageText, { timeoutMs: 50 });
160
+
161
+ expect(mockEventEmitter.emit).toHaveBeenCalledWith(
162
+ ChatUiEvent.eventMessageSend,
163
+ expect.any(Function),
164
+ expect.any(Function),
165
+ expect.objectContaining({
166
+ type: 'message',
167
+ message: messageText,
168
+ }),
169
+ store.filterStore.export(),
170
+ 50
171
+ );
172
+ });
173
+
156
174
  test('should sendMessageText without message', async () => {
157
175
  mockEmit();
158
176
  await store.sendMessageText('');
@@ -106,9 +106,10 @@ export class ChatbotUiBackendStore extends InitializeStore implements IChatbotUi
106
106
  resolve,
107
107
  _,
108
108
  messageModel: ChatMessageModelText,
109
- selections: Models.Selections | undefined
109
+ selections: Models.Selections | undefined,
110
+ timeoutMs?: number
110
111
  ) => {
111
- await this.sendMessage(messageModel, selections);
112
+ await this.sendMessage(messageModel, selections, timeoutMs);
112
113
  resolve();
113
114
  };
114
115
 
@@ -122,7 +123,8 @@ export class ChatbotUiBackendStore extends InitializeStore implements IChatbotUi
122
123
  return;
123
124
  }
124
125
  const selections = messageModel.data?.selections as Models.Selections | undefined;
125
- await this.sendMessage(messageModel as ChatMessageModelText, selections);
126
+ const timeoutMs = messageModel.data?.timeoutMs as number | undefined;
127
+ await this.sendMessage(messageModel as ChatMessageModelText, selections, timeoutMs);
126
128
  resolve();
127
129
  };
128
130
 
@@ -296,9 +298,14 @@ export class ChatbotUiBackendStore extends InitializeStore implements IChatbotUi
296
298
 
297
299
  protected async sendMessage(
298
300
  messageModel: ChatMessageModelText,
299
- selections?: Models.Selections
301
+ selections?: Models.Selections,
302
+ timeoutMs?: number
300
303
  ) {
301
304
  const question = messageModel.message;
305
+ const perRequestController = new AbortController();
306
+ const forwardStoreAbort = () =>
307
+ perRequestController.abort(this.abortController.signal.reason);
308
+ this.abortController.signal.addEventListener('abort', forwardStoreAbort, { once: true });
302
309
  try {
303
310
  await this.startSession();
304
311
  this.chatUiStore.setAgentTyping(true);
@@ -311,11 +318,12 @@ export class ChatbotUiBackendStore extends InitializeStore implements IChatbotUi
311
318
  experience: Models.Experience.MultiTurn,
312
319
  selections,
313
320
  }),
314
- this.abortController.signal
321
+ perRequestController.signal
315
322
  ),
316
- this.customizations?.timeouts?.chatbotRequestTimeoutMs ??
323
+ timeoutMs ??
324
+ this.customizations?.timeouts?.chatbotRequestTimeoutMs ??
317
325
  DEFAULT_CHATBOT_ASK_BOT_TIMEOUT_MS,
318
- this.abortController
326
+ perRequestController
319
327
  );
320
328
  this.withSaveState(() => {
321
329
  // Set message state to Delivered and preserve selections and bot answer id to the user question (to have a link between question and answer)
@@ -330,6 +338,7 @@ export class ChatbotUiBackendStore extends InitializeStore implements IChatbotUi
330
338
  this.withSaveState(() => {
331
339
  this.chatUiStore.setMessageState(messageModel, ChatMessageState.Failed, {
332
340
  selections,
341
+ ...(timeoutMs !== undefined ? { timeoutMs } : {}),
333
342
  });
334
343
  });
335
344
  if (error instanceof ChatError) {
@@ -343,6 +352,7 @@ export class ChatbotUiBackendStore extends InitializeStore implements IChatbotUi
343
352
  );
344
353
  }
345
354
  } finally {
355
+ this.abortController.signal.removeEventListener('abort', forwardStoreAbort);
346
356
  this.chatUiStore.setAgentTyping(false);
347
357
  }
348
358
  }
@@ -16,18 +16,19 @@ export enum ChatbotUiEvent {
16
16
  export interface IChatbotUiStore extends IChatUiStore<ChatbotCustomizations> {
17
17
  filterStore: IFilterStore;
18
18
  settings?: Models.IFrontendModel;
19
- setFilters: (filterOptions: Models.IFrontendModel, options?: IFilterStoreInitOptions) => void;
20
- startSession: (
19
+ sendMessageText(messageText: string, options?: { timeoutMs?: number }): Promise<void>;
20
+ setFilters(filterOptions: Models.IFrontendModel, options?: IFilterStoreInitOptions): void;
21
+ startSession(
21
22
  sessionData?: Models.ISession['data'],
22
23
  forceRecreate?: boolean
23
- ) => Promise<undefined | Models.Session>;
24
- sendSessionFeedback: (
24
+ ): Promise<undefined | Models.Session>;
25
+ sendSessionFeedback(
25
26
  feedback: Models.IFeedback
26
- ) => Promise<undefined | Models.ChatbotFeedbackState>;
27
- sendMessageFeedback: (
27
+ ): Promise<undefined | Models.ChatbotFeedbackState>;
28
+ sendMessageFeedback(
28
29
  message: Models.IBotMessageWithFeedback,
29
30
  messageFeedback: Models.IFeedback
30
- ) => Promise<undefined | Models.ChatbotFeedbackState>;
31
+ ): Promise<undefined | Models.ChatbotFeedbackState>;
31
32
  }
32
33
 
33
34
  @injectable()
@@ -49,13 +50,18 @@ export class ChatbotUiStore extends ChatUiStore<ChatbotCustomizations> implement
49
50
  this.triggerScroll();
50
51
  }
51
52
 
52
- override async sendMessageText(messageText: string) {
53
+ override async sendMessageText(messageText: string, options?: { timeoutMs?: number }) {
53
54
  await this.restartTimers();
54
55
  if (messageText.trim() === '') {
55
56
  return;
56
57
  }
57
58
  const messageModel = this.addMessage(false, messageText);
58
- await this.emitAsync(ChatUiEvent.eventMessageSend, messageModel, this.filterStore.export());
59
+ await this.emitAsync(
60
+ ChatUiEvent.eventMessageSend,
61
+ messageModel,
62
+ this.filterStore.export(),
63
+ ...(options?.timeoutMs !== undefined ? [options.timeoutMs] : [])
64
+ );
59
65
  }
60
66
 
61
67
  @action