@webex/contact-center 3.12.0-llmrefactor.2 → 3.12.0-llmrefactor.4

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 (187) hide show
  1. package/.sdd/manifest.json +5 -4
  2. package/ai-docs/ARCHITECTURE.md +1 -1
  3. package/ai-docs/CONTRACTS.md +6 -2
  4. package/ai-docs/SECURITY.md +1 -1
  5. package/ai-docs/contact-center-spec.md +22 -4
  6. package/ai-docs/features/consult-transfer-list-policy/spec/feature-spec.md +362 -0
  7. package/dist/cc.js +440 -10
  8. package/dist/cc.js.map +1 -1
  9. package/dist/config.js +7 -0
  10. package/dist/config.js.map +1 -1
  11. package/dist/constants.js +10 -2
  12. package/dist/constants.js.map +1 -1
  13. package/dist/index.js.map +1 -1
  14. package/dist/metrics/behavioral-events.js +13 -0
  15. package/dist/metrics/behavioral-events.js.map +1 -1
  16. package/dist/metrics/constants.js +2 -0
  17. package/dist/metrics/constants.js.map +1 -1
  18. package/dist/services/AddressBook.js +18 -15
  19. package/dist/services/AddressBook.js.map +1 -1
  20. package/dist/services/AnswerCallOnWebexService.js +174 -0
  21. package/dist/services/AnswerCallOnWebexService.js.map +1 -0
  22. package/dist/services/EntryPoint.js +46 -68
  23. package/dist/services/EntryPoint.js.map +1 -1
  24. package/dist/services/Queue.js +27 -22
  25. package/dist/services/Queue.js.map +1 -1
  26. package/dist/services/WebexCrossClientService.js +171 -0
  27. package/dist/services/WebexCrossClientService.js.map +1 -0
  28. package/dist/services/WxAppTelephonyMercurySync.js +93 -0
  29. package/dist/services/WxAppTelephonyMercurySync.js.map +1 -0
  30. package/dist/services/config/Util.js +3 -0
  31. package/dist/services/config/Util.js.map +1 -1
  32. package/dist/services/config/constants.js +10 -6
  33. package/dist/services/config/constants.js.map +1 -1
  34. package/dist/services/config/types.js +4 -0
  35. package/dist/services/config/types.js.map +1 -1
  36. package/dist/services/core/Err.js.map +1 -1
  37. package/dist/services/core/WebexRequest.js +6 -2
  38. package/dist/services/core/WebexRequest.js.map +1 -1
  39. package/dist/services/core/aqm-reqs.js +28 -14
  40. package/dist/services/core/aqm-reqs.js.map +1 -1
  41. package/dist/services/core/types.js.map +1 -1
  42. package/dist/services/task/Task.js +73 -7
  43. package/dist/services/task/Task.js.map +1 -1
  44. package/dist/services/task/TaskFactory.js +8 -4
  45. package/dist/services/task/TaskFactory.js.map +1 -1
  46. package/dist/services/task/TaskManager.js +353 -21
  47. package/dist/services/task/TaskManager.js.map +1 -1
  48. package/dist/services/task/WebexCallingUtils.js +70 -0
  49. package/dist/services/task/WebexCallingUtils.js.map +1 -0
  50. package/dist/services/task/constants.js +4 -1
  51. package/dist/services/task/constants.js.map +1 -1
  52. package/dist/services/task/contact.js +29 -0
  53. package/dist/services/task/contact.js.map +1 -1
  54. package/dist/services/task/digital/Digital.js +3 -2
  55. package/dist/services/task/digital/Digital.js.map +1 -1
  56. package/dist/services/task/state-machine/TaskStateMachine.js +135 -37
  57. package/dist/services/task/state-machine/TaskStateMachine.js.map +1 -1
  58. package/dist/services/task/state-machine/actions.js +6 -1
  59. package/dist/services/task/state-machine/actions.js.map +1 -1
  60. package/dist/services/task/state-machine/guards.js +38 -11
  61. package/dist/services/task/state-machine/guards.js.map +1 -1
  62. package/dist/services/task/state-machine/types.js.map +1 -1
  63. package/dist/services/task/state-machine/uiControlsComputer.js +118 -18
  64. package/dist/services/task/state-machine/uiControlsComputer.js.map +1 -1
  65. package/dist/services/task/types.js +16 -1
  66. package/dist/services/task/types.js.map +1 -1
  67. package/dist/services/task/voice/Voice.js +350 -14
  68. package/dist/services/task/voice/Voice.js.map +1 -1
  69. package/dist/services/task/voice/WebRTC.js +4 -1
  70. package/dist/services/task/voice/WebRTC.js.map +1 -1
  71. package/dist/services/task/voice/wxAppVoiceMethods.js +201 -0
  72. package/dist/services/task/voice/wxAppVoiceMethods.js.map +1 -0
  73. package/dist/services/wxAppTelephonyUtils.js +19 -0
  74. package/dist/services/wxAppTelephonyUtils.js.map +1 -0
  75. package/dist/types/cc.d.ts +77 -4
  76. package/dist/types/config.d.ts +7 -0
  77. package/dist/types/constants.d.ts +8 -0
  78. package/dist/types/index.d.ts +3 -1
  79. package/dist/types/metrics/constants.d.ts +2 -0
  80. package/dist/types/services/AddressBook.d.ts +2 -1
  81. package/dist/types/services/AnswerCallOnWebexService.d.ts +37 -0
  82. package/dist/types/services/EntryPoint.d.ts +7 -5
  83. package/dist/types/services/Queue.d.ts +5 -3
  84. package/dist/types/services/WebexCrossClientService.d.ts +28 -0
  85. package/dist/types/services/WxAppTelephonyMercurySync.d.ts +28 -0
  86. package/dist/types/services/config/constants.d.ts +8 -5
  87. package/dist/types/services/config/types.d.ts +17 -4
  88. package/dist/types/services/core/Err.d.ts +2 -0
  89. package/dist/types/services/core/WebexRequest.d.ts +1 -0
  90. package/dist/types/services/core/types.d.ts +2 -0
  91. package/dist/types/services/task/Task.d.ts +20 -2
  92. package/dist/types/services/task/TaskFactory.d.ts +2 -1
  93. package/dist/types/services/task/WebexCallingUtils.d.ts +11 -0
  94. package/dist/types/services/task/constants.d.ts +3 -0
  95. package/dist/types/services/task/contact.d.ts +4 -0
  96. package/dist/types/services/task/digital/Digital.d.ts +2 -2
  97. package/dist/types/services/task/state-machine/TaskStateMachine.d.ts +260 -56
  98. package/dist/types/services/task/state-machine/guards.d.ts +6 -5
  99. package/dist/types/services/task/state-machine/types.d.ts +9 -1
  100. package/dist/types/services/task/types.d.ts +81 -4
  101. package/dist/types/services/task/voice/Voice.d.ts +48 -9
  102. package/dist/types/services/task/voice/WebRTC.d.ts +2 -2
  103. package/dist/types/services/task/voice/wxAppVoiceMethods.d.ts +52 -0
  104. package/dist/types/services/wxAppTelephonyUtils.d.ts +5 -0
  105. package/dist/types/types.d.ts +14 -8
  106. package/dist/types/utils/PageCache.d.ts +20 -3
  107. package/dist/types.js +7 -2
  108. package/dist/types.js.map +1 -1
  109. package/dist/utils/PageCache.js +19 -5
  110. package/dist/utils/PageCache.js.map +1 -1
  111. package/dist/webex.js +1 -1
  112. package/package.json +9 -9
  113. package/src/cc.ts +506 -9
  114. package/src/config.ts +7 -0
  115. package/src/constants.ts +8 -0
  116. package/src/index.ts +2 -0
  117. package/src/metrics/ai-docs/metrics-spec.md +9 -3
  118. package/src/metrics/behavioral-events.ts +14 -0
  119. package/src/metrics/constants.ts +2 -0
  120. package/src/services/AddressBook.ts +17 -6
  121. package/src/services/AnswerCallOnWebexService.ts +206 -0
  122. package/src/services/EntryPoint.ts +59 -60
  123. package/src/services/Queue.ts +29 -12
  124. package/src/services/WebexCrossClientService.ts +212 -0
  125. package/src/services/WxAppTelephonyMercurySync.ts +115 -0
  126. package/src/services/ai-docs/AGENTS.md +10 -10
  127. package/src/services/ai-docs/services-spec.md +6 -1
  128. package/src/services/config/Util.ts +3 -0
  129. package/src/services/config/ai-docs/AGENTS.md +1 -1
  130. package/src/services/config/ai-docs/ARCHITECTURE.md +2 -2
  131. package/src/services/config/ai-docs/config-spec.md +6 -0
  132. package/src/services/config/constants.ts +10 -6
  133. package/src/services/config/types.ts +16 -4
  134. package/src/services/core/Err.ts +1 -0
  135. package/src/services/core/WebexRequest.ts +3 -1
  136. package/src/services/core/ai-docs/core-spec.md +5 -1
  137. package/src/services/core/aqm-reqs.ts +30 -15
  138. package/src/services/core/types.ts +2 -0
  139. package/src/services/task/Task.ts +79 -8
  140. package/src/services/task/TaskFactory.ts +8 -3
  141. package/src/services/task/TaskManager.ts +522 -16
  142. package/src/services/task/WebexCallingUtils.ts +136 -0
  143. package/src/services/task/ai-docs/ARCHITECTURE.md +1 -0
  144. package/src/services/task/ai-docs/task-spec.md +152 -2
  145. package/src/services/task/constants.ts +3 -0
  146. package/src/services/task/contact.ts +30 -0
  147. package/src/services/task/digital/Digital.ts +4 -2
  148. package/src/services/task/state-machine/TaskStateMachine.ts +210 -63
  149. package/src/services/task/state-machine/actions.ts +5 -2
  150. package/src/services/task/state-machine/ai-docs/AGENTS.md +8 -4
  151. package/src/services/task/state-machine/ai-docs/ARCHITECTURE.md +17 -8
  152. package/src/services/task/state-machine/ai-docs/task-state-machine-spec.md +56 -24
  153. package/src/services/task/state-machine/guards.ts +64 -11
  154. package/src/services/task/state-machine/types.ts +16 -1
  155. package/src/services/task/state-machine/uiControlsComputer.ts +206 -32
  156. package/src/services/task/types.ts +114 -4
  157. package/src/services/task/voice/Voice.ts +462 -12
  158. package/src/services/task/voice/WebRTC.ts +5 -1
  159. package/src/services/task/voice/wxAppVoiceMethods.ts +307 -0
  160. package/src/services/wxAppTelephonyUtils.ts +14 -0
  161. package/src/types.ts +33 -9
  162. package/src/utils/AGENTS.md +21 -10
  163. package/src/utils/PageCache.ts +38 -5
  164. package/src/utils/ai-docs/utils-spec.md +21 -11
  165. package/test/unit/spec/cc.ts +1274 -0
  166. package/test/unit/spec/metrics/behavioral-events.ts +18 -0
  167. package/test/unit/spec/services/AddressBook.ts +37 -6
  168. package/test/unit/spec/services/AnswerCallOnWebexService.ts +223 -0
  169. package/test/unit/spec/services/EntryPoint.ts +87 -40
  170. package/test/unit/spec/services/Queue.ts +123 -12
  171. package/test/unit/spec/services/WebexCrossClientService.ts +261 -0
  172. package/test/unit/spec/services/WxAppTelephonyMercurySync.ts +113 -0
  173. package/test/unit/spec/services/config/Util.ts +85 -0
  174. package/test/unit/spec/services/core/WebexRequest.ts +3 -1
  175. package/test/unit/spec/services/core/aqm-reqs.ts +113 -1
  176. package/test/unit/spec/services/task/Task.ts +200 -0
  177. package/test/unit/spec/services/task/TaskFactory.ts +39 -2
  178. package/test/unit/spec/services/task/TaskManager.ts +1294 -1
  179. package/test/unit/spec/services/task/WebexCallingUtils.ts +153 -0
  180. package/test/unit/spec/services/task/contact.ts +33 -0
  181. package/test/unit/spec/services/task/state-machine/TaskStateMachine.ts +854 -72
  182. package/test/unit/spec/services/task/state-machine/guards.ts +210 -8
  183. package/test/unit/spec/services/task/state-machine/uiControlsComputer.ts +445 -7
  184. package/test/unit/spec/services/task/voice/Voice.ts +874 -0
  185. package/test/unit/spec/services/task/voice/wxAppVoiceMethods.ts +459 -0
  186. package/umd/contact-center.min.js +2 -2
  187. package/umd/contact-center.min.js.map +1 -1
@@ -51,6 +51,7 @@ describe('TaskManager', () => {
51
51
  [TaskEvent.TASK_OFFERED]: TASK_EVENTS.TASK_OFFER_CONTACT,
52
52
  [TaskEvent.OFFER_CONSULT]: TASK_EVENTS.TASK_OFFER_CONSULT,
53
53
  [TaskEvent.HYDRATE]: TASK_EVENTS.TASK_HYDRATE,
54
+ [TaskEvent.CONTACT_OWNER_CHANGED]: TASK_EVENTS.TASK_HYDRATE,
54
55
  [TaskEvent.ASSIGN]: TASK_EVENTS.TASK_ASSIGNED,
55
56
  [TaskEvent.HOLD_SUCCESS]: TASK_EVENTS.TASK_HOLD,
56
57
  [TaskEvent.UNHOLD_SUCCESS]: TASK_EVENTS.TASK_RESUME,
@@ -86,7 +87,9 @@ describe('TaskManager', () => {
86
87
  ) {
87
88
  task.emit(mappedEvent, event.reason ?? event.taskData?.reason);
88
89
  } else if (event.type === TaskEvent.OUTBOUND_FAILED) {
89
- task.emit(mappedEvent, event.reason);
90
+ if (!event.taskData?.suppressOutdialFailedPopup) {
91
+ task.emit(mappedEvent, event.reason);
92
+ }
90
93
  } else {
91
94
  task.emit(mappedEvent, task);
92
95
  }
@@ -1468,6 +1471,368 @@ describe('TaskManager', () => {
1468
1471
  expect(removeTaskSpy).toHaveBeenCalled();
1469
1472
  });
1470
1473
 
1474
+ it('should map non-wxApp agent-terminated OUTDIAL ContactEnded to CONTACT_ENDED (not OUTBOUND_FAILED)', () => {
1475
+ const task = taskManager.getTask(taskId);
1476
+ const sendStateMachineEventSpy = jest.spyOn(task, 'sendStateMachineEvent');
1477
+ const outdialFailedSpy = jest.fn();
1478
+ task.on(TASK_EVENTS.TASK_OUTDIAL_FAILED, outdialFailedSpy);
1479
+
1480
+ const payload = {
1481
+ data: {
1482
+ type: CC_EVENTS.CONTACT_ENDED,
1483
+ interactionId: taskId,
1484
+ terminatingParty: 'Agent',
1485
+ interaction: {
1486
+ outboundType: 'OUTDIAL',
1487
+ state: 'wrapUp',
1488
+ mediaType: 'telephony',
1489
+ },
1490
+ agentsPendingWrapUp: ['test-agent-id'],
1491
+ },
1492
+ };
1493
+
1494
+ webSocketManagerMock.emit('message', JSON.stringify(payload));
1495
+
1496
+ expectLastStateMachineEvent(sendStateMachineEventSpy, TaskEvent.CONTACT_ENDED);
1497
+ expect(outdialFailedSpy).not.toHaveBeenCalled();
1498
+ sendStateMachineEventSpy.mockRestore();
1499
+ });
1500
+
1501
+ it('should map non-wxApp agent-terminated OUTDIAL AgentOutboundFailed to OUTBOUND_FAILED with popup suppressed', () => {
1502
+ const task = taskManager.getTask(taskId);
1503
+ const sendStateMachineEventSpy = jest.spyOn(task, 'sendStateMachineEvent');
1504
+ const outdialFailedSpy = jest.fn();
1505
+ task.on(TASK_EVENTS.TASK_OUTDIAL_FAILED, outdialFailedSpy);
1506
+
1507
+ const payload = {
1508
+ data: {
1509
+ type: CC_EVENTS.AGENT_OUTBOUND_FAILED,
1510
+ interactionId: taskId,
1511
+ terminatingParty: 'Agent',
1512
+ reason: 'AGENT_ENDS',
1513
+ interaction: {
1514
+ outboundType: 'OUTDIAL',
1515
+ state: 'wrapUp',
1516
+ mediaType: 'telephony',
1517
+ },
1518
+ agentsPendingWrapUp: ['test-agent-id'],
1519
+ },
1520
+ };
1521
+
1522
+ webSocketManagerMock.emit('message', JSON.stringify(payload));
1523
+
1524
+ const stateMachineEvent = expectLastStateMachineEvent(
1525
+ sendStateMachineEventSpy,
1526
+ TaskEvent.OUTBOUND_FAILED
1527
+ );
1528
+ expect(stateMachineEvent?.reason).toBe('AGENT_ENDS');
1529
+ expect(stateMachineEvent?.taskData?.suppressOutdialFailedPopup).toBe(true);
1530
+ expect(outdialFailedSpy).not.toHaveBeenCalled();
1531
+ sendStateMachineEventSpy.mockRestore();
1532
+ });
1533
+
1534
+ it('should map wxApp agent-terminated OUTDIAL AgentOutboundFailed to OUTBOUND_FAILED with AGENT_ENDS', () => {
1535
+ const task = taskManager.getTask(taskId);
1536
+ task.uiControlConfig = {enableWxBetterTogether: true};
1537
+ task.data = {
1538
+ ...task.data,
1539
+ agentId: 'test-agent-id',
1540
+ interaction: {
1541
+ ...task.data.interaction,
1542
+ outboundType: 'OUTDIAL',
1543
+ participants: {
1544
+ 'test-agent-id': {
1545
+ id: 'test-agent-id',
1546
+ deviceType: 'wxApp',
1547
+ deviceId: 'device-1',
1548
+ deviceCallId: 'call-1',
1549
+ },
1550
+ },
1551
+ },
1552
+ };
1553
+ const sendStateMachineEventSpy = jest.spyOn(task, 'sendStateMachineEvent');
1554
+ const outdialFailedSpy = jest.fn();
1555
+ task.on(TASK_EVENTS.TASK_OUTDIAL_FAILED, outdialFailedSpy);
1556
+
1557
+ const payload = {
1558
+ data: {
1559
+ type: CC_EVENTS.AGENT_OUTBOUND_FAILED,
1560
+ interactionId: taskId,
1561
+ terminatingParty: 'Agent',
1562
+ reason: 'AGENT_ENDS',
1563
+ interaction: {
1564
+ outboundType: 'OUTDIAL',
1565
+ state: 'wrapUp',
1566
+ mediaType: 'telephony',
1567
+ participants: {
1568
+ 'test-agent-id': {
1569
+ id: 'test-agent-id',
1570
+ deviceType: 'wxApp',
1571
+ deviceId: 'device-1',
1572
+ deviceCallId: 'call-1',
1573
+ },
1574
+ },
1575
+ },
1576
+ agentsPendingWrapUp: ['test-agent-id'],
1577
+ },
1578
+ };
1579
+
1580
+ webSocketManagerMock.emit('message', JSON.stringify(payload));
1581
+
1582
+ const stateMachineEvent = expectLastStateMachineEvent(
1583
+ sendStateMachineEventSpy,
1584
+ TaskEvent.OUTBOUND_FAILED
1585
+ );
1586
+ expect(stateMachineEvent?.reason).toBe('AGENT_ENDS');
1587
+ expect(outdialFailedSpy).toHaveBeenCalledWith('AGENT_ENDS');
1588
+ sendStateMachineEventSpy.mockRestore();
1589
+ });
1590
+
1591
+ it('should map wxApp agent-terminated OUTDIAL ContactEnded to OUTBOUND_FAILED with AGENT_ENDS', () => {
1592
+ const task = taskManager.getTask(taskId);
1593
+ task.uiControlConfig = {enableWxBetterTogether: true};
1594
+ task.data = {
1595
+ ...task.data,
1596
+ agentId: 'test-agent-id',
1597
+ interaction: {
1598
+ ...task.data.interaction,
1599
+ outboundType: 'OUTDIAL',
1600
+ participants: {
1601
+ 'test-agent-id': {
1602
+ id: 'test-agent-id',
1603
+ deviceType: 'wxApp',
1604
+ deviceId: 'device-1',
1605
+ deviceCallId: 'call-1',
1606
+ },
1607
+ },
1608
+ },
1609
+ };
1610
+ const sendStateMachineEventSpy = jest.spyOn(task, 'sendStateMachineEvent');
1611
+ const outdialFailedSpy = jest.fn();
1612
+ task.on(TASK_EVENTS.TASK_OUTDIAL_FAILED, outdialFailedSpy);
1613
+
1614
+ const payload = {
1615
+ data: {
1616
+ type: CC_EVENTS.CONTACT_ENDED,
1617
+ interactionId: taskId,
1618
+ terminatingParty: 'Agent',
1619
+ interaction: {
1620
+ outboundType: 'OUTDIAL',
1621
+ state: 'wrapUp',
1622
+ mediaType: 'telephony',
1623
+ participants: {
1624
+ 'test-agent-id': {
1625
+ id: 'test-agent-id',
1626
+ deviceType: 'wxApp',
1627
+ deviceId: 'device-1',
1628
+ deviceCallId: 'call-1',
1629
+ },
1630
+ },
1631
+ },
1632
+ agentsPendingWrapUp: ['test-agent-id'],
1633
+ },
1634
+ };
1635
+
1636
+ webSocketManagerMock.emit('message', JSON.stringify(payload));
1637
+
1638
+ const stateMachineEvent = expectLastStateMachineEvent(
1639
+ sendStateMachineEventSpy,
1640
+ TaskEvent.OUTBOUND_FAILED
1641
+ );
1642
+ expect(stateMachineEvent?.reason).toBe('AGENT_ENDS');
1643
+ expect(stateMachineEvent?.taskData?.wrapUpRequired).toBe(true);
1644
+ expect(outdialFailedSpy).toHaveBeenCalledWith('AGENT_ENDS');
1645
+ sendStateMachineEventSpy.mockRestore();
1646
+ });
1647
+
1648
+ it('should map offer-stage OUTDIAL decline ContactEnded to CONTACT_ENDED (not wrapup)', () => {
1649
+ const task = taskManager.getTask(taskId);
1650
+ task.stateMachineService = {
1651
+ getSnapshot: () => ({value: 'OFFERED'}),
1652
+ };
1653
+ task.uiControlConfig = {enableWxBetterTogether: true, wxAppAnswerPending: false};
1654
+ task.data = {
1655
+ ...task.data,
1656
+ agentId: 'test-agent-id',
1657
+ interaction: {
1658
+ ...task.data.interaction,
1659
+ outboundType: 'OUTDIAL',
1660
+ participants: {
1661
+ 'test-agent-id': {
1662
+ id: 'test-agent-id',
1663
+ deviceType: 'wxApp',
1664
+ deviceId: 'device-1',
1665
+ deviceCallId: 'call-1',
1666
+ },
1667
+ },
1668
+ },
1669
+ };
1670
+ const sendStateMachineEventSpy = jest.spyOn(task, 'sendStateMachineEvent');
1671
+ const outdialFailedSpy = jest.fn();
1672
+ task.on(TASK_EVENTS.TASK_OUTDIAL_FAILED, outdialFailedSpy);
1673
+
1674
+ const payload = {
1675
+ data: {
1676
+ type: CC_EVENTS.CONTACT_ENDED,
1677
+ interactionId: taskId,
1678
+ terminatingParty: 'Agent',
1679
+ interaction: {
1680
+ outboundType: 'OUTDIAL',
1681
+ state: 'wrapUp',
1682
+ mediaType: 'telephony',
1683
+ },
1684
+ agentsPendingWrapUp: [],
1685
+ },
1686
+ };
1687
+
1688
+ webSocketManagerMock.emit('message', JSON.stringify(payload));
1689
+
1690
+ expectLastStateMachineEvent(sendStateMachineEventSpy, TaskEvent.CONTACT_ENDED);
1691
+ expect(outdialFailedSpy).not.toHaveBeenCalled();
1692
+ sendStateMachineEventSpy.mockRestore();
1693
+ });
1694
+
1695
+ it('should map post-accept OUTDIAL ContactEnded to OUTBOUND_FAILED when still OFFERED with wxAppAnswerPending', () => {
1696
+ const task = taskManager.getTask(taskId);
1697
+ task.stateMachineService = {
1698
+ getSnapshot: () => ({value: 'OFFERED'}),
1699
+ };
1700
+ task.uiControlConfig = {enableWxBetterTogether: true, wxAppAnswerPending: true};
1701
+ task.data = {
1702
+ ...task.data,
1703
+ agentId: 'test-agent-id',
1704
+ interaction: {
1705
+ ...task.data.interaction,
1706
+ outboundType: 'OUTDIAL',
1707
+ participants: {
1708
+ 'test-agent-id': {
1709
+ id: 'test-agent-id',
1710
+ deviceType: 'wxApp',
1711
+ deviceId: 'device-1',
1712
+ deviceCallId: 'call-1',
1713
+ },
1714
+ },
1715
+ },
1716
+ };
1717
+ const sendStateMachineEventSpy = jest.spyOn(task, 'sendStateMachineEvent');
1718
+ const outdialFailedSpy = jest.fn();
1719
+ task.on(TASK_EVENTS.TASK_OUTDIAL_FAILED, outdialFailedSpy);
1720
+
1721
+ const payload = {
1722
+ data: {
1723
+ type: CC_EVENTS.CONTACT_ENDED,
1724
+ interactionId: taskId,
1725
+ terminatingParty: 'Agent',
1726
+ interaction: {
1727
+ outboundType: 'OUTDIAL',
1728
+ state: 'offered',
1729
+ mediaType: 'telephony',
1730
+ participants: {
1731
+ 'test-agent-id': {
1732
+ id: 'test-agent-id',
1733
+ deviceType: 'wxApp',
1734
+ deviceId: 'device-1',
1735
+ deviceCallId: 'call-1',
1736
+ },
1737
+ },
1738
+ },
1739
+ },
1740
+ };
1741
+
1742
+ webSocketManagerMock.emit('message', JSON.stringify(payload));
1743
+
1744
+ const stateMachineEvent = expectLastStateMachineEvent(
1745
+ sendStateMachineEventSpy,
1746
+ TaskEvent.OUTBOUND_FAILED
1747
+ );
1748
+ expect(stateMachineEvent?.reason).toBe('AGENT_ENDS');
1749
+ expect(outdialFailedSpy).toHaveBeenCalledWith('AGENT_ENDS');
1750
+ sendStateMachineEventSpy.mockRestore();
1751
+ });
1752
+
1753
+ it('should map post-accept agent-terminated OUTDIAL ContactEnded to OUTBOUND_FAILED when CONNECTED', () => {
1754
+ const task = taskManager.getTask(taskId);
1755
+ task.stateMachineService = {
1756
+ getSnapshot: () => ({value: 'CONNECTED'}),
1757
+ };
1758
+ task.uiControlConfig = {enableWxBetterTogether: true};
1759
+ task.data = {
1760
+ ...task.data,
1761
+ agentId: 'test-agent-id',
1762
+ interaction: {
1763
+ ...task.data.interaction,
1764
+ outboundType: 'OUTDIAL',
1765
+ participants: {
1766
+ 'test-agent-id': {
1767
+ id: 'test-agent-id',
1768
+ deviceType: 'wxApp',
1769
+ deviceId: 'device-1',
1770
+ deviceCallId: 'call-1',
1771
+ },
1772
+ },
1773
+ },
1774
+ };
1775
+ const sendStateMachineEventSpy = jest.spyOn(task, 'sendStateMachineEvent');
1776
+ const outdialFailedSpy = jest.fn();
1777
+ task.on(TASK_EVENTS.TASK_OUTDIAL_FAILED, outdialFailedSpy);
1778
+
1779
+ const payload = {
1780
+ data: {
1781
+ type: CC_EVENTS.CONTACT_ENDED,
1782
+ interactionId: taskId,
1783
+ terminatingParty: 'Agent',
1784
+ interaction: {
1785
+ outboundType: 'OUTDIAL',
1786
+ state: 'wrapUp',
1787
+ mediaType: 'telephony',
1788
+ participants: {
1789
+ 'test-agent-id': {
1790
+ id: 'test-agent-id',
1791
+ deviceType: 'wxApp',
1792
+ deviceId: 'device-1',
1793
+ deviceCallId: 'call-1',
1794
+ },
1795
+ },
1796
+ },
1797
+ agentsPendingWrapUp: ['test-agent-id'],
1798
+ },
1799
+ };
1800
+
1801
+ webSocketManagerMock.emit('message', JSON.stringify(payload));
1802
+
1803
+ const stateMachineEvent = expectLastStateMachineEvent(
1804
+ sendStateMachineEventSpy,
1805
+ TaskEvent.OUTBOUND_FAILED
1806
+ );
1807
+ expect(stateMachineEvent?.reason).toBe('AGENT_ENDS');
1808
+ expect(outdialFailedSpy).toHaveBeenCalledWith('AGENT_ENDS');
1809
+ sendStateMachineEventSpy.mockRestore();
1810
+ });
1811
+
1812
+ it('should map customer-terminated OUTDIAL ContactEnded to CONTACT_ENDED (no AGENT_ENDS remap)', () => {
1813
+ const task = taskManager.getTask(taskId);
1814
+ const sendStateMachineEventSpy = jest.spyOn(task, 'sendStateMachineEvent');
1815
+
1816
+ const payload = {
1817
+ data: {
1818
+ type: CC_EVENTS.CONTACT_ENDED,
1819
+ interactionId: taskId,
1820
+ terminatingParty: 'Customer',
1821
+ interaction: {
1822
+ outboundType: 'OUTDIAL',
1823
+ state: 'new',
1824
+ mediaType: 'telephony',
1825
+ },
1826
+ agentsPendingWrapUp: ['test-agent-id'],
1827
+ },
1828
+ };
1829
+
1830
+ webSocketManagerMock.emit('message', JSON.stringify(payload));
1831
+
1832
+ expectLastStateMachineEvent(sendStateMachineEventSpy, TaskEvent.CONTACT_ENDED);
1833
+ sendStateMachineEventSpy.mockRestore();
1834
+ });
1835
+
1471
1836
  it('should handle CONTACT_ENDED gracefully when task is undefined', () => {
1472
1837
  const payload = {
1473
1838
  data: {
@@ -2359,6 +2724,646 @@ describe('TaskManager', () => {
2359
2724
  });
2360
2725
  });
2361
2726
 
2727
+ describe('Contact owner propagation', () => {
2728
+ const currentAgentId = 'test-agent-id';
2729
+ const previousOwnerId = 'previous-owner-id';
2730
+ const promotedOwnerId = 'promoted-owner-id';
2731
+
2732
+ const createConferenceTaskData = (
2733
+ interactionId: string,
2734
+ owner: string,
2735
+ mainInteractionId = taskId
2736
+ ) => ({
2737
+ ...taskDataMock,
2738
+ agentId: currentAgentId,
2739
+ interactionId,
2740
+ owner,
2741
+ interaction: {
2742
+ mediaType: 'telephony',
2743
+ interactionId,
2744
+ mainInteractionId,
2745
+ owner,
2746
+ participants: {
2747
+ [currentAgentId]: {
2748
+ id: currentAgentId,
2749
+ pType: 'Agent',
2750
+ hasJoined: true,
2751
+ hasLeft: false,
2752
+ },
2753
+ [previousOwnerId]: {
2754
+ id: previousOwnerId,
2755
+ pType: 'Agent',
2756
+ hasJoined: true,
2757
+ hasLeft: false,
2758
+ },
2759
+ [promotedOwnerId]: {
2760
+ id: promotedOwnerId,
2761
+ pType: 'Agent',
2762
+ hasJoined: true,
2763
+ hasLeft: false,
2764
+ },
2765
+ },
2766
+ media: {
2767
+ [mainInteractionId]: {
2768
+ mediaResourceId: mainInteractionId,
2769
+ mediaType: 'telephony',
2770
+ mType: 'mainCall',
2771
+ participants: [currentAgentId, previousOwnerId, promotedOwnerId],
2772
+ },
2773
+ },
2774
+ },
2775
+ });
2776
+
2777
+ const installTask = (data) => {
2778
+ const task = createMockTask(data);
2779
+ taskManager.taskCollection = {[data.interactionId]: task};
2780
+ (taskManager as any).setupTaskListeners(task);
2781
+
2782
+ return task;
2783
+ };
2784
+
2785
+ const createPromotedAgentOwnerChangeData = (
2786
+ interactionId = 'promoted-child-interaction-id',
2787
+ mainInteractionId = taskId
2788
+ ) => {
2789
+ const data = createConferenceTaskData(interactionId, currentAgentId, mainInteractionId);
2790
+
2791
+ data.interaction.state = 'conference';
2792
+
2793
+ return {...data, type: CC_EVENTS.CONTACT_OWNER_CHANGED};
2794
+ };
2795
+
2796
+ const clearTasksAndFactoryHistory = () => {
2797
+ taskManager.taskCollection = {};
2798
+ (TaskFactory.createTask as jest.Mock).mockClear();
2799
+ };
2800
+
2801
+ it('emits one hydrate when ContactUpdated changes interaction.owner', () => {
2802
+ const task = installTask(createConferenceTaskData(taskId, previousOwnerId));
2803
+ const hydrateHandler = jest.fn();
2804
+ taskManager.on(TASK_EVENTS.TASK_HYDRATE, hydrateHandler);
2805
+
2806
+ webSocketManagerMock.emit(
2807
+ 'message',
2808
+ JSON.stringify({
2809
+ data: {
2810
+ ...createConferenceTaskData(taskId, promotedOwnerId),
2811
+ type: CC_EVENTS.CONTACT_UPDATED,
2812
+ },
2813
+ })
2814
+ );
2815
+
2816
+ expectLastStateMachineEvent(task.sendStateMachineEvent, TaskEvent.CONTACT_OWNER_CHANGED);
2817
+ expect(task.data.interaction.owner).toBe(promotedOwnerId);
2818
+ expect(hydrateHandler).toHaveBeenCalledTimes(1);
2819
+ expect(hydrateHandler).toHaveBeenCalledWith(task);
2820
+ });
2821
+
2822
+ it.each([
2823
+ ['the same owner', previousOwnerId],
2824
+ ['no owner', undefined],
2825
+ ['a blank owner', ' '],
2826
+ ])('keeps ContactUpdated data-only when it carries %s', (_description, owner) => {
2827
+ const task = installTask(createConferenceTaskData(taskId, previousOwnerId));
2828
+ const hydrateHandler = jest.fn();
2829
+ taskManager.on(TASK_EVENTS.TASK_HYDRATE, hydrateHandler);
2830
+ const interaction = createConferenceTaskData(taskId, previousOwnerId).interaction;
2831
+ if (owner === undefined) {
2832
+ delete interaction.owner;
2833
+ } else {
2834
+ interaction.owner = owner;
2835
+ }
2836
+
2837
+ webSocketManagerMock.emit(
2838
+ 'message',
2839
+ JSON.stringify({
2840
+ data: {
2841
+ ...createConferenceTaskData(taskId, previousOwnerId),
2842
+ type: CC_EVENTS.CONTACT_UPDATED,
2843
+ interaction,
2844
+ },
2845
+ })
2846
+ );
2847
+
2848
+ expectLastStateMachineEvent(task.sendStateMachineEvent, TaskEvent.CONTACT_UPDATED);
2849
+ expect(hydrateHandler).not.toHaveBeenCalled();
2850
+ });
2851
+
2852
+ it('correlates child-keyed ContactOwnerChanged and keeps the main task identity', () => {
2853
+ const childTaskId = 'surviving-child-id';
2854
+ const promotedChildId = 'promoted-child-id';
2855
+ const task = installTask(
2856
+ createConferenceTaskData(childTaskId, previousOwnerId, taskId)
2857
+ );
2858
+ const hydrateHandler = jest.fn();
2859
+ taskManager.on(TASK_EVENTS.TASK_HYDRATE, hydrateHandler);
2860
+
2861
+ const ownerChangeData = createConferenceTaskData(
2862
+ promotedChildId,
2863
+ promotedOwnerId,
2864
+ taskId
2865
+ );
2866
+ ownerChangeData.interaction.interactionId = taskId;
2867
+ webSocketManagerMock.emit(
2868
+ 'message',
2869
+ JSON.stringify({
2870
+ data: {...ownerChangeData, type: CC_EVENTS.CONTACT_OWNER_CHANGED},
2871
+ })
2872
+ );
2873
+
2874
+ expectLastStateMachineEvent(task.sendStateMachineEvent, TaskEvent.CONTACT_OWNER_CHANGED);
2875
+ expect(task.data.interactionId).toBe(taskId);
2876
+ expect(task.data.interaction.owner).toBe(promotedOwnerId);
2877
+ expect(taskManager.taskCollection[childTaskId]).toBeUndefined();
2878
+ expect(taskManager.taskCollection[promotedChildId]).toBeUndefined();
2879
+ expect(taskManager.taskCollection[taskId]).toBe(task);
2880
+ expect(Object.values(taskManager.taskCollection).filter((entry) => entry === task)).toHaveLength(
2881
+ 1
2882
+ );
2883
+ expect(hydrateHandler).toHaveBeenCalledTimes(1);
2884
+ });
2885
+
2886
+ it('uses the incoming promotion snapshot to correlate a stale child-keyed task', () => {
2887
+ const childTaskId = 'stale-child-interaction-id';
2888
+ const staleTaskData = createConferenceTaskData(childTaskId, previousOwnerId, taskId);
2889
+ staleTaskData.interaction.media = {
2890
+ [childTaskId]: {
2891
+ mediaResourceId: childTaskId,
2892
+ mediaType: 'telephony',
2893
+ mType: 'consult',
2894
+ participants: [currentAgentId, promotedOwnerId],
2895
+ },
2896
+ };
2897
+ const task = installTask(staleTaskData);
2898
+ const hydrateHandler = jest.fn();
2899
+ taskManager.on(TASK_EVENTS.TASK_HYDRATE, hydrateHandler);
2900
+
2901
+ webSocketManagerMock.emit(
2902
+ 'message',
2903
+ JSON.stringify({data: createPromotedAgentOwnerChangeData()})
2904
+ );
2905
+
2906
+ expectLastStateMachineEvent(task.sendStateMachineEvent, TaskEvent.CONTACT_OWNER_CHANGED);
2907
+ expect(task.data.interaction.owner).toBe(currentAgentId);
2908
+ expect(taskManager.taskCollection[childTaskId]).toBeUndefined();
2909
+ expect(taskManager.taskCollection[taskId]).toBe(task);
2910
+ expect(TaskFactory.createTask).not.toHaveBeenCalled();
2911
+ expect(hydrateHandler).toHaveBeenCalledTimes(1);
2912
+ });
2913
+
2914
+ it('reuses a task stored under the incoming main-call media key', () => {
2915
+ const mainMediaInteractionId = 'main-media-interaction-id';
2916
+ const promotedChildId = 'promoted-child-without-main-id';
2917
+ const task = installTask(
2918
+ createConferenceTaskData(
2919
+ mainMediaInteractionId,
2920
+ previousOwnerId,
2921
+ mainMediaInteractionId
2922
+ )
2923
+ );
2924
+ const hydrateHandler = jest.fn();
2925
+ taskManager.on(TASK_EVENTS.TASK_HYDRATE, hydrateHandler);
2926
+ const ownerChangeData = createPromotedAgentOwnerChangeData(
2927
+ promotedChildId,
2928
+ mainMediaInteractionId
2929
+ );
2930
+ delete ownerChangeData.interaction.mainInteractionId;
2931
+
2932
+ webSocketManagerMock.emit('message', JSON.stringify({data: ownerChangeData}));
2933
+
2934
+ expect(task.sendStateMachineEvent).toHaveBeenCalledTimes(1);
2935
+ expectLastStateMachineEvent(task.sendStateMachineEvent, TaskEvent.CONTACT_OWNER_CHANGED);
2936
+ expect(TaskFactory.createTask).not.toHaveBeenCalled();
2937
+ expect(taskManager.taskCollection[mainMediaInteractionId]).toBe(task);
2938
+ expect(taskManager.taskCollection[promotedChildId]).toBeUndefined();
2939
+ expect(Object.values(taskManager.taskCollection)).toEqual([task]);
2940
+ expect(task.listenerCount(TASK_EVENTS.TASK_HYDRATE)).toBe(1);
2941
+ expect(hydrateHandler).toHaveBeenCalledTimes(1);
2942
+ expect(hydrateHandler).toHaveBeenCalledWith(task);
2943
+ });
2944
+
2945
+ it('prefers the exact task for ContactOwnerChanged over related child tasks', () => {
2946
+ const exactTask = installTask(createConferenceTaskData(taskId, previousOwnerId));
2947
+ const childTaskId = 'related-child-id';
2948
+ const childTask = createMockTask(
2949
+ createConferenceTaskData(childTaskId, previousOwnerId, taskId)
2950
+ );
2951
+ taskManager.taskCollection[childTaskId] = childTask;
2952
+
2953
+ webSocketManagerMock.emit(
2954
+ 'message',
2955
+ JSON.stringify({
2956
+ data: {
2957
+ ...createConferenceTaskData(taskId, promotedOwnerId),
2958
+ type: CC_EVENTS.CONTACT_OWNER_CHANGED,
2959
+ },
2960
+ })
2961
+ );
2962
+
2963
+ expectLastStateMachineEvent(exactTask.sendStateMachineEvent, TaskEvent.CONTACT_OWNER_CHANGED);
2964
+ expect(childTask.sendStateMachineEvent).not.toHaveBeenCalled();
2965
+ });
2966
+
2967
+ it('ignores a related ContactOwnerChanged task whose current agent is consult-only', () => {
2968
+ const childTaskId = 'consult-only-child-id';
2969
+ const data = createConferenceTaskData(childTaskId, previousOwnerId, taskId);
2970
+ data.interaction.media[taskId].participants = [previousOwnerId, promotedOwnerId];
2971
+ data.interaction.media[childTaskId] = {
2972
+ mediaResourceId: childTaskId,
2973
+ mediaType: 'telephony',
2974
+ mType: 'consult',
2975
+ participants: [currentAgentId, promotedOwnerId],
2976
+ };
2977
+ const task = installTask(data);
2978
+
2979
+ webSocketManagerMock.emit(
2980
+ 'message',
2981
+ JSON.stringify({
2982
+ data: {
2983
+ ...createConferenceTaskData('unmatched-child-id', promotedOwnerId, taskId),
2984
+ type: CC_EVENTS.CONTACT_OWNER_CHANGED,
2985
+ },
2986
+ })
2987
+ );
2988
+
2989
+ expect(task.sendStateMachineEvent).not.toHaveBeenCalled();
2990
+ expect(taskManager.taskCollection[childTaskId]).toBe(task);
2991
+ expect(TaskFactory.createTask).not.toHaveBeenCalled();
2992
+ });
2993
+
2994
+ it.each([
2995
+ [
2996
+ 'the current agent is marked departed',
2997
+ (data) => {
2998
+ data.interaction.participants[currentAgentId].hasLeft = true;
2999
+ },
3000
+ ],
3001
+ [
3002
+ 'the current agent participant is missing',
3003
+ (data) => {
3004
+ delete data.interaction.participants[currentAgentId];
3005
+ },
3006
+ ],
3007
+ [
3008
+ 'the current agent is present only on a consult leg',
3009
+ (data) => {
3010
+ data.interaction.media[taskId].mType = 'consult';
3011
+ },
3012
+ ],
3013
+ ])('does not recover a promoted task when %s', (_description, mutatePayload) => {
3014
+ clearTasksAndFactoryHistory();
3015
+ const ownerChangeData = createPromotedAgentOwnerChangeData();
3016
+ mutatePayload(ownerChangeData);
3017
+
3018
+ webSocketManagerMock.emit('message', JSON.stringify({data: ownerChangeData}));
3019
+
3020
+ expect(TaskFactory.createTask).not.toHaveBeenCalled();
3021
+ expect(taskManager.getAllTasks()).toEqual({});
3022
+ });
3023
+
3024
+ it('ignores ambiguous active main-call matches for ContactOwnerChanged', () => {
3025
+ const firstTask = createMockTask(
3026
+ createConferenceTaskData('first-child-id', previousOwnerId, taskId)
3027
+ );
3028
+ const secondTask = createMockTask(
3029
+ createConferenceTaskData('second-child-id', previousOwnerId, taskId)
3030
+ );
3031
+ taskManager.taskCollection = {
3032
+ 'first-child-id': firstTask,
3033
+ 'second-child-id': secondTask,
3034
+ };
3035
+
3036
+ const ownerChangeData = createConferenceTaskData(
3037
+ 'unmatched-child-id',
3038
+ promotedOwnerId,
3039
+ taskId
3040
+ );
3041
+ ownerChangeData.interaction.interactionId = 'unmatched-child-id';
3042
+ webSocketManagerMock.emit(
3043
+ 'message',
3044
+ JSON.stringify({
3045
+ data: {...ownerChangeData, type: CC_EVENTS.CONTACT_OWNER_CHANGED},
3046
+ })
3047
+ );
3048
+
3049
+ expect(firstTask.sendStateMachineEvent).not.toHaveBeenCalled();
3050
+ expect(secondTask.sendStateMachineEvent).not.toHaveBeenCalled();
3051
+ });
3052
+
3053
+ it('does not recover a new task when multiple stale related tasks become eligible from the payload', () => {
3054
+ const createStaleRelatedTask = (interactionId: string) => {
3055
+ const data = createConferenceTaskData(interactionId, previousOwnerId, taskId);
3056
+ data.interaction.media[taskId].participants = [previousOwnerId, promotedOwnerId];
3057
+
3058
+ return createMockTask(data);
3059
+ };
3060
+ const firstTask = createStaleRelatedTask('first-stale-child-id');
3061
+ const secondTask = createStaleRelatedTask('second-stale-child-id');
3062
+ taskManager.taskCollection = {
3063
+ 'first-stale-child-id': firstTask,
3064
+ 'second-stale-child-id': secondTask,
3065
+ };
3066
+ (TaskFactory.createTask as jest.Mock).mockClear();
3067
+
3068
+ webSocketManagerMock.emit(
3069
+ 'message',
3070
+ JSON.stringify({data: createPromotedAgentOwnerChangeData()})
3071
+ );
3072
+
3073
+ expect(firstTask.sendStateMachineEvent).not.toHaveBeenCalled();
3074
+ expect(secondTask.sendStateMachineEvent).not.toHaveBeenCalled();
3075
+ expect(TaskFactory.createTask).not.toHaveBeenCalled();
3076
+ expect(Object.keys(taskManager.taskCollection)).toEqual([
3077
+ 'first-stale-child-id',
3078
+ 'second-stale-child-id',
3079
+ ]);
3080
+ });
3081
+
3082
+ it('ignores ContactOwnerChanged when different direct nested IDs match active tasks', () => {
3083
+ const firstTaskId = 'direct-main-id';
3084
+ const secondTaskId = 'direct-parent-id';
3085
+ const firstTask = createMockTask(
3086
+ createConferenceTaskData(firstTaskId, previousOwnerId, firstTaskId)
3087
+ );
3088
+ const secondTask = createMockTask(
3089
+ createConferenceTaskData(secondTaskId, previousOwnerId, secondTaskId)
3090
+ );
3091
+ taskManager.taskCollection = {
3092
+ [firstTaskId]: firstTask,
3093
+ [secondTaskId]: secondTask,
3094
+ };
3095
+
3096
+ const ownerChangeData = createConferenceTaskData(
3097
+ 'unmatched-child-id',
3098
+ promotedOwnerId,
3099
+ firstTaskId
3100
+ );
3101
+ ownerChangeData.interaction.interactionId = secondTaskId;
3102
+ webSocketManagerMock.emit(
3103
+ 'message',
3104
+ JSON.stringify({
3105
+ data: {...ownerChangeData, type: CC_EVENTS.CONTACT_OWNER_CHANGED},
3106
+ })
3107
+ );
3108
+
3109
+ expect(firstTask.sendStateMachineEvent).not.toHaveBeenCalled();
3110
+ expect(secondTask.sendStateMachineEvent).not.toHaveBeenCalled();
3111
+ });
3112
+
3113
+ it('ignores ContactOwnerChanged without an interaction correlation identifier', () => {
3114
+ const task = installTask(createConferenceTaskData(taskId, previousOwnerId));
3115
+
3116
+ webSocketManagerMock.emit(
3117
+ 'message',
3118
+ JSON.stringify({
3119
+ data: {
3120
+ type: CC_EVENTS.CONTACT_OWNER_CHANGED,
3121
+ interaction: {owner: promotedOwnerId},
3122
+ },
3123
+ })
3124
+ );
3125
+
3126
+ expect(task.sendStateMachineEvent).not.toHaveBeenCalled();
3127
+ });
3128
+
3129
+ it('recovers a missing promoted-agent task under the main interaction and emits one hydrate', () => {
3130
+ clearTasksAndFactoryHistory();
3131
+ const hydrateHandler = jest.fn();
3132
+ const incomingHandler = jest.fn();
3133
+ taskManager.on(TASK_EVENTS.TASK_HYDRATE, hydrateHandler);
3134
+ taskManager.on(TASK_EVENTS.TASK_INCOMING, incomingHandler);
3135
+
3136
+ webSocketManagerMock.emit(
3137
+ 'message',
3138
+ JSON.stringify({data: createPromotedAgentOwnerChangeData()})
3139
+ );
3140
+
3141
+ expect(TaskFactory.createTask).toHaveBeenCalledTimes(1);
3142
+ expect(TaskFactory.createTask).toHaveBeenCalledWith(
3143
+ contactMock,
3144
+ webCallingService,
3145
+ expect.objectContaining({
3146
+ interactionId: taskId,
3147
+ isAutoAnswering: false,
3148
+ isConsulted: false,
3149
+ isConferenceInProgress: true,
3150
+ wrapUpRequired: false,
3151
+ }),
3152
+ undefined,
3153
+ undefined,
3154
+ currentAgentId,
3155
+ undefined
3156
+ );
3157
+
3158
+ const recoveredTask = (TaskFactory.createTask as jest.Mock).mock.results[0].value;
3159
+ expect(recoveredTask.sendStateMachineEvent).toHaveBeenCalledTimes(2);
3160
+ expect(recoveredTask.sendStateMachineEvent).toHaveBeenNthCalledWith(
3161
+ 1,
3162
+ expect.objectContaining({
3163
+ type: TaskEvent.HYDRATE,
3164
+ agentId: currentAgentId,
3165
+ taskData: expect.objectContaining({interactionId: taskId}),
3166
+ })
3167
+ );
3168
+ expect(recoveredTask.sendStateMachineEvent).toHaveBeenNthCalledWith(
3169
+ 2,
3170
+ expect.objectContaining({
3171
+ type: TaskEvent.CONTACT_OWNER_CHANGED,
3172
+ taskData: expect.objectContaining({interactionId: taskId}),
3173
+ })
3174
+ );
3175
+ expect(taskManager.taskCollection[taskId]).toBe(recoveredTask);
3176
+ expect(taskManager.taskCollection['promoted-child-interaction-id']).toBeUndefined();
3177
+ expect(Object.values(taskManager.taskCollection)).toEqual([recoveredTask]);
3178
+ expect(recoveredTask.data.interaction.owner).toBe(currentAgentId);
3179
+ expect(hydrateHandler).toHaveBeenCalledTimes(1);
3180
+ expect(hydrateHandler).toHaveBeenCalledWith(recoveredTask);
3181
+ expect(incomingHandler).not.toHaveBeenCalled();
3182
+ });
3183
+
3184
+ it.each([
3185
+ [
3186
+ 'another agent owns the interaction',
3187
+ (data) => {
3188
+ data.interaction.owner = promotedOwnerId;
3189
+ data.owner = promotedOwnerId;
3190
+ },
3191
+ ],
3192
+ [
3193
+ 'the payload has no main-call media',
3194
+ (data) => {
3195
+ data.interaction.media = {};
3196
+ },
3197
+ ],
3198
+ [
3199
+ 'the interaction is explicitly terminated',
3200
+ (data) => {
3201
+ data.interaction.isTerminated = true;
3202
+ },
3203
+ ],
3204
+ ])('does not create a missing task when %s', (_description, mutatePayload) => {
3205
+ clearTasksAndFactoryHistory();
3206
+ const ownerChangeData = createPromotedAgentOwnerChangeData();
3207
+ mutatePayload(ownerChangeData);
3208
+
3209
+ webSocketManagerMock.emit('message', JSON.stringify({data: ownerChangeData}));
3210
+
3211
+ expect(TaskFactory.createTask).not.toHaveBeenCalled();
3212
+ expect(taskManager.getAllTasks()).toEqual({});
3213
+ });
3214
+
3215
+ it('does not create a missing task for an owner-changing ContactUpdated event', () => {
3216
+ clearTasksAndFactoryHistory();
3217
+ const contactUpdatedData = {
3218
+ ...createPromotedAgentOwnerChangeData(),
3219
+ type: CC_EVENTS.CONTACT_UPDATED,
3220
+ };
3221
+
3222
+ webSocketManagerMock.emit('message', JSON.stringify({data: contactUpdatedData}));
3223
+
3224
+ expect(TaskFactory.createTask).not.toHaveBeenCalled();
3225
+ expect(taskManager.getAllTasks()).toEqual({});
3226
+ });
3227
+
3228
+ it('reuses a recovered task for later AgentContact and ContactMerged events', () => {
3229
+ clearTasksAndFactoryHistory();
3230
+ const hydrateHandler = jest.fn();
3231
+ const mergedHandler = jest.fn();
3232
+ taskManager.on(TASK_EVENTS.TASK_HYDRATE, hydrateHandler);
3233
+ taskManager.on(TASK_EVENTS.TASK_MERGED, mergedHandler);
3234
+
3235
+ webSocketManagerMock.emit(
3236
+ 'message',
3237
+ JSON.stringify({data: createPromotedAgentOwnerChangeData()})
3238
+ );
3239
+
3240
+ const recoveredTask = taskManager.taskCollection[taskId];
3241
+ expect(recoveredTask).toBeDefined();
3242
+ expect(recoveredTask.listenerCount(TASK_EVENTS.TASK_HYDRATE)).toBe(1);
3243
+
3244
+ webSocketManagerMock.emit(
3245
+ 'message',
3246
+ JSON.stringify({
3247
+ data: {
3248
+ ...createConferenceTaskData(taskId, currentAgentId),
3249
+ type: CC_EVENTS.AGENT_CONTACT,
3250
+ },
3251
+ })
3252
+ );
3253
+ webSocketManagerMock.emit(
3254
+ 'message',
3255
+ JSON.stringify({
3256
+ data: {
3257
+ ...createConferenceTaskData(taskId, currentAgentId),
3258
+ type: CC_EVENTS.CONTACT_MERGED,
3259
+ childInteractionId: 'promoted-child-interaction-id',
3260
+ },
3261
+ })
3262
+ );
3263
+
3264
+ expect(TaskFactory.createTask).toHaveBeenCalledTimes(1);
3265
+ expect(taskManager.taskCollection[taskId]).toBe(recoveredTask);
3266
+ expect(taskManager.taskCollection['promoted-child-interaction-id']).toBeUndefined();
3267
+ expect(Object.values(taskManager.taskCollection)).toEqual([recoveredTask]);
3268
+ expect(recoveredTask.listenerCount(TASK_EVENTS.TASK_HYDRATE)).toBe(1);
3269
+ expect(hydrateHandler).toHaveBeenCalledTimes(2);
3270
+ expect(mergedHandler).toHaveBeenCalledTimes(1);
3271
+ expect(mergedHandler).toHaveBeenCalledWith(recoveredTask);
3272
+ });
3273
+
3274
+ it('does not let a late ParticipantLeftConference restore a departed owner', () => {
3275
+ const task = installTask(createConferenceTaskData(taskId, previousOwnerId));
3276
+
3277
+ webSocketManagerMock.emit(
3278
+ 'message',
3279
+ JSON.stringify({
3280
+ data: {
3281
+ ...createConferenceTaskData(taskId, promotedOwnerId),
3282
+ type: CC_EVENTS.CONTACT_UPDATED,
3283
+ },
3284
+ })
3285
+ );
3286
+
3287
+ const participantLeftData = createConferenceTaskData(taskId, previousOwnerId);
3288
+ participantLeftData.participantId = previousOwnerId;
3289
+ participantLeftData.interaction.participants[previousOwnerId].hasLeft = true;
3290
+ participantLeftData.interaction.media[taskId].participants = [
3291
+ currentAgentId,
3292
+ promotedOwnerId,
3293
+ ];
3294
+ webSocketManagerMock.emit(
3295
+ 'message',
3296
+ JSON.stringify({
3297
+ data: {...participantLeftData, type: CC_EVENTS.PARTICIPANT_LEFT_CONFERENCE},
3298
+ })
3299
+ );
3300
+
3301
+ const participantLeaveEvent = expectLastStateMachineEvent(
3302
+ task.sendStateMachineEvent,
3303
+ TaskEvent.PARTICIPANT_LEAVE
3304
+ );
3305
+ expect(participantLeaveEvent.taskData.interaction.owner).toBe(promotedOwnerId);
3306
+ expect(task.data.interaction.owner).toBe(promotedOwnerId);
3307
+ });
3308
+
3309
+ it('does not infer that an incoming owner departed from a partial main-call roster', () => {
3310
+ const task = installTask(createConferenceTaskData(taskId, promotedOwnerId));
3311
+ const partialParticipantLeftData = createConferenceTaskData(taskId, previousOwnerId);
3312
+ partialParticipantLeftData.participantId = 'another-agent-id';
3313
+ partialParticipantLeftData.interaction.media[taskId].participants = [
3314
+ currentAgentId,
3315
+ promotedOwnerId,
3316
+ ];
3317
+
3318
+ webSocketManagerMock.emit(
3319
+ 'message',
3320
+ JSON.stringify({
3321
+ data: {
3322
+ ...partialParticipantLeftData,
3323
+ type: CC_EVENTS.PARTICIPANT_LEFT_CONFERENCE,
3324
+ },
3325
+ })
3326
+ );
3327
+
3328
+ const participantLeaveEvent = expectLastStateMachineEvent(
3329
+ task.sendStateMachineEvent,
3330
+ TaskEvent.PARTICIPANT_LEAVE
3331
+ );
3332
+ expect(participantLeaveEvent.taskData.interaction.owner).toBe(previousOwnerId);
3333
+ expect(task.data.interaction.owner).toBe(previousOwnerId);
3334
+ });
3335
+
3336
+ it('applies the promoted owner when ParticipantLeftConference arrives first', () => {
3337
+ const task = installTask(createConferenceTaskData(taskId, previousOwnerId));
3338
+ const participantLeftData = createConferenceTaskData(taskId, previousOwnerId);
3339
+ participantLeftData.participantId = previousOwnerId;
3340
+ participantLeftData.interaction.participants[previousOwnerId].hasLeft = true;
3341
+ participantLeftData.interaction.media[taskId].participants = [
3342
+ currentAgentId,
3343
+ promotedOwnerId,
3344
+ ];
3345
+
3346
+ webSocketManagerMock.emit(
3347
+ 'message',
3348
+ JSON.stringify({
3349
+ data: {...participantLeftData, type: CC_EVENTS.PARTICIPANT_LEFT_CONFERENCE},
3350
+ })
3351
+ );
3352
+ webSocketManagerMock.emit(
3353
+ 'message',
3354
+ JSON.stringify({
3355
+ data: {
3356
+ ...createConferenceTaskData(taskId, promotedOwnerId),
3357
+ type: CC_EVENTS.CONTACT_UPDATED,
3358
+ },
3359
+ })
3360
+ );
3361
+
3362
+ expect(task.data.interaction.owner).toBe(promotedOwnerId);
3363
+ expectLastStateMachineEvent(task.sendStateMachineEvent, TaskEvent.CONTACT_OWNER_CHANGED);
3364
+ });
3365
+ });
3366
+
2362
3367
  describe('Conference event handling', () => {
2363
3368
  let task;
2364
3369
 
@@ -2412,6 +3417,254 @@ describe('TaskManager', () => {
2412
3417
  expect(call.type).toBe(TaskEvent.PARTICIPANT_LEAVE);
2413
3418
  });
2414
3419
 
3420
+ it('routes participant-left from a main interaction to a child-keyed task', () => {
3421
+ const childTaskId = 'child-interaction-id';
3422
+ const childTask = createMockTask({
3423
+ ...taskDataMock,
3424
+ interactionId: childTaskId,
3425
+ isConsulted: true,
3426
+ interaction: {
3427
+ mediaType: 'telephony',
3428
+ interactionId: childTaskId,
3429
+ parentInteractionId: taskId,
3430
+ },
3431
+ });
3432
+ delete taskManager.taskCollection[taskId];
3433
+ taskManager.taskCollection[childTaskId] = childTask;
3434
+
3435
+ webSocketManagerMock.emit(
3436
+ 'message',
3437
+ JSON.stringify({
3438
+ data: {
3439
+ type: CC_EVENTS.PARTICIPANT_LEFT_CONFERENCE,
3440
+ interactionId: taskId,
3441
+ participantId: 'test-agent-id',
3442
+ interaction: {
3443
+ mediaType: 'telephony',
3444
+ interactionId: taskId,
3445
+ mainInteractionId: taskId,
3446
+ },
3447
+ },
3448
+ })
3449
+ );
3450
+
3451
+ expect(childTask.sendStateMachineEvent).toHaveBeenCalledWith(
3452
+ expect.objectContaining({
3453
+ type: TaskEvent.PARTICIPANT_LEAVE,
3454
+ participantId: 'test-agent-id',
3455
+ })
3456
+ );
3457
+ expect(taskManager.taskCollection[childTaskId]).toBeUndefined();
3458
+ expect(taskManager.taskCollection[taskId]).toBe(childTask);
3459
+ expect(
3460
+ Object.values(taskManager.taskCollection).filter((entry) => entry === childTask)
3461
+ ).toHaveLength(1);
3462
+ });
3463
+
3464
+ it('routes consult-end from a main interaction to a child-keyed consulted task', () => {
3465
+ const childTaskId = 'consult-child-interaction-id';
3466
+ const childTask = createMockTask({
3467
+ ...taskDataMock,
3468
+ interactionId: childTaskId,
3469
+ isConsulted: true,
3470
+ interaction: {
3471
+ mediaType: 'telephony',
3472
+ interactionId: childTaskId,
3473
+ callProcessingDetails: {parentInteractionId: taskId},
3474
+ },
3475
+ });
3476
+ delete taskManager.taskCollection[taskId];
3477
+ taskManager.taskCollection[childTaskId] = childTask;
3478
+
3479
+ webSocketManagerMock.emit(
3480
+ 'message',
3481
+ JSON.stringify({
3482
+ data: {
3483
+ type: CC_EVENTS.AGENT_CONSULT_ENDED,
3484
+ interactionId: taskId,
3485
+ interaction: {
3486
+ mediaType: 'telephony',
3487
+ interactionId: taskId,
3488
+ mainInteractionId: taskId,
3489
+ },
3490
+ },
3491
+ })
3492
+ );
3493
+
3494
+ expect(childTask.sendStateMachineEvent).toHaveBeenCalledWith(
3495
+ expect.objectContaining({type: TaskEvent.CONSULT_END})
3496
+ );
3497
+ });
3498
+
3499
+ it('deduplicates aliases of the same child task during related-interaction lookup', () => {
3500
+ const childTaskId = 'aliased-child-interaction-id';
3501
+ const childTask = createMockTask({
3502
+ ...taskDataMock,
3503
+ interactionId: childTaskId,
3504
+ interaction: {
3505
+ mediaType: 'telephony',
3506
+ interactionId: childTaskId,
3507
+ mainInteractionId: taskId,
3508
+ },
3509
+ });
3510
+ delete taskManager.taskCollection[taskId];
3511
+ taskManager.taskCollection[childTaskId] = childTask;
3512
+ taskManager.taskCollection['child-task-alias'] = childTask;
3513
+
3514
+ webSocketManagerMock.emit(
3515
+ 'message',
3516
+ JSON.stringify({
3517
+ data: {
3518
+ type: CC_EVENTS.PARTICIPANT_LEFT_CONFERENCE,
3519
+ interactionId: taskId,
3520
+ participantId: 'test-agent-id',
3521
+ },
3522
+ })
3523
+ );
3524
+
3525
+ expect(childTask.sendStateMachineEvent).toHaveBeenCalledTimes(1);
3526
+ expect(
3527
+ Object.values(taskManager.taskCollection).filter((entry) => entry === childTask)
3528
+ ).toHaveLength(1);
3529
+ });
3530
+
3531
+ it('prefers an exact task match over a child task with the same main interaction', () => {
3532
+ const childTaskId = 'child-interaction-id';
3533
+ const childTask = createMockTask({
3534
+ ...taskDataMock,
3535
+ interactionId: childTaskId,
3536
+ interaction: {
3537
+ mediaType: 'telephony',
3538
+ interactionId: childTaskId,
3539
+ mainInteractionId: taskId,
3540
+ },
3541
+ });
3542
+ taskManager.taskCollection[childTaskId] = childTask;
3543
+
3544
+ webSocketManagerMock.emit(
3545
+ 'message',
3546
+ JSON.stringify({
3547
+ data: {
3548
+ type: CC_EVENTS.PARTICIPANT_LEFT_CONFERENCE,
3549
+ interactionId: taskId,
3550
+ participantId: 'another-agent',
3551
+ },
3552
+ })
3553
+ );
3554
+
3555
+ expect(task.sendStateMachineEvent).toHaveBeenCalled();
3556
+ expect(childTask.sendStateMachineEvent).not.toHaveBeenCalled();
3557
+ });
3558
+
3559
+ it('does not route a main-interaction event when multiple unique child tasks match', () => {
3560
+ delete taskManager.taskCollection[taskId];
3561
+ const createChildTask = (interactionId: string) =>
3562
+ createMockTask({
3563
+ ...taskDataMock,
3564
+ interactionId,
3565
+ interaction: {
3566
+ mediaType: 'telephony',
3567
+ interactionId,
3568
+ mainInteractionId: taskId,
3569
+ },
3570
+ });
3571
+ const firstChildTask = createChildTask('child-1');
3572
+ const secondChildTask = createChildTask('child-2');
3573
+ taskManager.taskCollection['child-1'] = firstChildTask;
3574
+ taskManager.taskCollection['child-2'] = secondChildTask;
3575
+
3576
+ webSocketManagerMock.emit(
3577
+ 'message',
3578
+ JSON.stringify({
3579
+ data: {
3580
+ type: CC_EVENTS.PARTICIPANT_LEFT_CONFERENCE,
3581
+ interactionId: taskId,
3582
+ },
3583
+ })
3584
+ );
3585
+
3586
+ expect(firstChildTask.sendStateMachineEvent).not.toHaveBeenCalled();
3587
+ expect(secondChildTask.sendStateMachineEvent).not.toHaveBeenCalled();
3588
+ });
3589
+
3590
+ it('does not route a lifecycle event without a correlation identifier', () => {
3591
+ webSocketManagerMock.emit(
3592
+ 'message',
3593
+ JSON.stringify({
3594
+ data: {
3595
+ type: CC_EVENTS.PARTICIPANT_LEFT_CONFERENCE,
3596
+ participantId: 'another-agent',
3597
+ },
3598
+ })
3599
+ );
3600
+
3601
+ expect(task.sendStateMachineEvent).not.toHaveBeenCalled();
3602
+ });
3603
+
3604
+ it('removes every collection alias for a task during terminal cleanup', () => {
3605
+ taskManager.taskCollection['task-alias'] = task;
3606
+
3607
+ (taskManager as any).removeTaskFromCollection(task);
3608
+
3609
+ expect(taskManager.taskCollection[taskId]).toBeUndefined();
3610
+ expect(taskManager.taskCollection['task-alias']).toBeUndefined();
3611
+ });
3612
+
3613
+ it('replaces an EP-DN child task with a hydrated main task on CONTACT_MERGED', () => {
3614
+ const childTaskId = 'ep-dn-child-interaction-id';
3615
+ const childTask = createMockTask({
3616
+ ...taskDataMock,
3617
+ interactionId: childTaskId,
3618
+ interaction: {
3619
+ mediaType: 'telephony',
3620
+ interactionId: childTaskId,
3621
+ parentInteractionId: taskId,
3622
+ },
3623
+ });
3624
+ const mergedHandler = jest.fn();
3625
+ delete taskManager.taskCollection[taskId];
3626
+ taskManager.taskCollection[childTaskId] = childTask;
3627
+ taskManager.on(TASK_EVENTS.TASK_MERGED, mergedHandler);
3628
+
3629
+ webSocketManagerMock.emit(
3630
+ 'message',
3631
+ JSON.stringify({
3632
+ data: {
3633
+ type: CC_EVENTS.CONTACT_MERGED,
3634
+ interactionId: taskId,
3635
+ childInteractionId: childTaskId,
3636
+ interaction: {
3637
+ mediaType: 'telephony',
3638
+ interactionId: taskId,
3639
+ mainInteractionId: taskId,
3640
+ state: 'conference',
3641
+ owner: 'test-agent-id',
3642
+ participants: {
3643
+ 'test-agent-id': {id: 'test-agent-id', pType: 'Agent', hasLeft: false},
3644
+ },
3645
+ media: {
3646
+ [taskId]: {
3647
+ mediaResourceId: taskId,
3648
+ mediaType: 'telephony',
3649
+ mType: 'mainCall',
3650
+ participants: ['test-agent-id'],
3651
+ },
3652
+ },
3653
+ },
3654
+ },
3655
+ })
3656
+ );
3657
+
3658
+ const mainTask = taskManager.taskCollection[taskId];
3659
+ expect(taskManager.taskCollection[childTaskId]).toBeUndefined();
3660
+ expect(mainTask).toBeDefined();
3661
+ expect(mainTask).not.toBe(childTask);
3662
+ expect(mainTask.sendStateMachineEvent).toHaveBeenCalledWith(
3663
+ expect.objectContaining({type: TaskEvent.HYDRATE})
3664
+ );
3665
+ expect(mergedHandler).toHaveBeenCalledWith(mainTask);
3666
+ });
3667
+
2415
3668
  it('handles AGENT_CONSULT_CONFERENCING event without errors', () => {
2416
3669
  const payload = {
2417
3670
  data: {type: CC_EVENTS.AGENT_CONSULT_CONFERENCING, interactionId: taskId},
@@ -2478,4 +3731,44 @@ describe('TaskManager', () => {
2478
3731
  sendStateMachineEventSpy.mockRestore();
2479
3732
  });
2480
3733
  });
3734
+
3735
+ describe('applyEnableWxBetterTogether', () => {
3736
+ it('updates config flags and propagates to active tasks', () => {
3737
+ const taskOne = {setEnableWxBetterTogether: jest.fn()};
3738
+ const taskTwo = {setEnableWxBetterTogether: jest.fn()};
3739
+
3740
+ taskManager.setConfigFlags({
3741
+ isEndTaskEnabled: true,
3742
+ isEndConsultEnabled: true,
3743
+ enableWxBetterTogether: true,
3744
+ });
3745
+ taskManager['taskCollection'] = {
3746
+ [taskId]: taskOne,
3747
+ 'task-2': taskTwo,
3748
+ };
3749
+
3750
+ taskManager.applyEnableWxBetterTogether(false);
3751
+
3752
+ expect(taskManager['configFlags']?.enableWxBetterTogether).toBe(false);
3753
+ expect(taskOne.setEnableWxBetterTogether).toHaveBeenCalledWith(false);
3754
+ expect(taskTwo.setEnableWxBetterTogether).toHaveBeenCalledWith(false);
3755
+ });
3756
+ });
3757
+
3758
+ describe('applyWxAppMuteStateFromSync', () => {
3759
+ it('propagates mute state to all tasks', () => {
3760
+ const taskOne = {applyWxAppMuteStateFromSync: jest.fn()};
3761
+ const taskTwo = {applyWxAppMuteStateFromSync: jest.fn()};
3762
+
3763
+ taskManager['taskCollection'] = {
3764
+ [taskId]: taskOne,
3765
+ 'task-2': taskTwo,
3766
+ };
3767
+
3768
+ taskManager.applyWxAppMuteStateFromSync('call-1', true);
3769
+
3770
+ expect(taskOne.applyWxAppMuteStateFromSync).toHaveBeenCalledWith('call-1', true);
3771
+ expect(taskTwo.applyWxAppMuteStateFromSync).toHaveBeenCalledWith('call-1', true);
3772
+ });
3773
+ });
2481
3774
  });