@servicetitan/titan-chatbot-api 7.1.2 → 9.0.0

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.
Files changed (178) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/dist/api-client/__mocks__/chatbot-api-client.mock.d.ts +1 -0
  3. package/dist/api-client/__mocks__/chatbot-api-client.mock.d.ts.map +1 -1
  4. package/dist/api-client/__mocks__/chatbot-api-client.mock.js +22 -47
  5. package/dist/api-client/__mocks__/chatbot-api-client.mock.js.map +1 -1
  6. package/dist/api-client/base/chatbot-api-client.d.ts +7 -0
  7. package/dist/api-client/base/chatbot-api-client.d.ts.map +1 -1
  8. package/dist/api-client/base/chatbot-api-client.js +3 -4
  9. package/dist/api-client/base/chatbot-api-client.js.map +1 -1
  10. package/dist/api-client/index.d.ts +2 -2
  11. package/dist/api-client/index.d.ts.map +1 -1
  12. package/dist/api-client/index.js +12 -7
  13. package/dist/api-client/index.js.map +1 -1
  14. package/dist/api-client/models/__mocks__/models.mock.js +154 -124
  15. package/dist/api-client/models/__mocks__/models.mock.js.map +1 -1
  16. package/dist/api-client/models/index.d.ts +2 -1
  17. package/dist/api-client/models/index.d.ts.map +1 -1
  18. package/dist/api-client/models/index.js +8 -7
  19. package/dist/api-client/models/index.js.map +1 -1
  20. package/dist/api-client/titan-chat/__tests__/chatbot-api-client-stream.test.d.ts +2 -0
  21. package/dist/api-client/titan-chat/__tests__/chatbot-api-client-stream.test.d.ts.map +1 -0
  22. package/dist/api-client/titan-chat/__tests__/chatbot-api-client-stream.test.js +240 -0
  23. package/dist/api-client/titan-chat/__tests__/chatbot-api-client-stream.test.js.map +1 -0
  24. package/dist/api-client/titan-chat/__tests__/native-client.test.js +6 -6
  25. package/dist/api-client/titan-chat/__tests__/native-client.test.js.map +1 -1
  26. package/dist/api-client/titan-chat/chatbot-api-client.d.ts +11 -0
  27. package/dist/api-client/titan-chat/chatbot-api-client.d.ts.map +1 -1
  28. package/dist/api-client/titan-chat/chatbot-api-client.js +69 -35
  29. package/dist/api-client/titan-chat/chatbot-api-client.js.map +1 -1
  30. package/dist/api-client/titan-chat/index.d.ts +2 -1
  31. package/dist/api-client/titan-chat/index.d.ts.map +1 -1
  32. package/dist/api-client/titan-chat/index.js +1 -0
  33. package/dist/api-client/titan-chat/index.js.map +1 -1
  34. package/dist/api-client/titan-chat/native-client.js +359 -812
  35. package/dist/api-client/titan-chat/native-client.js.map +1 -1
  36. package/dist/api-client/utils/__tests__/model-utils.test.js +454 -191
  37. package/dist/api-client/utils/__tests__/model-utils.test.js.map +1 -1
  38. package/dist/api-client/utils/model-utils.d.ts.map +1 -1
  39. package/dist/api-client/utils/model-utils.js +28 -25
  40. package/dist/api-client/utils/model-utils.js.map +1 -1
  41. package/dist/hooks/use-customization-chatbot.js +2 -1
  42. package/dist/hooks/use-customization-chatbot.js.map +1 -1
  43. package/dist/index.d.ts +3 -2
  44. package/dist/index.d.ts.map +1 -1
  45. package/dist/index.js +7 -5
  46. package/dist/index.js.map +1 -1
  47. package/dist/models/__tests__/chatbot-customizations.test.d.ts +2 -0
  48. package/dist/models/__tests__/chatbot-customizations.test.d.ts.map +1 -0
  49. package/dist/models/__tests__/chatbot-customizations.test.js +36 -0
  50. package/dist/models/__tests__/chatbot-customizations.test.js.map +1 -0
  51. package/dist/models/chatbot-customizations.d.ts +17 -0
  52. package/dist/models/chatbot-customizations.d.ts.map +1 -1
  53. package/dist/models/chatbot-customizations.js +7 -1
  54. package/dist/models/chatbot-customizations.js.map +1 -1
  55. package/dist/models/index.js +1 -0
  56. package/dist/models/index.js.map +1 -1
  57. package/dist/stores/__tests__/chatbot-ui-backend.store.observability.test.d.ts +2 -0
  58. package/dist/stores/__tests__/chatbot-ui-backend.store.observability.test.d.ts.map +1 -0
  59. package/dist/stores/__tests__/chatbot-ui-backend.store.observability.test.js +107 -0
  60. package/dist/stores/__tests__/chatbot-ui-backend.store.observability.test.js.map +1 -0
  61. package/dist/stores/__tests__/chatbot-ui-backend.store.streaming.test.d.ts +2 -0
  62. package/dist/stores/__tests__/chatbot-ui-backend.store.streaming.test.d.ts.map +1 -0
  63. package/dist/stores/__tests__/chatbot-ui-backend.store.streaming.test.js +312 -0
  64. package/dist/stores/__tests__/chatbot-ui-backend.store.streaming.test.js.map +1 -0
  65. package/dist/stores/__tests__/chatbot-ui-backend.store.test.js +267 -172
  66. package/dist/stores/__tests__/chatbot-ui-backend.store.test.js.map +1 -1
  67. package/dist/stores/__tests__/chatbot-ui.store.test.js +61 -64
  68. package/dist/stores/__tests__/chatbot-ui.store.test.js.map +1 -1
  69. package/dist/stores/__tests__/filter.store.test.js +243 -116
  70. package/dist/stores/__tests__/filter.store.test.js.map +1 -1
  71. package/dist/stores/__tests__/initialize.store.test.js +9 -8
  72. package/dist/stores/__tests__/initialize.store.test.js.map +1 -1
  73. package/dist/stores/__tests__/message-feedback-guardrail.store.test.js +8 -7
  74. package/dist/stores/__tests__/message-feedback-guardrail.store.test.js.map +1 -1
  75. package/dist/stores/__tests__/message-feedback.store.test.js +34 -27
  76. package/dist/stores/__tests__/message-feedback.store.test.js.map +1 -1
  77. package/dist/stores/__tests__/session-feedback.store.test.js +9 -8
  78. package/dist/stores/__tests__/session-feedback.store.test.js.map +1 -1
  79. package/dist/stores/chatbot-ui-backend.store.d.ts +26 -2
  80. package/dist/stores/chatbot-ui-backend.store.d.ts.map +1 -1
  81. package/dist/stores/chatbot-ui-backend.store.js +295 -239
  82. package/dist/stores/chatbot-ui-backend.store.js.map +1 -1
  83. package/dist/stores/chatbot-ui.store.js +73 -46
  84. package/dist/stores/chatbot-ui.store.js.map +1 -1
  85. package/dist/stores/filter.store.js +298 -378
  86. package/dist/stores/filter.store.js.map +1 -1
  87. package/dist/stores/index.d.ts +5 -3
  88. package/dist/stores/index.d.ts.map +1 -1
  89. package/dist/stores/index.js +3 -2
  90. package/dist/stores/index.js.map +1 -1
  91. package/dist/stores/initialize.store.js +55 -51
  92. package/dist/stores/initialize.store.js.map +1 -1
  93. package/dist/stores/message-feedback-base.store.js +2 -1
  94. package/dist/stores/message-feedback-base.store.js.map +1 -1
  95. package/dist/stores/message-feedback-guardrail.store.js +50 -47
  96. package/dist/stores/message-feedback-guardrail.store.js.map +1 -1
  97. package/dist/stores/message-feedback.store.js +84 -89
  98. package/dist/stores/message-feedback.store.js.map +1 -1
  99. package/dist/stores/session-feedback.store.js +46 -39
  100. package/dist/stores/session-feedback.store.js.map +1 -1
  101. package/dist/streaming/__tests__/agent-stream.test.d.ts +2 -0
  102. package/dist/streaming/__tests__/agent-stream.test.d.ts.map +1 -0
  103. package/dist/streaming/__tests__/agent-stream.test.js +92 -0
  104. package/dist/streaming/__tests__/agent-stream.test.js.map +1 -0
  105. package/dist/streaming/agent-stream.d.ts +83 -0
  106. package/dist/streaming/agent-stream.d.ts.map +1 -0
  107. package/dist/streaming/agent-stream.js +28 -0
  108. package/dist/streaming/agent-stream.js.map +1 -0
  109. package/dist/streaming/index.d.ts +3 -0
  110. package/dist/streaming/index.d.ts.map +1 -0
  111. package/dist/streaming/index.js +4 -0
  112. package/dist/streaming/index.js.map +1 -0
  113. package/dist/streaming/run-agent-stream.d.ts +23 -0
  114. package/dist/streaming/run-agent-stream.d.ts.map +1 -0
  115. package/dist/streaming/run-agent-stream.js +83 -0
  116. package/dist/streaming/run-agent-stream.js.map +1 -0
  117. package/dist/utils/__tests__/axios-utils.test.js +8 -7
  118. package/dist/utils/__tests__/axios-utils.test.js.map +1 -1
  119. package/dist/utils/axios-utils.js +9 -7
  120. package/dist/utils/axios-utils.js.map +1 -1
  121. package/dist/utils/test-utils.js +5 -5
  122. package/dist/utils/test-utils.js.map +1 -1
  123. package/package.json +6 -3
  124. package/src/api-client/__mocks__/chatbot-api-client.mock.ts +1 -0
  125. package/src/api-client/base/chatbot-api-client.ts +11 -0
  126. package/src/api-client/index.ts +2 -7
  127. package/src/api-client/models/index.ts +15 -13
  128. package/src/api-client/titan-chat/__tests__/chatbot-api-client-stream.test.ts +208 -0
  129. package/src/api-client/titan-chat/chatbot-api-client.ts +46 -0
  130. package/src/api-client/titan-chat/index.ts +2 -1
  131. package/src/api-client/utils/model-utils.ts +4 -8
  132. package/src/index.ts +7 -2
  133. package/src/models/__tests__/chatbot-customizations.test.ts +26 -0
  134. package/src/models/chatbot-customizations.ts +20 -0
  135. package/src/stores/__tests__/chatbot-ui-backend.store.observability.test.ts +105 -0
  136. package/src/stores/__tests__/chatbot-ui-backend.store.streaming.test.ts +261 -0
  137. package/src/stores/chatbot-ui-backend.store.ts +179 -4
  138. package/src/stores/index.ts +5 -12
  139. package/src/streaming/__tests__/agent-stream.test.ts +80 -0
  140. package/src/streaming/agent-stream.ts +103 -0
  141. package/src/streaming/index.ts +2 -0
  142. package/src/streaming/run-agent-stream.ts +109 -0
  143. package/tsconfig.tsbuildinfo +1 -1
  144. package/dist/api-client/help-center/__tests__/converter-from-models.test.d.ts +0 -2
  145. package/dist/api-client/help-center/__tests__/converter-from-models.test.d.ts.map +0 -1
  146. package/dist/api-client/help-center/__tests__/converter-from-models.test.js +0 -34
  147. package/dist/api-client/help-center/__tests__/converter-from-models.test.js.map +0 -1
  148. package/dist/api-client/help-center/__tests__/converter-to-models.test.d.ts +0 -2
  149. package/dist/api-client/help-center/__tests__/converter-to-models.test.d.ts.map +0 -1
  150. package/dist/api-client/help-center/__tests__/converter-to-models.test.js +0 -82
  151. package/dist/api-client/help-center/__tests__/converter-to-models.test.js.map +0 -1
  152. package/dist/api-client/help-center/chatbot-api-client.d.ts +0 -32
  153. package/dist/api-client/help-center/chatbot-api-client.d.ts.map +0 -1
  154. package/dist/api-client/help-center/chatbot-api-client.js +0 -102
  155. package/dist/api-client/help-center/chatbot-api-client.js.map +0 -1
  156. package/dist/api-client/help-center/converter-from-models.d.ts +0 -13
  157. package/dist/api-client/help-center/converter-from-models.d.ts.map +0 -1
  158. package/dist/api-client/help-center/converter-from-models.js +0 -114
  159. package/dist/api-client/help-center/converter-from-models.js.map +0 -1
  160. package/dist/api-client/help-center/converter-to-models.d.ts +0 -13
  161. package/dist/api-client/help-center/converter-to-models.d.ts.map +0 -1
  162. package/dist/api-client/help-center/converter-to-models.js +0 -98
  163. package/dist/api-client/help-center/converter-to-models.js.map +0 -1
  164. package/dist/api-client/help-center/index.d.ts +0 -2
  165. package/dist/api-client/help-center/index.d.ts.map +0 -1
  166. package/dist/api-client/help-center/index.js +0 -2
  167. package/dist/api-client/help-center/index.js.map +0 -1
  168. package/dist/api-client/help-center/native-client.d.ts +0 -1268
  169. package/dist/api-client/help-center/native-client.d.ts.map +0 -1
  170. package/dist/api-client/help-center/native-client.js +0 -6242
  171. package/dist/api-client/help-center/native-client.js.map +0 -1
  172. package/src/api-client/help-center/__tests__/converter-from-models.test.ts +0 -41
  173. package/src/api-client/help-center/__tests__/converter-to-models.test.ts +0 -89
  174. package/src/api-client/help-center/chatbot-api-client.ts +0 -122
  175. package/src/api-client/help-center/converter-from-models.ts +0 -133
  176. package/src/api-client/help-center/converter-to-models.ts +0 -127
  177. package/src/api-client/help-center/index.ts +0 -1
  178. package/src/api-client/help-center/native-client.ts +0 -5727
@@ -9,16 +9,25 @@ import {
9
9
  ChatUiEvent,
10
10
  ChatUiEventListener,
11
11
  IChatUiBackendStore,
12
+ StreamingProgressModel,
12
13
  } from '@servicetitan/titan-chat-ui-common';
13
14
  import axios from 'axios';
14
15
  import { makeObservable, runInAction } from 'mobx';
15
16
  import { CHATBOT_API_CLIENT, IChatbotApiClient, Models, ModelsUtils } from '../api-client';
17
+ import {
18
+ DEFAULT_STREAMING_INACTIVITY_TIMEOUT_MS,
19
+ isChatbotStreamingEnabled,
20
+ } from '../models/chatbot-customizations';
21
+ import { AgentStreamError, AgentStreamHandlers } from '../streaming';
16
22
  import { withTimeout } from '../utils/axios-utils';
17
23
  import { CHATBOT_UI_STORE_TOKEN, ChatbotUiEvent, IChatbotUiStore } from './chatbot-ui.store';
18
24
  import { InitializeStore } from './initialize.store';
19
25
 
20
26
  const DEFAULT_CHATBOT_ASK_BOT_TIMEOUT_MS = 31000; // 31 seconds
21
27
 
28
+ const STREAMING_KEEPALIVE_TEXT = 'Still working on it…';
29
+ const STREAMING_STEP_ERROR = 'Something went wrong during this step. Please try again.';
30
+
22
31
  const isAbortedError = (error: unknown): boolean =>
23
32
  axios.isCancel(error) || (error as { name?: string } | null)?.name === 'AbortError';
24
33
 
@@ -28,6 +37,10 @@ export const CHATBOT_UI_BACKEND_STORE_TOKEN = symbolToken<IChatbotUiBackendStore
28
37
 
29
38
  export interface IChatbotUiBackendStore extends IChatUiBackendStore {
30
39
  session?: Models.ISession;
40
+ /** Live progress for the in-flight streamed run. */
41
+ streamingProgress: StreamingProgressModel;
42
+ /** Count of streaming → non-streaming fallbacks. */
43
+ streamingFallbackCount: number;
31
44
  initialize: () => void;
32
45
  saveCurrentState: () => void;
33
46
  deleteFromSessionStorage: () => void;
@@ -40,6 +53,12 @@ export interface IChatbotUiBackendStore extends IChatUiBackendStore {
40
53
  export class ChatbotUiBackendStore extends InitializeStore implements IChatbotUiBackendStore {
41
54
  session?: Models.ISession;
42
55
  abortController!: AbortController;
56
+
57
+ /** Live progress for the in-flight streamed run (status line, activity log, steps, keepalive). */
58
+ readonly streamingProgress = new StreamingProgressModel();
59
+ /** Count of streaming → non-streaming fallbacks, tracked separately for regression detection. */
60
+ streamingFallbackCount = 0;
61
+
43
62
  @inject(CHATBOT_API_CLIENT) protected readonly chatbotApiClient!: IChatbotApiClient;
44
63
  @inject(CHATBOT_UI_STORE_TOKEN) protected readonly chatUiStore!: IChatbotUiStore;
45
64
 
@@ -112,7 +131,7 @@ export class ChatbotUiBackendStore extends InitializeStore implements IChatbotUi
112
131
  selections: Models.Selections | undefined,
113
132
  timeoutMs?: number
114
133
  ) => {
115
- await this.sendMessage(messageModel, selections, timeoutMs);
134
+ await this.routeSend(messageModel, selections, timeoutMs);
116
135
  resolve();
117
136
  };
118
137
 
@@ -127,7 +146,7 @@ export class ChatbotUiBackendStore extends InitializeStore implements IChatbotUi
127
146
  }
128
147
  const selections = messageModel.data?.selections as Models.Selections | undefined;
129
148
  const timeoutMs = messageModel.data?.timeoutMs as number | undefined;
130
- await this.sendMessage(messageModel as ChatMessageModelText, selections, timeoutMs);
149
+ await this.routeSend(messageModel as ChatMessageModelText, selections, timeoutMs);
131
150
  resolve();
132
151
  };
133
152
 
@@ -299,6 +318,21 @@ export class ChatbotUiBackendStore extends InitializeStore implements IChatbotUi
299
318
  this.chatUiStore.addMessage(true, answer.answer, answer);
300
319
  }
301
320
 
321
+ /**
322
+ * Routes a send to the SSE streaming path when enabled (and supported by the client),
323
+ * otherwise to the existing non-streaming path. Default is non-streaming.
324
+ */
325
+ protected routeSend(
326
+ messageModel: ChatMessageModelText,
327
+ selections?: Models.Selections,
328
+ timeoutMs?: number
329
+ ): Promise<void> {
330
+ if (isChatbotStreamingEnabled(this.customizations) && this.chatbotApiClient.streamMessage) {
331
+ return this.sendMessageStreaming(messageModel, selections, timeoutMs);
332
+ }
333
+ return this.sendMessage(messageModel, selections, timeoutMs);
334
+ }
335
+
302
336
  protected async sendMessage(
303
337
  messageModel: ChatMessageModelText,
304
338
  selections?: Models.Selections,
@@ -360,6 +394,147 @@ export class ChatbotUiBackendStore extends InitializeStore implements IChatbotUi
360
394
  }
361
395
  }
362
396
 
397
+ /**
398
+ * Streaming send-path: consumes the SSE agent-progress stream, rendering live progress and
399
+ * resolving the final answer on `run.finished`. Applies an inactivity-only timeout (no hard
400
+ * request timeout). Falls back transparently to {@link sendMessage} when the stream is
401
+ * unreachable at connect time — propagating the original per-message `timeoutMs` so the
402
+ * fallback behaves identically to the non-streaming path; surfaces a step error if the
403
+ * connection fails after connecting.
404
+ */
405
+ protected async sendMessageStreaming(
406
+ messageModel: ChatMessageModelText,
407
+ selections?: Models.Selections,
408
+ timeoutMs?: number
409
+ ) {
410
+ const question = messageModel.message;
411
+ const perRequestController = new AbortController();
412
+ const forwardStoreAbort = () =>
413
+ perRequestController.abort(this.abortController.signal.reason);
414
+ this.abortController.signal.addEventListener('abort', forwardStoreAbort, { once: true });
415
+
416
+ let connected = false;
417
+ try {
418
+ await this.startSession();
419
+ this.chatUiStore.setAgentTyping(true);
420
+ runInAction(() => this.streamingProgress.reset());
421
+
422
+ const inactivityTimeoutMs =
423
+ this.customizations?.streaming?.inactivityTimeoutMs ??
424
+ DEFAULT_STREAMING_INACTIVITY_TIMEOUT_MS;
425
+
426
+ // Apply a progress mutation, then scroll so the newest event stays in view.
427
+ const updateProgress = (mutate: () => void) => {
428
+ runInAction(mutate);
429
+ this.chatUiStore.triggerScroll();
430
+ };
431
+
432
+ const handlers: AgentStreamHandlers = {
433
+ inactivityTimeoutMs,
434
+ onStatus: text =>
435
+ updateProgress(() => {
436
+ this.streamingProgress.clearKeepalive();
437
+ this.streamingProgress.setStatus(text);
438
+ }),
439
+ onText: text =>
440
+ updateProgress(() => {
441
+ this.streamingProgress.clearKeepalive();
442
+ this.streamingProgress.appendLog(text);
443
+ }),
444
+ onPlan: steps =>
445
+ updateProgress(() => {
446
+ this.streamingProgress.clearKeepalive();
447
+ this.streamingProgress.setSteps(steps);
448
+ }),
449
+ onStepActive: id =>
450
+ updateProgress(() => {
451
+ this.streamingProgress.clearKeepalive();
452
+ this.streamingProgress.setActiveStep(id);
453
+ }),
454
+ onInactivity: () =>
455
+ updateProgress(() =>
456
+ this.streamingProgress.setKeepalive(STREAMING_KEEPALIVE_TEXT)
457
+ ),
458
+ onConnected: () => {
459
+ connected = true;
460
+ this.logStreaming('connected');
461
+ },
462
+ onDisconnected: () => this.logStreaming('disconnected'),
463
+ onTimeout: () => this.logStreaming('timed_out'),
464
+ onCompleted: () => this.logStreaming('completed'),
465
+ };
466
+
467
+ const answer = await this.api(
468
+ 'streamMessage',
469
+ new Models.UserMessage({
470
+ sessionId: this.session!.id!,
471
+ question,
472
+ experience: Models.Experience.MultiTurn,
473
+ selections,
474
+ }),
475
+ handlers,
476
+ perRequestController.signal
477
+ );
478
+
479
+ this.withSaveState(() => {
480
+ this.chatUiStore.setMessageState(messageModel, ChatMessageState.Delivered, {
481
+ answerId: answer.id,
482
+ selections,
483
+ });
484
+ this.processBotAnswer(answer);
485
+ this.chatUiStore.resetError(ChatRunState.Started);
486
+ });
487
+ /*
488
+ * Mark every plan step done and clear the transient keepalive; the rendered trail is
489
+ * faded by the UI once the final answer is shown. The next streamed send resets the model.
490
+ */
491
+ runInAction(() => {
492
+ this.streamingProgress.completeAllSteps();
493
+ this.streamingProgress.clearKeepalive();
494
+ });
495
+ } catch (error: unknown) {
496
+ if (isAbortedError(error)) {
497
+ return;
498
+ }
499
+
500
+ // Never connected → the endpoint was unreachable at connect time: fall back silently.
501
+ if (!connected) {
502
+ this.streamingFallbackCount += 1;
503
+ this.logStreaming('fallback');
504
+ runInAction(() => this.streamingProgress.reset());
505
+ await this.sendMessage(messageModel, selections, timeoutMs);
506
+ return;
507
+ }
508
+
509
+ // Connected then failed: surface the step error (the agent's message if provided).
510
+ this.withSaveState(() => {
511
+ this.chatUiStore.setMessageState(messageModel, ChatMessageState.Failed, {
512
+ selections,
513
+ });
514
+ });
515
+ runInAction(() => this.streamingProgress.reset());
516
+ const message =
517
+ error instanceof AgentStreamError ? error.message : STREAMING_STEP_ERROR;
518
+ if (error instanceof ChatError) {
519
+ this.setChatError('FailedToSendMessage', error);
520
+ } else {
521
+ this.setError('FailedToSendMessage', 'Send message', message, error);
522
+ }
523
+ } finally {
524
+ this.abortController.signal.removeEventListener('abort', forwardStoreAbort);
525
+ this.chatUiStore.setAgentTyping(false);
526
+ }
527
+ }
528
+
529
+ /** Logs a streaming connection lifecycle phase (connected, disconnected, timed_out, completed, fallback). */
530
+ protected logStreaming(phase: string): void {
531
+ this.logService.info({
532
+ message: `Streaming ${phase}`,
533
+ category: 'TitanChatbot',
534
+ code: `TitanChatbot_Streaming_${phase}`,
535
+ });
536
+ }
537
+
363
538
  protected setSession(session?: Models.ISession) {
364
539
  this.session = session;
365
540
  }
@@ -400,8 +575,8 @@ export class ChatbotUiBackendStore extends InitializeStore implements IChatbotUi
400
575
 
401
576
  protected async api<
402
577
  K extends keyof IChatbotApiClient,
403
- Args extends Parameters<IChatbotApiClient[K]>,
404
- Result extends ReturnType<IChatbotApiClient[K]>,
578
+ Args extends Parameters<NonNullable<IChatbotApiClient[K]>>,
579
+ Result extends ReturnType<NonNullable<IChatbotApiClient[K]>>,
405
580
  >(methodName: K, ...args: Args): Promise<Awaited<Result>> {
406
581
  const method = this.chatbotApiClient[methodName];
407
582
  if (method) {
@@ -1,14 +1,7 @@
1
- export {
2
- CHATBOT_UI_BACKEND_STORE_TOKEN,
3
- IChatbotUiBackendStore,
4
- ChatbotUiBackendStore,
5
- } from './chatbot-ui-backend.store';
6
- export {
7
- CHATBOT_UI_STORE_TOKEN,
8
- IChatbotUiStore,
9
- ChatbotUiStore,
10
- ChatbotUiEvent,
11
- } from './chatbot-ui.store';
1
+ export type { IChatbotUiBackendStore } from './chatbot-ui-backend.store';
2
+ export { CHATBOT_UI_BACKEND_STORE_TOKEN, ChatbotUiBackendStore } from './chatbot-ui-backend.store';
3
+ export type { IChatbotUiStore } from './chatbot-ui.store';
4
+ export { CHATBOT_UI_STORE_TOKEN, ChatbotUiStore, ChatbotUiEvent } from './chatbot-ui.store';
12
5
  export {
13
6
  type IUiFilterOption,
14
7
  type IFilterStore,
@@ -18,4 +11,4 @@ export { InitializeStore, InitializeStoreStatus } from './initialize.store';
18
11
  export { MessageFeedbackStore } from './message-feedback.store';
19
12
  export { MessageFeedbackGuardrailStore } from './message-feedback-guardrail.store';
20
13
  export { SessionFeedbackStore } from './session-feedback.store';
21
- export { type FeedbackDraft, IMessageFeedbackBaseStore } from './message-feedback-base.store';
14
+ export type { FeedbackDraft, IMessageFeedbackBaseStore } from './message-feedback-base.store';
@@ -0,0 +1,80 @@
1
+ import { describe, expect, test } from '@jest/globals';
2
+ import { Models } from '../../api-client';
3
+ import { convertAgentFinishToBotMessage } from '../agent-stream';
4
+
5
+ describe('convertAgentFinishToBotMessage', () => {
6
+ test('preserves the backend BotMessage fields verbatim', () => {
7
+ // Real Bifurcation payload: backend sends guardFlag "BI" + isGuardrailed true.
8
+ const message = convertAgentFinishToBotMessage({
9
+ answer: 'pick one',
10
+ guardFlag: 'BI',
11
+ isGuardrailed: true,
12
+ sessionId: 122,
13
+ status: 'Bifurcation',
14
+ });
15
+
16
+ expect(message).toBeInstanceOf(Models.BotMessage);
17
+ expect(message.answer).toBe('pick one');
18
+ expect(message.guardFlag).toBe('BI');
19
+ expect(message.isGuardrailed).toBe(true);
20
+ expect(message.sessionId).toBe(122);
21
+ });
22
+
23
+ test('ignores streaming-only metadata (status, durationMs, runId, seq)', () => {
24
+ const message = convertAgentFinishToBotMessage({
25
+ answer: 'ok',
26
+ guardFlag: 'N',
27
+ isGuardrailed: false,
28
+ status: 'Success',
29
+ durationMs: 13603,
30
+ runId: 'ec66a0b47d99430a99b964f7478aadd7',
31
+ seq: 6,
32
+ });
33
+
34
+ const json = message.toJSON();
35
+ expect(json).not.toHaveProperty('status');
36
+ expect(json).not.toHaveProperty('durationMs');
37
+ expect(json).not.toHaveProperty('runId');
38
+ expect(json).not.toHaveProperty('seq');
39
+ });
40
+
41
+ test('wraps nested data into serializable instances (toJSON does not throw)', () => {
42
+ // Mirrors the decoded SSE JSON: nested data arrives as plain objects, not class instances.
43
+ const payload: any = {
44
+ answer: 'final',
45
+ guardFlag: 'BI',
46
+ isGuardrailed: true,
47
+ sessionId: 3,
48
+ scoredUrls: [{ url: 'https://kb/1', score: 0.9, title: 'Doc' }],
49
+ agentOptions: [{ agentId: 'playwright', passPhrase: 'Do it for me', caption: 'Auto' }],
50
+ workflowPlan: {
51
+ displayName: 'Refund',
52
+ estimatedRuntimeSec: 5,
53
+ steps: [{ number: 1, description: 'Look up order' }],
54
+ },
55
+ };
56
+ const message = convertAgentFinishToBotMessage(payload);
57
+
58
+ /*
59
+ * Regression: persisting chat state calls BotMessage.toJSON(), which iterates these and
60
+ * calls item.toJSON() on each — it threw "item.toJSON is not a function" for plain objects.
61
+ */
62
+ expect(() => JSON.stringify(message)).not.toThrow();
63
+ expect(message.scoredUrls?.[0]).toBeInstanceOf(Models.ScoredUrl);
64
+ expect(message.agentOptions?.[0]).toBeInstanceOf(Models.AgentOption);
65
+ expect(message.workflowPlan).toBeInstanceOf(Models.WorkflowPlan);
66
+ expect(message.workflowPlan?.steps[0]).toBeInstanceOf(Models.WorkflowStep);
67
+ });
68
+
69
+ test('handles a minimal payload with no nested data', () => {
70
+ const message = convertAgentFinishToBotMessage({
71
+ answer: 'x',
72
+ guardFlag: 'N',
73
+ isGuardrailed: false,
74
+ });
75
+ expect(message.scoredUrls).toBeUndefined();
76
+ expect(message.agentOptions).toBeUndefined();
77
+ expect(message.workflowPlan).toBeUndefined();
78
+ expect(() => JSON.stringify(message)).not.toThrow();
79
+ });
80
+ });
@@ -0,0 +1,103 @@
1
+ import { Models } from '../api-client';
2
+
3
+ /** Final run status reported by the backend on `run.finished`. */
4
+ export type AgentRunStatus =
5
+ | 'Success'
6
+ | 'Guardrailed'
7
+ | 'PendingApproval'
8
+ | 'Bifurcation'
9
+ | 'Error'
10
+ | 'Timeout';
11
+
12
+ export interface AgentPlanStep {
13
+ id: string;
14
+ title: string;
15
+ description?: string;
16
+ }
17
+
18
+ export interface AgentInputOption {
19
+ label: string;
20
+ value: string;
21
+ style?: string;
22
+ }
23
+
24
+ export interface AgentInputRequest {
25
+ kind: string;
26
+ prompt?: string;
27
+ options?: AgentInputOption[];
28
+ }
29
+
30
+ /**
31
+ * Decoded payload of the terminal `run.finished` event. The backend sends a full `BotMessage`
32
+ * (same field names: `answer`, `guardFlag`, `isGuardrailed`, `scoredUrls`, `agentOptions`,
33
+ * `workflowPlan`, `sessionId`, …) plus a little streaming-only metadata — so the mapping to the
34
+ * shared model is trivial (see {@link convertAgentFinishToBotMessage}).
35
+ */
36
+ export interface AgentRunFinishedData extends Models.IBotMessage {
37
+ /** Final run status (streaming-only metadata, not part of the BotMessage model). */
38
+ status?: AgentRunStatus;
39
+ /** Total run duration in ms (streaming-only metadata). */
40
+ durationMs?: number;
41
+ /** Backend run identifier (streaming-only metadata). */
42
+ runId?: string;
43
+ /** Monotonic event sequence number (streaming-only metadata). */
44
+ seq?: number;
45
+ }
46
+
47
+ /**
48
+ * Callbacks + config the consumer supplies to a streamed message. Maps the chatbot's agent events
49
+ * onto progress updates and exposes the generic connection lifecycle for logging / keepalive / fallback.
50
+ */
51
+ export interface AgentStreamHandlers {
52
+ /** Inactivity threshold before `onInactivity` fires (defaults applied by the caller). */
53
+ inactivityTimeoutMs?: number;
54
+ onStatus?(text: string): void;
55
+ onText?(text: string): void;
56
+ onPlan?(steps: AgentPlanStep[]): void;
57
+ /**
58
+ * Marks the plan step with the given id as the active one (earlier steps become done, later
59
+ * ones stay pending). Driven by an explicit `activeStepId` on `plan.proposed` or a `stepId` on
60
+ * `status.changed` when the backend supplies it; absent that, the first step is activated on
61
+ * `plan.proposed`. Mid-plan advancement therefore requires backend step correlation.
62
+ */
63
+ onStepActive?(id: string): void;
64
+ onInputRequested?(request: AgentInputRequest): void;
65
+ onInactivity?(): void;
66
+ onConnected?(): void;
67
+ onDisconnected?(): void;
68
+ onTimeout?(): void;
69
+ onCompleted?(): void;
70
+ }
71
+
72
+ /** Raised when the backend emits a `run.error` event. */
73
+ export class AgentStreamError extends Error {
74
+ constructor(message?: string) {
75
+ super(message ?? 'Something went wrong during this step. Please try again.');
76
+ this.name = 'AgentStreamError';
77
+ }
78
+ }
79
+
80
+ /** Path of the v2 streaming endpoint, appended to the configured base URL. */
81
+ export const AGENT_STREAM_PATH = '/api/v2/message/stream';
82
+
83
+ /** SSE event names emitted by the backend agent pipeline. */
84
+ export const AGENT_EVENT = {
85
+ RunStarted: 'run.started',
86
+ RunFinished: 'run.finished',
87
+ RunError: 'run.error',
88
+ StatusChanged: 'status.changed',
89
+ TextAppended: 'text.appended',
90
+ PlanProposed: 'plan.proposed',
91
+ InputRequested: 'input.requested',
92
+ } as const;
93
+
94
+ /**
95
+ * Map a terminal `run.finished` payload to the shared `BotMessage` model. The payload is already
96
+ * BotMessage-shaped, so `fromJS` does everything: it reads the backend's `guardFlag`/`isGuardrailed`
97
+ * verbatim, wraps nested data into real instances (`scoredUrls`, `agentOptions`, `workflowPlan` and
98
+ * its `steps`/`inputs`) so `toJSON()` can serialize it for session storage, and ignores the
99
+ * streaming-only metadata (`status`, `durationMs`, `runId`, `seq`).
100
+ */
101
+ export function convertAgentFinishToBotMessage(data: AgentRunFinishedData): Models.BotMessage {
102
+ return Models.BotMessage.fromJS(data);
103
+ }
@@ -0,0 +1,2 @@
1
+ export * from './agent-stream';
2
+ export * from './run-agent-stream';
@@ -0,0 +1,109 @@
1
+ import { ChatSseClient } from '@servicetitan/titan-chat-ui-common';
2
+ import { Models } from '../api-client';
3
+ import {
4
+ AGENT_EVENT,
5
+ AgentPlanStep,
6
+ AgentRunFinishedData,
7
+ AgentStreamError,
8
+ AgentStreamHandlers,
9
+ convertAgentFinishToBotMessage,
10
+ } from './agent-stream';
11
+
12
+ export interface RunAgentStreamOptions {
13
+ /** Fully-constructed streaming endpoint URL. */
14
+ url: string;
15
+ /** Request headers (auth, X-Client-ID, …). */
16
+ headers?: Record<string, string>;
17
+ /** Request body, serialized as JSON by the SSE client. */
18
+ body: unknown;
19
+ /** Custom `fetch` (same as the regular API client) so auth/headers/credentials apply identically. */
20
+ fetch?: typeof fetch;
21
+ /** Progress + lifecycle callbacks (and the inactivity timeout). */
22
+ handlers: AgentStreamHandlers;
23
+ abortSignal?: AbortSignal;
24
+ }
25
+
26
+ /**
27
+ * Shared SSE consumption used by every {@link IChatbotApiClient} adapter: opens a {@link ChatSseClient},
28
+ * maps the agent events onto the supplied handlers, and resolves the final {@link Models.BotMessage}
29
+ * from `run.finished` (or rejects on `run.error` / a fatal connection error). The only per-adapter
30
+ * differences — the URL, headers and body — are passed in by the caller.
31
+ */
32
+ export function runAgentStream({
33
+ abortSignal,
34
+ body,
35
+ fetch,
36
+ handlers,
37
+ headers,
38
+ url,
39
+ }: RunAgentStreamOptions): Promise<Models.BotMessage> {
40
+ return new Promise<Models.BotMessage>((resolve, reject) => {
41
+ let finished: AgentRunFinishedData | undefined;
42
+
43
+ const client = new ChatSseClient({
44
+ url,
45
+ headers,
46
+ fetch,
47
+ signal: abortSignal,
48
+ body,
49
+ inactivityTimeoutMs: handlers.inactivityTimeoutMs,
50
+ isTerminalEvent: e =>
51
+ e.event === AGENT_EVENT.RunFinished || e.event === AGENT_EVENT.RunError,
52
+ handlers: {
53
+ [AGENT_EVENT.StatusChanged]: (d: { text: string; stepId?: string }) => {
54
+ handlers.onStatus?.(d.text);
55
+ if (d.stepId !== undefined) {
56
+ handlers.onStepActive?.(d.stepId);
57
+ }
58
+ },
59
+ [AGENT_EVENT.TextAppended]: (d: { text: string }) => handlers.onText?.(d.text),
60
+ [AGENT_EVENT.PlanProposed]: (d: {
61
+ steps: AgentPlanStep[];
62
+ activeStepId?: string;
63
+ }) => {
64
+ handlers.onPlan?.(d.steps);
65
+ // Activate the explicitly-signalled step, else default to the first.
66
+ const activeId = d.activeStepId ?? d.steps?.[0]?.id;
67
+ if (activeId !== undefined) {
68
+ handlers.onStepActive?.(activeId);
69
+ }
70
+ },
71
+ [AGENT_EVENT.InputRequested]: (d: any) =>
72
+ handlers.onInputRequested?.({
73
+ kind: d.kind,
74
+ prompt: d.prompt,
75
+ options: d.options,
76
+ }),
77
+ [AGENT_EVENT.RunFinished]: (d: AgentRunFinishedData) => {
78
+ finished = d;
79
+ },
80
+ [AGENT_EVENT.RunError]: (d: { message?: string }) => {
81
+ reject(new AgentStreamError(d.message));
82
+ },
83
+ },
84
+ onConnected: handlers.onConnected,
85
+ onDisconnected: handlers.onDisconnected,
86
+ onTimeout: handlers.onTimeout,
87
+ onInactivity: handlers.onInactivity,
88
+ onCompleted: handlers.onCompleted,
89
+ onError: reject,
90
+ });
91
+
92
+ client.start().then(() => {
93
+ if (finished) {
94
+ try {
95
+ resolve(convertAgentFinishToBotMessage(finished));
96
+ } catch (err) {
97
+ reject(err);
98
+ }
99
+ return;
100
+ }
101
+ /*
102
+ * Stream completed without `run.finished`. A run.error / fatal onError may have
103
+ * already rejected (subsequent reject calls are no-ops); otherwise the server closed
104
+ * the connection unexpectedly — reject so the returned Promise never hangs.
105
+ */
106
+ reject(new AgentStreamError('Stream ended without a run.finished event'));
107
+ }, reject);
108
+ });
109
+ }