@webex/contact-center 3.11.0 → 3.12.0-auth-prejoin-fetch.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (325) hide show
  1. package/.sdd/manifest.json +883 -0
  2. package/AGENTS.md +94 -0
  3. package/ai-docs/ARCHITECTURE.md +168 -0
  4. package/ai-docs/CONTRACTS.md +50 -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 +48 -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 +359 -0
  17. package/ai-docs/features/consult-transfer-list-policy/spec/feature-spec.md +362 -0
  18. package/ai-docs/features/generated-spec-conformance-fidelity-remediation/spec/feature-spec.md +117 -0
  19. package/ai-docs/features/residual-warning-coverage-completion/spec/feature-spec.md +203 -0
  20. package/ai-docs/features/validator-code-fidelity-drift-fix/spec/feature-spec.md +315 -0
  21. package/ai-docs/patterns/event-driven-patterns.md +485 -0
  22. package/ai-docs/patterns/testing-patterns.md +480 -0
  23. package/ai-docs/patterns/typescript-patterns.md +365 -0
  24. package/ai-docs/templates/README.md +102 -0
  25. package/ai-docs/templates/documentation/create-agents-md.md +240 -0
  26. package/ai-docs/templates/documentation/create-architecture-md.md +295 -0
  27. package/ai-docs/templates/existing-service/bug-fix.md +254 -0
  28. package/ai-docs/templates/existing-service/feature-enhancement.md +450 -0
  29. package/ai-docs/templates/new-method/00-master.md +80 -0
  30. package/ai-docs/templates/new-method/01-requirements.md +232 -0
  31. package/ai-docs/templates/new-method/02-implementation.md +295 -0
  32. package/ai-docs/templates/new-method/03-tests.md +201 -0
  33. package/ai-docs/templates/new-method/04-validation.md +141 -0
  34. package/ai-docs/templates/new-service/00-master.md +109 -0
  35. package/ai-docs/templates/new-service/01-pre-questions.md +159 -0
  36. package/ai-docs/templates/new-service/02-code-generation.md +346 -0
  37. package/ai-docs/templates/new-service/03-integration.md +178 -0
  38. package/ai-docs/templates/new-service/04-test-generation.md +205 -0
  39. package/ai-docs/templates/new-service/05-validation.md +145 -0
  40. package/dist/cc.js +818 -59
  41. package/dist/cc.js.map +1 -1
  42. package/dist/config.js +13 -0
  43. package/dist/config.js.map +1 -1
  44. package/dist/constants.js +31 -3
  45. package/dist/constants.js.map +1 -1
  46. package/dist/index.js +27 -5
  47. package/dist/index.js.map +1 -1
  48. package/dist/metrics/behavioral-events.js +127 -0
  49. package/dist/metrics/behavioral-events.js.map +1 -1
  50. package/dist/metrics/constants.js +34 -3
  51. package/dist/metrics/constants.js.map +1 -1
  52. package/dist/services/AddressBook.js +18 -15
  53. package/dist/services/AddressBook.js.map +1 -1
  54. package/dist/services/AnswerCallOnWebexService.js +174 -0
  55. package/dist/services/AnswerCallOnWebexService.js.map +1 -0
  56. package/dist/services/ApiAiAssistant.js +318 -0
  57. package/dist/services/ApiAiAssistant.js.map +1 -0
  58. package/dist/services/EntryPoint.js +46 -68
  59. package/dist/services/EntryPoint.js.map +1 -1
  60. package/dist/services/Queue.js +27 -22
  61. package/dist/services/Queue.js.map +1 -1
  62. package/dist/services/UserPreference.js +427 -0
  63. package/dist/services/UserPreference.js.map +1 -0
  64. package/dist/services/WebexCrossClientService.js +171 -0
  65. package/dist/services/WebexCrossClientService.js.map +1 -0
  66. package/dist/services/WxAppTelephonyMercurySync.js +93 -0
  67. package/dist/services/WxAppTelephonyMercurySync.js.map +1 -0
  68. package/dist/services/agent/types.js.map +1 -1
  69. package/dist/services/config/Util.js +11 -4
  70. package/dist/services/config/Util.js.map +1 -1
  71. package/dist/services/config/constants.js +45 -8
  72. package/dist/services/config/constants.js.map +1 -1
  73. package/dist/services/config/index.js +41 -2
  74. package/dist/services/config/index.js.map +1 -1
  75. package/dist/services/config/types.js +70 -8
  76. package/dist/services/config/types.js.map +1 -1
  77. package/dist/services/constants.js +27 -1
  78. package/dist/services/constants.js.map +1 -1
  79. package/dist/services/core/Err.js.map +1 -1
  80. package/dist/services/core/Utils.js +122 -25
  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 +119 -30
  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 +22 -6
  88. package/dist/services/core/websocket/WebSocketManager.js.map +1 -1
  89. package/dist/services/core/websocket/connection-service.js +3 -1
  90. package/dist/services/core/websocket/connection-service.js.map +1 -1
  91. package/dist/services/core/websocket/types.js.map +1 -1
  92. package/dist/services/index.js +6 -0
  93. package/dist/services/index.js.map +1 -1
  94. package/dist/services/task/Task.js +754 -0
  95. package/dist/services/task/Task.js.map +1 -0
  96. package/dist/services/task/TaskFactory.js +49 -0
  97. package/dist/services/task/TaskFactory.js.map +1 -0
  98. package/dist/services/task/TaskManager.js +1073 -447
  99. package/dist/services/task/TaskManager.js.map +1 -1
  100. package/dist/services/task/TaskUtils.js +220 -23
  101. package/dist/services/task/TaskUtils.js.map +1 -1
  102. package/dist/services/task/WebexCallingUtils.js +70 -0
  103. package/dist/services/task/WebexCallingUtils.js.map +1 -0
  104. package/dist/services/task/constants.js +26 -2
  105. package/dist/services/task/constants.js.map +1 -1
  106. package/dist/services/task/contact.js +29 -0
  107. package/dist/services/task/contact.js.map +1 -1
  108. package/dist/services/task/dialer.js +129 -0
  109. package/dist/services/task/dialer.js.map +1 -1
  110. package/dist/services/task/digital/Digital.js +78 -0
  111. package/dist/services/task/digital/Digital.js.map +1 -0
  112. package/dist/services/task/state-machine/TaskStateMachine.js +971 -0
  113. package/dist/services/task/state-machine/TaskStateMachine.js.map +1 -0
  114. package/dist/services/task/state-machine/actions.js +572 -0
  115. package/dist/services/task/state-machine/actions.js.map +1 -0
  116. package/dist/services/task/state-machine/constants.js +161 -0
  117. package/dist/services/task/state-machine/constants.js.map +1 -0
  118. package/dist/services/task/state-machine/guards.js +409 -0
  119. package/dist/services/task/state-machine/guards.js.map +1 -0
  120. package/dist/services/task/state-machine/index.js +53 -0
  121. package/dist/services/task/state-machine/index.js.map +1 -0
  122. package/dist/services/task/state-machine/types.js +54 -0
  123. package/dist/services/task/state-machine/types.js.map +1 -0
  124. package/dist/services/task/state-machine/uiControlsComputer.js +703 -0
  125. package/dist/services/task/state-machine/uiControlsComputer.js.map +1 -0
  126. package/dist/services/task/taskDataNormalizer.js +99 -0
  127. package/dist/services/task/taskDataNormalizer.js.map +1 -0
  128. package/dist/services/task/types.js +243 -5
  129. package/dist/services/task/types.js.map +1 -1
  130. package/dist/services/task/voice/Voice.js +1380 -0
  131. package/dist/services/task/voice/Voice.js.map +1 -0
  132. package/dist/services/task/voice/WebRTC.js +152 -0
  133. package/dist/services/task/voice/WebRTC.js.map +1 -0
  134. package/dist/services/task/voice/wxAppVoiceMethods.js +201 -0
  135. package/dist/services/task/voice/wxAppVoiceMethods.js.map +1 -0
  136. package/dist/services/wxAppTelephonyUtils.js +19 -0
  137. package/dist/services/wxAppTelephonyUtils.js.map +1 -0
  138. package/dist/types/cc.d.ts +967 -0
  139. package/dist/types/config.d.ts +79 -0
  140. package/dist/types/constants.d.ts +74 -0
  141. package/dist/types/index.d.ts +201 -0
  142. package/dist/types/logger-proxy.d.ts +71 -0
  143. package/dist/types/metrics/MetricsManager.d.ts +223 -0
  144. package/dist/types/metrics/behavioral-events.d.ts +29 -0
  145. package/dist/types/metrics/constants.d.ts +183 -0
  146. package/dist/types/services/AddressBook.d.ts +75 -0
  147. package/dist/types/services/AnswerCallOnWebexService.d.ts +37 -0
  148. package/dist/types/services/ApiAiAssistant.d.ts +49 -0
  149. package/dist/types/services/EntryPoint.d.ts +69 -0
  150. package/dist/types/services/Queue.d.ts +78 -0
  151. package/dist/types/services/UserPreference.d.ts +118 -0
  152. package/dist/types/services/WebCallingService.d.ts +1 -0
  153. package/dist/types/services/WebexCrossClientService.d.ts +28 -0
  154. package/dist/types/services/WxAppTelephonyMercurySync.d.ts +28 -0
  155. package/dist/types/services/agent/index.d.ts +46 -0
  156. package/dist/types/services/agent/types.d.ts +413 -0
  157. package/dist/types/services/config/Util.d.ts +20 -0
  158. package/dist/types/services/config/constants.d.ts +273 -0
  159. package/dist/types/services/config/index.d.ts +177 -0
  160. package/dist/types/services/config/types.d.ts +1381 -0
  161. package/dist/types/services/constants.d.ts +110 -0
  162. package/dist/types/services/core/Err.d.ts +127 -0
  163. package/dist/types/services/core/GlobalTypes.d.ts +58 -0
  164. package/dist/types/services/core/Utils.d.ts +121 -0
  165. package/dist/types/services/core/WebexRequest.d.ts +23 -0
  166. package/dist/types/services/core/aqm-reqs.d.ts +65 -0
  167. package/dist/types/services/core/constants.d.ts +99 -0
  168. package/dist/types/services/core/types.d.ts +49 -0
  169. package/dist/types/services/core/websocket/WebSocketManager.d.ts +36 -0
  170. package/dist/types/services/core/websocket/connection-service.d.ts +27 -0
  171. package/dist/types/services/core/websocket/keepalive.worker.d.ts +2 -0
  172. package/dist/types/services/core/websocket/types.d.ts +37 -0
  173. package/dist/types/services/index.d.ts +54 -0
  174. package/dist/types/services/task/AutoWrapup.d.ts +40 -0
  175. package/dist/types/services/task/Task.d.ts +175 -0
  176. package/dist/types/services/task/TaskFactory.d.ts +13 -0
  177. package/dist/types/services/task/TaskManager.d.ts +1 -0
  178. package/dist/types/services/task/TaskUtils.d.ts +138 -0
  179. package/dist/types/services/task/WebexCallingUtils.d.ts +11 -0
  180. package/dist/types/services/task/constants.d.ts +94 -0
  181. package/dist/types/services/task/contact.d.ts +73 -0
  182. package/dist/types/services/task/dialer.d.ts +73 -0
  183. package/dist/types/services/task/digital/Digital.d.ts +22 -0
  184. package/dist/types/services/task/state-machine/TaskStateMachine.d.ts +1398 -0
  185. package/dist/types/services/task/state-machine/actions.d.ts +10 -0
  186. package/dist/types/services/task/state-machine/constants.d.ts +107 -0
  187. package/dist/types/services/task/state-machine/guards.d.ts +103 -0
  188. package/dist/types/services/task/state-machine/index.d.ts +13 -0
  189. package/dist/types/services/task/state-machine/types.d.ts +277 -0
  190. package/dist/types/services/task/state-machine/uiControlsComputer.d.ts +9 -0
  191. package/dist/types/services/task/taskDataNormalizer.d.ts +10 -0
  192. package/dist/types/services/task/types.d.ts +1933 -0
  193. package/dist/types/services/task/voice/Voice.d.ts +223 -0
  194. package/dist/types/services/task/voice/WebRTC.d.ts +53 -0
  195. package/dist/types/services/task/voice/wxAppVoiceMethods.d.ts +52 -0
  196. package/dist/types/services/wxAppTelephonyUtils.d.ts +5 -0
  197. package/dist/types/types.d.ts +784 -0
  198. package/dist/types/utils/PageCache.d.ts +190 -0
  199. package/dist/types/webex-config.d.ts +53 -0
  200. package/dist/types/webex.d.ts +8 -0
  201. package/dist/types.js +137 -3
  202. package/dist/types.js.map +1 -1
  203. package/dist/utils/PageCache.js +19 -5
  204. package/dist/utils/PageCache.js.map +1 -1
  205. package/dist/webex.js +14 -2
  206. package/dist/webex.js.map +1 -1
  207. package/package.json +16 -12
  208. package/src/cc.ts +983 -60
  209. package/src/config.ts +13 -0
  210. package/src/constants.ts +29 -1
  211. package/src/index.ts +26 -5
  212. package/src/metrics/ai-docs/AGENTS.md +350 -0
  213. package/src/metrics/ai-docs/ARCHITECTURE.md +338 -0
  214. package/src/metrics/ai-docs/metrics-spec.md +860 -0
  215. package/src/metrics/behavioral-events.ts +134 -0
  216. package/src/metrics/constants.ts +39 -3
  217. package/src/services/AddressBook.ts +17 -6
  218. package/src/services/AnswerCallOnWebexService.ts +206 -0
  219. package/src/services/ApiAiAssistant.ts +412 -0
  220. package/src/services/EntryPoint.ts +59 -60
  221. package/src/services/Queue.ts +29 -12
  222. package/src/services/UserPreference.ts +509 -0
  223. package/src/services/WebexCrossClientService.ts +212 -0
  224. package/src/services/WxAppTelephonyMercurySync.ts +115 -0
  225. package/src/services/agent/ai-docs/AGENTS.md +240 -0
  226. package/src/services/agent/ai-docs/ARCHITECTURE.md +304 -0
  227. package/src/services/agent/ai-docs/agent-spec.md +504 -0
  228. package/src/services/agent/types.ts +1 -1
  229. package/src/services/ai-docs/AGENTS.md +386 -0
  230. package/src/services/ai-docs/services-spec.md +497 -0
  231. package/src/services/config/Util.ts +13 -2
  232. package/src/services/config/ai-docs/AGENTS.md +255 -0
  233. package/src/services/config/ai-docs/ARCHITECTURE.md +426 -0
  234. package/src/services/config/ai-docs/config-spec.md +675 -0
  235. package/src/services/config/constants.ts +47 -7
  236. package/src/services/config/index.ts +45 -1
  237. package/src/services/config/types.ts +253 -11
  238. package/src/services/constants.ts +29 -0
  239. package/src/services/core/Err.ts +4 -0
  240. package/src/services/core/Utils.ts +143 -30
  241. package/src/services/core/WebexRequest.ts +3 -1
  242. package/src/services/core/ai-docs/AGENTS.md +381 -0
  243. package/src/services/core/ai-docs/ARCHITECTURE.md +698 -0
  244. package/src/services/core/ai-docs/core-spec.md +787 -0
  245. package/src/services/core/aqm-reqs.ts +125 -32
  246. package/src/services/core/types.ts +2 -0
  247. package/src/services/core/websocket/WebSocketManager.ts +23 -6
  248. package/src/services/core/websocket/connection-service.ts +5 -1
  249. package/src/services/core/websocket/types.ts +1 -1
  250. package/src/services/index.ts +4 -0
  251. package/src/services/task/Task.ts +908 -0
  252. package/src/services/task/TaskFactory.ts +60 -0
  253. package/src/services/task/TaskManager.ts +1291 -513
  254. package/src/services/task/TaskUtils.ts +314 -24
  255. package/src/services/task/WebexCallingUtils.ts +136 -0
  256. package/src/services/task/ai-docs/AGENTS.md +457 -0
  257. package/src/services/task/ai-docs/ARCHITECTURE.md +595 -0
  258. package/src/services/task/ai-docs/task-spec.md +1469 -0
  259. package/src/services/task/constants.ts +26 -0
  260. package/src/services/task/contact.ts +30 -0
  261. package/src/services/task/dialer.ts +136 -1
  262. package/src/services/task/digital/Digital.ts +97 -0
  263. package/src/services/task/state-machine/TaskStateMachine.ts +1313 -0
  264. package/src/services/task/state-machine/actions.ts +741 -0
  265. package/src/services/task/state-machine/ai-docs/AGENTS.md +462 -0
  266. package/src/services/task/state-machine/ai-docs/ARCHITECTURE.md +1146 -0
  267. package/src/services/task/state-machine/ai-docs/task-state-machine-spec.md +2209 -0
  268. package/src/services/task/state-machine/constants.ts +172 -0
  269. package/src/services/task/state-machine/guards.ts +498 -0
  270. package/src/services/task/state-machine/index.ts +28 -0
  271. package/src/services/task/state-machine/types.ts +258 -0
  272. package/src/services/task/state-machine/uiControlsComputer.ts +1135 -0
  273. package/src/services/task/taskDataNormalizer.ts +137 -0
  274. package/src/services/task/types.ts +843 -70
  275. package/src/services/task/voice/Voice.ts +1720 -0
  276. package/src/services/task/voice/WebRTC.ts +191 -0
  277. package/src/services/task/voice/wxAppVoiceMethods.ts +307 -0
  278. package/src/services/wxAppTelephonyUtils.ts +14 -0
  279. package/src/types.ts +238 -11
  280. package/src/utils/AGENTS.md +289 -0
  281. package/src/utils/PageCache.ts +38 -5
  282. package/src/utils/ai-docs/utils-spec.md +391 -0
  283. package/src/webex.js +2 -0
  284. package/test/unit/spec/cc.ts +1856 -122
  285. package/test/unit/spec/logger-proxy.ts +70 -0
  286. package/test/unit/spec/metrics/behavioral-events.ts +18 -0
  287. package/test/unit/spec/services/AddressBook.ts +37 -6
  288. package/test/unit/spec/services/AnswerCallOnWebexService.ts +223 -0
  289. package/test/unit/spec/services/ApiAiAssistant.ts +273 -0
  290. package/test/unit/spec/services/EntryPoint.ts +87 -40
  291. package/test/unit/spec/services/Queue.ts +123 -12
  292. package/test/unit/spec/services/UserPreference.ts +401 -0
  293. package/test/unit/spec/services/WebCallingService.ts +7 -1
  294. package/test/unit/spec/services/WebexCrossClientService.ts +261 -0
  295. package/test/unit/spec/services/WxAppTelephonyMercurySync.ts +113 -0
  296. package/test/unit/spec/services/config/Util.ts +85 -0
  297. package/test/unit/spec/services/config/index.ts +85 -29
  298. package/test/unit/spec/services/core/Utils.ts +481 -2
  299. package/test/unit/spec/services/core/WebexRequest.ts +3 -1
  300. package/test/unit/spec/services/core/aqm-reqs.ts +113 -1
  301. package/test/unit/spec/services/core/websocket/WebSocketManager.ts +137 -41
  302. package/test/unit/spec/services/core/websocket/connection-service.ts +3 -1
  303. package/test/unit/spec/services/task/AutoWrapup.ts +63 -0
  304. package/test/unit/spec/services/task/Task.ts +677 -0
  305. package/test/unit/spec/services/task/TaskFactory.ts +99 -0
  306. package/test/unit/spec/services/task/TaskManager.ts +2209 -918
  307. package/test/unit/spec/services/task/TaskUtils.ts +235 -0
  308. package/test/unit/spec/services/task/WebexCallingUtils.ts +153 -0
  309. package/test/unit/spec/services/task/contact.ts +33 -0
  310. package/test/unit/spec/services/task/dialer.ts +372 -96
  311. package/test/unit/spec/services/task/digital/Digital.ts +105 -0
  312. package/test/unit/spec/services/task/state-machine/TaskStateMachine.ts +3433 -0
  313. package/test/unit/spec/services/task/state-machine/guards.ts +839 -0
  314. package/test/unit/spec/services/task/state-machine/types.ts +18 -0
  315. package/test/unit/spec/services/task/state-machine/uiControlsComputer.ts +3101 -0
  316. package/test/unit/spec/services/task/taskTestUtils.ts +87 -0
  317. package/test/unit/spec/services/task/voice/Voice.ts +1523 -0
  318. package/test/unit/spec/services/task/voice/WebRTC.ts +235 -0
  319. package/test/unit/spec/services/task/voice/wxAppVoiceMethods.ts +459 -0
  320. package/umd/contact-center.min.js +2 -2
  321. package/umd/contact-center.min.js.map +1 -1
  322. package/dist/services/task/index.js +0 -1525
  323. package/dist/services/task/index.js.map +0 -1
  324. package/src/services/task/index.ts +0 -1801
  325. package/test/unit/spec/services/task/index.ts +0 -2184
@@ -5,15 +5,21 @@ import {CALL_EVENT_KEYS, CallingClientConfig, LINE_EVENTS} from '@webex/calling'
5
5
  import {CC_AGENT_EVENTS, CC_EVENTS} from '../../../../../src/services/config/types';
6
6
  import TaskManager from '../../../../../src/services/task/TaskManager';
7
7
  import * as contact from '../../../../../src/services/task/contact';
8
- import Task from '../../../../../src/services/task';
8
+ import Task from '../../../../../src/services/task/Task';
9
9
  import {TASK_EVENTS} from '../../../../../src/services/task/types';
10
+ import {TaskEvent} from '../../../../../src/services/task/state-machine';
11
+ import WebRTC from '../../../../../src/services/task/voice/WebRTC';
12
+ import {Profile} from '../../../../../src/services/config/types';
10
13
  import WebCallingService from '../../../../../src/services/WebCallingService';
11
14
  import config from '../../../../../src/config';
12
15
  import {CC_TASK_EVENTS} from '../../../../../src/services/config/types';
16
+ import TaskFactory from '../../../../../src/services/task/TaskFactory';
13
17
 
14
18
  describe('TaskManager', () => {
15
19
  let mockCall;
20
+ let mockApiAIAssistant;
16
21
  let webSocketManagerMock;
22
+ let rtdWebSocketManagerMock;
17
23
  let onSpy;
18
24
  let offSpy;
19
25
  let taskManager;
@@ -23,6 +29,111 @@ describe('TaskManager', () => {
23
29
  let webex: WebexSDK;
24
30
  const taskId = '0ae913a4-c857-4705-8d49-76dd3dde75e4';
25
31
 
32
+ const createMockTask = (data = taskDataMock) => {
33
+ const task = new EventEmitter() as any;
34
+
35
+ const updateTaskData = jest.fn().mockImplementation((newData) => {
36
+ task.data = {...task.data, ...newData};
37
+ return task;
38
+ });
39
+
40
+ Object.assign(task, {
41
+ data,
42
+ accept: jest.fn(),
43
+ decline: jest.fn(),
44
+ updateTaskData,
45
+ unregisterWebCallListeners: jest.fn(),
46
+ cancelAutoWrapupTimer: jest.fn(),
47
+ });
48
+
49
+ const taskEventMap: Partial<Record<TaskEvent, string>> = {
50
+ [TaskEvent.TASK_INCOMING]: TASK_EVENTS.TASK_INCOMING,
51
+ [TaskEvent.TASK_OFFERED]: TASK_EVENTS.TASK_OFFER_CONTACT,
52
+ [TaskEvent.OFFER_CONSULT]: TASK_EVENTS.TASK_OFFER_CONSULT,
53
+ [TaskEvent.HYDRATE]: TASK_EVENTS.TASK_HYDRATE,
54
+ [TaskEvent.CONTACT_OWNER_CHANGED]: TASK_EVENTS.TASK_HYDRATE,
55
+ [TaskEvent.ASSIGN]: TASK_EVENTS.TASK_ASSIGNED,
56
+ [TaskEvent.HOLD_SUCCESS]: TASK_EVENTS.TASK_HOLD,
57
+ [TaskEvent.UNHOLD_SUCCESS]: TASK_EVENTS.TASK_RESUME,
58
+ [TaskEvent.CONSULT_CREATED]: TASK_EVENTS.TASK_CONSULT_CREATED,
59
+ [TaskEvent.CONSULTING_ACTIVE]: TASK_EVENTS.TASK_CONSULT_ACCEPTED,
60
+ [TaskEvent.CONSULT_END]: TASK_EVENTS.TASK_CONSULT_END,
61
+ [TaskEvent.CONSULT_FAILED]: CC_EVENTS.AGENT_CONSULT_FAILED,
62
+ [TaskEvent.CTQ_CANCEL]: TASK_EVENTS.TASK_CONSULT_QUEUE_CANCELLED,
63
+ [TaskEvent.CTQ_CANCEL_FAILED]: TASK_EVENTS.TASK_CONSULT_QUEUE_FAILED,
64
+ [TaskEvent.END]: TASK_EVENTS.TASK_END,
65
+ [TaskEvent.CONTACT_ENDED]: TASK_EVENTS.TASK_END,
66
+ [TaskEvent.ASSIGN_FAILED]: TASK_EVENTS.TASK_REJECT,
67
+ [TaskEvent.INVITE_FAILED]: TASK_EVENTS.TASK_REJECT,
68
+ [TaskEvent.RONA]: TASK_EVENTS.TASK_REJECT,
69
+ [TaskEvent.OUTBOUND_FAILED]: TASK_EVENTS.TASK_OUTDIAL_FAILED,
70
+ [TaskEvent.RECORDING_STARTED]: TASK_EVENTS.TASK_RECORDING_STARTED,
71
+ [TaskEvent.PAUSE_RECORDING]: TASK_EVENTS.TASK_RECORDING_PAUSED,
72
+ [TaskEvent.RESUME_RECORDING]: TASK_EVENTS.TASK_RECORDING_RESUMED,
73
+ [TaskEvent.WRAPUP_COMPLETE]: TASK_EVENTS.TASK_WRAPPEDUP,
74
+ };
75
+
76
+ task.sendStateMachineEvent = jest.fn().mockImplementation((event) => {
77
+ if (event.taskData) {
78
+ task.updateTaskData(event.taskData);
79
+ }
80
+
81
+ const mappedEvent = taskEventMap[event.type as TaskEvent];
82
+ if (mappedEvent) {
83
+ if (
84
+ [TaskEvent.ASSIGN_FAILED, TaskEvent.RONA, TaskEvent.INVITE_FAILED].includes(
85
+ event.type as TaskEvent
86
+ )
87
+ ) {
88
+ task.emit(mappedEvent, event.reason ?? event.taskData?.reason);
89
+ } else if (event.type === TaskEvent.OUTBOUND_FAILED) {
90
+ if (!event.taskData?.suppressOutdialFailedPopup) {
91
+ task.emit(mappedEvent, event.reason);
92
+ }
93
+ } else {
94
+ task.emit(mappedEvent, task);
95
+ }
96
+ }
97
+
98
+ if ([TaskEvent.ASSIGN, TaskEvent.CONSULTING_ACTIVE].includes(event.type as TaskEvent)) {
99
+ task.emit(TASK_EVENTS.TASK_MULTI_LOGIN_HYDRATE, task);
100
+ }
101
+
102
+ // Auto-answer is now handled at the Task layer (triggered by state machine actions)
103
+ if (
104
+ [TaskEvent.TASK_OFFERED, TaskEvent.OFFER_CONSULT].includes(event.type as TaskEvent) &&
105
+ (event.taskData?.isAutoAnswering === true || event.taskData?.isAutoAnswering === 'true')
106
+ ) {
107
+ Promise.resolve(task.accept())
108
+ .then(() => {
109
+ task.emit(TASK_EVENTS.TASK_AUTO_ANSWERED, task);
110
+ })
111
+ .catch(() => undefined);
112
+ }
113
+
114
+ // Cleanup is now emitted by state machine actions (Task layer).
115
+ // Simulate the TASK_CLEANUP emission for unit tests using mock tasks.
116
+ const eventType = event.type as TaskEvent;
117
+ const shouldCleanup =
118
+ eventType === TaskEvent.CONTACT_ENDED ||
119
+ eventType === TaskEvent.END ||
120
+ eventType === TaskEvent.TASK_WRAPUP ||
121
+ eventType === TaskEvent.WRAPUP_COMPLETE ||
122
+ eventType === TaskEvent.ASSIGN_FAILED ||
123
+ eventType === TaskEvent.INVITE_FAILED ||
124
+ eventType === TaskEvent.RONA ||
125
+ eventType === TaskEvent.OUTBOUND_FAILED ||
126
+ (eventType === TaskEvent.CONSULT_END && task.data?.isConsulted === true);
127
+
128
+ if (shouldCleanup) {
129
+ const removeFromCollection = eventType !== TaskEvent.CONTACT_ENDED;
130
+ task.emit(TASK_EVENTS.TASK_CLEANUP, task, {removeFromCollection});
131
+ }
132
+ });
133
+
134
+ return task;
135
+ };
136
+
26
137
  taskDataMock = {
27
138
  type: CC_EVENTS.AGENT_CONTACT_RESERVED,
28
139
  agentId: '723a8ffb-a26e-496d-b14a-ff44fb83b64f',
@@ -42,9 +153,21 @@ describe('TaskManager', () => {
42
153
  data: taskDataMock,
43
154
  };
44
155
 
156
+ const expectLastStateMachineEvent = (
157
+ spy: jest.SpyInstance | jest.Mock,
158
+ expectedType: TaskEvent
159
+ ) => {
160
+ expect(spy).toHaveBeenCalled();
161
+ const lastCall = spy.mock.calls[spy.mock.calls.length - 1] || [];
162
+ const event = lastCall[3]?.type ? lastCall[3] : lastCall[0];
163
+ expect(event?.type).toBe(expectedType);
164
+ return event;
165
+ };
166
+
45
167
  beforeEach(() => {
46
168
  contactMock = contact;
47
169
  webSocketManagerMock = new EventEmitter();
170
+ rtdWebSocketManagerMock = new EventEmitter();
48
171
 
49
172
  webex = {
50
173
  logger: {
@@ -74,15 +197,25 @@ describe('TaskManager', () => {
74
197
  onSpy = jest.spyOn(webCallingService, 'on');
75
198
  offSpy = jest.spyOn(webCallingService, 'off');
76
199
 
77
- taskManager = new TaskManager(contactMock, webCallingService, webSocketManagerMock);
78
- taskManager.taskCollection[taskId] = {
79
- emit: jest.fn(),
80
- accept: jest.fn(),
81
- decline: jest.fn(),
82
- updateTaskData: jest.fn(),
83
- data: taskDataMock,
200
+ mockApiAIAssistant = {
201
+ sendEvent: jest.fn().mockResolvedValue({}),
84
202
  };
203
+
204
+ taskManager = new TaskManager(
205
+ mockApiAIAssistant as any,
206
+ contactMock,
207
+ webCallingService,
208
+ webSocketManagerMock as any,
209
+ rtdWebSocketManagerMock as any
210
+ );
211
+ taskManager.taskCollection[taskId] = createMockTask(taskDataMock);
212
+ (taskManager as any).setupTaskListeners?.(taskManager.taskCollection[taskId]);
85
213
  taskManager.call = mockCall;
214
+ taskManager.setAgentId('test-agent-id');
215
+
216
+ jest
217
+ .spyOn(TaskFactory, 'createTask')
218
+ .mockImplementation((contact, webCallingService, data, configFlags) => createMockTask(data));
86
219
  });
87
220
 
88
221
  afterEach(() => {
@@ -93,7 +226,8 @@ describe('TaskManager', () => {
93
226
  it('should initialize TaskManager and register listeners', () => {
94
227
  webSocketManagerMock.emit('message', JSON.stringify({data: taskDataMock}));
95
228
  const incomingCallCb = onSpy.mock.calls[0][1];
96
- const taskEmitSpy = jest.spyOn(taskManager, 'emit');
229
+ const incomingHandler = jest.fn();
230
+ taskManager.on(TASK_EVENTS.TASK_INCOMING, incomingHandler);
97
231
 
98
232
  expect(taskManager).toBeInstanceOf(TaskManager);
99
233
  expect(webCallingService.listenerCount(LINE_EVENTS.INCOMING_CALL)).toBe(1);
@@ -102,14 +236,13 @@ describe('TaskManager', () => {
102
236
 
103
237
  incomingCallCb(mockCall);
104
238
 
105
- expect(taskEmitSpy).toHaveBeenCalledWith(TASK_EVENTS.TASK_INCOMING, taskManager.getTask(taskId));
239
+ expect(incomingHandler).toHaveBeenCalledWith(taskManager.getTask(taskId));
240
+ taskManager.off(TASK_EVENTS.TASK_INCOMING, incomingHandler);
106
241
  });
107
242
 
108
243
  it('should re-emit task related events', () => {
109
244
  const dummyPayload = {
110
- data: {...taskDataMock,
111
- type: CC_TASK_EVENTS.AGENT_CONSULTING,
112
- },
245
+ data: {...taskDataMock, type: CC_TASK_EVENTS.AGENT_CONSULTING},
113
246
  };
114
247
  webSocketManagerMock.emit('message', JSON.stringify({data: taskDataMock}));
115
248
  const taskEmitSpy = jest.spyOn(taskManager.getTask(taskId), 'emit');
@@ -120,7 +253,186 @@ describe('TaskManager', () => {
120
253
 
121
254
  webSocketManagerMock.emit('message', JSON.stringify(dummyPayload));
122
255
 
123
- expect(taskEmitSpy).toHaveBeenCalledWith(dummyPayload.data.type, dummyPayload.data);
256
+ expect(taskEmitSpy).toHaveBeenCalledWith(
257
+ TASK_EVENTS.TASK_CONSULT_ACCEPTED,
258
+ taskManager.getTask(taskId)
259
+ );
260
+ });
261
+
262
+ it('should invoke sendEvent for configured start/stop backend events', () => {
263
+ taskManager.setConfigFlags({
264
+ isEndTaskEnabled: true,
265
+ isEndConsultEnabled: true,
266
+ webRtcEnabled: true,
267
+ autoWrapup: false,
268
+ aiFeature: {
269
+ id: 'ai-feature-1',
270
+ realtimeTranscripts: {
271
+ enable: true,
272
+ },
273
+ },
274
+ });
275
+
276
+ const interactionId = taskId;
277
+ const message = (type: CC_EVENTS) =>
278
+ JSON.stringify({
279
+ data: {
280
+ ...taskDataMock,
281
+ interactionId,
282
+ type,
283
+ },
284
+ });
285
+
286
+ webSocketManagerMock.emit('message', message(CC_EVENTS.AGENT_CONTACT_ASSIGNED));
287
+ webSocketManagerMock.emit('message', message(CC_EVENTS.AGENT_CONSULTING));
288
+ webSocketManagerMock.emit('message', message(CC_EVENTS.AGENT_CONSULT_CONFERENCED));
289
+ webSocketManagerMock.emit('message', message(CC_EVENTS.AGENT_CONSULT_ENDED));
290
+ webSocketManagerMock.emit('message', message(CC_EVENTS.PARTICIPANT_LEFT_CONFERENCE));
291
+ webSocketManagerMock.emit('message', message(CC_EVENTS.AGENT_WRAPUP));
292
+
293
+ expect(mockApiAIAssistant.sendEvent).toHaveBeenCalledTimes(6);
294
+ expect(mockApiAIAssistant.sendEvent).toHaveBeenCalledWith(
295
+ 'test-agent-id',
296
+ interactionId,
297
+ 'CUSTOM_EVENT',
298
+ 'GET_TRANSCRIPTS',
299
+ {action: 'START'}
300
+ );
301
+ expect(mockApiAIAssistant.sendEvent).toHaveBeenCalledWith(
302
+ 'test-agent-id',
303
+ interactionId,
304
+ 'CUSTOM_EVENT',
305
+ 'GET_TRANSCRIPTS',
306
+ {action: 'STOP'}
307
+ );
308
+ });
309
+
310
+ it('should not invoke sendEvent when realtime transcripts are disabled in aiFeature', () => {
311
+ taskManager.setConfigFlags({
312
+ isEndTaskEnabled: true,
313
+ isEndConsultEnabled: true,
314
+ webRtcEnabled: true,
315
+ autoWrapup: false,
316
+ aiFeature: {
317
+ id: 'ai-feature-1',
318
+ realtimeTranscripts: {
319
+ enable: false,
320
+ },
321
+ },
322
+ });
323
+
324
+ const message = (type: CC_EVENTS) =>
325
+ JSON.stringify({
326
+ data: {
327
+ ...taskDataMock,
328
+ interactionId: taskId,
329
+ type,
330
+ },
331
+ });
332
+
333
+ webSocketManagerMock.emit('message', message(CC_EVENTS.AGENT_CONTACT_ASSIGNED));
334
+ webSocketManagerMock.emit('message', message(CC_EVENTS.AGENT_CONSULTING));
335
+
336
+ expect(mockApiAIAssistant.sendEvent).not.toHaveBeenCalled();
337
+ });
338
+
339
+ it('should not invoke sendEvent when realtime transcripts config is missing', () => {
340
+ taskManager.setConfigFlags({
341
+ isEndTaskEnabled: true,
342
+ isEndConsultEnabled: true,
343
+ webRtcEnabled: true,
344
+ autoWrapup: false,
345
+ aiFeature: {
346
+ id: 'ai-feature-1',
347
+ suggestedResponses: {
348
+ enable: true,
349
+ },
350
+ },
351
+ });
352
+
353
+ const message = (type: CC_EVENTS) =>
354
+ JSON.stringify({
355
+ data: {
356
+ ...taskDataMock,
357
+ interactionId: taskId,
358
+ type,
359
+ },
360
+ });
361
+
362
+ webSocketManagerMock.emit('message', message(CC_EVENTS.AGENT_CONTACT_ASSIGNED));
363
+ webSocketManagerMock.emit('message', message(CC_EVENTS.AGENT_CONSULTING));
364
+
365
+ expect(mockApiAIAssistant.sendEvent).not.toHaveBeenCalled();
366
+ });
367
+
368
+ it('should emit REAL_TIME_TRANSCRIPTION from RTD websocket payload', () => {
369
+ const task = taskManager.getTask(taskId);
370
+ const taskEmitSpy = jest.spyOn(task, 'emit');
371
+ const realtimePayload = {
372
+ data: {
373
+ agentId: 'test-agent-id',
374
+ data: {
375
+ content: 'Thank you. Okay.',
376
+ conversationId: taskId,
377
+ isFinal: true,
378
+ languageCode: 'en-US',
379
+ messageId: '1',
380
+ orgId: 'org-id',
381
+ publishTimestamp: 1773807297475,
382
+ role: 'AGENT',
383
+ trackingId: 'tracking-id',
384
+ utteranceId: 'utterance-id',
385
+ },
386
+ notifDetails: {
387
+ actionEvent: 'REAL_TIME_TRANSCRIPTION',
388
+ },
389
+ notifType: 'REAL_TIME_TRANSCRIPTION',
390
+ orgId: 'org-id',
391
+ },
392
+ orgId: 'org-id',
393
+ trackingId: 'notifs_tracking-id',
394
+ type: 'REAL_TIME_TRANSCRIPTION',
395
+ };
396
+
397
+ taskManager.handleRealtimeWebsocketEvent(JSON.stringify(realtimePayload));
398
+
399
+ expect(taskEmitSpy).toHaveBeenCalledWith(
400
+ CC_EVENTS.REAL_TIME_TRANSCRIPTION,
401
+ realtimePayload.data
402
+ );
403
+ });
404
+
405
+ it('should ignore RTD transcript events when task is not found', () => {
406
+ const realtimePayload = {
407
+ data: {
408
+ data: {
409
+ content: 'Thank you. Okay.',
410
+ conversationId: 'missing-task-id',
411
+ isFinal: true,
412
+ languageCode: 'en-US',
413
+ messageId: '1',
414
+ orgId: 'org-id',
415
+ publishTimestamp: 1773807297475,
416
+ role: 'AGENT',
417
+ trackingId: 'tracking-id',
418
+ utteranceId: 'utterance-id',
419
+ },
420
+ notifDetails: {
421
+ actionEvent: 'REAL_TIME_TRANSCRIPTION',
422
+ },
423
+ notifType: 'REAL_TIME_TRANSCRIPTION',
424
+ orgId: 'org-id',
425
+ },
426
+ orgId: 'org-id',
427
+ trackingId: 'notifs_tracking-id',
428
+ type: 'REAL_TIME_TRANSCRIPTION',
429
+ };
430
+
431
+ const existingTaskEmitSpy = jest.spyOn(taskManager.getTask(taskId), 'emit');
432
+
433
+ taskManager.handleRealtimeWebsocketEvent(JSON.stringify(realtimePayload));
434
+
435
+ expect(existingTaskEmitSpy).not.toHaveBeenCalled();
124
436
  });
125
437
 
126
438
  it('should not re-emit agent related events', () => {
@@ -138,7 +450,7 @@ describe('TaskManager', () => {
138
450
 
139
451
  webSocketManagerMock.emit('message', JSON.stringify(dummyPayload));
140
452
 
141
- expect(taskEmitSpy).not.toHaveBeenCalledWith(dummyPayload.data.type, dummyPayload.data);
453
+ expect(taskEmitSpy).not.toHaveBeenCalled();
142
454
  });
143
455
 
144
456
  it('should handle WebSocket message for AGENT_CONTACT_RESERVED and emit task:incoming for browser case', () => {
@@ -159,14 +471,12 @@ describe('TaskManager', () => {
159
471
  },
160
472
  };
161
473
 
162
- const taskIncomingSpy = jest.spyOn(taskManager, 'emit');
474
+ const incomingHandler = jest.fn();
475
+ taskManager.on(TASK_EVENTS.TASK_INCOMING, incomingHandler);
163
476
 
164
477
  webSocketManagerMock.emit('message', JSON.stringify(payload));
165
478
 
166
- expect(taskIncomingSpy).toHaveBeenCalledWith(
167
- TASK_EVENTS.TASK_INCOMING,
168
- taskManager.getTask(payload.data.interactionId)
169
- );
479
+ expect(incomingHandler).toHaveBeenCalledWith(taskManager.getTask(payload.data.interactionId));
170
480
  expect(taskManager.getTask(payload.data.interactionId)).toBe(taskManager.getTask(taskId));
171
481
  expect(taskManager.getAllTasks()).toHaveProperty(payload.data.interactionId);
172
482
 
@@ -187,7 +497,11 @@ describe('TaskManager', () => {
187
497
  },
188
498
  };
189
499
 
190
- const currentTaskAssignedSpy = jest.spyOn(taskManager.getTask(payload.data.interactionId), 'emit');
500
+ const currentTaskAssignedSpy = jest.spyOn(
501
+ taskManager.getTask(payload.data.interactionId),
502
+ 'emit'
503
+ );
504
+ const taskManagerEmitSpy = jest.spyOn(taskManager, 'emit');
191
505
 
192
506
  webSocketManagerMock.emit('message', JSON.stringify(assignedPayload));
193
507
 
@@ -195,6 +509,10 @@ describe('TaskManager', () => {
195
509
  TASK_EVENTS.TASK_ASSIGNED,
196
510
  taskManager.getTask(taskId)
197
511
  );
512
+ expect(taskManagerEmitSpy).toHaveBeenCalledWith(
513
+ TASK_EVENTS.TASK_MULTI_LOGIN_HYDRATE,
514
+ taskManager.getTask(taskId)
515
+ );
198
516
  });
199
517
 
200
518
  it('should handle WebSocket message for AGENT_CONTACT_RESERVED and emit task:incoming for extension case', () => {
@@ -216,16 +534,64 @@ describe('TaskManager', () => {
216
534
  },
217
535
  };
218
536
 
219
- const taskIncomingSpy = jest.spyOn(taskManager, 'emit');
537
+ const incomingHandler = jest.fn();
538
+ taskManager.on(TASK_EVENTS.TASK_INCOMING, incomingHandler);
220
539
 
221
540
  webSocketManagerMock.emit('message', JSON.stringify(payload));
222
541
 
223
- expect(taskIncomingSpy).toHaveBeenCalledWith(
224
- TASK_EVENTS.TASK_INCOMING,
225
- taskManager.getTask(taskId)
226
- );
542
+ expect(incomingHandler).toHaveBeenCalledWith(taskManager.getTask(taskId));
227
543
  expect(taskManager.getTask(payload.data.interactionId)).toBe(taskManager.getTask(taskId));
228
544
  expect(taskManager.getAllTasks()).toHaveProperty(payload.data.interactionId);
545
+ taskManager.off(TASK_EVENTS.TASK_INCOMING, incomingHandler);
546
+ });
547
+
548
+ it('should send mapped events through the state machine without duplicate updates', () => {
549
+ webSocketManagerMock.emit('message', JSON.stringify(initalPayload));
550
+ const task = taskManager.getTask(taskId);
551
+ const updateSpy = task.updateTaskData as jest.Mock;
552
+ updateSpy.mockClear();
553
+ const sendSpy = task.sendStateMachineEvent as jest.Mock;
554
+ sendSpy.mockClear();
555
+ const cleanupSpy = jest.spyOn(taskManager as any, 'handleTaskCleanup');
556
+
557
+ const assignFailedPayload = {
558
+ data: {
559
+ ...initalPayload.data,
560
+ type: CC_EVENTS.AGENT_CONTACT_ASSIGN_FAILED,
561
+ reason: 'ASSIGN_FAILED',
562
+ },
563
+ };
564
+
565
+ webSocketManagerMock.emit('message', JSON.stringify(assignFailedPayload));
566
+
567
+ const stateMachineEvent = expectLastStateMachineEvent(sendSpy, TaskEvent.ASSIGN_FAILED);
568
+ expect(stateMachineEvent).toEqual({
569
+ type: TaskEvent.ASSIGN_FAILED,
570
+ reason: assignFailedPayload.data.reason,
571
+ });
572
+ expect(updateSpy).toHaveBeenCalledWith(assignFailedPayload.data);
573
+ expect(cleanupSpy).toHaveBeenCalledWith(task);
574
+ });
575
+
576
+ it('should update task data directly when no state machine mapping exists', () => {
577
+ webSocketManagerMock.emit('message', JSON.stringify(initalPayload));
578
+ const task = taskManager.getTask(taskId);
579
+ const updateSpy = task.updateTaskData as jest.Mock;
580
+ updateSpy.mockClear();
581
+ const sendSpy = task.sendStateMachineEvent as jest.Mock;
582
+ sendSpy.mockClear();
583
+
584
+ const participantMovedPayload = {
585
+ data: {
586
+ ...initalPayload.data,
587
+ type: CC_EVENTS.CONSULTED_PARTICIPANT_MOVING,
588
+ },
589
+ };
590
+
591
+ webSocketManagerMock.emit('message', JSON.stringify(participantMovedPayload));
592
+
593
+ expect(sendSpy).not.toHaveBeenCalled();
594
+ expect(updateSpy).toHaveBeenCalledWith(participantMovedPayload.data);
229
595
  });
230
596
 
231
597
  it('should return task by ID', () => {
@@ -310,9 +676,30 @@ describe('TaskManager', () => {
310
676
  it('test call listeners being switched off on call end', () => {
311
677
  webSocketManagerMock.emit('message', JSON.stringify(initalPayload));
312
678
 
313
- const taskEmitSpy = jest.spyOn(taskManager.getTask(taskId), 'emit');
314
- const webCallListenerSpy = jest.spyOn(taskManager.getTask(taskId), 'unregisterWebCallListeners');
679
+ const webrtcTask = new WebRTC(contactMock, webCallingService, taskDataMock, {
680
+ isEndTaskEnabled: true,
681
+ isEndConsultEnabled: true,
682
+ });
683
+ (taskManager as any).taskCollection[taskId] = webrtcTask;
684
+ // TaskManager must listen to task-level cleanup events emitted by the state machine.
685
+ // This is normally wired when TaskManager creates the task via TaskFactory.
686
+ (taskManager as any).setupTaskListeners(webrtcTask);
687
+
688
+ const task = taskManager.getTask(taskId)!;
689
+ // This test doesn't validate UI controls; avoid requiring full interaction.media
690
+ // shape for WebRTC UI controls computation.
691
+ jest.spyOn(task as any, 'updateUiControls').mockImplementation(() => undefined);
692
+ const originalEmit = task.emit;
693
+ jest.spyOn(task, 'emit').mockImplementation((event, arg) => {
694
+ if (event === CC_EVENTS.CONTACT_ENDED) {
695
+ return;
696
+ }
697
+ return originalEmit.call(task, event, arg);
698
+ });
699
+
700
+ const webCallListenerSpy = jest.spyOn(task, 'unregisterWebCallListeners');
315
701
  const callOffSpy = jest.spyOn(mockCall, 'off');
702
+ const sendStateMachineEventSpy = jest.spyOn(task, 'sendStateMachineEvent');
316
703
  const payload = {
317
704
  data: {
318
705
  type: CC_EVENTS.CONTACT_ENDED,
@@ -330,12 +717,25 @@ describe('TaskManager', () => {
330
717
  },
331
718
  };
332
719
 
720
+ // Ensure the state machine is hydrated into a connected state before CONTACT_ENDED
721
+ const hydratePayload = {
722
+ data: {
723
+ ...payload.data,
724
+ type: CC_EVENTS.AGENT_CONTACT,
725
+ interaction: {state: 'connected', mediaType: 'telephony'},
726
+ },
727
+ };
728
+
729
+ taskManager.getTask(taskId).data = hydratePayload.data;
730
+ webSocketManagerMock.emit('message', JSON.stringify(hydratePayload));
731
+
333
732
  taskManager.getTask(taskId).data = payload.data;
334
- const task = taskManager.getTask(taskId)
335
733
  webSocketManagerMock.emit('message', JSON.stringify(payload));
336
- expect(taskEmitSpy).toHaveBeenCalledWith(
337
- TASK_EVENTS.TASK_END, task
734
+ const stateMachineEvent = expectLastStateMachineEvent(
735
+ sendStateMachineEventSpy,
736
+ TaskEvent.CONTACT_ENDED
338
737
  );
738
+ expect(stateMachineEvent?.taskData.wrapUpRequired).toBe(false);
339
739
  expect(webCallListenerSpy).toHaveBeenCalledWith();
340
740
  expect(callOffSpy).toHaveBeenCalledWith(
341
741
  CALL_EVENT_KEYS.REMOTE_MEDIA,
@@ -346,12 +746,13 @@ describe('TaskManager', () => {
346
746
  expect(offSpy.mock.calls.length).toBe(2); // 1 for incoming call and 1 for remote media
347
747
  expect(offSpy).toHaveBeenCalledWith(CALL_EVENT_KEYS.REMOTE_MEDIA, offSpy.mock.calls[0][1]);
348
748
  expect(offSpy).toHaveBeenCalledWith(LINE_EVENTS.INCOMING_CALL, offSpy.mock.calls[1][1]);
749
+ sendStateMachineEventSpy.mockRestore();
349
750
  });
350
751
 
351
752
  it('should emit TASK_END event with wrapupRequired on regular call end', () => {
352
753
  webSocketManagerMock.emit('message', JSON.stringify(initalPayload));
353
-
354
- const taskEmitSpy = jest.spyOn(taskManager.getTask(taskId), 'emit');
754
+ const task = taskManager.getTask(taskId);
755
+ const sendStateMachineEventSpy = jest.spyOn(task, 'sendStateMachineEvent');
355
756
  const payload = {
356
757
  data: {
357
758
  type: CC_EVENTS.CONTACT_ENDED,
@@ -366,80 +767,77 @@ describe('TaskManager', () => {
366
767
  destAgentId: 'ebeb893b-ba67-4f36-8418-95c7492b28c2',
367
768
  owner: '723a8ffb-a26e-496d-b14a-ff44fb83b64f',
368
769
  queueMgr: 'aqm',
770
+ agentsPendingWrapUp: ['test-agent-id'],
369
771
  },
370
772
  };
371
773
 
372
- taskManager.getTask(taskId).updateTaskData(payload.data);
774
+ task.updateTaskData(payload.data);
373
775
  webSocketManagerMock.emit('message', JSON.stringify(payload));
374
- expect(taskEmitSpy).toHaveBeenCalledWith(
375
- CC_EVENTS.CONTACT_ENDED,
376
- { ...payload.data}
377
- );
378
- expect(taskEmitSpy).toHaveBeenCalledWith(
379
- TASK_EVENTS.TASK_END,
380
- taskManager.getTask(taskId)
776
+ const stateMachineEvent = expectLastStateMachineEvent(
777
+ sendStateMachineEventSpy,
778
+ TaskEvent.CONTACT_ENDED
381
779
  );
780
+ expect(stateMachineEvent?.taskData.wrapUpRequired).toBe(true);
781
+ sendStateMachineEventSpy.mockRestore();
382
782
  });
383
783
 
384
784
  it('should emit TASK_REJECT event on AGENT_INVITE_FAILED event', () => {
385
785
  webSocketManagerMock.emit('message', JSON.stringify(initalPayload));
386
786
 
387
- const taskEmitSpy = jest.spyOn(taskManager.getTask(taskId), 'emit');
388
- const metricsTrackSpy = jest.spyOn(taskManager.metricsManager, 'trackEvent');
389
- const payload = {
390
- data: {
391
- type: CC_EVENTS.AGENT_INVITE_FAILED,
392
- agentId: '723a8ffb-a26e-496d-b14a-ff44fb83b64f',
393
- eventTime: 1733211616959,
394
- eventType: 'RoutingMessage',
395
- interaction: {state: 'connected'},
396
- interactionId: taskId,
397
- orgId: '6ecef209-9a34-4ed1-a07a-7ddd1dbe925a',
398
- trackingId: '575c0ec2-618c-42af-a61c-53aeb0a221ee',
399
- mediaResourceId: '0ae913a4-c857-4705-8d49-76dd3dde75e4',
400
- destAgentId: 'ebeb893b-ba67-4f36-8418-95c7492b28c2',
401
- owner: '723a8ffb-a26e-496d-b14a-ff44fb83b64f',
402
- queueMgr: 'aqm',
403
- reason: 'INVITE_FAILED',
404
- },
405
- };
787
+ const task = taskManager.getTask(taskId);
788
+ const sendStateMachineEventSpy = jest.spyOn(task, 'sendStateMachineEvent');
789
+ const payload = {
790
+ data: {
791
+ type: CC_EVENTS.AGENT_INVITE_FAILED,
792
+ agentId: '723a8ffb-a26e-496d-b14a-ff44fb83b64f',
793
+ eventTime: 1733211616959,
794
+ eventType: 'RoutingMessage',
795
+ interaction: {state: 'connected'},
796
+ interactionId: taskId,
797
+ orgId: '6ecef209-9a34-4ed1-a07a-7ddd1dbe925a',
798
+ trackingId: '575c0ec2-618c-42af-a61c-53aeb0a221ee',
799
+ mediaResourceId: '0ae913a4-c857-4705-8d49-76dd3dde75e4',
800
+ destAgentId: 'ebeb893b-ba67-4f36-8418-95c7492b28c2',
801
+ owner: '723a8ffb-a26e-496d-b14a-ff44fb83b64f',
802
+ queueMgr: 'aqm',
803
+ reason: 'INVITE_FAILED',
804
+ },
805
+ };
406
806
 
407
- taskManager.getTask(taskId).updateTaskData(payload.data);
408
- webSocketManagerMock.emit('message', JSON.stringify(payload));
409
- expect(taskEmitSpy).toHaveBeenCalledWith(
410
- CC_EVENTS.AGENT_INVITE_FAILED,
411
- { ...payload.data}
412
- );
413
- expect(taskEmitSpy).toHaveBeenCalledWith(
414
- TASK_EVENTS.TASK_REJECT,
415
- payload.data.reason
416
- );
417
- // Verify the correct metric event name is used for AGENT_INVITE_FAILED
418
- expect(metricsTrackSpy).toHaveBeenCalled();
419
- expect(metricsTrackSpy.mock.calls[0][0]).toBe('Agent Invite Failed');
807
+ task.updateTaskData(payload.data);
808
+ webSocketManagerMock.emit('message', JSON.stringify(payload));
809
+ const stateMachineEvent = expectLastStateMachineEvent(
810
+ sendStateMachineEventSpy,
811
+ TaskEvent.INVITE_FAILED
812
+ );
813
+ expect(stateMachineEvent?.reason).toBe(payload.data.reason);
814
+ sendStateMachineEventSpy.mockRestore();
420
815
  });
421
816
 
422
-
423
- it('should not emit TASK_HYDRATE if task is already present in taskManager', () => {
817
+ it('should emit TASK_HYDRATE even if task is already present in taskManager', () => {
424
818
  const payload = {
425
819
  data: {
426
820
  ...initalPayload.data,
427
821
  type: CC_EVENTS.AGENT_CONTACT,
428
822
  },
429
823
  };
430
- const taskEmitSpy = jest.spyOn(taskManager, 'emit');
824
+ const existingTask = taskManager.getTask(taskId);
825
+ const sendStateMachineEventSpy = jest.spyOn(existingTask, 'sendStateMachineEvent');
431
826
  webSocketManagerMock.emit('message', JSON.stringify(payload));
432
827
 
433
- expect(taskEmitSpy).not.toHaveBeenCalledWith(
434
- TASK_EVENTS.TASK_HYDRATE,
435
- taskManager.getTask(taskId)
828
+ const stateMachineEvent = expectLastStateMachineEvent(
829
+ sendStateMachineEventSpy,
830
+ TaskEvent.HYDRATE
436
831
  );
832
+ expect(stateMachineEvent?.taskData).toEqual(payload.data);
833
+ expect(existingTask).toBe(taskManager.getTask(taskId));
437
834
  expect(taskManager.taskCollection[payload.data.interactionId]).toBe(
438
835
  taskManager.getTask(taskId)
439
836
  );
837
+ sendStateMachineEventSpy.mockRestore();
440
838
  });
441
839
 
442
- it('should emit TASK_INCOMING event on AGENT_CONTACT event if task is new and not in the taskManager ', () => {
840
+ it('should emit TASK_HYDRATE event on AGENT_CONTACT when task is created from payload', () => {
443
841
  taskManager.taskCollection = [];
444
842
  const payload = {
445
843
  data: {
@@ -449,13 +847,15 @@ describe('TaskManager', () => {
449
847
  },
450
848
  };
451
849
 
452
- const taskEmitSpy = jest.spyOn(taskManager, 'emit');
453
850
  webSocketManagerMock.emit('message', JSON.stringify(payload));
454
851
 
455
- expect(taskEmitSpy).toHaveBeenCalledWith(
456
- TASK_EVENTS.TASK_INCOMING,
457
- taskManager.getTask(taskId)
852
+ const createdTask = taskManager.getTask(taskId);
853
+ const sendStateMachineEventSpy = createdTask.sendStateMachineEvent as jest.Mock;
854
+ const stateMachineEvent = expectLastStateMachineEvent(
855
+ sendStateMachineEventSpy,
856
+ TaskEvent.HYDRATE
458
857
  );
858
+ expect(stateMachineEvent?.taskData).toEqual(payload.data);
459
859
  expect(taskManager.taskCollection[payload.data.interactionId]).toBe(
460
860
  taskManager.getTask(taskId)
461
861
  );
@@ -470,10 +870,15 @@ describe('TaskManager', () => {
470
870
  },
471
871
  };
472
872
 
473
- const taskEmitSpy = jest.spyOn(taskManager, 'emit');
474
873
  webSocketManagerMock.emit('message', JSON.stringify(payload));
475
874
 
476
- expect(taskEmitSpy).toHaveBeenCalledWith(TASK_EVENTS.TASK_HYDRATE, taskManager.getTask(taskId));
875
+ const createdTask = taskManager.getTask(taskId);
876
+ const sendStateMachineEventSpy = createdTask.sendStateMachineEvent as jest.Mock;
877
+ const stateMachineEvent = expectLastStateMachineEvent(
878
+ sendStateMachineEventSpy,
879
+ TaskEvent.HYDRATE
880
+ );
881
+ expect(stateMachineEvent?.taskData).toEqual(payload.data);
477
882
  expect(taskManager.taskCollection[payload.data.interactionId]).toBe(
478
883
  taskManager.getTask(taskId)
479
884
  );
@@ -483,7 +888,7 @@ describe('TaskManager', () => {
483
888
  const testAgentId = '723a8ffb-a26e-496d-b14a-ff44fb83b64f';
484
889
  taskManager.setAgentId(testAgentId);
485
890
  taskManager.taskCollection = [];
486
-
891
+
487
892
  const payload = {
488
893
  data: {
489
894
  ...initalPayload.data,
@@ -492,9 +897,9 @@ describe('TaskManager', () => {
492
897
  mediaType: 'telephony',
493
898
  state: 'conference',
494
899
  participants: {
495
- [testAgentId]: { pType: 'Agent', hasLeft: false },
496
- 'agent-2': { pType: 'Agent', hasLeft: false },
497
- 'customer-1': { pType: 'Customer', hasLeft: false },
900
+ [testAgentId]: {pType: 'Agent', hasLeft: false},
901
+ 'agent-2': {pType: 'Agent', hasLeft: false},
902
+ 'customer-1': {pType: 'Customer', hasLeft: false},
498
903
  },
499
904
  media: {
500
905
  [taskId]: {
@@ -517,7 +922,7 @@ describe('TaskManager', () => {
517
922
  const testAgentId = '723a8ffb-a26e-496d-b14a-ff44fb83b64f';
518
923
  taskManager.setAgentId(testAgentId);
519
924
  taskManager.taskCollection = [];
520
-
925
+
521
926
  const payload = {
522
927
  data: {
523
928
  ...initalPayload.data,
@@ -526,8 +931,8 @@ describe('TaskManager', () => {
526
931
  mediaType: 'telephony',
527
932
  state: 'connected',
528
933
  participants: {
529
- [testAgentId]: { pType: 'Agent', hasLeft: false },
530
- 'customer-1': { pType: 'Customer', hasLeft: false },
934
+ [testAgentId]: {pType: 'Agent', hasLeft: false},
935
+ 'customer-1': {pType: 'Customer', hasLeft: false},
531
936
  },
532
937
  media: {
533
938
  [taskId]: {
@@ -546,7 +951,7 @@ describe('TaskManager', () => {
546
951
  expect(createdTask.data.isConferenceInProgress).toBe(false);
547
952
  });
548
953
 
549
- it('should emit TASK_END event on AGENT_WRAPUP event', () => {
954
+ it('should emit TASK_WRAPUP event on AGENT_WRAPUP event', () => {
550
955
  webSocketManagerMock.emit('message', JSON.stringify(initalPayload));
551
956
 
552
957
  const wrapupPayload = {
@@ -563,18 +968,20 @@ describe('TaskManager', () => {
563
968
  destAgentId: 'ebeb893b-ba67-4f36-8418-95c7492b28c2',
564
969
  owner: '723a8ffb-a26e-496d-b14a-ff44fb83b64f',
565
970
  queueMgr: 'aqm',
566
- wrapUpRequired: true
971
+ wrapUpRequired: true,
567
972
  },
568
973
  };
569
974
 
570
975
  const task = taskManager.getTask(taskId);
571
- const updateTaskDataSpy = jest.spyOn(task, 'updateTaskData');
572
- const taskEmitSpy = jest.spyOn(task, 'emit');
976
+ const updateTaskDataSpy = task.updateTaskData as jest.Mock;
977
+ updateTaskDataSpy.mockClear();
978
+ const sendStateMachineEventSpy = jest.spyOn(task, 'sendStateMachineEvent');
573
979
 
574
980
  webSocketManagerMock.emit('message', JSON.stringify(wrapupPayload));
575
981
 
576
982
  expect(updateTaskDataSpy).toHaveBeenCalledWith(wrapupPayload.data);
577
- expect(taskEmitSpy).toHaveBeenCalledWith(TASK_EVENTS.TASK_END, task);
983
+ expectLastStateMachineEvent(sendStateMachineEventSpy, TaskEvent.TASK_WRAPUP);
984
+ sendStateMachineEventSpy.mockRestore();
578
985
  });
579
986
 
580
987
  it('should emit TASK_HOLD event on AGENT_CONTACT_HELD event', () => {
@@ -597,13 +1004,20 @@ describe('TaskManager', () => {
597
1004
  },
598
1005
  };
599
1006
 
600
- const taskEmitSpy = jest.spyOn(taskManager.getTask(taskId), 'emit');
601
- const taskUpdateTaskDataSpy = jest.spyOn(taskManager.getTask(taskId), 'updateTaskData');
1007
+ const task = taskManager.getTask(taskId);
1008
+ const taskUpdateTaskDataSpy = task.updateTaskData as jest.Mock;
1009
+ taskUpdateTaskDataSpy.mockClear();
1010
+ const sendStateMachineEventSpy = jest.spyOn(task, 'sendStateMachineEvent');
602
1011
 
603
1012
  webSocketManagerMock.emit('message', JSON.stringify(payload));
604
1013
 
605
1014
  expect(taskUpdateTaskDataSpy).toHaveBeenCalledWith(payload.data);
606
- expect(taskEmitSpy).toHaveBeenCalledWith(TASK_EVENTS.TASK_HOLD, taskManager.getTask(taskId));
1015
+ const stateMachineEvent = expectLastStateMachineEvent(
1016
+ sendStateMachineEventSpy,
1017
+ TaskEvent.HOLD_SUCCESS
1018
+ );
1019
+ expect(stateMachineEvent?.taskData).toEqual(payload.data);
1020
+ sendStateMachineEventSpy.mockRestore();
607
1021
  });
608
1022
 
609
1023
  it('should emit TASK_RESUME event on AGENT_CONTACT_UNHELD event', () => {
@@ -626,11 +1040,18 @@ describe('TaskManager', () => {
626
1040
  },
627
1041
  };
628
1042
 
629
- const taskEmitSpy = jest.spyOn(taskManager.getTask(taskId), 'emit');
630
- const taskUpdateTaskDataSpy = jest.spyOn(taskManager.getTask(taskId), 'updateTaskData');
1043
+ const task = taskManager.getTask(taskId);
1044
+ const taskUpdateTaskDataSpy = task.updateTaskData as jest.Mock;
1045
+ taskUpdateTaskDataSpy.mockClear();
1046
+ const sendStateMachineEventSpy = jest.spyOn(task, 'sendStateMachineEvent');
631
1047
  webSocketManagerMock.emit('message', JSON.stringify(payload));
632
1048
  expect(taskUpdateTaskDataSpy).toHaveBeenCalledWith(payload.data);
633
- expect(taskEmitSpy).toHaveBeenCalledWith(TASK_EVENTS.TASK_RESUME, taskManager.getTask(taskId));
1049
+ const stateMachineEvent = expectLastStateMachineEvent(
1050
+ sendStateMachineEventSpy,
1051
+ TaskEvent.UNHOLD_SUCCESS
1052
+ );
1053
+ expect(stateMachineEvent?.taskData).toEqual(payload.data);
1054
+ sendStateMachineEventSpy.mockRestore();
634
1055
  });
635
1056
 
636
1057
  it('handle AGENT_CONSULT_CREATED event', () => {
@@ -643,17 +1064,16 @@ describe('TaskManager', () => {
643
1064
 
644
1065
  webSocketManagerMock.emit('message', JSON.stringify(initalPayload));
645
1066
  const task = taskManager.getTask(taskId);
646
- const taskUpdateTaskDataSpy = jest.spyOn(task, 'updateTaskData');
647
- const taskEmitSpy = jest.spyOn(task, 'emit');
1067
+ const sendStateMachineEventSpy = jest.spyOn(task, 'sendStateMachineEvent');
648
1068
 
649
1069
  webSocketManagerMock.emit('message', JSON.stringify(payload));
650
1070
 
651
- expect(taskUpdateTaskDataSpy).toHaveBeenCalledWith({
652
- ...payload.data,
653
- isConsulted: false,
654
- });
655
- expect(task.data.isConsulted).toBe(false);
656
- expect(taskEmitSpy).toHaveBeenCalledWith(TASK_EVENTS.TASK_CONSULT_CREATED, task);
1071
+ const stateMachineEvent = expectLastStateMachineEvent(
1072
+ sendStateMachineEventSpy,
1073
+ TaskEvent.CONSULT_CREATED
1074
+ );
1075
+ expect(stateMachineEvent?.taskData).toEqual({...payload.data, isConsulted: false});
1076
+ sendStateMachineEventSpy.mockRestore();
657
1077
  });
658
1078
 
659
1079
  it('handle AGENT_OFFER_CONTACT event', () => {
@@ -666,11 +1086,17 @@ describe('TaskManager', () => {
666
1086
 
667
1087
  webSocketManagerMock.emit('message', JSON.stringify(initalPayload));
668
1088
 
669
- const taskUpdateTaskDataSpy = jest.spyOn(taskManager.getTask(taskId), 'updateTaskData');
1089
+ const task = taskManager.getTask(taskId);
1090
+ const sendStateMachineEventSpy = jest.spyOn(task, 'sendStateMachineEvent');
670
1091
 
671
1092
  webSocketManagerMock.emit('message', JSON.stringify(payload));
672
1093
 
673
- expect(taskUpdateTaskDataSpy).toHaveBeenCalledWith(payload.data);
1094
+ const stateMachineEvent = expectLastStateMachineEvent(
1095
+ sendStateMachineEventSpy,
1096
+ TaskEvent.TASK_OFFERED
1097
+ );
1098
+ expect(stateMachineEvent?.taskData).toEqual(payload.data);
1099
+ sendStateMachineEventSpy.mockRestore();
674
1100
  });
675
1101
 
676
1102
  describe('Auto-Answer Functionality', () => {
@@ -680,8 +1106,8 @@ describe('TaskManager', () => {
680
1106
 
681
1107
  const task = taskManager.getTask(taskId);
682
1108
  const taskEmitSpy = jest.spyOn(task, 'emit');
683
- const taskManagerEmitSpy = jest.spyOn(taskManager, 'emit');
684
1109
  const taskAcceptSpy = jest.spyOn(task, 'accept').mockResolvedValue(undefined);
1110
+ const sendStateMachineEventSpy = jest.spyOn(task, 'sendStateMachineEvent');
685
1111
 
686
1112
  // Step 2: Trigger AGENT_OFFER_CONTACT with auto-answer
687
1113
  const autoAnswerPayload = {
@@ -705,9 +1131,14 @@ describe('TaskManager', () => {
705
1131
  // Verify accept was called
706
1132
  expect(taskAcceptSpy).toHaveBeenCalledTimes(1);
707
1133
 
708
- // Verify BOTH events were emitted
709
- expect(taskManagerEmitSpy).toHaveBeenCalledWith(TASK_EVENTS.TASK_OFFER_CONTACT, task);
1134
+ const stateMachineEvent = expectLastStateMachineEvent(
1135
+ sendStateMachineEventSpy,
1136
+ TaskEvent.TASK_OFFERED
1137
+ );
1138
+ expect(stateMachineEvent?.taskData).toEqual(autoAnswerPayload.data);
1139
+ // Verify task auto-answer event was emitted
710
1140
  expect(taskEmitSpy).toHaveBeenCalledWith(TASK_EVENTS.TASK_AUTO_ANSWERED, task);
1141
+ sendStateMachineEventSpy.mockRestore();
711
1142
  });
712
1143
 
713
1144
  it('should NOT emit TASK_AUTO_ANSWERED event when auto-answer fails', async () => {
@@ -716,7 +1147,9 @@ describe('TaskManager', () => {
716
1147
 
717
1148
  const task = taskManager.getTask(taskId);
718
1149
  const taskEmitSpy = jest.spyOn(task, 'emit');
719
- const taskAcceptSpy = jest.spyOn(task, 'accept').mockRejectedValue(new Error('Accept failed'));
1150
+ const taskAcceptSpy = jest
1151
+ .spyOn(task, 'accept')
1152
+ .mockRejectedValue(new Error('Accept failed'));
720
1153
 
721
1154
  // Step 2: Trigger AGENT_OFFER_CONTACT with auto-answer (will fail)
722
1155
  const autoAnswerPayload = {
@@ -751,6 +1184,7 @@ describe('TaskManager', () => {
751
1184
  const task = taskManager.getTask(taskId);
752
1185
  const taskEmitSpy = jest.spyOn(task, 'emit');
753
1186
  const taskAcceptSpy = jest.spyOn(task, 'accept').mockResolvedValue(undefined);
1187
+ const sendStateMachineEventSpy = jest.spyOn(task, 'sendStateMachineEvent');
754
1188
 
755
1189
  // Step 2: Trigger AGENT_OFFER_CONSULT with auto-answer
756
1190
  const consultAutoAnswerPayload = {
@@ -775,12 +1209,20 @@ describe('TaskManager', () => {
775
1209
  // Verify accept was called
776
1210
  expect(taskAcceptSpy).toHaveBeenCalledTimes(1);
777
1211
 
778
- // Verify BOTH events were emitted
779
- expect(taskEmitSpy).toHaveBeenCalledWith(TASK_EVENTS.TASK_OFFER_CONSULT, task);
1212
+ const stateMachineEvent = expectLastStateMachineEvent(
1213
+ sendStateMachineEventSpy,
1214
+ TaskEvent.OFFER_CONSULT
1215
+ );
1216
+ expect(stateMachineEvent?.taskData).toEqual({
1217
+ ...consultAutoAnswerPayload.data,
1218
+ isConsulted: true,
1219
+ });
1220
+ // Verify task auto-answer event was emitted
780
1221
  expect(taskEmitSpy).toHaveBeenCalledWith(TASK_EVENTS.TASK_AUTO_ANSWERED, task);
781
-
1222
+
782
1223
  // Verify isConsulted flag is set correctly
783
1224
  expect(task.data.isConsulted).toBe(true);
1225
+ sendStateMachineEventSpy.mockRestore();
784
1226
  });
785
1227
 
786
1228
  it('should NOT emit TASK_AUTO_ANSWERED when isAutoAnswering is false', async () => {
@@ -814,28 +1256,27 @@ describe('TaskManager', () => {
814
1256
  expect(taskAcceptSpy).not.toHaveBeenCalled();
815
1257
 
816
1258
  // Verify TASK_AUTO_ANSWERED event was NOT emitted
817
- expect(taskEmitSpy).not.toHaveBeenCalledWith(TASK_EVENTS.TASK_AUTO_ANSWERED, expect.anything());
1259
+ expect(taskEmitSpy).not.toHaveBeenCalledWith(
1260
+ TASK_EVENTS.TASK_AUTO_ANSWERED,
1261
+ expect.anything()
1262
+ );
818
1263
  });
819
1264
  });
820
1265
 
821
- it('should NOT remove OUTDIAL task from taskCollection on AGENT_OUTBOUND_FAILED when terminated (wrap-up flow)', () => {
1266
+ it('should remove OUTDIAL task from taskCollection on AGENT_OUTBOUND_FAILED when terminated', () => {
822
1267
  const task = taskManager.getTask(taskId);
823
- task.updateTaskData = jest.fn().mockImplementation((newData) => {
824
- task.data = {
825
- ...task.data,
826
- ...newData,
827
- interaction: {
828
- ...task.data.interaction,
829
- ...newData.interaction,
830
- outboundType: 'OUTDIAL',
831
- state: 'new',
832
- isTerminated: true,
833
- },
834
- };
835
- return task;
1268
+ Object.assign(task.data, {
1269
+ interaction: {
1270
+ ...task.data.interaction,
1271
+ outboundType: 'OUTDIAL',
1272
+ state: 'new',
1273
+ isTerminated: true,
1274
+ },
1275
+ agentsPendingWrapUp: ['agent-123'],
836
1276
  });
837
1277
  task.unregisterWebCallListeners = jest.fn();
838
1278
  const removeTaskSpy = jest.spyOn(taskManager, 'removeTaskFromCollection');
1279
+ const sendStateMachineEventSpy = jest.spyOn(task, 'sendStateMachineEvent');
839
1280
 
840
1281
  const payload = {
841
1282
  data: {
@@ -848,6 +1289,7 @@ describe('TaskManager', () => {
848
1289
  state: 'new',
849
1290
  isTerminated: true,
850
1291
  },
1292
+ agentsPendingWrapUp: ['agent-123'],
851
1293
  interactionId: taskId,
852
1294
  orgId: '6ecef209-9a34-4ed1-a07a-7ddd1dbe925a',
853
1295
  trackingId: '575c0ec2-618c-42af-a61c-53aeb0a221ee',
@@ -862,23 +1304,61 @@ describe('TaskManager', () => {
862
1304
 
863
1305
  webSocketManagerMock.emit('message', JSON.stringify(payload));
864
1306
 
865
- expect(taskManager.getTask(taskId)).toBeDefined();
866
- expect(removeTaskSpy).not.toHaveBeenCalled();
1307
+ webSocketManagerMock.emit('message', JSON.stringify(payload));
1308
+
1309
+ expect(taskManager.getTask(taskId)).toBeUndefined();
1310
+ expect(removeTaskSpy).toHaveBeenCalled();
1311
+ const stateMachineEvent = expectLastStateMachineEvent(
1312
+ sendStateMachineEventSpy,
1313
+ TaskEvent.OUTBOUND_FAILED
1314
+ );
1315
+ expect(stateMachineEvent?.reason).toBe('CUSTOMER_BUSY');
1316
+ sendStateMachineEventSpy.mockRestore();
867
1317
  });
868
1318
 
869
1319
  it('should emit TASK_OUTDIAL_FAILED event on AGENT_OUTBOUND_FAILED', () => {
870
1320
  const task = taskManager.getTask(taskId);
871
- task.updateTaskData = jest.fn().mockReturnValue(task);
872
- const taskEmitSpy = jest.spyOn(task, 'emit');
1321
+ const sendStateMachineEventSpy = jest.spyOn(task, 'sendStateMachineEvent');
1322
+ const payload = {
1323
+ data: {
1324
+ type: CC_EVENTS.AGENT_OUTBOUND_FAILED,
1325
+ interactionId: taskId,
1326
+ reason: 'CUSTOMER_BUSY',
1327
+ },
1328
+ };
1329
+ webSocketManagerMock.emit('message', JSON.stringify(payload));
1330
+ const stateMachineEvent = expectLastStateMachineEvent(
1331
+ sendStateMachineEventSpy,
1332
+ TaskEvent.OUTBOUND_FAILED
1333
+ );
1334
+ expect(stateMachineEvent?.reason).toBe('CUSTOMER_BUSY');
1335
+ sendStateMachineEventSpy.mockRestore();
1336
+ });
1337
+
1338
+ it('should pass taskData in OUTBOUND_FAILED event for shouldWrapUp guard evaluation', () => {
1339
+ const task = taskManager.getTask(taskId);
1340
+ const sendStateMachineEventSpy = jest.spyOn(task, 'sendStateMachineEvent');
873
1341
  const payload = {
874
1342
  data: {
875
1343
  type: CC_EVENTS.AGENT_OUTBOUND_FAILED,
876
1344
  interactionId: taskId,
877
1345
  reason: 'CUSTOMER_BUSY',
1346
+ agentsPendingWrapUp: ['agent-123'],
1347
+ interaction: {
1348
+ outboundType: 'OUTDIAL',
1349
+ isTerminated: true,
1350
+ },
878
1351
  },
879
1352
  };
880
1353
  webSocketManagerMock.emit('message', JSON.stringify(payload));
881
- expect(taskEmitSpy).toHaveBeenCalledWith(TASK_EVENTS.TASK_OUTDIAL_FAILED, 'CUSTOMER_BUSY');
1354
+ const stateMachineEvent = expectLastStateMachineEvent(
1355
+ sendStateMachineEventSpy,
1356
+ TaskEvent.OUTBOUND_FAILED
1357
+ );
1358
+ expect(stateMachineEvent?.taskData).toBeDefined();
1359
+ expect(stateMachineEvent?.taskData?.agentsPendingWrapUp).toEqual(['agent-123']);
1360
+ expect(stateMachineEvent?.taskData?.interaction?.outboundType).toBe('OUTDIAL');
1361
+ sendStateMachineEventSpy.mockRestore();
882
1362
  });
883
1363
 
884
1364
  it('should handle AGENT_OUTBOUND_FAILED gracefully when task is undefined', () => {
@@ -897,19 +1377,14 @@ describe('TaskManager', () => {
897
1377
 
898
1378
  it('should NOT remove OUTDIAL task on CONTACT_ENDED when agentsPendingWrapUp exists', () => {
899
1379
  const task = taskManager.getTask(taskId);
900
- task.updateTaskData = jest.fn().mockImplementation((newData) => {
901
- task.data = {
902
- ...task.data,
903
- ...newData,
904
- interaction: {
905
- ...task.data.interaction,
906
- outboundType: 'OUTDIAL',
907
- state: 'new',
908
- mediaType: 'telephony',
909
- },
910
- agentsPendingWrapUp: ['agent-123'],
911
- };
912
- return task;
1380
+ Object.assign(task.data, {
1381
+ interaction: {
1382
+ ...task.data.interaction,
1383
+ outboundType: 'OUTDIAL',
1384
+ state: 'new',
1385
+ mediaType: 'telephony',
1386
+ },
1387
+ agentsPendingWrapUp: ['test-agent-id'],
913
1388
  });
914
1389
  task.unregisterWebCallListeners = jest.fn();
915
1390
  const removeTaskSpy = jest.spyOn(taskManager, 'removeTaskFromCollection');
@@ -923,7 +1398,7 @@ describe('TaskManager', () => {
923
1398
  state: 'new',
924
1399
  mediaType: 'telephony',
925
1400
  },
926
- agentsPendingWrapUp: ['agent-123'],
1401
+ agentsPendingWrapUp: ['test-agent-id'],
927
1402
  },
928
1403
  };
929
1404
 
@@ -935,19 +1410,14 @@ describe('TaskManager', () => {
935
1410
 
936
1411
  it('should remove OUTDIAL task on CONTACT_ENDED when agentsPendingWrapUp is empty', () => {
937
1412
  const task = taskManager.getTask(taskId);
938
- task.updateTaskData = jest.fn().mockImplementation((newData) => {
939
- task.data = {
940
- ...task.data,
941
- ...newData,
942
- interaction: {
943
- ...task.data.interaction,
944
- outboundType: 'OUTDIAL',
945
- state: 'new',
946
- mediaType: 'telephony',
947
- },
948
- agentsPendingWrapUp: [],
949
- };
950
- return task;
1413
+ Object.assign(task.data, {
1414
+ interaction: {
1415
+ ...task.data.interaction,
1416
+ outboundType: 'OUTDIAL',
1417
+ state: 'new',
1418
+ mediaType: 'telephony',
1419
+ },
1420
+ agentsPendingWrapUp: [],
951
1421
  });
952
1422
  task.unregisterWebCallListeners = jest.fn();
953
1423
  const removeTaskSpy = jest.spyOn(taskManager, 'removeTaskFromCollection');
@@ -972,19 +1442,13 @@ describe('TaskManager', () => {
972
1442
 
973
1443
  it('should remove OUTDIAL task on CONTACT_ENDED when agentsPendingWrapUp is undefined', () => {
974
1444
  const task = taskManager.getTask(taskId);
975
- task.updateTaskData = jest.fn().mockImplementation((newData) => {
976
- task.data = {
977
- ...task.data,
978
- ...newData,
979
- interaction: {
980
- ...task.data.interaction,
981
- outboundType: 'OUTDIAL',
982
- state: 'new',
983
- mediaType: 'telephony',
984
- },
985
- // agentsPendingWrapUp is undefined
986
- };
987
- return task;
1445
+ Object.assign(task.data, {
1446
+ interaction: {
1447
+ ...task.data.interaction,
1448
+ outboundType: 'OUTDIAL',
1449
+ state: 'new',
1450
+ mediaType: 'telephony',
1451
+ },
988
1452
  });
989
1453
  task.unregisterWebCallListeners = jest.fn();
990
1454
  const removeTaskSpy = jest.spyOn(taskManager, 'removeTaskFromCollection');
@@ -1007,7 +1471,369 @@ describe('TaskManager', () => {
1007
1471
  expect(removeTaskSpy).toHaveBeenCalled();
1008
1472
  });
1009
1473
 
1010
- it('should handle CONTACT_ENDED gracefully when task is undefined', () => {
1474
+ it('should map non-wxApp agent-terminated OUTDIAL ContactEnded to CONTACT_ENDED (not OUTBOUND_FAILED)', () => {
1475
+ const task = taskManager.getTask(taskId);
1476
+ const sendStateMachineEventSpy = jest.spyOn(task, 'sendStateMachineEvent');
1477
+ const outdialFailedSpy = jest.fn();
1478
+ task.on(TASK_EVENTS.TASK_OUTDIAL_FAILED, outdialFailedSpy);
1479
+
1480
+ const payload = {
1481
+ data: {
1482
+ type: CC_EVENTS.CONTACT_ENDED,
1483
+ interactionId: taskId,
1484
+ terminatingParty: 'Agent',
1485
+ interaction: {
1486
+ outboundType: 'OUTDIAL',
1487
+ state: 'wrapUp',
1488
+ mediaType: 'telephony',
1489
+ },
1490
+ agentsPendingWrapUp: ['test-agent-id'],
1491
+ },
1492
+ };
1493
+
1494
+ webSocketManagerMock.emit('message', JSON.stringify(payload));
1495
+
1496
+ expectLastStateMachineEvent(sendStateMachineEventSpy, TaskEvent.CONTACT_ENDED);
1497
+ expect(outdialFailedSpy).not.toHaveBeenCalled();
1498
+ sendStateMachineEventSpy.mockRestore();
1499
+ });
1500
+
1501
+ it('should map non-wxApp agent-terminated OUTDIAL AgentOutboundFailed to OUTBOUND_FAILED with popup suppressed', () => {
1502
+ const task = taskManager.getTask(taskId);
1503
+ const sendStateMachineEventSpy = jest.spyOn(task, 'sendStateMachineEvent');
1504
+ const outdialFailedSpy = jest.fn();
1505
+ task.on(TASK_EVENTS.TASK_OUTDIAL_FAILED, outdialFailedSpy);
1506
+
1507
+ const payload = {
1508
+ data: {
1509
+ type: CC_EVENTS.AGENT_OUTBOUND_FAILED,
1510
+ interactionId: taskId,
1511
+ terminatingParty: 'Agent',
1512
+ reason: 'AGENT_ENDS',
1513
+ interaction: {
1514
+ outboundType: 'OUTDIAL',
1515
+ state: 'wrapUp',
1516
+ mediaType: 'telephony',
1517
+ },
1518
+ agentsPendingWrapUp: ['test-agent-id'],
1519
+ },
1520
+ };
1521
+
1522
+ webSocketManagerMock.emit('message', JSON.stringify(payload));
1523
+
1524
+ const stateMachineEvent = expectLastStateMachineEvent(
1525
+ sendStateMachineEventSpy,
1526
+ TaskEvent.OUTBOUND_FAILED
1527
+ );
1528
+ expect(stateMachineEvent?.reason).toBe('AGENT_ENDS');
1529
+ expect(stateMachineEvent?.taskData?.suppressOutdialFailedPopup).toBe(true);
1530
+ expect(outdialFailedSpy).not.toHaveBeenCalled();
1531
+ sendStateMachineEventSpy.mockRestore();
1532
+ });
1533
+
1534
+ it('should map wxApp agent-terminated OUTDIAL AgentOutboundFailed to OUTBOUND_FAILED with AGENT_ENDS', () => {
1535
+ const task = taskManager.getTask(taskId);
1536
+ task.uiControlConfig = {enableWxBetterTogether: true};
1537
+ task.data = {
1538
+ ...task.data,
1539
+ agentId: 'test-agent-id',
1540
+ interaction: {
1541
+ ...task.data.interaction,
1542
+ outboundType: 'OUTDIAL',
1543
+ participants: {
1544
+ 'test-agent-id': {
1545
+ id: 'test-agent-id',
1546
+ deviceType: 'wxApp',
1547
+ deviceId: 'device-1',
1548
+ deviceCallId: 'call-1',
1549
+ },
1550
+ },
1551
+ },
1552
+ };
1553
+ const sendStateMachineEventSpy = jest.spyOn(task, 'sendStateMachineEvent');
1554
+ const outdialFailedSpy = jest.fn();
1555
+ task.on(TASK_EVENTS.TASK_OUTDIAL_FAILED, outdialFailedSpy);
1556
+
1557
+ const payload = {
1558
+ data: {
1559
+ type: CC_EVENTS.AGENT_OUTBOUND_FAILED,
1560
+ interactionId: taskId,
1561
+ terminatingParty: 'Agent',
1562
+ reason: 'AGENT_ENDS',
1563
+ interaction: {
1564
+ outboundType: 'OUTDIAL',
1565
+ state: 'wrapUp',
1566
+ mediaType: 'telephony',
1567
+ participants: {
1568
+ 'test-agent-id': {
1569
+ id: 'test-agent-id',
1570
+ deviceType: 'wxApp',
1571
+ deviceId: 'device-1',
1572
+ deviceCallId: 'call-1',
1573
+ },
1574
+ },
1575
+ },
1576
+ agentsPendingWrapUp: ['test-agent-id'],
1577
+ },
1578
+ };
1579
+
1580
+ webSocketManagerMock.emit('message', JSON.stringify(payload));
1581
+
1582
+ const stateMachineEvent = expectLastStateMachineEvent(
1583
+ sendStateMachineEventSpy,
1584
+ TaskEvent.OUTBOUND_FAILED
1585
+ );
1586
+ expect(stateMachineEvent?.reason).toBe('AGENT_ENDS');
1587
+ expect(outdialFailedSpy).toHaveBeenCalledWith('AGENT_ENDS');
1588
+ sendStateMachineEventSpy.mockRestore();
1589
+ });
1590
+
1591
+ it('should map wxApp agent-terminated OUTDIAL ContactEnded to OUTBOUND_FAILED with AGENT_ENDS', () => {
1592
+ const task = taskManager.getTask(taskId);
1593
+ task.uiControlConfig = {enableWxBetterTogether: true};
1594
+ task.data = {
1595
+ ...task.data,
1596
+ agentId: 'test-agent-id',
1597
+ interaction: {
1598
+ ...task.data.interaction,
1599
+ outboundType: 'OUTDIAL',
1600
+ participants: {
1601
+ 'test-agent-id': {
1602
+ id: 'test-agent-id',
1603
+ deviceType: 'wxApp',
1604
+ deviceId: 'device-1',
1605
+ deviceCallId: 'call-1',
1606
+ },
1607
+ },
1608
+ },
1609
+ };
1610
+ const sendStateMachineEventSpy = jest.spyOn(task, 'sendStateMachineEvent');
1611
+ const outdialFailedSpy = jest.fn();
1612
+ task.on(TASK_EVENTS.TASK_OUTDIAL_FAILED, outdialFailedSpy);
1613
+
1614
+ const payload = {
1615
+ data: {
1616
+ type: CC_EVENTS.CONTACT_ENDED,
1617
+ interactionId: taskId,
1618
+ terminatingParty: 'Agent',
1619
+ interaction: {
1620
+ outboundType: 'OUTDIAL',
1621
+ state: 'wrapUp',
1622
+ mediaType: 'telephony',
1623
+ participants: {
1624
+ 'test-agent-id': {
1625
+ id: 'test-agent-id',
1626
+ deviceType: 'wxApp',
1627
+ deviceId: 'device-1',
1628
+ deviceCallId: 'call-1',
1629
+ },
1630
+ },
1631
+ },
1632
+ agentsPendingWrapUp: ['test-agent-id'],
1633
+ },
1634
+ };
1635
+
1636
+ webSocketManagerMock.emit('message', JSON.stringify(payload));
1637
+
1638
+ const stateMachineEvent = expectLastStateMachineEvent(
1639
+ sendStateMachineEventSpy,
1640
+ TaskEvent.OUTBOUND_FAILED
1641
+ );
1642
+ expect(stateMachineEvent?.reason).toBe('AGENT_ENDS');
1643
+ expect(stateMachineEvent?.taskData?.wrapUpRequired).toBe(true);
1644
+ expect(outdialFailedSpy).toHaveBeenCalledWith('AGENT_ENDS');
1645
+ sendStateMachineEventSpy.mockRestore();
1646
+ });
1647
+
1648
+ it('should map offer-stage OUTDIAL decline ContactEnded to CONTACT_ENDED (not wrapup)', () => {
1649
+ const task = taskManager.getTask(taskId);
1650
+ task.stateMachineService = {
1651
+ getSnapshot: () => ({value: 'OFFERED'}),
1652
+ };
1653
+ task.uiControlConfig = {enableWxBetterTogether: true, wxAppAnswerPending: false};
1654
+ task.data = {
1655
+ ...task.data,
1656
+ agentId: 'test-agent-id',
1657
+ interaction: {
1658
+ ...task.data.interaction,
1659
+ outboundType: 'OUTDIAL',
1660
+ participants: {
1661
+ 'test-agent-id': {
1662
+ id: 'test-agent-id',
1663
+ deviceType: 'wxApp',
1664
+ deviceId: 'device-1',
1665
+ deviceCallId: 'call-1',
1666
+ },
1667
+ },
1668
+ },
1669
+ };
1670
+ const sendStateMachineEventSpy = jest.spyOn(task, 'sendStateMachineEvent');
1671
+ const outdialFailedSpy = jest.fn();
1672
+ task.on(TASK_EVENTS.TASK_OUTDIAL_FAILED, outdialFailedSpy);
1673
+
1674
+ const payload = {
1675
+ data: {
1676
+ type: CC_EVENTS.CONTACT_ENDED,
1677
+ interactionId: taskId,
1678
+ terminatingParty: 'Agent',
1679
+ interaction: {
1680
+ outboundType: 'OUTDIAL',
1681
+ state: 'wrapUp',
1682
+ mediaType: 'telephony',
1683
+ },
1684
+ agentsPendingWrapUp: [],
1685
+ },
1686
+ };
1687
+
1688
+ webSocketManagerMock.emit('message', JSON.stringify(payload));
1689
+
1690
+ expectLastStateMachineEvent(sendStateMachineEventSpy, TaskEvent.CONTACT_ENDED);
1691
+ expect(outdialFailedSpy).not.toHaveBeenCalled();
1692
+ sendStateMachineEventSpy.mockRestore();
1693
+ });
1694
+
1695
+ it('should map post-accept OUTDIAL ContactEnded to OUTBOUND_FAILED when still OFFERED with wxAppAnswerPending', () => {
1696
+ const task = taskManager.getTask(taskId);
1697
+ task.stateMachineService = {
1698
+ getSnapshot: () => ({value: 'OFFERED'}),
1699
+ };
1700
+ task.uiControlConfig = {enableWxBetterTogether: true, wxAppAnswerPending: true};
1701
+ task.data = {
1702
+ ...task.data,
1703
+ agentId: 'test-agent-id',
1704
+ interaction: {
1705
+ ...task.data.interaction,
1706
+ outboundType: 'OUTDIAL',
1707
+ participants: {
1708
+ 'test-agent-id': {
1709
+ id: 'test-agent-id',
1710
+ deviceType: 'wxApp',
1711
+ deviceId: 'device-1',
1712
+ deviceCallId: 'call-1',
1713
+ },
1714
+ },
1715
+ },
1716
+ };
1717
+ const sendStateMachineEventSpy = jest.spyOn(task, 'sendStateMachineEvent');
1718
+ const outdialFailedSpy = jest.fn();
1719
+ task.on(TASK_EVENTS.TASK_OUTDIAL_FAILED, outdialFailedSpy);
1720
+
1721
+ const payload = {
1722
+ data: {
1723
+ type: CC_EVENTS.CONTACT_ENDED,
1724
+ interactionId: taskId,
1725
+ terminatingParty: 'Agent',
1726
+ interaction: {
1727
+ outboundType: 'OUTDIAL',
1728
+ state: 'offered',
1729
+ mediaType: 'telephony',
1730
+ participants: {
1731
+ 'test-agent-id': {
1732
+ id: 'test-agent-id',
1733
+ deviceType: 'wxApp',
1734
+ deviceId: 'device-1',
1735
+ deviceCallId: 'call-1',
1736
+ },
1737
+ },
1738
+ },
1739
+ },
1740
+ };
1741
+
1742
+ webSocketManagerMock.emit('message', JSON.stringify(payload));
1743
+
1744
+ const stateMachineEvent = expectLastStateMachineEvent(
1745
+ sendStateMachineEventSpy,
1746
+ TaskEvent.OUTBOUND_FAILED
1747
+ );
1748
+ expect(stateMachineEvent?.reason).toBe('AGENT_ENDS');
1749
+ expect(outdialFailedSpy).toHaveBeenCalledWith('AGENT_ENDS');
1750
+ sendStateMachineEventSpy.mockRestore();
1751
+ });
1752
+
1753
+ it('should map post-accept agent-terminated OUTDIAL ContactEnded to OUTBOUND_FAILED when CONNECTED', () => {
1754
+ const task = taskManager.getTask(taskId);
1755
+ task.stateMachineService = {
1756
+ getSnapshot: () => ({value: 'CONNECTED'}),
1757
+ };
1758
+ task.uiControlConfig = {enableWxBetterTogether: true};
1759
+ task.data = {
1760
+ ...task.data,
1761
+ agentId: 'test-agent-id',
1762
+ interaction: {
1763
+ ...task.data.interaction,
1764
+ outboundType: 'OUTDIAL',
1765
+ participants: {
1766
+ 'test-agent-id': {
1767
+ id: 'test-agent-id',
1768
+ deviceType: 'wxApp',
1769
+ deviceId: 'device-1',
1770
+ deviceCallId: 'call-1',
1771
+ },
1772
+ },
1773
+ },
1774
+ };
1775
+ const sendStateMachineEventSpy = jest.spyOn(task, 'sendStateMachineEvent');
1776
+ const outdialFailedSpy = jest.fn();
1777
+ task.on(TASK_EVENTS.TASK_OUTDIAL_FAILED, outdialFailedSpy);
1778
+
1779
+ const payload = {
1780
+ data: {
1781
+ type: CC_EVENTS.CONTACT_ENDED,
1782
+ interactionId: taskId,
1783
+ terminatingParty: 'Agent',
1784
+ interaction: {
1785
+ outboundType: 'OUTDIAL',
1786
+ state: 'wrapUp',
1787
+ mediaType: 'telephony',
1788
+ participants: {
1789
+ 'test-agent-id': {
1790
+ id: 'test-agent-id',
1791
+ deviceType: 'wxApp',
1792
+ deviceId: 'device-1',
1793
+ deviceCallId: 'call-1',
1794
+ },
1795
+ },
1796
+ },
1797
+ agentsPendingWrapUp: ['test-agent-id'],
1798
+ },
1799
+ };
1800
+
1801
+ webSocketManagerMock.emit('message', JSON.stringify(payload));
1802
+
1803
+ const stateMachineEvent = expectLastStateMachineEvent(
1804
+ sendStateMachineEventSpy,
1805
+ TaskEvent.OUTBOUND_FAILED
1806
+ );
1807
+ expect(stateMachineEvent?.reason).toBe('AGENT_ENDS');
1808
+ expect(outdialFailedSpy).toHaveBeenCalledWith('AGENT_ENDS');
1809
+ sendStateMachineEventSpy.mockRestore();
1810
+ });
1811
+
1812
+ it('should map customer-terminated OUTDIAL ContactEnded to CONTACT_ENDED (no AGENT_ENDS remap)', () => {
1813
+ const task = taskManager.getTask(taskId);
1814
+ const sendStateMachineEventSpy = jest.spyOn(task, 'sendStateMachineEvent');
1815
+
1816
+ const payload = {
1817
+ data: {
1818
+ type: CC_EVENTS.CONTACT_ENDED,
1819
+ interactionId: taskId,
1820
+ terminatingParty: 'Customer',
1821
+ interaction: {
1822
+ outboundType: 'OUTDIAL',
1823
+ state: 'new',
1824
+ mediaType: 'telephony',
1825
+ },
1826
+ agentsPendingWrapUp: ['test-agent-id'],
1827
+ },
1828
+ };
1829
+
1830
+ webSocketManagerMock.emit('message', JSON.stringify(payload));
1831
+
1832
+ expectLastStateMachineEvent(sendStateMachineEventSpy, TaskEvent.CONTACT_ENDED);
1833
+ sendStateMachineEventSpy.mockRestore();
1834
+ });
1835
+
1836
+ it('should handle CONTACT_ENDED gracefully when task is undefined', () => {
1011
1837
  const payload = {
1012
1838
  data: {
1013
1839
  type: CC_EVENTS.CONTACT_ENDED,
@@ -1025,19 +1851,13 @@ describe('TaskManager', () => {
1025
1851
 
1026
1852
  it('should remove OUTDIAL task from taskCollection on AGENT_CONTACT_ASSIGN_FAILED when NOT terminated (user-declined)', () => {
1027
1853
  const task = taskManager.getTask(taskId);
1028
- task.updateTaskData = jest.fn().mockImplementation((newData) => {
1029
- task.data = {
1030
- ...task.data,
1031
- ...newData,
1032
- interaction: {
1033
- ...task.data.interaction,
1034
- ...newData.interaction,
1035
- outboundType: 'OUTDIAL',
1036
- state: 'new',
1037
- isTerminated: false,
1038
- },
1039
- };
1040
- return task;
1854
+ Object.assign(task.data, {
1855
+ interaction: {
1856
+ ...task.data.interaction,
1857
+ outboundType: 'OUTDIAL',
1858
+ state: 'new',
1859
+ isTerminated: false,
1860
+ },
1041
1861
  });
1042
1862
  task.unregisterWebCallListeners = jest.fn();
1043
1863
  const removeTaskSpy = jest.spyOn(taskManager, 'removeTaskFromCollection');
@@ -1081,20 +1901,16 @@ describe('TaskManager', () => {
1081
1901
 
1082
1902
  webSocketManagerMock.emit('message', JSON.stringify(initalPayload));
1083
1903
  const task = taskManager.getTask(taskId);
1084
- task.updateTaskData = jest.fn().mockImplementation((newData) => {
1085
- task.data = {...newData, isConsulted: true};
1086
- return task;
1087
- });
1088
- const taskEmitSpy = jest.spyOn(task, 'emit');
1904
+ const sendStateMachineEventSpy = jest.spyOn(task, 'sendStateMachineEvent');
1089
1905
 
1090
1906
  webSocketManagerMock.emit('message', JSON.stringify(payload));
1091
1907
 
1092
- expect(task.updateTaskData).toHaveBeenCalledWith({
1093
- ...payload.data,
1094
- isConsulted: true,
1095
- });
1096
- expect(task.data.isConsulted).toBe(true);
1097
- expect(taskEmitSpy).toHaveBeenCalledWith(TASK_EVENTS.TASK_OFFER_CONSULT, task);
1908
+ const stateMachineEvent = expectLastStateMachineEvent(
1909
+ sendStateMachineEventSpy,
1910
+ TaskEvent.OFFER_CONSULT
1911
+ );
1912
+ expect(stateMachineEvent?.taskData).toEqual({...payload.data, isConsulted: true});
1913
+ sendStateMachineEventSpy.mockRestore();
1098
1914
  });
1099
1915
 
1100
1916
  it('should emit TASK_CONSULT_ACCEPTED event on AGENT_CONSULTING event', () => {
@@ -1113,19 +1929,18 @@ describe('TaskManager', () => {
1113
1929
  };
1114
1930
 
1115
1931
  webSocketManagerMock.emit('message', JSON.stringify(initalPayload));
1116
- taskManager.getTask(taskId).updateTaskData = jest.fn().mockImplementation((newData) => {
1117
- taskManager.getTask(taskId).data = {...newData, isConsulted: true};
1118
- return taskManager.getTask(taskId);
1119
- });
1120
1932
 
1121
- const taskEmitSpy = jest.spyOn(taskManager.getTask(taskId), 'emit');
1933
+ const task = taskManager.getTask(taskId);
1934
+ const sendStateMachineEventSpy = jest.spyOn(task, 'sendStateMachineEvent');
1935
+
1122
1936
  webSocketManagerMock.emit('message', JSON.stringify(initialConsultingPayload));
1123
1937
  webSocketManagerMock.emit('message', JSON.stringify(consultingPayload));
1124
- expect(taskManager.getTask(taskId).data.isConsulted).toBe(true);
1125
- expect(taskEmitSpy).toHaveBeenCalledWith(
1126
- TASK_EVENTS.TASK_CONSULT_ACCEPTED,
1127
- taskManager.getTask(taskId)
1938
+ const stateMachineEvent = expectLastStateMachineEvent(
1939
+ sendStateMachineEventSpy,
1940
+ TaskEvent.CONSULTING_ACTIVE
1128
1941
  );
1942
+ expect(stateMachineEvent?.taskData).toEqual(consultingPayload.data);
1943
+ sendStateMachineEventSpy.mockRestore();
1129
1944
  });
1130
1945
 
1131
1946
  it('should emit TASK_CONSULT_ENDED event on AGENT_CONSULT_ENDED event', () => {
@@ -1137,11 +1952,16 @@ describe('TaskManager', () => {
1137
1952
  };
1138
1953
 
1139
1954
  webSocketManagerMock.emit('message', JSON.stringify(initalPayload));
1140
- const taskEmitSpy = jest.spyOn(taskManager.getTask(taskId), 'emit');
1141
- const taskUpdateTaskDataSpy = jest.spyOn(taskManager.getTask(taskId), 'updateTaskData');
1955
+ taskManager.getTask(taskId).data.isConsulted = true;
1956
+ const task = taskManager.getTask(taskId);
1957
+ const sendStateMachineEventSpy = jest.spyOn(task, 'sendStateMachineEvent');
1142
1958
  webSocketManagerMock.emit('message', JSON.stringify(payload));
1143
- expect(taskUpdateTaskDataSpy).toHaveBeenCalledWith(payload.data);
1144
- expect(taskEmitSpy).toHaveBeenCalledWith(TASK_EVENTS.TASK_CONSULT_END, taskManager.getTask(taskId));
1959
+ const stateMachineEvent = expectLastStateMachineEvent(
1960
+ sendStateMachineEventSpy,
1961
+ TaskEvent.CONSULT_END
1962
+ );
1963
+ expect(stateMachineEvent?.taskData).toEqual(payload.data);
1964
+ sendStateMachineEventSpy.mockRestore();
1145
1965
  });
1146
1966
 
1147
1967
  it('should emit TASK_CONSULT_ENDED event and remove currentTask when on AGENT_CONSULT_ENDED event when requested for a consult', () => {
@@ -1154,19 +1974,14 @@ describe('TaskManager', () => {
1154
1974
 
1155
1975
  webSocketManagerMock.emit('message', JSON.stringify(initalPayload));
1156
1976
 
1157
- taskManager.getTask(taskId).updateTaskData = jest.fn().mockImplementation((newData) => {
1158
- taskManager.getTask(taskId).data = {...newData, isConsulted: true};
1159
- return taskManager.getTask(taskId);
1160
- });
1977
+ taskManager.getTask(taskId).data.isConsulted = true;
1161
1978
  const task = taskManager.getTask(taskId);
1162
1979
 
1163
- const taskEmitSpy = jest.spyOn(taskManager.getTask(taskId), 'emit');
1164
- const taskUpdateTaskDataSpy = jest.spyOn(taskManager.getTask(taskId), 'updateTaskData');
1980
+ const sendStateMachineEventSpy = jest.spyOn(task, 'sendStateMachineEvent');
1165
1981
  webSocketManagerMock.emit('message', JSON.stringify(payload));
1166
- expect(taskUpdateTaskDataSpy).toHaveBeenCalledWith(payload.data);
1167
- expect(taskEmitSpy).toHaveBeenCalledWith(CC_EVENTS.AGENT_CONSULT_ENDED, payload.data);
1168
- expect(taskEmitSpy).toHaveBeenCalledWith(TASK_EVENTS.TASK_CONSULT_END, task);
1982
+ expectLastStateMachineEvent(sendStateMachineEventSpy, TaskEvent.CONSULT_END);
1169
1983
  expect(taskManager.getTask(taskId)).toBeUndefined(); // Ensure task is removed from the task collection after the consult ends
1984
+ sendStateMachineEventSpy.mockRestore();
1170
1985
  });
1171
1986
 
1172
1987
  it('should emit TASK_CANCELLED event on AGENT_CTQ_CANCELLED event', () => {
@@ -1178,14 +1993,15 @@ describe('TaskManager', () => {
1178
1993
  };
1179
1994
 
1180
1995
  webSocketManagerMock.emit('message', JSON.stringify(initalPayload));
1181
- const taskEmitSpy = jest.spyOn(taskManager.getTask(taskId), 'emit');
1182
- const taskUpdateTaskDataSpy = jest.spyOn(taskManager.getTask(taskId), 'updateTaskData');
1996
+ const task = taskManager.getTask(taskId);
1997
+ const sendStateMachineEventSpy = jest.spyOn(task, 'sendStateMachineEvent');
1183
1998
  webSocketManagerMock.emit('message', JSON.stringify(payload));
1184
- expect(taskUpdateTaskDataSpy).toHaveBeenCalledWith(payload.data);
1185
- expect(taskEmitSpy).toHaveBeenCalledWith(
1186
- TASK_EVENTS.TASK_CONSULT_QUEUE_CANCELLED,
1187
- taskManager.getTask(taskId)
1999
+ const stateMachineEvent = expectLastStateMachineEvent(
2000
+ sendStateMachineEventSpy,
2001
+ TaskEvent.CTQ_CANCEL
1188
2002
  );
2003
+ expect(stateMachineEvent?.taskData).toEqual(payload.data);
2004
+ sendStateMachineEventSpy.mockRestore();
1189
2005
  });
1190
2006
 
1191
2007
  it('should handle AGENT_CONSULT_FAILED event', () => {
@@ -1199,9 +2015,15 @@ describe('TaskManager', () => {
1199
2015
  webSocketManagerMock.emit('message', JSON.stringify(initalPayload));
1200
2016
 
1201
2017
  // Always spy on the updated task object after CONTACT_RESERVED is emitted
1202
- const taskUpdateTaskDataSpy = jest.spyOn(taskManager.getTask(taskId), 'updateTaskData');
2018
+ const task = taskManager.getTask(taskId);
2019
+ const sendStateMachineEventSpy = jest.spyOn(task, 'sendStateMachineEvent');
1203
2020
  webSocketManagerMock.emit('message', JSON.stringify(payload));
1204
- expect(taskUpdateTaskDataSpy).toHaveBeenCalledWith(payload.data);
2021
+ const stateMachineEvent = expectLastStateMachineEvent(
2022
+ sendStateMachineEventSpy,
2023
+ TaskEvent.CONSULT_FAILED
2024
+ );
2025
+ expect(stateMachineEvent?.taskData).toEqual(payload.data);
2026
+ sendStateMachineEventSpy.mockRestore();
1205
2027
  });
1206
2028
 
1207
2029
  it('should emit TASK_CONSULT_QUEUE_FAILED on AGENT_CTQ_CANCEL_FAILED event', () => {
@@ -1213,14 +2035,15 @@ describe('TaskManager', () => {
1213
2035
  };
1214
2036
 
1215
2037
  webSocketManagerMock.emit('message', JSON.stringify(initalPayload));
1216
- const taskEmitSpy = jest.spyOn(taskManager.getTask(taskId), 'emit');
1217
- const taskUpdateTaskDataSpy = jest.spyOn(taskManager.getTask(taskId), 'updateTaskData');
2038
+ const task = taskManager.getTask(taskId);
2039
+ const sendStateMachineEventSpy = jest.spyOn(task, 'sendStateMachineEvent');
1218
2040
  webSocketManagerMock.emit('message', JSON.stringify(payload));
1219
- expect(taskUpdateTaskDataSpy).toHaveBeenCalledWith(payload.data);
1220
- expect(taskEmitSpy).toHaveBeenCalledWith(
1221
- TASK_EVENTS.TASK_CONSULT_QUEUE_FAILED,
1222
- taskManager.getTask(taskId)
2041
+ const stateMachineEvent = expectLastStateMachineEvent(
2042
+ sendStateMachineEventSpy,
2043
+ TaskEvent.CTQ_CANCEL_FAILED
1223
2044
  );
2045
+ expect(stateMachineEvent?.taskData).toEqual(payload.data);
2046
+ sendStateMachineEventSpy.mockRestore();
1224
2047
  });
1225
2048
 
1226
2049
  it('should emit TASK_REJECT event on AGENT_CONTACT_OFFER_RONA event', () => {
@@ -1263,15 +2086,14 @@ describe('TaskManager', () => {
1263
2086
  };
1264
2087
 
1265
2088
  taskManager.taskCollection[taskId] = taskManager.getTask(taskId);
1266
- const taskEmitSpy = jest.spyOn(taskManager.getTask(taskId), 'emit');
1267
- const metricsTrackSpy = jest.spyOn(taskManager.metricsManager, 'trackEvent');
2089
+ const task = taskManager.getTask(taskId);
2090
+ const sendStateMachineEventSpy = jest.spyOn(task, 'sendStateMachineEvent');
1268
2091
 
1269
2092
  webSocketManagerMock.emit('message', JSON.stringify(ronaPayload));
1270
2093
 
1271
- expect(taskEmitSpy).toHaveBeenCalledWith(TASK_EVENTS.TASK_REJECT, ronaPayload.data.reason);
1272
- // Verify the correct metric event name is used for AGENT_CONTACT_OFFER_RONA
1273
- expect(metricsTrackSpy).toHaveBeenCalled();
1274
- expect(metricsTrackSpy.mock.calls[0][0]).toBe('Agent RONA');
2094
+ const stateMachineEvent = expectLastStateMachineEvent(sendStateMachineEventSpy, TaskEvent.RONA);
2095
+ expect(stateMachineEvent?.reason).toBe(ronaPayload.data.reason);
2096
+ sendStateMachineEventSpy.mockRestore();
1275
2097
  });
1276
2098
 
1277
2099
  it('should emit TASK_REJECT event on AGENT_CONTACT_ASSIGN_FAILED event', () => {
@@ -1315,17 +2137,16 @@ describe('TaskManager', () => {
1315
2137
 
1316
2138
  taskManager.taskCollection[taskId] = taskManager.getTask(taskId);
1317
2139
  const task = taskManager.getTask(taskId);
1318
- const taskEmitSpy = jest.spyOn(task, 'emit');
1319
- const taskUpdateDataSpy = jest.spyOn(task, 'updateTaskData');
1320
- const metricsTrackSpy = jest.spyOn(taskManager.metricsManager, 'trackEvent');
2140
+ const sendStateMachineEventSpy = jest.spyOn(task, 'sendStateMachineEvent');
1321
2141
 
1322
2142
  webSocketManagerMock.emit('message', JSON.stringify(assignFailedPayload));
1323
2143
 
1324
- expect(taskUpdateDataSpy).toHaveBeenCalledWith(assignFailedPayload.data);
1325
- expect(taskEmitSpy).toHaveBeenCalledWith(TASK_EVENTS.TASK_REJECT, assignFailedPayload.data.reason);
1326
- // Verify the correct metric event name is used for AGENT_CONTACT_ASSIGN_FAILED
1327
- expect(metricsTrackSpy).toHaveBeenCalled();
1328
- expect(metricsTrackSpy.mock.calls[0][0]).toBe('Agent Contact Assign Failed');
2144
+ const stateMachineEvent = expectLastStateMachineEvent(
2145
+ sendStateMachineEventSpy,
2146
+ TaskEvent.ASSIGN_FAILED
2147
+ );
2148
+ expect(stateMachineEvent?.reason).toBe(assignFailedPayload.data.reason);
2149
+ sendStateMachineEventSpy.mockRestore();
1329
2150
  });
1330
2151
 
1331
2152
  it('should remove currentTask from taskCollection on AGENT_WRAPPEDUP event', () => {
@@ -1348,11 +2169,12 @@ describe('TaskManager', () => {
1348
2169
 
1349
2170
  webSocketManagerMock.emit('message', JSON.stringify(initalPayload));
1350
2171
  const task = taskManager.getTask(taskId);
1351
- const taskEmitSpy = jest.spyOn(task, 'emit');
2172
+ const sendStateMachineEventSpy = jest.spyOn(task, 'sendStateMachineEvent');
1352
2173
 
1353
2174
  webSocketManagerMock.emit('message', JSON.stringify(payload));
1354
2175
 
1355
- expect(taskEmitSpy).toHaveBeenCalledWith(TASK_EVENTS.TASK_WRAPPEDUP, task);
2176
+ expectLastStateMachineEvent(sendStateMachineEventSpy, TaskEvent.WRAPUP_COMPLETE);
2177
+ sendStateMachineEventSpy.mockRestore();
1356
2178
  expect(taskManager.getTask(taskId)).toBeUndefined();
1357
2179
  });
1358
2180
 
@@ -1378,7 +2200,8 @@ describe('TaskManager', () => {
1378
2200
  };
1379
2201
 
1380
2202
  const taskEmitSpy = jest.spyOn(taskManager.getTask(taskId), 'emit');
1381
- const taskUpdateTaskDataSpy = jest.spyOn(taskManager.getTask(taskId), 'updateTaskData');
2203
+ const taskUpdateTaskDataSpy = taskManager.getTask(taskId).updateTaskData as jest.Mock;
2204
+ taskUpdateTaskDataSpy.mockClear();
1382
2205
  webSocketManagerMock.emit('message', JSON.stringify(payload));
1383
2206
  expect(taskEmitSpy).not.toHaveBeenCalled();
1384
2207
  expect(taskUpdateTaskDataSpy).not.toHaveBeenCalled();
@@ -1387,7 +2210,9 @@ describe('TaskManager', () => {
1387
2210
  it('should emit TASK_CONSULTING event when agent is consulting', () => {
1388
2211
  webSocketManagerMock.emit('message', JSON.stringify(initalPayload));
1389
2212
  taskManager.getTask(taskId).data.isConsulted = false;
1390
- const taskEmitSpy = jest.spyOn(taskManager.getTask(taskId), 'emit');
2213
+ const task = taskManager.getTask(taskId);
2214
+ const sendStateMachineEventSpy = jest.spyOn(task, 'sendStateMachineEvent');
2215
+ const taskManagerEmitSpy = jest.spyOn(taskManager, 'emit');
1391
2216
  const consultingPayload = {
1392
2217
  data: {
1393
2218
  ...initalPayload.data,
@@ -1396,13 +2221,21 @@ describe('TaskManager', () => {
1396
2221
  },
1397
2222
  };
1398
2223
  webSocketManagerMock.emit('message', JSON.stringify(consultingPayload));
1399
- expect(taskEmitSpy).toHaveBeenCalledWith(CC_EVENTS.AGENT_CONSULTING, consultingPayload.data);
1400
- expect(taskEmitSpy).toHaveBeenCalledWith(TASK_EVENTS.TASK_CONSULTING, taskManager.getTask(taskId));
2224
+ expectLastStateMachineEvent(sendStateMachineEventSpy, TaskEvent.CONSULTING_ACTIVE);
2225
+ expect(taskManagerEmitSpy).toHaveBeenCalledWith(
2226
+ TASK_EVENTS.TASK_MULTI_LOGIN_HYDRATE,
2227
+ taskManager.getTask(taskId)
2228
+ );
2229
+ sendStateMachineEventSpy.mockRestore();
1401
2230
  });
1402
2231
 
1403
- it('should emit TASK_END event on AGENT_CONTACT_UNASSIGNED', () => {
2232
+ it('should update task data on AGENT_CONTACT_UNASSIGNED', () => {
1404
2233
  webSocketManagerMock.emit('message', JSON.stringify(initalPayload));
1405
- const taskEmitSpy = jest.spyOn(taskManager.getTask(taskId), 'emit');
2234
+ const task = taskManager.getTask(taskId);
2235
+ const sendStateMachineEventSpy = task.sendStateMachineEvent as jest.Mock;
2236
+ sendStateMachineEventSpy.mockClear();
2237
+ const updateTaskDataSpy = task.updateTaskData as jest.Mock;
2238
+ updateTaskDataSpy.mockClear();
1406
2239
  const unassignedPayload = {
1407
2240
  data: {
1408
2241
  type: CC_EVENTS.AGENT_CONTACT_UNASSIGNED,
@@ -1420,8 +2253,76 @@ describe('TaskManager', () => {
1420
2253
  },
1421
2254
  };
1422
2255
  webSocketManagerMock.emit('message', JSON.stringify(unassignedPayload));
1423
- expect(taskEmitSpy).toHaveBeenCalledWith(CC_EVENTS.AGENT_CONTACT_UNASSIGNED, unassignedPayload.data);
1424
- expect(taskEmitSpy).toHaveBeenCalledWith(TASK_EVENTS.TASK_END, taskManager.getTask(taskId));
2256
+ expect(sendStateMachineEventSpy).not.toHaveBeenCalled();
2257
+ expect(updateTaskDataSpy).toHaveBeenCalledWith(unassignedPayload.data);
2258
+ });
2259
+
2260
+ it('preserves consult fields from state context during consulting payload refresh', () => {
2261
+ const task = createMockTask({
2262
+ ...taskDataMock,
2263
+ interaction: {state: 'consulting'} as any,
2264
+ interactionId: taskId,
2265
+ });
2266
+ task.stateMachineService = {
2267
+ getSnapshot: () => ({
2268
+ value: 'CONSULTING',
2269
+ context: {
2270
+ consultDestinationAgentId: 'agent-preserved',
2271
+ consultDestinationType: 'agent',
2272
+ },
2273
+ }),
2274
+ };
2275
+
2276
+ const incomingTaskData = {
2277
+ ...taskDataMock,
2278
+ interaction: {state: 'consulting'} as any,
2279
+ interactionId: taskId,
2280
+ destAgentId: null,
2281
+ destinationType: null,
2282
+ };
2283
+
2284
+ (taskManager as any).updateTaskData(task, incomingTaskData);
2285
+
2286
+ expect(task.updateTaskData).toHaveBeenCalledWith(
2287
+ expect.objectContaining({
2288
+ destAgentId: 'agent-preserved',
2289
+ destinationType: 'agent',
2290
+ })
2291
+ );
2292
+ });
2293
+
2294
+ it('does not preserve stale consult fields once consult is no longer active', () => {
2295
+ const task = createMockTask({
2296
+ ...taskDataMock,
2297
+ interaction: {state: 'connected'} as any,
2298
+ interactionId: taskId,
2299
+ });
2300
+ task.stateMachineService = {
2301
+ getSnapshot: () => ({
2302
+ value: 'CONNECTED',
2303
+ context: {
2304
+ consultDestinationAgentId: 'agent-stale',
2305
+ consultDestinationType: 'agent',
2306
+ },
2307
+ }),
2308
+ };
2309
+
2310
+ const incomingTaskData = {
2311
+ ...taskDataMock,
2312
+ interaction: {state: 'connected'} as any,
2313
+ interactionId: taskId,
2314
+ destAgentId: null,
2315
+ destinationType: null,
2316
+ };
2317
+
2318
+ (taskManager as any).updateTaskData(task, incomingTaskData);
2319
+
2320
+ expect(task.updateTaskData).toHaveBeenCalledWith(
2321
+ expect.objectContaining({
2322
+ destAgentId: null,
2323
+ destinationType: null,
2324
+ })
2325
+ );
1425
2326
  });
1426
2327
 
1427
2328
  it('should handle chat interaction and emit TASK_INCOMING immediately', () => {
@@ -1429,20 +2330,20 @@ describe('TaskManager', () => {
1429
2330
  const chatPayload = {
1430
2331
  data: {
1431
2332
  ...initalPayload.data,
1432
- interaction: { mediaType: 'chat' },
2333
+ interaction: {mediaType: 'chat'},
1433
2334
  },
1434
2335
  };
1435
2336
 
1436
- const taskIncomingSpy = jest.spyOn(taskManager, 'emit');
1437
-
1438
2337
  // Simulate receiving a chat task
1439
2338
  webSocketManagerMock.emit('message', JSON.stringify(chatPayload));
1440
2339
 
1441
- // For non-telephony tasks, TASK_INCOMING should be emitted immediately
1442
- expect(taskIncomingSpy).toHaveBeenCalledWith(
1443
- TASK_EVENTS.TASK_INCOMING,
1444
- taskManager.getTask(chatPayload.data.interactionId)
2340
+ const chatTask = taskManager.getTask(chatPayload.data.interactionId);
2341
+ const sendStateMachineEventSpy = chatTask.sendStateMachineEvent as jest.Mock;
2342
+ const stateMachineEvent = expectLastStateMachineEvent(
2343
+ sendStateMachineEventSpy,
2344
+ TaskEvent.TASK_INCOMING
1445
2345
  );
2346
+ expect(stateMachineEvent?.taskData).toEqual(chatPayload.data);
1446
2347
  expect(taskManager.getAllTasks()).toHaveProperty(chatPayload.data.interactionId);
1447
2348
  });
1448
2349
 
@@ -1451,20 +2352,20 @@ describe('TaskManager', () => {
1451
2352
  const emailPayload = {
1452
2353
  data: {
1453
2354
  ...initalPayload.data,
1454
- interaction: { mediaType: 'email' },
2355
+ interaction: {mediaType: 'email'},
1455
2356
  },
1456
2357
  };
1457
2358
 
1458
- const taskIncomingSpy = jest.spyOn(taskManager, 'emit');
1459
-
1460
2359
  // Simulate receiving an email task
1461
2360
  webSocketManagerMock.emit('message', JSON.stringify(emailPayload));
1462
2361
 
1463
- // For non-telephony tasks, TASK_INCOMING should be emitted immediately
1464
- expect(taskIncomingSpy).toHaveBeenCalledWith(
1465
- TASK_EVENTS.TASK_INCOMING,
1466
- taskManager.getTask(emailPayload.data.interactionId)
2362
+ const emailTask = taskManager.getTask(emailPayload.data.interactionId);
2363
+ const sendStateMachineEventSpy = emailTask.sendStateMachineEvent as jest.Mock;
2364
+ const stateMachineEvent = expectLastStateMachineEvent(
2365
+ sendStateMachineEventSpy,
2366
+ TaskEvent.TASK_INCOMING
1467
2367
  );
2368
+ expect(stateMachineEvent?.taskData).toEqual(emailPayload.data);
1468
2369
  expect(taskManager.getAllTasks()).toHaveProperty(emailPayload.data.interactionId);
1469
2370
  });
1470
2371
 
@@ -1474,18 +2375,16 @@ describe('TaskManager', () => {
1474
2375
  data: {
1475
2376
  ...initalPayload.data,
1476
2377
  type: CC_EVENTS.AGENT_CONTACT_RESERVED,
1477
- interaction: { mediaType: 'chat' },
2378
+ interaction: {mediaType: 'chat'},
1478
2379
  },
1479
2380
  };
1480
-
1481
- const taskIncomingSpy = jest.spyOn(taskManager, 'emit');
2381
+
1482
2382
  webSocketManagerMock.emit('message', JSON.stringify(chatReservedPayload));
1483
-
1484
- expect(taskIncomingSpy).toHaveBeenCalledWith(
1485
- TASK_EVENTS.TASK_INCOMING,
1486
- taskManager.getTask(chatReservedPayload.data.interactionId)
1487
- );
1488
-
2383
+ const task = taskManager.getTask(chatReservedPayload.data.interactionId);
2384
+ const sendStateMachineEventSpy = task.sendStateMachineEvent as jest.Mock;
2385
+
2386
+ expectLastStateMachineEvent(sendStateMachineEventSpy, TaskEvent.TASK_INCOMING);
2387
+
1489
2388
  // 2. Chat task is assigned
1490
2389
  const chatAssignedPayload = {
1491
2390
  data: {
@@ -1493,26 +2392,26 @@ describe('TaskManager', () => {
1493
2392
  type: CC_EVENTS.AGENT_CONTACT_ASSIGNED,
1494
2393
  },
1495
2394
  };
1496
-
1497
- const task = taskManager.getTask(chatReservedPayload.data.interactionId);
1498
- const taskEmitSpy = jest.spyOn(task, 'emit');
1499
-
2395
+
1500
2396
  webSocketManagerMock.emit('message', JSON.stringify(chatAssignedPayload));
1501
-
1502
- expect(taskEmitSpy).toHaveBeenCalledWith(TASK_EVENTS.TASK_ASSIGNED, task);
1503
-
2397
+
2398
+ expectLastStateMachineEvent(sendStateMachineEventSpy, TaskEvent.ASSIGN);
2399
+
1504
2400
  // 3. Chat task is ended with state 'new' to trigger cleanup
1505
2401
  const chatEndedPayload = {
1506
2402
  data: {
1507
2403
  ...chatReservedPayload.data,
1508
2404
  type: CC_EVENTS.CONTACT_ENDED,
1509
- interaction: { mediaType: 'chat', state: 'new' }, // Change to 'new' state
2405
+ interaction: {mediaType: 'chat', state: 'new'}, // Change to 'new' state
2406
+ wrapUpRequired: false,
1510
2407
  },
1511
2408
  };
1512
2409
 
2410
+ // Simulate state on the task to allow cleanup logic
2411
+ task.data.interaction.state = 'new';
1513
2412
  webSocketManagerMock.emit('message', JSON.stringify(chatEndedPayload));
1514
2413
 
1515
- expect(taskEmitSpy).toHaveBeenCalledWith(TASK_EVENTS.TASK_END, task);
2414
+ expectLastStateMachineEvent(sendStateMachineEventSpy, TaskEvent.CONTACT_ENDED);
1516
2415
  // Verify task is removed since it was in a 'new' state
1517
2416
  expect(taskManager.getTask(chatReservedPayload.data.interactionId)).toBeUndefined();
1518
2417
  });
@@ -1523,40 +2422,46 @@ describe('TaskManager', () => {
1523
2422
  data: {
1524
2423
  ...initalPayload.data,
1525
2424
  interactionId: 'telephony-task-id',
1526
- interaction: { mediaType: 'telephony' },
2425
+ interaction: {mediaType: 'telephony'},
1527
2426
  },
1528
2427
  };
1529
-
2428
+
1530
2429
  const chatPayload = {
1531
2430
  data: {
1532
2431
  ...initalPayload.data,
1533
2432
  interactionId: 'chat-task-id',
1534
- interaction: { mediaType: 'chat' },
2433
+ interaction: {mediaType: 'chat'},
1535
2434
  },
1536
2435
  };
1537
-
2436
+
1538
2437
  const emailPayload = {
1539
2438
  data: {
1540
2439
  ...initalPayload.data,
1541
2440
  interactionId: 'email-task-id',
1542
- interaction: { mediaType: 'email' },
2441
+ interaction: {mediaType: 'email'},
1543
2442
  },
1544
2443
  };
1545
-
2444
+
1546
2445
  // Simulate receiving tasks of different types
1547
2446
  webSocketManagerMock.emit('message', JSON.stringify(telephonyPayload));
1548
2447
  webSocketManagerMock.emit('message', JSON.stringify(chatPayload));
1549
2448
  webSocketManagerMock.emit('message', JSON.stringify(emailPayload));
1550
-
2449
+
1551
2450
  // Verify all tasks are in the collection
1552
2451
  expect(taskManager.getAllTasks()).toHaveProperty(telephonyPayload.data.interactionId);
1553
2452
  expect(taskManager.getAllTasks()).toHaveProperty(chatPayload.data.interactionId);
1554
2453
  expect(taskManager.getAllTasks()).toHaveProperty(emailPayload.data.interactionId);
1555
-
2454
+
1556
2455
  // Verify the task media types are correctly set
1557
- expect(taskManager.getTask(telephonyPayload.data.interactionId).data.interaction.mediaType).toBe('telephony');
1558
- expect(taskManager.getTask(chatPayload.data.interactionId).data.interaction.mediaType).toBe('chat');
1559
- expect(taskManager.getTask(emailPayload.data.interactionId).data.interaction.mediaType).toBe('email');
2456
+ expect(
2457
+ taskManager.getTask(telephonyPayload.data.interactionId).data.interaction.mediaType
2458
+ ).toBe('telephony');
2459
+ expect(taskManager.getTask(chatPayload.data.interactionId).data.interaction.mediaType).toBe(
2460
+ 'chat'
2461
+ );
2462
+ expect(taskManager.getTask(emailPayload.data.interactionId).data.interaction.mediaType).toBe(
2463
+ 'email'
2464
+ );
1560
2465
  });
1561
2466
 
1562
2467
  it('should properly handle one task ending when multiple tasks are active', () => {
@@ -1565,102 +2470,102 @@ describe('TaskManager', () => {
1565
2470
  data: {
1566
2471
  ...initalPayload.data,
1567
2472
  interactionId: 'task-id-1',
1568
- interaction: { mediaType: 'telephony' },
2473
+ interaction: {mediaType: 'telephony'},
1569
2474
  },
1570
2475
  };
1571
-
2476
+
1572
2477
  const task2Payload = {
1573
2478
  data: {
1574
2479
  ...initalPayload.data,
1575
2480
  interactionId: 'task-id-2',
1576
- interaction: { mediaType: 'chat' },
2481
+ interaction: {mediaType: 'chat'},
1577
2482
  },
1578
2483
  };
1579
-
2484
+
1580
2485
  const task3Payload = {
1581
2486
  data: {
1582
2487
  ...initalPayload.data,
1583
2488
  interactionId: 'task-id-3',
1584
- interaction: { mediaType: 'email' },
2489
+ interaction: {mediaType: 'email'},
1585
2490
  },
1586
2491
  };
1587
-
2492
+
1588
2493
  // Initialize all tasks
1589
2494
  webSocketManagerMock.emit('message', JSON.stringify(task1Payload));
1590
2495
  webSocketManagerMock.emit('message', JSON.stringify(task2Payload));
1591
2496
  webSocketManagerMock.emit('message', JSON.stringify(task3Payload));
1592
-
2497
+
1593
2498
  // Verify all tasks are in the collection
1594
2499
  expect(taskManager.getAllTasks()).toHaveProperty(task1Payload.data.interactionId);
1595
2500
  expect(taskManager.getAllTasks()).toHaveProperty(task2Payload.data.interactionId);
1596
2501
  expect(taskManager.getAllTasks()).toHaveProperty(task3Payload.data.interactionId);
1597
-
1598
- // Create spies for all tasks
1599
- const task1EmitSpy = jest.spyOn(taskManager.getTask(task1Payload.data.interactionId), 'emit');
1600
- const task2EmitSpy = jest.spyOn(taskManager.getTask(task2Payload.data.interactionId), 'emit');
1601
- const task3EmitSpy = jest.spyOn(taskManager.getTask(task3Payload.data.interactionId), 'emit');
1602
-
1603
- // Store reference to task2 before it gets removed
2502
+
1604
2503
  const task2 = taskManager.getTask(task2Payload.data.interactionId);
1605
-
2504
+ const task2SendStateMachineEventSpy = task2.sendStateMachineEvent as jest.Mock;
2505
+
1606
2506
  // End only the second task (chat task)
1607
2507
  const chatEndedPayload = {
1608
2508
  data: {
1609
2509
  ...task2Payload.data,
1610
2510
  type: CC_EVENTS.CONTACT_ENDED,
1611
- interaction: { mediaType: 'chat', state: 'new' }, // Using 'new' to trigger cleanup
2511
+ interaction: {mediaType: 'chat', state: 'new'}, // Using 'new' to trigger cleanup
2512
+ wrapUpRequired: false,
1612
2513
  },
1613
2514
  };
1614
-
2515
+
2516
+ task2.data.interaction.state = 'new';
1615
2517
  webSocketManagerMock.emit('message', JSON.stringify(chatEndedPayload));
1616
-
1617
- // Verify only task2 emitted TASK_END
1618
- expect(task1EmitSpy).not.toHaveBeenCalledWith(TASK_EVENTS.TASK_END);
1619
- expect(task2EmitSpy).toHaveBeenCalledWith(TASK_EVENTS.TASK_END, task2);
1620
- expect(task3EmitSpy).not.toHaveBeenCalledWith(TASK_EVENTS.TASK_END);
1621
-
2518
+
2519
+ const firstEndEvent = expectLastStateMachineEvent(
2520
+ task2SendStateMachineEventSpy,
2521
+ TaskEvent.CONTACT_ENDED
2522
+ );
2523
+ expect(firstEndEvent?.taskData).toEqual(chatEndedPayload.data);
2524
+
1622
2525
  // Verify task2 was removed from collection (since state was 'new')
1623
2526
  expect(taskManager.getTask(task2Payload.data.interactionId)).toBeUndefined();
1624
-
2527
+
1625
2528
  // Verify other tasks remain in the collection
1626
2529
  expect(taskManager.getTask(task1Payload.data.interactionId)).toBeDefined();
1627
2530
  expect(taskManager.getTask(task3Payload.data.interactionId)).toBeDefined();
1628
-
2531
+
1629
2532
  // Store reference to task3 before we end it
1630
2533
  const task3 = taskManager.getTask(task3Payload.data.interactionId);
1631
-
2534
+ const task3SendStateMachineEventSpy = task3.sendStateMachineEvent as jest.Mock;
2535
+
1632
2536
  // Now end task3 with a state that doesn't trigger cleanup
1633
2537
  const emailEndedPayload = {
1634
2538
  data: {
1635
2539
  ...task3Payload.data,
1636
2540
  type: CC_EVENTS.CONTACT_ENDED,
1637
- interaction: { mediaType: 'email', state: 'connected' }, // Using 'connected' to NOT trigger cleanup
2541
+ interaction: {mediaType: 'email', state: 'connected'}, // Using 'connected' to NOT trigger cleanup
2542
+ wrapUpRequired: true,
2543
+ agentsPendingWrapUp: ['test-agent-id'],
1638
2544
  },
1639
2545
  };
1640
-
2546
+
2547
+ task3.data.interaction.state = 'connected';
1641
2548
  webSocketManagerMock.emit('message', JSON.stringify(emailEndedPayload));
1642
-
1643
- // Verify task3 emitted TASK_END
1644
- expect(task3EmitSpy).toHaveBeenCalledWith(TASK_EVENTS.TASK_END, task3);
1645
-
2549
+
2550
+ const secondEndEvent = expectLastStateMachineEvent(
2551
+ task3SendStateMachineEventSpy,
2552
+ TaskEvent.CONTACT_ENDED
2553
+ );
2554
+ expect(secondEndEvent?.taskData).toEqual(emailEndedPayload.data);
2555
+
1646
2556
  // Verify task3 is still in collection (since state was 'connected')
1647
2557
  expect(taskManager.getTask(task3Payload.data.interactionId)).toBeDefined();
1648
-
2558
+
1649
2559
  // Verify task1 remains unaffected
1650
- expect(task1EmitSpy).not.toHaveBeenCalledWith(TASK_EVENTS.TASK_END);
1651
2560
  expect(taskManager.getTask(task1Payload.data.interactionId)).toBeDefined();
1652
2561
  });
1653
2562
 
1654
- it('should emit TASK_END event on AGENT_VTEAM_TRANSFERRED event', () => {
2563
+ it('should emit TRANSFER_SUCCESS event on AGENT_VTEAM_TRANSFERRED event', () => {
1655
2564
  // First create a task by emitting the initial payload
1656
2565
  webSocketManagerMock.emit('message', JSON.stringify(initalPayload));
1657
-
1658
- // Get a reference to the task from taskCollection
1659
2566
  const task = taskManager.getTask(taskId);
1660
-
1661
- // Now spy on the task's emit method
1662
- const taskEmitSpy = jest.spyOn(task, 'emit');
1663
-
2567
+ const sendStateMachineEventSpy = jest.spyOn(task, 'sendStateMachineEvent');
2568
+
1664
2569
  const vteamTransferredPayload = {
1665
2570
  data: {
1666
2571
  type: CC_EVENTS.AGENT_VTEAM_TRANSFERRED,
@@ -1677,34 +2582,39 @@ describe('TaskManager', () => {
1677
2582
  queueMgr: initalPayload.data.queueMgr,
1678
2583
  },
1679
2584
  };
1680
-
2585
+
1681
2586
  // No need to explicitly set the task in the collection as it's already there
1682
2587
  // from the initial message processing
1683
-
2588
+
1684
2589
  webSocketManagerMock.emit('message', JSON.stringify(vteamTransferredPayload));
1685
-
1686
- // Check that task.emit was called with TASK_END event
1687
- expect(taskEmitSpy).toHaveBeenCalledWith(TASK_EVENTS.TASK_END, task);
1688
-
2590
+
2591
+ // Check that the state machine received the END event
2592
+ expectLastStateMachineEvent(sendStateMachineEventSpy, TaskEvent.TRANSFER_SUCCESS);
2593
+ sendStateMachineEventSpy.mockRestore();
2594
+
1689
2595
  // The task should still exist in the collection based on current implementation
1690
2596
  expect(taskManager.getTask(taskId)).toBeDefined();
1691
2597
  });
1692
2598
 
1693
2599
  it('should update task data on AGENT_WRAPUP event', () => {
1694
2600
  const payload = {
1695
- data: {
1696
- type: CC_EVENTS.AGENT_WRAPUP,
1697
- interactionId: taskId,
1698
- wrapUpRequired: true,
1699
- },
2601
+ data: {
2602
+ type: CC_EVENTS.AGENT_WRAPUP,
2603
+ interactionId: taskId,
2604
+ wrapUpRequired: true,
2605
+ },
1700
2606
  };
1701
2607
  const task = taskManager.getTask(taskId);
1702
- const updateSpy = jest.spyOn(task, 'updateTaskData').mockImplementation((data) => {
1703
- task.data = { ...(task.data || {}), ...(data || {}) };
1704
- return task;
1705
- });
2608
+ const sendStateMachineEventSpy = jest.spyOn(task, 'sendStateMachineEvent');
1706
2609
  webSocketManagerMock.emit('message', JSON.stringify(payload));
1707
- expect(updateSpy).toHaveBeenCalledWith(payload.data);
2610
+ expect(sendStateMachineEventSpy).toHaveBeenCalled();
2611
+ const stateMachineEvent = sendStateMachineEventSpy.mock.calls.at(-1)?.[0];
2612
+ expect(stateMachineEvent?.type).toBe(TaskEvent.TASK_WRAPUP);
2613
+ expect(stateMachineEvent?.taskData).toEqual({
2614
+ ...payload.data,
2615
+ wrapUpRequired: true,
2616
+ });
2617
+ sendStateMachineEventSpy.mockRestore();
1708
2618
  });
1709
2619
 
1710
2620
  it('should not attempt cleanup twice when AGENT_CONTACT_UNASSIGNED is followed by AGENT_WRAPUP', () => {
@@ -1716,7 +2626,7 @@ describe('TaskManager', () => {
1716
2626
  data: {
1717
2627
  type: CC_EVENTS.AGENT_CONTACT_UNASSIGNED,
1718
2628
  agentId: initalPayload.data.agentId,
1719
- interaction: { mediaType: 'telephony' },
2629
+ interaction: {mediaType: 'telephony'},
1720
2630
  interactionId: initalPayload.data.interactionId,
1721
2631
  orgId: initalPayload.data.orgId,
1722
2632
  trackingId: initalPayload.data.trackingId,
@@ -1735,7 +2645,7 @@ describe('TaskManager', () => {
1735
2645
  data: {
1736
2646
  type: CC_EVENTS.AGENT_WRAPUP,
1737
2647
  interactionId: taskId,
1738
- interaction: { mediaType: 'telephony' },
2648
+ interaction: {mediaType: 'telephony'},
1739
2649
  },
1740
2650
  };
1741
2651
  webSocketManagerMock.emit('message', JSON.stringify(wrapupPayload));
@@ -1752,7 +2662,7 @@ describe('TaskManager', () => {
1752
2662
  data: {
1753
2663
  type: CC_EVENTS.AGENT_VTEAM_TRANSFERRED,
1754
2664
  agentId: initalPayload.data.agentId,
1755
- interaction: { mediaType: 'telephony' },
2665
+ interaction: {mediaType: 'telephony'},
1756
2666
  interactionId: initalPayload.data.interactionId,
1757
2667
  orgId: initalPayload.data.orgId,
1758
2668
  trackingId: initalPayload.data.trackingId,
@@ -1771,7 +2681,7 @@ describe('TaskManager', () => {
1771
2681
  data: {
1772
2682
  type: CC_EVENTS.AGENT_WRAPUP,
1773
2683
  interactionId: taskId,
1774
- interaction: { mediaType: 'telephony' },
2684
+ interaction: {mediaType: 'telephony'},
1775
2685
  },
1776
2686
  };
1777
2687
  webSocketManagerMock.emit('message', JSON.stringify(wrapupPayload));
@@ -1780,704 +2690,1085 @@ describe('TaskManager', () => {
1780
2690
  });
1781
2691
 
1782
2692
  describe('should emit appropriate task events for recording events', () => {
1783
- ['PAUSED', 'PAUSE_FAILED', 'RESUMED', 'RESUME_FAILED'].forEach((suffix) => {
2693
+ const eventMap: Record<string, TaskEvent | null> = {
2694
+ STARTED: TaskEvent.RECORDING_STARTED,
2695
+ PAUSED: TaskEvent.PAUSE_RECORDING,
2696
+ PAUSE_FAILED: null,
2697
+ RESUMED: TaskEvent.RESUME_RECORDING,
2698
+ RESUME_FAILED: null,
2699
+ };
2700
+
2701
+ ['STARTED', 'PAUSED', 'PAUSE_FAILED', 'RESUMED', 'RESUME_FAILED'].forEach((suffix) => {
1784
2702
  const ccEvent = CC_EVENTS[`CONTACT_RECORDING_${suffix}`];
1785
- const taskEvent = TASK_EVENTS[`TASK_RECORDING_${suffix}`];
1786
- it(`should emit ${taskEvent} on ${ccEvent} event`, () => {
2703
+ const expectedTaskEvent = eventMap[suffix];
2704
+ it(`should ${expectedTaskEvent ? 'send' : 'not send'} ${
2705
+ expectedTaskEvent ?? 'a'
2706
+ } state machine event on ${ccEvent} event`, () => {
1787
2707
  const payload = {data: {...initalPayload.data, type: ccEvent}};
1788
2708
  webSocketManagerMock.emit('message', JSON.stringify(initalPayload));
1789
2709
  const task = taskManager.getTask(taskId);
1790
- const spy = jest.spyOn(task, 'emit');
2710
+ const sendStateMachineEventSpy = task.sendStateMachineEvent as jest.Mock;
2711
+ sendStateMachineEventSpy.mockClear();
1791
2712
 
1792
2713
  webSocketManagerMock.emit('message', JSON.stringify(payload));
1793
- expect(spy).toHaveBeenCalledWith(taskEvent, task);
2714
+ if (expectedTaskEvent) {
2715
+ const stateMachineEvent = expectLastStateMachineEvent(
2716
+ sendStateMachineEventSpy,
2717
+ expectedTaskEvent
2718
+ );
2719
+ expect(stateMachineEvent?.taskData).toEqual(payload.data);
2720
+ } else {
2721
+ expect(sendStateMachineEventSpy).not.toHaveBeenCalled();
2722
+ }
1794
2723
  });
1795
2724
  });
1796
- });
2725
+ });
1797
2726
 
1798
- describe('Conference event handling', () => {
1799
- let task;
1800
- const agentId = '723a8ffb-a26e-496d-b14a-ff44fb83b64f';
1801
-
1802
- beforeEach(() => {
1803
- // Set the agentId on taskManager before tests run
1804
- taskManager.setAgentId(agentId);
1805
-
1806
- task = {
1807
- data: { interactionId: taskId },
1808
- emit: jest.fn(),
1809
- updateTaskData: jest.fn().mockImplementation((updatedData) => {
1810
- // Mock the updateTaskData method to actually update task.data
1811
- task.data = { ...task.data, ...updatedData };
1812
- return task;
1813
- }),
1814
- };
1815
- taskManager.taskCollection[taskId] = task;
2727
+ describe('Contact owner propagation', () => {
2728
+ const currentAgentId = 'test-agent-id';
2729
+ const previousOwnerId = 'previous-owner-id';
2730
+ const promotedOwnerId = 'promoted-owner-id';
2731
+
2732
+ const createConferenceTaskData = (
2733
+ interactionId: string,
2734
+ owner: string,
2735
+ mainInteractionId = taskId
2736
+ ) => ({
2737
+ ...taskDataMock,
2738
+ agentId: currentAgentId,
2739
+ interactionId,
2740
+ owner,
2741
+ interaction: {
2742
+ mediaType: 'telephony',
2743
+ interactionId,
2744
+ mainInteractionId,
2745
+ owner,
2746
+ participants: {
2747
+ [currentAgentId]: {
2748
+ id: currentAgentId,
2749
+ pType: 'Agent',
2750
+ hasJoined: true,
2751
+ hasLeft: false,
2752
+ },
2753
+ [previousOwnerId]: {
2754
+ id: previousOwnerId,
2755
+ pType: 'Agent',
2756
+ hasJoined: true,
2757
+ hasLeft: false,
2758
+ },
2759
+ [promotedOwnerId]: {
2760
+ id: promotedOwnerId,
2761
+ pType: 'Agent',
2762
+ hasJoined: true,
2763
+ hasLeft: false,
2764
+ },
2765
+ },
2766
+ media: {
2767
+ [mainInteractionId]: {
2768
+ mediaResourceId: mainInteractionId,
2769
+ mediaType: 'telephony',
2770
+ mType: 'mainCall',
2771
+ participants: [currentAgentId, previousOwnerId, promotedOwnerId],
2772
+ },
2773
+ },
2774
+ },
1816
2775
  });
1817
2776
 
1818
- it('should handle AGENT_CONSULT_CONFERENCED event', () => {
1819
- const payload = {
1820
- data: {
1821
- type: CC_EVENTS.AGENT_CONSULT_CONFERENCED,
1822
- interactionId: taskId,
1823
- isConferencing: true,
2777
+ const installTask = (data) => {
2778
+ const task = createMockTask(data);
2779
+ taskManager.taskCollection = {[data.interactionId]: task};
2780
+ (taskManager as any).setupTaskListeners(task);
2781
+
2782
+ return task;
2783
+ };
2784
+
2785
+ const createPromotedAgentOwnerChangeData = (
2786
+ interactionId = 'promoted-child-interaction-id',
2787
+ mainInteractionId = taskId
2788
+ ) => {
2789
+ const data = createConferenceTaskData(interactionId, currentAgentId, mainInteractionId);
2790
+
2791
+ data.interaction.state = 'conference';
2792
+
2793
+ return {...data, type: CC_EVENTS.CONTACT_OWNER_CHANGED};
2794
+ };
2795
+
2796
+ const clearTasksAndFactoryHistory = () => {
2797
+ taskManager.taskCollection = {};
2798
+ (TaskFactory.createTask as jest.Mock).mockClear();
2799
+ };
2800
+
2801
+ it('emits one hydrate when ContactUpdated changes interaction.owner', () => {
2802
+ const task = installTask(createConferenceTaskData(taskId, previousOwnerId));
2803
+ const hydrateHandler = jest.fn();
2804
+ taskManager.on(TASK_EVENTS.TASK_HYDRATE, hydrateHandler);
2805
+
2806
+ webSocketManagerMock.emit(
2807
+ 'message',
2808
+ JSON.stringify({
2809
+ data: {
2810
+ ...createConferenceTaskData(taskId, promotedOwnerId),
2811
+ type: CC_EVENTS.CONTACT_UPDATED,
2812
+ },
2813
+ })
2814
+ );
2815
+
2816
+ expectLastStateMachineEvent(task.sendStateMachineEvent, TaskEvent.CONTACT_OWNER_CHANGED);
2817
+ expect(task.data.interaction.owner).toBe(promotedOwnerId);
2818
+ expect(hydrateHandler).toHaveBeenCalledTimes(1);
2819
+ expect(hydrateHandler).toHaveBeenCalledWith(task);
2820
+ });
2821
+
2822
+ it.each([
2823
+ ['the same owner', previousOwnerId],
2824
+ ['no owner', undefined],
2825
+ ['a blank owner', ' '],
2826
+ ])('keeps ContactUpdated data-only when it carries %s', (_description, owner) => {
2827
+ const task = installTask(createConferenceTaskData(taskId, previousOwnerId));
2828
+ const hydrateHandler = jest.fn();
2829
+ taskManager.on(TASK_EVENTS.TASK_HYDRATE, hydrateHandler);
2830
+ const interaction = createConferenceTaskData(taskId, previousOwnerId).interaction;
2831
+ if (owner === undefined) {
2832
+ delete interaction.owner;
2833
+ } else {
2834
+ interaction.owner = owner;
2835
+ }
2836
+
2837
+ webSocketManagerMock.emit(
2838
+ 'message',
2839
+ JSON.stringify({
2840
+ data: {
2841
+ ...createConferenceTaskData(taskId, previousOwnerId),
2842
+ type: CC_EVENTS.CONTACT_UPDATED,
2843
+ interaction,
2844
+ },
2845
+ })
2846
+ );
2847
+
2848
+ expectLastStateMachineEvent(task.sendStateMachineEvent, TaskEvent.CONTACT_UPDATED);
2849
+ expect(hydrateHandler).not.toHaveBeenCalled();
2850
+ });
2851
+
2852
+ it('correlates child-keyed ContactOwnerChanged and keeps the main task identity', () => {
2853
+ const childTaskId = 'surviving-child-id';
2854
+ const promotedChildId = 'promoted-child-id';
2855
+ const task = installTask(
2856
+ createConferenceTaskData(childTaskId, previousOwnerId, taskId)
2857
+ );
2858
+ const hydrateHandler = jest.fn();
2859
+ taskManager.on(TASK_EVENTS.TASK_HYDRATE, hydrateHandler);
2860
+
2861
+ const ownerChangeData = createConferenceTaskData(
2862
+ promotedChildId,
2863
+ promotedOwnerId,
2864
+ taskId
2865
+ );
2866
+ ownerChangeData.interaction.interactionId = taskId;
2867
+ webSocketManagerMock.emit(
2868
+ 'message',
2869
+ JSON.stringify({
2870
+ data: {...ownerChangeData, type: CC_EVENTS.CONTACT_OWNER_CHANGED},
2871
+ })
2872
+ );
2873
+
2874
+ expectLastStateMachineEvent(task.sendStateMachineEvent, TaskEvent.CONTACT_OWNER_CHANGED);
2875
+ expect(task.data.interactionId).toBe(taskId);
2876
+ expect(task.data.interaction.owner).toBe(promotedOwnerId);
2877
+ expect(taskManager.taskCollection[childTaskId]).toBeUndefined();
2878
+ expect(taskManager.taskCollection[promotedChildId]).toBeUndefined();
2879
+ expect(taskManager.taskCollection[taskId]).toBe(task);
2880
+ expect(Object.values(taskManager.taskCollection).filter((entry) => entry === task)).toHaveLength(
2881
+ 1
2882
+ );
2883
+ expect(hydrateHandler).toHaveBeenCalledTimes(1);
2884
+ });
2885
+
2886
+ it('uses the incoming promotion snapshot to correlate a stale child-keyed task', () => {
2887
+ const childTaskId = 'stale-child-interaction-id';
2888
+ const staleTaskData = createConferenceTaskData(childTaskId, previousOwnerId, taskId);
2889
+ staleTaskData.interaction.media = {
2890
+ [childTaskId]: {
2891
+ mediaResourceId: childTaskId,
2892
+ mediaType: 'telephony',
2893
+ mType: 'consult',
2894
+ participants: [currentAgentId, promotedOwnerId],
1824
2895
  },
1825
2896
  };
2897
+ const task = installTask(staleTaskData);
2898
+ const hydrateHandler = jest.fn();
2899
+ taskManager.on(TASK_EVENTS.TASK_HYDRATE, hydrateHandler);
1826
2900
 
1827
- webSocketManagerMock.emit('message', JSON.stringify(payload));
2901
+ webSocketManagerMock.emit(
2902
+ 'message',
2903
+ JSON.stringify({data: createPromotedAgentOwnerChangeData()})
2904
+ );
1828
2905
 
1829
- expect(task.data.isConferencing).toBe(true);
1830
- expect(task.emit).toHaveBeenCalledWith(TASK_EVENTS.TASK_CONFERENCE_STARTED, task);
2906
+ expectLastStateMachineEvent(task.sendStateMachineEvent, TaskEvent.CONTACT_OWNER_CHANGED);
2907
+ expect(task.data.interaction.owner).toBe(currentAgentId);
2908
+ expect(taskManager.taskCollection[childTaskId]).toBeUndefined();
2909
+ expect(taskManager.taskCollection[taskId]).toBe(task);
2910
+ expect(TaskFactory.createTask).not.toHaveBeenCalled();
2911
+ expect(hydrateHandler).toHaveBeenCalledTimes(1);
1831
2912
  });
1832
2913
 
1833
- it('should handle AGENT_CONSULT_CONFERENCING event', () => {
1834
- const payload = {
1835
- data: {
1836
- type: CC_EVENTS.AGENT_CONSULT_CONFERENCING,
1837
- interactionId: taskId,
1838
- isConferencing: true,
1839
- },
2914
+ it('reuses a task stored under the incoming main-call media key', () => {
2915
+ const mainMediaInteractionId = 'main-media-interaction-id';
2916
+ const promotedChildId = 'promoted-child-without-main-id';
2917
+ const task = installTask(
2918
+ createConferenceTaskData(
2919
+ mainMediaInteractionId,
2920
+ previousOwnerId,
2921
+ mainMediaInteractionId
2922
+ )
2923
+ );
2924
+ const hydrateHandler = jest.fn();
2925
+ taskManager.on(TASK_EVENTS.TASK_HYDRATE, hydrateHandler);
2926
+ const ownerChangeData = createPromotedAgentOwnerChangeData(
2927
+ promotedChildId,
2928
+ mainMediaInteractionId
2929
+ );
2930
+ delete ownerChangeData.interaction.mainInteractionId;
2931
+
2932
+ webSocketManagerMock.emit('message', JSON.stringify({data: ownerChangeData}));
2933
+
2934
+ expect(task.sendStateMachineEvent).toHaveBeenCalledTimes(1);
2935
+ expectLastStateMachineEvent(task.sendStateMachineEvent, TaskEvent.CONTACT_OWNER_CHANGED);
2936
+ expect(TaskFactory.createTask).not.toHaveBeenCalled();
2937
+ expect(taskManager.taskCollection[mainMediaInteractionId]).toBe(task);
2938
+ expect(taskManager.taskCollection[promotedChildId]).toBeUndefined();
2939
+ expect(Object.values(taskManager.taskCollection)).toEqual([task]);
2940
+ expect(task.listenerCount(TASK_EVENTS.TASK_HYDRATE)).toBe(1);
2941
+ expect(hydrateHandler).toHaveBeenCalledTimes(1);
2942
+ expect(hydrateHandler).toHaveBeenCalledWith(task);
2943
+ });
2944
+
2945
+ it('prefers the exact task for ContactOwnerChanged over related child tasks', () => {
2946
+ const exactTask = installTask(createConferenceTaskData(taskId, previousOwnerId));
2947
+ const childTaskId = 'related-child-id';
2948
+ const childTask = createMockTask(
2949
+ createConferenceTaskData(childTaskId, previousOwnerId, taskId)
2950
+ );
2951
+ taskManager.taskCollection[childTaskId] = childTask;
2952
+
2953
+ webSocketManagerMock.emit(
2954
+ 'message',
2955
+ JSON.stringify({
2956
+ data: {
2957
+ ...createConferenceTaskData(taskId, promotedOwnerId),
2958
+ type: CC_EVENTS.CONTACT_OWNER_CHANGED,
2959
+ },
2960
+ })
2961
+ );
2962
+
2963
+ expectLastStateMachineEvent(exactTask.sendStateMachineEvent, TaskEvent.CONTACT_OWNER_CHANGED);
2964
+ expect(childTask.sendStateMachineEvent).not.toHaveBeenCalled();
2965
+ });
2966
+
2967
+ it('ignores a related ContactOwnerChanged task whose current agent is consult-only', () => {
2968
+ const childTaskId = 'consult-only-child-id';
2969
+ const data = createConferenceTaskData(childTaskId, previousOwnerId, taskId);
2970
+ data.interaction.media[taskId].participants = [previousOwnerId, promotedOwnerId];
2971
+ data.interaction.media[childTaskId] = {
2972
+ mediaResourceId: childTaskId,
2973
+ mediaType: 'telephony',
2974
+ mType: 'consult',
2975
+ participants: [currentAgentId, promotedOwnerId],
1840
2976
  };
2977
+ const task = installTask(data);
1841
2978
 
1842
- webSocketManagerMock.emit('message', JSON.stringify(payload));
2979
+ webSocketManagerMock.emit(
2980
+ 'message',
2981
+ JSON.stringify({
2982
+ data: {
2983
+ ...createConferenceTaskData('unmatched-child-id', promotedOwnerId, taskId),
2984
+ type: CC_EVENTS.CONTACT_OWNER_CHANGED,
2985
+ },
2986
+ })
2987
+ );
1843
2988
 
1844
- expect(task.data.isConferencing).toBe(true);
1845
- // No task event emission for conferencing - only for conferenced (completed)
1846
- expect(task.emit).not.toHaveBeenCalledWith(TASK_EVENTS.TASK_CONFERENCE_STARTED, task);
2989
+ expect(task.sendStateMachineEvent).not.toHaveBeenCalled();
2990
+ expect(taskManager.taskCollection[childTaskId]).toBe(task);
2991
+ expect(TaskFactory.createTask).not.toHaveBeenCalled();
1847
2992
  });
1848
2993
 
1849
- it('should handle AGENT_CONSULT_CONFERENCE_FAILED event', () => {
1850
- const payload = {
1851
- data: {
1852
- type: CC_EVENTS.AGENT_CONSULT_CONFERENCE_FAILED,
1853
- interactionId: taskId,
1854
- reason: 'Network error',
2994
+ it.each([
2995
+ [
2996
+ 'the current agent is marked departed',
2997
+ (data) => {
2998
+ data.interaction.participants[currentAgentId].hasLeft = true;
1855
2999
  },
3000
+ ],
3001
+ [
3002
+ 'the current agent participant is missing',
3003
+ (data) => {
3004
+ delete data.interaction.participants[currentAgentId];
3005
+ },
3006
+ ],
3007
+ [
3008
+ 'the current agent is present only on a consult leg',
3009
+ (data) => {
3010
+ data.interaction.media[taskId].mType = 'consult';
3011
+ },
3012
+ ],
3013
+ ])('does not recover a promoted task when %s', (_description, mutatePayload) => {
3014
+ clearTasksAndFactoryHistory();
3015
+ const ownerChangeData = createPromotedAgentOwnerChangeData();
3016
+ mutatePayload(ownerChangeData);
3017
+
3018
+ webSocketManagerMock.emit('message', JSON.stringify({data: ownerChangeData}));
3019
+
3020
+ expect(TaskFactory.createTask).not.toHaveBeenCalled();
3021
+ expect(taskManager.getAllTasks()).toEqual({});
3022
+ });
3023
+
3024
+ it('ignores ambiguous active main-call matches for ContactOwnerChanged', () => {
3025
+ const firstTask = createMockTask(
3026
+ createConferenceTaskData('first-child-id', previousOwnerId, taskId)
3027
+ );
3028
+ const secondTask = createMockTask(
3029
+ createConferenceTaskData('second-child-id', previousOwnerId, taskId)
3030
+ );
3031
+ taskManager.taskCollection = {
3032
+ 'first-child-id': firstTask,
3033
+ 'second-child-id': secondTask,
1856
3034
  };
1857
3035
 
1858
- webSocketManagerMock.emit('message', JSON.stringify(payload));
3036
+ const ownerChangeData = createConferenceTaskData(
3037
+ 'unmatched-child-id',
3038
+ promotedOwnerId,
3039
+ taskId
3040
+ );
3041
+ ownerChangeData.interaction.interactionId = 'unmatched-child-id';
3042
+ webSocketManagerMock.emit(
3043
+ 'message',
3044
+ JSON.stringify({
3045
+ data: {...ownerChangeData, type: CC_EVENTS.CONTACT_OWNER_CHANGED},
3046
+ })
3047
+ );
1859
3048
 
1860
- expect(task.data.reason).toBe('Network error');
1861
- // No event emission expected for failure - handled by contact method promise rejection
3049
+ expect(firstTask.sendStateMachineEvent).not.toHaveBeenCalled();
3050
+ expect(secondTask.sendStateMachineEvent).not.toHaveBeenCalled();
1862
3051
  });
1863
3052
 
1864
- it('should handle PARTICIPANT_JOINED_CONFERENCE event', () => {
1865
- const payload = {
1866
- data: {
1867
- type: CC_EVENTS.PARTICIPANT_JOINED_CONFERENCE,
1868
- interactionId: taskId,
1869
- participantId: 'new-participant-123',
1870
- participantType: 'agent',
1871
- interaction: {
1872
- participants: {
1873
- [agentId]: { pType: 'Agent', hasLeft: false },
1874
- 'new-participant-123': { pType: 'Agent', hasLeft: false },
1875
- },
1876
- media: {
1877
- [taskId]: {
1878
- mType: 'mainCall',
1879
- participants: [agentId, 'new-participant-123'],
1880
- },
1881
- },
1882
- },
1883
- },
3053
+ it('does not recover a new task when multiple stale related tasks become eligible from the payload', () => {
3054
+ const createStaleRelatedTask = (interactionId: string) => {
3055
+ const data = createConferenceTaskData(interactionId, previousOwnerId, taskId);
3056
+ data.interaction.media[taskId].participants = [previousOwnerId, promotedOwnerId];
3057
+
3058
+ return createMockTask(data);
1884
3059
  };
3060
+ const firstTask = createStaleRelatedTask('first-stale-child-id');
3061
+ const secondTask = createStaleRelatedTask('second-stale-child-id');
3062
+ taskManager.taskCollection = {
3063
+ 'first-stale-child-id': firstTask,
3064
+ 'second-stale-child-id': secondTask,
3065
+ };
3066
+ (TaskFactory.createTask as jest.Mock).mockClear();
1885
3067
 
1886
- webSocketManagerMock.emit('message', JSON.stringify(payload));
3068
+ webSocketManagerMock.emit(
3069
+ 'message',
3070
+ JSON.stringify({data: createPromotedAgentOwnerChangeData()})
3071
+ );
1887
3072
 
1888
- expect(task.data.participantId).toBe('new-participant-123');
1889
- expect(task.data.participantType).toBe('agent');
1890
- // No specific task event emission for participant joined - just data update
3073
+ expect(firstTask.sendStateMachineEvent).not.toHaveBeenCalled();
3074
+ expect(secondTask.sendStateMachineEvent).not.toHaveBeenCalled();
3075
+ expect(TaskFactory.createTask).not.toHaveBeenCalled();
3076
+ expect(Object.keys(taskManager.taskCollection)).toEqual([
3077
+ 'first-stale-child-id',
3078
+ 'second-stale-child-id',
3079
+ ]);
1891
3080
  });
1892
3081
 
1893
- it('should call updateTaskData only once for PARTICIPANT_JOINED_CONFERENCE with pre-calculated isConferenceInProgress', () => {
1894
- const payload = {
1895
- data: {
1896
- type: CC_EVENTS.PARTICIPANT_JOINED_CONFERENCE,
1897
- interactionId: taskId,
1898
- participantId: 'new-agent-789',
1899
- interaction: {
1900
- participants: {
1901
- [agentId]: { pType: 'Agent', hasLeft: false },
1902
- 'agent-2': { pType: 'Agent', hasLeft: false },
1903
- 'new-agent-789': { pType: 'Agent', hasLeft: false },
1904
- 'customer-1': { pType: 'Customer', hasLeft: false },
1905
- },
1906
- media: {
1907
- [taskId]: {
1908
- mType: 'mainCall',
1909
- participants: [agentId, 'agent-2', 'new-agent-789', 'customer-1'],
1910
- },
1911
- },
1912
- },
1913
- },
3082
+ it('ignores ContactOwnerChanged when different direct nested IDs match active tasks', () => {
3083
+ const firstTaskId = 'direct-main-id';
3084
+ const secondTaskId = 'direct-parent-id';
3085
+ const firstTask = createMockTask(
3086
+ createConferenceTaskData(firstTaskId, previousOwnerId, firstTaskId)
3087
+ );
3088
+ const secondTask = createMockTask(
3089
+ createConferenceTaskData(secondTaskId, previousOwnerId, secondTaskId)
3090
+ );
3091
+ taskManager.taskCollection = {
3092
+ [firstTaskId]: firstTask,
3093
+ [secondTaskId]: secondTask,
1914
3094
  };
1915
3095
 
1916
- const updateTaskDataSpy = jest.spyOn(task, 'updateTaskData');
1917
-
1918
- webSocketManagerMock.emit('message', JSON.stringify(payload));
3096
+ const ownerChangeData = createConferenceTaskData(
3097
+ 'unmatched-child-id',
3098
+ promotedOwnerId,
3099
+ firstTaskId
3100
+ );
3101
+ ownerChangeData.interaction.interactionId = secondTaskId;
3102
+ webSocketManagerMock.emit(
3103
+ 'message',
3104
+ JSON.stringify({
3105
+ data: {...ownerChangeData, type: CC_EVENTS.CONTACT_OWNER_CHANGED},
3106
+ })
3107
+ );
3108
+
3109
+ expect(firstTask.sendStateMachineEvent).not.toHaveBeenCalled();
3110
+ expect(secondTask.sendStateMachineEvent).not.toHaveBeenCalled();
3111
+ });
3112
+
3113
+ it('ignores ContactOwnerChanged without an interaction correlation identifier', () => {
3114
+ const task = installTask(createConferenceTaskData(taskId, previousOwnerId));
3115
+
3116
+ webSocketManagerMock.emit(
3117
+ 'message',
3118
+ JSON.stringify({
3119
+ data: {
3120
+ type: CC_EVENTS.CONTACT_OWNER_CHANGED,
3121
+ interaction: {owner: promotedOwnerId},
3122
+ },
3123
+ })
3124
+ );
3125
+
3126
+ expect(task.sendStateMachineEvent).not.toHaveBeenCalled();
3127
+ });
3128
+
3129
+ it('recovers a missing promoted-agent task under the main interaction and emits one hydrate', () => {
3130
+ clearTasksAndFactoryHistory();
3131
+ const hydrateHandler = jest.fn();
3132
+ const incomingHandler = jest.fn();
3133
+ taskManager.on(TASK_EVENTS.TASK_HYDRATE, hydrateHandler);
3134
+ taskManager.on(TASK_EVENTS.TASK_INCOMING, incomingHandler);
3135
+
3136
+ webSocketManagerMock.emit(
3137
+ 'message',
3138
+ JSON.stringify({data: createPromotedAgentOwnerChangeData()})
3139
+ );
3140
+
3141
+ expect(TaskFactory.createTask).toHaveBeenCalledTimes(1);
3142
+ expect(TaskFactory.createTask).toHaveBeenCalledWith(
3143
+ contactMock,
3144
+ webCallingService,
3145
+ expect.objectContaining({
3146
+ interactionId: taskId,
3147
+ isAutoAnswering: false,
3148
+ isConsulted: false,
3149
+ isConferenceInProgress: true,
3150
+ wrapUpRequired: false,
3151
+ }),
3152
+ undefined,
3153
+ undefined,
3154
+ currentAgentId,
3155
+ undefined
3156
+ );
1919
3157
 
1920
- // Verify updateTaskData was called exactly once
1921
- expect(updateTaskDataSpy).toHaveBeenCalledTimes(1);
1922
-
1923
- // Verify it was called with isConferenceInProgress already calculated
1924
- expect(updateTaskDataSpy).toHaveBeenCalledWith(
3158
+ const recoveredTask = (TaskFactory.createTask as jest.Mock).mock.results[0].value;
3159
+ expect(recoveredTask.sendStateMachineEvent).toHaveBeenCalledTimes(2);
3160
+ expect(recoveredTask.sendStateMachineEvent).toHaveBeenNthCalledWith(
3161
+ 1,
3162
+ expect.objectContaining({
3163
+ type: TaskEvent.HYDRATE,
3164
+ agentId: currentAgentId,
3165
+ taskData: expect.objectContaining({interactionId: taskId}),
3166
+ })
3167
+ );
3168
+ expect(recoveredTask.sendStateMachineEvent).toHaveBeenNthCalledWith(
3169
+ 2,
1925
3170
  expect.objectContaining({
1926
- participantId: 'new-agent-789',
1927
- isConferenceInProgress: true, // 3 active agents
3171
+ type: TaskEvent.CONTACT_OWNER_CHANGED,
3172
+ taskData: expect.objectContaining({interactionId: taskId}),
1928
3173
  })
1929
3174
  );
1930
-
1931
- expect(task.emit).toHaveBeenCalledWith(TASK_EVENTS.TASK_PARTICIPANT_JOINED, task);
3175
+ expect(taskManager.taskCollection[taskId]).toBe(recoveredTask);
3176
+ expect(taskManager.taskCollection['promoted-child-interaction-id']).toBeUndefined();
3177
+ expect(Object.values(taskManager.taskCollection)).toEqual([recoveredTask]);
3178
+ expect(recoveredTask.data.interaction.owner).toBe(currentAgentId);
3179
+ expect(hydrateHandler).toHaveBeenCalledTimes(1);
3180
+ expect(hydrateHandler).toHaveBeenCalledWith(recoveredTask);
3181
+ expect(incomingHandler).not.toHaveBeenCalled();
3182
+ });
3183
+
3184
+ it.each([
3185
+ [
3186
+ 'another agent owns the interaction',
3187
+ (data) => {
3188
+ data.interaction.owner = promotedOwnerId;
3189
+ data.owner = promotedOwnerId;
3190
+ },
3191
+ ],
3192
+ [
3193
+ 'the payload has no main-call media',
3194
+ (data) => {
3195
+ data.interaction.media = {};
3196
+ },
3197
+ ],
3198
+ [
3199
+ 'the interaction is explicitly terminated',
3200
+ (data) => {
3201
+ data.interaction.isTerminated = true;
3202
+ },
3203
+ ],
3204
+ ])('does not create a missing task when %s', (_description, mutatePayload) => {
3205
+ clearTasksAndFactoryHistory();
3206
+ const ownerChangeData = createPromotedAgentOwnerChangeData();
3207
+ mutatePayload(ownerChangeData);
3208
+
3209
+ webSocketManagerMock.emit('message', JSON.stringify({data: ownerChangeData}));
3210
+
3211
+ expect(TaskFactory.createTask).not.toHaveBeenCalled();
3212
+ expect(taskManager.getAllTasks()).toEqual({});
3213
+ });
3214
+
3215
+ it('does not create a missing task for an owner-changing ContactUpdated event', () => {
3216
+ clearTasksAndFactoryHistory();
3217
+ const contactUpdatedData = {
3218
+ ...createPromotedAgentOwnerChangeData(),
3219
+ type: CC_EVENTS.CONTACT_UPDATED,
3220
+ };
3221
+
3222
+ webSocketManagerMock.emit('message', JSON.stringify({data: contactUpdatedData}));
3223
+
3224
+ expect(TaskFactory.createTask).not.toHaveBeenCalled();
3225
+ expect(taskManager.getAllTasks()).toEqual({});
1932
3226
  });
1933
3227
 
1934
- describe('PARTICIPANT_LEFT_CONFERENCE event handling', () => {
1935
- it('should call updateTaskData only once for PARTICIPANT_LEFT_CONFERENCE with pre-calculated isConferenceInProgress', () => {
1936
- const payload = {
3228
+ it('reuses a recovered task for later AgentContact and ContactMerged events', () => {
3229
+ clearTasksAndFactoryHistory();
3230
+ const hydrateHandler = jest.fn();
3231
+ const mergedHandler = jest.fn();
3232
+ taskManager.on(TASK_EVENTS.TASK_HYDRATE, hydrateHandler);
3233
+ taskManager.on(TASK_EVENTS.TASK_MERGED, mergedHandler);
3234
+
3235
+ webSocketManagerMock.emit(
3236
+ 'message',
3237
+ JSON.stringify({data: createPromotedAgentOwnerChangeData()})
3238
+ );
3239
+
3240
+ const recoveredTask = taskManager.taskCollection[taskId];
3241
+ expect(recoveredTask).toBeDefined();
3242
+ expect(recoveredTask.listenerCount(TASK_EVENTS.TASK_HYDRATE)).toBe(1);
3243
+
3244
+ webSocketManagerMock.emit(
3245
+ 'message',
3246
+ JSON.stringify({
1937
3247
  data: {
1938
- type: CC_EVENTS.PARTICIPANT_LEFT_CONFERENCE,
1939
- interactionId: taskId,
1940
- interaction: {
1941
- participants: {
1942
- [agentId]: { pType: 'Agent', hasLeft: false },
1943
- 'agent-2': { pType: 'Agent', hasLeft: true }, // This agent left
1944
- 'customer-1': { pType: 'Customer', hasLeft: false },
1945
- },
1946
- media: {
1947
- [taskId]: {
1948
- mType: 'mainCall',
1949
- participants: [agentId, 'customer-1'], // agent-2 removed from participants
1950
- },
1951
- },
1952
- },
3248
+ ...createConferenceTaskData(taskId, currentAgentId),
3249
+ type: CC_EVENTS.AGENT_CONTACT,
3250
+ },
3251
+ })
3252
+ );
3253
+ webSocketManagerMock.emit(
3254
+ 'message',
3255
+ JSON.stringify({
3256
+ data: {
3257
+ ...createConferenceTaskData(taskId, currentAgentId),
3258
+ type: CC_EVENTS.CONTACT_MERGED,
3259
+ childInteractionId: 'promoted-child-interaction-id',
1953
3260
  },
1954
- };
3261
+ })
3262
+ );
1955
3263
 
1956
- const updateTaskDataSpy = jest.spyOn(task, 'updateTaskData');
1957
-
1958
- webSocketManagerMock.emit('message', JSON.stringify(payload));
3264
+ expect(TaskFactory.createTask).toHaveBeenCalledTimes(1);
3265
+ expect(taskManager.taskCollection[taskId]).toBe(recoveredTask);
3266
+ expect(taskManager.taskCollection['promoted-child-interaction-id']).toBeUndefined();
3267
+ expect(Object.values(taskManager.taskCollection)).toEqual([recoveredTask]);
3268
+ expect(recoveredTask.listenerCount(TASK_EVENTS.TASK_HYDRATE)).toBe(1);
3269
+ expect(hydrateHandler).toHaveBeenCalledTimes(2);
3270
+ expect(mergedHandler).toHaveBeenCalledTimes(1);
3271
+ expect(mergedHandler).toHaveBeenCalledWith(recoveredTask);
3272
+ });
1959
3273
 
1960
- // Verify updateTaskData was called exactly once
1961
- expect(updateTaskDataSpy).toHaveBeenCalledTimes(1);
1962
-
1963
- // Verify it was called with isConferenceInProgress already calculated
1964
- expect(updateTaskDataSpy).toHaveBeenCalledWith(
1965
- expect.objectContaining({
1966
- isConferenceInProgress: false, // Only 1 active agent remains
1967
- })
1968
- );
1969
-
1970
- expect(task.emit).toHaveBeenCalledWith(TASK_EVENTS.TASK_PARTICIPANT_LEFT, task);
1971
- });
3274
+ it('does not let a late ParticipantLeftConference restore a departed owner', () => {
3275
+ const task = installTask(createConferenceTaskData(taskId, previousOwnerId));
1972
3276
 
1973
- it('should emit TASK_PARTICIPANT_LEFT event when participant leaves conference', () => {
1974
- const payload = {
3277
+ webSocketManagerMock.emit(
3278
+ 'message',
3279
+ JSON.stringify({
1975
3280
  data: {
1976
- type: CC_EVENTS.PARTICIPANT_LEFT_CONFERENCE,
1977
- interactionId: taskId,
1978
- interaction: {
1979
- participants: {
1980
- [agentId]: {
1981
- hasLeft: false,
1982
- },
1983
- },
1984
- },
3281
+ ...createConferenceTaskData(taskId, promotedOwnerId),
3282
+ type: CC_EVENTS.CONTACT_UPDATED,
1985
3283
  },
1986
- };
3284
+ })
3285
+ );
1987
3286
 
1988
- webSocketManagerMock.emit('message', JSON.stringify(payload));
3287
+ const participantLeftData = createConferenceTaskData(taskId, previousOwnerId);
3288
+ participantLeftData.participantId = previousOwnerId;
3289
+ participantLeftData.interaction.participants[previousOwnerId].hasLeft = true;
3290
+ participantLeftData.interaction.media[taskId].participants = [
3291
+ currentAgentId,
3292
+ promotedOwnerId,
3293
+ ];
3294
+ webSocketManagerMock.emit(
3295
+ 'message',
3296
+ JSON.stringify({
3297
+ data: {...participantLeftData, type: CC_EVENTS.PARTICIPANT_LEFT_CONFERENCE},
3298
+ })
3299
+ );
1989
3300
 
1990
- expect(task.emit).toHaveBeenCalledWith(TASK_EVENTS.TASK_PARTICIPANT_LEFT, task);
1991
- });
3301
+ const participantLeaveEvent = expectLastStateMachineEvent(
3302
+ task.sendStateMachineEvent,
3303
+ TaskEvent.PARTICIPANT_LEAVE
3304
+ );
3305
+ expect(participantLeaveEvent.taskData.interaction.owner).toBe(promotedOwnerId);
3306
+ expect(task.data.interaction.owner).toBe(promotedOwnerId);
3307
+ });
1992
3308
 
1993
- it('should NOT remove task when agent is still in interaction', () => {
1994
- const payload = {
3309
+ it('does not infer that an incoming owner departed from a partial main-call roster', () => {
3310
+ const task = installTask(createConferenceTaskData(taskId, promotedOwnerId));
3311
+ const partialParticipantLeftData = createConferenceTaskData(taskId, previousOwnerId);
3312
+ partialParticipantLeftData.participantId = 'another-agent-id';
3313
+ partialParticipantLeftData.interaction.media[taskId].participants = [
3314
+ currentAgentId,
3315
+ promotedOwnerId,
3316
+ ];
3317
+
3318
+ webSocketManagerMock.emit(
3319
+ 'message',
3320
+ JSON.stringify({
1995
3321
  data: {
3322
+ ...partialParticipantLeftData,
1996
3323
  type: CC_EVENTS.PARTICIPANT_LEFT_CONFERENCE,
1997
- interactionId: taskId,
1998
- interaction: {
1999
- participants: {
2000
- [agentId]: {
2001
- hasLeft: false,
2002
- },
2003
- },
2004
- },
2005
3324
  },
2006
- };
3325
+ })
3326
+ );
2007
3327
 
2008
- webSocketManagerMock.emit('message', JSON.stringify(payload));
3328
+ const participantLeaveEvent = expectLastStateMachineEvent(
3329
+ task.sendStateMachineEvent,
3330
+ TaskEvent.PARTICIPANT_LEAVE
3331
+ );
3332
+ expect(participantLeaveEvent.taskData.interaction.owner).toBe(previousOwnerId);
3333
+ expect(task.data.interaction.owner).toBe(previousOwnerId);
3334
+ });
3335
+
3336
+ it('applies the promoted owner when ParticipantLeftConference arrives first', () => {
3337
+ const task = installTask(createConferenceTaskData(taskId, previousOwnerId));
3338
+ const participantLeftData = createConferenceTaskData(taskId, previousOwnerId);
3339
+ participantLeftData.participantId = previousOwnerId;
3340
+ participantLeftData.interaction.participants[previousOwnerId].hasLeft = true;
3341
+ participantLeftData.interaction.media[taskId].participants = [
3342
+ currentAgentId,
3343
+ promotedOwnerId,
3344
+ ];
3345
+
3346
+ webSocketManagerMock.emit(
3347
+ 'message',
3348
+ JSON.stringify({
3349
+ data: {...participantLeftData, type: CC_EVENTS.PARTICIPANT_LEFT_CONFERENCE},
3350
+ })
3351
+ );
3352
+ webSocketManagerMock.emit(
3353
+ 'message',
3354
+ JSON.stringify({
3355
+ data: {
3356
+ ...createConferenceTaskData(taskId, promotedOwnerId),
3357
+ type: CC_EVENTS.CONTACT_UPDATED,
3358
+ },
3359
+ })
3360
+ );
3361
+
3362
+ expect(task.data.interaction.owner).toBe(promotedOwnerId);
3363
+ expectLastStateMachineEvent(task.sendStateMachineEvent, TaskEvent.CONTACT_OWNER_CHANGED);
3364
+ });
3365
+ });
3366
+
3367
+ describe('Conference event handling', () => {
3368
+ let task;
3369
+
3370
+ beforeEach(() => {
3371
+ task = {
3372
+ data: {interactionId: taskId},
3373
+ emit: jest.fn(),
3374
+ updateTaskData: jest.fn(),
3375
+ sendStateMachineEvent: jest.fn(),
3376
+ };
3377
+ taskManager.taskCollection[taskId] = task as any;
3378
+ });
3379
+
3380
+ it('sends AGENT_CONSULT_CONFERENCED to state machine as CONFERENCE_START', () => {
3381
+ const payload = {
3382
+ data: {type: CC_EVENTS.AGENT_CONSULT_CONFERENCED, interactionId: taskId},
3383
+ };
3384
+ webSocketManagerMock.emit('message', JSON.stringify(payload));
3385
+ expect(task.sendStateMachineEvent).toHaveBeenCalled();
3386
+ const call = task.sendStateMachineEvent.mock.calls[0][0];
3387
+ expect(call.type).toBe(TaskEvent.CONFERENCE_START);
3388
+ });
3389
+
3390
+ it('sends PARTICIPANT_JOINED_CONFERENCE to state machine as CONFERENCE_START', () => {
3391
+ const payload = {
3392
+ data: {type: CC_EVENTS.PARTICIPANT_JOINED_CONFERENCE, interactionId: taskId},
3393
+ };
3394
+ webSocketManagerMock.emit('message', JSON.stringify(payload));
3395
+ expect(task.sendStateMachineEvent).toHaveBeenCalled();
3396
+ const call = task.sendStateMachineEvent.mock.calls[0][0];
3397
+ expect(call.type).toBe(TaskEvent.CONFERENCE_START);
3398
+ });
3399
+
3400
+ it('sends AGENT_CONSULT_CONFERENCE_FAILED to state machine as CONFERENCE_FAILED', () => {
3401
+ const payload = {
3402
+ data: {type: CC_EVENTS.AGENT_CONSULT_CONFERENCE_FAILED, interactionId: taskId},
3403
+ };
3404
+ webSocketManagerMock.emit('message', JSON.stringify(payload));
3405
+ expect(task.sendStateMachineEvent).toHaveBeenCalled();
3406
+ const call = task.sendStateMachineEvent.mock.calls[0][0];
3407
+ expect(call.type).toBe(TaskEvent.CONFERENCE_FAILED);
3408
+ });
3409
+
3410
+ it('sends PARTICIPANT_LEFT_CONFERENCE to state machine as PARTICIPANT_LEAVE', () => {
3411
+ const payload = {
3412
+ data: {type: CC_EVENTS.PARTICIPANT_LEFT_CONFERENCE, interactionId: taskId},
3413
+ };
3414
+ webSocketManagerMock.emit('message', JSON.stringify(payload));
3415
+ expect(task.sendStateMachineEvent).toHaveBeenCalled();
3416
+ const call = task.sendStateMachineEvent.mock.calls[0][0];
3417
+ expect(call.type).toBe(TaskEvent.PARTICIPANT_LEAVE);
3418
+ });
2009
3419
 
2010
- // Task should still exist in collection
2011
- expect(taskManager.getTask(taskId)).toBeDefined();
2012
- expect(task.emit).toHaveBeenCalledWith(TASK_EVENTS.TASK_PARTICIPANT_LEFT, task);
3420
+ it('routes participant-left from a main interaction to a child-keyed task', () => {
3421
+ const childTaskId = 'child-interaction-id';
3422
+ const childTask = createMockTask({
3423
+ ...taskDataMock,
3424
+ interactionId: childTaskId,
3425
+ isConsulted: true,
3426
+ interaction: {
3427
+ mediaType: 'telephony',
3428
+ interactionId: childTaskId,
3429
+ parentInteractionId: taskId,
3430
+ },
2013
3431
  });
3432
+ delete taskManager.taskCollection[taskId];
3433
+ taskManager.taskCollection[childTaskId] = childTask;
2014
3434
 
2015
- it('should NOT remove task when agent left but is in main interaction', () => {
2016
- const payload = {
3435
+ webSocketManagerMock.emit(
3436
+ 'message',
3437
+ JSON.stringify({
2017
3438
  data: {
2018
3439
  type: CC_EVENTS.PARTICIPANT_LEFT_CONFERENCE,
2019
3440
  interactionId: taskId,
3441
+ participantId: 'test-agent-id',
2020
3442
  interaction: {
2021
- participants: {
2022
- [agentId]: {
2023
- hasLeft: true,
2024
- },
2025
- },
2026
- media: {
2027
- [taskId]: {
2028
- mType: 'mainCall',
2029
- participants: [agentId],
2030
- },
2031
- },
3443
+ mediaType: 'telephony',
3444
+ interactionId: taskId,
3445
+ mainInteractionId: taskId,
2032
3446
  },
2033
3447
  },
2034
- };
2035
-
2036
- const removeTaskSpy = jest.spyOn(taskManager, 'removeTaskFromCollection');
3448
+ })
3449
+ );
2037
3450
 
2038
- webSocketManagerMock.emit('message', JSON.stringify(payload));
3451
+ expect(childTask.sendStateMachineEvent).toHaveBeenCalledWith(
3452
+ expect.objectContaining({
3453
+ type: TaskEvent.PARTICIPANT_LEAVE,
3454
+ participantId: 'test-agent-id',
3455
+ })
3456
+ );
3457
+ expect(taskManager.taskCollection[childTaskId]).toBeUndefined();
3458
+ expect(taskManager.taskCollection[taskId]).toBe(childTask);
3459
+ expect(
3460
+ Object.values(taskManager.taskCollection).filter((entry) => entry === childTask)
3461
+ ).toHaveLength(1);
3462
+ });
2039
3463
 
2040
- // Task should still exist - not removed
2041
- expect(removeTaskSpy).not.toHaveBeenCalled();
2042
- expect(taskManager.getTask(taskId)).toBeDefined();
2043
- expect(task.emit).toHaveBeenCalledWith(TASK_EVENTS.TASK_PARTICIPANT_LEFT, task);
3464
+ it('routes consult-end from a main interaction to a child-keyed consulted task', () => {
3465
+ const childTaskId = 'consult-child-interaction-id';
3466
+ const childTask = createMockTask({
3467
+ ...taskDataMock,
3468
+ interactionId: childTaskId,
3469
+ isConsulted: true,
3470
+ interaction: {
3471
+ mediaType: 'telephony',
3472
+ interactionId: childTaskId,
3473
+ callProcessingDetails: {parentInteractionId: taskId},
3474
+ },
2044
3475
  });
3476
+ delete taskManager.taskCollection[taskId];
3477
+ taskManager.taskCollection[childTaskId] = childTask;
2045
3478
 
2046
- it('should NOT remove task when agent left but is primary (owner)', () => {
2047
- const payload = {
3479
+ webSocketManagerMock.emit(
3480
+ 'message',
3481
+ JSON.stringify({
2048
3482
  data: {
2049
- type: CC_EVENTS.PARTICIPANT_LEFT_CONFERENCE,
3483
+ type: CC_EVENTS.AGENT_CONSULT_ENDED,
2050
3484
  interactionId: taskId,
2051
3485
  interaction: {
2052
- participants: {
2053
- [agentId]: {
2054
- hasLeft: true,
2055
- },
2056
- },
2057
- owner: agentId,
2058
- media: {
2059
- [taskId]: {
2060
- mType: 'consultCall',
2061
- participants: ['other-agent'],
2062
- },
2063
- },
3486
+ mediaType: 'telephony',
3487
+ interactionId: taskId,
3488
+ mainInteractionId: taskId,
2064
3489
  },
2065
3490
  },
2066
- };
2067
-
2068
- const removeTaskSpy = jest.spyOn(taskManager, 'removeTaskFromCollection');
3491
+ })
3492
+ );
2069
3493
 
2070
- webSocketManagerMock.emit('message', JSON.stringify(payload));
3494
+ expect(childTask.sendStateMachineEvent).toHaveBeenCalledWith(
3495
+ expect.objectContaining({type: TaskEvent.CONSULT_END})
3496
+ );
3497
+ });
2071
3498
 
2072
- // Task should still exist - not removed because agent is primary
2073
- expect(removeTaskSpy).not.toHaveBeenCalled();
2074
- expect(taskManager.getTask(taskId)).toBeDefined();
2075
- expect(task.emit).toHaveBeenCalledWith(TASK_EVENTS.TASK_PARTICIPANT_LEFT, task);
3499
+ it('deduplicates aliases of the same child task during related-interaction lookup', () => {
3500
+ const childTaskId = 'aliased-child-interaction-id';
3501
+ const childTask = createMockTask({
3502
+ ...taskDataMock,
3503
+ interactionId: childTaskId,
3504
+ interaction: {
3505
+ mediaType: 'telephony',
3506
+ interactionId: childTaskId,
3507
+ mainInteractionId: taskId,
3508
+ },
2076
3509
  });
3510
+ delete taskManager.taskCollection[taskId];
3511
+ taskManager.taskCollection[childTaskId] = childTask;
3512
+ taskManager.taskCollection['child-task-alias'] = childTask;
2077
3513
 
2078
- it('should remove task when agent left and is NOT in main interaction and is NOT primary', () => {
2079
- const payload = {
3514
+ webSocketManagerMock.emit(
3515
+ 'message',
3516
+ JSON.stringify({
2080
3517
  data: {
2081
3518
  type: CC_EVENTS.PARTICIPANT_LEFT_CONFERENCE,
2082
3519
  interactionId: taskId,
2083
- interaction: {
2084
- participants: {
2085
- [agentId]: {
2086
- hasLeft: true,
2087
- },
2088
- },
2089
- owner: 'another-agent-id',
2090
- media: {
2091
- [taskId]: {
2092
- mType: 'mainCall',
2093
- participants: ['another-agent-id'],
2094
- },
2095
- },
2096
- },
3520
+ participantId: 'test-agent-id',
2097
3521
  },
2098
- };
2099
-
2100
- const removeTaskSpy = jest.spyOn(taskManager, 'removeTaskFromCollection');
3522
+ })
3523
+ );
2101
3524
 
2102
- webSocketManagerMock.emit('message', JSON.stringify(payload));
3525
+ expect(childTask.sendStateMachineEvent).toHaveBeenCalledTimes(1);
3526
+ expect(
3527
+ Object.values(taskManager.taskCollection).filter((entry) => entry === childTask)
3528
+ ).toHaveLength(1);
3529
+ });
2103
3530
 
2104
- // Task should be removed
2105
- expect(removeTaskSpy).toHaveBeenCalled();
2106
- expect(task.emit).toHaveBeenCalledWith(TASK_EVENTS.TASK_PARTICIPANT_LEFT, task);
3531
+ it('prefers an exact task match over a child task with the same main interaction', () => {
3532
+ const childTaskId = 'child-interaction-id';
3533
+ const childTask = createMockTask({
3534
+ ...taskDataMock,
3535
+ interactionId: childTaskId,
3536
+ interaction: {
3537
+ mediaType: 'telephony',
3538
+ interactionId: childTaskId,
3539
+ mainInteractionId: taskId,
3540
+ },
2107
3541
  });
3542
+ taskManager.taskCollection[childTaskId] = childTask;
2108
3543
 
2109
- it('should remove task when agent is not in participants list', () => {
2110
- const payload = {
3544
+ webSocketManagerMock.emit(
3545
+ 'message',
3546
+ JSON.stringify({
2111
3547
  data: {
2112
3548
  type: CC_EVENTS.PARTICIPANT_LEFT_CONFERENCE,
2113
3549
  interactionId: taskId,
2114
- interaction: {
2115
- participants: {
2116
- 'other-agent-id': {
2117
- hasLeft: false,
2118
- },
2119
- },
2120
- owner: 'another-agent-id',
2121
- },
3550
+ participantId: 'another-agent',
2122
3551
  },
2123
- };
3552
+ })
3553
+ );
2124
3554
 
2125
- const removeTaskSpy = jest.spyOn(taskManager, 'removeTaskFromCollection');
3555
+ expect(task.sendStateMachineEvent).toHaveBeenCalled();
3556
+ expect(childTask.sendStateMachineEvent).not.toHaveBeenCalled();
3557
+ });
2126
3558
 
2127
- webSocketManagerMock.emit('message', JSON.stringify(payload));
3559
+ it('does not route a main-interaction event when multiple unique child tasks match', () => {
3560
+ delete taskManager.taskCollection[taskId];
3561
+ const createChildTask = (interactionId: string) =>
3562
+ createMockTask({
3563
+ ...taskDataMock,
3564
+ interactionId,
3565
+ interaction: {
3566
+ mediaType: 'telephony',
3567
+ interactionId,
3568
+ mainInteractionId: taskId,
3569
+ },
3570
+ });
3571
+ const firstChildTask = createChildTask('child-1');
3572
+ const secondChildTask = createChildTask('child-2');
3573
+ taskManager.taskCollection['child-1'] = firstChildTask;
3574
+ taskManager.taskCollection['child-2'] = secondChildTask;
3575
+
3576
+ webSocketManagerMock.emit(
3577
+ 'message',
3578
+ JSON.stringify({
3579
+ data: {
3580
+ type: CC_EVENTS.PARTICIPANT_LEFT_CONFERENCE,
3581
+ interactionId: taskId,
3582
+ },
3583
+ })
3584
+ );
2128
3585
 
2129
- // Task should be removed because agent is not in participants
2130
- expect(removeTaskSpy).toHaveBeenCalled();
2131
- expect(task.emit).toHaveBeenCalledWith(TASK_EVENTS.TASK_PARTICIPANT_LEFT, task);
2132
- });
3586
+ expect(firstChildTask.sendStateMachineEvent).not.toHaveBeenCalled();
3587
+ expect(secondChildTask.sendStateMachineEvent).not.toHaveBeenCalled();
3588
+ });
2133
3589
 
2134
- it('should update isConferenceInProgress based on remaining active agents', () => {
2135
- const payload = {
3590
+ it('does not route a lifecycle event without a correlation identifier', () => {
3591
+ webSocketManagerMock.emit(
3592
+ 'message',
3593
+ JSON.stringify({
2136
3594
  data: {
2137
3595
  type: CC_EVENTS.PARTICIPANT_LEFT_CONFERENCE,
2138
- interactionId: taskId,
2139
- interaction: {
2140
- participants: {
2141
- [agentId]: {
2142
- hasLeft: false,
2143
- pType: 'Agent',
2144
- },
2145
- 'agent-2': {
2146
- hasLeft: false,
2147
- pType: 'Agent',
2148
- },
2149
- 'customer-1': {
2150
- hasLeft: false,
2151
- pType: 'Customer',
2152
- },
2153
- },
2154
- media: {
2155
- [taskId]: {
2156
- mType: 'mainCall',
2157
- participants: [agentId, 'agent-2', 'customer-1'],
2158
- },
2159
- },
2160
- },
3596
+ participantId: 'another-agent',
2161
3597
  },
2162
- };
3598
+ })
3599
+ );
2163
3600
 
2164
- webSocketManagerMock.emit('message', JSON.stringify(payload));
3601
+ expect(task.sendStateMachineEvent).not.toHaveBeenCalled();
3602
+ });
2165
3603
 
2166
- // isConferenceInProgress should be true (2 active agents)
2167
- expect(task.data.isConferenceInProgress).toBe(true);
2168
- expect(task.emit).toHaveBeenCalledWith(TASK_EVENTS.TASK_PARTICIPANT_LEFT, task);
2169
- });
3604
+ it('removes every collection alias for a task during terminal cleanup', () => {
3605
+ taskManager.taskCollection['task-alias'] = task;
3606
+
3607
+ (taskManager as any).removeTaskFromCollection(task);
2170
3608
 
2171
- it('should set isConferenceInProgress to false when only one agent remains', () => {
2172
- const payload = {
3609
+ expect(taskManager.taskCollection[taskId]).toBeUndefined();
3610
+ expect(taskManager.taskCollection['task-alias']).toBeUndefined();
3611
+ });
3612
+
3613
+ it('replaces an EP-DN child task with a hydrated main task on CONTACT_MERGED', () => {
3614
+ const childTaskId = 'ep-dn-child-interaction-id';
3615
+ const childTask = createMockTask({
3616
+ ...taskDataMock,
3617
+ interactionId: childTaskId,
3618
+ interaction: {
3619
+ mediaType: 'telephony',
3620
+ interactionId: childTaskId,
3621
+ parentInteractionId: taskId,
3622
+ },
3623
+ });
3624
+ const mergedHandler = jest.fn();
3625
+ delete taskManager.taskCollection[taskId];
3626
+ taskManager.taskCollection[childTaskId] = childTask;
3627
+ taskManager.on(TASK_EVENTS.TASK_MERGED, mergedHandler);
3628
+
3629
+ webSocketManagerMock.emit(
3630
+ 'message',
3631
+ JSON.stringify({
2173
3632
  data: {
2174
- type: CC_EVENTS.PARTICIPANT_LEFT_CONFERENCE,
3633
+ type: CC_EVENTS.CONTACT_MERGED,
2175
3634
  interactionId: taskId,
3635
+ childInteractionId: childTaskId,
2176
3636
  interaction: {
3637
+ mediaType: 'telephony',
3638
+ interactionId: taskId,
3639
+ mainInteractionId: taskId,
3640
+ state: 'conference',
3641
+ owner: 'test-agent-id',
2177
3642
  participants: {
2178
- [agentId]: {
2179
- hasLeft: false,
2180
- pType: 'Agent',
2181
- },
2182
- 'agent-2': {
2183
- hasLeft: true,
2184
- pType: 'Agent',
2185
- },
2186
- 'customer-1': {
2187
- hasLeft: false,
2188
- pType: 'Customer',
2189
- },
3643
+ 'test-agent-id': {id: 'test-agent-id', pType: 'Agent', hasLeft: false},
2190
3644
  },
2191
3645
  media: {
2192
3646
  [taskId]: {
3647
+ mediaResourceId: taskId,
3648
+ mediaType: 'telephony',
2193
3649
  mType: 'mainCall',
2194
- participants: [agentId, 'customer-1'],
3650
+ participants: ['test-agent-id'],
2195
3651
  },
2196
3652
  },
2197
3653
  },
2198
3654
  },
2199
- };
2200
-
2201
- webSocketManagerMock.emit('message', JSON.stringify(payload));
2202
-
2203
- // isConferenceInProgress should be false (only 1 active agent)
2204
- expect(task.data.isConferenceInProgress).toBe(false);
2205
- expect(task.emit).toHaveBeenCalledWith(TASK_EVENTS.TASK_PARTICIPANT_LEFT, task);
2206
- });
2207
-
2208
- it('should handle participant left when no participants data exists', () => {
2209
- const payload = {
2210
- data: {
2211
- type: CC_EVENTS.PARTICIPANT_LEFT_CONFERENCE,
2212
- interactionId: taskId,
2213
- interaction: {},
2214
- },
2215
- };
3655
+ })
3656
+ );
2216
3657
 
2217
- const removeTaskSpy = jest.spyOn(taskManager, 'removeTaskFromCollection');
3658
+ const mainTask = taskManager.taskCollection[taskId];
3659
+ expect(taskManager.taskCollection[childTaskId]).toBeUndefined();
3660
+ expect(mainTask).toBeDefined();
3661
+ expect(mainTask).not.toBe(childTask);
3662
+ expect(mainTask.sendStateMachineEvent).toHaveBeenCalledWith(
3663
+ expect.objectContaining({type: TaskEvent.HYDRATE})
3664
+ );
3665
+ expect(mergedHandler).toHaveBeenCalledWith(mainTask);
3666
+ });
2218
3667
 
3668
+ it('handles AGENT_CONSULT_CONFERENCING event without errors', () => {
3669
+ const payload = {
3670
+ data: {type: CC_EVENTS.AGENT_CONSULT_CONFERENCING, interactionId: taskId},
3671
+ };
3672
+ expect(() => {
2219
3673
  webSocketManagerMock.emit('message', JSON.stringify(payload));
2220
-
2221
- // When no participants data exists, checkParticipantNotInInteraction returns true
2222
- // Since agent won't be in main interaction either, task should be removed
2223
- expect(removeTaskSpy).toHaveBeenCalled();
2224
- expect(task.emit).toHaveBeenCalledWith(TASK_EVENTS.TASK_PARTICIPANT_LEFT, task);
2225
- });
3674
+ }).not.toThrow();
2226
3675
  });
2227
3676
 
2228
- it('should handle PARTICIPANT_LEFT_CONFERENCE_FAILED event', () => {
3677
+ it('handles PARTICIPANT_LEFT_CONFERENCE_FAILED event without errors', () => {
2229
3678
  const payload = {
2230
- data: {
2231
- type: CC_EVENTS.PARTICIPANT_LEFT_CONFERENCE_FAILED,
2232
- interactionId: taskId,
2233
- reason: 'Exit failed',
2234
- },
3679
+ data: {type: CC_EVENTS.PARTICIPANT_LEFT_CONFERENCE_FAILED, interactionId: taskId},
2235
3680
  };
2236
-
2237
- webSocketManagerMock.emit('message', JSON.stringify(payload));
2238
-
2239
- expect(task.data.reason).toBe('Exit failed');
2240
- // No event emission expected for failure - handled by contact method promise rejection
3681
+ expect(() => {
3682
+ webSocketManagerMock.emit('message', JSON.stringify(payload));
3683
+ }).not.toThrow();
2241
3684
  });
2242
3685
 
2243
- it('should only update task for matching interactionId', () => {
3686
+ it('only routes conference events to matching tasks', () => {
2244
3687
  const otherTaskId = 'other-task-id';
2245
- const otherTask = {
2246
- data: { interactionId: otherTaskId },
3688
+ const otherTask: any = {
3689
+ data: {interactionId: otherTaskId},
2247
3690
  emit: jest.fn(),
3691
+ updateTaskData: jest.fn(),
3692
+ sendStateMachineEvent: jest.fn(),
2248
3693
  };
2249
3694
  taskManager.taskCollection[otherTaskId] = otherTask;
2250
3695
 
2251
3696
  const payload = {
2252
- data: {
2253
- type: CC_EVENTS.AGENT_CONSULT_CONFERENCED,
2254
- interactionId: taskId,
2255
- isConferencing: true,
2256
- },
3697
+ data: {type: CC_EVENTS.AGENT_CONSULT_CONFERENCED, interactionId: taskId},
2257
3698
  };
2258
-
2259
3699
  webSocketManagerMock.emit('message', JSON.stringify(payload));
2260
3700
 
2261
- // Only the matching task should be updated
2262
- expect(task.data.isConferencing).toBe(true);
2263
- expect(task.emit).toHaveBeenCalledWith(TASK_EVENTS.TASK_CONFERENCE_STARTED, task);
2264
-
2265
- // Other task should not be affected
2266
- expect(otherTask.data.isConferencing).toBeUndefined();
2267
- expect(otherTask.emit).not.toHaveBeenCalled();
3701
+ expect(task.sendStateMachineEvent).toHaveBeenCalled();
3702
+ expect(otherTask.sendStateMachineEvent).not.toHaveBeenCalled();
2268
3703
  });
2269
3704
  });
2270
3705
 
2271
- describe('CONTACT_MERGED event handling', () => {
2272
- let task;
2273
- let taskEmitSpy;
2274
- let managerEmitSpy;
2275
-
2276
- beforeEach(() => {
2277
- // Create initial task
2278
- webSocketManagerMock.emit('message', JSON.stringify(initalPayload));
2279
- task = taskManager.getTask(taskId);
2280
- taskEmitSpy = jest.spyOn(task, 'emit');
2281
- managerEmitSpy = jest.spyOn(taskManager, 'emit');
2282
- });
2283
-
2284
- it('should update existing task data and emit TASK_MERGED event when CONTACT_MERGED is received', () => {
2285
- const mergedPayload = {
2286
- data: {
2287
- type: CC_EVENTS.CONTACT_MERGED,
2288
- interactionId: taskId,
2289
- agentId: taskDataMock.agentId,
2290
- interaction: {
2291
- ...taskDataMock.interaction,
2292
- state: 'merged',
2293
- customField: 'updated-value',
2294
- },
2295
- },
2296
- };
2297
-
2298
- webSocketManagerMock.emit('message', JSON.stringify(mergedPayload));
3706
+ describe('state machine integration', () => {
3707
+ it('maps CC events to task state machine events using normalized payload', () => {
3708
+ const mapped = (TaskManager as any).mapEventToTaskStateMachineEvent(
3709
+ CC_EVENTS.AGENT_CONTACT_ASSIGNED,
3710
+ {...taskDataMock, type: CC_EVENTS.AGENT_CONTACT_ASSIGNED}
3711
+ );
2299
3712
 
2300
- const updatedTask = taskManager.getTask(taskId);
2301
- expect(updatedTask).toBeDefined();
2302
- expect(updatedTask.data.interaction.customField).toBe('updated-value');
2303
- expect(updatedTask.data.interaction.state).toBe('merged');
2304
- expect(managerEmitSpy).toHaveBeenCalledWith(TASK_EVENTS.TASK_MERGED, updatedTask);
3713
+ expect(mapped).toEqual({
3714
+ type: TaskEvent.ASSIGN,
3715
+ taskData: {...taskDataMock, type: CC_EVENTS.AGENT_CONTACT_ASSIGNED},
3716
+ });
2305
3717
  });
2306
3718
 
2307
- it('should create new task when CONTACT_MERGED is received for non-existing task', () => {
2308
- const newMergedTaskId = 'new-merged-task-id';
2309
- const mergedPayload = {
2310
- data: {
2311
- type: CC_EVENTS.CONTACT_MERGED,
2312
- interactionId: newMergedTaskId,
2313
- agentId: taskDataMock.agentId,
2314
- interaction: {
2315
- mediaType: 'telephony',
2316
- state: 'merged',
2317
- participants: {
2318
- [taskDataMock.agentId]: {
2319
- isWrapUp: false,
2320
- hasJoined: true,
2321
- },
2322
- },
2323
- },
2324
- },
2325
- };
3719
+ it('sends mapped events to the task state machine service', () => {
3720
+ const payload = {...taskDataMock, type: CC_EVENTS.AGENT_CONTACT_ASSIGNED};
3721
+ const task = taskManager.getTask(taskId);
3722
+ const sendStateMachineEventSpy = jest.spyOn(task, 'sendStateMachineEvent');
2326
3723
 
2327
- // Verify task doesn't exist before
2328
- expect(taskManager.getTask(newMergedTaskId)).toBeUndefined();
3724
+ webSocketManagerMock.emit('message', JSON.stringify({data: payload}));
2329
3725
 
2330
- webSocketManagerMock.emit('message', JSON.stringify(mergedPayload));
3726
+ expect(sendStateMachineEventSpy).toHaveBeenCalledWith({
3727
+ type: TaskEvent.ASSIGN,
3728
+ taskData: payload,
3729
+ });
2331
3730
 
2332
- // Verify task was created
2333
- const newTask = taskManager.getTask(newMergedTaskId);
2334
- expect(newTask).toBeDefined();
2335
- expect(newTask.data.interactionId).toBe(newMergedTaskId);
2336
- expect(managerEmitSpy).toHaveBeenCalledWith(TASK_EVENTS.TASK_MERGED, newTask);
3731
+ sendStateMachineEventSpy.mockRestore();
2337
3732
  });
3733
+ });
2338
3734
 
2339
- it('should remove child task when childInteractionId is present in CONTACT_MERGED', () => {
2340
- const childTaskId = 'child-task-id';
2341
- const parentTaskId = 'parent-task-id';
2342
-
2343
- // Create child task
2344
- const childPayload = {
2345
- data: {
2346
- type: CC_EVENTS.AGENT_CONTACT_RESERVED,
2347
- interactionId: childTaskId,
2348
- agentId: taskDataMock.agentId,
2349
- interaction: {mediaType: 'telephony'},
2350
- },
2351
- };
2352
- webSocketManagerMock.emit('message', JSON.stringify(childPayload));
2353
-
2354
- // Verify child task exists
2355
- expect(taskManager.getTask(childTaskId)).toBeDefined();
2356
-
2357
- // Create parent task
2358
- const parentPayload = {
2359
- data: {
2360
- type: CC_EVENTS.AGENT_CONTACT_RESERVED,
2361
- interactionId: parentTaskId,
2362
- agentId: taskDataMock.agentId,
2363
- interaction: {mediaType: 'telephony'},
2364
- },
2365
- };
2366
- webSocketManagerMock.emit('message', JSON.stringify(parentPayload));
2367
-
2368
- // Send CONTACT_MERGED with childInteractionId
2369
- const mergedPayload = {
2370
- data: {
2371
- type: CC_EVENTS.CONTACT_MERGED,
2372
- interactionId: parentTaskId,
2373
- childInteractionId: childTaskId,
2374
- agentId: taskDataMock.agentId,
2375
- interaction: {
2376
- mediaType: 'telephony',
2377
- state: 'merged',
2378
- },
2379
- },
2380
- };
2381
-
2382
- webSocketManagerMock.emit('message', JSON.stringify(mergedPayload));
2383
-
2384
- // Verify child task was removed
2385
- expect(taskManager.getTask(childTaskId)).toBeUndefined();
2386
-
2387
- // Verify parent task still exists
2388
- expect(taskManager.getTask(parentTaskId)).toBeDefined();
2389
-
2390
- // Verify TASK_MERGED event was emitted
2391
- expect(managerEmitSpy).toHaveBeenCalledWith(
2392
- TASK_EVENTS.TASK_MERGED,
2393
- expect.objectContaining({
2394
- data: expect.objectContaining({
2395
- interactionId: parentTaskId,
2396
- }),
2397
- })
2398
- );
2399
- });
3735
+ describe('applyEnableWxBetterTogether', () => {
3736
+ it('updates config flags and propagates to active tasks', () => {
3737
+ const taskOne = {setEnableWxBetterTogether: jest.fn()};
3738
+ const taskTwo = {setEnableWxBetterTogether: jest.fn()};
2400
3739
 
2401
- it('should handle CONTACT_MERGED with EP-DN participant correctly', () => {
2402
- const epdnTaskId = 'epdn-merged-task';
2403
- const mergedPayload = {
2404
- data: {
2405
- type: CC_EVENTS.CONTACT_MERGED,
2406
- interactionId: epdnTaskId,
2407
- agentId: taskDataMock.agentId,
2408
- interaction: {
2409
- mediaType: 'telephony',
2410
- state: 'merged',
2411
- participants: {
2412
- [taskDataMock.agentId]: {
2413
- type: 'Agent',
2414
- isWrapUp: false,
2415
- hasJoined: true,
2416
- },
2417
- 'epdn-participant': {
2418
- type: 'EpDn',
2419
- epId: 'entry-point-123',
2420
- isWrapUp: false,
2421
- },
2422
- },
2423
- },
2424
- },
3740
+ taskManager.setConfigFlags({
3741
+ isEndTaskEnabled: true,
3742
+ isEndConsultEnabled: true,
3743
+ enableWxBetterTogether: true,
3744
+ });
3745
+ taskManager['taskCollection'] = {
3746
+ [taskId]: taskOne,
3747
+ 'task-2': taskTwo,
2425
3748
  };
2426
3749
 
2427
- webSocketManagerMock.emit('message', JSON.stringify(mergedPayload));
3750
+ taskManager.applyEnableWxBetterTogether(false);
2428
3751
 
2429
- const mergedTask = taskManager.getTask(epdnTaskId);
2430
- expect(mergedTask).toBeDefined();
2431
- expect(mergedTask.data.interaction.participants['epdn-participant']).toBeDefined();
2432
- expect(mergedTask.data.interaction.participants['epdn-participant'].type).toBe('EpDn');
2433
- expect(managerEmitSpy).toHaveBeenCalledWith(TASK_EVENTS.TASK_MERGED, mergedTask);
3752
+ expect(taskManager['configFlags']?.enableWxBetterTogether).toBe(false);
3753
+ expect(taskOne.setEnableWxBetterTogether).toHaveBeenCalledWith(false);
3754
+ expect(taskTwo.setEnableWxBetterTogether).toHaveBeenCalledWith(false);
2434
3755
  });
3756
+ });
2435
3757
 
2436
- it('should not affect other tasks when CONTACT_MERGED is received', () => {
2437
- const otherTaskId = 'other-unrelated-task';
2438
- const otherPayload = {
2439
- data: {
2440
- type: CC_EVENTS.AGENT_CONTACT_RESERVED,
2441
- interactionId: otherTaskId,
2442
- agentId: taskDataMock.agentId,
2443
- interaction: {mediaType: 'chat'},
2444
- },
2445
- };
2446
- webSocketManagerMock.emit('message', JSON.stringify(otherPayload));
2447
-
2448
- const otherTask = taskManager.getTask(otherTaskId);
2449
- const otherTaskEmitSpy = jest.spyOn(otherTask, 'emit');
3758
+ describe('applyWxAppMuteStateFromSync', () => {
3759
+ it('propagates mute state to all tasks', () => {
3760
+ const taskOne = {applyWxAppMuteStateFromSync: jest.fn()};
3761
+ const taskTwo = {applyWxAppMuteStateFromSync: jest.fn()};
2450
3762
 
2451
- // Send CONTACT_MERGED for the original task
2452
- const mergedPayload = {
2453
- data: {
2454
- type: CC_EVENTS.CONTACT_MERGED,
2455
- interactionId: taskId,
2456
- agentId: taskDataMock.agentId,
2457
- interaction: {
2458
- mediaType: 'telephony',
2459
- state: 'merged',
2460
- },
2461
- },
3763
+ taskManager['taskCollection'] = {
3764
+ [taskId]: taskOne,
3765
+ 'task-2': taskTwo,
2462
3766
  };
2463
3767
 
2464
- webSocketManagerMock.emit('message', JSON.stringify(mergedPayload));
3768
+ taskManager.applyWxAppMuteStateFromSync('call-1', true);
2465
3769
 
2466
- // Verify other task was not affected
2467
- expect(otherTaskEmitSpy).not.toHaveBeenCalled();
2468
- expect(otherTask.data.interaction.mediaType).toBe('chat');
2469
-
2470
- // Verify original task was updated
2471
- expect(managerEmitSpy).toHaveBeenCalledWith(
2472
- TASK_EVENTS.TASK_MERGED,
2473
- expect.objectContaining({
2474
- data: expect.objectContaining({
2475
- interactionId: taskId,
2476
- }),
2477
- })
2478
- );
3770
+ expect(taskOne.applyWxAppMuteStateFromSync).toHaveBeenCalledWith('call-1', true);
3771
+ expect(taskTwo.applyWxAppMuteStateFromSync).toHaveBeenCalledWith('call-1', true);
2479
3772
  });
2480
3773
  });
2481
-
2482
3774
  });
2483
-