@webex/contact-center 3.12.0-next.13 → 3.12.0-next.130

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 (312) hide show
  1. package/.sdd/manifest.json +884 -0
  2. package/AGENTS.md +94 -0
  3. package/ai-docs/ARCHITECTURE.md +168 -0
  4. package/ai-docs/CONTRACTS.md +53 -0
  5. package/ai-docs/GETTING_STARTED.md +168 -0
  6. package/ai-docs/GLOSSARY.md +43 -0
  7. package/ai-docs/README.md +138 -0
  8. package/ai-docs/REVIEW_CHECKLIST.md +41 -0
  9. package/ai-docs/RULES.md +444 -0
  10. package/ai-docs/SECURITY.md +52 -0
  11. package/ai-docs/SERVICE_STATE.md +52 -0
  12. package/ai-docs/SPEC_INDEX.md +65 -0
  13. package/ai-docs/adr/0001-spec-source-policy.md +55 -0
  14. package/ai-docs/adr/README.md +8 -0
  15. package/ai-docs/adr/_adr-template.md +31 -0
  16. package/ai-docs/contact-center-spec.md +382 -0
  17. package/ai-docs/features/consult-transfer-list-policy/spec/feature-spec.md +362 -0
  18. package/ai-docs/features/contact-owner-change-refactor.md +78 -0
  19. package/ai-docs/features/generated-spec-conformance-fidelity-remediation/spec/feature-spec.md +117 -0
  20. package/ai-docs/features/residual-warning-coverage-completion/spec/feature-spec.md +203 -0
  21. package/ai-docs/features/validator-code-fidelity-drift-fix/spec/feature-spec.md +315 -0
  22. package/ai-docs/patterns/event-driven-patterns.md +485 -0
  23. package/ai-docs/patterns/testing-patterns.md +480 -0
  24. package/ai-docs/patterns/typescript-patterns.md +365 -0
  25. package/ai-docs/templates/README.md +102 -0
  26. package/ai-docs/templates/documentation/create-agents-md.md +240 -0
  27. package/ai-docs/templates/documentation/create-architecture-md.md +295 -0
  28. package/ai-docs/templates/existing-service/bug-fix.md +254 -0
  29. package/ai-docs/templates/existing-service/feature-enhancement.md +450 -0
  30. package/ai-docs/templates/new-method/00-master.md +80 -0
  31. package/ai-docs/templates/new-method/01-requirements.md +232 -0
  32. package/ai-docs/templates/new-method/02-implementation.md +295 -0
  33. package/ai-docs/templates/new-method/03-tests.md +201 -0
  34. package/ai-docs/templates/new-method/04-validation.md +141 -0
  35. package/ai-docs/templates/new-service/00-master.md +109 -0
  36. package/ai-docs/templates/new-service/01-pre-questions.md +159 -0
  37. package/ai-docs/templates/new-service/02-code-generation.md +346 -0
  38. package/ai-docs/templates/new-service/03-integration.md +178 -0
  39. package/ai-docs/templates/new-service/04-test-generation.md +205 -0
  40. package/ai-docs/templates/new-service/05-validation.md +145 -0
  41. package/dist/cc.js +971 -42
  42. package/dist/cc.js.map +1 -1
  43. package/dist/config.js +13 -0
  44. package/dist/config.js.map +1 -1
  45. package/dist/constants.js +31 -3
  46. package/dist/constants.js.map +1 -1
  47. package/dist/index.js +45 -15
  48. package/dist/index.js.map +1 -1
  49. package/dist/metrics/MetricsManager.js +16 -0
  50. package/dist/metrics/MetricsManager.js.map +1 -1
  51. package/dist/metrics/behavioral-events.js +205 -0
  52. package/dist/metrics/behavioral-events.js.map +1 -1
  53. package/dist/metrics/constants.js +48 -4
  54. package/dist/metrics/constants.js.map +1 -1
  55. package/dist/services/AddressBook.js +18 -15
  56. package/dist/services/AddressBook.js.map +1 -1
  57. package/dist/services/AnswerCallOnWebexService.js +202 -0
  58. package/dist/services/AnswerCallOnWebexService.js.map +1 -0
  59. package/dist/services/ApiAiAssistant.js +269 -9
  60. package/dist/services/ApiAiAssistant.js.map +1 -1
  61. package/dist/services/EntryPoint.js +46 -68
  62. package/dist/services/EntryPoint.js.map +1 -1
  63. package/dist/services/Queue.js +27 -22
  64. package/dist/services/Queue.js.map +1 -1
  65. package/dist/services/UserPreference.js +427 -0
  66. package/dist/services/UserPreference.js.map +1 -0
  67. package/dist/services/WebexCrossClientService.js +222 -0
  68. package/dist/services/WebexCrossClientService.js.map +1 -0
  69. package/dist/services/WxAppTelephonyMercurySync.js +108 -0
  70. package/dist/services/WxAppTelephonyMercurySync.js.map +1 -0
  71. package/dist/services/config/Util.js +11 -4
  72. package/dist/services/config/Util.js.map +1 -1
  73. package/dist/services/config/constants.js +41 -7
  74. package/dist/services/config/constants.js.map +1 -1
  75. package/dist/services/config/index.js +51 -0
  76. package/dist/services/config/index.js.map +1 -1
  77. package/dist/services/config/types.js +57 -12
  78. package/dist/services/config/types.js.map +1 -1
  79. package/dist/services/core/Err.js.map +1 -1
  80. package/dist/services/core/Utils.js +111 -32
  81. package/dist/services/core/Utils.js.map +1 -1
  82. package/dist/services/core/WebexRequest.js +6 -2
  83. package/dist/services/core/WebexRequest.js.map +1 -1
  84. package/dist/services/core/aqm-reqs.js +28 -14
  85. package/dist/services/core/aqm-reqs.js.map +1 -1
  86. package/dist/services/core/types.js.map +1 -1
  87. package/dist/services/core/websocket/WebSocketManager.js +2 -1
  88. package/dist/services/core/websocket/WebSocketManager.js.map +1 -1
  89. package/dist/services/core/websocket/types.js.map +1 -1
  90. package/dist/services/index.js +1 -1
  91. package/dist/services/index.js.map +1 -1
  92. package/dist/services/task/Task.js +754 -0
  93. package/dist/services/task/Task.js.map +1 -0
  94. package/dist/services/task/TaskFactory.js +49 -0
  95. package/dist/services/task/TaskFactory.js.map +1 -0
  96. package/dist/services/task/TaskManager.js +1172 -521
  97. package/dist/services/task/TaskManager.js.map +1 -1
  98. package/dist/services/task/TaskUtils.js +211 -27
  99. package/dist/services/task/TaskUtils.js.map +1 -1
  100. package/dist/services/task/WebexCallingUtils.js +70 -0
  101. package/dist/services/task/WebexCallingUtils.js.map +1 -0
  102. package/dist/services/task/constants.js +12 -2
  103. package/dist/services/task/constants.js.map +1 -1
  104. package/dist/services/task/contact.js +29 -0
  105. package/dist/services/task/contact.js.map +1 -1
  106. package/dist/services/task/dialer.js +78 -0
  107. package/dist/services/task/dialer.js.map +1 -1
  108. package/dist/services/task/digital/Digital.js +78 -0
  109. package/dist/services/task/digital/Digital.js.map +1 -0
  110. package/dist/services/task/state-machine/TaskStateMachine.js +982 -0
  111. package/dist/services/task/state-machine/TaskStateMachine.js.map +1 -0
  112. package/dist/services/task/state-machine/actions.js +572 -0
  113. package/dist/services/task/state-machine/actions.js.map +1 -0
  114. package/dist/services/task/state-machine/constants.js +161 -0
  115. package/dist/services/task/state-machine/constants.js.map +1 -0
  116. package/dist/services/task/state-machine/guards.js +379 -0
  117. package/dist/services/task/state-machine/guards.js.map +1 -0
  118. package/dist/services/task/state-machine/index.js +53 -0
  119. package/dist/services/task/state-machine/index.js.map +1 -0
  120. package/dist/services/task/state-machine/types.js +54 -0
  121. package/dist/services/task/state-machine/types.js.map +1 -0
  122. package/dist/services/task/state-machine/uiControlsComputer.js +703 -0
  123. package/dist/services/task/state-machine/uiControlsComputer.js.map +1 -0
  124. package/dist/services/task/taskDataNormalizer.js +99 -0
  125. package/dist/services/task/taskDataNormalizer.js.map +1 -0
  126. package/dist/services/task/types.js +228 -5
  127. package/dist/services/task/types.js.map +1 -1
  128. package/dist/services/task/voice/Voice.js +1485 -0
  129. package/dist/services/task/voice/Voice.js.map +1 -0
  130. package/dist/services/task/voice/WebRTC.js +152 -0
  131. package/dist/services/task/voice/WebRTC.js.map +1 -0
  132. package/dist/services/task/voice/wxAppVoiceMethods.js +354 -0
  133. package/dist/services/task/voice/wxAppVoiceMethods.js.map +1 -0
  134. package/dist/services/wxAppDiagnosticLogging.js +122 -0
  135. package/dist/services/wxAppDiagnosticLogging.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 +193 -6
  139. package/dist/types/config.d.ts +13 -0
  140. package/dist/types/constants.d.ts +29 -1
  141. package/dist/types/index.d.ts +35 -8
  142. package/dist/types/metrics/MetricsManager.d.ts +6 -0
  143. package/dist/types/metrics/constants.d.ts +43 -1
  144. package/dist/types/services/AddressBook.d.ts +2 -1
  145. package/dist/types/services/AnswerCallOnWebexService.d.ts +38 -0
  146. package/dist/types/services/ApiAiAssistant.d.ts +56 -5
  147. package/dist/types/services/EntryPoint.d.ts +7 -5
  148. package/dist/types/services/Queue.d.ts +5 -3
  149. package/dist/types/services/UserPreference.d.ts +118 -0
  150. package/dist/types/services/WebexCrossClientService.d.ts +32 -0
  151. package/dist/types/services/WxAppTelephonyMercurySync.d.ts +28 -0
  152. package/dist/types/services/config/constants.d.ts +34 -5
  153. package/dist/types/services/config/types.d.ts +197 -12
  154. package/dist/types/services/core/Err.d.ts +6 -0
  155. package/dist/types/services/core/Utils.d.ts +35 -13
  156. package/dist/types/services/core/WebexRequest.d.ts +1 -0
  157. package/dist/types/services/core/types.d.ts +2 -0
  158. package/dist/types/services/core/websocket/WebSocketManager.d.ts +1 -0
  159. package/dist/types/services/core/websocket/types.d.ts +1 -1
  160. package/dist/types/services/index.d.ts +1 -1
  161. package/dist/types/services/task/Task.d.ts +175 -0
  162. package/dist/types/services/task/TaskFactory.d.ts +13 -0
  163. package/dist/types/services/task/TaskUtils.d.ts +48 -2
  164. package/dist/types/services/task/WebexCallingUtils.d.ts +11 -0
  165. package/dist/types/services/task/constants.d.ts +10 -0
  166. package/dist/types/services/task/contact.d.ts +4 -0
  167. package/dist/types/services/task/dialer.d.ts +30 -0
  168. package/dist/types/services/task/digital/Digital.d.ts +22 -0
  169. package/dist/types/services/task/state-machine/TaskStateMachine.d.ts +1442 -0
  170. package/dist/types/services/task/state-machine/actions.d.ts +10 -0
  171. package/dist/types/services/task/state-machine/constants.d.ts +107 -0
  172. package/dist/types/services/task/state-machine/guards.d.ts +103 -0
  173. package/dist/types/services/task/state-machine/index.d.ts +13 -0
  174. package/dist/types/services/task/state-machine/types.d.ts +277 -0
  175. package/dist/types/services/task/state-machine/uiControlsComputer.d.ts +9 -0
  176. package/dist/types/services/task/taskDataNormalizer.d.ts +10 -0
  177. package/dist/types/services/task/types.d.ts +680 -66
  178. package/dist/types/services/task/voice/Voice.d.ts +227 -0
  179. package/dist/types/services/task/voice/WebRTC.d.ts +53 -0
  180. package/dist/types/services/task/voice/wxAppVoiceMethods.d.ts +55 -0
  181. package/dist/types/services/wxAppDiagnosticLogging.d.ts +51 -0
  182. package/dist/types/services/wxAppTelephonyUtils.d.ts +5 -0
  183. package/dist/types/types.d.ts +205 -9
  184. package/dist/types/utils/PageCache.d.ts +20 -3
  185. package/dist/types/webex.d.ts +1 -0
  186. package/dist/types.js +162 -4
  187. package/dist/types.js.map +1 -1
  188. package/dist/utils/PageCache.js +19 -5
  189. package/dist/utils/PageCache.js.map +1 -1
  190. package/dist/webex.js +22 -2
  191. package/dist/webex.js.map +1 -1
  192. package/package.json +16 -12
  193. package/src/cc.ts +1289 -112
  194. package/src/config.ts +13 -0
  195. package/src/constants.ts +29 -1
  196. package/src/index.ts +36 -5
  197. package/src/metrics/MetricsManager.ts +16 -0
  198. package/src/metrics/ai-docs/AGENTS.md +350 -0
  199. package/src/metrics/ai-docs/ARCHITECTURE.md +338 -0
  200. package/src/metrics/ai-docs/metrics-spec.md +921 -0
  201. package/src/metrics/behavioral-events.ts +212 -0
  202. package/src/metrics/constants.ts +52 -4
  203. package/src/services/AddressBook.ts +17 -6
  204. package/src/services/AnswerCallOnWebexService.ts +239 -0
  205. package/src/services/ApiAiAssistant.ts +356 -9
  206. package/src/services/EntryPoint.ts +59 -60
  207. package/src/services/Queue.ts +29 -12
  208. package/src/services/UserPreference.ts +509 -0
  209. package/src/services/WebexCrossClientService.ts +293 -0
  210. package/src/services/WxAppTelephonyMercurySync.ts +142 -0
  211. package/src/services/agent/ai-docs/AGENTS.md +240 -0
  212. package/src/services/agent/ai-docs/ARCHITECTURE.md +304 -0
  213. package/src/services/agent/ai-docs/agent-spec.md +505 -0
  214. package/src/services/ai-docs/AGENTS.md +386 -0
  215. package/src/services/ai-docs/services-spec.md +505 -0
  216. package/src/services/config/Util.ts +12 -3
  217. package/src/services/config/ai-docs/AGENTS.md +255 -0
  218. package/src/services/config/ai-docs/ARCHITECTURE.md +426 -0
  219. package/src/services/config/ai-docs/config-spec.md +686 -0
  220. package/src/services/config/constants.ts +46 -6
  221. package/src/services/config/index.ts +65 -0
  222. package/src/services/config/types.ts +197 -13
  223. package/src/services/core/Err.ts +3 -0
  224. package/src/services/core/Utils.ts +127 -37
  225. package/src/services/core/WebexRequest.ts +3 -1
  226. package/src/services/core/ai-docs/AGENTS.md +381 -0
  227. package/src/services/core/ai-docs/ARCHITECTURE.md +698 -0
  228. package/src/services/core/ai-docs/core-spec.md +787 -0
  229. package/src/services/core/aqm-reqs.ts +30 -15
  230. package/src/services/core/types.ts +2 -0
  231. package/src/services/core/websocket/WebSocketManager.ts +2 -0
  232. package/src/services/core/websocket/types.ts +1 -1
  233. package/src/services/index.ts +1 -1
  234. package/src/services/task/Task.ts +908 -0
  235. package/src/services/task/TaskFactory.ts +60 -0
  236. package/src/services/task/TaskManager.ts +1419 -608
  237. package/src/services/task/TaskUtils.ts +303 -25
  238. package/src/services/task/WebexCallingUtils.ts +136 -0
  239. package/src/services/task/ai-docs/AGENTS.md +457 -0
  240. package/src/services/task/ai-docs/ARCHITECTURE.md +595 -0
  241. package/src/services/task/ai-docs/task-spec.md +1474 -0
  242. package/src/services/task/constants.ts +10 -0
  243. package/src/services/task/contact.ts +30 -0
  244. package/src/services/task/dialer.ts +80 -0
  245. package/src/services/task/digital/Digital.ts +97 -0
  246. package/src/services/task/state-machine/TaskStateMachine.ts +1320 -0
  247. package/src/services/task/state-machine/actions.ts +741 -0
  248. package/src/services/task/state-machine/ai-docs/AGENTS.md +460 -0
  249. package/src/services/task/state-machine/ai-docs/ARCHITECTURE.md +1156 -0
  250. package/src/services/task/state-machine/ai-docs/task-state-machine-spec.md +2247 -0
  251. package/src/services/task/state-machine/constants.ts +172 -0
  252. package/src/services/task/state-machine/guards.ts +442 -0
  253. package/src/services/task/state-machine/index.ts +28 -0
  254. package/src/services/task/state-machine/types.ts +258 -0
  255. package/src/services/task/state-machine/uiControlsComputer.ts +1135 -0
  256. package/src/services/task/taskDataNormalizer.ts +137 -0
  257. package/src/services/task/types.ts +819 -70
  258. package/src/services/task/voice/Voice.ts +1851 -0
  259. package/src/services/task/voice/WebRTC.ts +191 -0
  260. package/src/services/task/voice/wxAppVoiceMethods.ts +516 -0
  261. package/src/services/wxAppDiagnosticLogging.ts +174 -0
  262. package/src/services/wxAppTelephonyUtils.ts +14 -0
  263. package/src/types.ts +262 -11
  264. package/src/utils/AGENTS.md +289 -0
  265. package/src/utils/PageCache.ts +38 -5
  266. package/src/utils/ai-docs/utils-spec.md +391 -0
  267. package/src/webex.js +10 -0
  268. package/test/unit/spec/cc.ts +2210 -119
  269. package/test/unit/spec/logger-proxy.ts +70 -0
  270. package/test/unit/spec/metrics/MetricsManager.ts +25 -0
  271. package/test/unit/spec/metrics/behavioral-events.ts +60 -0
  272. package/test/unit/spec/services/AddressBook.ts +37 -6
  273. package/test/unit/spec/services/AnswerCallOnWebexService.ts +264 -0
  274. package/test/unit/spec/services/ApiAiAssistant.ts +311 -21
  275. package/test/unit/spec/services/EntryPoint.ts +87 -40
  276. package/test/unit/spec/services/Queue.ts +123 -12
  277. package/test/unit/spec/services/UserPreference.ts +401 -0
  278. package/test/unit/spec/services/WebCallingService.ts +7 -1
  279. package/test/unit/spec/services/WebexCrossClientService.ts +467 -0
  280. package/test/unit/spec/services/WxAppTelephonyMercurySync.ts +136 -0
  281. package/test/unit/spec/services/config/Util.ts +129 -0
  282. package/test/unit/spec/services/config/index.ts +97 -29
  283. package/test/unit/spec/services/core/Utils.ts +425 -8
  284. package/test/unit/spec/services/core/WebexRequest.ts +3 -1
  285. package/test/unit/spec/services/core/aqm-reqs.ts +113 -1
  286. package/test/unit/spec/services/core/websocket/WebSocketManager.ts +66 -40
  287. package/test/unit/spec/services/task/AutoWrapup.ts +63 -0
  288. package/test/unit/spec/services/task/Task.ts +745 -0
  289. package/test/unit/spec/services/task/TaskFactory.ts +99 -0
  290. package/test/unit/spec/services/task/TaskManager.ts +2518 -1494
  291. package/test/unit/spec/services/task/TaskUtils.ts +235 -0
  292. package/test/unit/spec/services/task/WebexCallingUtils.ts +153 -0
  293. package/test/unit/spec/services/task/contact.ts +33 -0
  294. package/test/unit/spec/services/task/dialer.ts +190 -0
  295. package/test/unit/spec/services/task/digital/Digital.ts +105 -0
  296. package/test/unit/spec/services/task/state-machine/TaskStateMachine.ts +4060 -0
  297. package/test/unit/spec/services/task/state-machine/guards.ts +839 -0
  298. package/test/unit/spec/services/task/state-machine/types.ts +18 -0
  299. package/test/unit/spec/services/task/state-machine/uiControlsComputer.ts +3101 -0
  300. package/test/unit/spec/services/task/taskTestUtils.ts +87 -0
  301. package/test/unit/spec/services/task/voice/Voice.ts +2061 -0
  302. package/test/unit/spec/services/task/voice/WebRTC.ts +235 -0
  303. package/test/unit/spec/services/task/voice/wxAppVoiceMethods.ts +603 -0
  304. package/test/unit/spec/services/wxAppDiagnosticLogging.ts +143 -0
  305. package/test/unit/spec/wellnessSampleUtils.ts +288 -0
  306. package/umd/contact-center.min.js +2 -2
  307. package/umd/contact-center.min.js.map +1 -1
  308. package/dist/services/task/index.js +0 -1525
  309. package/dist/services/task/index.js.map +0 -1
  310. package/dist/types/services/task/index.d.ts +0 -650
  311. package/src/services/task/index.ts +0 -1801
  312. package/test/unit/spec/services/task/index.ts +0 -2184
@@ -5,16 +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, TaskState} 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;
16
20
  let mockApiAIAssistant;
17
21
  let webSocketManagerMock;
22
+ let rtdWebSocketManagerMock;
18
23
  let onSpy;
19
24
  let offSpy;
20
25
  let taskManager;
@@ -24,6 +29,111 @@ describe('TaskManager', () => {
24
29
  let webex: WebexSDK;
25
30
  const taskId = '0ae913a4-c857-4705-8d49-76dd3dde75e4';
26
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
+
27
137
  taskDataMock = {
28
138
  type: CC_EVENTS.AGENT_CONTACT_RESERVED,
29
139
  agentId: '723a8ffb-a26e-496d-b14a-ff44fb83b64f',
@@ -43,18 +153,21 @@ describe('TaskManager', () => {
43
153
  data: taskDataMock,
44
154
  };
45
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
+
46
167
  beforeEach(() => {
47
168
  contactMock = contact;
48
169
  webSocketManagerMock = new EventEmitter();
49
- mockApiAIAssistant = {
50
- sendEvent: jest.fn().mockResolvedValue({}),
51
- setAIFeatureFlags: jest.fn(),
52
- aiFeature: {
53
- realtimeTranscripts: {
54
- enable: true,
55
- },
56
- },
57
- };
170
+ rtdWebSocketManagerMock = new EventEmitter();
58
171
 
59
172
  webex = {
60
173
  logger: {
@@ -84,20 +197,25 @@ describe('TaskManager', () => {
84
197
  onSpy = jest.spyOn(webCallingService, 'on');
85
198
  offSpy = jest.spyOn(webCallingService, 'off');
86
199
 
87
- taskManager = new TaskManager(mockApiAIAssistant, contactMock, webCallingService, webSocketManagerMock);
88
- const taskMock = {
89
- emit: jest.fn(),
90
- accept: jest.fn(),
91
- decline: jest.fn(),
92
- updateTaskData: jest.fn().mockImplementation((updatedData) => {
93
- taskMock.data = {...taskMock.data, ...updatedData};
94
- return taskMock;
95
- }),
96
- data: taskDataMock,
200
+ mockApiAIAssistant = {
201
+ sendEvent: jest.fn().mockResolvedValue({}),
97
202
  };
98
- taskManager.taskCollection[taskId] = taskMock;
99
- taskManager.agentId = 'test-agent-id';
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]);
100
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));
101
219
  });
102
220
 
103
221
  afterEach(() => {
@@ -108,7 +226,8 @@ describe('TaskManager', () => {
108
226
  it('should initialize TaskManager and register listeners', () => {
109
227
  webSocketManagerMock.emit('message', JSON.stringify({data: taskDataMock}));
110
228
  const incomingCallCb = onSpy.mock.calls[0][1];
111
- const taskEmitSpy = jest.spyOn(taskManager, 'emit');
229
+ const incomingHandler = jest.fn();
230
+ taskManager.on(TASK_EVENTS.TASK_INCOMING, incomingHandler);
112
231
 
113
232
  expect(taskManager).toBeInstanceOf(TaskManager);
114
233
  expect(webCallingService.listenerCount(LINE_EVENTS.INCOMING_CALL)).toBe(1);
@@ -117,10 +236,8 @@ describe('TaskManager', () => {
117
236
 
118
237
  incomingCallCb(mockCall);
119
238
 
120
- expect(taskEmitSpy).toHaveBeenCalledWith(
121
- TASK_EVENTS.TASK_INCOMING,
122
- taskManager.getTask(taskId)
123
- );
239
+ expect(incomingHandler).toHaveBeenCalledWith(taskManager.getTask(taskId));
240
+ taskManager.off(TASK_EVENTS.TASK_INCOMING, incomingHandler);
124
241
  });
125
242
 
126
243
  it('should re-emit task related events', () => {
@@ -136,15 +253,32 @@ describe('TaskManager', () => {
136
253
 
137
254
  webSocketManagerMock.emit('message', JSON.stringify(dummyPayload));
138
255
 
139
- expect(taskEmitSpy).toHaveBeenCalledWith(dummyPayload.data.type, dummyPayload.data);
256
+ expect(taskEmitSpy).toHaveBeenCalledWith(
257
+ TASK_EVENTS.TASK_CONSULT_ACCEPTED,
258
+ taskManager.getTask(taskId)
259
+ );
140
260
  });
141
261
 
142
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;
143
277
  const message = (type: CC_EVENTS) =>
144
278
  JSON.stringify({
145
279
  data: {
146
280
  ...taskDataMock,
147
- taskId,
281
+ interactionId,
148
282
  type,
149
283
  },
150
284
  });
@@ -153,67 +287,114 @@ describe('TaskManager', () => {
153
287
  webSocketManagerMock.emit('message', message(CC_EVENTS.AGENT_CONSULTING));
154
288
  webSocketManagerMock.emit('message', message(CC_EVENTS.AGENT_CONSULT_CONFERENCED));
155
289
  webSocketManagerMock.emit('message', message(CC_EVENTS.AGENT_CONSULT_ENDED));
156
- webSocketManagerMock.emit('message', message(CC_EVENTS.AGENT_WRAPUP));
157
290
  webSocketManagerMock.emit('message', message(CC_EVENTS.PARTICIPANT_LEFT_CONFERENCE));
291
+ webSocketManagerMock.emit('message', message(CC_EVENTS.AGENT_WRAPUP));
158
292
 
159
293
  expect(mockApiAIAssistant.sendEvent).toHaveBeenCalledTimes(6);
160
294
  expect(mockApiAIAssistant.sendEvent).toHaveBeenCalledWith(
161
295
  'test-agent-id',
162
- taskId,
296
+ interactionId,
163
297
  'CUSTOM_EVENT',
164
298
  'GET_TRANSCRIPTS',
165
- 'START'
299
+ {action: 'START'}
166
300
  );
167
301
  expect(mockApiAIAssistant.sendEvent).toHaveBeenCalledWith(
168
302
  'test-agent-id',
169
- taskId,
303
+ interactionId,
170
304
  'CUSTOM_EVENT',
171
305
  'GET_TRANSCRIPTS',
172
- 'STOP'
306
+ {action: 'STOP'}
173
307
  );
174
308
  });
175
309
 
176
- it('should not invoke sendEvent for transcript events when realtime transcript feature is disabled', () => {
177
- mockApiAIAssistant.aiFeature = {
178
- realtimeTranscripts: {
179
- enable: false,
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
+ },
180
321
  },
181
- };
182
- mockApiAIAssistant.setAIFeatureFlags(mockApiAIAssistant.aiFeature);
322
+ });
183
323
 
184
324
  const message = (type: CC_EVENTS) =>
185
325
  JSON.stringify({
186
326
  data: {
187
327
  ...taskDataMock,
188
- taskId,
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,
189
358
  type,
190
359
  },
191
360
  });
192
361
 
193
362
  webSocketManagerMock.emit('message', message(CC_EVENTS.AGENT_CONTACT_ASSIGNED));
194
363
  webSocketManagerMock.emit('message', message(CC_EVENTS.AGENT_CONSULTING));
195
- webSocketManagerMock.emit('message', message(CC_EVENTS.AGENT_CONSULT_CONFERENCED));
196
- webSocketManagerMock.emit('message', message(CC_EVENTS.AGENT_CONSULT_ENDED));
197
- webSocketManagerMock.emit('message', message(CC_EVENTS.AGENT_WRAPUP));
198
- webSocketManagerMock.emit('message', message(CC_EVENTS.PARTICIPANT_LEFT_CONFERENCE));
199
364
 
200
365
  expect(mockApiAIAssistant.sendEvent).not.toHaveBeenCalled();
201
366
  });
202
367
 
203
- it('should emit REAL_TIME_TRANSCRIPTION from task object', () => {
368
+ it('should emit REAL_TIME_TRANSCRIPTION from RTD websocket payload', () => {
204
369
  const task = taskManager.getTask(taskId);
205
370
  const taskEmitSpy = jest.spyOn(task, 'emit');
206
371
  const realtimePayload = {
207
372
  data: {
208
- ...taskDataMock,
209
- type: CC_EVENTS.REAL_TIME_TRANSCRIPTION,
373
+ agentId: 'test-agent-id',
210
374
  data: {
211
- content: 'hello from transcript',
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',
212
385
  },
386
+ notifDetails: {
387
+ actionEvent: 'REAL_TIME_TRANSCRIPTION',
388
+ },
389
+ notifType: 'REAL_TIME_TRANSCRIPTION',
390
+ orgId: 'org-id',
213
391
  },
392
+ orgId: 'org-id',
393
+ trackingId: 'notifs_tracking-id',
394
+ type: 'REAL_TIME_TRANSCRIPTION',
214
395
  };
215
396
 
216
- webSocketManagerMock.emit('message', JSON.stringify(realtimePayload));
397
+ taskManager.handleRealtimeWebsocketEvent(JSON.stringify(realtimePayload));
217
398
 
218
399
  expect(taskEmitSpy).toHaveBeenCalledWith(
219
400
  CC_EVENTS.REAL_TIME_TRANSCRIPTION,
@@ -221,22 +402,37 @@ describe('TaskManager', () => {
221
402
  );
222
403
  });
223
404
 
224
- it('should emit REAL_TIME_TRANSCRIPTION from RTD websocket payload on task object', () => {
225
- const task = taskManager.getTask(taskId);
226
- const taskEmitSpy = jest.spyOn(task, 'emit');
405
+ it('should ignore RTD transcript events when task is not found', () => {
227
406
  const realtimePayload = {
228
407
  data: {
229
- notifType: CC_EVENTS.REAL_TIME_TRANSCRIPTION,
230
408
  data: {
231
- conversationId: taskId,
232
- content: 'hello from rtd websocket',
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',
233
422
  },
423
+ notifType: 'REAL_TIME_TRANSCRIPTION',
424
+ orgId: 'org-id',
234
425
  },
426
+ orgId: 'org-id',
427
+ trackingId: 'notifs_tracking-id',
428
+ type: 'REAL_TIME_TRANSCRIPTION',
235
429
  };
236
430
 
431
+ const existingTaskEmitSpy = jest.spyOn(taskManager.getTask(taskId), 'emit');
432
+
237
433
  taskManager.handleRealtimeWebsocketEvent(JSON.stringify(realtimePayload));
238
434
 
239
- expect(taskEmitSpy).toHaveBeenCalledWith(CC_EVENTS.REAL_TIME_TRANSCRIPTION, realtimePayload.data);
435
+ expect(existingTaskEmitSpy).not.toHaveBeenCalled();
240
436
  });
241
437
 
242
438
  it('should not re-emit agent related events', () => {
@@ -254,7 +450,7 @@ describe('TaskManager', () => {
254
450
 
255
451
  webSocketManagerMock.emit('message', JSON.stringify(dummyPayload));
256
452
 
257
- expect(taskEmitSpy).not.toHaveBeenCalledWith(dummyPayload.data.type, dummyPayload.data);
453
+ expect(taskEmitSpy).not.toHaveBeenCalled();
258
454
  });
259
455
 
260
456
  it('should handle WebSocket message for AGENT_CONTACT_RESERVED and emit task:incoming for browser case', () => {
@@ -275,14 +471,12 @@ describe('TaskManager', () => {
275
471
  },
276
472
  };
277
473
 
278
- const taskIncomingSpy = jest.spyOn(taskManager, 'emit');
474
+ const incomingHandler = jest.fn();
475
+ taskManager.on(TASK_EVENTS.TASK_INCOMING, incomingHandler);
279
476
 
280
477
  webSocketManagerMock.emit('message', JSON.stringify(payload));
281
478
 
282
- expect(taskIncomingSpy).toHaveBeenCalledWith(
283
- TASK_EVENTS.TASK_INCOMING,
284
- taskManager.getTask(payload.data.interactionId)
285
- );
479
+ expect(incomingHandler).toHaveBeenCalledWith(taskManager.getTask(payload.data.interactionId));
286
480
  expect(taskManager.getTask(payload.data.interactionId)).toBe(taskManager.getTask(taskId));
287
481
  expect(taskManager.getAllTasks()).toHaveProperty(payload.data.interactionId);
288
482
 
@@ -307,6 +501,7 @@ describe('TaskManager', () => {
307
501
  taskManager.getTask(payload.data.interactionId),
308
502
  'emit'
309
503
  );
504
+ const taskManagerEmitSpy = jest.spyOn(taskManager, 'emit');
310
505
 
311
506
  webSocketManagerMock.emit('message', JSON.stringify(assignedPayload));
312
507
 
@@ -314,6 +509,10 @@ describe('TaskManager', () => {
314
509
  TASK_EVENTS.TASK_ASSIGNED,
315
510
  taskManager.getTask(taskId)
316
511
  );
512
+ expect(taskManagerEmitSpy).toHaveBeenCalledWith(
513
+ TASK_EVENTS.TASK_MULTI_LOGIN_HYDRATE,
514
+ taskManager.getTask(taskId)
515
+ );
317
516
  });
318
517
 
319
518
  it('should handle WebSocket message for AGENT_CONTACT_RESERVED and emit task:incoming for extension case', () => {
@@ -335,16 +534,64 @@ describe('TaskManager', () => {
335
534
  },
336
535
  };
337
536
 
338
- const taskIncomingSpy = jest.spyOn(taskManager, 'emit');
537
+ const incomingHandler = jest.fn();
538
+ taskManager.on(TASK_EVENTS.TASK_INCOMING, incomingHandler);
339
539
 
340
540
  webSocketManagerMock.emit('message', JSON.stringify(payload));
341
541
 
342
- expect(taskIncomingSpy).toHaveBeenCalledWith(
343
- TASK_EVENTS.TASK_INCOMING,
344
- taskManager.getTask(taskId)
345
- );
542
+ expect(incomingHandler).toHaveBeenCalledWith(taskManager.getTask(taskId));
346
543
  expect(taskManager.getTask(payload.data.interactionId)).toBe(taskManager.getTask(taskId));
347
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);
348
595
  });
349
596
 
350
597
  it('should return task by ID', () => {
@@ -429,12 +676,30 @@ describe('TaskManager', () => {
429
676
  it('test call listeners being switched off on call end', () => {
430
677
  webSocketManagerMock.emit('message', JSON.stringify(initalPayload));
431
678
 
432
- const taskEmitSpy = jest.spyOn(taskManager.getTask(taskId), 'emit');
433
- const webCallListenerSpy = jest.spyOn(
434
- taskManager.getTask(taskId),
435
- 'unregisterWebCallListeners'
436
- );
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');
437
701
  const callOffSpy = jest.spyOn(mockCall, 'off');
702
+ const sendStateMachineEventSpy = jest.spyOn(task, 'sendStateMachineEvent');
438
703
  const payload = {
439
704
  data: {
440
705
  type: CC_EVENTS.CONTACT_ENDED,
@@ -452,10 +717,25 @@ describe('TaskManager', () => {
452
717
  },
453
718
  };
454
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
+
455
732
  taskManager.getTask(taskId).data = payload.data;
456
- const task = taskManager.getTask(taskId);
457
733
  webSocketManagerMock.emit('message', JSON.stringify(payload));
458
- expect(taskEmitSpy).toHaveBeenCalledWith(TASK_EVENTS.TASK_END, task);
734
+ const stateMachineEvent = expectLastStateMachineEvent(
735
+ sendStateMachineEventSpy,
736
+ TaskEvent.CONTACT_ENDED
737
+ );
738
+ expect(stateMachineEvent?.taskData.wrapUpRequired).toBe(false);
459
739
  expect(webCallListenerSpy).toHaveBeenCalledWith();
460
740
  expect(callOffSpy).toHaveBeenCalledWith(
461
741
  CALL_EVENT_KEYS.REMOTE_MEDIA,
@@ -466,12 +746,13 @@ describe('TaskManager', () => {
466
746
  expect(offSpy.mock.calls.length).toBe(2); // 1 for incoming call and 1 for remote media
467
747
  expect(offSpy).toHaveBeenCalledWith(CALL_EVENT_KEYS.REMOTE_MEDIA, offSpy.mock.calls[0][1]);
468
748
  expect(offSpy).toHaveBeenCalledWith(LINE_EVENTS.INCOMING_CALL, offSpy.mock.calls[1][1]);
749
+ sendStateMachineEventSpy.mockRestore();
469
750
  });
470
751
 
471
752
  it('should emit TASK_END event with wrapupRequired on regular call end', () => {
472
753
  webSocketManagerMock.emit('message', JSON.stringify(initalPayload));
473
-
474
- const taskEmitSpy = jest.spyOn(taskManager.getTask(taskId), 'emit');
754
+ const task = taskManager.getTask(taskId);
755
+ const sendStateMachineEventSpy = jest.spyOn(task, 'sendStateMachineEvent');
475
756
  const payload = {
476
757
  data: {
477
758
  type: CC_EVENTS.CONTACT_ENDED,
@@ -486,20 +767,25 @@ describe('TaskManager', () => {
486
767
  destAgentId: 'ebeb893b-ba67-4f36-8418-95c7492b28c2',
487
768
  owner: '723a8ffb-a26e-496d-b14a-ff44fb83b64f',
488
769
  queueMgr: 'aqm',
770
+ agentsPendingWrapUp: ['test-agent-id'],
489
771
  },
490
772
  };
491
773
 
492
- taskManager.getTask(taskId).updateTaskData(payload.data);
774
+ task.updateTaskData(payload.data);
493
775
  webSocketManagerMock.emit('message', JSON.stringify(payload));
494
- expect(taskEmitSpy).toHaveBeenCalledWith(CC_EVENTS.CONTACT_ENDED, {...payload.data});
495
- expect(taskEmitSpy).toHaveBeenCalledWith(TASK_EVENTS.TASK_END, taskManager.getTask(taskId));
776
+ const stateMachineEvent = expectLastStateMachineEvent(
777
+ sendStateMachineEventSpy,
778
+ TaskEvent.CONTACT_ENDED
779
+ );
780
+ expect(stateMachineEvent?.taskData.wrapUpRequired).toBe(true);
781
+ sendStateMachineEventSpy.mockRestore();
496
782
  });
497
783
 
498
784
  it('should emit TASK_REJECT event on AGENT_INVITE_FAILED event', () => {
499
785
  webSocketManagerMock.emit('message', JSON.stringify(initalPayload));
500
786
 
501
- const taskEmitSpy = jest.spyOn(taskManager.getTask(taskId), 'emit');
502
- const metricsTrackSpy = jest.spyOn(taskManager.metricsManager, 'trackEvent');
787
+ const task = taskManager.getTask(taskId);
788
+ const sendStateMachineEventSpy = jest.spyOn(task, 'sendStateMachineEvent');
503
789
  const payload = {
504
790
  data: {
505
791
  type: CC_EVENTS.AGENT_INVITE_FAILED,
@@ -518,35 +804,40 @@ describe('TaskManager', () => {
518
804
  },
519
805
  };
520
806
 
521
- taskManager.getTask(taskId).updateTaskData(payload.data);
807
+ task.updateTaskData(payload.data);
522
808
  webSocketManagerMock.emit('message', JSON.stringify(payload));
523
- expect(taskEmitSpy).toHaveBeenCalledWith(CC_EVENTS.AGENT_INVITE_FAILED, {...payload.data});
524
- expect(taskEmitSpy).toHaveBeenCalledWith(TASK_EVENTS.TASK_REJECT, payload.data.reason);
525
- // Verify the correct metric event name is used for AGENT_INVITE_FAILED
526
- expect(metricsTrackSpy).toHaveBeenCalled();
527
- expect(metricsTrackSpy.mock.calls[0][0]).toBe('Agent Invite Failed');
809
+ const stateMachineEvent = expectLastStateMachineEvent(
810
+ sendStateMachineEventSpy,
811
+ TaskEvent.INVITE_FAILED
812
+ );
813
+ expect(stateMachineEvent?.reason).toBe(payload.data.reason);
814
+ sendStateMachineEventSpy.mockRestore();
528
815
  });
529
816
 
530
- 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', () => {
531
818
  const payload = {
532
819
  data: {
533
820
  ...initalPayload.data,
534
821
  type: CC_EVENTS.AGENT_CONTACT,
535
822
  },
536
823
  };
537
- const taskEmitSpy = jest.spyOn(taskManager, 'emit');
824
+ const existingTask = taskManager.getTask(taskId);
825
+ const sendStateMachineEventSpy = jest.spyOn(existingTask, 'sendStateMachineEvent');
538
826
  webSocketManagerMock.emit('message', JSON.stringify(payload));
539
827
 
540
- expect(taskEmitSpy).not.toHaveBeenCalledWith(
541
- TASK_EVENTS.TASK_HYDRATE,
542
- taskManager.getTask(taskId)
828
+ const stateMachineEvent = expectLastStateMachineEvent(
829
+ sendStateMachineEventSpy,
830
+ TaskEvent.HYDRATE
543
831
  );
832
+ expect(stateMachineEvent?.taskData).toEqual(payload.data);
833
+ expect(existingTask).toBe(taskManager.getTask(taskId));
544
834
  expect(taskManager.taskCollection[payload.data.interactionId]).toBe(
545
835
  taskManager.getTask(taskId)
546
836
  );
837
+ sendStateMachineEventSpy.mockRestore();
547
838
  });
548
839
 
549
- 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', () => {
550
841
  taskManager.taskCollection = [];
551
842
  const payload = {
552
843
  data: {
@@ -556,13 +847,15 @@ describe('TaskManager', () => {
556
847
  },
557
848
  };
558
849
 
559
- const taskEmitSpy = jest.spyOn(taskManager, 'emit');
560
850
  webSocketManagerMock.emit('message', JSON.stringify(payload));
561
851
 
562
- expect(taskEmitSpy).toHaveBeenCalledWith(
563
- TASK_EVENTS.TASK_INCOMING,
564
- 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
565
857
  );
858
+ expect(stateMachineEvent?.taskData).toEqual(payload.data);
566
859
  expect(taskManager.taskCollection[payload.data.interactionId]).toBe(
567
860
  taskManager.getTask(taskId)
568
861
  );
@@ -577,10 +870,15 @@ describe('TaskManager', () => {
577
870
  },
578
871
  };
579
872
 
580
- const taskEmitSpy = jest.spyOn(taskManager, 'emit');
581
873
  webSocketManagerMock.emit('message', JSON.stringify(payload));
582
874
 
583
- 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);
584
882
  expect(taskManager.taskCollection[payload.data.interactionId]).toBe(
585
883
  taskManager.getTask(taskId)
586
884
  );
@@ -653,7 +951,7 @@ describe('TaskManager', () => {
653
951
  expect(createdTask.data.isConferenceInProgress).toBe(false);
654
952
  });
655
953
 
656
- it('should emit TASK_END event on AGENT_WRAPUP event', () => {
954
+ it('should emit TASK_WRAPUP event on AGENT_WRAPUP event', () => {
657
955
  webSocketManagerMock.emit('message', JSON.stringify(initalPayload));
658
956
 
659
957
  const wrapupPayload = {
@@ -675,13 +973,15 @@ describe('TaskManager', () => {
675
973
  };
676
974
 
677
975
  const task = taskManager.getTask(taskId);
678
- const updateTaskDataSpy = jest.spyOn(task, 'updateTaskData');
679
- const taskEmitSpy = jest.spyOn(task, 'emit');
976
+ const updateTaskDataSpy = task.updateTaskData as jest.Mock;
977
+ updateTaskDataSpy.mockClear();
978
+ const sendStateMachineEventSpy = jest.spyOn(task, 'sendStateMachineEvent');
680
979
 
681
980
  webSocketManagerMock.emit('message', JSON.stringify(wrapupPayload));
682
981
 
683
982
  expect(updateTaskDataSpy).toHaveBeenCalledWith(wrapupPayload.data);
684
- expect(taskEmitSpy).toHaveBeenCalledWith(TASK_EVENTS.TASK_END, task);
983
+ expectLastStateMachineEvent(sendStateMachineEventSpy, TaskEvent.TASK_WRAPUP);
984
+ sendStateMachineEventSpy.mockRestore();
685
985
  });
686
986
 
687
987
  it('should emit TASK_HOLD event on AGENT_CONTACT_HELD event', () => {
@@ -704,13 +1004,20 @@ describe('TaskManager', () => {
704
1004
  },
705
1005
  };
706
1006
 
707
- const taskEmitSpy = jest.spyOn(taskManager.getTask(taskId), 'emit');
708
- 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');
709
1011
 
710
1012
  webSocketManagerMock.emit('message', JSON.stringify(payload));
711
1013
 
712
1014
  expect(taskUpdateTaskDataSpy).toHaveBeenCalledWith(payload.data);
713
- 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();
714
1021
  });
715
1022
 
716
1023
  it('should emit TASK_RESUME event on AGENT_CONTACT_UNHELD event', () => {
@@ -733,11 +1040,18 @@ describe('TaskManager', () => {
733
1040
  },
734
1041
  };
735
1042
 
736
- const taskEmitSpy = jest.spyOn(taskManager.getTask(taskId), 'emit');
737
- 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');
738
1047
  webSocketManagerMock.emit('message', JSON.stringify(payload));
739
1048
  expect(taskUpdateTaskDataSpy).toHaveBeenCalledWith(payload.data);
740
- 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();
741
1055
  });
742
1056
 
743
1057
  it('handle AGENT_CONSULT_CREATED event', () => {
@@ -750,17 +1064,16 @@ describe('TaskManager', () => {
750
1064
 
751
1065
  webSocketManagerMock.emit('message', JSON.stringify(initalPayload));
752
1066
  const task = taskManager.getTask(taskId);
753
- const taskUpdateTaskDataSpy = jest.spyOn(task, 'updateTaskData');
754
- const taskEmitSpy = jest.spyOn(task, 'emit');
1067
+ const sendStateMachineEventSpy = jest.spyOn(task, 'sendStateMachineEvent');
755
1068
 
756
1069
  webSocketManagerMock.emit('message', JSON.stringify(payload));
757
1070
 
758
- expect(taskUpdateTaskDataSpy).toHaveBeenCalledWith({
759
- ...payload.data,
760
- isConsulted: false,
761
- });
762
- expect(task.data.isConsulted).toBe(false);
763
- 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();
764
1077
  });
765
1078
 
766
1079
  it('handle AGENT_OFFER_CONTACT event', () => {
@@ -773,11 +1086,17 @@ describe('TaskManager', () => {
773
1086
 
774
1087
  webSocketManagerMock.emit('message', JSON.stringify(initalPayload));
775
1088
 
776
- const taskUpdateTaskDataSpy = jest.spyOn(taskManager.getTask(taskId), 'updateTaskData');
1089
+ const task = taskManager.getTask(taskId);
1090
+ const sendStateMachineEventSpy = jest.spyOn(task, 'sendStateMachineEvent');
777
1091
 
778
1092
  webSocketManagerMock.emit('message', JSON.stringify(payload));
779
1093
 
780
- 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();
781
1100
  });
782
1101
 
783
1102
  describe('Auto-Answer Functionality', () => {
@@ -787,8 +1106,8 @@ describe('TaskManager', () => {
787
1106
 
788
1107
  const task = taskManager.getTask(taskId);
789
1108
  const taskEmitSpy = jest.spyOn(task, 'emit');
790
- const taskManagerEmitSpy = jest.spyOn(taskManager, 'emit');
791
1109
  const taskAcceptSpy = jest.spyOn(task, 'accept').mockResolvedValue(undefined);
1110
+ const sendStateMachineEventSpy = jest.spyOn(task, 'sendStateMachineEvent');
792
1111
 
793
1112
  // Step 2: Trigger AGENT_OFFER_CONTACT with auto-answer
794
1113
  const autoAnswerPayload = {
@@ -812,9 +1131,14 @@ describe('TaskManager', () => {
812
1131
  // Verify accept was called
813
1132
  expect(taskAcceptSpy).toHaveBeenCalledTimes(1);
814
1133
 
815
- // Verify BOTH events were emitted
816
- 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
817
1140
  expect(taskEmitSpy).toHaveBeenCalledWith(TASK_EVENTS.TASK_AUTO_ANSWERED, task);
1141
+ sendStateMachineEventSpy.mockRestore();
818
1142
  });
819
1143
 
820
1144
  it('should NOT emit TASK_AUTO_ANSWERED event when auto-answer fails', async () => {
@@ -860,6 +1184,7 @@ describe('TaskManager', () => {
860
1184
  const task = taskManager.getTask(taskId);
861
1185
  const taskEmitSpy = jest.spyOn(task, 'emit');
862
1186
  const taskAcceptSpy = jest.spyOn(task, 'accept').mockResolvedValue(undefined);
1187
+ const sendStateMachineEventSpy = jest.spyOn(task, 'sendStateMachineEvent');
863
1188
 
864
1189
  // Step 2: Trigger AGENT_OFFER_CONSULT with auto-answer
865
1190
  const consultAutoAnswerPayload = {
@@ -884,12 +1209,20 @@ describe('TaskManager', () => {
884
1209
  // Verify accept was called
885
1210
  expect(taskAcceptSpy).toHaveBeenCalledTimes(1);
886
1211
 
887
- // Verify BOTH events were emitted
888
- 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
889
1221
  expect(taskEmitSpy).toHaveBeenCalledWith(TASK_EVENTS.TASK_AUTO_ANSWERED, task);
890
1222
 
891
1223
  // Verify isConsulted flag is set correctly
892
1224
  expect(task.data.isConsulted).toBe(true);
1225
+ sendStateMachineEventSpy.mockRestore();
893
1226
  });
894
1227
 
895
1228
  it('should NOT emit TASK_AUTO_ANSWERED when isAutoAnswering is false', async () => {
@@ -930,24 +1263,20 @@ describe('TaskManager', () => {
930
1263
  });
931
1264
  });
932
1265
 
933
- 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', () => {
934
1267
  const task = taskManager.getTask(taskId);
935
- task.updateTaskData = jest.fn().mockImplementation((newData) => {
936
- task.data = {
937
- ...task.data,
938
- ...newData,
939
- interaction: {
940
- ...task.data.interaction,
941
- ...newData.interaction,
942
- outboundType: 'OUTDIAL',
943
- state: 'new',
944
- isTerminated: true,
945
- },
946
- };
947
- 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'],
948
1276
  });
949
1277
  task.unregisterWebCallListeners = jest.fn();
950
1278
  const removeTaskSpy = jest.spyOn(taskManager, 'removeTaskFromCollection');
1279
+ const sendStateMachineEventSpy = jest.spyOn(task, 'sendStateMachineEvent');
951
1280
 
952
1281
  const payload = {
953
1282
  data: {
@@ -960,6 +1289,7 @@ describe('TaskManager', () => {
960
1289
  state: 'new',
961
1290
  isTerminated: true,
962
1291
  },
1292
+ agentsPendingWrapUp: ['agent-123'],
963
1293
  interactionId: taskId,
964
1294
  orgId: '6ecef209-9a34-4ed1-a07a-7ddd1dbe925a',
965
1295
  trackingId: '575c0ec2-618c-42af-a61c-53aeb0a221ee',
@@ -974,23 +1304,61 @@ describe('TaskManager', () => {
974
1304
 
975
1305
  webSocketManagerMock.emit('message', JSON.stringify(payload));
976
1306
 
977
- expect(taskManager.getTask(taskId)).toBeDefined();
978
- 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();
979
1317
  });
980
1318
 
981
1319
  it('should emit TASK_OUTDIAL_FAILED event on AGENT_OUTBOUND_FAILED', () => {
982
1320
  const task = taskManager.getTask(taskId);
983
- task.updateTaskData = jest.fn().mockReturnValue(task);
984
- 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');
985
1341
  const payload = {
986
1342
  data: {
987
1343
  type: CC_EVENTS.AGENT_OUTBOUND_FAILED,
988
1344
  interactionId: taskId,
989
1345
  reason: 'CUSTOMER_BUSY',
1346
+ agentsPendingWrapUp: ['agent-123'],
1347
+ interaction: {
1348
+ outboundType: 'OUTDIAL',
1349
+ isTerminated: true,
1350
+ },
990
1351
  },
991
1352
  };
992
1353
  webSocketManagerMock.emit('message', JSON.stringify(payload));
993
- 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();
994
1362
  });
995
1363
 
996
1364
  it('should handle AGENT_OUTBOUND_FAILED gracefully when task is undefined', () => {
@@ -1008,23 +1376,15 @@ describe('TaskManager', () => {
1008
1376
  });
1009
1377
 
1010
1378
  it('should NOT remove OUTDIAL task on CONTACT_ENDED when agentsPendingWrapUp exists', () => {
1011
- const agentId = '723a8ffb-a26e-496d-b14a-ff44fb83b64f';
1012
- taskManager.setAgentId(agentId);
1013
-
1014
1379
  const task = taskManager.getTask(taskId);
1015
- task.updateTaskData = jest.fn().mockImplementation((newData) => {
1016
- task.data = {
1017
- ...task.data,
1018
- ...newData,
1019
- interaction: {
1020
- ...task.data.interaction,
1021
- outboundType: 'OUTDIAL',
1022
- state: 'new',
1023
- mediaType: 'telephony',
1024
- },
1025
- agentsPendingWrapUp: [agentId],
1026
- };
1027
- 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'],
1028
1388
  });
1029
1389
  task.unregisterWebCallListeners = jest.fn();
1030
1390
  const removeTaskSpy = jest.spyOn(taskManager, 'removeTaskFromCollection');
@@ -1038,7 +1398,7 @@ describe('TaskManager', () => {
1038
1398
  state: 'new',
1039
1399
  mediaType: 'telephony',
1040
1400
  },
1041
- agentsPendingWrapUp: [agentId],
1401
+ agentsPendingWrapUp: ['test-agent-id'],
1042
1402
  },
1043
1403
  };
1044
1404
 
@@ -1050,19 +1410,14 @@ describe('TaskManager', () => {
1050
1410
 
1051
1411
  it('should remove OUTDIAL task on CONTACT_ENDED when agentsPendingWrapUp is empty', () => {
1052
1412
  const task = taskManager.getTask(taskId);
1053
- task.updateTaskData = jest.fn().mockImplementation((newData) => {
1054
- task.data = {
1055
- ...task.data,
1056
- ...newData,
1057
- interaction: {
1058
- ...task.data.interaction,
1059
- outboundType: 'OUTDIAL',
1060
- state: 'new',
1061
- mediaType: 'telephony',
1062
- },
1063
- agentsPendingWrapUp: [],
1064
- };
1065
- 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: [],
1066
1421
  });
1067
1422
  task.unregisterWebCallListeners = jest.fn();
1068
1423
  const removeTaskSpy = jest.spyOn(taskManager, 'removeTaskFromCollection');
@@ -1087,19 +1442,13 @@ describe('TaskManager', () => {
1087
1442
 
1088
1443
  it('should remove OUTDIAL task on CONTACT_ENDED when agentsPendingWrapUp is undefined', () => {
1089
1444
  const task = taskManager.getTask(taskId);
1090
- task.updateTaskData = jest.fn().mockImplementation((newData) => {
1091
- task.data = {
1092
- ...task.data,
1093
- ...newData,
1094
- interaction: {
1095
- ...task.data.interaction,
1096
- outboundType: 'OUTDIAL',
1097
- state: 'new',
1098
- mediaType: 'telephony',
1099
- },
1100
- // agentsPendingWrapUp is undefined
1101
- };
1102
- return task;
1445
+ Object.assign(task.data, {
1446
+ interaction: {
1447
+ ...task.data.interaction,
1448
+ outboundType: 'OUTDIAL',
1449
+ state: 'new',
1450
+ mediaType: 'telephony',
1451
+ },
1103
1452
  });
1104
1453
  task.unregisterWebCallListeners = jest.fn();
1105
1454
  const removeTaskSpy = jest.spyOn(taskManager, 'removeTaskFromCollection');
@@ -1122,300 +1471,393 @@ describe('TaskManager', () => {
1122
1471
  expect(removeTaskSpy).toHaveBeenCalled();
1123
1472
  });
1124
1473
 
1125
- 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
+
1126
1480
  const payload = {
1127
1481
  data: {
1128
1482
  type: CC_EVENTS.CONTACT_ENDED,
1129
- interactionId: 'non-existent-task-id',
1483
+ interactionId: taskId,
1484
+ terminatingParty: 'Agent',
1130
1485
  interaction: {
1131
- state: 'new',
1486
+ outboundType: 'OUTDIAL',
1487
+ state: 'wrapUp',
1488
+ mediaType: 'telephony',
1132
1489
  },
1490
+ agentsPendingWrapUp: ['test-agent-id'],
1133
1491
  },
1134
1492
  };
1135
- // Should not throw error when task doesn't exist
1136
- expect(() => {
1137
- webSocketManagerMock.emit('message', JSON.stringify(payload));
1138
- }).not.toThrow();
1493
+
1494
+ webSocketManagerMock.emit('message', JSON.stringify(payload));
1495
+
1496
+ expectLastStateMachineEvent(sendStateMachineEventSpy, TaskEvent.CONTACT_ENDED);
1497
+ expect(outdialFailedSpy).not.toHaveBeenCalled();
1498
+ sendStateMachineEventSpy.mockRestore();
1139
1499
  });
1140
1500
 
1141
- describe('wrapUpRequired logic in CONTACT_ENDED event', () => {
1142
- const agentId = '723a8ffb-a26e-496d-b14a-ff44fb83b64f';
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);
1143
1506
 
1144
- beforeEach(() => {
1145
- // Set the agent ID on taskManager
1146
- taskManager.setAgentId(agentId);
1147
- });
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
+ };
1148
1521
 
1149
- it('should set wrapUpRequired to true when agent is in agentsPendingWrapUp array', () => {
1150
- const task = taskManager.getTask(taskId);
1151
- task.updateTaskData = jest.fn().mockImplementation((newData) => {
1152
- task.data = {
1153
- ...task.data,
1154
- ...newData,
1155
- };
1156
- return task;
1157
- });
1158
- task.unregisterWebCallListeners = jest.fn();
1522
+ webSocketManagerMock.emit('message', JSON.stringify(payload));
1159
1523
 
1160
- const payload = {
1161
- data: {
1162
- type: CC_EVENTS.CONTACT_ENDED,
1163
- interactionId: taskId,
1164
- interaction: {
1165
- state: 'connected',
1166
- mediaType: 'telephony',
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',
1167
1549
  },
1168
- agentsPendingWrapUp: [agentId, 'other-agent-id'],
1169
1550
  },
1170
- };
1171
-
1172
- webSocketManagerMock.emit('message', JSON.stringify(payload));
1173
-
1174
- expect(task.updateTaskData).toHaveBeenCalledWith(
1175
- expect.objectContaining({
1176
- wrapUpRequired: true,
1177
- })
1178
- );
1179
- });
1180
-
1181
- it('should set wrapUpRequired to false when agent is not in agentsPendingWrapUp array', () => {
1182
- const task = taskManager.getTask(taskId);
1183
- task.updateTaskData = jest.fn().mockImplementation((newData) => {
1184
- task.data = {
1185
- ...task.data,
1186
- ...newData,
1187
- };
1188
- return task;
1189
- });
1190
- task.unregisterWebCallListeners = jest.fn();
1551
+ },
1552
+ };
1553
+ const sendStateMachineEventSpy = jest.spyOn(task, 'sendStateMachineEvent');
1554
+ const outdialFailedSpy = jest.fn();
1555
+ task.on(TASK_EVENTS.TASK_OUTDIAL_FAILED, outdialFailedSpy);
1191
1556
 
1192
- const payload = {
1193
- data: {
1194
- type: CC_EVENTS.CONTACT_ENDED,
1195
- interactionId: taskId,
1196
- interaction: {
1197
- state: 'connected',
1198
- mediaType: 'telephony',
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
+ },
1199
1574
  },
1200
- agentsPendingWrapUp: ['other-agent-id', 'another-agent-id'],
1201
1575
  },
1202
- };
1203
-
1204
- webSocketManagerMock.emit('message', JSON.stringify(payload));
1576
+ agentsPendingWrapUp: ['test-agent-id'],
1577
+ },
1578
+ };
1205
1579
 
1206
- expect(task.updateTaskData).toHaveBeenCalledWith(
1207
- expect.objectContaining({
1208
- wrapUpRequired: false,
1209
- })
1210
- );
1211
- });
1580
+ webSocketManagerMock.emit('message', JSON.stringify(payload));
1212
1581
 
1213
- it('should set wrapUpRequired to false when agentsPendingWrapUp is an empty array', () => {
1214
- const task = taskManager.getTask(taskId);
1215
- task.updateTaskData = jest.fn().mockImplementation((newData) => {
1216
- task.data = {
1217
- ...task.data,
1218
- ...newData,
1219
- };
1220
- return task;
1221
- });
1222
- task.unregisterWebCallListeners = jest.fn();
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
+ });
1223
1590
 
1224
- const payload = {
1225
- data: {
1226
- type: CC_EVENTS.CONTACT_ENDED,
1227
- interactionId: taskId,
1228
- interaction: {
1229
- state: 'connected',
1230
- mediaType: 'telephony',
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',
1231
1606
  },
1232
- agentsPendingWrapUp: [],
1233
1607
  },
1234
- };
1608
+ },
1609
+ };
1610
+ const sendStateMachineEventSpy = jest.spyOn(task, 'sendStateMachineEvent');
1611
+ const outdialFailedSpy = jest.fn();
1612
+ task.on(TASK_EVENTS.TASK_OUTDIAL_FAILED, outdialFailedSpy);
1235
1613
 
1236
- webSocketManagerMock.emit('message', JSON.stringify(payload));
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
+ };
1237
1635
 
1238
- expect(task.updateTaskData).toHaveBeenCalledWith(
1239
- expect.objectContaining({
1240
- wrapUpRequired: false,
1241
- })
1242
- );
1243
- });
1636
+ webSocketManagerMock.emit('message', JSON.stringify(payload));
1244
1637
 
1245
- it('should set wrapUpRequired to false when agentsPendingWrapUp is undefined', () => {
1246
- const task = taskManager.getTask(taskId);
1247
- task.updateTaskData = jest.fn().mockImplementation((newData) => {
1248
- task.data = {
1249
- ...task.data,
1250
- ...newData,
1251
- };
1252
- return task;
1253
- });
1254
- task.unregisterWebCallListeners = jest.fn();
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
+ });
1255
1647
 
1256
- const payload = {
1257
- data: {
1258
- type: CC_EVENTS.CONTACT_ENDED,
1259
- interactionId: taskId,
1260
- interaction: {
1261
- state: 'connected',
1262
- mediaType: 'telephony',
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',
1263
1666
  },
1264
- // agentsPendingWrapUp is not defined
1265
1667
  },
1266
- };
1668
+ },
1669
+ };
1670
+ const sendStateMachineEventSpy = jest.spyOn(task, 'sendStateMachineEvent');
1671
+ const outdialFailedSpy = jest.fn();
1672
+ task.on(TASK_EVENTS.TASK_OUTDIAL_FAILED, outdialFailedSpy);
1267
1673
 
1268
- webSocketManagerMock.emit('message', JSON.stringify(payload));
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
+ };
1269
1687
 
1270
- expect(task.updateTaskData).toHaveBeenCalledWith(
1271
- expect.objectContaining({
1272
- wrapUpRequired: false,
1273
- })
1274
- );
1275
- });
1688
+ webSocketManagerMock.emit('message', JSON.stringify(payload));
1276
1689
 
1277
- it('should set wrapUpRequired to false when agentsPendingWrapUp is null', () => {
1278
- const task = taskManager.getTask(taskId);
1279
- task.updateTaskData = jest.fn().mockImplementation((newData) => {
1280
- task.data = {
1281
- ...task.data,
1282
- ...newData,
1283
- };
1284
- return task;
1285
- });
1286
- task.unregisterWebCallListeners = jest.fn();
1690
+ expectLastStateMachineEvent(sendStateMachineEventSpy, TaskEvent.CONTACT_ENDED);
1691
+ expect(outdialFailedSpy).not.toHaveBeenCalled();
1692
+ sendStateMachineEventSpy.mockRestore();
1693
+ });
1287
1694
 
1288
- const payload = {
1289
- data: {
1290
- type: CC_EVENTS.CONTACT_ENDED,
1291
- interactionId: taskId,
1292
- interaction: {
1293
- state: 'connected',
1294
- mediaType: 'telephony',
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',
1295
1713
  },
1296
- agentsPendingWrapUp: null,
1297
1714
  },
1298
- };
1299
-
1300
- webSocketManagerMock.emit('message', JSON.stringify(payload));
1301
-
1302
- expect(task.updateTaskData).toHaveBeenCalledWith(
1303
- expect.objectContaining({
1304
- wrapUpRequired: false,
1305
- })
1306
- );
1307
- });
1308
-
1309
- it('should set wrapUpRequired correctly when agent is the only one in agentsPendingWrapUp', () => {
1310
- const task = taskManager.getTask(taskId);
1311
- task.updateTaskData = jest.fn().mockImplementation((newData) => {
1312
- task.data = {
1313
- ...task.data,
1314
- ...newData,
1315
- };
1316
- return task;
1317
- });
1318
- task.unregisterWebCallListeners = jest.fn();
1715
+ },
1716
+ };
1717
+ const sendStateMachineEventSpy = jest.spyOn(task, 'sendStateMachineEvent');
1718
+ const outdialFailedSpy = jest.fn();
1719
+ task.on(TASK_EVENTS.TASK_OUTDIAL_FAILED, outdialFailedSpy);
1319
1720
 
1320
- const payload = {
1321
- data: {
1322
- type: CC_EVENTS.CONTACT_ENDED,
1323
- interactionId: taskId,
1324
- interaction: {
1325
- state: 'connected',
1326
- mediaType: 'telephony',
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
+ },
1327
1737
  },
1328
- agentsPendingWrapUp: [agentId],
1329
1738
  },
1330
- };
1739
+ },
1740
+ };
1331
1741
 
1332
- webSocketManagerMock.emit('message', JSON.stringify(payload));
1742
+ webSocketManagerMock.emit('message', JSON.stringify(payload));
1333
1743
 
1334
- expect(task.updateTaskData).toHaveBeenCalledWith(
1335
- expect.objectContaining({
1336
- wrapUpRequired: true,
1337
- })
1338
- );
1339
- });
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
+ });
1340
1752
 
1341
- it('should work correctly for different interaction states when agent is in agentsPendingWrapUp', () => {
1342
- const task = taskManager.getTask(taskId);
1343
- task.updateTaskData = jest.fn().mockImplementation((newData) => {
1344
- task.data = {
1345
- ...task.data,
1346
- ...newData,
1347
- interaction: {
1348
- ...task.data.interaction,
1349
- ...newData.interaction,
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',
1350
1771
  },
1351
- };
1352
- return task;
1353
- });
1354
- task.unregisterWebCallListeners = jest.fn();
1772
+ },
1773
+ },
1774
+ };
1775
+ const sendStateMachineEventSpy = jest.spyOn(task, 'sendStateMachineEvent');
1776
+ const outdialFailedSpy = jest.fn();
1777
+ task.on(TASK_EVENTS.TASK_OUTDIAL_FAILED, outdialFailedSpy);
1355
1778
 
1356
- // Test with 'connected' state
1357
- const payloadConnected = {
1358
- data: {
1359
- type: CC_EVENTS.CONTACT_ENDED,
1360
- interactionId: taskId,
1361
- interaction: {
1362
- state: 'connected',
1363
- mediaType: 'telephony',
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
+ },
1364
1795
  },
1365
- agentsPendingWrapUp: [agentId],
1366
1796
  },
1367
- };
1797
+ agentsPendingWrapUp: ['test-agent-id'],
1798
+ },
1799
+ };
1368
1800
 
1369
- webSocketManagerMock.emit('message', JSON.stringify(payloadConnected));
1801
+ webSocketManagerMock.emit('message', JSON.stringify(payload));
1370
1802
 
1371
- // First call should set wrapUpRequired to true
1372
- expect(task.updateTaskData).toHaveBeenNthCalledWith(
1373
- 1,
1374
- expect.objectContaining({
1375
- wrapUpRequired: true,
1376
- })
1377
- );
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
+ });
1378
1811
 
1379
- // Test with 'held' state to verify it still works regardless of state
1380
- const payloadHeld = {
1381
- data: {
1382
- type: CC_EVENTS.CONTACT_ENDED,
1383
- interactionId: taskId,
1384
- interaction: {
1385
- state: 'held',
1386
- mediaType: 'telephony',
1387
- },
1388
- agentsPendingWrapUp: [agentId],
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',
1389
1825
  },
1390
- };
1826
+ agentsPendingWrapUp: ['test-agent-id'],
1827
+ },
1828
+ };
1391
1829
 
1392
- webSocketManagerMock.emit('message', JSON.stringify(payloadHeld));
1830
+ webSocketManagerMock.emit('message', JSON.stringify(payload));
1393
1831
 
1394
- // Second call should also set wrapUpRequired to true
1395
- expect(task.updateTaskData).toHaveBeenNthCalledWith(
1396
- 2,
1397
- expect.objectContaining({
1398
- wrapUpRequired: true,
1399
- })
1400
- );
1401
- });
1832
+ expectLastStateMachineEvent(sendStateMachineEventSpy, TaskEvent.CONTACT_ENDED);
1833
+ sendStateMachineEventSpy.mockRestore();
1402
1834
  });
1403
1835
 
1404
- it('should remove OUTDIAL task from taskCollection on AGENT_CONTACT_ASSIGN_FAILED when NOT terminated (user-declined)', () => {
1405
- const task = taskManager.getTask(taskId);
1406
- task.updateTaskData = jest.fn().mockImplementation((newData) => {
1407
- task.data = {
1408
- ...task.data,
1409
- ...newData,
1836
+ it('should handle CONTACT_ENDED gracefully when task is undefined', () => {
1837
+ const payload = {
1838
+ data: {
1839
+ type: CC_EVENTS.CONTACT_ENDED,
1840
+ interactionId: 'non-existent-task-id',
1410
1841
  interaction: {
1411
- ...task.data.interaction,
1412
- ...newData.interaction,
1413
- outboundType: 'OUTDIAL',
1414
1842
  state: 'new',
1415
- isTerminated: false,
1416
1843
  },
1417
- };
1418
- return task;
1844
+ },
1845
+ };
1846
+ // Should not throw error when task doesn't exist
1847
+ expect(() => {
1848
+ webSocketManagerMock.emit('message', JSON.stringify(payload));
1849
+ }).not.toThrow();
1850
+ });
1851
+
1852
+ it('should remove OUTDIAL task from taskCollection on AGENT_CONTACT_ASSIGN_FAILED when NOT terminated (user-declined)', () => {
1853
+ const task = taskManager.getTask(taskId);
1854
+ Object.assign(task.data, {
1855
+ interaction: {
1856
+ ...task.data.interaction,
1857
+ outboundType: 'OUTDIAL',
1858
+ state: 'new',
1859
+ isTerminated: false,
1860
+ },
1419
1861
  });
1420
1862
  task.unregisterWebCallListeners = jest.fn();
1421
1863
  const removeTaskSpy = jest.spyOn(taskManager, 'removeTaskFromCollection');
@@ -1459,20 +1901,16 @@ describe('TaskManager', () => {
1459
1901
 
1460
1902
  webSocketManagerMock.emit('message', JSON.stringify(initalPayload));
1461
1903
  const task = taskManager.getTask(taskId);
1462
- task.updateTaskData = jest.fn().mockImplementation((newData) => {
1463
- task.data = {...newData, isConsulted: true};
1464
- return task;
1465
- });
1466
- const taskEmitSpy = jest.spyOn(task, 'emit');
1904
+ const sendStateMachineEventSpy = jest.spyOn(task, 'sendStateMachineEvent');
1467
1905
 
1468
1906
  webSocketManagerMock.emit('message', JSON.stringify(payload));
1469
1907
 
1470
- expect(task.updateTaskData).toHaveBeenCalledWith({
1471
- ...payload.data,
1472
- isConsulted: true,
1473
- });
1474
- expect(task.data.isConsulted).toBe(true);
1475
- 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();
1476
1914
  });
1477
1915
 
1478
1916
  it('should emit TASK_CONSULT_ACCEPTED event on AGENT_CONSULTING event', () => {
@@ -1491,19 +1929,18 @@ describe('TaskManager', () => {
1491
1929
  };
1492
1930
 
1493
1931
  webSocketManagerMock.emit('message', JSON.stringify(initalPayload));
1494
- taskManager.getTask(taskId).updateTaskData = jest.fn().mockImplementation((newData) => {
1495
- taskManager.getTask(taskId).data = {...newData, isConsulted: true};
1496
- return taskManager.getTask(taskId);
1497
- });
1498
1932
 
1499
- const taskEmitSpy = jest.spyOn(taskManager.getTask(taskId), 'emit');
1933
+ const task = taskManager.getTask(taskId);
1934
+ const sendStateMachineEventSpy = jest.spyOn(task, 'sendStateMachineEvent');
1935
+
1500
1936
  webSocketManagerMock.emit('message', JSON.stringify(initialConsultingPayload));
1501
1937
  webSocketManagerMock.emit('message', JSON.stringify(consultingPayload));
1502
- expect(taskManager.getTask(taskId).data.isConsulted).toBe(true);
1503
- expect(taskEmitSpy).toHaveBeenCalledWith(
1504
- TASK_EVENTS.TASK_CONSULT_ACCEPTED,
1505
- taskManager.getTask(taskId)
1938
+ const stateMachineEvent = expectLastStateMachineEvent(
1939
+ sendStateMachineEventSpy,
1940
+ TaskEvent.CONSULTING_ACTIVE
1506
1941
  );
1942
+ expect(stateMachineEvent?.taskData).toEqual(consultingPayload.data);
1943
+ sendStateMachineEventSpy.mockRestore();
1507
1944
  });
1508
1945
 
1509
1946
  it('should emit TASK_CONSULT_ENDED event on AGENT_CONSULT_ENDED event', () => {
@@ -1515,14 +1952,16 @@ describe('TaskManager', () => {
1515
1952
  };
1516
1953
 
1517
1954
  webSocketManagerMock.emit('message', JSON.stringify(initalPayload));
1518
- const taskEmitSpy = jest.spyOn(taskManager.getTask(taskId), 'emit');
1519
- 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');
1520
1958
  webSocketManagerMock.emit('message', JSON.stringify(payload));
1521
- expect(taskUpdateTaskDataSpy).toHaveBeenCalledWith(payload.data);
1522
- expect(taskEmitSpy).toHaveBeenCalledWith(
1523
- TASK_EVENTS.TASK_CONSULT_END,
1524
- taskManager.getTask(taskId)
1959
+ const stateMachineEvent = expectLastStateMachineEvent(
1960
+ sendStateMachineEventSpy,
1961
+ TaskEvent.CONSULT_END
1525
1962
  );
1963
+ expect(stateMachineEvent?.taskData).toEqual(payload.data);
1964
+ sendStateMachineEventSpy.mockRestore();
1526
1965
  });
1527
1966
 
1528
1967
  it('should emit TASK_CONSULT_ENDED event and remove currentTask when on AGENT_CONSULT_ENDED event when requested for a consult', () => {
@@ -1535,19 +1974,14 @@ describe('TaskManager', () => {
1535
1974
 
1536
1975
  webSocketManagerMock.emit('message', JSON.stringify(initalPayload));
1537
1976
 
1538
- taskManager.getTask(taskId).updateTaskData = jest.fn().mockImplementation((newData) => {
1539
- taskManager.getTask(taskId).data = {...newData, isConsulted: true};
1540
- return taskManager.getTask(taskId);
1541
- });
1977
+ taskManager.getTask(taskId).data.isConsulted = true;
1542
1978
  const task = taskManager.getTask(taskId);
1543
1979
 
1544
- const taskEmitSpy = jest.spyOn(taskManager.getTask(taskId), 'emit');
1545
- const taskUpdateTaskDataSpy = jest.spyOn(taskManager.getTask(taskId), 'updateTaskData');
1980
+ const sendStateMachineEventSpy = jest.spyOn(task, 'sendStateMachineEvent');
1546
1981
  webSocketManagerMock.emit('message', JSON.stringify(payload));
1547
- expect(taskUpdateTaskDataSpy).toHaveBeenCalledWith(payload.data);
1548
- expect(taskEmitSpy).toHaveBeenCalledWith(CC_EVENTS.AGENT_CONSULT_ENDED, payload.data);
1549
- expect(taskEmitSpy).toHaveBeenCalledWith(TASK_EVENTS.TASK_CONSULT_END, task);
1982
+ expectLastStateMachineEvent(sendStateMachineEventSpy, TaskEvent.CONSULT_END);
1550
1983
  expect(taskManager.getTask(taskId)).toBeUndefined(); // Ensure task is removed from the task collection after the consult ends
1984
+ sendStateMachineEventSpy.mockRestore();
1551
1985
  });
1552
1986
 
1553
1987
  it('should emit TASK_CANCELLED event on AGENT_CTQ_CANCELLED event', () => {
@@ -1559,14 +1993,15 @@ describe('TaskManager', () => {
1559
1993
  };
1560
1994
 
1561
1995
  webSocketManagerMock.emit('message', JSON.stringify(initalPayload));
1562
- const taskEmitSpy = jest.spyOn(taskManager.getTask(taskId), 'emit');
1563
- const taskUpdateTaskDataSpy = jest.spyOn(taskManager.getTask(taskId), 'updateTaskData');
1996
+ const task = taskManager.getTask(taskId);
1997
+ const sendStateMachineEventSpy = jest.spyOn(task, 'sendStateMachineEvent');
1564
1998
  webSocketManagerMock.emit('message', JSON.stringify(payload));
1565
- expect(taskUpdateTaskDataSpy).toHaveBeenCalledWith(payload.data);
1566
- expect(taskEmitSpy).toHaveBeenCalledWith(
1567
- TASK_EVENTS.TASK_CONSULT_QUEUE_CANCELLED,
1568
- taskManager.getTask(taskId)
1999
+ const stateMachineEvent = expectLastStateMachineEvent(
2000
+ sendStateMachineEventSpy,
2001
+ TaskEvent.CTQ_CANCEL
1569
2002
  );
2003
+ expect(stateMachineEvent?.taskData).toEqual(payload.data);
2004
+ sendStateMachineEventSpy.mockRestore();
1570
2005
  });
1571
2006
 
1572
2007
  it('should handle AGENT_CONSULT_FAILED event', () => {
@@ -1580,9 +2015,15 @@ describe('TaskManager', () => {
1580
2015
  webSocketManagerMock.emit('message', JSON.stringify(initalPayload));
1581
2016
 
1582
2017
  // Always spy on the updated task object after CONTACT_RESERVED is emitted
1583
- const taskUpdateTaskDataSpy = jest.spyOn(taskManager.getTask(taskId), 'updateTaskData');
2018
+ const task = taskManager.getTask(taskId);
2019
+ const sendStateMachineEventSpy = jest.spyOn(task, 'sendStateMachineEvent');
1584
2020
  webSocketManagerMock.emit('message', JSON.stringify(payload));
1585
- 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();
1586
2027
  });
1587
2028
 
1588
2029
  it('should emit TASK_CONSULT_QUEUE_FAILED on AGENT_CTQ_CANCEL_FAILED event', () => {
@@ -1594,14 +2035,15 @@ describe('TaskManager', () => {
1594
2035
  };
1595
2036
 
1596
2037
  webSocketManagerMock.emit('message', JSON.stringify(initalPayload));
1597
- const taskEmitSpy = jest.spyOn(taskManager.getTask(taskId), 'emit');
1598
- const taskUpdateTaskDataSpy = jest.spyOn(taskManager.getTask(taskId), 'updateTaskData');
2038
+ const task = taskManager.getTask(taskId);
2039
+ const sendStateMachineEventSpy = jest.spyOn(task, 'sendStateMachineEvent');
1599
2040
  webSocketManagerMock.emit('message', JSON.stringify(payload));
1600
- expect(taskUpdateTaskDataSpy).toHaveBeenCalledWith(payload.data);
1601
- expect(taskEmitSpy).toHaveBeenCalledWith(
1602
- TASK_EVENTS.TASK_CONSULT_QUEUE_FAILED,
1603
- taskManager.getTask(taskId)
2041
+ const stateMachineEvent = expectLastStateMachineEvent(
2042
+ sendStateMachineEventSpy,
2043
+ TaskEvent.CTQ_CANCEL_FAILED
1604
2044
  );
2045
+ expect(stateMachineEvent?.taskData).toEqual(payload.data);
2046
+ sendStateMachineEventSpy.mockRestore();
1605
2047
  });
1606
2048
 
1607
2049
  it('should emit TASK_REJECT event on AGENT_CONTACT_OFFER_RONA event', () => {
@@ -1644,15 +2086,14 @@ describe('TaskManager', () => {
1644
2086
  };
1645
2087
 
1646
2088
  taskManager.taskCollection[taskId] = taskManager.getTask(taskId);
1647
- const taskEmitSpy = jest.spyOn(taskManager.getTask(taskId), 'emit');
1648
- const metricsTrackSpy = jest.spyOn(taskManager.metricsManager, 'trackEvent');
2089
+ const task = taskManager.getTask(taskId);
2090
+ const sendStateMachineEventSpy = jest.spyOn(task, 'sendStateMachineEvent');
1649
2091
 
1650
2092
  webSocketManagerMock.emit('message', JSON.stringify(ronaPayload));
1651
2093
 
1652
- expect(taskEmitSpy).toHaveBeenCalledWith(TASK_EVENTS.TASK_REJECT, ronaPayload.data.reason);
1653
- // Verify the correct metric event name is used for AGENT_CONTACT_OFFER_RONA
1654
- expect(metricsTrackSpy).toHaveBeenCalled();
1655
- 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();
1656
2097
  });
1657
2098
 
1658
2099
  it('should emit TASK_REJECT event on AGENT_CONTACT_ASSIGN_FAILED event', () => {
@@ -1696,20 +2137,16 @@ describe('TaskManager', () => {
1696
2137
 
1697
2138
  taskManager.taskCollection[taskId] = taskManager.getTask(taskId);
1698
2139
  const task = taskManager.getTask(taskId);
1699
- const taskEmitSpy = jest.spyOn(task, 'emit');
1700
- const taskUpdateDataSpy = jest.spyOn(task, 'updateTaskData');
1701
- const metricsTrackSpy = jest.spyOn(taskManager.metricsManager, 'trackEvent');
2140
+ const sendStateMachineEventSpy = jest.spyOn(task, 'sendStateMachineEvent');
1702
2141
 
1703
2142
  webSocketManagerMock.emit('message', JSON.stringify(assignFailedPayload));
1704
2143
 
1705
- expect(taskUpdateDataSpy).toHaveBeenCalledWith(assignFailedPayload.data);
1706
- expect(taskEmitSpy).toHaveBeenCalledWith(
1707
- TASK_EVENTS.TASK_REJECT,
1708
- assignFailedPayload.data.reason
2144
+ const stateMachineEvent = expectLastStateMachineEvent(
2145
+ sendStateMachineEventSpy,
2146
+ TaskEvent.ASSIGN_FAILED
1709
2147
  );
1710
- // Verify the correct metric event name is used for AGENT_CONTACT_ASSIGN_FAILED
1711
- expect(metricsTrackSpy).toHaveBeenCalled();
1712
- expect(metricsTrackSpy.mock.calls[0][0]).toBe('Agent Contact Assign Failed');
2148
+ expect(stateMachineEvent?.reason).toBe(assignFailedPayload.data.reason);
2149
+ sendStateMachineEventSpy.mockRestore();
1713
2150
  });
1714
2151
 
1715
2152
  it('should remove currentTask from taskCollection on AGENT_WRAPPEDUP event', () => {
@@ -1732,11 +2169,12 @@ describe('TaskManager', () => {
1732
2169
 
1733
2170
  webSocketManagerMock.emit('message', JSON.stringify(initalPayload));
1734
2171
  const task = taskManager.getTask(taskId);
1735
- const taskEmitSpy = jest.spyOn(task, 'emit');
2172
+ const sendStateMachineEventSpy = jest.spyOn(task, 'sendStateMachineEvent');
1736
2173
 
1737
2174
  webSocketManagerMock.emit('message', JSON.stringify(payload));
1738
2175
 
1739
- expect(taskEmitSpy).toHaveBeenCalledWith(TASK_EVENTS.TASK_WRAPPEDUP, task);
2176
+ expectLastStateMachineEvent(sendStateMachineEventSpy, TaskEvent.WRAPUP_COMPLETE);
2177
+ sendStateMachineEventSpy.mockRestore();
1740
2178
  expect(taskManager.getTask(taskId)).toBeUndefined();
1741
2179
  });
1742
2180
 
@@ -1762,7 +2200,8 @@ describe('TaskManager', () => {
1762
2200
  };
1763
2201
 
1764
2202
  const taskEmitSpy = jest.spyOn(taskManager.getTask(taskId), 'emit');
1765
- const taskUpdateTaskDataSpy = jest.spyOn(taskManager.getTask(taskId), 'updateTaskData');
2203
+ const taskUpdateTaskDataSpy = taskManager.getTask(taskId).updateTaskData as jest.Mock;
2204
+ taskUpdateTaskDataSpy.mockClear();
1766
2205
  webSocketManagerMock.emit('message', JSON.stringify(payload));
1767
2206
  expect(taskEmitSpy).not.toHaveBeenCalled();
1768
2207
  expect(taskUpdateTaskDataSpy).not.toHaveBeenCalled();
@@ -1771,7 +2210,9 @@ describe('TaskManager', () => {
1771
2210
  it('should emit TASK_CONSULTING event when agent is consulting', () => {
1772
2211
  webSocketManagerMock.emit('message', JSON.stringify(initalPayload));
1773
2212
  taskManager.getTask(taskId).data.isConsulted = false;
1774
- 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');
1775
2216
  const consultingPayload = {
1776
2217
  data: {
1777
2218
  ...initalPayload.data,
@@ -1780,16 +2221,21 @@ describe('TaskManager', () => {
1780
2221
  },
1781
2222
  };
1782
2223
  webSocketManagerMock.emit('message', JSON.stringify(consultingPayload));
1783
- expect(taskEmitSpy).toHaveBeenCalledWith(CC_EVENTS.AGENT_CONSULTING, consultingPayload.data);
1784
- expect(taskEmitSpy).toHaveBeenCalledWith(
1785
- TASK_EVENTS.TASK_CONSULTING,
2224
+ expectLastStateMachineEvent(sendStateMachineEventSpy, TaskEvent.CONSULTING_ACTIVE);
2225
+ expect(taskManagerEmitSpy).toHaveBeenCalledWith(
2226
+ TASK_EVENTS.TASK_MULTI_LOGIN_HYDRATE,
1786
2227
  taskManager.getTask(taskId)
1787
2228
  );
2229
+ sendStateMachineEventSpy.mockRestore();
1788
2230
  });
1789
2231
 
1790
- it('should emit TASK_END event on AGENT_CONTACT_UNASSIGNED', () => {
2232
+ it('should update task data on AGENT_CONTACT_UNASSIGNED', () => {
1791
2233
  webSocketManagerMock.emit('message', JSON.stringify(initalPayload));
1792
- 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();
1793
2239
  const unassignedPayload = {
1794
2240
  data: {
1795
2241
  type: CC_EVENTS.AGENT_CONTACT_UNASSIGNED,
@@ -1807,11 +2253,76 @@ describe('TaskManager', () => {
1807
2253
  },
1808
2254
  };
1809
2255
  webSocketManagerMock.emit('message', JSON.stringify(unassignedPayload));
1810
- expect(taskEmitSpy).toHaveBeenCalledWith(
1811
- CC_EVENTS.AGENT_CONTACT_UNASSIGNED,
1812
- unassignedPayload.data
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
+ })
1813
2325
  );
1814
- expect(taskEmitSpy).toHaveBeenCalledWith(TASK_EVENTS.TASK_END, taskManager.getTask(taskId));
1815
2326
  });
1816
2327
 
1817
2328
  it('should handle chat interaction and emit TASK_INCOMING immediately', () => {
@@ -1823,16 +2334,16 @@ describe('TaskManager', () => {
1823
2334
  },
1824
2335
  };
1825
2336
 
1826
- const taskIncomingSpy = jest.spyOn(taskManager, 'emit');
1827
-
1828
2337
  // Simulate receiving a chat task
1829
2338
  webSocketManagerMock.emit('message', JSON.stringify(chatPayload));
1830
2339
 
1831
- // For non-telephony tasks, TASK_INCOMING should be emitted immediately
1832
- expect(taskIncomingSpy).toHaveBeenCalledWith(
1833
- TASK_EVENTS.TASK_INCOMING,
1834
- 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
1835
2345
  );
2346
+ expect(stateMachineEvent?.taskData).toEqual(chatPayload.data);
1836
2347
  expect(taskManager.getAllTasks()).toHaveProperty(chatPayload.data.interactionId);
1837
2348
  });
1838
2349
 
@@ -1845,16 +2356,16 @@ describe('TaskManager', () => {
1845
2356
  },
1846
2357
  };
1847
2358
 
1848
- const taskIncomingSpy = jest.spyOn(taskManager, 'emit');
1849
-
1850
2359
  // Simulate receiving an email task
1851
2360
  webSocketManagerMock.emit('message', JSON.stringify(emailPayload));
1852
2361
 
1853
- // For non-telephony tasks, TASK_INCOMING should be emitted immediately
1854
- expect(taskIncomingSpy).toHaveBeenCalledWith(
1855
- TASK_EVENTS.TASK_INCOMING,
1856
- 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
1857
2367
  );
2368
+ expect(stateMachineEvent?.taskData).toEqual(emailPayload.data);
1858
2369
  expect(taskManager.getAllTasks()).toHaveProperty(emailPayload.data.interactionId);
1859
2370
  });
1860
2371
 
@@ -1868,13 +2379,11 @@ describe('TaskManager', () => {
1868
2379
  },
1869
2380
  };
1870
2381
 
1871
- const taskIncomingSpy = jest.spyOn(taskManager, 'emit');
1872
2382
  webSocketManagerMock.emit('message', JSON.stringify(chatReservedPayload));
2383
+ const task = taskManager.getTask(chatReservedPayload.data.interactionId);
2384
+ const sendStateMachineEventSpy = task.sendStateMachineEvent as jest.Mock;
1873
2385
 
1874
- expect(taskIncomingSpy).toHaveBeenCalledWith(
1875
- TASK_EVENTS.TASK_INCOMING,
1876
- taskManager.getTask(chatReservedPayload.data.interactionId)
1877
- );
2386
+ expectLastStateMachineEvent(sendStateMachineEventSpy, TaskEvent.TASK_INCOMING);
1878
2387
 
1879
2388
  // 2. Chat task is assigned
1880
2389
  const chatAssignedPayload = {
@@ -1884,12 +2393,9 @@ describe('TaskManager', () => {
1884
2393
  },
1885
2394
  };
1886
2395
 
1887
- const task = taskManager.getTask(chatReservedPayload.data.interactionId);
1888
- const taskEmitSpy = jest.spyOn(task, 'emit');
1889
-
1890
2396
  webSocketManagerMock.emit('message', JSON.stringify(chatAssignedPayload));
1891
2397
 
1892
- expect(taskEmitSpy).toHaveBeenCalledWith(TASK_EVENTS.TASK_ASSIGNED, task);
2398
+ expectLastStateMachineEvent(sendStateMachineEventSpy, TaskEvent.ASSIGN);
1893
2399
 
1894
2400
  // 3. Chat task is ended with state 'new' to trigger cleanup
1895
2401
  const chatEndedPayload = {
@@ -1897,12 +2403,15 @@ describe('TaskManager', () => {
1897
2403
  ...chatReservedPayload.data,
1898
2404
  type: CC_EVENTS.CONTACT_ENDED,
1899
2405
  interaction: {mediaType: 'chat', state: 'new'}, // Change to 'new' state
2406
+ wrapUpRequired: false,
1900
2407
  },
1901
2408
  };
1902
2409
 
2410
+ // Simulate state on the task to allow cleanup logic
2411
+ task.data.interaction.state = 'new';
1903
2412
  webSocketManagerMock.emit('message', JSON.stringify(chatEndedPayload));
1904
2413
 
1905
- expect(taskEmitSpy).toHaveBeenCalledWith(TASK_EVENTS.TASK_END, task);
2414
+ expectLastStateMachineEvent(sendStateMachineEventSpy, TaskEvent.CONTACT_ENDED);
1906
2415
  // Verify task is removed since it was in a 'new' state
1907
2416
  expect(taskManager.getTask(chatReservedPayload.data.interactionId)).toBeUndefined();
1908
2417
  });
@@ -1991,13 +2500,8 @@ describe('TaskManager', () => {
1991
2500
  expect(taskManager.getAllTasks()).toHaveProperty(task2Payload.data.interactionId);
1992
2501
  expect(taskManager.getAllTasks()).toHaveProperty(task3Payload.data.interactionId);
1993
2502
 
1994
- // Create spies for all tasks
1995
- const task1EmitSpy = jest.spyOn(taskManager.getTask(task1Payload.data.interactionId), 'emit');
1996
- const task2EmitSpy = jest.spyOn(taskManager.getTask(task2Payload.data.interactionId), 'emit');
1997
- const task3EmitSpy = jest.spyOn(taskManager.getTask(task3Payload.data.interactionId), 'emit');
1998
-
1999
- // Store reference to task2 before it gets removed
2000
2503
  const task2 = taskManager.getTask(task2Payload.data.interactionId);
2504
+ const task2SendStateMachineEventSpy = task2.sendStateMachineEvent as jest.Mock;
2001
2505
 
2002
2506
  // End only the second task (chat task)
2003
2507
  const chatEndedPayload = {
@@ -2005,15 +2509,18 @@ describe('TaskManager', () => {
2005
2509
  ...task2Payload.data,
2006
2510
  type: CC_EVENTS.CONTACT_ENDED,
2007
2511
  interaction: {mediaType: 'chat', state: 'new'}, // Using 'new' to trigger cleanup
2512
+ wrapUpRequired: false,
2008
2513
  },
2009
2514
  };
2010
2515
 
2516
+ task2.data.interaction.state = 'new';
2011
2517
  webSocketManagerMock.emit('message', JSON.stringify(chatEndedPayload));
2012
2518
 
2013
- // Verify only task2 emitted TASK_END
2014
- expect(task1EmitSpy).not.toHaveBeenCalledWith(TASK_EVENTS.TASK_END);
2015
- expect(task2EmitSpy).toHaveBeenCalledWith(TASK_EVENTS.TASK_END, task2);
2016
- expect(task3EmitSpy).not.toHaveBeenCalledWith(TASK_EVENTS.TASK_END);
2519
+ const firstEndEvent = expectLastStateMachineEvent(
2520
+ task2SendStateMachineEventSpy,
2521
+ TaskEvent.CONTACT_ENDED
2522
+ );
2523
+ expect(firstEndEvent?.taskData).toEqual(chatEndedPayload.data);
2017
2524
 
2018
2525
  // Verify task2 was removed from collection (since state was 'new')
2019
2526
  expect(taskManager.getTask(task2Payload.data.interactionId)).toBeUndefined();
@@ -2024,6 +2531,7 @@ describe('TaskManager', () => {
2024
2531
 
2025
2532
  // Store reference to task3 before we end it
2026
2533
  const task3 = taskManager.getTask(task3Payload.data.interactionId);
2534
+ const task3SendStateMachineEventSpy = task3.sendStateMachineEvent as jest.Mock;
2027
2535
 
2028
2536
  // Now end task3 with a state that doesn't trigger cleanup
2029
2537
  const emailEndedPayload = {
@@ -2031,31 +2539,32 @@ describe('TaskManager', () => {
2031
2539
  ...task3Payload.data,
2032
2540
  type: CC_EVENTS.CONTACT_ENDED,
2033
2541
  interaction: {mediaType: 'email', state: 'connected'}, // Using 'connected' to NOT trigger cleanup
2542
+ wrapUpRequired: true,
2543
+ agentsPendingWrapUp: ['test-agent-id'],
2034
2544
  },
2035
2545
  };
2036
2546
 
2547
+ task3.data.interaction.state = 'connected';
2037
2548
  webSocketManagerMock.emit('message', JSON.stringify(emailEndedPayload));
2038
2549
 
2039
- // Verify task3 emitted TASK_END
2040
- expect(task3EmitSpy).toHaveBeenCalledWith(TASK_EVENTS.TASK_END, task3);
2550
+ const secondEndEvent = expectLastStateMachineEvent(
2551
+ task3SendStateMachineEventSpy,
2552
+ TaskEvent.CONTACT_ENDED
2553
+ );
2554
+ expect(secondEndEvent?.taskData).toEqual(emailEndedPayload.data);
2041
2555
 
2042
2556
  // Verify task3 is still in collection (since state was 'connected')
2043
2557
  expect(taskManager.getTask(task3Payload.data.interactionId)).toBeDefined();
2044
2558
 
2045
2559
  // Verify task1 remains unaffected
2046
- expect(task1EmitSpy).not.toHaveBeenCalledWith(TASK_EVENTS.TASK_END);
2047
2560
  expect(taskManager.getTask(task1Payload.data.interactionId)).toBeDefined();
2048
2561
  });
2049
2562
 
2050
- it('should emit TASK_END event on AGENT_VTEAM_TRANSFERRED event', () => {
2563
+ it('should emit TRANSFER_SUCCESS event on AGENT_VTEAM_TRANSFERRED event', () => {
2051
2564
  // First create a task by emitting the initial payload
2052
2565
  webSocketManagerMock.emit('message', JSON.stringify(initalPayload));
2053
-
2054
- // Get a reference to the task from taskCollection
2055
2566
  const task = taskManager.getTask(taskId);
2056
-
2057
- // Now spy on the task's emit method
2058
- const taskEmitSpy = jest.spyOn(task, 'emit');
2567
+ const sendStateMachineEventSpy = jest.spyOn(task, 'sendStateMachineEvent');
2059
2568
 
2060
2569
  const vteamTransferredPayload = {
2061
2570
  data: {
@@ -2079,8 +2588,9 @@ describe('TaskManager', () => {
2079
2588
 
2080
2589
  webSocketManagerMock.emit('message', JSON.stringify(vteamTransferredPayload));
2081
2590
 
2082
- // Check that task.emit was called with TASK_END event
2083
- expect(taskEmitSpy).toHaveBeenCalledWith(TASK_EVENTS.TASK_END, task);
2591
+ // Check that the state machine received the END event
2592
+ expectLastStateMachineEvent(sendStateMachineEventSpy, TaskEvent.TRANSFER_SUCCESS);
2593
+ sendStateMachineEventSpy.mockRestore();
2084
2594
 
2085
2595
  // The task should still exist in the collection based on current implementation
2086
2596
  expect(taskManager.getTask(taskId)).toBeDefined();
@@ -2095,12 +2605,32 @@ describe('TaskManager', () => {
2095
2605
  },
2096
2606
  };
2097
2607
  const task = taskManager.getTask(taskId);
2098
- const updateSpy = jest.spyOn(task, 'updateTaskData').mockImplementation((data) => {
2099
- task.data = {...(task.data || {}), ...(data || {})};
2100
- return task;
2608
+ const sendStateMachineEventSpy = jest.spyOn(task, 'sendStateMachineEvent');
2609
+ webSocketManagerMock.emit('message', JSON.stringify(payload));
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,
2101
2616
  });
2617
+ sendStateMachineEventSpy.mockRestore();
2618
+ });
2619
+
2620
+ it('stamps wrapUpRequired on AGENT_WRAPUP when the payload omits it', () => {
2621
+ const payload = {
2622
+ data: {
2623
+ type: CC_EVENTS.AGENT_WRAPUP,
2624
+ interactionId: taskId,
2625
+ },
2626
+ };
2627
+ const task = taskManager.getTask(taskId);
2102
2628
  webSocketManagerMock.emit('message', JSON.stringify(payload));
2103
- expect(updateSpy).toHaveBeenCalledWith(payload.data);
2629
+ const stateMachineEvent = task.sendStateMachineEvent.mock.calls.at(-1)?.[0];
2630
+
2631
+ expect(stateMachineEvent?.type).toBe(TaskEvent.TASK_WRAPUP);
2632
+ expect(stateMachineEvent?.taskData.wrapUpRequired).toBe(true);
2633
+ expect(task.data.wrapUpRequired).toBe(true);
2104
2634
  });
2105
2635
 
2106
2636
  it('should not attempt cleanup twice when AGENT_CONTACT_UNASSIGNED is followed by AGENT_WRAPUP', () => {
@@ -2176,1176 +2706,1670 @@ describe('TaskManager', () => {
2176
2706
  });
2177
2707
 
2178
2708
  describe('should emit appropriate task events for recording events', () => {
2179
- ['PAUSED', 'PAUSE_FAILED', 'RESUMED', 'RESUME_FAILED'].forEach((suffix) => {
2709
+ const eventMap: Record<string, TaskEvent | null> = {
2710
+ STARTED: TaskEvent.RECORDING_STARTED,
2711
+ PAUSED: TaskEvent.PAUSE_RECORDING,
2712
+ PAUSE_FAILED: null,
2713
+ RESUMED: TaskEvent.RESUME_RECORDING,
2714
+ RESUME_FAILED: null,
2715
+ };
2716
+
2717
+ ['STARTED', 'PAUSED', 'PAUSE_FAILED', 'RESUMED', 'RESUME_FAILED'].forEach((suffix) => {
2180
2718
  const ccEvent = CC_EVENTS[`CONTACT_RECORDING_${suffix}`];
2181
- const taskEvent = TASK_EVENTS[`TASK_RECORDING_${suffix}`];
2182
- it(`should emit ${taskEvent} on ${ccEvent} event`, () => {
2719
+ const expectedTaskEvent = eventMap[suffix];
2720
+ it(`should ${expectedTaskEvent ? 'send' : 'not send'} ${
2721
+ expectedTaskEvent ?? 'a'
2722
+ } state machine event on ${ccEvent} event`, () => {
2183
2723
  const payload = {data: {...initalPayload.data, type: ccEvent}};
2184
2724
  webSocketManagerMock.emit('message', JSON.stringify(initalPayload));
2185
2725
  const task = taskManager.getTask(taskId);
2186
- const spy = jest.spyOn(task, 'emit');
2726
+ const sendStateMachineEventSpy = task.sendStateMachineEvent as jest.Mock;
2727
+ sendStateMachineEventSpy.mockClear();
2187
2728
 
2188
2729
  webSocketManagerMock.emit('message', JSON.stringify(payload));
2189
- expect(spy).toHaveBeenCalledWith(taskEvent, task);
2730
+ if (expectedTaskEvent) {
2731
+ const stateMachineEvent = expectLastStateMachineEvent(
2732
+ sendStateMachineEventSpy,
2733
+ expectedTaskEvent
2734
+ );
2735
+ expect(stateMachineEvent?.taskData).toEqual(payload.data);
2736
+ } else {
2737
+ expect(sendStateMachineEventSpy).not.toHaveBeenCalled();
2738
+ }
2190
2739
  });
2191
2740
  });
2192
2741
  });
2193
2742
 
2194
- describe('Conference event handling', () => {
2195
- let task;
2196
- const agentId = '723a8ffb-a26e-496d-b14a-ff44fb83b64f';
2197
-
2198
- beforeEach(() => {
2199
- // Set the agentId on taskManager before tests run
2200
- taskManager.setAgentId(agentId);
2201
-
2202
- task = {
2203
- data: {interactionId: taskId},
2204
- emit: jest.fn(),
2205
- updateTaskData: jest.fn().mockImplementation((updatedData) => {
2206
- // Mock the updateTaskData method to actually update task.data
2207
- task.data = {...task.data, ...updatedData};
2208
- return task;
2209
- }),
2210
- };
2211
- taskManager.taskCollection[taskId] = task;
2743
+ describe('Contact owner propagation', () => {
2744
+ const currentAgentId = 'test-agent-id';
2745
+ const previousOwnerId = 'previous-owner-id';
2746
+ const promotedOwnerId = 'promoted-owner-id';
2747
+
2748
+ const createConferenceTaskData = (
2749
+ interactionId: string,
2750
+ owner: string,
2751
+ mainInteractionId = taskId
2752
+ ) => ({
2753
+ ...taskDataMock,
2754
+ agentId: currentAgentId,
2755
+ interactionId,
2756
+ owner,
2757
+ interaction: {
2758
+ mediaType: 'telephony',
2759
+ interactionId,
2760
+ mainInteractionId,
2761
+ owner,
2762
+ participants: {
2763
+ [currentAgentId]: {
2764
+ id: currentAgentId,
2765
+ pType: 'Agent',
2766
+ hasJoined: true,
2767
+ hasLeft: false,
2768
+ },
2769
+ [previousOwnerId]: {
2770
+ id: previousOwnerId,
2771
+ pType: 'Agent',
2772
+ hasJoined: true,
2773
+ hasLeft: false,
2774
+ },
2775
+ [promotedOwnerId]: {
2776
+ id: promotedOwnerId,
2777
+ pType: 'Agent',
2778
+ hasJoined: true,
2779
+ hasLeft: false,
2780
+ },
2781
+ },
2782
+ media: {
2783
+ [mainInteractionId]: {
2784
+ mediaResourceId: mainInteractionId,
2785
+ mediaType: 'telephony',
2786
+ mType: 'mainCall',
2787
+ participants: [currentAgentId, previousOwnerId, promotedOwnerId],
2788
+ },
2789
+ },
2790
+ },
2212
2791
  });
2213
2792
 
2214
- it('should handle AGENT_CONSULT_CONFERENCED event', () => {
2215
- const payload = {
2216
- data: {
2217
- type: CC_EVENTS.AGENT_CONSULT_CONFERENCED,
2218
- interactionId: taskId,
2219
- isConferencing: true,
2220
- },
2221
- };
2793
+ const installTask = (data) => {
2794
+ const task = createMockTask(data);
2795
+ taskManager.taskCollection = {[data.interactionId]: task};
2796
+ (taskManager as any).setupTaskListeners(task);
2222
2797
 
2223
- webSocketManagerMock.emit('message', JSON.stringify(payload));
2798
+ return task;
2799
+ };
2224
2800
 
2225
- expect(task.data.isConferencing).toBe(true);
2226
- expect(task.emit).toHaveBeenCalledWith(TASK_EVENTS.TASK_CONFERENCE_STARTED, task);
2227
- });
2801
+ const createPromotedAgentOwnerChangeData = (
2802
+ interactionId = 'promoted-child-interaction-id',
2803
+ mainInteractionId = taskId
2804
+ ) => {
2805
+ const data = createConferenceTaskData(interactionId, currentAgentId, mainInteractionId);
2228
2806
 
2229
- it('should handle AGENT_CONSULT_CONFERENCING event', () => {
2230
- const payload = {
2231
- data: {
2232
- type: CC_EVENTS.AGENT_CONSULT_CONFERENCING,
2233
- interactionId: taskId,
2234
- isConferencing: true,
2235
- },
2236
- };
2807
+ data.interaction.state = 'conference';
2237
2808
 
2238
- webSocketManagerMock.emit('message', JSON.stringify(payload));
2809
+ return {...data, type: CC_EVENTS.CONTACT_OWNER_CHANGED};
2810
+ };
2239
2811
 
2240
- expect(task.data.isConferencing).toBe(true);
2241
- // No task event emission for conferencing - only for conferenced (completed)
2242
- expect(task.emit).not.toHaveBeenCalledWith(TASK_EVENTS.TASK_CONFERENCE_STARTED, task);
2243
- });
2812
+ const clearTasksAndFactoryHistory = () => {
2813
+ taskManager.taskCollection = {};
2814
+ (TaskFactory.createTask as jest.Mock).mockClear();
2815
+ };
2244
2816
 
2245
- it('should handle AGENT_CONSULT_CONFERENCE_FAILED event', () => {
2246
- const payload = {
2247
- data: {
2248
- type: CC_EVENTS.AGENT_CONSULT_CONFERENCE_FAILED,
2249
- interactionId: taskId,
2250
- reason: 'Network error',
2251
- },
2252
- };
2817
+ it('emits one hydrate when ContactUpdated changes interaction.owner', () => {
2818
+ const task = installTask(createConferenceTaskData(taskId, previousOwnerId));
2819
+ const hydrateHandler = jest.fn();
2820
+ taskManager.on(TASK_EVENTS.TASK_HYDRATE, hydrateHandler);
2253
2821
 
2254
- webSocketManagerMock.emit('message', JSON.stringify(payload));
2822
+ webSocketManagerMock.emit(
2823
+ 'message',
2824
+ JSON.stringify({
2825
+ data: {
2826
+ ...createConferenceTaskData(taskId, promotedOwnerId),
2827
+ type: CC_EVENTS.CONTACT_UPDATED,
2828
+ },
2829
+ })
2830
+ );
2255
2831
 
2256
- expect(task.data.reason).toBe('Network error');
2257
- // No event emission expected for failure - handled by contact method promise rejection
2832
+ expectLastStateMachineEvent(task.sendStateMachineEvent, TaskEvent.CONTACT_OWNER_CHANGED);
2833
+ expect(task.data.interaction.owner).toBe(promotedOwnerId);
2834
+ expect(hydrateHandler).toHaveBeenCalledTimes(1);
2835
+ expect(hydrateHandler).toHaveBeenCalledWith(task);
2258
2836
  });
2259
2837
 
2260
- it('should handle PARTICIPANT_JOINED_CONFERENCE event', () => {
2261
- const payload = {
2262
- data: {
2263
- type: CC_EVENTS.PARTICIPANT_JOINED_CONFERENCE,
2264
- interactionId: taskId,
2265
- participantId: 'new-participant-123',
2266
- participantType: 'agent',
2267
- interaction: {
2268
- participants: {
2269
- [agentId]: {pType: 'Agent', hasLeft: false},
2270
- 'new-participant-123': {pType: 'Agent', hasLeft: false},
2271
- },
2272
- media: {
2273
- [taskId]: {
2274
- mType: 'mainCall',
2275
- participants: [agentId, 'new-participant-123'],
2276
- },
2277
- },
2838
+ it.each([
2839
+ ['the same owner', previousOwnerId],
2840
+ ['no owner', undefined],
2841
+ ['a blank owner', ' '],
2842
+ ])('keeps ContactUpdated data-only when it carries %s', (_description, owner) => {
2843
+ const task = installTask(createConferenceTaskData(taskId, previousOwnerId));
2844
+ const hydrateHandler = jest.fn();
2845
+ taskManager.on(TASK_EVENTS.TASK_HYDRATE, hydrateHandler);
2846
+ const interaction = createConferenceTaskData(taskId, previousOwnerId).interaction;
2847
+ if (owner === undefined) {
2848
+ delete interaction.owner;
2849
+ } else {
2850
+ interaction.owner = owner;
2851
+ }
2852
+
2853
+ webSocketManagerMock.emit(
2854
+ 'message',
2855
+ JSON.stringify({
2856
+ data: {
2857
+ ...createConferenceTaskData(taskId, previousOwnerId),
2858
+ type: CC_EVENTS.CONTACT_UPDATED,
2859
+ interaction,
2278
2860
  },
2279
- },
2280
- };
2861
+ })
2862
+ );
2281
2863
 
2282
- webSocketManagerMock.emit('message', JSON.stringify(payload));
2864
+ expectLastStateMachineEvent(task.sendStateMachineEvent, TaskEvent.CONTACT_UPDATED);
2865
+ expect(hydrateHandler).not.toHaveBeenCalled();
2866
+ });
2867
+
2868
+ it('correlates child-keyed ContactOwnerChanged and keeps the main task identity', () => {
2869
+ const childTaskId = 'surviving-child-id';
2870
+ const promotedChildId = 'promoted-child-id';
2871
+ const task = installTask(
2872
+ createConferenceTaskData(childTaskId, previousOwnerId, taskId)
2873
+ );
2874
+ const hydrateHandler = jest.fn();
2875
+ taskManager.on(TASK_EVENTS.TASK_HYDRATE, hydrateHandler);
2876
+
2877
+ const ownerChangeData = createConferenceTaskData(
2878
+ promotedChildId,
2879
+ promotedOwnerId,
2880
+ taskId
2881
+ );
2882
+ ownerChangeData.interaction.interactionId = taskId;
2883
+ webSocketManagerMock.emit(
2884
+ 'message',
2885
+ JSON.stringify({
2886
+ data: {...ownerChangeData, type: CC_EVENTS.CONTACT_OWNER_CHANGED},
2887
+ })
2888
+ );
2283
2889
 
2284
- expect(task.data.participantId).toBe('new-participant-123');
2285
- expect(task.data.participantType).toBe('agent');
2286
- // No specific task event emission for participant joined - just data update
2890
+ expectLastStateMachineEvent(task.sendStateMachineEvent, TaskEvent.CONTACT_OWNER_CHANGED);
2891
+ expect(task.data.interactionId).toBe(taskId);
2892
+ expect(task.data.interaction.owner).toBe(promotedOwnerId);
2893
+ expect(taskManager.taskCollection[childTaskId]).toBeUndefined();
2894
+ expect(taskManager.taskCollection[promotedChildId]).toBeUndefined();
2895
+ expect(taskManager.taskCollection[taskId]).toBe(task);
2896
+ expect(Object.values(taskManager.taskCollection).filter((entry) => entry === task)).toHaveLength(
2897
+ 1
2898
+ );
2899
+ expect(hydrateHandler).toHaveBeenCalledTimes(1);
2287
2900
  });
2288
2901
 
2289
- it('should call updateTaskData only once for PARTICIPANT_JOINED_CONFERENCE with pre-calculated isConferenceInProgress', () => {
2290
- const payload = {
2291
- data: {
2292
- type: CC_EVENTS.PARTICIPANT_JOINED_CONFERENCE,
2293
- interactionId: taskId,
2294
- participantId: 'new-agent-789',
2295
- interaction: {
2296
- participants: {
2297
- [agentId]: {pType: 'Agent', hasLeft: false},
2298
- 'agent-2': {pType: 'Agent', hasLeft: false},
2299
- 'new-agent-789': {pType: 'Agent', hasLeft: false},
2300
- 'customer-1': {pType: 'Customer', hasLeft: false},
2301
- },
2302
- media: {
2303
- [taskId]: {
2304
- mType: 'mainCall',
2305
- participants: [agentId, 'agent-2', 'new-agent-789', 'customer-1'],
2306
- },
2307
- },
2308
- },
2902
+ it('uses the incoming promotion snapshot to correlate a stale child-keyed task', () => {
2903
+ const childTaskId = 'stale-child-interaction-id';
2904
+ const staleTaskData = createConferenceTaskData(childTaskId, previousOwnerId, taskId);
2905
+ staleTaskData.interaction.media = {
2906
+ [childTaskId]: {
2907
+ mediaResourceId: childTaskId,
2908
+ mediaType: 'telephony',
2909
+ mType: 'consult',
2910
+ participants: [currentAgentId, promotedOwnerId],
2309
2911
  },
2310
2912
  };
2913
+ const task = installTask(staleTaskData);
2914
+ const hydrateHandler = jest.fn();
2915
+ taskManager.on(TASK_EVENTS.TASK_HYDRATE, hydrateHandler);
2311
2916
 
2312
- const updateTaskDataSpy = jest.spyOn(task, 'updateTaskData');
2313
-
2314
- webSocketManagerMock.emit('message', JSON.stringify(payload));
2917
+ webSocketManagerMock.emit(
2918
+ 'message',
2919
+ JSON.stringify({data: createPromotedAgentOwnerChangeData()})
2920
+ );
2315
2921
 
2316
- // Verify updateTaskData was called exactly once
2317
- expect(updateTaskDataSpy).toHaveBeenCalledTimes(1);
2922
+ expectLastStateMachineEvent(task.sendStateMachineEvent, TaskEvent.CONTACT_OWNER_CHANGED);
2923
+ expect(task.data.interaction.owner).toBe(currentAgentId);
2924
+ expect(taskManager.taskCollection[childTaskId]).toBeUndefined();
2925
+ expect(taskManager.taskCollection[taskId]).toBe(task);
2926
+ expect(TaskFactory.createTask).not.toHaveBeenCalled();
2927
+ expect(hydrateHandler).toHaveBeenCalledTimes(1);
2928
+ });
2318
2929
 
2319
- // Verify it was called with isConferenceInProgress already calculated
2320
- expect(updateTaskDataSpy).toHaveBeenCalledWith(
2321
- expect.objectContaining({
2322
- participantId: 'new-agent-789',
2323
- isConferenceInProgress: true, // 3 active agents
2324
- })
2930
+ it('reuses a task identified by the incoming main-call media key', () => {
2931
+ const mainMediaInteractionId = 'main-media-interaction-id';
2932
+ const promotedChildId = 'promoted-child-without-main-id';
2933
+ const task = installTask(
2934
+ createConferenceTaskData(
2935
+ mainMediaInteractionId,
2936
+ previousOwnerId,
2937
+ mainMediaInteractionId
2938
+ )
2325
2939
  );
2940
+ const hydrateHandler = jest.fn();
2941
+ taskManager.on(TASK_EVENTS.TASK_HYDRATE, hydrateHandler);
2942
+ const ownerChangeData = createPromotedAgentOwnerChangeData(
2943
+ promotedChildId,
2944
+ mainMediaInteractionId
2945
+ );
2946
+ delete ownerChangeData.interaction.mainInteractionId;
2947
+
2948
+ webSocketManagerMock.emit('message', JSON.stringify({data: ownerChangeData}));
2949
+
2950
+ expect(task.sendStateMachineEvent).toHaveBeenCalledTimes(1);
2951
+ expectLastStateMachineEvent(task.sendStateMachineEvent, TaskEvent.CONTACT_OWNER_CHANGED);
2952
+ expect(TaskFactory.createTask).not.toHaveBeenCalled();
2953
+ expect(taskManager.taskCollection[mainMediaInteractionId]).toBe(task);
2954
+ expect(taskManager.taskCollection[promotedChildId]).toBeUndefined();
2955
+ expect(Object.values(taskManager.taskCollection)).toEqual([task]);
2956
+ expect(task.listenerCount(TASK_EVENTS.TASK_HYDRATE)).toBe(1);
2957
+ expect(hydrateHandler).toHaveBeenCalledTimes(1);
2958
+ expect(hydrateHandler).toHaveBeenCalledWith(task);
2959
+ });
2326
2960
 
2327
- expect(task.emit).toHaveBeenCalledWith(TASK_EVENTS.TASK_PARTICIPANT_JOINED, task);
2961
+ it('reuses a stale task through its collection key and the incoming main-call media key', () => {
2962
+ const mainMediaInteractionId = 'collection-main-interaction-id';
2963
+ const staleChildId = 'stale-collection-child-id';
2964
+ const promotedChildId = 'promoted-collection-child-id';
2965
+ const staleTaskData = createConferenceTaskData(
2966
+ staleChildId,
2967
+ previousOwnerId,
2968
+ staleChildId
2969
+ );
2970
+ staleTaskData.interaction.media[staleChildId].mType = 'consult';
2971
+ const task = createMockTask(staleTaskData);
2972
+ taskManager.taskCollection = {[mainMediaInteractionId]: task};
2973
+ (taskManager as any).setupTaskListeners(task);
2974
+ const hydrateHandler = jest.fn();
2975
+ taskManager.on(TASK_EVENTS.TASK_HYDRATE, hydrateHandler);
2976
+ const ownerChangeData = createPromotedAgentOwnerChangeData(
2977
+ promotedChildId,
2978
+ mainMediaInteractionId
2979
+ );
2980
+ delete ownerChangeData.interaction.mainInteractionId;
2981
+
2982
+ webSocketManagerMock.emit('message', JSON.stringify({data: ownerChangeData}));
2983
+
2984
+ expectLastStateMachineEvent(task.sendStateMachineEvent, TaskEvent.CONTACT_OWNER_CHANGED);
2985
+ expect(task.data.interactionId).toBe(mainMediaInteractionId);
2986
+ expect(task.data.interaction.owner).toBe(currentAgentId);
2987
+ expect(TaskFactory.createTask).not.toHaveBeenCalled();
2988
+ expect(taskManager.taskCollection[mainMediaInteractionId]).toBe(task);
2989
+ expect(taskManager.taskCollection[staleChildId]).toBeUndefined();
2990
+ expect(taskManager.taskCollection[promotedChildId]).toBeUndefined();
2991
+ expect(Object.values(taskManager.taskCollection)).toEqual([task]);
2992
+ expect(task.listenerCount(TASK_EVENTS.TASK_HYDRATE)).toBe(1);
2993
+ expect(hydrateHandler).toHaveBeenCalledTimes(1);
2328
2994
  });
2329
2995
 
2330
- describe('PARTICIPANT_LEFT_CONFERENCE event handling', () => {
2331
- it('should call updateTaskData only once for PARTICIPANT_LEFT_CONFERENCE with pre-calculated isConferenceInProgress', () => {
2332
- const payload = {
2996
+ it('prefers the exact task for ContactOwnerChanged over related child tasks', () => {
2997
+ const exactTask = installTask(createConferenceTaskData(taskId, previousOwnerId));
2998
+ const childTaskId = 'related-child-id';
2999
+ const childTask = createMockTask(
3000
+ createConferenceTaskData(childTaskId, previousOwnerId, taskId)
3001
+ );
3002
+ taskManager.taskCollection[childTaskId] = childTask;
3003
+
3004
+ webSocketManagerMock.emit(
3005
+ 'message',
3006
+ JSON.stringify({
2333
3007
  data: {
2334
- type: CC_EVENTS.PARTICIPANT_LEFT_CONFERENCE,
2335
- interactionId: taskId,
2336
- interaction: {
2337
- participants: {
2338
- [agentId]: {pType: 'Agent', hasLeft: false},
2339
- 'agent-2': {pType: 'Agent', hasLeft: true}, // This agent left
2340
- 'customer-1': {pType: 'Customer', hasLeft: false},
2341
- },
2342
- media: {
2343
- [taskId]: {
2344
- mType: 'mainCall',
2345
- participants: [agentId, 'customer-1'], // agent-2 removed from participants
2346
- },
2347
- },
2348
- },
3008
+ ...createConferenceTaskData(taskId, promotedOwnerId),
3009
+ type: CC_EVENTS.CONTACT_OWNER_CHANGED,
2349
3010
  },
2350
- };
3011
+ })
3012
+ );
2351
3013
 
2352
- const updateTaskDataSpy = jest.spyOn(task, 'updateTaskData');
3014
+ expectLastStateMachineEvent(exactTask.sendStateMachineEvent, TaskEvent.CONTACT_OWNER_CHANGED);
3015
+ expect(childTask.sendStateMachineEvent).not.toHaveBeenCalled();
3016
+ });
2353
3017
 
2354
- webSocketManagerMock.emit('message', JSON.stringify(payload));
3018
+ it('does not replace a distinct main task when an exact child task also exists', () => {
3019
+ const childTaskId = 'exact-child-id';
3020
+ const mainTask = installTask(createConferenceTaskData(taskId, previousOwnerId));
3021
+ const childTask = createMockTask(
3022
+ createConferenceTaskData(childTaskId, previousOwnerId, taskId)
3023
+ );
3024
+ taskManager.taskCollection[childTaskId] = childTask;
2355
3025
 
2356
- // Verify updateTaskData was called exactly once
2357
- expect(updateTaskDataSpy).toHaveBeenCalledTimes(1);
3026
+ webSocketManagerMock.emit(
3027
+ 'message',
3028
+ JSON.stringify({
3029
+ data: {
3030
+ ...createConferenceTaskData(childTaskId, promotedOwnerId, taskId),
3031
+ type: CC_EVENTS.CONTACT_OWNER_CHANGED,
3032
+ },
3033
+ })
3034
+ );
2358
3035
 
2359
- // Verify it was called with isConferenceInProgress already calculated
2360
- expect(updateTaskDataSpy).toHaveBeenCalledWith(
2361
- expect.objectContaining({
2362
- isConferenceInProgress: false, // Only 1 active agent remains
2363
- })
2364
- );
3036
+ expect(childTask.sendStateMachineEvent).not.toHaveBeenCalled();
3037
+ expect(mainTask.sendStateMachineEvent).not.toHaveBeenCalled();
3038
+ expect(taskManager.taskCollection[taskId]).toBe(mainTask);
3039
+ expect(taskManager.taskCollection[childTaskId]).toBe(childTask);
3040
+ });
2365
3041
 
2366
- expect(task.emit).toHaveBeenCalledWith(TASK_EVENTS.TASK_PARTICIPANT_LEFT, task);
2367
- });
3042
+ it('ignores a related owner change with conflicting explicit main interaction IDs', () => {
3043
+ const childTaskId = 'conflicting-main-child-id';
3044
+ const existingMainId = 'existing-main-id';
3045
+ const incomingMainId = 'incoming-main-id';
3046
+ const sharedParentId = 'shared-parent-id';
3047
+ const taskData = createConferenceTaskData(
3048
+ childTaskId,
3049
+ previousOwnerId,
3050
+ existingMainId
3051
+ );
3052
+ taskData.interaction.parentInteractionId = sharedParentId;
3053
+ const task = installTask(taskData);
3054
+ const ownerChangeData = createConferenceTaskData(
3055
+ 'different-child-id',
3056
+ promotedOwnerId,
3057
+ incomingMainId
3058
+ );
3059
+ ownerChangeData.interaction.parentInteractionId = sharedParentId;
2368
3060
 
2369
- it('should emit TASK_PARTICIPANT_LEFT event when participant leaves conference', () => {
2370
- const payload = {
2371
- data: {
2372
- type: CC_EVENTS.PARTICIPANT_LEFT_CONFERENCE,
2373
- interactionId: taskId,
2374
- interaction: {
2375
- participants: {
2376
- [agentId]: {
2377
- hasLeft: false,
2378
- },
2379
- },
2380
- },
2381
- },
2382
- };
3061
+ webSocketManagerMock.emit(
3062
+ 'message',
3063
+ JSON.stringify({
3064
+ data: {...ownerChangeData, type: CC_EVENTS.CONTACT_OWNER_CHANGED},
3065
+ })
3066
+ );
2383
3067
 
2384
- webSocketManagerMock.emit('message', JSON.stringify(payload));
3068
+ expect(task.sendStateMachineEvent).not.toHaveBeenCalled();
3069
+ expect(taskManager.taskCollection[childTaskId]).toBe(task);
3070
+ expect(taskManager.taskCollection[existingMainId]).toBeUndefined();
3071
+ expect(taskManager.taskCollection[incomingMainId]).toBeUndefined();
3072
+ expect(TaskFactory.createTask).not.toHaveBeenCalled();
3073
+ });
2385
3074
 
2386
- expect(task.emit).toHaveBeenCalledWith(TASK_EVENTS.TASK_PARTICIPANT_LEFT, task);
2387
- });
3075
+ it('does not replace a distinct task already stored at the canonical main key', () => {
3076
+ const childTaskId = 'related-active-child-id';
3077
+ const canonicalMainId = 'occupied-canonical-main-id';
3078
+ const candidate = createMockTask(
3079
+ createConferenceTaskData(childTaskId, previousOwnerId, canonicalMainId)
3080
+ );
3081
+ const occupiedData = createConferenceTaskData(
3082
+ 'occupied-task-child-id',
3083
+ previousOwnerId,
3084
+ canonicalMainId
3085
+ );
3086
+ occupiedData.interaction.media[canonicalMainId].mType = 'consult';
3087
+ const occupiedTask = createMockTask(occupiedData);
3088
+ taskManager.taskCollection = {
3089
+ [childTaskId]: candidate,
3090
+ [canonicalMainId]: occupiedTask,
3091
+ };
2388
3092
 
2389
- it('should NOT remove task when agent is still in interaction', () => {
2390
- const payload = {
3093
+ webSocketManagerMock.emit(
3094
+ 'message',
3095
+ JSON.stringify({
2391
3096
  data: {
2392
- type: CC_EVENTS.PARTICIPANT_LEFT_CONFERENCE,
2393
- interactionId: taskId,
2394
- interaction: {
2395
- participants: {
2396
- [agentId]: {
2397
- hasLeft: false,
2398
- },
2399
- },
2400
- },
3097
+ ...createConferenceTaskData(
3098
+ 'incoming-related-child-id',
3099
+ promotedOwnerId,
3100
+ canonicalMainId
3101
+ ),
3102
+ type: CC_EVENTS.CONTACT_OWNER_CHANGED,
2401
3103
  },
2402
- };
3104
+ })
3105
+ );
2403
3106
 
2404
- webSocketManagerMock.emit('message', JSON.stringify(payload));
3107
+ expect(candidate.sendStateMachineEvent).not.toHaveBeenCalled();
3108
+ expect(occupiedTask.sendStateMachineEvent).not.toHaveBeenCalled();
3109
+ expect(taskManager.taskCollection[childTaskId]).toBe(candidate);
3110
+ expect(taskManager.taskCollection[canonicalMainId]).toBe(occupiedTask);
3111
+ expect(TaskFactory.createTask).not.toHaveBeenCalled();
3112
+ });
2405
3113
 
2406
- // Task should still exist in collection
2407
- expect(taskManager.getTask(taskId)).toBeDefined();
2408
- expect(task.emit).toHaveBeenCalledWith(TASK_EVENTS.TASK_PARTICIPANT_LEFT, task);
2409
- });
3114
+ it('ignores a related ContactOwnerChanged task whose current agent is consult-only', () => {
3115
+ const childTaskId = 'consult-only-child-id';
3116
+ const data = createConferenceTaskData(childTaskId, previousOwnerId, taskId);
3117
+ data.interaction.media[taskId].participants = [previousOwnerId, promotedOwnerId];
3118
+ data.interaction.media[childTaskId] = {
3119
+ mediaResourceId: childTaskId,
3120
+ mediaType: 'telephony',
3121
+ mType: 'consult',
3122
+ participants: [currentAgentId, promotedOwnerId],
3123
+ };
3124
+ const task = installTask(data);
2410
3125
 
2411
- it('should NOT remove task when agent left but is in main interaction', () => {
2412
- const payload = {
3126
+ webSocketManagerMock.emit(
3127
+ 'message',
3128
+ JSON.stringify({
2413
3129
  data: {
2414
- type: CC_EVENTS.PARTICIPANT_LEFT_CONFERENCE,
2415
- interactionId: taskId,
2416
- interaction: {
2417
- participants: {
2418
- [agentId]: {
2419
- hasLeft: true,
2420
- },
2421
- },
2422
- media: {
2423
- [taskId]: {
2424
- mType: 'mainCall',
2425
- participants: [agentId],
2426
- },
2427
- },
2428
- },
3130
+ ...createConferenceTaskData('unmatched-child-id', promotedOwnerId, taskId),
3131
+ type: CC_EVENTS.CONTACT_OWNER_CHANGED,
2429
3132
  },
2430
- };
3133
+ })
3134
+ );
2431
3135
 
2432
- const removeTaskSpy = jest.spyOn(taskManager, 'removeTaskFromCollection');
3136
+ expect(task.sendStateMachineEvent).not.toHaveBeenCalled();
3137
+ expect(taskManager.taskCollection[childTaskId]).toBe(task);
3138
+ expect(TaskFactory.createTask).not.toHaveBeenCalled();
3139
+ });
2433
3140
 
2434
- webSocketManagerMock.emit('message', JSON.stringify(payload));
3141
+ it.each([
3142
+ [
3143
+ 'the current agent is marked departed',
3144
+ (data) => {
3145
+ data.interaction.participants[currentAgentId].hasLeft = true;
3146
+ },
3147
+ ],
3148
+ [
3149
+ 'the current agent participant is missing',
3150
+ (data) => {
3151
+ delete data.interaction.participants[currentAgentId];
3152
+ },
3153
+ ],
3154
+ [
3155
+ 'the current agent is present only on a consult leg',
3156
+ (data) => {
3157
+ data.interaction.media[taskId].mType = 'consult';
3158
+ },
3159
+ ],
3160
+ ])('does not recover a promoted task when %s', (_description, mutatePayload) => {
3161
+ clearTasksAndFactoryHistory();
3162
+ const ownerChangeData = createPromotedAgentOwnerChangeData();
3163
+ mutatePayload(ownerChangeData);
2435
3164
 
2436
- // Task should still exist - not removed
2437
- expect(removeTaskSpy).not.toHaveBeenCalled();
2438
- expect(taskManager.getTask(taskId)).toBeDefined();
2439
- expect(task.emit).toHaveBeenCalledWith(TASK_EVENTS.TASK_PARTICIPANT_LEFT, task);
2440
- });
3165
+ webSocketManagerMock.emit('message', JSON.stringify({data: ownerChangeData}));
2441
3166
 
2442
- it('should NOT remove task when agent left but is primary (owner)', () => {
2443
- const payload = {
2444
- data: {
2445
- type: CC_EVENTS.PARTICIPANT_LEFT_CONFERENCE,
2446
- interactionId: taskId,
2447
- interaction: {
2448
- participants: {
2449
- [agentId]: {
2450
- hasLeft: true,
2451
- },
2452
- },
2453
- owner: agentId,
2454
- media: {
2455
- [taskId]: {
2456
- mType: 'consultCall',
2457
- participants: ['other-agent'],
2458
- },
2459
- },
2460
- },
2461
- },
2462
- };
3167
+ expect(TaskFactory.createTask).not.toHaveBeenCalled();
3168
+ expect(taskManager.getAllTasks()).toEqual({});
3169
+ });
2463
3170
 
2464
- const removeTaskSpy = jest.spyOn(taskManager, 'removeTaskFromCollection');
3171
+ it('ignores ambiguous active main-call matches for ContactOwnerChanged', () => {
3172
+ const firstTask = createMockTask(
3173
+ createConferenceTaskData('first-child-id', previousOwnerId, taskId)
3174
+ );
3175
+ const secondTask = createMockTask(
3176
+ createConferenceTaskData('second-child-id', previousOwnerId, taskId)
3177
+ );
3178
+ taskManager.taskCollection = {
3179
+ 'first-child-id': firstTask,
3180
+ 'second-child-id': secondTask,
3181
+ };
2465
3182
 
2466
- webSocketManagerMock.emit('message', JSON.stringify(payload));
3183
+ const ownerChangeData = createConferenceTaskData(
3184
+ 'unmatched-child-id',
3185
+ promotedOwnerId,
3186
+ taskId
3187
+ );
3188
+ ownerChangeData.interaction.interactionId = 'unmatched-child-id';
3189
+ webSocketManagerMock.emit(
3190
+ 'message',
3191
+ JSON.stringify({
3192
+ data: {...ownerChangeData, type: CC_EVENTS.CONTACT_OWNER_CHANGED},
3193
+ })
3194
+ );
2467
3195
 
2468
- // Task should still exist - not removed because agent is primary
2469
- expect(removeTaskSpy).not.toHaveBeenCalled();
2470
- expect(taskManager.getTask(taskId)).toBeDefined();
2471
- expect(task.emit).toHaveBeenCalledWith(TASK_EVENTS.TASK_PARTICIPANT_LEFT, task);
2472
- });
3196
+ expect(firstTask.sendStateMachineEvent).not.toHaveBeenCalled();
3197
+ expect(secondTask.sendStateMachineEvent).not.toHaveBeenCalled();
3198
+ });
2473
3199
 
2474
- it('should remove task when agent left and is NOT in main interaction and is NOT primary', () => {
2475
- const payload = {
2476
- data: {
2477
- type: CC_EVENTS.PARTICIPANT_LEFT_CONFERENCE,
2478
- interactionId: taskId,
2479
- interaction: {
2480
- participants: {
2481
- [agentId]: {
2482
- hasLeft: true,
2483
- },
2484
- },
2485
- owner: 'another-agent-id',
2486
- media: {
2487
- [taskId]: {
2488
- mType: 'mainCall',
2489
- participants: ['another-agent-id'],
2490
- },
2491
- },
2492
- },
3200
+ it('ignores ambiguous main-call media-key matches without creating a task', () => {
3201
+ const mainMediaInteractionId = 'ambiguous-main-media-id';
3202
+ const createMediaRelatedTask = (interactionId: string) => {
3203
+ const data = createConferenceTaskData(interactionId, previousOwnerId, interactionId);
3204
+ data.interaction.media = {
3205
+ [mainMediaInteractionId]: {
3206
+ mediaResourceId: mainMediaInteractionId,
3207
+ mediaType: 'telephony',
3208
+ mType: 'mainCall',
3209
+ participants: [currentAgentId, previousOwnerId, promotedOwnerId],
2493
3210
  },
2494
3211
  };
2495
3212
 
2496
- const removeTaskSpy = jest.spyOn(taskManager, 'removeTaskFromCollection');
3213
+ return createMockTask(data);
3214
+ };
3215
+ const firstTask = createMediaRelatedTask('first-media-child-id');
3216
+ const secondTask = createMediaRelatedTask('second-media-child-id');
3217
+ taskManager.taskCollection = {
3218
+ 'first-media-child-id': firstTask,
3219
+ 'second-media-child-id': secondTask,
3220
+ };
3221
+ (TaskFactory.createTask as jest.Mock).mockClear();
3222
+ const ownerChangeData = createPromotedAgentOwnerChangeData(
3223
+ 'promoted-media-child-id',
3224
+ mainMediaInteractionId
3225
+ );
3226
+ delete ownerChangeData.interaction.mainInteractionId;
2497
3227
 
2498
- webSocketManagerMock.emit('message', JSON.stringify(payload));
3228
+ webSocketManagerMock.emit('message', JSON.stringify({data: ownerChangeData}));
2499
3229
 
2500
- // Task should be removed
2501
- expect(removeTaskSpy).toHaveBeenCalled();
2502
- expect(task.emit).toHaveBeenCalledWith(TASK_EVENTS.TASK_PARTICIPANT_LEFT, task);
2503
- });
3230
+ expect(firstTask.sendStateMachineEvent).not.toHaveBeenCalled();
3231
+ expect(secondTask.sendStateMachineEvent).not.toHaveBeenCalled();
3232
+ expect(TaskFactory.createTask).not.toHaveBeenCalled();
3233
+ expect(Object.values(taskManager.taskCollection)).toEqual([firstTask, secondTask]);
3234
+ });
2504
3235
 
2505
- it('should remove task when agent is not in participants list', () => {
2506
- const payload = {
2507
- data: {
2508
- type: CC_EVENTS.PARTICIPANT_LEFT_CONFERENCE,
2509
- interactionId: taskId,
2510
- interaction: {
2511
- participants: {
2512
- 'other-agent-id': {
2513
- hasLeft: false,
2514
- },
2515
- },
2516
- owner: 'another-agent-id',
2517
- },
2518
- },
2519
- };
3236
+ it('does not recover a new task when multiple stale related tasks exist', () => {
3237
+ const createStaleRelatedTask = (interactionId: string) => {
3238
+ const data = createConferenceTaskData(interactionId, previousOwnerId, taskId);
3239
+ data.interaction.media[taskId].participants = [previousOwnerId, promotedOwnerId];
2520
3240
 
2521
- const removeTaskSpy = jest.spyOn(taskManager, 'removeTaskFromCollection');
3241
+ return createMockTask(data);
3242
+ };
3243
+ const firstTask = createStaleRelatedTask('first-stale-child-id');
3244
+ const secondTask = createStaleRelatedTask('second-stale-child-id');
3245
+ taskManager.taskCollection = {
3246
+ 'first-stale-child-id': firstTask,
3247
+ 'second-stale-child-id': secondTask,
3248
+ };
3249
+ (TaskFactory.createTask as jest.Mock).mockClear();
2522
3250
 
2523
- webSocketManagerMock.emit('message', JSON.stringify(payload));
3251
+ webSocketManagerMock.emit(
3252
+ 'message',
3253
+ JSON.stringify({data: createPromotedAgentOwnerChangeData()})
3254
+ );
2524
3255
 
2525
- // Task should be removed because agent is not in participants
2526
- expect(removeTaskSpy).toHaveBeenCalled();
2527
- expect(task.emit).toHaveBeenCalledWith(TASK_EVENTS.TASK_PARTICIPANT_LEFT, task);
2528
- });
3256
+ expect(firstTask.sendStateMachineEvent).not.toHaveBeenCalled();
3257
+ expect(secondTask.sendStateMachineEvent).not.toHaveBeenCalled();
3258
+ expect(TaskFactory.createTask).not.toHaveBeenCalled();
3259
+ expect(Object.keys(taskManager.taskCollection)).toEqual([
3260
+ 'first-stale-child-id',
3261
+ 'second-stale-child-id',
3262
+ ]);
3263
+ });
2529
3264
 
2530
- it('should update isConferenceInProgress based on remaining active agents', () => {
2531
- const payload = {
2532
- data: {
2533
- type: CC_EVENTS.PARTICIPANT_LEFT_CONFERENCE,
2534
- interactionId: taskId,
2535
- interaction: {
2536
- participants: {
2537
- [agentId]: {
2538
- hasLeft: false,
2539
- pType: 'Agent',
2540
- },
2541
- 'agent-2': {
2542
- hasLeft: false,
2543
- pType: 'Agent',
2544
- },
2545
- 'customer-1': {
2546
- hasLeft: false,
2547
- pType: 'Customer',
2548
- },
2549
- },
2550
- media: {
2551
- [taskId]: {
2552
- mType: 'mainCall',
2553
- participants: [agentId, 'agent-2', 'customer-1'],
2554
- },
2555
- },
2556
- },
2557
- },
2558
- };
3265
+ it('ignores ContactOwnerChanged when different direct nested IDs match active tasks', () => {
3266
+ const firstTaskId = 'direct-main-id';
3267
+ const secondTaskId = 'direct-parent-id';
3268
+ const firstTask = createMockTask(
3269
+ createConferenceTaskData(firstTaskId, previousOwnerId, firstTaskId)
3270
+ );
3271
+ const secondTask = createMockTask(
3272
+ createConferenceTaskData(secondTaskId, previousOwnerId, secondTaskId)
3273
+ );
3274
+ taskManager.taskCollection = {
3275
+ [firstTaskId]: firstTask,
3276
+ [secondTaskId]: secondTask,
3277
+ };
2559
3278
 
2560
- webSocketManagerMock.emit('message', JSON.stringify(payload));
3279
+ const ownerChangeData = createConferenceTaskData(
3280
+ 'unmatched-child-id',
3281
+ promotedOwnerId,
3282
+ firstTaskId
3283
+ );
3284
+ ownerChangeData.interaction.interactionId = secondTaskId;
3285
+ webSocketManagerMock.emit(
3286
+ 'message',
3287
+ JSON.stringify({
3288
+ data: {...ownerChangeData, type: CC_EVENTS.CONTACT_OWNER_CHANGED},
3289
+ })
3290
+ );
2561
3291
 
2562
- // isConferenceInProgress should be true (2 active agents)
2563
- expect(task.data.isConferenceInProgress).toBe(true);
2564
- expect(task.emit).toHaveBeenCalledWith(TASK_EVENTS.TASK_PARTICIPANT_LEFT, task);
2565
- });
3292
+ expect(firstTask.sendStateMachineEvent).not.toHaveBeenCalled();
3293
+ expect(secondTask.sendStateMachineEvent).not.toHaveBeenCalled();
3294
+ });
3295
+
3296
+ it('ignores ContactOwnerChanged without an interaction correlation identifier', () => {
3297
+ const task = installTask(createConferenceTaskData(taskId, previousOwnerId));
2566
3298
 
2567
- it('should set isConferenceInProgress to false when only one agent remains', () => {
2568
- const payload = {
3299
+ webSocketManagerMock.emit(
3300
+ 'message',
3301
+ JSON.stringify({
2569
3302
  data: {
2570
- type: CC_EVENTS.PARTICIPANT_LEFT_CONFERENCE,
2571
- interactionId: taskId,
2572
- interaction: {
2573
- participants: {
2574
- [agentId]: {
2575
- hasLeft: false,
2576
- pType: 'Agent',
2577
- },
2578
- 'agent-2': {
2579
- hasLeft: true,
2580
- pType: 'Agent',
2581
- },
2582
- 'customer-1': {
2583
- hasLeft: false,
2584
- pType: 'Customer',
2585
- },
2586
- },
2587
- media: {
2588
- [taskId]: {
2589
- mType: 'mainCall',
2590
- participants: [agentId, 'customer-1'],
2591
- },
2592
- },
2593
- },
3303
+ type: CC_EVENTS.CONTACT_OWNER_CHANGED,
3304
+ interaction: {owner: promotedOwnerId},
2594
3305
  },
2595
- };
2596
-
2597
- webSocketManagerMock.emit('message', JSON.stringify(payload));
3306
+ })
3307
+ );
2598
3308
 
2599
- // isConferenceInProgress should be false (only 1 active agent)
2600
- expect(task.data.isConferenceInProgress).toBe(false);
2601
- expect(task.emit).toHaveBeenCalledWith(TASK_EVENTS.TASK_PARTICIPANT_LEFT, task);
2602
- });
3309
+ expect(task.sendStateMachineEvent).not.toHaveBeenCalled();
3310
+ });
2603
3311
 
2604
- it('should handle participant left when no participants data exists', () => {
2605
- const payload = {
2606
- data: {
2607
- type: CC_EVENTS.PARTICIPANT_LEFT_CONFERENCE,
2608
- interactionId: taskId,
2609
- interaction: {},
2610
- },
2611
- };
3312
+ it('recovers a missing promoted-agent task under the main interaction and emits one hydrate', () => {
3313
+ clearTasksAndFactoryHistory();
3314
+ const hydrateHandler = jest.fn();
3315
+ const incomingHandler = jest.fn();
3316
+ taskManager.on(TASK_EVENTS.TASK_HYDRATE, hydrateHandler);
3317
+ taskManager.on(TASK_EVENTS.TASK_INCOMING, incomingHandler);
2612
3318
 
2613
- const removeTaskSpy = jest.spyOn(taskManager, 'removeTaskFromCollection');
3319
+ webSocketManagerMock.emit(
3320
+ 'message',
3321
+ JSON.stringify({data: createPromotedAgentOwnerChangeData()})
3322
+ );
2614
3323
 
2615
- webSocketManagerMock.emit('message', JSON.stringify(payload));
3324
+ expect(TaskFactory.createTask).toHaveBeenCalledTimes(1);
3325
+ expect(TaskFactory.createTask).toHaveBeenCalledWith(
3326
+ contactMock,
3327
+ webCallingService,
3328
+ expect.objectContaining({
3329
+ interactionId: taskId,
3330
+ isAutoAnswering: false,
3331
+ isConsulted: false,
3332
+ isConferenceInProgress: true,
3333
+ wrapUpRequired: false,
3334
+ }),
3335
+ undefined,
3336
+ undefined,
3337
+ currentAgentId,
3338
+ undefined
3339
+ );
2616
3340
 
2617
- // When no participants data exists, checkParticipantNotInInteraction returns true
2618
- // Since agent won't be in main interaction either, task should be removed
2619
- expect(removeTaskSpy).toHaveBeenCalled();
2620
- expect(task.emit).toHaveBeenCalledWith(TASK_EVENTS.TASK_PARTICIPANT_LEFT, task);
2621
- });
3341
+ const recoveredTask = (TaskFactory.createTask as jest.Mock).mock.results[0].value;
3342
+ expect(recoveredTask.sendStateMachineEvent).toHaveBeenCalledTimes(2);
3343
+ expect(recoveredTask.sendStateMachineEvent).toHaveBeenNthCalledWith(
3344
+ 1,
3345
+ expect.objectContaining({
3346
+ type: TaskEvent.HYDRATE,
3347
+ agentId: currentAgentId,
3348
+ taskData: expect.objectContaining({
3349
+ interactionId: taskId,
3350
+ interaction: expect.objectContaining({state: 'conference'}),
3351
+ }),
3352
+ })
3353
+ );
3354
+ expect(recoveredTask.sendStateMachineEvent).toHaveBeenNthCalledWith(
3355
+ 2,
3356
+ expect.objectContaining({
3357
+ type: TaskEvent.CONTACT_OWNER_CHANGED,
3358
+ taskData: expect.objectContaining({interactionId: taskId}),
3359
+ })
3360
+ );
3361
+ expect(taskManager.taskCollection[taskId]).toBe(recoveredTask);
3362
+ expect(taskManager.taskCollection['promoted-child-interaction-id']).toBeUndefined();
3363
+ expect(Object.values(taskManager.taskCollection)).toEqual([recoveredTask]);
3364
+ expect(recoveredTask.data.interaction.owner).toBe(currentAgentId);
3365
+ expect(recoveredTask.listenerCount(TASK_EVENTS.TASK_HYDRATE)).toBe(1);
3366
+ expect(hydrateHandler).toHaveBeenCalledTimes(1);
3367
+ expect(hydrateHandler).toHaveBeenCalledWith(recoveredTask);
3368
+ expect(incomingHandler).not.toHaveBeenCalled();
2622
3369
  });
2623
3370
 
2624
- it('should handle PARTICIPANT_LEFT_CONFERENCE_FAILED event', () => {
2625
- const payload = {
2626
- data: {
2627
- type: CC_EVENTS.PARTICIPANT_LEFT_CONFERENCE_FAILED,
2628
- interactionId: taskId,
2629
- reason: 'Exit failed',
3371
+ it.each([
3372
+ [
3373
+ 'another agent owns the interaction',
3374
+ (data) => {
3375
+ data.interaction.owner = promotedOwnerId;
3376
+ data.owner = promotedOwnerId;
2630
3377
  },
2631
- };
3378
+ ],
3379
+ [
3380
+ 'the payload has no main-call media',
3381
+ (data) => {
3382
+ data.interaction.media = {};
3383
+ },
3384
+ ],
3385
+ [
3386
+ 'the interaction is explicitly terminated',
3387
+ (data) => {
3388
+ data.interaction.isTerminated = true;
3389
+ },
3390
+ ],
3391
+ [
3392
+ 'the interaction is not telephony',
3393
+ (data) => {
3394
+ data.interaction.mediaType = 'chat';
3395
+ },
3396
+ ],
3397
+ [
3398
+ 'the payload has no stable interaction identifier',
3399
+ (data) => {
3400
+ data.interactionId = undefined;
3401
+ data.interaction.interactionId = undefined;
3402
+ delete data.interaction.mainInteractionId;
3403
+ data.interaction.media = {};
3404
+ },
3405
+ ],
3406
+ ])('does not create a missing task when %s', (_description, mutatePayload) => {
3407
+ clearTasksAndFactoryHistory();
3408
+ const ownerChangeData = createPromotedAgentOwnerChangeData();
3409
+ mutatePayload(ownerChangeData);
2632
3410
 
2633
- webSocketManagerMock.emit('message', JSON.stringify(payload));
3411
+ webSocketManagerMock.emit('message', JSON.stringify({data: ownerChangeData}));
2634
3412
 
2635
- expect(task.data.reason).toBe('Exit failed');
2636
- // No event emission expected for failure - handled by contact method promise rejection
3413
+ expect(TaskFactory.createTask).not.toHaveBeenCalled();
3414
+ expect(taskManager.getAllTasks()).toEqual({});
2637
3415
  });
2638
3416
 
2639
- it('should only update task for matching interactionId', () => {
2640
- const otherTaskId = 'other-task-id';
2641
- const otherTask = {
2642
- data: {interactionId: otherTaskId},
2643
- emit: jest.fn(),
3417
+ it('does not create a missing task for an owner-changing ContactUpdated event', () => {
3418
+ clearTasksAndFactoryHistory();
3419
+ const contactUpdatedData = {
3420
+ ...createPromotedAgentOwnerChangeData(),
3421
+ type: CC_EVENTS.CONTACT_UPDATED,
2644
3422
  };
2645
- taskManager.taskCollection[otherTaskId] = otherTask;
2646
3423
 
2647
- const payload = {
2648
- data: {
2649
- type: CC_EVENTS.AGENT_CONSULT_CONFERENCED,
2650
- interactionId: taskId,
2651
- isConferencing: true,
2652
- },
2653
- };
3424
+ webSocketManagerMock.emit('message', JSON.stringify({data: contactUpdatedData}));
2654
3425
 
2655
- webSocketManagerMock.emit('message', JSON.stringify(payload));
3426
+ expect(TaskFactory.createTask).not.toHaveBeenCalled();
3427
+ expect(taskManager.getAllTasks()).toEqual({});
3428
+ });
2656
3429
 
2657
- // Only the matching task should be updated
2658
- expect(task.data.isConferencing).toBe(true);
2659
- expect(task.emit).toHaveBeenCalledWith(TASK_EVENTS.TASK_CONFERENCE_STARTED, task);
3430
+ it('reuses a recovered task for later AgentContact and ContactMerged events', () => {
3431
+ clearTasksAndFactoryHistory();
3432
+ const hydrateHandler = jest.fn();
3433
+ const mergedHandler = jest.fn();
3434
+ taskManager.on(TASK_EVENTS.TASK_HYDRATE, hydrateHandler);
3435
+ taskManager.on(TASK_EVENTS.TASK_MERGED, mergedHandler);
2660
3436
 
2661
- // Other task should not be affected
2662
- expect(otherTask.data.isConferencing).toBeUndefined();
2663
- expect(otherTask.emit).not.toHaveBeenCalled();
2664
- });
2665
- });
3437
+ webSocketManagerMock.emit(
3438
+ 'message',
3439
+ JSON.stringify({data: createPromotedAgentOwnerChangeData()})
3440
+ );
2666
3441
 
2667
- describe('handleTaskCleanup - stage changes', () => {
2668
- const agentId = '723a8ffb-a26e-496d-b14a-ff44fb83b64f';
3442
+ const recoveredTask = taskManager.taskCollection[taskId];
3443
+ expect(recoveredTask).toBeDefined();
3444
+ expect(recoveredTask.listenerCount(TASK_EVENTS.TASK_HYDRATE)).toBe(1);
2669
3445
 
2670
- beforeEach(() => {
2671
- taskManager.setAgentId(agentId);
3446
+ webSocketManagerMock.emit(
3447
+ 'message',
3448
+ JSON.stringify({
3449
+ data: {
3450
+ ...createConferenceTaskData(taskId, currentAgentId),
3451
+ type: CC_EVENTS.AGENT_CONTACT,
3452
+ },
3453
+ })
3454
+ );
3455
+ webSocketManagerMock.emit(
3456
+ 'message',
3457
+ JSON.stringify({
3458
+ data: {
3459
+ ...createConferenceTaskData(taskId, currentAgentId),
3460
+ type: CC_EVENTS.CONTACT_MERGED,
3461
+ childInteractionId: 'promoted-child-interaction-id',
3462
+ },
3463
+ })
3464
+ );
3465
+
3466
+ expect(TaskFactory.createTask).toHaveBeenCalledTimes(1);
3467
+ expect(taskManager.taskCollection[taskId]).toBe(recoveredTask);
3468
+ expect(taskManager.taskCollection['promoted-child-interaction-id']).toBeUndefined();
3469
+ expect(Object.values(taskManager.taskCollection)).toEqual([recoveredTask]);
3470
+ expect(recoveredTask.listenerCount(TASK_EVENTS.TASK_HYDRATE)).toBe(1);
3471
+ expect(hydrateHandler).toHaveBeenCalledTimes(2);
3472
+ expect(mergedHandler).toHaveBeenCalledTimes(1);
3473
+ expect(mergedHandler).toHaveBeenCalledWith(recoveredTask);
2672
3474
  });
2673
3475
 
2674
- it('should remove OUTDIAL task on CONTACT_ENDED when current agent is NOT in agentsPendingWrapUp', () => {
2675
- const task = taskManager.getTask(taskId);
2676
- task.updateTaskData = jest.fn().mockImplementation((newData) => {
2677
- task.data = {
2678
- ...task.data,
2679
- ...newData,
2680
- interaction: {
2681
- ...task.data.interaction,
2682
- outboundType: 'OUTDIAL',
2683
- state: 'new',
2684
- mediaType: 'telephony',
2685
- },
2686
- agentsPendingWrapUp: ['different-agent-123'], // Current agent not in the list
2687
- };
2688
- return task;
2689
- });
2690
- task.unregisterWebCallListeners = jest.fn();
2691
- const removeTaskSpy = jest.spyOn(taskManager, 'removeTaskFromCollection');
3476
+ it('does not let a late ParticipantLeftConference restore a departed owner', () => {
3477
+ const task = installTask(createConferenceTaskData(taskId, previousOwnerId));
2692
3478
 
2693
- const payload = {
2694
- data: {
2695
- type: CC_EVENTS.CONTACT_ENDED,
2696
- interactionId: taskId,
2697
- interaction: {
2698
- outboundType: 'OUTDIAL',
2699
- state: 'new',
2700
- mediaType: 'telephony',
3479
+ webSocketManagerMock.emit(
3480
+ 'message',
3481
+ JSON.stringify({
3482
+ data: {
3483
+ ...createConferenceTaskData(taskId, promotedOwnerId),
3484
+ type: CC_EVENTS.CONTACT_UPDATED,
2701
3485
  },
2702
- agentsPendingWrapUp: ['different-agent-123'], // Current agent not in the list
2703
- },
2704
- };
3486
+ })
3487
+ );
2705
3488
 
2706
- webSocketManagerMock.emit('message', JSON.stringify(payload));
3489
+ const participantLeftData = createConferenceTaskData(taskId, previousOwnerId);
3490
+ participantLeftData.participantId = previousOwnerId;
3491
+ participantLeftData.interaction.participants[previousOwnerId].hasLeft = true;
3492
+ participantLeftData.interaction.media[taskId].participants = [
3493
+ currentAgentId,
3494
+ promotedOwnerId,
3495
+ ];
3496
+ webSocketManagerMock.emit(
3497
+ 'message',
3498
+ JSON.stringify({
3499
+ data: {...participantLeftData, type: CC_EVENTS.PARTICIPANT_LEFT_CONFERENCE},
3500
+ })
3501
+ );
2707
3502
 
2708
- expect(removeTaskSpy).toHaveBeenCalled();
2709
- expect(taskManager.getTask(taskId)).toBeUndefined();
3503
+ const participantLeaveEvent = expectLastStateMachineEvent(
3504
+ task.sendStateMachineEvent,
3505
+ TaskEvent.PARTICIPANT_LEAVE
3506
+ );
3507
+ expect(participantLeaveEvent.taskData.interaction.owner).toBe(promotedOwnerId);
3508
+ expect(task.data.interaction.owner).toBe(promotedOwnerId);
2710
3509
  });
2711
3510
 
2712
- it('should NOT remove OUTDIAL task on CONTACT_ENDED when current agent IS in agentsPendingWrapUp', () => {
2713
- const task = taskManager.getTask(taskId);
2714
- task.updateTaskData = jest.fn().mockImplementation((newData) => {
2715
- task.data = {
2716
- ...task.data,
2717
- ...newData,
2718
- interaction: {
2719
- ...task.data.interaction,
2720
- outboundType: 'OUTDIAL',
2721
- state: 'new',
2722
- mediaType: 'telephony',
3511
+ it('does not infer that an incoming owner departed from a partial main-call roster', () => {
3512
+ const task = installTask(createConferenceTaskData(taskId, promotedOwnerId));
3513
+ const partialParticipantLeftData = createConferenceTaskData(taskId, previousOwnerId);
3514
+ partialParticipantLeftData.participantId = 'another-agent-id';
3515
+ partialParticipantLeftData.interaction.media[taskId].participants = [
3516
+ currentAgentId,
3517
+ promotedOwnerId,
3518
+ ];
3519
+
3520
+ webSocketManagerMock.emit(
3521
+ 'message',
3522
+ JSON.stringify({
3523
+ data: {
3524
+ ...partialParticipantLeftData,
3525
+ type: CC_EVENTS.PARTICIPANT_LEFT_CONFERENCE,
2723
3526
  },
2724
- agentsPendingWrapUp: [agentId, 'other-agent-456'], // Current agent IS in the list
2725
- };
2726
- return task;
2727
- });
2728
- task.unregisterWebCallListeners = jest.fn();
2729
- const removeTaskSpy = jest.spyOn(taskManager, 'removeTaskFromCollection');
3527
+ })
3528
+ );
2730
3529
 
2731
- const payload = {
2732
- data: {
2733
- type: CC_EVENTS.CONTACT_ENDED,
2734
- interactionId: taskId,
2735
- interaction: {
2736
- outboundType: 'OUTDIAL',
2737
- state: 'new',
2738
- mediaType: 'telephony',
3530
+ const participantLeaveEvent = expectLastStateMachineEvent(
3531
+ task.sendStateMachineEvent,
3532
+ TaskEvent.PARTICIPANT_LEAVE
3533
+ );
3534
+ expect(participantLeaveEvent.taskData.interaction.owner).toBe(previousOwnerId);
3535
+ expect(task.data.interaction.owner).toBe(previousOwnerId);
3536
+ });
3537
+
3538
+ it('applies the promoted owner when ParticipantLeftConference arrives first', () => {
3539
+ const task = installTask(createConferenceTaskData(taskId, previousOwnerId));
3540
+ const participantLeftData = createConferenceTaskData(taskId, previousOwnerId);
3541
+ participantLeftData.participantId = previousOwnerId;
3542
+ participantLeftData.interaction.participants[previousOwnerId].hasLeft = true;
3543
+ participantLeftData.interaction.media[taskId].participants = [
3544
+ currentAgentId,
3545
+ promotedOwnerId,
3546
+ ];
3547
+
3548
+ webSocketManagerMock.emit(
3549
+ 'message',
3550
+ JSON.stringify({
3551
+ data: {...participantLeftData, type: CC_EVENTS.PARTICIPANT_LEFT_CONFERENCE},
3552
+ })
3553
+ );
3554
+ webSocketManagerMock.emit(
3555
+ 'message',
3556
+ JSON.stringify({
3557
+ data: {
3558
+ ...createConferenceTaskData(taskId, promotedOwnerId),
3559
+ type: CC_EVENTS.CONTACT_UPDATED,
2739
3560
  },
2740
- agentsPendingWrapUp: [agentId, 'other-agent-456'], // Current agent IS in the list
2741
- },
2742
- };
3561
+ })
3562
+ );
2743
3563
 
2744
- webSocketManagerMock.emit('message', JSON.stringify(payload));
3564
+ expect(task.data.interaction.owner).toBe(promotedOwnerId);
3565
+ expectLastStateMachineEvent(task.sendStateMachineEvent, TaskEvent.CONTACT_OWNER_CHANGED);
3566
+ });
3567
+ });
3568
+
3569
+ describe('Conference event handling', () => {
3570
+ let task;
2745
3571
 
2746
- expect(removeTaskSpy).not.toHaveBeenCalled();
2747
- expect(taskManager.getTask(taskId)).toBeDefined();
3572
+ beforeEach(() => {
3573
+ task = {
3574
+ data: {interactionId: taskId},
3575
+ emit: jest.fn(),
3576
+ updateTaskData: jest.fn(),
3577
+ sendStateMachineEvent: jest.fn(),
3578
+ };
3579
+ taskManager.taskCollection[taskId] = task as any;
2748
3580
  });
2749
3581
 
2750
- it('should remove OUTDIAL task when needsWrapUp is false and task is outdial', () => {
2751
- const task = taskManager.getTask(taskId);
2752
- task.updateTaskData = jest.fn().mockImplementation((newData) => {
2753
- task.data = {
2754
- ...task.data,
2755
- ...newData,
2756
- interaction: {
2757
- ...task.data.interaction,
2758
- outboundType: 'OUTDIAL',
2759
- state: 'WRAPUP', // Not 'new' state
2760
- mediaType: 'telephony',
2761
- },
2762
- agentsPendingWrapUp: [], // No agents pending wrap-up
2763
- };
2764
- return task;
2765
- });
2766
- task.unregisterWebCallListeners = jest.fn();
2767
- const removeTaskSpy = jest.spyOn(taskManager, 'removeTaskFromCollection');
3582
+ it('sends AGENT_CONSULT_CONFERENCED to state machine as CONFERENCE_START', () => {
3583
+ const payload = {
3584
+ data: {type: CC_EVENTS.AGENT_CONSULT_CONFERENCED, interactionId: taskId},
3585
+ };
3586
+ webSocketManagerMock.emit('message', JSON.stringify(payload));
3587
+ expect(task.sendStateMachineEvent).toHaveBeenCalled();
3588
+ const call = task.sendStateMachineEvent.mock.calls[0][0];
3589
+ expect(call.type).toBe(TaskEvent.CONFERENCE_START);
3590
+ });
2768
3591
 
3592
+ it('sends PARTICIPANT_JOINED_CONFERENCE to state machine as CONFERENCE_START', () => {
2769
3593
  const payload = {
2770
- data: {
2771
- type: CC_EVENTS.CONTACT_ENDED,
2772
- interactionId: taskId,
2773
- interaction: {
2774
- outboundType: 'OUTDIAL',
2775
- state: 'WRAPUP', // Not 'new' state
2776
- mediaType: 'telephony',
2777
- },
2778
- agentsPendingWrapUp: [], // No agents pending wrap-up
2779
- },
3594
+ data: {type: CC_EVENTS.PARTICIPANT_JOINED_CONFERENCE, interactionId: taskId},
2780
3595
  };
3596
+ webSocketManagerMock.emit('message', JSON.stringify(payload));
3597
+ expect(task.sendStateMachineEvent).toHaveBeenCalled();
3598
+ const call = task.sendStateMachineEvent.mock.calls[0][0];
3599
+ expect(call.type).toBe(TaskEvent.CONFERENCE_START);
3600
+ });
2781
3601
 
3602
+ it('sends AGENT_CONSULT_CONFERENCE_FAILED to state machine as CONFERENCE_FAILED', () => {
3603
+ const payload = {
3604
+ data: {type: CC_EVENTS.AGENT_CONSULT_CONFERENCE_FAILED, interactionId: taskId},
3605
+ };
2782
3606
  webSocketManagerMock.emit('message', JSON.stringify(payload));
3607
+ expect(task.sendStateMachineEvent).toHaveBeenCalled();
3608
+ const call = task.sendStateMachineEvent.mock.calls[0][0];
3609
+ expect(call.type).toBe(TaskEvent.CONFERENCE_FAILED);
3610
+ });
2783
3611
 
2784
- expect(removeTaskSpy).toHaveBeenCalled();
2785
- expect(taskManager.getTask(taskId)).toBeUndefined();
3612
+ it('sends PARTICIPANT_LEFT_CONFERENCE to state machine as PARTICIPANT_LEAVE', () => {
3613
+ const payload = {
3614
+ data: {type: CC_EVENTS.PARTICIPANT_LEFT_CONFERENCE, interactionId: taskId},
3615
+ };
3616
+ webSocketManagerMock.emit('message', JSON.stringify(payload));
3617
+ expect(task.sendStateMachineEvent).toHaveBeenCalled();
3618
+ const call = task.sendStateMachineEvent.mock.calls[0][0];
3619
+ expect(call.type).toBe(TaskEvent.PARTICIPANT_LEAVE);
2786
3620
  });
2787
3621
 
2788
- it('should remove OUTDIAL task when needsWrapUp is false (agentsPendingWrapUp is undefined)', () => {
2789
- const task = taskManager.getTask(taskId);
2790
- task.updateTaskData = jest.fn().mockImplementation((newData) => {
2791
- task.data = {
2792
- ...task.data,
2793
- ...newData,
2794
- interaction: {
2795
- ...task.data.interaction,
2796
- outboundType: 'OUTDIAL',
2797
- state: 'WRAPUP',
2798
- mediaType: 'telephony',
2799
- },
2800
- agentsPendingWrapUp: undefined, // No agentsPendingWrapUp field
2801
- };
2802
- return task;
3622
+ it('routes participant-left from a main interaction to a child-keyed task', () => {
3623
+ const childTaskId = 'child-interaction-id';
3624
+ const childTask = createMockTask({
3625
+ ...taskDataMock,
3626
+ interactionId: childTaskId,
3627
+ isConsulted: true,
3628
+ interaction: {
3629
+ mediaType: 'telephony',
3630
+ interactionId: childTaskId,
3631
+ parentInteractionId: taskId,
3632
+ },
2803
3633
  });
2804
- task.unregisterWebCallListeners = jest.fn();
2805
- const removeTaskSpy = jest.spyOn(taskManager, 'removeTaskFromCollection');
3634
+ delete taskManager.taskCollection[taskId];
3635
+ taskManager.taskCollection[childTaskId] = childTask;
2806
3636
 
2807
- const payload = {
2808
- data: {
2809
- type: CC_EVENTS.CONTACT_ENDED,
2810
- interactionId: taskId,
2811
- interaction: {
2812
- outboundType: 'OUTDIAL',
2813
- state: 'WRAPUP',
2814
- mediaType: 'telephony',
3637
+ webSocketManagerMock.emit(
3638
+ 'message',
3639
+ JSON.stringify({
3640
+ data: {
3641
+ type: CC_EVENTS.PARTICIPANT_LEFT_CONFERENCE,
3642
+ interactionId: taskId,
3643
+ participantId: 'test-agent-id',
3644
+ interaction: {
3645
+ mediaType: 'telephony',
3646
+ interactionId: taskId,
3647
+ mainInteractionId: taskId,
3648
+ },
2815
3649
  },
2816
- // agentsPendingWrapUp not included
3650
+ })
3651
+ );
3652
+
3653
+ expect(childTask.sendStateMachineEvent).toHaveBeenCalledWith(
3654
+ expect.objectContaining({
3655
+ type: TaskEvent.PARTICIPANT_LEAVE,
3656
+ participantId: 'test-agent-id',
3657
+ })
3658
+ );
3659
+ expect(taskManager.taskCollection[childTaskId]).toBeUndefined();
3660
+ expect(taskManager.taskCollection[taskId]).toBe(childTask);
3661
+ expect(
3662
+ Object.values(taskManager.taskCollection).filter((entry) => entry === childTask)
3663
+ ).toHaveLength(1);
3664
+ });
3665
+
3666
+ it('routes consult-end from a main interaction to a child-keyed consulted task', () => {
3667
+ const childTaskId = 'consult-child-interaction-id';
3668
+ const childTask = createMockTask({
3669
+ ...taskDataMock,
3670
+ interactionId: childTaskId,
3671
+ isConsulted: true,
3672
+ interaction: {
3673
+ mediaType: 'telephony',
3674
+ interactionId: childTaskId,
3675
+ callProcessingDetails: {parentInteractionId: taskId},
2817
3676
  },
2818
- };
3677
+ });
3678
+ delete taskManager.taskCollection[taskId];
3679
+ taskManager.taskCollection[childTaskId] = childTask;
2819
3680
 
2820
- webSocketManagerMock.emit('message', JSON.stringify(payload));
3681
+ webSocketManagerMock.emit(
3682
+ 'message',
3683
+ JSON.stringify({
3684
+ data: {
3685
+ type: CC_EVENTS.AGENT_CONSULT_ENDED,
3686
+ interactionId: taskId,
3687
+ interaction: {
3688
+ mediaType: 'telephony',
3689
+ interactionId: taskId,
3690
+ mainInteractionId: taskId,
3691
+ },
3692
+ },
3693
+ })
3694
+ );
2821
3695
 
2822
- expect(removeTaskSpy).toHaveBeenCalled();
2823
- expect(taskManager.getTask(taskId)).toBeUndefined();
3696
+ expect(childTask.sendStateMachineEvent).toHaveBeenCalledWith(
3697
+ expect.objectContaining({type: TaskEvent.CONSULT_END})
3698
+ );
2824
3699
  });
2825
3700
 
2826
- it('should NOT remove OUTDIAL task when needsWrapUp is true (current agent in agentsPendingWrapUp) even if state is WRAPUP', () => {
2827
- const task = taskManager.getTask(taskId);
2828
- task.updateTaskData = jest.fn().mockImplementation((newData) => {
2829
- task.data = {
2830
- ...task.data,
2831
- ...newData,
2832
- interaction: {
2833
- ...task.data.interaction,
2834
- outboundType: 'OUTDIAL',
2835
- state: 'WRAPUP',
2836
- mediaType: 'telephony',
2837
- },
2838
- agentsPendingWrapUp: [agentId], // Current agent needs wrap-up
2839
- };
2840
- return task;
3701
+ it('deduplicates aliases of the same child task during related-interaction lookup', () => {
3702
+ const childTaskId = 'aliased-child-interaction-id';
3703
+ const childTask = createMockTask({
3704
+ ...taskDataMock,
3705
+ interactionId: childTaskId,
3706
+ interaction: {
3707
+ mediaType: 'telephony',
3708
+ interactionId: childTaskId,
3709
+ mainInteractionId: taskId,
3710
+ },
2841
3711
  });
2842
- task.unregisterWebCallListeners = jest.fn();
2843
- const removeTaskSpy = jest.spyOn(taskManager, 'removeTaskFromCollection');
3712
+ delete taskManager.taskCollection[taskId];
3713
+ taskManager.taskCollection[childTaskId] = childTask;
3714
+ taskManager.taskCollection['child-task-alias'] = childTask;
2844
3715
 
2845
- const payload = {
2846
- data: {
2847
- type: CC_EVENTS.CONTACT_ENDED,
2848
- interactionId: taskId,
2849
- interaction: {
2850
- outboundType: 'OUTDIAL',
2851
- state: 'WRAPUP',
2852
- mediaType: 'telephony',
3716
+ webSocketManagerMock.emit(
3717
+ 'message',
3718
+ JSON.stringify({
3719
+ data: {
3720
+ type: CC_EVENTS.PARTICIPANT_LEFT_CONFERENCE,
3721
+ interactionId: taskId,
3722
+ participantId: 'test-agent-id',
2853
3723
  },
2854
- agentsPendingWrapUp: [agentId], // Current agent needs wrap-up
3724
+ })
3725
+ );
3726
+
3727
+ expect(childTask.sendStateMachineEvent).toHaveBeenCalledTimes(1);
3728
+ expect(
3729
+ Object.values(taskManager.taskCollection).filter((entry) => entry === childTask)
3730
+ ).toHaveLength(1);
3731
+ });
3732
+
3733
+ it('prefers an exact task match over a child task with the same main interaction', () => {
3734
+ const childTaskId = 'child-interaction-id';
3735
+ const childTask = createMockTask({
3736
+ ...taskDataMock,
3737
+ interactionId: childTaskId,
3738
+ interaction: {
3739
+ mediaType: 'telephony',
3740
+ interactionId: childTaskId,
3741
+ mainInteractionId: taskId,
2855
3742
  },
2856
- };
3743
+ });
3744
+ taskManager.taskCollection[childTaskId] = childTask;
2857
3745
 
2858
- webSocketManagerMock.emit('message', JSON.stringify(payload));
3746
+ webSocketManagerMock.emit(
3747
+ 'message',
3748
+ JSON.stringify({
3749
+ data: {
3750
+ type: CC_EVENTS.PARTICIPANT_LEFT_CONFERENCE,
3751
+ interactionId: taskId,
3752
+ participantId: 'another-agent',
3753
+ },
3754
+ })
3755
+ );
2859
3756
 
2860
- expect(removeTaskSpy).not.toHaveBeenCalled();
2861
- expect(taskManager.getTask(taskId)).toBeDefined();
3757
+ expect(task.sendStateMachineEvent).toHaveBeenCalled();
3758
+ expect(childTask.sendStateMachineEvent).not.toHaveBeenCalled();
2862
3759
  });
2863
3760
 
2864
- it('should remove non-OUTDIAL task when state is new regardless of agentsPendingWrapUp', () => {
2865
- const task = taskManager.getTask(taskId);
2866
- task.updateTaskData = jest.fn().mockImplementation((newData) => {
2867
- task.data = {
2868
- ...task.data,
2869
- ...newData,
3761
+ it('does not route a main-interaction event when multiple unique child tasks match', () => {
3762
+ delete taskManager.taskCollection[taskId];
3763
+ const createChildTask = (interactionId: string) =>
3764
+ createMockTask({
3765
+ ...taskDataMock,
3766
+ interactionId,
2870
3767
  interaction: {
2871
- ...task.data.interaction,
2872
- outboundType: 'PREVIEW', // Not OUTDIAL
2873
- state: 'new',
2874
3768
  mediaType: 'telephony',
3769
+ interactionId,
3770
+ mainInteractionId: taskId,
2875
3771
  },
2876
- agentsPendingWrapUp: [agentId],
2877
- };
2878
- return task;
2879
- });
2880
- task.unregisterWebCallListeners = jest.fn();
2881
- const removeTaskSpy = jest.spyOn(taskManager, 'removeTaskFromCollection');
3772
+ });
3773
+ const firstChildTask = createChildTask('child-1');
3774
+ const secondChildTask = createChildTask('child-2');
3775
+ taskManager.taskCollection['child-1'] = firstChildTask;
3776
+ taskManager.taskCollection['child-2'] = secondChildTask;
3777
+
3778
+ webSocketManagerMock.emit(
3779
+ 'message',
3780
+ JSON.stringify({
3781
+ data: {
3782
+ type: CC_EVENTS.PARTICIPANT_LEFT_CONFERENCE,
3783
+ interactionId: taskId,
3784
+ },
3785
+ })
3786
+ );
2882
3787
 
2883
- const payload = {
2884
- data: {
2885
- type: CC_EVENTS.CONTACT_ENDED,
2886
- interactionId: taskId,
2887
- interaction: {
2888
- outboundType: 'PREVIEW',
2889
- state: 'new',
2890
- mediaType: 'telephony',
3788
+ expect(firstChildTask.sendStateMachineEvent).not.toHaveBeenCalled();
3789
+ expect(secondChildTask.sendStateMachineEvent).not.toHaveBeenCalled();
3790
+ });
3791
+
3792
+ it('does not route a lifecycle event without a correlation identifier', () => {
3793
+ webSocketManagerMock.emit(
3794
+ 'message',
3795
+ JSON.stringify({
3796
+ data: {
3797
+ type: CC_EVENTS.PARTICIPANT_LEFT_CONFERENCE,
3798
+ participantId: 'another-agent',
2891
3799
  },
2892
- agentsPendingWrapUp: [agentId],
2893
- },
2894
- };
3800
+ })
3801
+ );
2895
3802
 
2896
- webSocketManagerMock.emit('message', JSON.stringify(payload));
3803
+ expect(task.sendStateMachineEvent).not.toHaveBeenCalled();
3804
+ });
3805
+
3806
+ it('removes every collection alias for a task during terminal cleanup', () => {
3807
+ taskManager.taskCollection['task-alias'] = task;
3808
+
3809
+ (taskManager as any).removeTaskFromCollection(task);
3810
+
3811
+ expect(taskManager.taskCollection[taskId]).toBeUndefined();
3812
+ expect(taskManager.taskCollection['task-alias']).toBeUndefined();
3813
+ });
3814
+
3815
+ it('replaces an EP-DN child task with a hydrated main task on CONTACT_MERGED', () => {
3816
+ const childTaskId = 'ep-dn-child-interaction-id';
3817
+ const childTask = createMockTask({
3818
+ ...taskDataMock,
3819
+ interactionId: childTaskId,
3820
+ interaction: {
3821
+ mediaType: 'telephony',
3822
+ interactionId: childTaskId,
3823
+ parentInteractionId: taskId,
3824
+ },
3825
+ });
3826
+ const mergedHandler = jest.fn();
3827
+ delete taskManager.taskCollection[taskId];
3828
+ taskManager.taskCollection[childTaskId] = childTask;
3829
+ taskManager.on(TASK_EVENTS.TASK_MERGED, mergedHandler);
3830
+
3831
+ webSocketManagerMock.emit(
3832
+ 'message',
3833
+ JSON.stringify({
3834
+ data: {
3835
+ type: CC_EVENTS.CONTACT_MERGED,
3836
+ interactionId: taskId,
3837
+ childInteractionId: childTaskId,
3838
+ interaction: {
3839
+ mediaType: 'telephony',
3840
+ interactionId: taskId,
3841
+ mainInteractionId: taskId,
3842
+ state: 'conference',
3843
+ owner: 'test-agent-id',
3844
+ participants: {
3845
+ 'test-agent-id': {id: 'test-agent-id', pType: 'Agent', hasLeft: false},
3846
+ },
3847
+ media: {
3848
+ [taskId]: {
3849
+ mediaResourceId: taskId,
3850
+ mediaType: 'telephony',
3851
+ mType: 'mainCall',
3852
+ participants: ['test-agent-id'],
3853
+ },
3854
+ },
3855
+ },
3856
+ },
3857
+ })
3858
+ );
2897
3859
 
2898
- expect(removeTaskSpy).toHaveBeenCalled();
2899
- expect(taskManager.getTask(taskId)).toBeUndefined();
3860
+ const mainTask = taskManager.taskCollection[taskId];
3861
+ expect(taskManager.taskCollection[childTaskId]).toBeUndefined();
3862
+ expect(mainTask).toBeDefined();
3863
+ expect(mainTask).not.toBe(childTask);
3864
+ expect(mainTask.sendStateMachineEvent).toHaveBeenCalledWith(
3865
+ expect.objectContaining({type: TaskEvent.HYDRATE})
3866
+ );
3867
+ expect(mergedHandler).toHaveBeenCalledWith(mainTask);
2900
3868
  });
2901
3869
 
2902
- it('should handle agentsPendingWrapUp with multiple agents correctly - remove if current agent not in list', () => {
2903
- const task = taskManager.getTask(taskId);
2904
- task.updateTaskData = jest.fn().mockImplementation((newData) => {
2905
- task.data = {
2906
- ...task.data,
2907
- ...newData,
2908
- interaction: {
2909
- ...task.data.interaction,
2910
- outboundType: 'OUTDIAL',
2911
- state: 'new',
2912
- mediaType: 'telephony',
2913
- },
2914
- agentsPendingWrapUp: ['agent-1', 'agent-2', 'agent-3'], // Current agent not in the list
2915
- };
2916
- return task;
2917
- });
2918
- task.unregisterWebCallListeners = jest.fn();
2919
- const removeTaskSpy = jest.spyOn(taskManager, 'removeTaskFromCollection');
2920
-
3870
+ it('handles AGENT_CONSULT_CONFERENCING event without errors', () => {
2921
3871
  const payload = {
2922
- data: {
2923
- type: CC_EVENTS.CONTACT_ENDED,
2924
- interactionId: taskId,
2925
- interaction: {
2926
- outboundType: 'OUTDIAL',
2927
- state: 'new',
2928
- mediaType: 'telephony',
2929
- },
2930
- agentsPendingWrapUp: ['agent-1', 'agent-2', 'agent-3'],
2931
- },
3872
+ data: {type: CC_EVENTS.AGENT_CONSULT_CONFERENCING, interactionId: taskId},
2932
3873
  };
3874
+ expect(() => {
3875
+ webSocketManagerMock.emit('message', JSON.stringify(payload));
3876
+ }).not.toThrow();
3877
+ });
2933
3878
 
2934
- webSocketManagerMock.emit('message', JSON.stringify(payload));
2935
-
2936
- expect(removeTaskSpy).toHaveBeenCalled();
2937
- expect(taskManager.getTask(taskId)).toBeUndefined();
3879
+ it('handles PARTICIPANT_LEFT_CONFERENCE_FAILED event without errors', () => {
3880
+ const payload = {
3881
+ data: {type: CC_EVENTS.PARTICIPANT_LEFT_CONFERENCE_FAILED, interactionId: taskId},
3882
+ };
3883
+ expect(() => {
3884
+ webSocketManagerMock.emit('message', JSON.stringify(payload));
3885
+ }).not.toThrow();
2938
3886
  });
2939
3887
 
2940
- it('should handle agentsPendingWrapUp with multiple agents correctly - keep if current agent is in list', () => {
2941
- const task = taskManager.getTask(taskId);
2942
- task.updateTaskData = jest.fn().mockImplementation((newData) => {
2943
- task.data = {
2944
- ...task.data,
2945
- ...newData,
2946
- interaction: {
2947
- ...task.data.interaction,
2948
- outboundType: 'OUTDIAL',
2949
- state: 'new',
2950
- mediaType: 'telephony',
2951
- },
2952
- agentsPendingWrapUp: ['agent-1', agentId, 'agent-3'], // Current agent IS in the list
2953
- };
2954
- return task;
2955
- });
2956
- task.unregisterWebCallListeners = jest.fn();
2957
- const removeTaskSpy = jest.spyOn(taskManager, 'removeTaskFromCollection');
3888
+ it('only routes conference events to matching tasks', () => {
3889
+ const otherTaskId = 'other-task-id';
3890
+ const otherTask: any = {
3891
+ data: {interactionId: otherTaskId},
3892
+ emit: jest.fn(),
3893
+ updateTaskData: jest.fn(),
3894
+ sendStateMachineEvent: jest.fn(),
3895
+ };
3896
+ taskManager.taskCollection[otherTaskId] = otherTask;
2958
3897
 
2959
3898
  const payload = {
2960
- data: {
2961
- type: CC_EVENTS.CONTACT_ENDED,
2962
- interactionId: taskId,
2963
- interaction: {
2964
- outboundType: 'OUTDIAL',
2965
- state: 'new',
2966
- mediaType: 'telephony',
2967
- },
2968
- agentsPendingWrapUp: ['agent-1', agentId, 'agent-3'],
2969
- },
3899
+ data: {type: CC_EVENTS.AGENT_CONSULT_CONFERENCED, interactionId: taskId},
2970
3900
  };
2971
-
2972
3901
  webSocketManagerMock.emit('message', JSON.stringify(payload));
2973
3902
 
2974
- expect(removeTaskSpy).not.toHaveBeenCalled();
2975
- expect(taskManager.getTask(taskId)).toBeDefined();
3903
+ expect(task.sendStateMachineEvent).toHaveBeenCalled();
3904
+ expect(otherTask.sendStateMachineEvent).not.toHaveBeenCalled();
2976
3905
  });
2977
3906
  });
2978
3907
 
2979
- describe('CONTACT_MERGED event handling', () => {
2980
- let task;
2981
- let taskEmitSpy;
2982
- let managerEmitSpy;
3908
+ describe('state machine integration', () => {
3909
+ it('maps CC events to task state machine events using normalized payload', () => {
3910
+ const mapped = (TaskManager as any).mapEventToTaskStateMachineEvent(
3911
+ CC_EVENTS.AGENT_CONTACT_ASSIGNED,
3912
+ {...taskDataMock, type: CC_EVENTS.AGENT_CONTACT_ASSIGNED}
3913
+ );
2983
3914
 
2984
- beforeEach(() => {
2985
- // Create initial task
2986
- webSocketManagerMock.emit('message', JSON.stringify(initalPayload));
2987
- task = taskManager.getTask(taskId);
2988
- taskEmitSpy = jest.spyOn(task, 'emit');
2989
- managerEmitSpy = jest.spyOn(taskManager, 'emit');
3915
+ expect(mapped).toEqual({
3916
+ type: TaskEvent.ASSIGN,
3917
+ taskData: {...taskDataMock, type: CC_EVENTS.AGENT_CONTACT_ASSIGNED},
3918
+ });
2990
3919
  });
2991
3920
 
2992
- it('should update existing task data and emit TASK_MERGED event when CONTACT_MERGED is received', () => {
2993
- const mergedPayload = {
2994
- data: {
2995
- type: CC_EVENTS.CONTACT_MERGED,
2996
- interactionId: taskId,
2997
- agentId: taskDataMock.agentId,
2998
- interaction: {
2999
- ...taskDataMock.interaction,
3000
- state: 'merged',
3001
- customField: 'updated-value',
3002
- },
3003
- },
3004
- };
3921
+ const stampWrapUp = (
3922
+ eventType: CC_EVENTS,
3923
+ payload: Record<string, unknown>,
3924
+ agentId = 'test-agent-id',
3925
+ task?: unknown
3926
+ ) => (TaskManager as any).stampWrapUpRequiredForEvent(eventType, payload, agentId, task);
3927
+
3928
+ it('maps conference wrap-up events without restamping wrapUpRequired', () => {
3929
+ const unstamped = {...taskDataMock, wrapUpRequired: false};
3930
+ const conferenceEnded = (TaskManager as any).mapEventToTaskStateMachineEvent(
3931
+ CC_EVENTS.AGENT_CONSULT_CONFERENCE_ENDED,
3932
+ unstamped,
3933
+ 'test-agent-id'
3934
+ );
3935
+ const participantLeft = (TaskManager as any).mapEventToTaskStateMachineEvent(
3936
+ CC_EVENTS.PARTICIPANT_LEFT_CONFERENCE,
3937
+ {...unstamped, participantId: 'test-agent-id'},
3938
+ 'test-agent-id'
3939
+ );
3940
+ const transferred = (TaskManager as any).mapEventToTaskStateMachineEvent(
3941
+ CC_EVENTS.AGENT_CONFERENCE_TRANSFERRED,
3942
+ unstamped,
3943
+ 'test-agent-id'
3944
+ );
3945
+
3946
+ expect(conferenceEnded.taskData).toBe(unstamped);
3947
+ expect(participantLeft.taskData.wrapUpRequired).toBe(false);
3948
+ expect(participantLeft.participantId).toBe('test-agent-id');
3949
+ expect(transferred.taskData).toBe(unstamped);
3950
+ });
3005
3951
 
3006
- webSocketManagerMock.emit('message', JSON.stringify(mergedPayload));
3952
+ it('stamps wrapUpRequired on AGENT_CONSULT_CONFERENCE_ENDED only from explicit signals', () => {
3953
+ const wrapping = stampWrapUp(CC_EVENTS.AGENT_CONSULT_CONFERENCE_ENDED, {
3954
+ ...taskDataMock,
3955
+ agentsPendingWrapUp: ['test-agent-id'],
3956
+ });
3957
+ const remaining = stampWrapUp(CC_EVENTS.AGENT_CONSULT_CONFERENCE_ENDED, {
3958
+ ...taskDataMock,
3959
+ owner: 'test-agent-id',
3960
+ isConsulted: false,
3961
+ });
3007
3962
 
3008
- const updatedTask = taskManager.getTask(taskId);
3009
- expect(updatedTask).toBeDefined();
3010
- expect(updatedTask.data.interaction.customField).toBe('updated-value');
3011
- expect(updatedTask.data.interaction.state).toBe('merged');
3012
- expect(managerEmitSpy).toHaveBeenCalledWith(TASK_EVENTS.TASK_MERGED, updatedTask);
3963
+ expect(wrapping.wrapUpRequired).toBe(true);
3964
+ expect(remaining.wrapUpRequired).toBe(false);
3013
3965
  });
3014
3966
 
3015
- it('should create new task when CONTACT_MERGED is received for non-existing task', () => {
3016
- const newMergedTaskId = 'new-merged-task-id';
3017
- const mergedPayload = {
3018
- data: {
3019
- type: CC_EVENTS.CONTACT_MERGED,
3020
- interactionId: newMergedTaskId,
3021
- agentId: taskDataMock.agentId,
3022
- interaction: {
3023
- mediaType: 'telephony',
3024
- state: 'merged',
3025
- participants: {
3026
- [taskDataMock.agentId]: {
3027
- isWrapUp: false,
3028
- hasJoined: true,
3029
- },
3030
- },
3967
+ it('stamps wrapUpRequired on PARTICIPANT_LEFT_CONFERENCE only when this agent left', () => {
3968
+ const selfLeft = stampWrapUp(CC_EVENTS.PARTICIPANT_LEFT_CONFERENCE, {
3969
+ ...taskDataMock,
3970
+ participantId: 'test-agent-id',
3971
+ interaction: {
3972
+ ...taskDataMock.interaction,
3973
+ owner: 'test-agent-id',
3974
+ participants: {},
3975
+ },
3976
+ });
3977
+ const otherLeft = stampWrapUp(CC_EVENTS.PARTICIPANT_LEFT_CONFERENCE, {
3978
+ ...taskDataMock,
3979
+ participantId: 'other-agent-id',
3980
+ interaction: {
3981
+ ...taskDataMock.interaction,
3982
+ owner: 'test-agent-id',
3983
+ participants: {
3984
+ 'test-agent-id': {id: 'test-agent-id', pType: 'Agent', hasLeft: false},
3031
3985
  },
3032
3986
  },
3033
- };
3987
+ });
3034
3988
 
3035
- // Verify task doesn't exist before
3036
- expect(taskManager.getTask(newMergedTaskId)).toBeUndefined();
3989
+ expect(selfLeft.wrapUpRequired).toBe(true);
3990
+ expect(otherLeft.wrapUpRequired).toBe(false);
3991
+ });
3037
3992
 
3038
- webSocketManagerMock.emit('message', JSON.stringify(mergedPayload));
3993
+ it('stamps wrapUpRequired true when this agent left and pending is empty or absent', () => {
3994
+ const emptyPending = stampWrapUp(CC_EVENTS.PARTICIPANT_LEFT_CONFERENCE, {
3995
+ ...taskDataMock,
3996
+ participantId: 'test-agent-id',
3997
+ agentsPendingWrapUp: [],
3998
+ interaction: {
3999
+ ...taskDataMock.interaction,
4000
+ participants: {},
4001
+ },
4002
+ });
4003
+ const absentPending = stampWrapUp(CC_EVENTS.PARTICIPANT_LEFT_CONFERENCE, {
4004
+ ...taskDataMock,
4005
+ participantId: 'test-agent-id',
4006
+ interaction: {
4007
+ ...taskDataMock.interaction,
4008
+ participants: {},
4009
+ },
4010
+ });
3039
4011
 
3040
- // Verify task was created
3041
- const newTask = taskManager.getTask(newMergedTaskId);
3042
- expect(newTask).toBeDefined();
3043
- expect(newTask.data.interactionId).toBe(newMergedTaskId);
3044
- expect(managerEmitSpy).toHaveBeenCalledWith(TASK_EVENTS.TASK_MERGED, newTask);
4012
+ expect(emptyPending.wrapUpRequired).toBe(true);
4013
+ expect(absentPending.wrapUpRequired).toBe(true);
3045
4014
  });
3046
4015
 
3047
- it('should remove child task when childInteractionId is present in CONTACT_MERGED', () => {
3048
- const childTaskId = 'child-task-id';
3049
- const parentTaskId = 'parent-task-id';
3050
-
3051
- // Create child task
3052
- const childPayload = {
3053
- data: {
3054
- type: CC_EVENTS.AGENT_CONTACT_RESERVED,
3055
- interactionId: childTaskId,
3056
- agentId: taskDataMock.agentId,
3057
- interaction: {mediaType: 'telephony'},
4016
+ it('does not stamp wrapUpRequired when this agent left but pending excludes self', () => {
4017
+ const leftExcluded = stampWrapUp(CC_EVENTS.PARTICIPANT_LEFT_CONFERENCE, {
4018
+ ...taskDataMock,
4019
+ participantId: 'test-agent-id',
4020
+ wrapUpRequired: true,
4021
+ agentsPendingWrapUp: ['other-agent-id'],
4022
+ interaction: {
4023
+ ...taskDataMock.interaction,
4024
+ owner: 'test-agent-id',
4025
+ participants: {},
3058
4026
  },
3059
- };
3060
- webSocketManagerMock.emit('message', JSON.stringify(childPayload));
4027
+ });
3061
4028
 
3062
- // Verify child task exists
3063
- expect(taskManager.getTask(childTaskId)).toBeDefined();
4029
+ expect(leftExcluded.wrapUpRequired).toBe(false);
4030
+ });
3064
4031
 
3065
- // Create parent task
3066
- const parentPayload = {
3067
- data: {
3068
- type: CC_EVENTS.AGENT_CONTACT_RESERVED,
3069
- interactionId: parentTaskId,
3070
- agentId: taskDataMock.agentId,
3071
- interaction: {mediaType: 'telephony'},
4032
+ it('does not infer self-leave from a partial participant map when another participant is named', () => {
4033
+ const remaining = stampWrapUp(CC_EVENTS.PARTICIPANT_LEFT_CONFERENCE, {
4034
+ ...taskDataMock,
4035
+ participantId: 'other-agent-id',
4036
+ agentsPendingWrapUp: [],
4037
+ interaction: {
4038
+ ...taskDataMock.interaction,
4039
+ owner: 'test-agent-id',
4040
+ participants: {
4041
+ 'other-agent-id': {id: 'other-agent-id', pType: 'Agent', hasLeft: true},
4042
+ },
3072
4043
  },
3073
- };
3074
- webSocketManagerMock.emit('message', JSON.stringify(parentPayload));
4044
+ });
3075
4045
 
3076
- // Send CONTACT_MERGED with childInteractionId
3077
- const mergedPayload = {
3078
- data: {
3079
- type: CC_EVENTS.CONTACT_MERGED,
3080
- interactionId: parentTaskId,
3081
- childInteractionId: childTaskId,
3082
- agentId: taskDataMock.agentId,
3083
- interaction: {
3084
- mediaType: 'telephony',
3085
- state: 'merged',
4046
+ expect(remaining.wrapUpRequired).toBe(false);
4047
+
4048
+ class TimerTask extends Task {
4049
+ public accept() {
4050
+ return Promise.resolve({} as any);
4051
+ }
4052
+ }
4053
+ const remainingTask = new TimerTask(
4054
+ {},
4055
+ {...taskDataMock, wrapUpRequired: false},
4056
+ {channelType: 'voice', isEndTaskEnabled: true, isEndConsultEnabled: true},
4057
+ {
4058
+ wrapUpProps: {
4059
+ autoWrapup: true,
4060
+ autoWrapupInterval: 1000,
4061
+ wrapUpReasonList: [{id: 'code-1', name: 'Default', isDefault: true, isSystem: false}],
3086
4062
  },
3087
4063
  },
3088
- };
3089
-
3090
- webSocketManagerMock.emit('message', JSON.stringify(mergedPayload));
3091
-
3092
- // Verify child task was removed
3093
- expect(taskManager.getTask(childTaskId)).toBeUndefined();
3094
-
3095
- // Verify parent task still exists
3096
- expect(taskManager.getTask(parentTaskId)).toBeDefined();
4064
+ 'test-agent-id'
4065
+ );
4066
+ try {
4067
+ remainingTask.updateTaskData(remaining);
4068
+ expect(remainingTask.autoWrapup).toBeUndefined();
4069
+ } finally {
4070
+ remainingTask.cancelAutoWrapupTimer();
4071
+ }
4072
+ });
3097
4073
 
3098
- // Verify TASK_MERGED event was emitted
3099
- expect(managerEmitSpy).toHaveBeenCalledWith(
3100
- TASK_EVENTS.TASK_MERGED,
3101
- expect.objectContaining({
3102
- data: expect.objectContaining({
3103
- interactionId: parentTaskId,
3104
- }),
3105
- })
4074
+ it('does not stamp wrapUpRequired when a consulted non-owner leaves with empty pending', () => {
4075
+ const consulted = stampWrapUp(CC_EVENTS.PARTICIPANT_LEFT_CONFERENCE, {
4076
+ ...taskDataMock,
4077
+ participantId: 'test-agent-id',
4078
+ isConsulted: true,
4079
+ agentsPendingWrapUp: [],
4080
+ interaction: {
4081
+ ...taskDataMock.interaction,
4082
+ owner: 'other-agent-id',
4083
+ participants: {},
4084
+ },
4085
+ });
4086
+ const consultedFromTask = stampWrapUp(
4087
+ CC_EVENTS.PARTICIPANT_LEFT_CONFERENCE,
4088
+ {
4089
+ ...taskDataMock,
4090
+ participantId: 'test-agent-id',
4091
+ agentsPendingWrapUp: [],
4092
+ interaction: {
4093
+ ...taskDataMock.interaction,
4094
+ participants: {},
4095
+ },
4096
+ },
4097
+ 'test-agent-id',
4098
+ {
4099
+ data: {
4100
+ isConsulted: true,
4101
+ interaction: {owner: 'other-agent-id'},
4102
+ },
4103
+ }
3106
4104
  );
4105
+
4106
+ expect(consulted.wrapUpRequired).toBe(false);
4107
+ expect(consultedFromTask.wrapUpRequired).toBe(false);
3107
4108
  });
3108
4109
 
3109
- it('should handle CONTACT_MERGED with EP-DN participant correctly', () => {
3110
- const epdnTaskId = 'epdn-merged-task';
3111
- const mergedPayload = {
3112
- data: {
3113
- type: CC_EVENTS.CONTACT_MERGED,
3114
- interactionId: epdnTaskId,
3115
- agentId: taskDataMock.agentId,
4110
+ it('stamps wrapUpRequired when EP-DN omits participantId and previous task included self', () => {
4111
+ const epdn = stampWrapUp(
4112
+ CC_EVENTS.PARTICIPANT_LEFT_CONFERENCE,
4113
+ {
4114
+ ...taskDataMock,
4115
+ agentsPendingWrapUp: [],
3116
4116
  interaction: {
3117
- mediaType: 'telephony',
3118
- state: 'merged',
3119
- participants: {
3120
- [taskDataMock.agentId]: {
3121
- type: 'Agent',
3122
- isWrapUp: false,
3123
- hasJoined: true,
3124
- },
3125
- 'epdn-participant': {
3126
- type: 'EpDn',
3127
- epId: 'entry-point-123',
3128
- isWrapUp: false,
4117
+ ...taskDataMock.interaction,
4118
+ owner: 'test-agent-id',
4119
+ participants: {},
4120
+ },
4121
+ },
4122
+ 'test-agent-id',
4123
+ {
4124
+ data: {
4125
+ interaction: {
4126
+ participants: {
4127
+ 'test-agent-id': {id: 'test-agent-id', pType: 'Agent', hasLeft: false},
3129
4128
  },
3130
4129
  },
3131
4130
  },
3132
- },
3133
- };
3134
-
3135
- webSocketManagerMock.emit('message', JSON.stringify(mergedPayload));
4131
+ }
4132
+ );
3136
4133
 
3137
- const mergedTask = taskManager.getTask(epdnTaskId);
3138
- expect(mergedTask).toBeDefined();
3139
- expect(mergedTask.data.interaction.participants['epdn-participant']).toBeDefined();
3140
- expect(mergedTask.data.interaction.participants['epdn-participant'].type).toBe('EpDn');
3141
- expect(managerEmitSpy).toHaveBeenCalledWith(TASK_EVENTS.TASK_MERGED, mergedTask);
4134
+ expect(epdn.wrapUpRequired).toBe(true);
3142
4135
  });
3143
4136
 
3144
- it('should not affect other tasks when CONTACT_MERGED is received', () => {
3145
- const otherTaskId = 'other-unrelated-task';
3146
- const otherPayload = {
3147
- data: {
3148
- type: CC_EVENTS.AGENT_CONTACT_RESERVED,
3149
- interactionId: otherTaskId,
3150
- agentId: taskDataMock.agentId,
3151
- interaction: {mediaType: 'chat'},
4137
+ it('does not stamp wrapUpRequired on PARTICIPANT_LEFT_CONFERENCE when the actor ignores PARTICIPANT_LEAVE', () => {
4138
+ const selfLeave = {
4139
+ ...taskDataMock,
4140
+ participantId: 'test-agent-id',
4141
+ agentsPendingWrapUp: [],
4142
+ interaction: {
4143
+ ...taskDataMock.interaction,
4144
+ owner: 'test-agent-id',
4145
+ participants: {},
3152
4146
  },
3153
4147
  };
3154
- webSocketManagerMock.emit('message', JSON.stringify(otherPayload));
4148
+ const snapshotTask = (state: TaskState) => ({
4149
+ stateMachineService: {getSnapshot: () => ({value: state})},
4150
+ });
3155
4151
 
3156
- const otherTask = taskManager.getTask(otherTaskId);
3157
- const otherTaskEmitSpy = jest.spyOn(otherTask, 'emit');
4152
+ const holdInitiating = stampWrapUp(
4153
+ CC_EVENTS.PARTICIPANT_LEFT_CONFERENCE,
4154
+ selfLeave,
4155
+ 'test-agent-id',
4156
+ snapshotTask(TaskState.HOLD_INITIATING)
4157
+ );
4158
+ const connected = stampWrapUp(
4159
+ CC_EVENTS.PARTICIPANT_LEFT_CONFERENCE,
4160
+ selfLeave,
4161
+ 'test-agent-id',
4162
+ snapshotTask(TaskState.CONNECTED)
4163
+ );
4164
+ const confInitiating = stampWrapUp(
4165
+ CC_EVENTS.PARTICIPANT_LEFT_CONFERENCE,
4166
+ selfLeave,
4167
+ 'test-agent-id',
4168
+ snapshotTask(TaskState.CONF_INITIATING)
4169
+ );
4170
+ const conferencing = stampWrapUp(
4171
+ CC_EVENTS.PARTICIPANT_LEFT_CONFERENCE,
4172
+ selfLeave,
4173
+ 'test-agent-id',
4174
+ snapshotTask(TaskState.CONFERENCING)
4175
+ );
3158
4176
 
3159
- // Send CONTACT_MERGED for the original task
3160
- const mergedPayload = {
3161
- data: {
3162
- type: CC_EVENTS.CONTACT_MERGED,
3163
- interactionId: taskId,
3164
- agentId: taskDataMock.agentId,
3165
- interaction: {
3166
- mediaType: 'telephony',
3167
- state: 'merged',
4177
+ expect(holdInitiating).toBe(selfLeave);
4178
+ expect(holdInitiating.wrapUpRequired).toBeUndefined();
4179
+ expect(connected.wrapUpRequired).toBeUndefined();
4180
+ expect(confInitiating.wrapUpRequired).toBeUndefined();
4181
+ expect(conferencing.wrapUpRequired).toBe(true);
4182
+
4183
+ class TimerTask extends Task {
4184
+ public accept() {
4185
+ return Promise.resolve({} as any);
4186
+ }
4187
+ }
4188
+ const holdTask = new TimerTask(
4189
+ {},
4190
+ {...taskDataMock, wrapUpRequired: false},
4191
+ {channelType: 'voice', isEndTaskEnabled: true, isEndConsultEnabled: true},
4192
+ {
4193
+ wrapUpProps: {
4194
+ autoWrapup: true,
4195
+ autoWrapupInterval: 1000,
4196
+ wrapUpReasonList: [{id: 'code-1', name: 'Default', isDefault: true, isSystem: false}],
3168
4197
  },
3169
4198
  },
3170
- };
3171
-
3172
- webSocketManagerMock.emit('message', JSON.stringify(mergedPayload));
4199
+ 'test-agent-id'
4200
+ );
4201
+ try {
4202
+ holdTask.updateTaskData(holdInitiating);
4203
+ expect(holdTask.autoWrapup).toBeUndefined();
4204
+ } finally {
4205
+ holdTask.cancelAutoWrapupTimer();
4206
+ }
4207
+ });
3173
4208
 
3174
- // Verify other task was not affected
3175
- expect(otherTaskEmitSpy).not.toHaveBeenCalled();
3176
- expect(otherTask.data.interaction.mediaType).toBe('chat');
4209
+ it('stamps wrapUpRequired on AGENT_CONFERENCE_TRANSFERRED for the initiator only', () => {
4210
+ const initiator = stampWrapUp(CC_EVENTS.AGENT_CONFERENCE_TRANSFERRED, {
4211
+ ...taskDataMock,
4212
+ consultingAgentId: 'test-agent-id',
4213
+ });
4214
+ const observer = stampWrapUp(CC_EVENTS.AGENT_CONFERENCE_TRANSFERRED, {
4215
+ ...taskDataMock,
4216
+ consultingAgentId: 'other-agent-id',
4217
+ owner: 'test-agent-id',
4218
+ });
3177
4219
 
3178
- // Verify original task was updated
3179
- expect(managerEmitSpy).toHaveBeenCalledWith(
3180
- TASK_EVENTS.TASK_MERGED,
3181
- expect.objectContaining({
3182
- data: expect.objectContaining({
3183
- interactionId: taskId,
3184
- }),
3185
- })
3186
- );
4220
+ expect(initiator.wrapUpRequired).toBe(true);
4221
+ expect(observer.wrapUpRequired).toBe(false);
3187
4222
  });
3188
- });
3189
4223
 
3190
- describe('Campaign Preview Reservation', () => {
3191
- it('should create a task and emit TASK_CAMPAIGN_PREVIEW_RESERVATION when AgentOfferCampaignReservation is received', () => {
3192
- const campaignPayload = {
3193
- data: {
3194
- type: CC_EVENTS.AGENT_OFFER_CAMPAIGN_RESERVATION,
3195
- interactionId: 'campaign-interaction-123',
3196
- agentId: taskDataMock.agentId,
3197
- orgId: taskDataMock.orgId,
3198
- trackingId: 'campaign-tracking-456',
3199
- interaction: {
3200
- mediaType: 'telephony',
3201
- callProcessingDetails: {
3202
- campaignId: 'campaign-789',
3203
- },
3204
- },
4224
+ it('does not let transfer initiator override a nonempty pending list that excludes self', () => {
4225
+ const consultingInitiator = stampWrapUp(CC_EVENTS.AGENT_CONFERENCE_TRANSFERRED, {
4226
+ ...taskDataMock,
4227
+ wrapUpRequired: true,
4228
+ agentsPendingWrapUp: ['other-agent-id'],
4229
+ consultingAgentId: 'test-agent-id',
4230
+ });
4231
+ const flaggedInitiator = stampWrapUp(
4232
+ CC_EVENTS.AGENT_CONFERENCE_TRANSFERRED,
4233
+ {
4234
+ ...taskDataMock,
4235
+ wrapUpRequired: true,
4236
+ agentsPendingWrapUp: ['other-agent-id'],
3205
4237
  },
3206
- };
3207
-
3208
- const managerEmitSpy = jest.spyOn(taskManager, 'emit');
4238
+ 'test-agent-id',
4239
+ {
4240
+ stateMachineService: {
4241
+ getSnapshot: () => ({context: {transferConferenceRequested: true}}),
4242
+ },
4243
+ }
4244
+ );
3209
4245
 
3210
- webSocketManagerMock.emit('message', JSON.stringify(campaignPayload));
4246
+ expect(consultingInitiator.wrapUpRequired).toBe(false);
4247
+ expect(flaggedInitiator.wrapUpRequired).toBe(false);
4248
+ });
3211
4249
 
3212
- // Should emit with a task object (not raw data)
3213
- expect(managerEmitSpy).toHaveBeenCalledWith(
3214
- TASK_EVENTS.TASK_CAMPAIGN_PREVIEW_RESERVATION,
3215
- expect.objectContaining({
3216
- data: expect.objectContaining({
3217
- interactionId: 'campaign-interaction-123',
3218
- type: CC_EVENTS.AGENT_OFFER_CAMPAIGN_RESERVATION,
3219
- wrapUpRequired: false,
3220
- isAutoAnswering: false,
3221
- }),
3222
- })
4250
+ it('honors transferConferenceRequested when pending is empty and consultingAgentId is omitted', () => {
4251
+ const flagged = stampWrapUp(
4252
+ CC_EVENTS.AGENT_CONFERENCE_TRANSFERRED,
4253
+ {...taskDataMock},
4254
+ 'test-agent-id',
4255
+ {
4256
+ stateMachineService: {
4257
+ getSnapshot: () => ({context: {transferConferenceRequested: true}}),
4258
+ },
4259
+ }
3223
4260
  );
4261
+ const unflagged = stampWrapUp(CC_EVENTS.AGENT_CONFERENCE_TRANSFERRED, {...taskDataMock});
3224
4262
 
3225
- // Task should be in the collection so subsequent events (e.g. AGENT_CONTACT_ASSIGNED) can find it
3226
- expect(taskManager['taskCollection']['campaign-interaction-123']).toBeDefined();
4263
+ expect(flagged.wrapUpRequired).toBe(true);
4264
+ expect(unflagged.wrapUpRequired).toBe(false);
3227
4265
  });
3228
4266
 
3229
- it('should not emit TASK_INCOMING for campaign preview reservation when incoming WebRTC call arrives', () => {
3230
- const campaignPayload = {
3231
- data: {
3232
- type: CC_EVENTS.AGENT_OFFER_CAMPAIGN_RESERVATION,
3233
- interactionId: 'campaign-interaction-123',
3234
- agentId: taskDataMock.agentId,
3235
- orgId: taskDataMock.orgId,
3236
- trackingId: 'campaign-tracking-456',
3237
- interaction: {
3238
- mediaType: 'telephony',
3239
- callProcessingDetails: {
3240
- campaignId: 'campaign-789',
3241
- },
3242
- },
3243
- },
4267
+ it('does not stamp wrapUpRequired when a nonempty pending list excludes this agent', () => {
4268
+ const conflictingPayload = {
4269
+ ...taskDataMock,
4270
+ wrapUpRequired: true,
4271
+ agentsPendingWrapUp: ['other-agent-id'],
3244
4272
  };
3245
4273
 
3246
- // Remove the default task so only the campaign preview task is in the collection
3247
- delete taskManager['taskCollection'][taskId];
3248
-
3249
- // Create campaign preview task via the reservation event
3250
- webSocketManagerMock.emit('message', JSON.stringify(campaignPayload));
4274
+ const conferenceEnded = stampWrapUp(
4275
+ CC_EVENTS.AGENT_CONSULT_CONFERENCE_ENDED,
4276
+ conflictingPayload
4277
+ );
4278
+ const conferenceTransferred = stampWrapUp(CC_EVENTS.AGENT_CONFERENCE_TRANSFERRED, {
4279
+ ...conflictingPayload,
4280
+ consultingAgentId: 'other-agent-id',
4281
+ });
4282
+ const remainingOnLeave = stampWrapUp(CC_EVENTS.PARTICIPANT_LEFT_CONFERENCE, {
4283
+ ...conflictingPayload,
4284
+ participantId: 'other-agent-id',
4285
+ interaction: {
4286
+ ...taskDataMock.interaction,
4287
+ owner: 'test-agent-id',
4288
+ participants: {
4289
+ 'test-agent-id': {id: 'test-agent-id', pType: 'Agent', hasLeft: false},
4290
+ },
4291
+ },
4292
+ });
3251
4293
 
3252
- const managerEmitSpy = jest.spyOn(taskManager, 'emit');
4294
+ expect(conferenceEnded.wrapUpRequired).toBe(false);
4295
+ expect(conferenceTransferred.wrapUpRequired).toBe(false);
4296
+ expect(remainingOnLeave.wrapUpRequired).toBe(false);
4297
+ });
3253
4298
 
3254
- // Simulate an incoming WebRTC call
3255
- const incomingCallCb = onSpy.mock.calls[0][1];
3256
- incomingCallCb(mockCall);
4299
+ it('stamps wrapUpRequired when pending includes this agent or pending is empty', () => {
4300
+ const pendingIncludesSelf = stampWrapUp(CC_EVENTS.AGENT_CONSULT_CONFERENCE_ENDED, {
4301
+ ...taskDataMock,
4302
+ wrapUpRequired: true,
4303
+ agentsPendingWrapUp: ['test-agent-id'],
4304
+ });
4305
+ const emptyPendingFallback = stampWrapUp(CC_EVENTS.AGENT_CONSULT_CONFERENCE_ENDED, {
4306
+ ...taskDataMock,
4307
+ wrapUpRequired: true,
4308
+ agentsPendingWrapUp: [],
4309
+ });
4310
+ const missingPendingFallback = stampWrapUp(CC_EVENTS.AGENT_CONSULT_CONFERENCE_ENDED, {
4311
+ ...taskDataMock,
4312
+ wrapUpRequired: true,
4313
+ });
3257
4314
 
3258
- // TASK_INCOMING should NOT be emitted because the only telephony task is a campaign preview
3259
- expect(managerEmitSpy).not.toHaveBeenCalledWith(TASK_EVENTS.TASK_INCOMING, expect.anything());
4315
+ expect(pendingIncludesSelf.wrapUpRequired).toBe(true);
4316
+ expect(emptyPendingFallback.wrapUpRequired).toBe(true);
4317
+ expect(missingPendingFallback.wrapUpRequired).toBe(true);
3260
4318
  });
3261
4319
 
3262
- it('should update existing task when AgentOfferCampaignReservation is received for known interactionId', () => {
3263
- const campaignPayload = {
3264
- data: {
3265
- type: CC_EVENTS.AGENT_OFFER_CAMPAIGN_RESERVATION,
3266
- interactionId: 'campaign-interaction-123',
3267
- agentId: taskDataMock.agentId,
3268
- orgId: taskDataMock.orgId,
3269
- trackingId: 'campaign-tracking-456',
3270
- interaction: {
3271
- mediaType: 'telephony',
3272
- callProcessingDetails: {
3273
- campaignId: 'campaign-789',
3274
- },
3275
- },
3276
- },
3277
- };
3278
-
3279
- // Send the first reservation to create the task
3280
- webSocketManagerMock.emit('message', JSON.stringify(campaignPayload));
4320
+ it('sends mapped events to the task state machine service', () => {
4321
+ const payload = {...taskDataMock, type: CC_EVENTS.AGENT_CONTACT_ASSIGNED};
4322
+ const task = taskManager.getTask(taskId);
4323
+ const sendStateMachineEventSpy = jest.spyOn(task, 'sendStateMachineEvent');
3281
4324
 
3282
- const managerEmitSpy = jest.spyOn(taskManager, 'emit');
4325
+ webSocketManagerMock.emit('message', JSON.stringify({data: payload}));
3283
4326
 
3284
- // Send a second reservation for the same interactionId
3285
- webSocketManagerMock.emit('message', JSON.stringify(campaignPayload));
4327
+ expect(sendStateMachineEventSpy).toHaveBeenCalledWith({
4328
+ type: TaskEvent.ASSIGN,
4329
+ taskData: payload,
4330
+ });
3286
4331
 
3287
- expect(managerEmitSpy).toHaveBeenCalledWith(
3288
- TASK_EVENTS.TASK_CAMPAIGN_PREVIEW_RESERVATION,
3289
- expect.objectContaining({
3290
- data: expect.objectContaining({
3291
- interactionId: 'campaign-interaction-123',
3292
- }),
3293
- })
3294
- );
4332
+ sendStateMachineEventSpy.mockRestore();
3295
4333
  });
4334
+ });
3296
4335
 
3297
- it('should update task data but NOT remove task when CampaignContactUpdated is received', () => {
3298
- const campaignInteractionId = 'campaign-interaction-123';
4336
+ describe('applyEnableWxBetterTogether', () => {
4337
+ it('updates config flags and propagates to active tasks', () => {
4338
+ const taskOne = {setEnableWxBetterTogether: jest.fn()};
4339
+ const taskTwo = {setEnableWxBetterTogether: jest.fn()};
3299
4340
 
3300
- // First create a campaign preview task
3301
- const reservationPayload = {
3302
- data: {
3303
- type: CC_EVENTS.AGENT_OFFER_CAMPAIGN_RESERVATION,
3304
- interactionId: campaignInteractionId,
3305
- agentId: taskDataMock.agentId,
3306
- orgId: taskDataMock.orgId,
3307
- trackingId: 'campaign-tracking-456',
3308
- interaction: {
3309
- mediaType: 'telephony',
3310
- callProcessingDetails: {
3311
- campaignId: 'campaign-789',
3312
- },
3313
- },
3314
- },
4341
+ taskManager.setConfigFlags({
4342
+ isEndTaskEnabled: true,
4343
+ isEndConsultEnabled: true,
4344
+ enableWxBetterTogether: true,
4345
+ });
4346
+ taskManager['taskCollection'] = {
4347
+ [taskId]: taskOne,
4348
+ 'task-2': taskTwo,
3315
4349
  };
3316
4350
 
3317
- webSocketManagerMock.emit('message', JSON.stringify(reservationPayload));
4351
+ taskManager.applyEnableWxBetterTogether(false);
3318
4352
 
3319
- // Verify task exists in collection
3320
- const task = taskManager['taskCollection'][campaignInteractionId];
3321
- expect(task).toBeDefined();
4353
+ expect(taskManager['configFlags']?.enableWxBetterTogether).toBe(false);
4354
+ expect(taskOne.setEnableWxBetterTogether).toHaveBeenCalledWith(false);
4355
+ expect(taskTwo.setEnableWxBetterTogether).toHaveBeenCalledWith(false);
4356
+ });
4357
+ });
3322
4358
 
3323
- const taskEmitSpy = jest.spyOn(task, 'emit');
4359
+ describe('applyWxAppMuteStateFromSync', () => {
4360
+ it('propagates mute state to all tasks', () => {
4361
+ const taskOne = {applyWxAppMuteStateFromSync: jest.fn()};
4362
+ const taskTwo = {applyWxAppMuteStateFromSync: jest.fn()};
3324
4363
 
3325
- // Now send CampaignContactUpdated
3326
- const campaignContactUpdatedPayload = {
3327
- data: {
3328
- type: CC_EVENTS.CAMPAIGN_CONTACT_UPDATED,
3329
- interactionId: campaignInteractionId,
3330
- agentId: taskDataMock.agentId,
3331
- orgId: taskDataMock.orgId,
3332
- interaction: {
3333
- mediaType: 'telephony',
3334
- state: 'new',
3335
- callProcessingDetails: {
3336
- campaignId: 'campaign-789',
3337
- },
3338
- },
3339
- },
4364
+ taskManager['taskCollection'] = {
4365
+ [taskId]: taskOne,
4366
+ 'task-2': taskTwo,
3340
4367
  };
3341
4368
 
3342
- webSocketManagerMock.emit('message', JSON.stringify(campaignContactUpdatedPayload));
3343
-
3344
- // Task should still exist in collection (not removed — non-terminal event)
3345
- expect(taskManager['taskCollection'][campaignInteractionId]).toBeDefined();
4369
+ taskManager.applyWxAppMuteStateFromSync('call-1', true);
3346
4370
 
3347
- // TASK_END should NOT have been emitted
3348
- expect(taskEmitSpy).not.toHaveBeenCalledWith(TASK_EVENTS.TASK_END, expect.anything());
4371
+ expect(taskOne.applyWxAppMuteStateFromSync).toHaveBeenCalledWith('call-1', true);
4372
+ expect(taskTwo.applyWxAppMuteStateFromSync).toHaveBeenCalledWith('call-1', true);
3349
4373
  });
3350
4374
  });
3351
4375
  });