@webex/contact-center 3.12.0-next.15 → 3.12.0-next.151

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 (322) hide show
  1. package/.sdd/manifest.json +887 -0
  2. package/AGENTS.md +94 -0
  3. package/ai-docs/ARCHITECTURE.md +168 -0
  4. package/ai-docs/CONTRACTS.md +62 -0
  5. package/ai-docs/GETTING_STARTED.md +168 -0
  6. package/ai-docs/GLOSSARY.md +43 -0
  7. package/ai-docs/README.md +138 -0
  8. package/ai-docs/REVIEW_CHECKLIST.md +41 -0
  9. package/ai-docs/RULES.md +444 -0
  10. package/ai-docs/SECURITY.md +52 -0
  11. package/ai-docs/SERVICE_STATE.md +52 -0
  12. package/ai-docs/SPEC_INDEX.md +65 -0
  13. package/ai-docs/adr/0001-spec-source-policy.md +55 -0
  14. package/ai-docs/adr/README.md +8 -0
  15. package/ai-docs/adr/_adr-template.md +31 -0
  16. package/ai-docs/contact-center-spec.md +382 -0
  17. package/ai-docs/features/consult-transfer-list-policy/spec/feature-spec.md +362 -0
  18. package/ai-docs/features/contact-owner-change-refactor.md +78 -0
  19. package/ai-docs/features/generated-spec-conformance-fidelity-remediation/spec/feature-spec.md +117 -0
  20. package/ai-docs/features/residual-warning-coverage-completion/spec/feature-spec.md +203 -0
  21. package/ai-docs/features/validator-code-fidelity-drift-fix/spec/feature-spec.md +315 -0
  22. package/ai-docs/patterns/event-driven-patterns.md +485 -0
  23. package/ai-docs/patterns/testing-patterns.md +480 -0
  24. package/ai-docs/patterns/typescript-patterns.md +365 -0
  25. package/ai-docs/templates/README.md +102 -0
  26. package/ai-docs/templates/documentation/create-agents-md.md +240 -0
  27. package/ai-docs/templates/documentation/create-architecture-md.md +295 -0
  28. package/ai-docs/templates/existing-service/bug-fix.md +254 -0
  29. package/ai-docs/templates/existing-service/feature-enhancement.md +450 -0
  30. package/ai-docs/templates/new-method/00-master.md +80 -0
  31. package/ai-docs/templates/new-method/01-requirements.md +232 -0
  32. package/ai-docs/templates/new-method/02-implementation.md +295 -0
  33. package/ai-docs/templates/new-method/03-tests.md +201 -0
  34. package/ai-docs/templates/new-method/04-validation.md +141 -0
  35. package/ai-docs/templates/new-service/00-master.md +109 -0
  36. package/ai-docs/templates/new-service/01-pre-questions.md +159 -0
  37. package/ai-docs/templates/new-service/02-code-generation.md +346 -0
  38. package/ai-docs/templates/new-service/03-integration.md +178 -0
  39. package/ai-docs/templates/new-service/04-test-generation.md +205 -0
  40. package/ai-docs/templates/new-service/05-validation.md +145 -0
  41. package/dist/cc.js +1010 -47
  42. package/dist/cc.js.map +1 -1
  43. package/dist/config.js +13 -0
  44. package/dist/config.js.map +1 -1
  45. package/dist/constants.js +40 -3
  46. package/dist/constants.js.map +1 -1
  47. package/dist/index.js +54 -17
  48. package/dist/index.js.map +1 -1
  49. package/dist/metrics/MetricsManager.js +16 -0
  50. package/dist/metrics/MetricsManager.js.map +1 -1
  51. package/dist/metrics/behavioral-events.js +211 -0
  52. package/dist/metrics/behavioral-events.js.map +1 -1
  53. package/dist/metrics/constants.js +58 -4
  54. package/dist/metrics/constants.js.map +1 -1
  55. package/dist/services/AddressBook.js +18 -15
  56. package/dist/services/AddressBook.js.map +1 -1
  57. package/dist/services/AnswerCallOnWebexService.js +202 -0
  58. package/dist/services/AnswerCallOnWebexService.js.map +1 -0
  59. package/dist/services/ApiAiAssistant.js +351 -14
  60. package/dist/services/ApiAiAssistant.js.map +1 -1
  61. package/dist/services/EntryPoint.js +46 -68
  62. package/dist/services/EntryPoint.js.map +1 -1
  63. package/dist/services/Queue.js +27 -22
  64. package/dist/services/Queue.js.map +1 -1
  65. package/dist/services/UserPreference.js +427 -0
  66. package/dist/services/UserPreference.js.map +1 -0
  67. package/dist/services/WebexCrossClientService.js +225 -0
  68. package/dist/services/WebexCrossClientService.js.map +1 -0
  69. package/dist/services/WxAppTelephonyMercurySync.js +111 -0
  70. package/dist/services/WxAppTelephonyMercurySync.js.map +1 -0
  71. package/dist/services/config/Util.js +48 -48
  72. package/dist/services/config/Util.js.map +1 -1
  73. package/dist/services/config/constants.js +56 -78
  74. package/dist/services/config/constants.js.map +1 -1
  75. package/dist/services/config/index.js +100 -226
  76. package/dist/services/config/index.js.map +1 -1
  77. package/dist/services/config/types.js +97 -24
  78. package/dist/services/config/types.js.map +1 -1
  79. package/dist/services/core/Err.js.map +1 -1
  80. package/dist/services/core/Utils.js +111 -32
  81. package/dist/services/core/Utils.js.map +1 -1
  82. package/dist/services/core/WebexRequest.js +6 -2
  83. package/dist/services/core/WebexRequest.js.map +1 -1
  84. package/dist/services/core/aqm-reqs.js +28 -14
  85. package/dist/services/core/aqm-reqs.js.map +1 -1
  86. package/dist/services/core/types.js.map +1 -1
  87. package/dist/services/core/websocket/WebSocketManager.js +2 -1
  88. package/dist/services/core/websocket/WebSocketManager.js.map +1 -1
  89. package/dist/services/core/websocket/types.js.map +1 -1
  90. package/dist/services/index.js +1 -1
  91. package/dist/services/index.js.map +1 -1
  92. package/dist/services/task/Task.js +955 -0
  93. package/dist/services/task/Task.js.map +1 -0
  94. package/dist/services/task/TaskFactory.js +50 -0
  95. package/dist/services/task/TaskFactory.js.map +1 -0
  96. package/dist/services/task/TaskManager.js +1272 -523
  97. package/dist/services/task/TaskManager.js.map +1 -1
  98. package/dist/services/task/TaskUtils.js +221 -27
  99. package/dist/services/task/TaskUtils.js.map +1 -1
  100. package/dist/services/task/WebexCallingUtils.js +70 -0
  101. package/dist/services/task/WebexCallingUtils.js.map +1 -0
  102. package/dist/services/task/constants.js +21 -2
  103. package/dist/services/task/constants.js.map +1 -1
  104. package/dist/services/task/contact.js +29 -0
  105. package/dist/services/task/contact.js.map +1 -1
  106. package/dist/services/task/dialer.js +78 -0
  107. package/dist/services/task/dialer.js.map +1 -1
  108. package/dist/services/task/digital/Digital.js +78 -0
  109. package/dist/services/task/digital/Digital.js.map +1 -0
  110. package/dist/services/task/state-machine/TaskStateMachine.js +982 -0
  111. package/dist/services/task/state-machine/TaskStateMachine.js.map +1 -0
  112. package/dist/services/task/state-machine/actions.js +572 -0
  113. package/dist/services/task/state-machine/actions.js.map +1 -0
  114. package/dist/services/task/state-machine/constants.js +161 -0
  115. package/dist/services/task/state-machine/constants.js.map +1 -0
  116. package/dist/services/task/state-machine/guards.js +379 -0
  117. package/dist/services/task/state-machine/guards.js.map +1 -0
  118. package/dist/services/task/state-machine/index.js +53 -0
  119. package/dist/services/task/state-machine/index.js.map +1 -0
  120. package/dist/services/task/state-machine/types.js +54 -0
  121. package/dist/services/task/state-machine/types.js.map +1 -0
  122. package/dist/services/task/state-machine/uiControlsComputer.js +703 -0
  123. package/dist/services/task/state-machine/uiControlsComputer.js.map +1 -0
  124. package/dist/services/task/taskDataNormalizer.js +99 -0
  125. package/dist/services/task/taskDataNormalizer.js.map +1 -0
  126. package/dist/services/task/types.js +259 -5
  127. package/dist/services/task/types.js.map +1 -1
  128. package/dist/services/task/voice/Voice.js +1504 -0
  129. package/dist/services/task/voice/Voice.js.map +1 -0
  130. package/dist/services/task/voice/WebRTC.js +152 -0
  131. package/dist/services/task/voice/WebRTC.js.map +1 -0
  132. package/dist/services/task/voice/wxAppOfferObservability.js +145 -0
  133. package/dist/services/task/voice/wxAppOfferObservability.js.map +1 -0
  134. package/dist/services/task/voice/wxAppVoiceMethods.js +395 -0
  135. package/dist/services/task/voice/wxAppVoiceMethods.js.map +1 -0
  136. package/dist/services/wxAppDiagnosticLogging.js +159 -0
  137. package/dist/services/wxAppDiagnosticLogging.js.map +1 -0
  138. package/dist/services/wxAppTelephonyUtils.js +19 -0
  139. package/dist/services/wxAppTelephonyUtils.js.map +1 -0
  140. package/dist/types/cc.d.ts +196 -9
  141. package/dist/types/config.d.ts +13 -0
  142. package/dist/types/constants.d.ts +38 -1
  143. package/dist/types/index.d.ts +54 -20
  144. package/dist/types/metrics/MetricsManager.d.ts +6 -0
  145. package/dist/types/metrics/constants.d.ts +52 -1
  146. package/dist/types/services/AddressBook.d.ts +2 -1
  147. package/dist/types/services/AnswerCallOnWebexService.d.ts +38 -0
  148. package/dist/types/services/ApiAiAssistant.d.ts +58 -5
  149. package/dist/types/services/EntryPoint.d.ts +7 -5
  150. package/dist/types/services/Queue.d.ts +5 -3
  151. package/dist/types/services/UserPreference.d.ts +118 -0
  152. package/dist/types/services/WebexCrossClientService.d.ts +32 -0
  153. package/dist/types/services/WxAppTelephonyMercurySync.d.ts +28 -0
  154. package/dist/types/services/config/Util.d.ts +4 -8
  155. package/dist/types/services/config/constants.d.ts +49 -76
  156. package/dist/types/services/config/index.d.ts +16 -64
  157. package/dist/types/services/config/types.d.ts +248 -114
  158. package/dist/types/services/core/Err.d.ts +6 -0
  159. package/dist/types/services/core/Utils.d.ts +35 -13
  160. package/dist/types/services/core/WebexRequest.d.ts +1 -0
  161. package/dist/types/services/core/types.d.ts +2 -0
  162. package/dist/types/services/core/websocket/WebSocketManager.d.ts +1 -0
  163. package/dist/types/services/core/websocket/types.d.ts +1 -1
  164. package/dist/types/services/index.d.ts +1 -1
  165. package/dist/types/services/task/Task.d.ts +186 -0
  166. package/dist/types/services/task/TaskFactory.d.ts +14 -0
  167. package/dist/types/services/task/TaskUtils.d.ts +54 -2
  168. package/dist/types/services/task/WebexCallingUtils.d.ts +11 -0
  169. package/dist/types/services/task/constants.d.ts +19 -0
  170. package/dist/types/services/task/contact.d.ts +4 -0
  171. package/dist/types/services/task/dialer.d.ts +30 -0
  172. package/dist/types/services/task/digital/Digital.d.ts +23 -0
  173. package/dist/types/services/task/state-machine/TaskStateMachine.d.ts +1442 -0
  174. package/dist/types/services/task/state-machine/actions.d.ts +10 -0
  175. package/dist/types/services/task/state-machine/constants.d.ts +107 -0
  176. package/dist/types/services/task/state-machine/guards.d.ts +103 -0
  177. package/dist/types/services/task/state-machine/index.d.ts +13 -0
  178. package/dist/types/services/task/state-machine/types.d.ts +277 -0
  179. package/dist/types/services/task/state-machine/uiControlsComputer.d.ts +9 -0
  180. package/dist/types/services/task/taskDataNormalizer.d.ts +10 -0
  181. package/dist/types/services/task/types.d.ts +790 -66
  182. package/dist/types/services/task/voice/Voice.d.ts +241 -0
  183. package/dist/types/services/task/voice/WebRTC.d.ts +54 -0
  184. package/dist/types/services/task/voice/wxAppOfferObservability.d.ts +39 -0
  185. package/dist/types/services/task/voice/wxAppVoiceMethods.d.ts +68 -0
  186. package/dist/types/services/wxAppDiagnosticLogging.d.ts +63 -0
  187. package/dist/types/services/wxAppTelephonyUtils.d.ts +5 -0
  188. package/dist/types/types.d.ts +213 -11
  189. package/dist/types/utils/PageCache.d.ts +20 -3
  190. package/dist/types/webex.d.ts +1 -0
  191. package/dist/types.js +168 -6
  192. package/dist/types.js.map +1 -1
  193. package/dist/utils/PageCache.js +19 -5
  194. package/dist/utils/PageCache.js.map +1 -1
  195. package/dist/webex.js +22 -2
  196. package/dist/webex.js.map +1 -1
  197. package/package.json +16 -12
  198. package/src/cc.ts +1338 -121
  199. package/src/config.ts +13 -0
  200. package/src/constants.ts +41 -1
  201. package/src/index.ts +62 -15
  202. package/src/metrics/MetricsManager.ts +16 -0
  203. package/src/metrics/ai-docs/AGENTS.md +350 -0
  204. package/src/metrics/ai-docs/ARCHITECTURE.md +338 -0
  205. package/src/metrics/ai-docs/metrics-spec.md +971 -0
  206. package/src/metrics/behavioral-events.ts +218 -0
  207. package/src/metrics/constants.ts +63 -4
  208. package/src/services/AddressBook.ts +17 -6
  209. package/src/services/AnswerCallOnWebexService.ts +239 -0
  210. package/src/services/ApiAiAssistant.ts +470 -15
  211. package/src/services/EntryPoint.ts +59 -60
  212. package/src/services/Queue.ts +29 -12
  213. package/src/services/UserPreference.ts +509 -0
  214. package/src/services/WebexCrossClientService.ts +298 -0
  215. package/src/services/WxAppTelephonyMercurySync.ts +142 -0
  216. package/src/services/agent/ai-docs/AGENTS.md +240 -0
  217. package/src/services/agent/ai-docs/ARCHITECTURE.md +304 -0
  218. package/src/services/agent/ai-docs/agent-spec.md +505 -0
  219. package/src/services/ai-docs/AGENTS.md +386 -0
  220. package/src/services/ai-docs/services-spec.md +505 -0
  221. package/src/services/config/Util.ts +54 -84
  222. package/src/services/config/ai-docs/AGENTS.md +250 -0
  223. package/src/services/config/ai-docs/ARCHITECTURE.md +413 -0
  224. package/src/services/config/ai-docs/config-spec.md +669 -0
  225. package/src/services/config/constants.ts +63 -85
  226. package/src/services/config/index.ts +116 -277
  227. package/src/services/config/types.ts +233 -125
  228. package/src/services/core/Err.ts +3 -0
  229. package/src/services/core/Utils.ts +127 -37
  230. package/src/services/core/WebexRequest.ts +3 -1
  231. package/src/services/core/ai-docs/AGENTS.md +381 -0
  232. package/src/services/core/ai-docs/ARCHITECTURE.md +698 -0
  233. package/src/services/core/ai-docs/core-spec.md +787 -0
  234. package/src/services/core/aqm-reqs.ts +30 -15
  235. package/src/services/core/types.ts +33 -1
  236. package/src/services/core/websocket/WebSocketManager.ts +2 -0
  237. package/src/services/core/websocket/types.ts +1 -1
  238. package/src/services/index.ts +1 -1
  239. package/src/services/task/Task.ts +1211 -0
  240. package/src/services/task/TaskFactory.ts +82 -0
  241. package/src/services/task/TaskManager.ts +1584 -643
  242. package/src/services/task/TaskUtils.ts +316 -25
  243. package/src/services/task/WebexCallingUtils.ts +136 -0
  244. package/src/services/task/ai-docs/AGENTS.md +457 -0
  245. package/src/services/task/ai-docs/ARCHITECTURE.md +595 -0
  246. package/src/services/task/ai-docs/task-spec.md +1501 -0
  247. package/src/services/task/constants.ts +20 -0
  248. package/src/services/task/contact.ts +30 -0
  249. package/src/services/task/dialer.ts +80 -0
  250. package/src/services/task/digital/Digital.ts +102 -0
  251. package/src/services/task/state-machine/TaskStateMachine.ts +1320 -0
  252. package/src/services/task/state-machine/actions.ts +741 -0
  253. package/src/services/task/state-machine/ai-docs/AGENTS.md +460 -0
  254. package/src/services/task/state-machine/ai-docs/ARCHITECTURE.md +1156 -0
  255. package/src/services/task/state-machine/ai-docs/task-state-machine-spec.md +2247 -0
  256. package/src/services/task/state-machine/constants.ts +172 -0
  257. package/src/services/task/state-machine/guards.ts +442 -0
  258. package/src/services/task/state-machine/index.ts +28 -0
  259. package/src/services/task/state-machine/types.ts +258 -0
  260. package/src/services/task/state-machine/uiControlsComputer.ts +1135 -0
  261. package/src/services/task/taskDataNormalizer.ts +137 -0
  262. package/src/services/task/types.ts +974 -70
  263. package/src/services/task/voice/Voice.ts +1878 -0
  264. package/src/services/task/voice/WebRTC.ts +194 -0
  265. package/src/services/task/voice/wxAppOfferObservability.ts +204 -0
  266. package/src/services/task/voice/wxAppVoiceMethods.ts +575 -0
  267. package/src/services/wxAppDiagnosticLogging.ts +286 -0
  268. package/src/services/wxAppTelephonyUtils.ts +14 -0
  269. package/src/types.ts +272 -13
  270. package/src/utils/AGENTS.md +289 -0
  271. package/src/utils/PageCache.ts +38 -5
  272. package/src/utils/ai-docs/utils-spec.md +391 -0
  273. package/src/webex.js +10 -0
  274. package/test/unit/fixtures/aiSummaryTestUtils.ts +40 -0
  275. package/test/unit/spec/cc.ts +3705 -150
  276. package/test/unit/spec/index.ts +885 -0
  277. package/test/unit/spec/logger-proxy.ts +70 -0
  278. package/test/unit/spec/metrics/MetricsManager.ts +25 -0
  279. package/test/unit/spec/metrics/behavioral-events.ts +60 -0
  280. package/test/unit/spec/services/AddressBook.ts +37 -6
  281. package/test/unit/spec/services/AnswerCallOnWebexService.ts +264 -0
  282. package/test/unit/spec/services/ApiAiAssistant.ts +429 -23
  283. package/test/unit/spec/services/EntryPoint.ts +87 -40
  284. package/test/unit/spec/services/Queue.ts +123 -12
  285. package/test/unit/spec/services/UserPreference.ts +401 -0
  286. package/test/unit/spec/services/WebCallingService.ts +7 -1
  287. package/test/unit/spec/services/WebexCrossClientService.ts +509 -0
  288. package/test/unit/spec/services/WxAppTelephonyMercurySync.ts +136 -0
  289. package/test/unit/spec/services/config/Util.ts +501 -0
  290. package/test/unit/spec/services/config/index.ts +317 -547
  291. package/test/unit/spec/services/core/Utils.ts +425 -8
  292. package/test/unit/spec/services/core/WebexRequest.ts +8 -7
  293. package/test/unit/spec/services/core/aqm-reqs.ts +113 -1
  294. package/test/unit/spec/services/core/websocket/WebSocketManager.ts +66 -40
  295. package/test/unit/spec/services/task/AutoWrapup.ts +63 -0
  296. package/test/unit/spec/services/task/Task.ts +2723 -0
  297. package/test/unit/spec/services/task/TaskFactory.ts +116 -0
  298. package/test/unit/spec/services/task/TaskManager.ts +4781 -1585
  299. package/test/unit/spec/services/task/TaskUtils.ts +307 -0
  300. package/test/unit/spec/services/task/WebexCallingUtils.ts +153 -0
  301. package/test/unit/spec/services/task/contact.ts +33 -0
  302. package/test/unit/spec/services/task/dialer.ts +190 -0
  303. package/test/unit/spec/services/task/digital/Digital.ts +105 -0
  304. package/test/unit/spec/services/task/state-machine/TaskStateMachine.ts +4060 -0
  305. package/test/unit/spec/services/task/state-machine/guards.ts +839 -0
  306. package/test/unit/spec/services/task/state-machine/types.ts +18 -0
  307. package/test/unit/spec/services/task/state-machine/uiControlsComputer.ts +3101 -0
  308. package/test/unit/spec/services/task/taskTestUtils.ts +87 -0
  309. package/test/unit/spec/services/task/voice/Voice.ts +2355 -0
  310. package/test/unit/spec/services/task/voice/WebRTC.ts +235 -0
  311. package/test/unit/spec/services/task/voice/wxAppOfferObservability.ts +361 -0
  312. package/test/unit/spec/services/task/voice/wxAppVoiceMethods.ts +719 -0
  313. package/test/unit/spec/services/wxAppDiagnosticLogging.ts +179 -0
  314. package/test/unit/spec/wellnessSampleUtils.ts +288 -0
  315. package/typedoc.md +1 -1
  316. package/umd/contact-center.min.js +2 -2
  317. package/umd/contact-center.min.js.map +1 -1
  318. package/dist/services/task/index.js +0 -1530
  319. package/dist/services/task/index.js.map +0 -1
  320. package/dist/types/services/task/index.d.ts +0 -650
  321. package/src/services/task/index.ts +0 -1806
  322. package/test/unit/spec/services/task/index.ts +0 -2205
@@ -0,0 +1,2723 @@
1
+ import Task from '../../../../../src/services/task/Task';
2
+ import {
3
+ TaskData,
4
+ DESTINATION_TYPE,
5
+ TASK_EVENTS,
6
+ TASK_CHANNEL_TYPE,
7
+ TransferPayLoad,
8
+ VOICE_VARIANT,
9
+ AISummaryAction,
10
+ AISummaryResponse,
11
+ } from '../../../../../src/services/task/types';
12
+ import {TaskEvent} from '../../../../../src/services/task/state-machine';
13
+ import {ENTRY_POINT_TRANSFER_DESTINATION_TYPE} from '../../../../../src/services/task/constants';
14
+ import LoggerProxy from '../../../../../src/logger-proxy';
15
+ import {createTaskData} from './taskTestUtils';
16
+ import {AIAssistantEventName} from '../../../../../src/types';
17
+ import {AI_SUMMARY_ERROR_CODES} from '../../../../../src/constants';
18
+ import {METRIC_EVENT_NAMES} from '../../../../../src/metrics/constants';
19
+ import {
20
+ AI_SUMMARY_DURATION_MS,
21
+ AI_SUMMARY_REQUEST_CANCELLED,
22
+ METHODS,
23
+ } from '../../../../../src/services/task/constants';
24
+ import {
25
+ createAISummaryError,
26
+ createAISummaryErrorExpectation,
27
+ createDeferred,
28
+ flushEventLoopTurn,
29
+ } from '../../../fixtures/aiSummaryTestUtils';
30
+ import type ApiAIAssistant from '../../../../../src/services/ApiAiAssistant';
31
+
32
+ const AI_SUMMARY_TRANSPORT_ERROR_CODES = {
33
+ VALIDATION_FAILED: 'AI_SUMMARY_TRANSPORT_VALIDATION_FAILED',
34
+ HTTP_REQUEST_FAILED: 'AI_SUMMARY_HTTP_REQUEST_FAILED',
35
+ TIMEOUT: 'AI_SUMMARY_TRANSPORT_TIMEOUT',
36
+ } as const;
37
+
38
+ class DummyTask extends Task {
39
+ constructor(
40
+ contact: any,
41
+ data: TaskData,
42
+ wrapupData?: any,
43
+ agentId = 'agent-1',
44
+ agentName = 'Receiving Agent',
45
+ apiAIAssistant?: ApiAIAssistant
46
+ ) {
47
+ super(
48
+ contact,
49
+ data,
50
+ {
51
+ channelType: 'voice',
52
+ isEndTaskEnabled: true,
53
+ isEndConsultEnabled: true,
54
+ },
55
+ wrapupData,
56
+ agentId,
57
+ agentName,
58
+ apiAIAssistant
59
+ );
60
+ }
61
+
62
+ public accept() {
63
+ return Promise.resolve({} as any);
64
+ }
65
+ }
66
+
67
+ class ConferenceTask extends DummyTask {
68
+ protected override getChannelSpecificActionOverrides() {
69
+ return {
70
+ ...super.getChannelSpecificActionOverrides(),
71
+ emitTaskParticipantLeft: this.createEmitSelfAction(TASK_EVENTS.TASK_PARTICIPANT_LEFT, {
72
+ updateTaskData: true,
73
+ }),
74
+ emitTaskConferenceEnded: this.createEmitSelfAction(TASK_EVENTS.TASK_CONFERENCE_ENDED, {
75
+ updateTaskData: true,
76
+ }),
77
+ };
78
+ }
79
+ }
80
+
81
+ class SpyAcceptTask extends Task {
82
+ public acceptMock: jest.Mock;
83
+
84
+ constructor(contact: any, data: TaskData, configOverrides: any = {}) {
85
+ super(contact, data, {
86
+ channelType: TASK_CHANNEL_TYPE.VOICE,
87
+ isEndTaskEnabled: true,
88
+ isEndConsultEnabled: true,
89
+ ...configOverrides,
90
+ });
91
+ this.acceptMock = jest.fn().mockResolvedValue({} as any);
92
+ }
93
+
94
+ public accept() {
95
+ return this.acceptMock();
96
+ }
97
+ }
98
+
99
+ jest.mock('xstate', () => ({
100
+ createActor: jest.fn((machine) => {
101
+ let snapshot = {value: 'IDLE', context: {}, event: undefined as any};
102
+ const subscribers = new Set<any>();
103
+ const actions = machine?.options?.actions ?? {};
104
+ const notify = () => {
105
+ subscribers.forEach((subscriber) => subscriber(snapshot));
106
+ };
107
+ const transition = (value: string, event: any) => {
108
+ snapshot = {value, context: {}, event};
109
+ notify();
110
+ };
111
+
112
+ return {
113
+ subscribe: jest.fn((subscriber) => {
114
+ subscribers.add(subscriber);
115
+
116
+ return {unsubscribe: () => subscribers.delete(subscriber)};
117
+ }),
118
+ start: jest.fn(() => notify()),
119
+ stop: jest.fn(),
120
+ getSnapshot: jest.fn(() => snapshot),
121
+ send: jest.fn((event) => {
122
+ switch (event?.type) {
123
+ case 'TASK_INCOMING':
124
+ actions.emitTaskIncoming?.({event});
125
+ transition('OFFERED', event);
126
+ break;
127
+ case 'TASK_OFFERED':
128
+ actions.requestAutoAnswer?.({event});
129
+ transition('OFFERED', event);
130
+ break;
131
+ case 'ASSIGN':
132
+ actions.emitTaskAssigned?.({event});
133
+ transition('CONNECTED', event);
134
+ break;
135
+ case 'CONTACT_UPDATED':
136
+ actions.syncTaskDataFromEvent?.({event});
137
+ transition(snapshot.value, event);
138
+ break;
139
+ case 'CONTACT_OWNER_CHANGED':
140
+ actions.emitTaskHydrate?.({event});
141
+ transition(snapshot.value, event);
142
+ break;
143
+ case 'CONFERENCE_START':
144
+ transition('CONFERENCING', event);
145
+ break;
146
+ case 'PARTICIPANT_LEAVE':
147
+ actions.emitTaskParticipantLeft?.({event});
148
+ transition(snapshot.value, event);
149
+ break;
150
+ case 'CONTACT_ENDED':
151
+ actions.requestCleanup?.({event});
152
+ transition('WRAPUP', event);
153
+ break;
154
+ case 'RONA':
155
+ actions.cleanupResources?.({event});
156
+ transition('ENDED', event);
157
+ break;
158
+ default:
159
+ transition(snapshot.value, event);
160
+ }
161
+ }),
162
+ };
163
+ }),
164
+ }));
165
+
166
+ jest.mock('../../../../../src/services/task/state-machine', () => ({
167
+ __esModule: true,
168
+ TaskEvent: {
169
+ TASK_INCOMING: 'TASK_INCOMING',
170
+ TASK_OFFERED: 'TASK_OFFERED',
171
+ ASSIGN: 'ASSIGN',
172
+ CONTACT_UPDATED: 'CONTACT_UPDATED',
173
+ CONTACT_OWNER_CHANGED: 'CONTACT_OWNER_CHANGED',
174
+ CONFERENCE_START: 'CONFERENCE_START',
175
+ PARTICIPANT_LEAVE: 'PARTICIPANT_LEAVE',
176
+ CONTACT_ENDED: 'CONTACT_ENDED',
177
+ RONA: 'RONA',
178
+ TASK_WRAPUP: 'TASK_WRAPUP',
179
+ },
180
+ createTaskStateMachine: jest.fn((_config, options) => ({options})),
181
+ }));
182
+
183
+ jest.mock('../../../../../src/services/task/state-machine/uiControlsComputer', () => {
184
+ const createControl = () => ({isVisible: false, isEnabled: false});
185
+ const createLegControls = () => ({
186
+ accept: createControl(),
187
+ decline: createControl(),
188
+ hold: createControl(),
189
+ transfer: createControl(),
190
+ consult: createControl(),
191
+ end: createControl(),
192
+ recording: createControl(),
193
+ mute: createControl(),
194
+ consultTransfer: createControl(),
195
+ endConsult: createControl(),
196
+ conference: createControl(),
197
+ exitConference: createControl(),
198
+ transferConference: createControl(),
199
+ mergeToConference: createControl(),
200
+ wrapup: createControl(),
201
+ switch: createControl(),
202
+ });
203
+ const createDefaultControls = () => ({
204
+ main: createLegControls(),
205
+ consult: createLegControls(),
206
+ activeLeg: 'main',
207
+ });
208
+
209
+ return {
210
+ __esModule: true,
211
+ computeUIControls: jest.fn((state) => {
212
+ const controls = createDefaultControls();
213
+ if (state === 'CONFERENCING') {
214
+ controls.main.exitConference = {isVisible: true, isEnabled: true};
215
+ }
216
+
217
+ return controls;
218
+ }),
219
+ getDefaultUIControls: jest.fn(createDefaultControls),
220
+ haveUIControlsChanged: jest.fn(() => false),
221
+ };
222
+ });
223
+
224
+ jest.mock('../../../../../src/services/task/AutoWrapup', () => ({
225
+ __esModule: true,
226
+ default: jest.fn().mockImplementation(() => ({
227
+ start: jest.fn(),
228
+ clear: jest.fn(),
229
+ })),
230
+ }));
231
+
232
+ jest.mock('../../../../../src/services/task/TaskUtils', () => ({
233
+ createSummaryError: jest.fn((errorCode) => {
234
+ const error = new Error(errorCode) as Error & {data?: Record<string, unknown>};
235
+ error.data = {errorCode};
236
+
237
+ return error;
238
+ }),
239
+ }));
240
+
241
+ jest.mock('../../../../../src/services/core/Utils', () => ({
242
+ getErrorDetails: jest.fn((_error, method) => ({
243
+ error: new Error(`Error while performing ${method}`),
244
+ })),
245
+ }));
246
+
247
+ jest.mock('../../../../../src/metrics/MetricsManager', () => {
248
+ const metricsInstance = {
249
+ trackEvent: jest.fn(),
250
+ timeEvent: jest.fn(),
251
+ };
252
+ const MetricsManager = jest.fn();
253
+
254
+ (MetricsManager as any).getInstance = jest.fn(() => metricsInstance);
255
+ (MetricsManager as any).getCommonTrackingFieldForAQMResponse = jest.fn(() => ({}));
256
+ (MetricsManager as any).getCommonTrackingFieldForAQMResponseFailed = jest.fn(() => ({}));
257
+
258
+ return {
259
+ __esModule: true,
260
+ default: MetricsManager,
261
+ };
262
+ });
263
+
264
+ jest.mock('../../../../../src/logger-proxy', () => ({
265
+ __esModule: true,
266
+ default: {
267
+ log: jest.fn(),
268
+ error: jest.fn(),
269
+ info: jest.fn(),
270
+ initialize: jest.fn(),
271
+ },
272
+ }));
273
+
274
+ jest.mock('../../../../../src/services/core/WebexRequest', () => ({
275
+ __esModule: true,
276
+ default: {
277
+ getInstance: jest.fn().mockReturnValue({uploadLogs: jest.fn()}),
278
+ },
279
+ }));
280
+
281
+ beforeEach(() => {
282
+ jest.clearAllMocks();
283
+ });
284
+
285
+ describe('Task (base class)', () => {
286
+ const dummyContact = {} as any;
287
+ const initialData = {
288
+ foo: 'bar',
289
+ nested: {a: 1, b: 2},
290
+ } as unknown as TaskData;
291
+
292
+ let task: DummyTask;
293
+
294
+ beforeEach(() => {
295
+ task = new DummyTask(dummyContact, initialData);
296
+ });
297
+
298
+ it('merges updateTaskData when shouldOverwrite is false', () => {
299
+ const updated = {foo: 'baz', nested: {b: 3}} as unknown as TaskData;
300
+ task.updateTaskData(updated);
301
+ expect(task.data.foo).toBe('baz');
302
+ // nested.a remains, nested.b updated
303
+ expect((task.data as any).nested).toEqual({a: 1, b: 3});
304
+ });
305
+
306
+ it('overwrites data when shouldOverwrite is true', () => {
307
+ const updated = {x: 42} as unknown as TaskData;
308
+ task.updateTaskData(updated, true);
309
+ expect((task.data as any).x).toBe(42);
310
+ expect((task.data as any).foo).toBeUndefined();
311
+ });
312
+
313
+ it('drops stale interaction.media/participants entries the backend no longer sends (merge keeps other fields)', () => {
314
+ const withConsult = createTaskData({
315
+ interaction: {
316
+ callAssociatedData: {a: {value: 'keep-me'}},
317
+ media: {
318
+ main: {mediaResourceId: 'main', mType: 'mainCall', isHold: true},
319
+ consult: {mediaResourceId: 'consult', mType: 'consult', isHold: false},
320
+ },
321
+ participants: {
322
+ 'customer-1': {id: 'customer-1', pType: 'Customer'},
323
+ 'agent-1': {id: 'agent-1', pType: 'Agent'},
324
+ 'agent-2': {id: 'agent-2', pType: 'Agent', consultState: 'consulting'},
325
+ },
326
+ } as any,
327
+ }) as unknown as TaskData;
328
+ task.updateTaskData(withConsult, true);
329
+
330
+ // Backend resume snapshot: consult leg gone (only main media + main participants).
331
+ const resumeData = createTaskData({
332
+ interaction: {
333
+ media: {main: {mediaResourceId: 'main', mType: 'mainCall', isHold: false}},
334
+ participants: {
335
+ 'customer-1': {id: 'customer-1', pType: 'Customer'},
336
+ 'agent-1': {id: 'agent-1', pType: 'Agent', consultState: null},
337
+ },
338
+ } as any,
339
+ }) as unknown as TaskData;
340
+ task.updateTaskData(resumeData);
341
+
342
+ const interaction = (task.data as any).interaction;
343
+ // Stale consult media + consultee participant (absent from the resume snapshot) are removed.
344
+ expect(interaction.media.consult).toBeUndefined();
345
+ expect(interaction.participants['agent-2']).toBeUndefined();
346
+ // Entries still present in the incoming snapshot survive and reflect the new values.
347
+ expect(interaction.media.main).toBeDefined();
348
+ expect(interaction.media.main.isHold).toBe(false);
349
+ expect(interaction.participants['agent-1']).toBeDefined();
350
+ expect(interaction.participants['customer-1']).toBeDefined();
351
+ // Unrelated merge-able fields (CAD) are preserved.
352
+ expect(interaction.callAssociatedData.a.value).toBe('keep-me');
353
+ });
354
+
355
+ it('does not touch interaction maps when the incoming payload omits them', () => {
356
+ const withConsult = createTaskData({
357
+ interaction: {
358
+ media: {main: {mediaResourceId: 'main'}, consult: {mediaResourceId: 'consult'}},
359
+ participants: {'agent-1': {id: 'agent-1'}, 'agent-2': {id: 'agent-2'}},
360
+ } as any,
361
+ }) as unknown as TaskData;
362
+ task.updateTaskData(withConsult, true);
363
+
364
+ // A partial update with no interaction maps must not prune existing media/participants.
365
+ task.updateTaskData({foo: 'changed'} as unknown as TaskData);
366
+
367
+ const interaction = (task.data as any).interaction;
368
+ expect(interaction.media.main).toBeDefined();
369
+ expect(interaction.media.consult).toBeDefined();
370
+ expect(interaction.participants['agent-1']).toBeDefined();
371
+ expect(interaction.participants['agent-2']).toBeDefined();
372
+ });
373
+
374
+ it('getUIControls returns default controls shape for idle voice task', () => {
375
+ const controls = task.uiControls;
376
+ const mainControls = controls.main;
377
+
378
+ // IDLE state: no active call, ALL controls should be hidden AND disabled
379
+ expect(mainControls.accept.isVisible).toBe(false);
380
+ expect(mainControls.accept.isEnabled).toBe(false);
381
+ expect(mainControls.decline.isVisible).toBe(false);
382
+ expect(mainControls.decline.isEnabled).toBe(false);
383
+ expect(mainControls.end.isVisible).toBe(false);
384
+ expect(mainControls.end.isEnabled).toBe(false);
385
+ expect(mainControls.transfer.isVisible).toBe(false);
386
+ expect(mainControls.transfer.isEnabled).toBe(false);
387
+ expect(mainControls.hold.isVisible).toBe(false);
388
+ expect(mainControls.hold.isEnabled).toBe(false);
389
+ expect(mainControls.mute.isVisible).toBe(false);
390
+ expect(mainControls.mute.isEnabled).toBe(false);
391
+ expect(mainControls.consult.isVisible).toBe(false);
392
+ expect(mainControls.consult.isEnabled).toBe(false);
393
+ expect(mainControls.consultTransfer.isVisible).toBe(false);
394
+ expect(mainControls.consultTransfer.isEnabled).toBe(false);
395
+ expect(mainControls.endConsult.isVisible).toBe(false);
396
+ expect(mainControls.endConsult.isEnabled).toBe(false);
397
+ expect(mainControls.recording.isVisible).toBe(false);
398
+ expect(mainControls.recording.isEnabled).toBe(false);
399
+ expect(mainControls.conference.isVisible).toBe(false);
400
+ expect(mainControls.conference.isEnabled).toBe(false);
401
+ expect(mainControls.wrapup.isVisible).toBe(false);
402
+ expect(mainControls.wrapup.isEnabled).toBe(false);
403
+ });
404
+
405
+ it('calls updateUiControls when updateTaskData is invoked', () => {
406
+ const spy = jest.spyOn(task as any, 'updateUiControls');
407
+ task.updateTaskData({foo: 'new'} as TaskData);
408
+ expect(spy).toHaveBeenCalled();
409
+ });
410
+
411
+ it('logs state transitions using locally tracked previous state', () => {
412
+ const logSpy = jest.spyOn(LoggerProxy, 'log');
413
+ const statefulData = createTaskData();
414
+ const transitionTask = new DummyTask(dummyContact, statefulData);
415
+
416
+ logSpy.mockClear();
417
+
418
+ transitionTask.stateMachineService?.send({
419
+ type: TaskEvent.TASK_INCOMING,
420
+ taskData: statefulData,
421
+ });
422
+ transitionTask.stateMachineService?.send({type: TaskEvent.ASSIGN, taskData: statefulData});
423
+
424
+ const transitionMessages = logSpy.mock.calls
425
+ .filter(
426
+ ([msg]) => typeof msg === 'string' && (msg as string).startsWith('State machine transition')
427
+ )
428
+ .map(([msg]) => msg);
429
+
430
+ expect(transitionMessages).toEqual([
431
+ 'State machine transition: IDLE -> OFFERED',
432
+ 'State machine transition: OFFERED -> CONNECTED',
433
+ ]);
434
+
435
+ transitionTask.stateMachineService?.stop();
436
+ });
437
+
438
+ it('emits task:wrapup when wrap-up is required', () => {
439
+ const overrides = (task as any).getStateMachineActionOverrides();
440
+ const emitSpy = jest.spyOn(task, 'emit');
441
+
442
+ task.updateTaskData(createTaskData({wrapUpRequired: true}) as TaskData);
443
+ overrides.emitTaskWrapup({event: {type: TaskEvent.TASK_WRAPUP}});
444
+
445
+ expect(emitSpy).toHaveBeenCalledWith(TASK_EVENTS.TASK_WRAPUP, task);
446
+ });
447
+
448
+ it('does not emit task:wrapup when wrap-up is not required', () => {
449
+ const overrides = (task as any).getStateMachineActionOverrides();
450
+ const emitSpy = jest.spyOn(task, 'emit');
451
+
452
+ task.updateTaskData(createTaskData({wrapUpRequired: false}) as TaskData);
453
+ overrides.emitTaskWrapup({event: {type: TaskEvent.TASK_WRAPUP}});
454
+
455
+ expect(emitSpy).not.toHaveBeenCalledWith(TASK_EVENTS.TASK_WRAPUP, task);
456
+ });
457
+
458
+ it('adapts incoming, consulting, and rejection state-machine actions to task events', () => {
459
+ const actions = (task as any).getStateMachineActionOverrides();
460
+ const incomingData = createTaskData({interactionId: 'action-incoming'});
461
+ const emitSpy = jest.spyOn(task, 'emit');
462
+
463
+ actions.emitTaskIncoming({event: {type: 'TASK_INCOMING', taskData: incomingData}});
464
+ actions.emitTaskConsulting({
465
+ event: {
466
+ type: 'CONSULTING_ACTIVE',
467
+ taskData: {...incomingData, isConsulted: false},
468
+ },
469
+ });
470
+ actions.emitTaskConsulting({
471
+ event: {
472
+ type: 'CONSULTING_ACTIVE',
473
+ taskData: {...incomingData, isConsulted: true},
474
+ },
475
+ });
476
+ actions.emitTaskReject({event: {type: 'ASSIGN_FAILED', reason: 'busy'}});
477
+ actions.emitTaskReject({event: undefined});
478
+
479
+ expect(task.data.interactionId).toBe('action-incoming');
480
+ expect(emitSpy).toHaveBeenCalledWith(TASK_EVENTS.TASK_CONSULTING, task);
481
+ expect(emitSpy).toHaveBeenCalledWith(TASK_EVENTS.TASK_CONSULT_ACCEPTED, task);
482
+ expect(emitSpy).toHaveBeenCalledWith(TASK_EVENTS.TASK_REJECT, 'busy');
483
+ });
484
+
485
+ it('starts auto wrap-up only when wrapUpRequired is true and profile autoWrapup is enabled', () => {
486
+ const wrapUpProps = {
487
+ autoWrapup: true,
488
+ autoWrapupInterval: 25000,
489
+ wrapUpReasonList: [{id: 'code-1', name: 'Default', isDefault: true, isSystem: false}],
490
+ };
491
+ const enabled = new DummyTask(
492
+ dummyContact,
493
+ createTaskData({wrapUpRequired: false}) as TaskData,
494
+ {wrapUpProps},
495
+ 'agent-1'
496
+ );
497
+
498
+ expect(enabled.autoWrapup).toBeUndefined();
499
+ try {
500
+ enabled.updateTaskData(createTaskData({wrapUpRequired: true}) as TaskData);
501
+ expect(enabled.autoWrapup).toBeDefined();
502
+
503
+ const disabled = new DummyTask(
504
+ dummyContact,
505
+ createTaskData({wrapUpRequired: false}) as TaskData,
506
+ {wrapUpProps: {...wrapUpProps, autoWrapup: false}},
507
+ 'agent-1'
508
+ );
509
+ disabled.updateTaskData(createTaskData({wrapUpRequired: true}) as TaskData);
510
+ expect(disabled.autoWrapup).toBeUndefined();
511
+ } finally {
512
+ enabled.cancelAutoWrapupTimer();
513
+ }
514
+ });
515
+
516
+ it('calls contact.wrapup when the auto wrap-up timer elapses after wrapUpRequired is stamped', async () => {
517
+ const wrapUpProps = {
518
+ autoWrapup: true,
519
+ autoWrapupInterval: 1000,
520
+ wrapUpReasonList: [{id: 'code-1', name: 'Default', isDefault: true, isSystem: false}],
521
+ };
522
+ const contact = {wrapup: jest.fn().mockResolvedValue({result: 'wrap'})};
523
+ const wrappingTask = new DummyTask(
524
+ contact,
525
+ createTaskData({wrapUpRequired: false}) as TaskData,
526
+ {wrapUpProps},
527
+ 'agent-1'
528
+ );
529
+
530
+ try {
531
+ wrappingTask.updateTaskData(createTaskData({wrapUpRequired: true}) as TaskData);
532
+ expect(wrappingTask.autoWrapup).toBeDefined();
533
+
534
+ const onComplete = (wrappingTask.autoWrapup as any).start.mock.calls[0][0];
535
+ await onComplete();
536
+
537
+ expect(contact.wrapup).toHaveBeenCalledWith({
538
+ interactionId: 'interaction-1',
539
+ data: {wrapUpReason: 'Default', auxCodeId: 'code-1'},
540
+ });
541
+ } finally {
542
+ wrappingTask.cancelAutoWrapupTimer();
543
+ }
544
+ });
545
+
546
+ it('throws for unsupported voice operations in the base class', async () => {
547
+ const fullData = createTaskData();
548
+ const voiceTask = new DummyTask(dummyContact, fullData);
549
+
550
+ const cases: Array<() => Promise<unknown>> = [
551
+ () => voiceTask.decline(),
552
+ () => voiceTask.pauseRecording(),
553
+ () => voiceTask.resumeRecording({} as any),
554
+ () => voiceTask.consult({} as any),
555
+ () => voiceTask.endConsult({} as any),
556
+ () => voiceTask.consultTransfer({} as any),
557
+ () => voiceTask.consultConference(),
558
+ () => voiceTask.dropConferenceParticipant({participantId: 'participant-id'}),
559
+ () => voiceTask.exitConference(),
560
+ () => voiceTask.transferConference(),
561
+ () => voiceTask.toggleMute(),
562
+ () => voiceTask.hold(),
563
+ () => voiceTask.resume(),
564
+ () => voiceTask.holdResume(),
565
+ ];
566
+
567
+ for (const fn of cases) {
568
+ await expect(fn()).rejects.toThrow('Unsupported operation');
569
+ }
570
+
571
+ expect(() => voiceTask.unregisterWebCallListeners()).not.toThrow();
572
+ });
573
+
574
+ it('syncs task.data from CONTACT_UPDATED', () => {
575
+ const fullData = createTaskData({foo: 'old'} as any);
576
+ const voiceTask = new DummyTask(dummyContact, fullData);
577
+
578
+ voiceTask.sendStateMachineEvent({
579
+ type: TaskEvent.CONTACT_UPDATED,
580
+ taskData: {...fullData, foo: 'new'} as any,
581
+ });
582
+
583
+ expect((voiceTask.data as any).foo).toBe('new');
584
+ });
585
+
586
+ it('syncs owner changes and emits task:hydrate without changing task state', () => {
587
+ const originalData = createTaskData({
588
+ interaction: {owner: 'agent-1'} as any,
589
+ });
590
+ const voiceTask = new DummyTask(dummyContact, originalData);
591
+ const hydrateHandler = jest.fn();
592
+
593
+ voiceTask.stateMachineService?.send({
594
+ type: TaskEvent.TASK_INCOMING,
595
+ taskData: originalData,
596
+ });
597
+ voiceTask.stateMachineService?.send({type: TaskEvent.ASSIGN, taskData: originalData});
598
+ const stateBeforeOwnerChange = voiceTask.stateMachineService?.getSnapshot().value;
599
+ voiceTask.on(TASK_EVENTS.TASK_HYDRATE, hydrateHandler);
600
+
601
+ const updatedData = createTaskData({
602
+ interaction: {owner: 'agent-2'} as any,
603
+ });
604
+ voiceTask.sendStateMachineEvent({
605
+ type: TaskEvent.CONTACT_OWNER_CHANGED,
606
+ taskData: updatedData,
607
+ });
608
+
609
+ expect(voiceTask.data.interaction.owner).toBe('agent-2');
610
+ expect(hydrateHandler).toHaveBeenCalledWith(voiceTask);
611
+ expect(voiceTask.stateMachineService?.getSnapshot().value).toBe(stateBeforeOwnerChange);
612
+ });
613
+
614
+ it('synchronizes the roster from PARTICIPANT_LEAVE without an unrelated state transition', () => {
615
+ const conferenceData = createTaskData({
616
+ agentId: 'agent-1',
617
+ interactionId: 'interaction-1',
618
+ interaction: {
619
+ state: 'conference',
620
+ owner: 'agent-1',
621
+ interactionId: 'interaction-1',
622
+ mainInteractionId: 'interaction-1',
623
+ participants: {
624
+ 'agent-1': {id: 'agent-1', pType: 'Agent', hasLeft: false},
625
+ 'agent-2': {id: 'agent-2', pType: 'Agent', hasLeft: false},
626
+ 'agent-3': {id: 'agent-3', pType: 'Agent', hasLeft: false},
627
+ customer: {id: 'customer', pType: 'Customer', hasLeft: false},
628
+ },
629
+ media: {
630
+ 'interaction-1': {
631
+ mediaResourceId: 'interaction-1',
632
+ mType: 'mainCall',
633
+ participants: ['agent-1', 'agent-2', 'agent-3', 'customer'],
634
+ isHold: false,
635
+ },
636
+ },
637
+ } as any,
638
+ });
639
+ const voiceTask = new ConferenceTask(dummyContact, conferenceData);
640
+ const participantLeftHandler = jest.fn();
641
+
642
+ voiceTask.sendStateMachineEvent({type: TaskEvent.TASK_INCOMING, taskData: conferenceData});
643
+ voiceTask.sendStateMachineEvent({type: TaskEvent.ASSIGN, taskData: conferenceData});
644
+ voiceTask.sendStateMachineEvent({type: TaskEvent.CONFERENCE_START, taskData: conferenceData});
645
+ const conferenceState = voiceTask.stateMachineService?.getSnapshot().value;
646
+ voiceTask.on(TASK_EVENTS.TASK_PARTICIPANT_LEFT, participantLeftHandler);
647
+
648
+ const updatedData = createTaskData({
649
+ ...conferenceData,
650
+ participantId: 'agent-2',
651
+ interaction: {
652
+ ...conferenceData.interaction,
653
+ participants: {
654
+ 'agent-1': {id: 'agent-1', pType: 'Agent', hasLeft: false},
655
+ 'agent-3': {id: 'agent-3', pType: 'Agent', hasLeft: false},
656
+ customer: {id: 'customer', pType: 'Customer', hasLeft: false},
657
+ },
658
+ media: {
659
+ 'interaction-1': {
660
+ ...conferenceData.interaction.media['interaction-1'],
661
+ participants: ['agent-1', 'agent-3', 'customer'],
662
+ },
663
+ },
664
+ } as any,
665
+ });
666
+
667
+ voiceTask.sendStateMachineEvent({
668
+ type: TaskEvent.PARTICIPANT_LEAVE,
669
+ taskData: updatedData,
670
+ participantId: 'agent-2',
671
+ });
672
+
673
+ expect(voiceTask.data.interaction.participants['agent-2']).toBeUndefined();
674
+ expect(participantLeftHandler).toHaveBeenCalledWith(voiceTask);
675
+ expect(voiceTask.stateMachineService?.getSnapshot().value).toBe(conferenceState);
676
+ });
677
+
678
+ it('keeps the conference participant controls after the customer leaves', () => {
679
+ const conferenceData = createTaskData({
680
+ agentId: 'agent-1',
681
+ interactionId: 'interaction-1',
682
+ isConferenceInProgress: true,
683
+ isConferencing: true,
684
+ interaction: {
685
+ state: 'conference',
686
+ mediaType: 'telephony',
687
+ owner: 'agent-1',
688
+ interactionId: 'interaction-1',
689
+ mainInteractionId: 'interaction-1',
690
+ callProcessingDetails: {isConferencing: true},
691
+ participants: {
692
+ 'agent-1': {id: 'agent-1', pType: 'Agent', hasLeft: false},
693
+ 'agent-2': {id: 'agent-2', pType: 'Agent', hasLeft: false},
694
+ customer: {id: 'customer', pType: 'Customer', hasLeft: false},
695
+ },
696
+ media: {
697
+ 'interaction-1': {
698
+ mediaResourceId: 'interaction-1',
699
+ mType: 'mainCall',
700
+ participants: ['agent-1', 'agent-2', 'customer'],
701
+ isHold: false,
702
+ },
703
+ },
704
+ } as any,
705
+ });
706
+ const voiceTask = new ConferenceTask(dummyContact, conferenceData);
707
+ const participantLeftHandler = jest.fn();
708
+ const conferenceEndedHandler = jest.fn();
709
+
710
+ voiceTask.sendStateMachineEvent({type: TaskEvent.TASK_INCOMING, taskData: conferenceData});
711
+ voiceTask.sendStateMachineEvent({type: TaskEvent.ASSIGN, taskData: conferenceData});
712
+ voiceTask.sendStateMachineEvent({type: TaskEvent.CONFERENCE_START, taskData: conferenceData});
713
+ voiceTask.on(TASK_EVENTS.TASK_PARTICIPANT_LEFT, participantLeftHandler);
714
+ voiceTask.on(TASK_EVENTS.TASK_CONFERENCE_ENDED, conferenceEndedHandler);
715
+
716
+ const customerLeftData = createTaskData({
717
+ ...conferenceData,
718
+ participantId: 'customer',
719
+ interaction: {
720
+ ...conferenceData.interaction,
721
+ participants: {
722
+ 'agent-1': {id: 'agent-1', pType: 'Agent', hasLeft: false},
723
+ 'agent-2': {id: 'agent-2', pType: 'Agent', hasLeft: false},
724
+ },
725
+ media: {
726
+ 'interaction-1': {
727
+ ...conferenceData.interaction.media['interaction-1'],
728
+ participants: ['agent-1', 'agent-2'],
729
+ },
730
+ },
731
+ } as any,
732
+ });
733
+
734
+ voiceTask.sendStateMachineEvent({
735
+ type: TaskEvent.PARTICIPANT_LEAVE,
736
+ taskData: customerLeftData,
737
+ participantId: 'customer',
738
+ });
739
+
740
+ expect(voiceTask.data.interaction.participants.customer).toBeUndefined();
741
+ expect(voiceTask.data.interaction.participants['agent-2']).toBeDefined();
742
+ expect(voiceTask.stateMachineService?.getSnapshot().value).toBe('CONFERENCING');
743
+ expect(voiceTask.uiControls.main.exitConference).toEqual({
744
+ isVisible: true,
745
+ isEnabled: true,
746
+ });
747
+ expect(participantLeftHandler).toHaveBeenCalledWith(voiceTask);
748
+ expect(conferenceEndedHandler).not.toHaveBeenCalled();
749
+ });
750
+
751
+ it('stopStateMachine clears state snapshot access', () => {
752
+ const fullData = createTaskData();
753
+ const voiceTask = new DummyTask(dummyContact, fullData);
754
+
755
+ expect((voiceTask as any).getCurrentState()).toBeDefined();
756
+ (voiceTask as any).stopStateMachine();
757
+ expect((voiceTask as any).getCurrentState()).toBeUndefined();
758
+ });
759
+
760
+ it('auto-answers on offer when supported and flagged', async () => {
761
+ const data = createTaskData({isAutoAnswering: true});
762
+ const webrtcTask = new SpyAcceptTask(dummyContact, data, {voiceVariant: VOICE_VARIANT.WEBRTC});
763
+ const autoAnsweredSpy = jest.fn();
764
+ webrtcTask.on(TASK_EVENTS.TASK_AUTO_ANSWERED, autoAnsweredSpy);
765
+
766
+ webrtcTask.sendStateMachineEvent({type: TaskEvent.TASK_INCOMING, taskData: data});
767
+ webrtcTask.sendStateMachineEvent({
768
+ type: TaskEvent.TASK_OFFERED,
769
+ taskData: {...data, isAutoAnswering: true} as any,
770
+ });
771
+
772
+ await flushEventLoopTurn();
773
+ expect(webrtcTask.acceptMock).toHaveBeenCalled();
774
+ expect(autoAnsweredSpy).toHaveBeenCalledWith(webrtcTask);
775
+ });
776
+
777
+ it('does not auto-answer when isAutoAnswering is false', async () => {
778
+ const data = createTaskData({isAutoAnswering: false});
779
+ const webrtcTask = new SpyAcceptTask(dummyContact, data, {voiceVariant: VOICE_VARIANT.WEBRTC});
780
+
781
+ webrtcTask.sendStateMachineEvent({type: TaskEvent.TASK_INCOMING, taskData: data});
782
+ webrtcTask.sendStateMachineEvent({type: TaskEvent.TASK_OFFERED, taskData: data});
783
+
784
+ await flushEventLoopTurn();
785
+ expect(webrtcTask.acceptMock).not.toHaveBeenCalled();
786
+ });
787
+
788
+ it('does not auto-answer for voice tasks when variant is not WebRTC', async () => {
789
+ const data = createTaskData({isAutoAnswering: true});
790
+ const pstnTask = new SpyAcceptTask(dummyContact, data, {voiceVariant: VOICE_VARIANT.PSTN});
791
+
792
+ pstnTask.sendStateMachineEvent({type: TaskEvent.TASK_INCOMING, taskData: data});
793
+ pstnTask.sendStateMachineEvent({type: TaskEvent.TASK_OFFERED, taskData: data});
794
+
795
+ await flushEventLoopTurn();
796
+ expect(pstnTask.acceptMock).not.toHaveBeenCalled();
797
+ });
798
+
799
+ it('clears isAutoAnswering when auto-answer fails', async () => {
800
+ const data = createTaskData({isAutoAnswering: true});
801
+ const webrtcTask = new SpyAcceptTask(dummyContact, data, {voiceVariant: VOICE_VARIANT.WEBRTC});
802
+ webrtcTask.acceptMock.mockRejectedValue(new Error('fail'));
803
+
804
+ webrtcTask.sendStateMachineEvent({type: TaskEvent.TASK_INCOMING, taskData: data});
805
+ webrtcTask.sendStateMachineEvent({type: TaskEvent.TASK_OFFERED, taskData: data});
806
+
807
+ await flushEventLoopTurn();
808
+ expect(webrtcTask.data.isAutoAnswering).toBe(false);
809
+ });
810
+
811
+ it('emits task:cleanup (non-removal) on CONTACT_ENDED when wrap-up is required', () => {
812
+ const cleanupSpy = jest.fn();
813
+ const base = createTaskData({
814
+ wrapUpRequired: true,
815
+ interaction: {state: 'connected'},
816
+ } as any);
817
+ const webrtcTask = new SpyAcceptTask(dummyContact, base, {voiceVariant: VOICE_VARIANT.WEBRTC});
818
+ webrtcTask.on(TASK_EVENTS.TASK_CLEANUP, cleanupSpy);
819
+
820
+ webrtcTask.sendStateMachineEvent({type: TaskEvent.TASK_INCOMING, taskData: base});
821
+ webrtcTask.sendStateMachineEvent({type: TaskEvent.TASK_OFFERED, taskData: base});
822
+ webrtcTask.sendStateMachineEvent({type: TaskEvent.ASSIGN, taskData: base});
823
+ webrtcTask.sendStateMachineEvent({type: TaskEvent.CONTACT_ENDED, taskData: base});
824
+
825
+ expect(cleanupSpy).toHaveBeenCalledWith(webrtcTask, {removeFromCollection: false});
826
+ });
827
+
828
+ it('emits task:cleanup (removal) when entering a final state', () => {
829
+ const cleanupSpy = jest.fn();
830
+ const base = createTaskData({wrapUpRequired: false});
831
+ const webrtcTask = new SpyAcceptTask(dummyContact, base, {voiceVariant: VOICE_VARIANT.WEBRTC});
832
+ webrtcTask.on(TASK_EVENTS.TASK_CLEANUP, cleanupSpy);
833
+
834
+ webrtcTask.sendStateMachineEvent({type: TaskEvent.TASK_INCOMING, taskData: base});
835
+ webrtcTask.sendStateMachineEvent({type: TaskEvent.RONA, taskData: base, reason: 'RONA'} as any);
836
+
837
+ expect(cleanupSpy).toHaveBeenCalledWith(webrtcTask, {removeFromCollection: true});
838
+ });
839
+ });
840
+
841
+ describe('Task common methods', () => {
842
+ let contact: any;
843
+ let task: DummyTask;
844
+ const taskData = {interactionId: '123', foo: 'bar', nested: {a: 1, b: 2}} as TaskData;
845
+
846
+ beforeEach(() => {
847
+ contact = {
848
+ vteamTransfer: jest.fn().mockResolvedValue({result: 'vt'}),
849
+ blindTransfer: jest.fn().mockResolvedValue({result: 'bt'}),
850
+ end: jest.fn().mockResolvedValue({result: 'end'}),
851
+ wrapup: jest.fn().mockResolvedValue({result: 'wrap'}),
852
+ };
853
+ task = new DummyTask(contact, taskData);
854
+ });
855
+
856
+ it('transfer uses blindTransfer for agent destinations', async () => {
857
+ const payload = {to: 'dest', destinationType: DESTINATION_TYPE.AGENT} as any;
858
+ const result = await task.transfer(payload);
859
+ expect(contact.blindTransfer).toHaveBeenCalledWith({
860
+ interactionId: taskData.interactionId,
861
+ data: payload,
862
+ });
863
+ expect(result).toEqual({result: 'bt'});
864
+ });
865
+
866
+ it('transfer uses vteamTransfer for queue destinations', async () => {
867
+ const payload = {to: 'queue1', destinationType: DESTINATION_TYPE.QUEUE} as any;
868
+ const result = await task.transfer(payload);
869
+ expect(contact.vteamTransfer).toHaveBeenCalledWith({
870
+ interactionId: taskData.interactionId,
871
+ data: payload,
872
+ });
873
+ expect(result).toEqual({result: 'vt'});
874
+ });
875
+
876
+ it('transfer uses vteamTransfer with the backend destination type for entry-point destinations', async () => {
877
+ const payload: TransferPayLoad = {
878
+ to: 'entry-point-1',
879
+ destinationType: DESTINATION_TYPE.ENTRYPOINT,
880
+ };
881
+ const result = await task.transfer(payload);
882
+
883
+ expect(contact.vteamTransfer).toHaveBeenCalledWith({
884
+ interactionId: taskData.interactionId,
885
+ data: {
886
+ to: payload.to,
887
+ destinationType: ENTRY_POINT_TRANSFER_DESTINATION_TYPE,
888
+ },
889
+ });
890
+ expect(contact.blindTransfer).not.toHaveBeenCalled();
891
+ expect(result).toEqual({result: 'vt'});
892
+ });
893
+
894
+ it('end invokes contact.end and returns its response', async () => {
895
+ const result = await task.end();
896
+ expect(contact.end).toHaveBeenCalledWith({
897
+ interactionId: taskData.interactionId,
898
+ });
899
+ expect(result).toEqual({result: 'end'});
900
+ });
901
+
902
+ it('wrapup invokes contact.wrapup with proper args', async () => {
903
+ const payload = {auxCodeId: 'code1', wrapUpReason: 'reason1'} as any;
904
+ const result = await task.wrapup(payload);
905
+ expect(contact.wrapup).toHaveBeenCalledWith({
906
+ interactionId: taskData.interactionId,
907
+ data: payload,
908
+ });
909
+ expect(result).toEqual({result: 'wrap'});
910
+ });
911
+ });
912
+
913
+ describe('Task failure scenarios', () => {
914
+ let contact: any;
915
+ let task: DummyTask;
916
+ const taskData = {interactionId: '123', foo: 'bar', nested: {a: 1, b: 2}} as TaskData;
917
+
918
+ beforeEach(() => {
919
+ contact = {
920
+ vteamTransfer: jest.fn(),
921
+ blindTransfer: jest.fn(),
922
+ end: jest.fn(),
923
+ wrapup: jest.fn(),
924
+ };
925
+ task = new DummyTask(contact, taskData);
926
+ });
927
+
928
+ it('transfer rejects when blindTransfer throws', async () => {
929
+ const payload = {to: 'dest', destinationType: DESTINATION_TYPE.AGENT} as any;
930
+ const err = new Error('Error while performing transfer');
931
+ contact.blindTransfer.mockRejectedValue(err);
932
+
933
+ await expect(task.transfer(payload)).rejects.toThrow('Error while performing transfer');
934
+ });
935
+
936
+ it('transfer rejects when vteamTransfer throws', async () => {
937
+ const payload = {to: 'queue1', destinationType: DESTINATION_TYPE.QUEUE} as any;
938
+ const err = new Error('Error while performing transfer');
939
+ contact.vteamTransfer.mockRejectedValue(err);
940
+
941
+ await expect(task.transfer(payload)).rejects.toThrow('Error while performing transfer');
942
+ });
943
+
944
+ it('end rejects when contact.end throws', async () => {
945
+ const err = new Error('Error while performing end');
946
+ contact.end.mockRejectedValue(err);
947
+
948
+ await expect(task.end()).rejects.toThrow('Error while performing end');
949
+ });
950
+
951
+ it('wrapup throws when auxCodeId is missing', async () => {
952
+ await expect(task.wrapup({auxCodeId: '', wrapUpReason: 'reason1'} as any)).rejects.toThrow(
953
+ 'Error while performing wrapup'
954
+ );
955
+ });
956
+
957
+ it('wrapup throws when wrapUpReason is missing', async () => {
958
+ await expect(task.wrapup({auxCodeId: 'code1', wrapUpReason: ''} as any)).rejects.toThrow(
959
+ 'Error while performing wrapup'
960
+ );
961
+ });
962
+
963
+ it('wrapup rejects when contact.wrapup throws', async () => {
964
+ const payload = {auxCodeId: 'code1', wrapUpReason: 'reason1'} as any;
965
+ const err = new Error('Error while performing wrapup');
966
+ contact.wrapup.mockRejectedValue(err);
967
+
968
+ await expect(task.wrapup(payload)).rejects.toThrow('Error while performing wrapup');
969
+ });
970
+ });
971
+
972
+ const createAISummaryTaskData = (overrides: Partial<TaskData> = {}): TaskData => {
973
+ const {interaction: interactionOverrides, ...taskOverrides} = overrides;
974
+ const interactionId = taskOverrides.interactionId ?? 'interaction-1';
975
+
976
+ return createTaskData({
977
+ taskId: 'task-owner-1',
978
+ ...taskOverrides,
979
+ interactionId,
980
+ interaction: {
981
+ interactionId,
982
+ mainInteractionId: 'conversation-1',
983
+ ...(interactionOverrides as Record<string, unknown> | undefined),
984
+ },
985
+ });
986
+ };
987
+
988
+ const createPostCallSummaryPayload = () => ({
989
+ conversationId: 'conversation-1',
990
+ summaryText: 'generated post-call summary',
991
+ sections: {initialContactReason: 'billing'},
992
+ });
993
+
994
+ const createMidCallSummaryPayload = () => ({
995
+ conversationId: 'conversation-1',
996
+ summaryText: 'generated mid-call summary',
997
+ sections: {reasonForTransferOrConsult: 'specialist'},
998
+ });
999
+
1000
+ const createPostCallResponsePayload = (
1001
+ overrides: Partial<AISummaryResponse> = {}
1002
+ ): AISummaryResponse =>
1003
+ ({
1004
+ summary: {summarySectionKeySentinel: 'summary-section-value-sentinel'} as any,
1005
+ feedback: 'thumbs_up',
1006
+ state: 'DEFAULT',
1007
+ wrapUpCode: 'resolved',
1008
+ numberOfTimesViewed: 1,
1009
+ numberOfTimesEdited: 0,
1010
+ numberOfTimesCopied: 0,
1011
+ ...overrides,
1012
+ } as AISummaryResponse);
1013
+
1014
+ const createMidCallResponsePayload = (
1015
+ overrides: Partial<AISummaryResponse> = {}
1016
+ ): AISummaryResponse =>
1017
+ ({
1018
+ summaryReceived: true,
1019
+ summary: {midCallSectionKeySentinel: 'mid-call-section-value-sentinel'} as any,
1020
+ feedback: 'none',
1021
+ state: 'DEFAULT',
1022
+ numberOfTimesViewed: 1,
1023
+ numberOfTimesEdited: 0,
1024
+ numberOfTimesCopied: 0,
1025
+ ...overrides,
1026
+ } as AISummaryResponse);
1027
+
1028
+ const summaryRequestCases: Array<{
1029
+ label: string;
1030
+ invoke: (task: DummyTask) => Promise<any>;
1031
+ eventName: AIAssistantEventName;
1032
+ timeoutCode: string;
1033
+ createPayload: () => any;
1034
+ }> = [
1035
+ {
1036
+ label: 'post-call',
1037
+ invoke: (task) => task.requestPostCallSummary(),
1038
+ eventName: AIAssistantEventName.GET_POST_CALL_SUMMARY,
1039
+ timeoutCode: AI_SUMMARY_ERROR_CODES.POST_CALL_SUMMARY_TIMEOUT,
1040
+ createPayload: createPostCallSummaryPayload,
1041
+ },
1042
+ {
1043
+ label: 'consult mid-call',
1044
+ invoke: (task) => task.requestMidCallSummary('CONSULT'),
1045
+ eventName: AIAssistantEventName.GET_MID_CALL_CONSULT_SUMMARY,
1046
+ timeoutCode: AI_SUMMARY_ERROR_CODES.MID_CALL_SUMMARY_TIMEOUT,
1047
+ createPayload: createMidCallSummaryPayload,
1048
+ },
1049
+ {
1050
+ label: 'transfer mid-call',
1051
+ invoke: (task) => task.requestMidCallSummary('TRANSFER'),
1052
+ eventName: AIAssistantEventName.GET_MID_CALL_TRANSFER_SUMMARY,
1053
+ timeoutCode: AI_SUMMARY_ERROR_CODES.MID_CALL_SUMMARY_TIMEOUT,
1054
+ createPayload: createMidCallSummaryPayload,
1055
+ },
1056
+ ];
1057
+
1058
+ describe('Task AI summary APIs', () => {
1059
+ const dummyContact = {} as any;
1060
+ const injectSummaryDependencies = (
1061
+ task: DummyTask,
1062
+ apiAIAssistant: any,
1063
+ flags: {wrapUpSummariesEnabled?: boolean; consultTransferSummariesEnabled?: boolean}
1064
+ ) => {
1065
+ apiAIAssistant.aiFeature = {id: 'summary-flags', generatedSummaries: flags};
1066
+ (task as any).apiAIAssistant = apiAIAssistant;
1067
+ };
1068
+
1069
+ const createSummaryMocks = (
1070
+ task: DummyTask,
1071
+ options: {
1072
+ postCallEnabled?: boolean;
1073
+ midCallEnabled?: boolean;
1074
+ wrapUpSummariesEnabled?: boolean;
1075
+ consultTransferSummariesEnabled?: boolean;
1076
+ registrationResult?: Promise<any>;
1077
+ } = {}
1078
+ ) => {
1079
+ const pendingRequests = new Map<string, any>();
1080
+ const adapter: any = {
1081
+ sendEvent: jest.fn().mockResolvedValue(undefined),
1082
+ requestAndWaitForRtd: jest.fn(async (requestOptions: any) => {
1083
+ const key = JSON.stringify([requestOptions.rtdEventType, requestOptions.correlationId]);
1084
+ let resolveResult: (value: any) => void = () => undefined;
1085
+ let rejectResult: (error: Error) => void = () => undefined;
1086
+ const result = new Promise((resolve, reject) => {
1087
+ resolveResult = resolve;
1088
+ rejectResult = reject;
1089
+ });
1090
+ const pending = {
1091
+ result,
1092
+ timeoutId: undefined as any,
1093
+ resolve: resolveResult,
1094
+ reject: rejectResult,
1095
+ };
1096
+ pendingRequests.set(key, pending);
1097
+ const publishTimestamp = Date.now();
1098
+ const acknowledgement = adapter.sendEvent(
1099
+ requestOptions.agentId,
1100
+ requestOptions.interactionId,
1101
+ requestOptions.eventType,
1102
+ requestOptions.eventName,
1103
+ requestOptions.eventMetaData,
1104
+ undefined,
1105
+ undefined,
1106
+ publishTimestamp,
1107
+ requestOptions.timeout
1108
+ );
1109
+ const value = options.registrationResult ?? Promise.resolve(createPostCallSummaryPayload());
1110
+ const timeout = new Promise((_resolve, reject) => {
1111
+ pending.timeoutId = setTimeout(
1112
+ () => reject(requestOptions.createTimeoutError()),
1113
+ requestOptions.timeoutMs
1114
+ );
1115
+ });
1116
+ try {
1117
+ const [resolvedValue] = await Promise.all([
1118
+ Promise.race([value, timeout]),
1119
+ acknowledgement,
1120
+ ]);
1121
+ clearTimeout(pending.timeoutId);
1122
+ pendingRequests.delete(key);
1123
+ return resolvedValue;
1124
+ } catch (error) {
1125
+ clearTimeout(pending.timeoutId);
1126
+ pendingRequests.delete(key);
1127
+ throw error;
1128
+ }
1129
+ }),
1130
+ resolveFromRtdEvent: jest.fn((rtdEventType: string, correlationId: string, payload: any) => {
1131
+ const key = JSON.stringify([rtdEventType, correlationId]);
1132
+ const pending = pendingRequests.get(key);
1133
+ if (!pending) return 'not-found';
1134
+ clearTimeout(pending.timeoutId);
1135
+ pendingRequests.delete(key);
1136
+ pending.resolve(payload);
1137
+ return 'resolved';
1138
+ }),
1139
+ clearAllRtdRequests: jest.fn(() => {
1140
+ pendingRequests.forEach((pending) => {
1141
+ clearTimeout(pending.timeoutId);
1142
+ pending.reject(new Error(AI_SUMMARY_REQUEST_CANCELLED));
1143
+ });
1144
+ pendingRequests.clear();
1145
+ }),
1146
+ clear: jest.fn(),
1147
+ pendingRequests,
1148
+ };
1149
+ const coordinator = adapter;
1150
+ const postCallEnabled = Object.prototype.hasOwnProperty.call(options, 'postCallEnabled')
1151
+ ? options.postCallEnabled
1152
+ : true;
1153
+ const midCallEnabled = Object.prototype.hasOwnProperty.call(options, 'midCallEnabled')
1154
+ ? options.midCallEnabled
1155
+ : true;
1156
+ const wrapUpSummariesEnabled = Object.prototype.hasOwnProperty.call(
1157
+ options,
1158
+ 'wrapUpSummariesEnabled'
1159
+ )
1160
+ ? options.wrapUpSummariesEnabled
1161
+ : true;
1162
+ const consultTransferSummariesEnabled = Object.prototype.hasOwnProperty.call(
1163
+ options,
1164
+ 'consultTransferSummariesEnabled'
1165
+ )
1166
+ ? options.consultTransferSummariesEnabled
1167
+ : true;
1168
+
1169
+ const generatedSummaryFlags = {
1170
+ wrapUpSummariesEnabled,
1171
+ consultTransferSummariesEnabled,
1172
+ };
1173
+
1174
+ task.setFeatureEnablement({
1175
+ interactionId: task.data.interactionId,
1176
+ postCallEnabled,
1177
+ midCallEnabled,
1178
+ });
1179
+ injectSummaryDependencies(task, adapter, generatedSummaryFlags);
1180
+
1181
+ return {adapter, coordinator};
1182
+ };
1183
+
1184
+ const createRealSummaryMocks = (task: DummyTask) => {
1185
+ const result = createDeferred<any>();
1186
+ const adapter = {
1187
+ sendEvent: jest.fn().mockResolvedValue(undefined),
1188
+ requestAndWaitForRtd: jest.fn(async (requestOptions: any) => {
1189
+ const publishTimestamp = Date.now();
1190
+ let timeoutId: ReturnType<typeof setTimeout>;
1191
+ const timeout = new Promise((_resolve, reject) => {
1192
+ timeoutId = setTimeout(
1193
+ () => reject(requestOptions.createTimeoutError()),
1194
+ requestOptions.timeoutMs
1195
+ );
1196
+ });
1197
+ await adapter.sendEvent(
1198
+ requestOptions.agentId,
1199
+ requestOptions.interactionId,
1200
+ requestOptions.eventType,
1201
+ requestOptions.eventName,
1202
+ requestOptions.eventMetaData,
1203
+ undefined,
1204
+ undefined,
1205
+ publishTimestamp,
1206
+ requestOptions.timeout
1207
+ );
1208
+ try {
1209
+ return await Promise.race([result.promise, timeout]);
1210
+ } finally {
1211
+ clearTimeout(timeoutId);
1212
+ }
1213
+ }),
1214
+ resolveFromRtdEvent: jest.fn((_eventType: string, _correlationId: string, payload: any) => {
1215
+ result.resolve(payload);
1216
+ return 'resolved';
1217
+ }),
1218
+ clearAll: jest.fn(() => result.reject(new Error(AI_SUMMARY_REQUEST_CANCELLED))),
1219
+ clear: jest.fn(),
1220
+ } as any;
1221
+ const generatedSummaryFlags = {
1222
+ wrapUpSummariesEnabled: true,
1223
+ consultTransferSummariesEnabled: true,
1224
+ };
1225
+
1226
+ task.setFeatureEnablement({
1227
+ interactionId: task.data.interactionId,
1228
+ postCallEnabled: true,
1229
+ midCallEnabled: true,
1230
+ });
1231
+ injectSummaryDependencies(task, adapter, generatedSummaryFlags);
1232
+
1233
+ return {adapter, coordinator: adapter, result};
1234
+ };
1235
+
1236
+ const expectSummaryGetEvent = (
1237
+ adapter: {sendEvent: jest.Mock},
1238
+ eventName: string,
1239
+ callIndex?: number
1240
+ ) => {
1241
+ const expectedArgs = [
1242
+ 'agent-1',
1243
+ 'interaction-1',
1244
+ 'CTI_EVENT',
1245
+ eventName,
1246
+ expect.objectContaining({
1247
+ conversationId: 'conversation-1',
1248
+ clientType: 'WxCC',
1249
+ }),
1250
+ undefined,
1251
+ undefined,
1252
+ expect.any(Number),
1253
+ AI_SUMMARY_DURATION_MS,
1254
+ ];
1255
+
1256
+ if (callIndex === undefined) {
1257
+ expect(adapter.sendEvent).toHaveBeenCalledWith(...expectedArgs);
1258
+
1259
+ return;
1260
+ }
1261
+
1262
+ expect(adapter.sendEvent).toHaveBeenNthCalledWith(callIndex, ...expectedArgs);
1263
+ };
1264
+
1265
+ const getSummaryEventPayload = (adapter: {sendEvent: jest.Mock}, callIndex = 0) => {
1266
+ const [agentId, interactionId, , eventName, eventData, , , publishTimestamp] =
1267
+ adapter.sendEvent.mock.calls[callIndex];
1268
+ const {clientType: _clientType, action: _action, ...summaryData} = eventData;
1269
+
1270
+ return {
1271
+ agentId,
1272
+ interactionId,
1273
+ eventName,
1274
+ ...summaryData,
1275
+ ...(publishTimestamp !== undefined ? {publishTimestamp} : {}),
1276
+ };
1277
+ };
1278
+
1279
+ const spyOnAISummaryMetrics = (task: DummyTask) => {
1280
+ const metricsManager = (task as any).metricsManager;
1281
+
1282
+ return {
1283
+ trackEvent: jest.spyOn(metricsManager, 'trackEvent').mockImplementation(() => undefined),
1284
+ timeEvent: jest.spyOn(metricsManager, 'timeEvent').mockImplementation(() => undefined),
1285
+ };
1286
+ };
1287
+
1288
+ const getPendingRequest = (
1289
+ coordinator: any,
1290
+ eventType: 'POST_CALL_SUMMARY' | 'MID_CALL_SUMMARY',
1291
+ conversationId: string
1292
+ ) =>
1293
+ (coordinator as any).pendingRequests.get(JSON.stringify([eventType, conversationId]));
1294
+
1295
+ afterEach(() => {
1296
+ jest.useRealTimers();
1297
+ jest.restoreAllMocks();
1298
+ });
1299
+
1300
+ it('defines the exact AI summary operation metric names', () => {
1301
+ expect(METRIC_EVENT_NAMES).toMatchObject({
1302
+ AI_SUMMARY_GET_POST_CALL_SUCCESS: 'Post Call Summary Get Success',
1303
+ AI_SUMMARY_GET_POST_CALL_FAILED: 'Post Call Summary Get Failed',
1304
+ AI_SUMMARY_GET_MID_CALL_SUCCESS: 'Mid Call Summary Get Success',
1305
+ AI_SUMMARY_GET_MID_CALL_FAILED: 'Mid Call Summary Get Failed',
1306
+ AI_SUMMARY_POST_CALL_RESPONSE_SUCCESS: 'Post Call Summary Response Success',
1307
+ AI_SUMMARY_POST_CALL_RESPONSE_FAILED: 'Post Call Summary Response Failed',
1308
+ AI_SUMMARY_MID_CALL_RESPONSE_SUCCESS: 'Mid Call Summary Response Success',
1309
+ AI_SUMMARY_MID_CALL_RESPONSE_FAILED: 'Mid Call Summary Response Failed',
1310
+ });
1311
+ });
1312
+
1313
+ it('requests a post-call summary through accepted registration, HTTP acknowledgement, and matching RTD result', async () => {
1314
+ const task = new DummyTask(dummyContact, createAISummaryTaskData());
1315
+ const metrics = spyOnAISummaryMetrics(task);
1316
+ const postCallResult = createPostCallSummaryPayload();
1317
+ const {adapter, coordinator} = createSummaryMocks(task, {
1318
+ registrationResult: Promise.resolve(postCallResult),
1319
+ });
1320
+
1321
+ await expect(task.requestPostCallSummary()).resolves.toBe(postCallResult);
1322
+
1323
+ expect(coordinator.requestAndWaitForRtd).toHaveBeenCalledWith(
1324
+ expect.objectContaining({
1325
+ correlationId: 'conversation-1',
1326
+ rtdEventType: 'POST_CALL_SUMMARY',
1327
+ timeoutMs: AI_SUMMARY_DURATION_MS,
1328
+ })
1329
+ );
1330
+ expectSummaryGetEvent(adapter, AIAssistantEventName.GET_POST_CALL_SUMMARY);
1331
+ expect(metrics.trackEvent).toHaveBeenCalledWith(
1332
+ METRIC_EVENT_NAMES.AI_SUMMARY_GET_POST_CALL_SUCCESS,
1333
+ expect.objectContaining({
1334
+ conversationId: 'conversation-1',
1335
+ interactionId: 'interaction-1',
1336
+ }),
1337
+ ['operational']
1338
+ );
1339
+ expect(metrics.timeEvent).toHaveBeenCalledWith([
1340
+ METRIC_EVENT_NAMES.AI_SUMMARY_GET_POST_CALL_SUCCESS,
1341
+ METRIC_EVENT_NAMES.AI_SUMMARY_GET_POST_CALL_FAILED,
1342
+ ]);
1343
+ });
1344
+
1345
+ it('maps consult and transfer mid-call summary requests to their exact outbound event names', async () => {
1346
+ const consultTask = new DummyTask(dummyContact, createAISummaryTaskData());
1347
+ const consultMetrics = spyOnAISummaryMetrics(consultTask);
1348
+ const consultResult = createMidCallSummaryPayload();
1349
+ const consultMocks = createSummaryMocks(consultTask, {
1350
+ registrationResult: Promise.resolve(consultResult),
1351
+ });
1352
+
1353
+ await expect(consultTask.requestMidCallSummary('CONSULT')).resolves.toBe(consultResult);
1354
+
1355
+ expect(consultMocks.coordinator.requestAndWaitForRtd).toHaveBeenCalledWith(
1356
+ expect.objectContaining({
1357
+ correlationId: 'conversation-1',
1358
+ rtdEventType: 'MID_CALL_SUMMARY',
1359
+ timeoutMs: AI_SUMMARY_DURATION_MS,
1360
+ })
1361
+ );
1362
+ expectSummaryGetEvent(
1363
+ consultMocks.adapter,
1364
+ AIAssistantEventName.GET_MID_CALL_CONSULT_SUMMARY
1365
+ );
1366
+ expect(consultMetrics.trackEvent).toHaveBeenCalledWith(
1367
+ METRIC_EVENT_NAMES.AI_SUMMARY_GET_MID_CALL_SUCCESS,
1368
+ expect.objectContaining({
1369
+ operation: METHODS.REQUEST_MID_CALL_SUMMARY,
1370
+ actionType: 'CONSULT',
1371
+ conversationId: 'conversation-1',
1372
+ interactionId: 'interaction-1',
1373
+ }),
1374
+ ['operational']
1375
+ );
1376
+
1377
+ const transferTask = new DummyTask(dummyContact, createAISummaryTaskData());
1378
+ const transferResult = createMidCallSummaryPayload();
1379
+ const transferMocks = createSummaryMocks(transferTask, {
1380
+ registrationResult: Promise.resolve(transferResult),
1381
+ });
1382
+
1383
+ await expect(transferTask.requestMidCallSummary('TRANSFER')).resolves.toBe(transferResult);
1384
+ expectSummaryGetEvent(
1385
+ transferMocks.adapter,
1386
+ AIAssistantEventName.GET_MID_CALL_TRANSFER_SUMMARY
1387
+ );
1388
+ });
1389
+
1390
+ it.each([
1391
+ {
1392
+ label: 'post-call registration first',
1393
+ invoke: (task: DummyTask) => task.requestPostCallSummary(),
1394
+ inboundType: 'POST_CALL_SUMMARY' as const,
1395
+ timeoutCode: AI_SUMMARY_ERROR_CODES.POST_CALL_SUMMARY_TIMEOUT,
1396
+ eventName: AIAssistantEventName.GET_POST_CALL_SUMMARY,
1397
+ payload: createPostCallSummaryPayload(),
1398
+ successMetric: METRIC_EVENT_NAMES.AI_SUMMARY_GET_POST_CALL_SUCCESS,
1399
+ failureMetric: METRIC_EVENT_NAMES.AI_SUMMARY_GET_POST_CALL_FAILED,
1400
+ operation: METHODS.REQUEST_POST_CALL_SUMMARY,
1401
+ settleFirst: 'result' as const,
1402
+ },
1403
+ {
1404
+ label: 'mid-call acknowledgement first',
1405
+ invoke: (task: DummyTask) => task.requestMidCallSummary('CONSULT'),
1406
+ inboundType: 'MID_CALL_SUMMARY' as const,
1407
+ timeoutCode: AI_SUMMARY_ERROR_CODES.MID_CALL_SUMMARY_TIMEOUT,
1408
+ eventName: AIAssistantEventName.GET_MID_CALL_CONSULT_SUMMARY,
1409
+ payload: createMidCallSummaryPayload(),
1410
+ successMetric: METRIC_EVENT_NAMES.AI_SUMMARY_GET_MID_CALL_SUCCESS,
1411
+ failureMetric: METRIC_EVENT_NAMES.AI_SUMMARY_GET_MID_CALL_FAILED,
1412
+ operation: METHODS.REQUEST_MID_CALL_SUMMARY,
1413
+ actionType: 'CONSULT',
1414
+ settleFirst: 'acknowledgement' as const,
1415
+ },
1416
+ ])(
1417
+ 'sends the HTTP request and waits for both request branches on $label',
1418
+ async ({
1419
+ invoke,
1420
+ inboundType,
1421
+ timeoutCode,
1422
+ eventName,
1423
+ payload,
1424
+ successMetric,
1425
+ failureMetric,
1426
+ operation,
1427
+ actionType,
1428
+ settleFirst,
1429
+ }) => {
1430
+ const task = new DummyTask(dummyContact, createAISummaryTaskData());
1431
+ const metrics = spyOnAISummaryMetrics(task);
1432
+ const acknowledgement = createDeferred<void>();
1433
+ const result = createDeferred<any>();
1434
+ const {adapter, coordinator} = createSummaryMocks(task, {
1435
+ registrationResult: result.promise,
1436
+ });
1437
+ let publicSettled = false;
1438
+
1439
+ adapter.sendEvent.mockReturnValueOnce(acknowledgement.promise);
1440
+
1441
+ const publicRequest = invoke(task);
1442
+
1443
+ publicRequest.then(
1444
+ () => {
1445
+ publicSettled = true;
1446
+ },
1447
+ () => {
1448
+ publicSettled = true;
1449
+ }
1450
+ );
1451
+ await Promise.resolve();
1452
+
1453
+ expect(coordinator.requestAndWaitForRtd).toHaveBeenCalledWith(
1454
+ expect.objectContaining({
1455
+ correlationId: 'conversation-1',
1456
+ rtdEventType: inboundType,
1457
+ timeoutMs: AI_SUMMARY_DURATION_MS,
1458
+ })
1459
+ );
1460
+ expectSummaryGetEvent(adapter, eventName);
1461
+ expect(publicSettled).toBe(false);
1462
+
1463
+ if (settleFirst === 'result') {
1464
+ result.resolve(payload);
1465
+ await Promise.resolve();
1466
+ expect(publicSettled).toBe(false);
1467
+ expect(metrics.trackEvent).not.toHaveBeenCalled();
1468
+
1469
+ acknowledgement.resolve(undefined);
1470
+ } else {
1471
+ acknowledgement.resolve(undefined);
1472
+ await Promise.resolve();
1473
+ expect(publicSettled).toBe(false);
1474
+ expect(metrics.trackEvent).not.toHaveBeenCalled();
1475
+
1476
+ result.resolve(payload);
1477
+ }
1478
+
1479
+ await expect(publicRequest).resolves.toBe(payload);
1480
+ expect(metrics.trackEvent).toHaveBeenCalledTimes(1);
1481
+ expect(metrics.trackEvent).toHaveBeenCalledWith(
1482
+ successMetric,
1483
+ expect.objectContaining({
1484
+ operation,
1485
+ ...(actionType ? {actionType} : {}),
1486
+ conversationId: 'conversation-1',
1487
+ interactionId: 'interaction-1',
1488
+ }),
1489
+ ['operational']
1490
+ );
1491
+ expect(metrics.timeEvent).toHaveBeenCalledWith([successMetric, failureMetric]);
1492
+ }
1493
+ );
1494
+
1495
+ it.each([
1496
+ {
1497
+ label: 'post-call organization false',
1498
+ invoke: (task: DummyTask) => task.requestPostCallSummary(),
1499
+ options: {wrapUpSummariesEnabled: false, postCallEnabled: true},
1500
+ disabledCode: AI_SUMMARY_ERROR_CODES.POST_CALL_SUMMARY_DISABLED,
1501
+ failureMetric: METRIC_EVENT_NAMES.AI_SUMMARY_GET_POST_CALL_FAILED,
1502
+ operation: METHODS.REQUEST_POST_CALL_SUMMARY,
1503
+ },
1504
+ {
1505
+ label: 'post-call organization undefined',
1506
+ invoke: (task: DummyTask) => task.requestPostCallSummary(),
1507
+ options: {wrapUpSummariesEnabled: undefined, postCallEnabled: true},
1508
+ disabledCode: AI_SUMMARY_ERROR_CODES.POST_CALL_SUMMARY_DISABLED,
1509
+ failureMetric: METRIC_EVENT_NAMES.AI_SUMMARY_GET_POST_CALL_FAILED,
1510
+ operation: METHODS.REQUEST_POST_CALL_SUMMARY,
1511
+ },
1512
+ {
1513
+ label: 'post-call interaction false',
1514
+ invoke: (task: DummyTask) => task.requestPostCallSummary(),
1515
+ options: {wrapUpSummariesEnabled: true, postCallEnabled: false},
1516
+ disabledCode: AI_SUMMARY_ERROR_CODES.POST_CALL_SUMMARY_DISABLED,
1517
+ failureMetric: METRIC_EVENT_NAMES.AI_SUMMARY_GET_POST_CALL_FAILED,
1518
+ operation: METHODS.REQUEST_POST_CALL_SUMMARY,
1519
+ },
1520
+ {
1521
+ label: 'post-call interaction undefined',
1522
+ invoke: (task: DummyTask) => task.requestPostCallSummary(),
1523
+ options: {wrapUpSummariesEnabled: true, postCallEnabled: undefined},
1524
+ disabledCode: AI_SUMMARY_ERROR_CODES.POST_CALL_SUMMARY_DISABLED,
1525
+ failureMetric: METRIC_EVENT_NAMES.AI_SUMMARY_GET_POST_CALL_FAILED,
1526
+ operation: METHODS.REQUEST_POST_CALL_SUMMARY,
1527
+ },
1528
+ {
1529
+ label: 'mid-call organization false',
1530
+ invoke: (task: DummyTask) => task.requestMidCallSummary('CONSULT'),
1531
+ options: {consultTransferSummariesEnabled: false, midCallEnabled: true},
1532
+ disabledCode: AI_SUMMARY_ERROR_CODES.MID_CALL_SUMMARY_DISABLED,
1533
+ failureMetric: METRIC_EVENT_NAMES.AI_SUMMARY_GET_MID_CALL_FAILED,
1534
+ operation: METHODS.REQUEST_MID_CALL_SUMMARY,
1535
+ actionType: 'CONSULT',
1536
+ },
1537
+ {
1538
+ label: 'mid-call organization undefined',
1539
+ invoke: (task: DummyTask) => task.requestMidCallSummary('CONSULT'),
1540
+ options: {consultTransferSummariesEnabled: undefined, midCallEnabled: true},
1541
+ disabledCode: AI_SUMMARY_ERROR_CODES.MID_CALL_SUMMARY_DISABLED,
1542
+ failureMetric: METRIC_EVENT_NAMES.AI_SUMMARY_GET_MID_CALL_FAILED,
1543
+ operation: METHODS.REQUEST_MID_CALL_SUMMARY,
1544
+ actionType: 'CONSULT',
1545
+ },
1546
+ {
1547
+ label: 'mid-call interaction false',
1548
+ invoke: (task: DummyTask) => task.requestMidCallSummary('CONSULT'),
1549
+ options: {consultTransferSummariesEnabled: true, midCallEnabled: false},
1550
+ disabledCode: AI_SUMMARY_ERROR_CODES.MID_CALL_SUMMARY_DISABLED,
1551
+ failureMetric: METRIC_EVENT_NAMES.AI_SUMMARY_GET_MID_CALL_FAILED,
1552
+ operation: METHODS.REQUEST_MID_CALL_SUMMARY,
1553
+ actionType: 'CONSULT',
1554
+ },
1555
+ {
1556
+ label: 'mid-call interaction undefined',
1557
+ invoke: (task: DummyTask) => task.requestMidCallSummary('CONSULT'),
1558
+ options: {consultTransferSummariesEnabled: true, midCallEnabled: undefined},
1559
+ disabledCode: AI_SUMMARY_ERROR_CODES.MID_CALL_SUMMARY_DISABLED,
1560
+ failureMetric: METRIC_EVENT_NAMES.AI_SUMMARY_GET_MID_CALL_FAILED,
1561
+ operation: METHODS.REQUEST_MID_CALL_SUMMARY,
1562
+ actionType: 'CONSULT',
1563
+ },
1564
+ ])(
1565
+ 'rejects disabled $label summary requests before registration or HTTP',
1566
+ async ({invoke, options, disabledCode, failureMetric, operation, actionType}) => {
1567
+ const task = new DummyTask(dummyContact, createAISummaryTaskData());
1568
+ const metrics = spyOnAISummaryMetrics(task);
1569
+ const {adapter, coordinator} = createSummaryMocks(task, options);
1570
+
1571
+ await expect(invoke(task)).rejects.toMatchObject(
1572
+ createAISummaryErrorExpectation(disabledCode)
1573
+ );
1574
+ expect(coordinator.requestAndWaitForRtd).not.toHaveBeenCalled();
1575
+ expect(adapter.sendEvent).not.toHaveBeenCalled();
1576
+ expect(metrics.trackEvent).toHaveBeenCalledTimes(1);
1577
+ expect(metrics.trackEvent).toHaveBeenCalledWith(
1578
+ failureMetric,
1579
+ expect.objectContaining({
1580
+ operation,
1581
+ ...(actionType ? {actionType} : {}),
1582
+ failureCode: disabledCode,
1583
+ }),
1584
+ ['operational']
1585
+ );
1586
+ expect(metrics.timeEvent).toHaveBeenCalled();
1587
+ }
1588
+ );
1589
+
1590
+ it.each([
1591
+ {
1592
+ label: 'post-call',
1593
+ invoke: (task: DummyTask) => task.requestPostCallSummary(),
1594
+ disabledCode: AI_SUMMARY_ERROR_CODES.POST_CALL_SUMMARY_DISABLED,
1595
+ featureEnablement: {postCallEnabled: false, midCallEnabled: true},
1596
+ },
1597
+ {
1598
+ label: 'mid-call',
1599
+ invoke: (task: DummyTask) => task.requestMidCallSummary('CONSULT'),
1600
+ disabledCode: AI_SUMMARY_ERROR_CODES.MID_CALL_SUMMARY_DISABLED,
1601
+ featureEnablement: {postCallEnabled: true, midCallEnabled: false},
1602
+ },
1603
+ ])(
1604
+ 'uses the top-level interaction feature key only for $label requests',
1605
+ async ({invoke, disabledCode, featureEnablement}) => {
1606
+ const task = new DummyTask(
1607
+ dummyContact,
1608
+ createAISummaryTaskData({
1609
+ interactionId: 'child-interaction-1',
1610
+ interaction: {mainInteractionId: 'conversation-1'} as any,
1611
+ })
1612
+ );
1613
+ const {adapter, coordinator} = createSummaryMocks(task, featureEnablement);
1614
+
1615
+ await expect(invoke(task)).rejects.toMatchObject(
1616
+ createAISummaryErrorExpectation(disabledCode)
1617
+ );
1618
+ expect(coordinator.requestAndWaitForRtd).not.toHaveBeenCalled();
1619
+ expect(adapter.sendEvent).not.toHaveBeenCalled();
1620
+ }
1621
+ );
1622
+
1623
+ it.each([
1624
+ {
1625
+ label: 'post-call organization false',
1626
+ invoke: (task: DummyTask) => task.requestPostCallSummary(),
1627
+ inboundType: 'POST_CALL_SUMMARY' as const,
1628
+ disabledCode: AI_SUMMARY_ERROR_CODES.POST_CALL_SUMMARY_DISABLED,
1629
+ flags: {wrapUpSummariesEnabled: false, consultTransferSummariesEnabled: true},
1630
+ featureEnablement: {interactionId: 'interaction-1', postCallEnabled: true},
1631
+ },
1632
+ {
1633
+ label: 'post-call raw undefined interaction flag',
1634
+ invoke: (task: DummyTask) => task.requestPostCallSummary(),
1635
+ inboundType: 'POST_CALL_SUMMARY' as const,
1636
+ disabledCode: AI_SUMMARY_ERROR_CODES.POST_CALL_SUMMARY_DISABLED,
1637
+ flags: {wrapUpSummariesEnabled: true, consultTransferSummariesEnabled: true},
1638
+ },
1639
+ {
1640
+ label: 'post-call mainInteractionId mismatch',
1641
+ invoke: (task: DummyTask) => task.requestPostCallSummary(),
1642
+ inboundType: 'POST_CALL_SUMMARY' as const,
1643
+ disabledCode: AI_SUMMARY_ERROR_CODES.POST_CALL_SUMMARY_DISABLED,
1644
+ flags: {wrapUpSummariesEnabled: true, consultTransferSummariesEnabled: true},
1645
+ taskData: createAISummaryTaskData({
1646
+ interactionId: 'child-interaction-1',
1647
+ interaction: {mainInteractionId: 'conversation-1'} as any,
1648
+ }),
1649
+ featureEnablement: {interactionId: 'conversation-1', postCallEnabled: true},
1650
+ },
1651
+ {
1652
+ label: 'mid-call organization false',
1653
+ invoke: (task: DummyTask) => task.requestMidCallSummary('CONSULT'),
1654
+ inboundType: 'MID_CALL_SUMMARY' as const,
1655
+ disabledCode: AI_SUMMARY_ERROR_CODES.MID_CALL_SUMMARY_DISABLED,
1656
+ flags: {wrapUpSummariesEnabled: true, consultTransferSummariesEnabled: false},
1657
+ featureEnablement: {interactionId: 'interaction-1', midCallEnabled: true},
1658
+ },
1659
+ {
1660
+ label: 'mid-call raw undefined interaction flag',
1661
+ invoke: (task: DummyTask) => task.requestMidCallSummary('CONSULT'),
1662
+ inboundType: 'MID_CALL_SUMMARY' as const,
1663
+ disabledCode: AI_SUMMARY_ERROR_CODES.MID_CALL_SUMMARY_DISABLED,
1664
+ flags: {wrapUpSummariesEnabled: true, consultTransferSummariesEnabled: true},
1665
+ },
1666
+ {
1667
+ label: 'mid-call mainInteractionId mismatch',
1668
+ invoke: (task: DummyTask) => task.requestMidCallSummary('CONSULT'),
1669
+ inboundType: 'MID_CALL_SUMMARY' as const,
1670
+ disabledCode: AI_SUMMARY_ERROR_CODES.MID_CALL_SUMMARY_DISABLED,
1671
+ flags: {wrapUpSummariesEnabled: true, consultTransferSummariesEnabled: true},
1672
+ taskData: createAISummaryTaskData({
1673
+ interactionId: 'child-interaction-1',
1674
+ interaction: {mainInteractionId: 'conversation-1'} as any,
1675
+ }),
1676
+ featureEnablement: {interactionId: 'conversation-1', midCallEnabled: true},
1677
+ },
1678
+ ])(
1679
+ 'leaves the real coordinator idle when $label disables a request',
1680
+ async ({invoke, inboundType, disabledCode, flags, taskData, featureEnablement}) => {
1681
+ jest.useFakeTimers();
1682
+ const task = new DummyTask(dummyContact, taskData ?? createAISummaryTaskData());
1683
+ const adapter: any = {
1684
+ sendEvent: jest.fn().mockResolvedValue(undefined),
1685
+ requestAndWaitForRtd: jest.fn(),
1686
+ pendingRequests: new Map(),
1687
+ };
1688
+ const coordinator = adapter;
1689
+ const registerSpy = jest.spyOn(coordinator, 'requestAndWaitForRtd');
1690
+ injectSummaryDependencies(task, adapter, flags);
1691
+ task.setFeatureEnablement({
1692
+ interactionId: task.data.interactionId,
1693
+ ...(featureEnablement ?? {}),
1694
+ });
1695
+
1696
+ await expect(invoke(task)).rejects.toMatchObject(
1697
+ createAISummaryErrorExpectation(disabledCode)
1698
+ );
1699
+ expect(registerSpy).not.toHaveBeenCalled();
1700
+ expect(adapter.sendEvent).not.toHaveBeenCalled();
1701
+ expect(getPendingRequest(coordinator, inboundType, 'conversation-1')).toBeUndefined();
1702
+ expect(jest.getTimerCount()).toBe(0);
1703
+ }
1704
+ );
1705
+
1706
+ it('uses the agent configuration’s current generated-summary flags for each request', async () => {
1707
+ const firstFlags = {
1708
+ wrapUpSummariesEnabled: false,
1709
+ consultTransferSummariesEnabled: true,
1710
+ };
1711
+ const secondFlags = {
1712
+ wrapUpSummariesEnabled: true,
1713
+ consultTransferSummariesEnabled: true,
1714
+ };
1715
+ const adapter: any = {
1716
+ sendEvent: jest.fn().mockResolvedValue(undefined),
1717
+ pendingRequests: new Map(),
1718
+ };
1719
+ adapter.aiFeature = {id: 'summary-flags', generatedSummaries: firstFlags};
1720
+ const featureEnablement = {interactionId: 'interaction-1', postCallEnabled: true};
1721
+ const coordinator = adapter;
1722
+ let resolveResult: (payload: any) => void = () => undefined;
1723
+ adapter.requestAndWaitForRtd = jest.fn(async (requestOptions: any) => {
1724
+ const result = new Promise((resolve) => {
1725
+ resolveResult = resolve;
1726
+ });
1727
+ await adapter.sendEvent(
1728
+ requestOptions.agentId,
1729
+ requestOptions.interactionId,
1730
+ requestOptions.eventType,
1731
+ requestOptions.eventName,
1732
+ requestOptions.eventMetaData,
1733
+ undefined,
1734
+ undefined,
1735
+ Date.now(),
1736
+ requestOptions.timeout
1737
+ );
1738
+ return result;
1739
+ });
1740
+ coordinator.resolveFromRtdEvent = jest.fn(() => {
1741
+ resolveResult(createPostCallSummaryPayload());
1742
+ return 'resolved';
1743
+ });
1744
+ const task = new DummyTask(
1745
+ dummyContact,
1746
+ createAISummaryTaskData(),
1747
+ undefined,
1748
+ 'agent-1',
1749
+ 'Receiving Agent',
1750
+ adapter
1751
+ );
1752
+ task.setFeatureEnablement({interactionId: task.data.interactionId, ...featureEnablement});
1753
+
1754
+ await expect(task.requestPostCallSummary()).rejects.toMatchObject(
1755
+ createAISummaryErrorExpectation(AI_SUMMARY_ERROR_CODES.POST_CALL_SUMMARY_DISABLED)
1756
+ );
1757
+ expect(adapter.sendEvent).not.toHaveBeenCalled();
1758
+
1759
+ adapter.aiFeature = {id: 'summary-flags', generatedSummaries: secondFlags};
1760
+ const request = task.requestPostCallSummary();
1761
+
1762
+ await Promise.resolve();
1763
+ expect(adapter.sendEvent).toHaveBeenCalledTimes(1);
1764
+ expect(
1765
+ coordinator.resolveFromRtdEvent(
1766
+ 'POST_CALL_SUMMARY',
1767
+ 'conversation-1',
1768
+ createPostCallSummaryPayload()
1769
+ )
1770
+ ).toBe('resolved');
1771
+ await expect(request).resolves.toEqual(createPostCallSummaryPayload());
1772
+ });
1773
+
1774
+ it('cleans up only the accepted request token when the request acknowledgement rejects', async () => {
1775
+ const task = new DummyTask(dummyContact, createAISummaryTaskData());
1776
+ const neverSettlingResult = createDeferred<any>();
1777
+ const {adapter, coordinator} = createSummaryMocks(task, {
1778
+ registrationResult: neverSettlingResult.promise,
1779
+ });
1780
+ const baseUrlError = createAISummaryError(
1781
+ AI_SUMMARY_ERROR_CODES.AI_ASSISTANT_BASE_URL_NOT_AVAILABLE
1782
+ );
1783
+
1784
+ adapter.sendEvent.mockRejectedValue(baseUrlError);
1785
+
1786
+ await expect(task.requestPostCallSummary()).rejects.toBe(baseUrlError);
1787
+ expect(coordinator.requestAndWaitForRtd).toHaveBeenCalled();
1788
+ });
1789
+
1790
+ it.each(summaryRequestCases.map((testCase) => [testCase.label, testCase] as const))(
1791
+ '%s request sends one outbound adapter invocation when it later fails from inbound timeout',
1792
+ async (_label, {invoke, eventName, timeoutCode}) => {
1793
+ const task = new DummyTask(dummyContact, createAISummaryTaskData());
1794
+ const timeoutResult = createDeferred<any>();
1795
+ const {adapter} = createSummaryMocks(task, {registrationResult: timeoutResult.promise});
1796
+
1797
+ const timedOutRequest = invoke(task);
1798
+ await flushEventLoopTurn();
1799
+
1800
+ expect(adapter.sendEvent).toHaveBeenCalledTimes(1);
1801
+ expectSummaryGetEvent(adapter, eventName);
1802
+
1803
+ timeoutResult.reject(createAISummaryError(timeoutCode));
1804
+
1805
+ await expect(timedOutRequest).rejects.toMatchObject({
1806
+ message: timeoutCode,
1807
+ data: {errorCode: timeoutCode},
1808
+ });
1809
+ }
1810
+ );
1811
+
1812
+ it.each(summaryRequestCases.map((testCase) => [testCase.label, testCase] as const))(
1813
+ 'issues a fresh second adapter call when %s request is invoked again after settlement',
1814
+ async (_label, {invoke, eventName, createPayload}) => {
1815
+ const task = new DummyTask(dummyContact, createAISummaryTaskData());
1816
+ const firstPayload = createPayload();
1817
+ const secondPayload = createPayload();
1818
+ const {adapter, coordinator} = createSummaryMocks(task);
1819
+
1820
+ expect(firstPayload).not.toBe(secondPayload);
1821
+ coordinator.requestAndWaitForRtd
1822
+ .mockImplementationOnce(async (requestOptions: any) => {
1823
+ const publishTimestamp = Date.now();
1824
+ await adapter.sendEvent(
1825
+ requestOptions.agentId,
1826
+ requestOptions.interactionId,
1827
+ requestOptions.eventType,
1828
+ requestOptions.eventName,
1829
+ requestOptions.eventMetaData,
1830
+ undefined,
1831
+ undefined,
1832
+ publishTimestamp,
1833
+ requestOptions.timeout
1834
+ );
1835
+
1836
+ return firstPayload;
1837
+ })
1838
+ .mockImplementationOnce(async (requestOptions: any) => {
1839
+ const publishTimestamp = Date.now();
1840
+ await adapter.sendEvent(
1841
+ requestOptions.agentId,
1842
+ requestOptions.interactionId,
1843
+ requestOptions.eventType,
1844
+ requestOptions.eventName,
1845
+ requestOptions.eventMetaData,
1846
+ undefined,
1847
+ undefined,
1848
+ publishTimestamp,
1849
+ requestOptions.timeout
1850
+ );
1851
+
1852
+ return secondPayload;
1853
+ });
1854
+
1855
+ await expect(invoke(task)).resolves.toBe(firstPayload);
1856
+ await expect(invoke(task)).resolves.toBe(secondPayload);
1857
+
1858
+ expect(coordinator.requestAndWaitForRtd).toHaveBeenCalledTimes(2);
1859
+ expect(adapter.sendEvent).toHaveBeenCalledTimes(2);
1860
+ expectSummaryGetEvent(adapter, eventName, 1);
1861
+ expectSummaryGetEvent(adapter, eventName, 2);
1862
+ }
1863
+ );
1864
+
1865
+ it('settles public request promises when coordinator rejects owner cleanup results', async () => {
1866
+ const cleanupTask = new DummyTask(dummyContact, createAISummaryTaskData());
1867
+ const cleanupResult = createDeferred<any>();
1868
+
1869
+ createSummaryMocks(cleanupTask, {registrationResult: cleanupResult.promise});
1870
+
1871
+ const cancelledRequest = cleanupTask.requestMidCallSummary('CONSULT');
1872
+ await flushEventLoopTurn();
1873
+ cleanupResult.reject(createAISummaryError(AI_SUMMARY_REQUEST_CANCELLED));
1874
+
1875
+ await expect(cancelledRequest).rejects.toMatchObject({
1876
+ message: AI_SUMMARY_REQUEST_CANCELLED,
1877
+ data: {errorCode: AI_SUMMARY_REQUEST_CANCELLED},
1878
+ });
1879
+ });
1880
+
1881
+ it('serializes post-call responses after request cleanup', async () => {
1882
+ const task = new DummyTask(dummyContact, createAISummaryTaskData());
1883
+ const taskRegistry: Record<string, DummyTask> = {'interaction-1': task};
1884
+ const metrics = spyOnAISummaryMetrics(task);
1885
+ const postCallResult = createPostCallSummaryPayload();
1886
+ const {adapter, coordinator, result} = createRealSummaryMocks(task);
1887
+
1888
+ const postCallRequest = task.requestPostCallSummary();
1889
+ await flushEventLoopTurn();
1890
+
1891
+ expectSummaryGetEvent(adapter, AIAssistantEventName.GET_POST_CALL_SUMMARY);
1892
+ result.resolve(postCallResult);
1893
+ await expect(postCallRequest).resolves.toBe(postCallResult);
1894
+
1895
+ delete taskRegistry['interaction-1'];
1896
+ coordinator.clear('task-owner-1', 'conversation-1');
1897
+ const responsePayload = createPostCallResponsePayload({
1898
+ summary: {humanAuthoredSectionKeySentinel: 'human-authored-section-value-sentinel'} as any,
1899
+ });
1900
+
1901
+ await expect(task.sendPostCallSummaryResponse(responsePayload)).resolves.toBeUndefined();
1902
+
1903
+ expect(taskRegistry['interaction-1']).toBeUndefined();
1904
+ expect(adapter.sendEvent).toHaveBeenCalledTimes(2);
1905
+ expect(getSummaryEventPayload(adapter, 1)).toStrictEqual({
1906
+ agentId: 'agent-1',
1907
+ interactionId: 'interaction-1',
1908
+ conversationId: 'conversation-1',
1909
+ eventName: AIAssistantEventName.POST_CALL_SUMMARY_RESPONSE,
1910
+ summary: {humanAuthoredSectionKeySentinel: 'human-authored-section-value-sentinel'},
1911
+ feedback: 'thumbs_up',
1912
+ wrapUpCode: 'resolved',
1913
+ publishTimestamp: expect.any(Number),
1914
+ numberOfTimesViewed: 1,
1915
+ numberOfTimesEdited: 0,
1916
+ numberOfTimesCopied: 0,
1917
+ state: 'DEFAULT',
1918
+ });
1919
+ expect(metrics.trackEvent).toHaveBeenCalledWith(
1920
+ METRIC_EVENT_NAMES.AI_SUMMARY_POST_CALL_RESPONSE_SUCCESS,
1921
+ expect.objectContaining({
1922
+ operation: METHODS.SEND_POST_CALL_SUMMARY_RESPONSE,
1923
+ conversationId: 'conversation-1',
1924
+ interactionId: 'interaction-1',
1925
+ }),
1926
+ ['operational']
1927
+ );
1928
+ expect(JSON.stringify(metrics.trackEvent.mock.calls)).not.toContain(
1929
+ 'humanAuthoredSectionKeySentinel'
1930
+ );
1931
+ expect(JSON.stringify(metrics.trackEvent.mock.calls)).not.toContain(
1932
+ 'human-authored-section-value-sentinel'
1933
+ );
1934
+ expect(metrics.timeEvent).toHaveBeenCalledWith([
1935
+ METRIC_EVENT_NAMES.AI_SUMMARY_POST_CALL_RESPONSE_SUCCESS,
1936
+ METRIC_EVENT_NAMES.AI_SUMMARY_POST_CALL_RESPONSE_FAILED,
1937
+ ]);
1938
+ });
1939
+
1940
+ it('propagates a post-call response telemetry failure', async () => {
1941
+ const task = new DummyTask(dummyContact, createAISummaryTaskData());
1942
+ const metrics = spyOnAISummaryMetrics(task);
1943
+ const {adapter} = createSummaryMocks(task);
1944
+
1945
+ metrics.trackEvent.mockImplementationOnce(() => {
1946
+ throw new Error('metrics unavailable');
1947
+ });
1948
+
1949
+ await expect(task.sendPostCallSummaryResponse(createPostCallResponsePayload())).rejects.toThrow(
1950
+ 'metrics unavailable'
1951
+ );
1952
+ expect(adapter.sendEvent).toHaveBeenCalledTimes(1);
1953
+ });
1954
+
1955
+ it('uses task IDs for direct post-call responses', async () => {
1956
+ const task = new DummyTask(dummyContact, createAISummaryTaskData());
1957
+ const {adapter} = createSummaryMocks(task);
1958
+
1959
+ await expect(
1960
+ task.sendPostCallSummaryResponse(createPostCallResponsePayload())
1961
+ ).resolves.toBeUndefined();
1962
+
1963
+ expect(getSummaryEventPayload(adapter)).toMatchObject({
1964
+ interactionId: 'interaction-1',
1965
+ conversationId: 'conversation-1',
1966
+ eventName: AIAssistantEventName.POST_CALL_SUMMARY_RESPONSE,
1967
+ });
1968
+ });
1969
+
1970
+ it('serializes mid-call responses after a summary request', async () => {
1971
+ const task = new DummyTask(dummyContact, createAISummaryTaskData());
1972
+ const {adapter, coordinator} = createRealSummaryMocks(task);
1973
+ const midCallRequest = task.requestMidCallSummary('CONSULT');
1974
+
1975
+ await flushEventLoopTurn();
1976
+ expect(
1977
+ coordinator.resolveFromRtdEvent(
1978
+ 'MID_CALL_SUMMARY',
1979
+ 'conversation-1',
1980
+ createMidCallSummaryPayload()
1981
+ )
1982
+ ).toBe('resolved');
1983
+ await expect(midCallRequest).resolves.toEqual(createMidCallSummaryPayload());
1984
+
1985
+ await expect(
1986
+ task.sendMidCallSummaryResponse(createMidCallResponsePayload(), 'CONSULT')
1987
+ ).resolves.toBeUndefined();
1988
+
1989
+ expect(getSummaryEventPayload(adapter, 1)).toMatchObject({
1990
+ interactionId: 'interaction-1',
1991
+ conversationId: 'conversation-1',
1992
+ eventName: AIAssistantEventName.MID_CALL_CONSULT_SUMMARY_RESPONSE,
1993
+ });
1994
+ });
1995
+
1996
+ it('uses the same task IDs for mid-call and post-call responses', async () => {
1997
+ const task = new DummyTask(dummyContact, createAISummaryTaskData());
1998
+ const {adapter} = createSummaryMocks(task);
1999
+
2000
+ await expect(task.requestMidCallSummary('CONSULT')).resolves.toBeDefined();
2001
+
2002
+ await expect(task.requestPostCallSummary()).resolves.toBeDefined();
2003
+
2004
+ await expect(
2005
+ task.sendMidCallSummaryResponse(createMidCallResponsePayload(), 'CONSULT')
2006
+ ).resolves.toBeUndefined();
2007
+ await expect(
2008
+ task.sendPostCallSummaryResponse(createPostCallResponsePayload())
2009
+ ).resolves.toBeUndefined();
2010
+
2011
+ expect(getSummaryEventPayload(adapter, 2)).toMatchObject({
2012
+ interactionId: 'interaction-1',
2013
+ conversationId: 'conversation-1',
2014
+ eventName: AIAssistantEventName.MID_CALL_CONSULT_SUMMARY_RESPONSE,
2015
+ });
2016
+ expect(getSummaryEventPayload(adapter, 3)).toMatchObject({
2017
+ interactionId: 'interaction-1',
2018
+ conversationId: 'conversation-1',
2019
+ eventName: AIAssistantEventName.POST_CALL_SUMMARY_RESPONSE,
2020
+ });
2021
+ });
2022
+
2023
+ it('sends a post-call response after a summary request fails', async () => {
2024
+ const task = new DummyTask(dummyContact, createAISummaryTaskData());
2025
+ const {adapter} = createSummaryMocks(task);
2026
+ const requestError = createAISummaryError(
2027
+ AI_SUMMARY_ERROR_CODES.AI_ASSISTANT_BASE_URL_NOT_AVAILABLE
2028
+ );
2029
+
2030
+ adapter.sendEvent.mockRejectedValueOnce(requestError);
2031
+
2032
+ await expect(task.requestPostCallSummary()).rejects.toBe(requestError);
2033
+
2034
+ await expect(
2035
+ task.sendPostCallSummaryResponse(createPostCallResponsePayload())
2036
+ ).resolves.toBeUndefined();
2037
+ expect(getSummaryEventPayload(adapter, 1)).toMatchObject({
2038
+ interactionId: 'interaction-1',
2039
+ conversationId: 'conversation-1',
2040
+ });
2041
+ });
2042
+
2043
+ it.each([
2044
+ {
2045
+ label: 'post-call',
2046
+ invoke: (task: DummyTask) =>
2047
+ task.sendPostCallSummaryResponse(
2048
+ createPostCallResponsePayload({
2049
+ summary: 'Caller reported a billing discrepancy.',
2050
+ numberOfTimesViewed: 2,
2051
+ numberOfTimesEdited: 1,
2052
+ numberOfTimesCopied: 3,
2053
+ })
2054
+ ),
2055
+ expected: {
2056
+ agentId: 'agent-1',
2057
+ interactionId: 'interaction-1',
2058
+ conversationId: 'conversation-1',
2059
+ eventName: AIAssistantEventName.POST_CALL_SUMMARY_RESPONSE,
2060
+ summary: 'Caller reported a billing discrepancy.',
2061
+ feedback: 'thumbs_up',
2062
+ wrapUpCode: 'resolved',
2063
+ publishTimestamp: expect.any(Number),
2064
+ numberOfTimesViewed: 2,
2065
+ numberOfTimesEdited: 1,
2066
+ numberOfTimesCopied: 3,
2067
+ state: 'DEFAULT',
2068
+ },
2069
+ },
2070
+ {
2071
+ label: 'mid-call',
2072
+ invoke: (task: DummyTask) =>
2073
+ task.sendMidCallSummaryResponse(
2074
+ createMidCallResponsePayload({
2075
+ summary: 'Caller reported a billing discrepancy.',
2076
+ numberOfTimesViewed: 2,
2077
+ numberOfTimesEdited: 1,
2078
+ numberOfTimesCopied: 3,
2079
+ }),
2080
+ 'TRANSFER'
2081
+ ),
2082
+ expected: {
2083
+ agentId: 'agent-1',
2084
+ interactionId: 'interaction-1',
2085
+ conversationId: 'conversation-1',
2086
+ eventName: AIAssistantEventName.MID_CALL_TRANSFER_SUMMARY_RESPONSE,
2087
+ summary: 'Caller reported a billing discrepancy.',
2088
+ feedback: 'none',
2089
+ agentName: 'Receiving Agent',
2090
+ publishTimestamp: expect.any(Number),
2091
+ numberOfTimesViewed: 2,
2092
+ numberOfTimesEdited: 1,
2093
+ numberOfTimesCopied: 3,
2094
+ state: 'DEFAULT',
2095
+ },
2096
+ },
2097
+ ])('forwards $label plain-text response summaries unchanged', async ({invoke, expected}) => {
2098
+ const task = new DummyTask(dummyContact, createAISummaryTaskData());
2099
+ const {adapter} = createSummaryMocks(task);
2100
+
2101
+ await expect(invoke(task)).resolves.toBeUndefined();
2102
+
2103
+ expect(getSummaryEventPayload(adapter)).toStrictEqual(expected);
2104
+ });
2105
+
2106
+ it.each([
2107
+ {
2108
+ label: 'post-call',
2109
+ invoke: (task: DummyTask) =>
2110
+ task.sendPostCallSummaryResponse(createPostCallResponsePayload()),
2111
+ expected: {
2112
+ agentId: 'agent-1',
2113
+ interactionId: 'interaction-1',
2114
+ conversationId: 'conversation-1',
2115
+ eventName: AIAssistantEventName.POST_CALL_SUMMARY_RESPONSE,
2116
+ summary: {summarySectionKeySentinel: 'summary-section-value-sentinel'},
2117
+ feedback: 'thumbs_up',
2118
+ wrapUpCode: 'resolved',
2119
+ numberOfTimesViewed: 1,
2120
+ numberOfTimesEdited: 0,
2121
+ numberOfTimesCopied: 0,
2122
+ state: 'DEFAULT',
2123
+ },
2124
+ },
2125
+ {
2126
+ label: 'mid-call',
2127
+ invoke: (task: DummyTask) =>
2128
+ task.sendMidCallSummaryResponse(createMidCallResponsePayload(), 'CONSULT'),
2129
+ expected: {
2130
+ agentId: 'agent-1',
2131
+ interactionId: 'interaction-1',
2132
+ conversationId: 'conversation-1',
2133
+ eventName: AIAssistantEventName.MID_CALL_CONSULT_SUMMARY_RESPONSE,
2134
+ summary: {midCallSectionKeySentinel: 'mid-call-section-value-sentinel'},
2135
+ feedback: 'none',
2136
+ agentName: 'Receiving Agent',
2137
+ numberOfTimesViewed: 1,
2138
+ numberOfTimesEdited: 0,
2139
+ numberOfTimesCopied: 0,
2140
+ state: 'DEFAULT',
2141
+ },
2142
+ },
2143
+ ])('generates the $label transport timestamp inside the SDK', async ({invoke, expected}) => {
2144
+ const task = new DummyTask(dummyContact, createAISummaryTaskData());
2145
+ const {adapter} = createSummaryMocks(task);
2146
+
2147
+ await expect(invoke(task)).resolves.toBeUndefined();
2148
+
2149
+ const transportPayload = getSummaryEventPayload(adapter);
2150
+
2151
+ expect(getSummaryEventPayload(adapter)).toMatchObject({
2152
+ ...expected,
2153
+ publishTimestamp: expect.any(Number),
2154
+ });
2155
+ expect(Object.prototype.hasOwnProperty.call(transportPayload, 'actionTimeStamp')).toBe(false);
2156
+ expect(Object.prototype.hasOwnProperty.call(transportPayload, 'publishTimestamp')).toBe(true);
2157
+ });
2158
+
2159
+ it('sends the exact post-call NOT_RECEIVED response transport payload', async () => {
2160
+ const task = new DummyTask(dummyContact, createAISummaryTaskData());
2161
+ const {adapter} = createSummaryMocks(task);
2162
+
2163
+ await expect(
2164
+ task.sendPostCallSummaryResponse(
2165
+ createPostCallResponsePayload({
2166
+ summary: '',
2167
+ feedback: 'none',
2168
+ state: 'NOT_RECEIVED',
2169
+ wrapUpCode: 'resolved',
2170
+ numberOfTimesViewed: 0,
2171
+ numberOfTimesEdited: 0,
2172
+ numberOfTimesCopied: 0,
2173
+ })
2174
+ )
2175
+ ).resolves.toBeUndefined();
2176
+
2177
+ expect(getSummaryEventPayload(adapter)).toStrictEqual({
2178
+ agentId: 'agent-1',
2179
+ interactionId: 'interaction-1',
2180
+ conversationId: 'conversation-1',
2181
+ eventName: AIAssistantEventName.POST_CALL_SUMMARY_RESPONSE,
2182
+ summary: '',
2183
+ feedback: 'none',
2184
+ wrapUpCode: 'resolved',
2185
+ publishTimestamp: expect.any(Number),
2186
+ numberOfTimesViewed: 0,
2187
+ numberOfTimesEdited: 0,
2188
+ numberOfTimesCopied: 0,
2189
+ state: 'NOT_RECEIVED',
2190
+ });
2191
+ });
2192
+
2193
+ it('serializes mid-call response branches without transport-only invalid fields', async () => {
2194
+ const consultTask = new DummyTask(dummyContact, createAISummaryTaskData());
2195
+ const consultMetrics = spyOnAISummaryMetrics(consultTask);
2196
+ const consultMocks = createSummaryMocks(consultTask);
2197
+
2198
+ await expect(
2199
+ consultTask.sendMidCallSummaryResponse(
2200
+ createMidCallResponsePayload({
2201
+ state: 'MID_CALL_CANCELLED',
2202
+ numberOfTimesViewed: 0,
2203
+ numberOfTimesEdited: 0,
2204
+ numberOfTimesCopied: 0,
2205
+ }),
2206
+ 'CONSULT'
2207
+ )
2208
+ ).resolves.toBeUndefined();
2209
+
2210
+ const consultPayload = getSummaryEventPayload(consultMocks.adapter);
2211
+
2212
+ expect(consultPayload).toMatchObject({
2213
+ eventName: AIAssistantEventName.MID_CALL_CONSULT_SUMMARY_RESPONSE,
2214
+ interactionId: 'interaction-1',
2215
+ conversationId: 'conversation-1',
2216
+ agentName: 'Receiving Agent',
2217
+ numberOfTimesViewed: 0,
2218
+ publishTimestamp: expect.any(Number),
2219
+ });
2220
+ expect(consultPayload).not.toHaveProperty('summaryReceived');
2221
+ expect(consultPayload).not.toHaveProperty('wrapUpCode');
2222
+ expect(consultMetrics.trackEvent).toHaveBeenCalledWith(
2223
+ METRIC_EVENT_NAMES.AI_SUMMARY_MID_CALL_RESPONSE_SUCCESS,
2224
+ expect.objectContaining({
2225
+ operation: METHODS.SEND_MID_CALL_SUMMARY_RESPONSE,
2226
+ actionType: 'CONSULT',
2227
+ conversationId: 'conversation-1',
2228
+ interactionId: 'interaction-1',
2229
+ }),
2230
+ ['operational']
2231
+ );
2232
+
2233
+ const transferTask = new DummyTask(dummyContact, createAISummaryTaskData());
2234
+ const transferMocks = createSummaryMocks(transferTask);
2235
+
2236
+ await expect(
2237
+ transferTask.sendMidCallSummaryResponse(
2238
+ createMidCallResponsePayload({
2239
+ summaryReceived: false,
2240
+ summary: '',
2241
+ feedback: 'none',
2242
+ state: 'NOT_RECEIVED',
2243
+ numberOfTimesViewed: 0,
2244
+ numberOfTimesEdited: 0,
2245
+ numberOfTimesCopied: 0,
2246
+ }),
2247
+ 'TRANSFER'
2248
+ )
2249
+ ).resolves.toBeUndefined();
2250
+
2251
+ expect(getSummaryEventPayload(transferMocks.adapter)).toMatchObject({
2252
+ eventName: AIAssistantEventName.MID_CALL_TRANSFER_SUMMARY_RESPONSE,
2253
+ interactionId: 'interaction-1',
2254
+ conversationId: 'conversation-1',
2255
+ summary: '',
2256
+ numberOfTimesViewed: 0,
2257
+ });
2258
+ });
2259
+
2260
+ it.each([
2261
+ {
2262
+ label: 'post-call received',
2263
+ invoke: (task: DummyTask) =>
2264
+ task.sendPostCallSummaryResponse(createPostCallResponsePayload()),
2265
+ eventName: AIAssistantEventName.POST_CALL_SUMMARY_RESPONSE,
2266
+ expected: {wrapUpCode: 'resolved', state: 'DEFAULT'},
2267
+ absent: ['agentName', 'summaryReceived'] as const,
2268
+ },
2269
+ {
2270
+ label: 'post-call NOT_RECEIVED',
2271
+ invoke: (task: DummyTask) =>
2272
+ task.sendPostCallSummaryResponse(
2273
+ createPostCallResponsePayload({
2274
+ summary: '',
2275
+ state: 'NOT_RECEIVED',
2276
+ numberOfTimesViewed: 0,
2277
+ numberOfTimesEdited: 0,
2278
+ numberOfTimesCopied: 0,
2279
+ })
2280
+ ),
2281
+ eventName: AIAssistantEventName.POST_CALL_SUMMARY_RESPONSE,
2282
+ expected: {wrapUpCode: 'resolved', summary: '', state: 'NOT_RECEIVED'},
2283
+ absent: ['agentName', 'summaryReceived'] as const,
2284
+ },
2285
+ {
2286
+ label: 'mid-call received',
2287
+ invoke: (task: DummyTask) =>
2288
+ task.sendMidCallSummaryResponse(createMidCallResponsePayload(), 'CONSULT'),
2289
+ eventName: AIAssistantEventName.MID_CALL_CONSULT_SUMMARY_RESPONSE,
2290
+ expected: {agentName: 'Receiving Agent', state: 'DEFAULT'},
2291
+ absent: ['wrapUpCode', 'summaryReceived'] as const,
2292
+ },
2293
+ {
2294
+ label: 'mid-call summaryReceived:false',
2295
+ invoke: (task: DummyTask) =>
2296
+ task.sendMidCallSummaryResponse(
2297
+ createMidCallResponsePayload({
2298
+ summaryReceived: false,
2299
+ summary: '',
2300
+ state: 'NOT_RECEIVED',
2301
+ numberOfTimesViewed: 0,
2302
+ numberOfTimesEdited: 0,
2303
+ numberOfTimesCopied: 0,
2304
+ }),
2305
+ 'TRANSFER'
2306
+ ),
2307
+ eventName: AIAssistantEventName.MID_CALL_TRANSFER_SUMMARY_RESPONSE,
2308
+ expected: {agentName: 'Receiving Agent', summary: '', state: 'NOT_RECEIVED'},
2309
+ absent: ['wrapUpCode', 'summaryReceived'] as const,
2310
+ },
2311
+ ])(
2312
+ 'populates both identifiers on $label response transport',
2313
+ async ({invoke, eventName, expected, absent}) => {
2314
+ const task = new DummyTask(
2315
+ dummyContact,
2316
+ createAISummaryTaskData({
2317
+ interactionId: 'response-interaction-1',
2318
+ interaction: {mainInteractionId: 'response-conversation-1'} as any,
2319
+ })
2320
+ );
2321
+ const {adapter} = createSummaryMocks(task);
2322
+
2323
+ await expect(invoke(task)).resolves.toBeUndefined();
2324
+
2325
+ expect(adapter.sendEvent).toHaveBeenCalledTimes(1);
2326
+ const transportPayload = getSummaryEventPayload(adapter);
2327
+
2328
+ expect(transportPayload.agentId).toBe('agent-1');
2329
+ expect(transportPayload).toMatchObject({
2330
+ agentId: 'agent-1',
2331
+ interactionId: 'response-interaction-1',
2332
+ conversationId: 'response-conversation-1',
2333
+ eventName,
2334
+ ...expected,
2335
+ });
2336
+ absent.forEach((propertyName) => {
2337
+ expect(transportPayload).not.toHaveProperty(propertyName);
2338
+ });
2339
+ }
2340
+ );
2341
+
2342
+ it.each(['CONSULT', 'TRANSFER'] as const)(
2343
+ 'sends a successful summary-not-received MID_CALL_CANCELLED %s response',
2344
+ async (actionType) => {
2345
+ const task = new DummyTask(dummyContact, createAISummaryTaskData());
2346
+ const {adapter} = createSummaryMocks(task);
2347
+
2348
+ await expect(
2349
+ task.sendMidCallSummaryResponse(
2350
+ createMidCallResponsePayload({
2351
+ summaryReceived: false,
2352
+ summary: '',
2353
+ feedback: 'none',
2354
+ state: 'MID_CALL_CANCELLED',
2355
+ numberOfTimesViewed: 0,
2356
+ numberOfTimesEdited: 0,
2357
+ numberOfTimesCopied: 0,
2358
+ }),
2359
+ actionType
2360
+ )
2361
+ ).resolves.toBeUndefined();
2362
+
2363
+ expect(getSummaryEventPayload(adapter)).toMatchObject({
2364
+ eventName:
2365
+ actionType === 'CONSULT'
2366
+ ? AIAssistantEventName.MID_CALL_CONSULT_SUMMARY_RESPONSE
2367
+ : AIAssistantEventName.MID_CALL_TRANSFER_SUMMARY_RESPONSE,
2368
+ summary: '',
2369
+ state: 'MID_CALL_CANCELLED',
2370
+ numberOfTimesViewed: 0,
2371
+ numberOfTimesEdited: 0,
2372
+ numberOfTimesCopied: 0,
2373
+ });
2374
+ }
2375
+ );
2376
+
2377
+ it('preserves a received MID_CALL_CANCELLED summary and viewed counter', async () => {
2378
+ const task = new DummyTask(dummyContact, createAISummaryTaskData());
2379
+ const {adapter} = createSummaryMocks(task);
2380
+ const summary = {
2381
+ reasonForTransferOrConsult: 'Customer requested a specialist',
2382
+ issueResolution: 'Warm transfer completed',
2383
+ };
2384
+
2385
+ await expect(
2386
+ task.sendMidCallSummaryResponse(
2387
+ createMidCallResponsePayload({
2388
+ summaryReceived: true,
2389
+ summary,
2390
+ state: 'MID_CALL_CANCELLED',
2391
+ numberOfTimesViewed: 1,
2392
+ numberOfTimesEdited: 0,
2393
+ numberOfTimesCopied: 0,
2394
+ }),
2395
+ 'CONSULT'
2396
+ )
2397
+ ).resolves.toBeUndefined();
2398
+
2399
+ expect(getSummaryEventPayload(adapter)).toMatchObject({
2400
+ summary,
2401
+ state: 'MID_CALL_CANCELLED',
2402
+ numberOfTimesViewed: 1,
2403
+ });
2404
+ });
2405
+
2406
+ it.each([
2407
+ {
2408
+ actionType: 'CONSULT' as AISummaryAction,
2409
+ eventName: AIAssistantEventName.MID_CALL_CONSULT_SUMMARY_RESPONSE,
2410
+ },
2411
+ {
2412
+ actionType: 'TRANSFER' as AISummaryAction,
2413
+ eventName: AIAssistantEventName.MID_CALL_TRANSFER_SUMMARY_RESPONSE,
2414
+ },
2415
+ ])(
2416
+ 'sends MID_CALL_CANCELLED $actionType responses without invoking a handoff',
2417
+ async ({actionType, eventName}) => {
2418
+ const task = new DummyTask(dummyContact, createAISummaryTaskData());
2419
+ const {adapter} = createSummaryMocks(task);
2420
+ const consultSpy = jest.spyOn(task, 'consult').mockResolvedValue({} as any);
2421
+ const transferSpy = jest.spyOn(task, 'transfer').mockResolvedValue({} as any);
2422
+ const sendResponseThenHandoff = async (
2423
+ payload: AISummaryResponse,
2424
+ handoffActionType: AISummaryAction
2425
+ ) => {
2426
+ await task.sendMidCallSummaryResponse(payload, handoffActionType);
2427
+
2428
+ if (payload.state === 'MID_CALL_CANCELLED') {
2429
+ return;
2430
+ }
2431
+
2432
+ if (handoffActionType === 'CONSULT') {
2433
+ await task.consult({} as any);
2434
+
2435
+ return;
2436
+ }
2437
+
2438
+ await task.transfer({} as any);
2439
+ };
2440
+
2441
+ await expect(
2442
+ sendResponseThenHandoff(
2443
+ createMidCallResponsePayload({
2444
+ state: 'MID_CALL_CANCELLED',
2445
+ numberOfTimesViewed: 0,
2446
+ numberOfTimesEdited: 0,
2447
+ numberOfTimesCopied: 0,
2448
+ }),
2449
+ actionType
2450
+ )
2451
+ ).resolves.toBeUndefined();
2452
+
2453
+ expect(getSummaryEventPayload(adapter)).toMatchObject({
2454
+ eventName,
2455
+ state: 'MID_CALL_CANCELLED',
2456
+ });
2457
+ expect(consultSpy).not.toHaveBeenCalled();
2458
+ expect(transferSpy).not.toHaveBeenCalled();
2459
+ }
2460
+ );
2461
+
2462
+ it.each([
2463
+ ['post-call IGNORED', 'post-call', 'IGNORED'],
2464
+ ['mid-call EXCLUDED', 'mid-call', 'EXCLUDED'],
2465
+ ['mid-call IGNORED', 'mid-call', 'IGNORED'],
2466
+ ] as const)('accepts %s response states', async (_label, flow, state) => {
2467
+ const task = new DummyTask(dummyContact, createAISummaryTaskData());
2468
+ const {adapter} = createSummaryMocks(task);
2469
+
2470
+ if (flow === 'post-call') {
2471
+ await expect(
2472
+ task.sendPostCallSummaryResponse(createPostCallResponsePayload({state: state as any}))
2473
+ ).resolves.toBeUndefined();
2474
+ } else {
2475
+ await expect(
2476
+ task.sendMidCallSummaryResponse(
2477
+ createMidCallResponsePayload({state: state as any}),
2478
+ 'CONSULT'
2479
+ )
2480
+ ).resolves.toBeUndefined();
2481
+ }
2482
+
2483
+ expect(getSummaryEventPayload(adapter)).toMatchObject({state});
2484
+ });
2485
+
2486
+ it('accepts mid-call IGNORED on the unavailable branch (summaryReceived:false)', async () => {
2487
+ const task = new DummyTask(dummyContact, createAISummaryTaskData());
2488
+ const {adapter} = createSummaryMocks(task);
2489
+
2490
+ await expect(
2491
+ task.sendMidCallSummaryResponse(
2492
+ {
2493
+ summaryReceived: false,
2494
+ summary: '',
2495
+ numberOfTimesViewed: 0,
2496
+ numberOfTimesEdited: 0,
2497
+ numberOfTimesCopied: 0,
2498
+ feedback: 'none',
2499
+ state: 'IGNORED',
2500
+ },
2501
+ 'CONSULT'
2502
+ )
2503
+ ).resolves.toBeUndefined();
2504
+
2505
+ expect(getSummaryEventPayload(adapter)).toMatchObject({state: 'IGNORED'});
2506
+ });
2507
+
2508
+ it.each(['none', 'thumbs_up', 'thumbs_down'] as const)(
2509
+ 'forwards valid %s feedback unchanged for both response flows',
2510
+ async (feedback) => {
2511
+ const postTask = new DummyTask(dummyContact, createAISummaryTaskData());
2512
+ const postMocks = createSummaryMocks(postTask);
2513
+ const midTask = new DummyTask(dummyContact, createAISummaryTaskData());
2514
+ const midMocks = createSummaryMocks(midTask);
2515
+
2516
+ await expect(
2517
+ postTask.sendPostCallSummaryResponse(createPostCallResponsePayload({feedback}))
2518
+ ).resolves.toBeUndefined();
2519
+ await expect(
2520
+ midTask.sendMidCallSummaryResponse(createMidCallResponsePayload({feedback}), 'CONSULT')
2521
+ ).resolves.toBeUndefined();
2522
+
2523
+ expect(getSummaryEventPayload(postMocks.adapter)).toMatchObject({feedback});
2524
+ expect(getSummaryEventPayload(midMocks.adapter)).toMatchObject({feedback});
2525
+ }
2526
+ );
2527
+
2528
+ it('forwards valid non-zero counters unchanged for both response flows', async () => {
2529
+ const counters = {
2530
+ numberOfTimesViewed: 2,
2531
+ numberOfTimesEdited: 1,
2532
+ numberOfTimesCopied: 3,
2533
+ };
2534
+ const postTask = new DummyTask(dummyContact, createAISummaryTaskData());
2535
+ const metrics = spyOnAISummaryMetrics(postTask);
2536
+ const postMocks = createSummaryMocks(postTask);
2537
+ const midTask = new DummyTask(dummyContact, createAISummaryTaskData());
2538
+ const midMocks = createSummaryMocks(midTask);
2539
+
2540
+ await expect(
2541
+ postTask.sendPostCallSummaryResponse(createPostCallResponsePayload(counters))
2542
+ ).resolves.toBeUndefined();
2543
+ await expect(
2544
+ midTask.sendMidCallSummaryResponse(createMidCallResponsePayload(counters), 'TRANSFER')
2545
+ ).resolves.toBeUndefined();
2546
+
2547
+ expect(getSummaryEventPayload(postMocks.adapter)).toMatchObject(counters);
2548
+ expect(getSummaryEventPayload(midMocks.adapter)).toMatchObject(counters);
2549
+ expect(metrics.trackEvent).toHaveBeenCalledWith(
2550
+ METRIC_EVENT_NAMES.AI_SUMMARY_POST_CALL_RESPONSE_SUCCESS,
2551
+ expect.objectContaining({operation: METHODS.SEND_POST_CALL_SUMMARY_RESPONSE}),
2552
+ ['operational']
2553
+ );
2554
+ expect(metrics.trackEvent).toHaveBeenCalledWith(
2555
+ METRIC_EVENT_NAMES.AI_SUMMARY_MID_CALL_RESPONSE_SUCCESS,
2556
+ expect.objectContaining({
2557
+ operation: METHODS.SEND_MID_CALL_SUMMARY_RESPONSE,
2558
+ actionType: 'TRANSFER',
2559
+ }),
2560
+ ['operational']
2561
+ );
2562
+ });
2563
+
2564
+ it('maps plain adapter errors to a bounded failure code and preserves rejection identity', async () => {
2565
+ const task = new DummyTask(dummyContact, createAISummaryTaskData());
2566
+ const metrics = spyOnAISummaryMetrics(task);
2567
+ const {adapter} = createSummaryMocks(task);
2568
+ const adapterError = new Error('summary-section-value-sentinel');
2569
+
2570
+ adapter.sendEvent.mockRejectedValue(adapterError);
2571
+
2572
+ await expect(
2573
+ task.sendMidCallSummaryResponse(createMidCallResponsePayload(), 'CONSULT')
2574
+ ).rejects.toBe(adapterError);
2575
+
2576
+ expect(JSON.stringify(metrics.trackEvent.mock.calls)).not.toContain(
2577
+ 'summary-section-value-sentinel'
2578
+ );
2579
+ expect(metrics.trackEvent).toHaveBeenCalledWith(
2580
+ METRIC_EVENT_NAMES.AI_SUMMARY_MID_CALL_RESPONSE_FAILED,
2581
+ expect.objectContaining({
2582
+ operation: METHODS.SEND_MID_CALL_SUMMARY_RESPONSE,
2583
+ actionType: 'CONSULT',
2584
+ failureCode: 'AI_SUMMARY_INVALID_RESPONSE_PAYLOAD',
2585
+ }),
2586
+ ['operational']
2587
+ );
2588
+ });
2589
+
2590
+ it.each([
2591
+ {
2592
+ label: 'summary base URL unavailable',
2593
+ expectedCode: AI_SUMMARY_ERROR_CODES.AI_ASSISTANT_BASE_URL_NOT_AVAILABLE,
2594
+ configure: true,
2595
+ payload: createPostCallResponsePayload(),
2596
+ adapterError: createAISummaryError(
2597
+ AI_SUMMARY_ERROR_CODES.AI_ASSISTANT_BASE_URL_NOT_AVAILABLE
2598
+ ),
2599
+ expectAdapterCall: true,
2600
+ },
2601
+ ...[
2602
+ AI_SUMMARY_TRANSPORT_ERROR_CODES.VALIDATION_FAILED,
2603
+ AI_SUMMARY_TRANSPORT_ERROR_CODES.HTTP_REQUEST_FAILED,
2604
+ AI_SUMMARY_TRANSPORT_ERROR_CODES.TIMEOUT,
2605
+ ].map((expectedCode) => ({
2606
+ label: expectedCode,
2607
+ expectedCode,
2608
+ configure: true,
2609
+ payload: createPostCallResponsePayload(),
2610
+ adapterError: createAISummaryError(expectedCode),
2611
+ expectAdapterCall: true,
2612
+ })),
2613
+ ])(
2614
+ 'records bounded post-call response failure metric for $label',
2615
+ async ({expectedCode, configure, taskData, payload, adapterError, expectAdapterCall}) => {
2616
+ const responseTask = new DummyTask(dummyContact, taskData ?? createAISummaryTaskData());
2617
+ const responseMetrics = spyOnAISummaryMetrics(responseTask);
2618
+ const summaryMocks = configure ? createSummaryMocks(responseTask) : undefined;
2619
+
2620
+ if (adapterError) {
2621
+ summaryMocks?.adapter.sendEvent.mockRejectedValue(adapterError);
2622
+ }
2623
+
2624
+ const result = responseTask.sendPostCallSummaryResponse(payload);
2625
+
2626
+ if (adapterError) {
2627
+ await expect(result).rejects.toBe(adapterError);
2628
+ } else {
2629
+ await expect(result).rejects.toMatchObject(createAISummaryErrorExpectation(expectedCode));
2630
+ }
2631
+
2632
+ if (summaryMocks) {
2633
+ if (expectAdapterCall) {
2634
+ expect(summaryMocks.adapter.sendEvent).toHaveBeenCalledTimes(1);
2635
+ } else {
2636
+ expect(summaryMocks.adapter.sendEvent).not.toHaveBeenCalled();
2637
+ }
2638
+ }
2639
+ expect(responseMetrics.trackEvent).toHaveBeenCalledWith(
2640
+ METRIC_EVENT_NAMES.AI_SUMMARY_POST_CALL_RESPONSE_FAILED,
2641
+ expect.objectContaining({
2642
+ operation: METHODS.SEND_POST_CALL_SUMMARY_RESPONSE,
2643
+ failureCode: expectedCode,
2644
+ }),
2645
+ ['operational']
2646
+ );
2647
+ expect(responseMetrics.timeEvent).toHaveBeenCalled();
2648
+ }
2649
+ );
2650
+
2651
+ it.each([
2652
+ {
2653
+ label: 'summary base URL unavailable',
2654
+ expectedCode: AI_SUMMARY_ERROR_CODES.AI_ASSISTANT_BASE_URL_NOT_AVAILABLE,
2655
+ configure: true,
2656
+ adapterError: createAISummaryError(
2657
+ AI_SUMMARY_ERROR_CODES.AI_ASSISTANT_BASE_URL_NOT_AVAILABLE
2658
+ ),
2659
+ taskData: undefined,
2660
+ expectAdapterCall: true,
2661
+ includeActionType: true,
2662
+ },
2663
+ ...[
2664
+ AI_SUMMARY_TRANSPORT_ERROR_CODES.VALIDATION_FAILED,
2665
+ AI_SUMMARY_TRANSPORT_ERROR_CODES.HTTP_REQUEST_FAILED,
2666
+ AI_SUMMARY_TRANSPORT_ERROR_CODES.TIMEOUT,
2667
+ ].map((expectedCode) => ({
2668
+ label: expectedCode,
2669
+ expectedCode,
2670
+ configure: true,
2671
+ adapterError: createAISummaryError(expectedCode),
2672
+ taskData: undefined,
2673
+ expectAdapterCall: true,
2674
+ includeActionType: true,
2675
+ })),
2676
+ ])(
2677
+ 'records bounded mid-call response failure metric for $label',
2678
+ async ({
2679
+ expectedCode,
2680
+ configure,
2681
+ taskData,
2682
+ adapterError,
2683
+ expectAdapterCall,
2684
+ }) => {
2685
+ const responseTask = new DummyTask(dummyContact, taskData ?? createAISummaryTaskData());
2686
+ const responseMetrics = spyOnAISummaryMetrics(responseTask);
2687
+ const summaryMocks = configure ? createSummaryMocks(responseTask) : undefined;
2688
+
2689
+ if (adapterError) {
2690
+ summaryMocks?.adapter.sendEvent.mockRejectedValue(adapterError);
2691
+ }
2692
+
2693
+ const result = responseTask.sendMidCallSummaryResponse(
2694
+ createMidCallResponsePayload(),
2695
+ 'CONSULT'
2696
+ );
2697
+
2698
+ if (adapterError) {
2699
+ await expect(result).rejects.toBe(adapterError);
2700
+ } else {
2701
+ await expect(result).rejects.toMatchObject(createAISummaryErrorExpectation(expectedCode));
2702
+ }
2703
+
2704
+ if (summaryMocks) {
2705
+ if (expectAdapterCall) {
2706
+ expect(summaryMocks.adapter.sendEvent).toHaveBeenCalledTimes(1);
2707
+ } else {
2708
+ expect(summaryMocks.adapter.sendEvent).not.toHaveBeenCalled();
2709
+ }
2710
+ }
2711
+ expect(responseMetrics.trackEvent).toHaveBeenCalledWith(
2712
+ METRIC_EVENT_NAMES.AI_SUMMARY_MID_CALL_RESPONSE_FAILED,
2713
+ expect.objectContaining({
2714
+ operation: METHODS.SEND_MID_CALL_SUMMARY_RESPONSE,
2715
+ actionType: 'CONSULT',
2716
+ failureCode: expectedCode,
2717
+ }),
2718
+ ['operational']
2719
+ );
2720
+ expect(responseMetrics.timeEvent).toHaveBeenCalled();
2721
+ }
2722
+ );
2723
+ });