@webex/contact-center 3.12.0-llmrefactor.1 → 3.12.0-llmrefactor.11

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 (198) hide show
  1. package/.sdd/manifest.json +7 -5
  2. package/ai-docs/ARCHITECTURE.md +1 -1
  3. package/ai-docs/CONTRACTS.md +8 -3
  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 +544 -12
  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/MetricsManager.js +16 -0
  15. package/dist/metrics/MetricsManager.js.map +1 -1
  16. package/dist/metrics/behavioral-events.js +104 -0
  17. package/dist/metrics/behavioral-events.js.map +1 -1
  18. package/dist/metrics/constants.js +18 -0
  19. package/dist/metrics/constants.js.map +1 -1
  20. package/dist/services/AddressBook.js +18 -15
  21. package/dist/services/AddressBook.js.map +1 -1
  22. package/dist/services/AnswerCallOnWebexService.js +202 -0
  23. package/dist/services/AnswerCallOnWebexService.js.map +1 -0
  24. package/dist/services/EntryPoint.js +46 -68
  25. package/dist/services/EntryPoint.js.map +1 -1
  26. package/dist/services/Queue.js +27 -22
  27. package/dist/services/Queue.js.map +1 -1
  28. package/dist/services/WebexCrossClientService.js +222 -0
  29. package/dist/services/WebexCrossClientService.js.map +1 -0
  30. package/dist/services/WxAppTelephonyMercurySync.js +108 -0
  31. package/dist/services/WxAppTelephonyMercurySync.js.map +1 -0
  32. package/dist/services/config/Util.js +3 -0
  33. package/dist/services/config/Util.js.map +1 -1
  34. package/dist/services/config/constants.js +10 -6
  35. package/dist/services/config/constants.js.map +1 -1
  36. package/dist/services/config/types.js +4 -0
  37. package/dist/services/config/types.js.map +1 -1
  38. package/dist/services/core/Err.js.map +1 -1
  39. package/dist/services/core/WebexRequest.js +6 -2
  40. package/dist/services/core/WebexRequest.js.map +1 -1
  41. package/dist/services/core/aqm-reqs.js +28 -14
  42. package/dist/services/core/aqm-reqs.js.map +1 -1
  43. package/dist/services/core/types.js.map +1 -1
  44. package/dist/services/task/Task.js +73 -7
  45. package/dist/services/task/Task.js.map +1 -1
  46. package/dist/services/task/TaskFactory.js +8 -4
  47. package/dist/services/task/TaskFactory.js.map +1 -1
  48. package/dist/services/task/TaskManager.js +490 -21
  49. package/dist/services/task/TaskManager.js.map +1 -1
  50. package/dist/services/task/WebexCallingUtils.js +70 -0
  51. package/dist/services/task/WebexCallingUtils.js.map +1 -0
  52. package/dist/services/task/constants.js +4 -1
  53. package/dist/services/task/constants.js.map +1 -1
  54. package/dist/services/task/contact.js +29 -0
  55. package/dist/services/task/contact.js.map +1 -1
  56. package/dist/services/task/digital/Digital.js +3 -2
  57. package/dist/services/task/digital/Digital.js.map +1 -1
  58. package/dist/services/task/state-machine/TaskStateMachine.js +162 -53
  59. package/dist/services/task/state-machine/TaskStateMachine.js.map +1 -1
  60. package/dist/services/task/state-machine/actions.js +6 -1
  61. package/dist/services/task/state-machine/actions.js.map +1 -1
  62. package/dist/services/task/state-machine/guards.js +7 -10
  63. package/dist/services/task/state-machine/guards.js.map +1 -1
  64. package/dist/services/task/state-machine/types.js.map +1 -1
  65. package/dist/services/task/state-machine/uiControlsComputer.js +118 -18
  66. package/dist/services/task/state-machine/uiControlsComputer.js.map +1 -1
  67. package/dist/services/task/types.js +16 -1
  68. package/dist/services/task/types.js.map +1 -1
  69. package/dist/services/task/voice/Voice.js +456 -15
  70. package/dist/services/task/voice/Voice.js.map +1 -1
  71. package/dist/services/task/voice/WebRTC.js +4 -1
  72. package/dist/services/task/voice/WebRTC.js.map +1 -1
  73. package/dist/services/task/voice/wxAppVoiceMethods.js +354 -0
  74. package/dist/services/task/voice/wxAppVoiceMethods.js.map +1 -0
  75. package/dist/services/wxAppDiagnosticLogging.js +122 -0
  76. package/dist/services/wxAppDiagnosticLogging.js.map +1 -0
  77. package/dist/services/wxAppTelephonyUtils.js +19 -0
  78. package/dist/services/wxAppTelephonyUtils.js.map +1 -0
  79. package/dist/types/cc.d.ts +78 -4
  80. package/dist/types/config.d.ts +7 -0
  81. package/dist/types/constants.d.ts +8 -0
  82. package/dist/types/index.d.ts +3 -1
  83. package/dist/types/metrics/MetricsManager.d.ts +6 -0
  84. package/dist/types/metrics/constants.d.ts +17 -0
  85. package/dist/types/services/AddressBook.d.ts +2 -1
  86. package/dist/types/services/AnswerCallOnWebexService.d.ts +38 -0
  87. package/dist/types/services/EntryPoint.d.ts +7 -5
  88. package/dist/types/services/Queue.d.ts +5 -3
  89. package/dist/types/services/WebexCrossClientService.d.ts +32 -0
  90. package/dist/types/services/WxAppTelephonyMercurySync.d.ts +28 -0
  91. package/dist/types/services/config/constants.d.ts +8 -5
  92. package/dist/types/services/config/types.d.ts +17 -4
  93. package/dist/types/services/core/Err.d.ts +2 -0
  94. package/dist/types/services/core/WebexRequest.d.ts +1 -0
  95. package/dist/types/services/core/types.d.ts +2 -0
  96. package/dist/types/services/task/Task.d.ts +20 -2
  97. package/dist/types/services/task/TaskFactory.d.ts +2 -1
  98. package/dist/types/services/task/WebexCallingUtils.d.ts +11 -0
  99. package/dist/types/services/task/constants.d.ts +3 -0
  100. package/dist/types/services/task/contact.d.ts +4 -0
  101. package/dist/types/services/task/digital/Digital.d.ts +2 -2
  102. package/dist/types/services/task/state-machine/TaskStateMachine.d.ts +304 -56
  103. package/dist/types/services/task/state-machine/guards.d.ts +6 -5
  104. package/dist/types/services/task/state-machine/types.d.ts +9 -1
  105. package/dist/types/services/task/types.d.ts +81 -4
  106. package/dist/types/services/task/voice/Voice.d.ts +52 -9
  107. package/dist/types/services/task/voice/WebRTC.d.ts +2 -2
  108. package/dist/types/services/task/voice/wxAppVoiceMethods.d.ts +55 -0
  109. package/dist/types/services/wxAppDiagnosticLogging.d.ts +51 -0
  110. package/dist/types/services/wxAppTelephonyUtils.d.ts +5 -0
  111. package/dist/types/types.d.ts +14 -8
  112. package/dist/types/utils/PageCache.d.ts +20 -3
  113. package/dist/types.js +7 -2
  114. package/dist/types.js.map +1 -1
  115. package/dist/utils/PageCache.js +19 -5
  116. package/dist/utils/PageCache.js.map +1 -1
  117. package/dist/webex.js +1 -1
  118. package/package.json +8 -8
  119. package/src/cc.ts +657 -12
  120. package/src/config.ts +7 -0
  121. package/src/constants.ts +8 -0
  122. package/src/index.ts +2 -0
  123. package/src/metrics/MetricsManager.ts +16 -0
  124. package/src/metrics/ai-docs/metrics-spec.md +78 -10
  125. package/src/metrics/behavioral-events.ts +106 -0
  126. package/src/metrics/constants.ts +20 -0
  127. package/src/services/AddressBook.ts +17 -6
  128. package/src/services/AnswerCallOnWebexService.ts +239 -0
  129. package/src/services/EntryPoint.ts +59 -60
  130. package/src/services/Queue.ts +29 -12
  131. package/src/services/WebexCrossClientService.ts +293 -0
  132. package/src/services/WxAppTelephonyMercurySync.ts +142 -0
  133. package/src/services/agent/ai-docs/agent-spec.md +1 -0
  134. package/src/services/ai-docs/AGENTS.md +10 -10
  135. package/src/services/ai-docs/services-spec.md +6 -1
  136. package/src/services/config/Util.ts +3 -0
  137. package/src/services/config/ai-docs/AGENTS.md +1 -1
  138. package/src/services/config/ai-docs/ARCHITECTURE.md +2 -2
  139. package/src/services/config/ai-docs/config-spec.md +6 -0
  140. package/src/services/config/constants.ts +10 -6
  141. package/src/services/config/types.ts +16 -4
  142. package/src/services/core/Err.ts +1 -0
  143. package/src/services/core/WebexRequest.ts +3 -1
  144. package/src/services/core/ai-docs/core-spec.md +5 -1
  145. package/src/services/core/aqm-reqs.ts +30 -15
  146. package/src/services/core/types.ts +2 -0
  147. package/src/services/task/Task.ts +79 -8
  148. package/src/services/task/TaskFactory.ts +8 -3
  149. package/src/services/task/TaskManager.ts +716 -17
  150. package/src/services/task/WebexCallingUtils.ts +136 -0
  151. package/src/services/task/ai-docs/ARCHITECTURE.md +1 -0
  152. package/src/services/task/ai-docs/task-spec.md +156 -3
  153. package/src/services/task/constants.ts +3 -0
  154. package/src/services/task/contact.ts +30 -0
  155. package/src/services/task/digital/Digital.ts +4 -2
  156. package/src/services/task/state-machine/TaskStateMachine.ts +235 -81
  157. package/src/services/task/state-machine/actions.ts +5 -2
  158. package/src/services/task/state-machine/ai-docs/AGENTS.md +6 -4
  159. package/src/services/task/state-machine/ai-docs/ARCHITECTURE.md +35 -16
  160. package/src/services/task/state-machine/ai-docs/task-state-machine-spec.md +103 -33
  161. package/src/services/task/state-machine/guards.ts +7 -10
  162. package/src/services/task/state-machine/types.ts +16 -1
  163. package/src/services/task/state-machine/uiControlsComputer.ts +206 -32
  164. package/src/services/task/types.ts +114 -4
  165. package/src/services/task/voice/Voice.ts +595 -14
  166. package/src/services/task/voice/WebRTC.ts +5 -1
  167. package/src/services/task/voice/wxAppVoiceMethods.ts +516 -0
  168. package/src/services/wxAppDiagnosticLogging.ts +174 -0
  169. package/src/services/wxAppTelephonyUtils.ts +14 -0
  170. package/src/types.ts +33 -9
  171. package/src/utils/AGENTS.md +21 -10
  172. package/src/utils/PageCache.ts +38 -5
  173. package/src/utils/ai-docs/utils-spec.md +21 -11
  174. package/test/unit/spec/cc.ts +1522 -19
  175. package/test/unit/spec/metrics/MetricsManager.ts +25 -0
  176. package/test/unit/spec/metrics/behavioral-events.ts +60 -0
  177. package/test/unit/spec/services/AddressBook.ts +37 -6
  178. package/test/unit/spec/services/AnswerCallOnWebexService.ts +264 -0
  179. package/test/unit/spec/services/EntryPoint.ts +87 -40
  180. package/test/unit/spec/services/Queue.ts +123 -12
  181. package/test/unit/spec/services/WebexCrossClientService.ts +467 -0
  182. package/test/unit/spec/services/WxAppTelephonyMercurySync.ts +136 -0
  183. package/test/unit/spec/services/config/Util.ts +85 -0
  184. package/test/unit/spec/services/core/WebexRequest.ts +3 -1
  185. package/test/unit/spec/services/core/aqm-reqs.ts +113 -1
  186. package/test/unit/spec/services/task/Task.ts +274 -6
  187. package/test/unit/spec/services/task/TaskFactory.ts +39 -2
  188. package/test/unit/spec/services/task/TaskManager.ts +1717 -9
  189. package/test/unit/spec/services/task/WebexCallingUtils.ts +153 -0
  190. package/test/unit/spec/services/task/contact.ts +33 -0
  191. package/test/unit/spec/services/task/state-machine/TaskStateMachine.ts +1485 -109
  192. package/test/unit/spec/services/task/state-machine/guards.ts +210 -8
  193. package/test/unit/spec/services/task/state-machine/uiControlsComputer.ts +445 -7
  194. package/test/unit/spec/services/task/voice/Voice.ts +1412 -0
  195. package/test/unit/spec/services/task/voice/wxAppVoiceMethods.ts +603 -0
  196. package/test/unit/spec/services/wxAppDiagnosticLogging.ts +143 -0
  197. package/umd/contact-center.min.js +2 -2
  198. package/umd/contact-center.min.js.map +1 -1
@@ -7,7 +7,7 @@ import TaskManager from '../../../../../src/services/task/TaskManager';
7
7
  import * as contact from '../../../../../src/services/task/contact';
8
8
  import Task from '../../../../../src/services/task/Task';
9
9
  import {TASK_EVENTS} from '../../../../../src/services/task/types';
10
- import {TaskEvent} from '../../../../../src/services/task/state-machine';
10
+ import {TaskEvent, TaskState} from '../../../../../src/services/task/state-machine';
11
11
  import WebRTC from '../../../../../src/services/task/voice/WebRTC';
12
12
  import {Profile} from '../../../../../src/services/config/types';
13
13
  import WebCallingService from '../../../../../src/services/WebCallingService';
@@ -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: {
@@ -2252,6 +2617,22 @@ describe('TaskManager', () => {
2252
2617
  sendStateMachineEventSpy.mockRestore();
2253
2618
  });
2254
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);
2628
+ webSocketManagerMock.emit('message', JSON.stringify(payload));
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);
2634
+ });
2635
+
2255
2636
  it('should not attempt cleanup twice when AGENT_CONTACT_UNASSIGNED is followed by AGENT_WRAPUP', () => {
2256
2637
  webSocketManagerMock.emit('message', JSON.stringify(initalPayload));
2257
2638
  const task = taskManager.getTask(taskId);
@@ -2359,14 +2740,654 @@ describe('TaskManager', () => {
2359
2740
  });
2360
2741
  });
2361
2742
 
2362
- describe('Conference event handling', () => {
2363
- let task;
2743
+ describe('Contact owner propagation', () => {
2744
+ const currentAgentId = 'test-agent-id';
2745
+ const previousOwnerId = 'previous-owner-id';
2746
+ const promotedOwnerId = 'promoted-owner-id';
2364
2747
 
2365
- beforeEach(() => {
2366
- task = {
2367
- data: {interactionId: taskId},
2368
- emit: jest.fn(),
2369
- updateTaskData: jest.fn(),
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
+ },
2791
+ });
2792
+
2793
+ const installTask = (data) => {
2794
+ const task = createMockTask(data);
2795
+ taskManager.taskCollection = {[data.interactionId]: task};
2796
+ (taskManager as any).setupTaskListeners(task);
2797
+
2798
+ return task;
2799
+ };
2800
+
2801
+ const createPromotedAgentOwnerChangeData = (
2802
+ interactionId = 'promoted-child-interaction-id',
2803
+ mainInteractionId = taskId
2804
+ ) => {
2805
+ const data = createConferenceTaskData(interactionId, currentAgentId, mainInteractionId);
2806
+
2807
+ data.interaction.state = 'conference';
2808
+
2809
+ return {...data, type: CC_EVENTS.CONTACT_OWNER_CHANGED};
2810
+ };
2811
+
2812
+ const clearTasksAndFactoryHistory = () => {
2813
+ taskManager.taskCollection = {};
2814
+ (TaskFactory.createTask as jest.Mock).mockClear();
2815
+ };
2816
+
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);
2821
+
2822
+ webSocketManagerMock.emit(
2823
+ 'message',
2824
+ JSON.stringify({
2825
+ data: {
2826
+ ...createConferenceTaskData(taskId, promotedOwnerId),
2827
+ type: CC_EVENTS.CONTACT_UPDATED,
2828
+ },
2829
+ })
2830
+ );
2831
+
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);
2836
+ });
2837
+
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,
2860
+ },
2861
+ })
2862
+ );
2863
+
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
+ );
2889
+
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);
2900
+ });
2901
+
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],
2911
+ },
2912
+ };
2913
+ const task = installTask(staleTaskData);
2914
+ const hydrateHandler = jest.fn();
2915
+ taskManager.on(TASK_EVENTS.TASK_HYDRATE, hydrateHandler);
2916
+
2917
+ webSocketManagerMock.emit(
2918
+ 'message',
2919
+ JSON.stringify({data: createPromotedAgentOwnerChangeData()})
2920
+ );
2921
+
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
+ });
2929
+
2930
+ it('reuses a task stored under 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
+ )
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
+ });
2960
+
2961
+ it('prefers the exact task for ContactOwnerChanged over related child tasks', () => {
2962
+ const exactTask = installTask(createConferenceTaskData(taskId, previousOwnerId));
2963
+ const childTaskId = 'related-child-id';
2964
+ const childTask = createMockTask(
2965
+ createConferenceTaskData(childTaskId, previousOwnerId, taskId)
2966
+ );
2967
+ taskManager.taskCollection[childTaskId] = childTask;
2968
+
2969
+ webSocketManagerMock.emit(
2970
+ 'message',
2971
+ JSON.stringify({
2972
+ data: {
2973
+ ...createConferenceTaskData(taskId, promotedOwnerId),
2974
+ type: CC_EVENTS.CONTACT_OWNER_CHANGED,
2975
+ },
2976
+ })
2977
+ );
2978
+
2979
+ expectLastStateMachineEvent(exactTask.sendStateMachineEvent, TaskEvent.CONTACT_OWNER_CHANGED);
2980
+ expect(childTask.sendStateMachineEvent).not.toHaveBeenCalled();
2981
+ });
2982
+
2983
+ it('ignores a related ContactOwnerChanged task whose current agent is consult-only', () => {
2984
+ const childTaskId = 'consult-only-child-id';
2985
+ const data = createConferenceTaskData(childTaskId, previousOwnerId, taskId);
2986
+ data.interaction.media[taskId].participants = [previousOwnerId, promotedOwnerId];
2987
+ data.interaction.media[childTaskId] = {
2988
+ mediaResourceId: childTaskId,
2989
+ mediaType: 'telephony',
2990
+ mType: 'consult',
2991
+ participants: [currentAgentId, promotedOwnerId],
2992
+ };
2993
+ const task = installTask(data);
2994
+
2995
+ webSocketManagerMock.emit(
2996
+ 'message',
2997
+ JSON.stringify({
2998
+ data: {
2999
+ ...createConferenceTaskData('unmatched-child-id', promotedOwnerId, taskId),
3000
+ type: CC_EVENTS.CONTACT_OWNER_CHANGED,
3001
+ },
3002
+ })
3003
+ );
3004
+
3005
+ expect(task.sendStateMachineEvent).not.toHaveBeenCalled();
3006
+ expect(taskManager.taskCollection[childTaskId]).toBe(task);
3007
+ expect(TaskFactory.createTask).not.toHaveBeenCalled();
3008
+ });
3009
+
3010
+ it.each([
3011
+ [
3012
+ 'the current agent is marked departed',
3013
+ (data) => {
3014
+ data.interaction.participants[currentAgentId].hasLeft = true;
3015
+ },
3016
+ ],
3017
+ [
3018
+ 'the current agent participant is missing',
3019
+ (data) => {
3020
+ delete data.interaction.participants[currentAgentId];
3021
+ },
3022
+ ],
3023
+ [
3024
+ 'the current agent is present only on a consult leg',
3025
+ (data) => {
3026
+ data.interaction.media[taskId].mType = 'consult';
3027
+ },
3028
+ ],
3029
+ ])('does not recover a promoted task when %s', (_description, mutatePayload) => {
3030
+ clearTasksAndFactoryHistory();
3031
+ const ownerChangeData = createPromotedAgentOwnerChangeData();
3032
+ mutatePayload(ownerChangeData);
3033
+
3034
+ webSocketManagerMock.emit('message', JSON.stringify({data: ownerChangeData}));
3035
+
3036
+ expect(TaskFactory.createTask).not.toHaveBeenCalled();
3037
+ expect(taskManager.getAllTasks()).toEqual({});
3038
+ });
3039
+
3040
+ it('ignores ambiguous active main-call matches for ContactOwnerChanged', () => {
3041
+ const firstTask = createMockTask(
3042
+ createConferenceTaskData('first-child-id', previousOwnerId, taskId)
3043
+ );
3044
+ const secondTask = createMockTask(
3045
+ createConferenceTaskData('second-child-id', previousOwnerId, taskId)
3046
+ );
3047
+ taskManager.taskCollection = {
3048
+ 'first-child-id': firstTask,
3049
+ 'second-child-id': secondTask,
3050
+ };
3051
+
3052
+ const ownerChangeData = createConferenceTaskData(
3053
+ 'unmatched-child-id',
3054
+ promotedOwnerId,
3055
+ taskId
3056
+ );
3057
+ ownerChangeData.interaction.interactionId = 'unmatched-child-id';
3058
+ webSocketManagerMock.emit(
3059
+ 'message',
3060
+ JSON.stringify({
3061
+ data: {...ownerChangeData, type: CC_EVENTS.CONTACT_OWNER_CHANGED},
3062
+ })
3063
+ );
3064
+
3065
+ expect(firstTask.sendStateMachineEvent).not.toHaveBeenCalled();
3066
+ expect(secondTask.sendStateMachineEvent).not.toHaveBeenCalled();
3067
+ });
3068
+
3069
+ it('does not recover a new task when multiple stale related tasks become eligible from the payload', () => {
3070
+ const createStaleRelatedTask = (interactionId: string) => {
3071
+ const data = createConferenceTaskData(interactionId, previousOwnerId, taskId);
3072
+ data.interaction.media[taskId].participants = [previousOwnerId, promotedOwnerId];
3073
+
3074
+ return createMockTask(data);
3075
+ };
3076
+ const firstTask = createStaleRelatedTask('first-stale-child-id');
3077
+ const secondTask = createStaleRelatedTask('second-stale-child-id');
3078
+ taskManager.taskCollection = {
3079
+ 'first-stale-child-id': firstTask,
3080
+ 'second-stale-child-id': secondTask,
3081
+ };
3082
+ (TaskFactory.createTask as jest.Mock).mockClear();
3083
+
3084
+ webSocketManagerMock.emit(
3085
+ 'message',
3086
+ JSON.stringify({data: createPromotedAgentOwnerChangeData()})
3087
+ );
3088
+
3089
+ expect(firstTask.sendStateMachineEvent).not.toHaveBeenCalled();
3090
+ expect(secondTask.sendStateMachineEvent).not.toHaveBeenCalled();
3091
+ expect(TaskFactory.createTask).not.toHaveBeenCalled();
3092
+ expect(Object.keys(taskManager.taskCollection)).toEqual([
3093
+ 'first-stale-child-id',
3094
+ 'second-stale-child-id',
3095
+ ]);
3096
+ });
3097
+
3098
+ it('ignores ContactOwnerChanged when different direct nested IDs match active tasks', () => {
3099
+ const firstTaskId = 'direct-main-id';
3100
+ const secondTaskId = 'direct-parent-id';
3101
+ const firstTask = createMockTask(
3102
+ createConferenceTaskData(firstTaskId, previousOwnerId, firstTaskId)
3103
+ );
3104
+ const secondTask = createMockTask(
3105
+ createConferenceTaskData(secondTaskId, previousOwnerId, secondTaskId)
3106
+ );
3107
+ taskManager.taskCollection = {
3108
+ [firstTaskId]: firstTask,
3109
+ [secondTaskId]: secondTask,
3110
+ };
3111
+
3112
+ const ownerChangeData = createConferenceTaskData(
3113
+ 'unmatched-child-id',
3114
+ promotedOwnerId,
3115
+ firstTaskId
3116
+ );
3117
+ ownerChangeData.interaction.interactionId = secondTaskId;
3118
+ webSocketManagerMock.emit(
3119
+ 'message',
3120
+ JSON.stringify({
3121
+ data: {...ownerChangeData, type: CC_EVENTS.CONTACT_OWNER_CHANGED},
3122
+ })
3123
+ );
3124
+
3125
+ expect(firstTask.sendStateMachineEvent).not.toHaveBeenCalled();
3126
+ expect(secondTask.sendStateMachineEvent).not.toHaveBeenCalled();
3127
+ });
3128
+
3129
+ it('ignores ContactOwnerChanged without an interaction correlation identifier', () => {
3130
+ const task = installTask(createConferenceTaskData(taskId, previousOwnerId));
3131
+
3132
+ webSocketManagerMock.emit(
3133
+ 'message',
3134
+ JSON.stringify({
3135
+ data: {
3136
+ type: CC_EVENTS.CONTACT_OWNER_CHANGED,
3137
+ interaction: {owner: promotedOwnerId},
3138
+ },
3139
+ })
3140
+ );
3141
+
3142
+ expect(task.sendStateMachineEvent).not.toHaveBeenCalled();
3143
+ });
3144
+
3145
+ it('recovers a missing promoted-agent task under the main interaction and emits one hydrate', () => {
3146
+ clearTasksAndFactoryHistory();
3147
+ const hydrateHandler = jest.fn();
3148
+ const incomingHandler = jest.fn();
3149
+ taskManager.on(TASK_EVENTS.TASK_HYDRATE, hydrateHandler);
3150
+ taskManager.on(TASK_EVENTS.TASK_INCOMING, incomingHandler);
3151
+
3152
+ webSocketManagerMock.emit(
3153
+ 'message',
3154
+ JSON.stringify({data: createPromotedAgentOwnerChangeData()})
3155
+ );
3156
+
3157
+ expect(TaskFactory.createTask).toHaveBeenCalledTimes(1);
3158
+ expect(TaskFactory.createTask).toHaveBeenCalledWith(
3159
+ contactMock,
3160
+ webCallingService,
3161
+ expect.objectContaining({
3162
+ interactionId: taskId,
3163
+ isAutoAnswering: false,
3164
+ isConsulted: false,
3165
+ isConferenceInProgress: true,
3166
+ wrapUpRequired: false,
3167
+ }),
3168
+ undefined,
3169
+ undefined,
3170
+ currentAgentId,
3171
+ undefined
3172
+ );
3173
+
3174
+ const recoveredTask = (TaskFactory.createTask as jest.Mock).mock.results[0].value;
3175
+ expect(recoveredTask.sendStateMachineEvent).toHaveBeenCalledTimes(2);
3176
+ expect(recoveredTask.sendStateMachineEvent).toHaveBeenNthCalledWith(
3177
+ 1,
3178
+ expect.objectContaining({
3179
+ type: TaskEvent.HYDRATE,
3180
+ agentId: currentAgentId,
3181
+ taskData: expect.objectContaining({interactionId: taskId}),
3182
+ })
3183
+ );
3184
+ expect(recoveredTask.sendStateMachineEvent).toHaveBeenNthCalledWith(
3185
+ 2,
3186
+ expect.objectContaining({
3187
+ type: TaskEvent.CONTACT_OWNER_CHANGED,
3188
+ taskData: expect.objectContaining({interactionId: taskId}),
3189
+ })
3190
+ );
3191
+ expect(taskManager.taskCollection[taskId]).toBe(recoveredTask);
3192
+ expect(taskManager.taskCollection['promoted-child-interaction-id']).toBeUndefined();
3193
+ expect(Object.values(taskManager.taskCollection)).toEqual([recoveredTask]);
3194
+ expect(recoveredTask.data.interaction.owner).toBe(currentAgentId);
3195
+ expect(hydrateHandler).toHaveBeenCalledTimes(1);
3196
+ expect(hydrateHandler).toHaveBeenCalledWith(recoveredTask);
3197
+ expect(incomingHandler).not.toHaveBeenCalled();
3198
+ });
3199
+
3200
+ it.each([
3201
+ [
3202
+ 'another agent owns the interaction',
3203
+ (data) => {
3204
+ data.interaction.owner = promotedOwnerId;
3205
+ data.owner = promotedOwnerId;
3206
+ },
3207
+ ],
3208
+ [
3209
+ 'the payload has no main-call media',
3210
+ (data) => {
3211
+ data.interaction.media = {};
3212
+ },
3213
+ ],
3214
+ [
3215
+ 'the interaction is explicitly terminated',
3216
+ (data) => {
3217
+ data.interaction.isTerminated = true;
3218
+ },
3219
+ ],
3220
+ ])('does not create a missing task when %s', (_description, mutatePayload) => {
3221
+ clearTasksAndFactoryHistory();
3222
+ const ownerChangeData = createPromotedAgentOwnerChangeData();
3223
+ mutatePayload(ownerChangeData);
3224
+
3225
+ webSocketManagerMock.emit('message', JSON.stringify({data: ownerChangeData}));
3226
+
3227
+ expect(TaskFactory.createTask).not.toHaveBeenCalled();
3228
+ expect(taskManager.getAllTasks()).toEqual({});
3229
+ });
3230
+
3231
+ it('does not create a missing task for an owner-changing ContactUpdated event', () => {
3232
+ clearTasksAndFactoryHistory();
3233
+ const contactUpdatedData = {
3234
+ ...createPromotedAgentOwnerChangeData(),
3235
+ type: CC_EVENTS.CONTACT_UPDATED,
3236
+ };
3237
+
3238
+ webSocketManagerMock.emit('message', JSON.stringify({data: contactUpdatedData}));
3239
+
3240
+ expect(TaskFactory.createTask).not.toHaveBeenCalled();
3241
+ expect(taskManager.getAllTasks()).toEqual({});
3242
+ });
3243
+
3244
+ it('reuses a recovered task for later AgentContact and ContactMerged events', () => {
3245
+ clearTasksAndFactoryHistory();
3246
+ const hydrateHandler = jest.fn();
3247
+ const mergedHandler = jest.fn();
3248
+ taskManager.on(TASK_EVENTS.TASK_HYDRATE, hydrateHandler);
3249
+ taskManager.on(TASK_EVENTS.TASK_MERGED, mergedHandler);
3250
+
3251
+ webSocketManagerMock.emit(
3252
+ 'message',
3253
+ JSON.stringify({data: createPromotedAgentOwnerChangeData()})
3254
+ );
3255
+
3256
+ const recoveredTask = taskManager.taskCollection[taskId];
3257
+ expect(recoveredTask).toBeDefined();
3258
+ expect(recoveredTask.listenerCount(TASK_EVENTS.TASK_HYDRATE)).toBe(1);
3259
+
3260
+ webSocketManagerMock.emit(
3261
+ 'message',
3262
+ JSON.stringify({
3263
+ data: {
3264
+ ...createConferenceTaskData(taskId, currentAgentId),
3265
+ type: CC_EVENTS.AGENT_CONTACT,
3266
+ },
3267
+ })
3268
+ );
3269
+ webSocketManagerMock.emit(
3270
+ 'message',
3271
+ JSON.stringify({
3272
+ data: {
3273
+ ...createConferenceTaskData(taskId, currentAgentId),
3274
+ type: CC_EVENTS.CONTACT_MERGED,
3275
+ childInteractionId: 'promoted-child-interaction-id',
3276
+ },
3277
+ })
3278
+ );
3279
+
3280
+ expect(TaskFactory.createTask).toHaveBeenCalledTimes(1);
3281
+ expect(taskManager.taskCollection[taskId]).toBe(recoveredTask);
3282
+ expect(taskManager.taskCollection['promoted-child-interaction-id']).toBeUndefined();
3283
+ expect(Object.values(taskManager.taskCollection)).toEqual([recoveredTask]);
3284
+ expect(recoveredTask.listenerCount(TASK_EVENTS.TASK_HYDRATE)).toBe(1);
3285
+ expect(hydrateHandler).toHaveBeenCalledTimes(2);
3286
+ expect(mergedHandler).toHaveBeenCalledTimes(1);
3287
+ expect(mergedHandler).toHaveBeenCalledWith(recoveredTask);
3288
+ });
3289
+
3290
+ it('does not let a late ParticipantLeftConference restore a departed owner', () => {
3291
+ const task = installTask(createConferenceTaskData(taskId, previousOwnerId));
3292
+
3293
+ webSocketManagerMock.emit(
3294
+ 'message',
3295
+ JSON.stringify({
3296
+ data: {
3297
+ ...createConferenceTaskData(taskId, promotedOwnerId),
3298
+ type: CC_EVENTS.CONTACT_UPDATED,
3299
+ },
3300
+ })
3301
+ );
3302
+
3303
+ const participantLeftData = createConferenceTaskData(taskId, previousOwnerId);
3304
+ participantLeftData.participantId = previousOwnerId;
3305
+ participantLeftData.interaction.participants[previousOwnerId].hasLeft = true;
3306
+ participantLeftData.interaction.media[taskId].participants = [
3307
+ currentAgentId,
3308
+ promotedOwnerId,
3309
+ ];
3310
+ webSocketManagerMock.emit(
3311
+ 'message',
3312
+ JSON.stringify({
3313
+ data: {...participantLeftData, type: CC_EVENTS.PARTICIPANT_LEFT_CONFERENCE},
3314
+ })
3315
+ );
3316
+
3317
+ const participantLeaveEvent = expectLastStateMachineEvent(
3318
+ task.sendStateMachineEvent,
3319
+ TaskEvent.PARTICIPANT_LEAVE
3320
+ );
3321
+ expect(participantLeaveEvent.taskData.interaction.owner).toBe(promotedOwnerId);
3322
+ expect(task.data.interaction.owner).toBe(promotedOwnerId);
3323
+ });
3324
+
3325
+ it('does not infer that an incoming owner departed from a partial main-call roster', () => {
3326
+ const task = installTask(createConferenceTaskData(taskId, promotedOwnerId));
3327
+ const partialParticipantLeftData = createConferenceTaskData(taskId, previousOwnerId);
3328
+ partialParticipantLeftData.participantId = 'another-agent-id';
3329
+ partialParticipantLeftData.interaction.media[taskId].participants = [
3330
+ currentAgentId,
3331
+ promotedOwnerId,
3332
+ ];
3333
+
3334
+ webSocketManagerMock.emit(
3335
+ 'message',
3336
+ JSON.stringify({
3337
+ data: {
3338
+ ...partialParticipantLeftData,
3339
+ type: CC_EVENTS.PARTICIPANT_LEFT_CONFERENCE,
3340
+ },
3341
+ })
3342
+ );
3343
+
3344
+ const participantLeaveEvent = expectLastStateMachineEvent(
3345
+ task.sendStateMachineEvent,
3346
+ TaskEvent.PARTICIPANT_LEAVE
3347
+ );
3348
+ expect(participantLeaveEvent.taskData.interaction.owner).toBe(previousOwnerId);
3349
+ expect(task.data.interaction.owner).toBe(previousOwnerId);
3350
+ });
3351
+
3352
+ it('applies the promoted owner when ParticipantLeftConference arrives first', () => {
3353
+ const task = installTask(createConferenceTaskData(taskId, previousOwnerId));
3354
+ const participantLeftData = createConferenceTaskData(taskId, previousOwnerId);
3355
+ participantLeftData.participantId = previousOwnerId;
3356
+ participantLeftData.interaction.participants[previousOwnerId].hasLeft = true;
3357
+ participantLeftData.interaction.media[taskId].participants = [
3358
+ currentAgentId,
3359
+ promotedOwnerId,
3360
+ ];
3361
+
3362
+ webSocketManagerMock.emit(
3363
+ 'message',
3364
+ JSON.stringify({
3365
+ data: {...participantLeftData, type: CC_EVENTS.PARTICIPANT_LEFT_CONFERENCE},
3366
+ })
3367
+ );
3368
+ webSocketManagerMock.emit(
3369
+ 'message',
3370
+ JSON.stringify({
3371
+ data: {
3372
+ ...createConferenceTaskData(taskId, promotedOwnerId),
3373
+ type: CC_EVENTS.CONTACT_UPDATED,
3374
+ },
3375
+ })
3376
+ );
3377
+
3378
+ expect(task.data.interaction.owner).toBe(promotedOwnerId);
3379
+ expectLastStateMachineEvent(task.sendStateMachineEvent, TaskEvent.CONTACT_OWNER_CHANGED);
3380
+ });
3381
+ });
3382
+
3383
+ describe('Conference event handling', () => {
3384
+ let task;
3385
+
3386
+ beforeEach(() => {
3387
+ task = {
3388
+ data: {interactionId: taskId},
3389
+ emit: jest.fn(),
3390
+ updateTaskData: jest.fn(),
2370
3391
  sendStateMachineEvent: jest.fn(),
2371
3392
  };
2372
3393
  taskManager.taskCollection[taskId] = task as any;
@@ -2412,6 +3433,254 @@ describe('TaskManager', () => {
2412
3433
  expect(call.type).toBe(TaskEvent.PARTICIPANT_LEAVE);
2413
3434
  });
2414
3435
 
3436
+ it('routes participant-left from a main interaction to a child-keyed task', () => {
3437
+ const childTaskId = 'child-interaction-id';
3438
+ const childTask = createMockTask({
3439
+ ...taskDataMock,
3440
+ interactionId: childTaskId,
3441
+ isConsulted: true,
3442
+ interaction: {
3443
+ mediaType: 'telephony',
3444
+ interactionId: childTaskId,
3445
+ parentInteractionId: taskId,
3446
+ },
3447
+ });
3448
+ delete taskManager.taskCollection[taskId];
3449
+ taskManager.taskCollection[childTaskId] = childTask;
3450
+
3451
+ webSocketManagerMock.emit(
3452
+ 'message',
3453
+ JSON.stringify({
3454
+ data: {
3455
+ type: CC_EVENTS.PARTICIPANT_LEFT_CONFERENCE,
3456
+ interactionId: taskId,
3457
+ participantId: 'test-agent-id',
3458
+ interaction: {
3459
+ mediaType: 'telephony',
3460
+ interactionId: taskId,
3461
+ mainInteractionId: taskId,
3462
+ },
3463
+ },
3464
+ })
3465
+ );
3466
+
3467
+ expect(childTask.sendStateMachineEvent).toHaveBeenCalledWith(
3468
+ expect.objectContaining({
3469
+ type: TaskEvent.PARTICIPANT_LEAVE,
3470
+ participantId: 'test-agent-id',
3471
+ })
3472
+ );
3473
+ expect(taskManager.taskCollection[childTaskId]).toBeUndefined();
3474
+ expect(taskManager.taskCollection[taskId]).toBe(childTask);
3475
+ expect(
3476
+ Object.values(taskManager.taskCollection).filter((entry) => entry === childTask)
3477
+ ).toHaveLength(1);
3478
+ });
3479
+
3480
+ it('routes consult-end from a main interaction to a child-keyed consulted task', () => {
3481
+ const childTaskId = 'consult-child-interaction-id';
3482
+ const childTask = createMockTask({
3483
+ ...taskDataMock,
3484
+ interactionId: childTaskId,
3485
+ isConsulted: true,
3486
+ interaction: {
3487
+ mediaType: 'telephony',
3488
+ interactionId: childTaskId,
3489
+ callProcessingDetails: {parentInteractionId: taskId},
3490
+ },
3491
+ });
3492
+ delete taskManager.taskCollection[taskId];
3493
+ taskManager.taskCollection[childTaskId] = childTask;
3494
+
3495
+ webSocketManagerMock.emit(
3496
+ 'message',
3497
+ JSON.stringify({
3498
+ data: {
3499
+ type: CC_EVENTS.AGENT_CONSULT_ENDED,
3500
+ interactionId: taskId,
3501
+ interaction: {
3502
+ mediaType: 'telephony',
3503
+ interactionId: taskId,
3504
+ mainInteractionId: taskId,
3505
+ },
3506
+ },
3507
+ })
3508
+ );
3509
+
3510
+ expect(childTask.sendStateMachineEvent).toHaveBeenCalledWith(
3511
+ expect.objectContaining({type: TaskEvent.CONSULT_END})
3512
+ );
3513
+ });
3514
+
3515
+ it('deduplicates aliases of the same child task during related-interaction lookup', () => {
3516
+ const childTaskId = 'aliased-child-interaction-id';
3517
+ const childTask = createMockTask({
3518
+ ...taskDataMock,
3519
+ interactionId: childTaskId,
3520
+ interaction: {
3521
+ mediaType: 'telephony',
3522
+ interactionId: childTaskId,
3523
+ mainInteractionId: taskId,
3524
+ },
3525
+ });
3526
+ delete taskManager.taskCollection[taskId];
3527
+ taskManager.taskCollection[childTaskId] = childTask;
3528
+ taskManager.taskCollection['child-task-alias'] = childTask;
3529
+
3530
+ webSocketManagerMock.emit(
3531
+ 'message',
3532
+ JSON.stringify({
3533
+ data: {
3534
+ type: CC_EVENTS.PARTICIPANT_LEFT_CONFERENCE,
3535
+ interactionId: taskId,
3536
+ participantId: 'test-agent-id',
3537
+ },
3538
+ })
3539
+ );
3540
+
3541
+ expect(childTask.sendStateMachineEvent).toHaveBeenCalledTimes(1);
3542
+ expect(
3543
+ Object.values(taskManager.taskCollection).filter((entry) => entry === childTask)
3544
+ ).toHaveLength(1);
3545
+ });
3546
+
3547
+ it('prefers an exact task match over a child task with the same main interaction', () => {
3548
+ const childTaskId = 'child-interaction-id';
3549
+ const childTask = createMockTask({
3550
+ ...taskDataMock,
3551
+ interactionId: childTaskId,
3552
+ interaction: {
3553
+ mediaType: 'telephony',
3554
+ interactionId: childTaskId,
3555
+ mainInteractionId: taskId,
3556
+ },
3557
+ });
3558
+ taskManager.taskCollection[childTaskId] = childTask;
3559
+
3560
+ webSocketManagerMock.emit(
3561
+ 'message',
3562
+ JSON.stringify({
3563
+ data: {
3564
+ type: CC_EVENTS.PARTICIPANT_LEFT_CONFERENCE,
3565
+ interactionId: taskId,
3566
+ participantId: 'another-agent',
3567
+ },
3568
+ })
3569
+ );
3570
+
3571
+ expect(task.sendStateMachineEvent).toHaveBeenCalled();
3572
+ expect(childTask.sendStateMachineEvent).not.toHaveBeenCalled();
3573
+ });
3574
+
3575
+ it('does not route a main-interaction event when multiple unique child tasks match', () => {
3576
+ delete taskManager.taskCollection[taskId];
3577
+ const createChildTask = (interactionId: string) =>
3578
+ createMockTask({
3579
+ ...taskDataMock,
3580
+ interactionId,
3581
+ interaction: {
3582
+ mediaType: 'telephony',
3583
+ interactionId,
3584
+ mainInteractionId: taskId,
3585
+ },
3586
+ });
3587
+ const firstChildTask = createChildTask('child-1');
3588
+ const secondChildTask = createChildTask('child-2');
3589
+ taskManager.taskCollection['child-1'] = firstChildTask;
3590
+ taskManager.taskCollection['child-2'] = secondChildTask;
3591
+
3592
+ webSocketManagerMock.emit(
3593
+ 'message',
3594
+ JSON.stringify({
3595
+ data: {
3596
+ type: CC_EVENTS.PARTICIPANT_LEFT_CONFERENCE,
3597
+ interactionId: taskId,
3598
+ },
3599
+ })
3600
+ );
3601
+
3602
+ expect(firstChildTask.sendStateMachineEvent).not.toHaveBeenCalled();
3603
+ expect(secondChildTask.sendStateMachineEvent).not.toHaveBeenCalled();
3604
+ });
3605
+
3606
+ it('does not route a lifecycle event without a correlation identifier', () => {
3607
+ webSocketManagerMock.emit(
3608
+ 'message',
3609
+ JSON.stringify({
3610
+ data: {
3611
+ type: CC_EVENTS.PARTICIPANT_LEFT_CONFERENCE,
3612
+ participantId: 'another-agent',
3613
+ },
3614
+ })
3615
+ );
3616
+
3617
+ expect(task.sendStateMachineEvent).not.toHaveBeenCalled();
3618
+ });
3619
+
3620
+ it('removes every collection alias for a task during terminal cleanup', () => {
3621
+ taskManager.taskCollection['task-alias'] = task;
3622
+
3623
+ (taskManager as any).removeTaskFromCollection(task);
3624
+
3625
+ expect(taskManager.taskCollection[taskId]).toBeUndefined();
3626
+ expect(taskManager.taskCollection['task-alias']).toBeUndefined();
3627
+ });
3628
+
3629
+ it('replaces an EP-DN child task with a hydrated main task on CONTACT_MERGED', () => {
3630
+ const childTaskId = 'ep-dn-child-interaction-id';
3631
+ const childTask = createMockTask({
3632
+ ...taskDataMock,
3633
+ interactionId: childTaskId,
3634
+ interaction: {
3635
+ mediaType: 'telephony',
3636
+ interactionId: childTaskId,
3637
+ parentInteractionId: taskId,
3638
+ },
3639
+ });
3640
+ const mergedHandler = jest.fn();
3641
+ delete taskManager.taskCollection[taskId];
3642
+ taskManager.taskCollection[childTaskId] = childTask;
3643
+ taskManager.on(TASK_EVENTS.TASK_MERGED, mergedHandler);
3644
+
3645
+ webSocketManagerMock.emit(
3646
+ 'message',
3647
+ JSON.stringify({
3648
+ data: {
3649
+ type: CC_EVENTS.CONTACT_MERGED,
3650
+ interactionId: taskId,
3651
+ childInteractionId: childTaskId,
3652
+ interaction: {
3653
+ mediaType: 'telephony',
3654
+ interactionId: taskId,
3655
+ mainInteractionId: taskId,
3656
+ state: 'conference',
3657
+ owner: 'test-agent-id',
3658
+ participants: {
3659
+ 'test-agent-id': {id: 'test-agent-id', pType: 'Agent', hasLeft: false},
3660
+ },
3661
+ media: {
3662
+ [taskId]: {
3663
+ mediaResourceId: taskId,
3664
+ mediaType: 'telephony',
3665
+ mType: 'mainCall',
3666
+ participants: ['test-agent-id'],
3667
+ },
3668
+ },
3669
+ },
3670
+ },
3671
+ })
3672
+ );
3673
+
3674
+ const mainTask = taskManager.taskCollection[taskId];
3675
+ expect(taskManager.taskCollection[childTaskId]).toBeUndefined();
3676
+ expect(mainTask).toBeDefined();
3677
+ expect(mainTask).not.toBe(childTask);
3678
+ expect(mainTask.sendStateMachineEvent).toHaveBeenCalledWith(
3679
+ expect.objectContaining({type: TaskEvent.HYDRATE})
3680
+ );
3681
+ expect(mergedHandler).toHaveBeenCalledWith(mainTask);
3682
+ });
3683
+
2415
3684
  it('handles AGENT_CONSULT_CONFERENCING event without errors', () => {
2416
3685
  const payload = {
2417
3686
  data: {type: CC_EVENTS.AGENT_CONSULT_CONFERENCING, interactionId: taskId},
@@ -2463,6 +3732,405 @@ describe('TaskManager', () => {
2463
3732
  });
2464
3733
  });
2465
3734
 
3735
+ const stampWrapUp = (
3736
+ eventType: CC_EVENTS,
3737
+ payload: Record<string, unknown>,
3738
+ agentId = 'test-agent-id',
3739
+ task?: unknown
3740
+ ) => (TaskManager as any).stampWrapUpRequiredForEvent(eventType, payload, agentId, task);
3741
+
3742
+ it('maps conference wrap-up events without restamping wrapUpRequired', () => {
3743
+ const unstamped = {...taskDataMock, wrapUpRequired: false};
3744
+ const conferenceEnded = (TaskManager as any).mapEventToTaskStateMachineEvent(
3745
+ CC_EVENTS.AGENT_CONSULT_CONFERENCE_ENDED,
3746
+ unstamped,
3747
+ 'test-agent-id'
3748
+ );
3749
+ const participantLeft = (TaskManager as any).mapEventToTaskStateMachineEvent(
3750
+ CC_EVENTS.PARTICIPANT_LEFT_CONFERENCE,
3751
+ {...unstamped, participantId: 'test-agent-id'},
3752
+ 'test-agent-id'
3753
+ );
3754
+ const transferred = (TaskManager as any).mapEventToTaskStateMachineEvent(
3755
+ CC_EVENTS.AGENT_CONFERENCE_TRANSFERRED,
3756
+ unstamped,
3757
+ 'test-agent-id'
3758
+ );
3759
+
3760
+ expect(conferenceEnded.taskData).toBe(unstamped);
3761
+ expect(participantLeft.taskData.wrapUpRequired).toBe(false);
3762
+ expect(participantLeft.participantId).toBe('test-agent-id');
3763
+ expect(transferred.taskData).toBe(unstamped);
3764
+ });
3765
+
3766
+ it('stamps wrapUpRequired on AGENT_CONSULT_CONFERENCE_ENDED only from explicit signals', () => {
3767
+ const wrapping = stampWrapUp(CC_EVENTS.AGENT_CONSULT_CONFERENCE_ENDED, {
3768
+ ...taskDataMock,
3769
+ agentsPendingWrapUp: ['test-agent-id'],
3770
+ });
3771
+ const remaining = stampWrapUp(CC_EVENTS.AGENT_CONSULT_CONFERENCE_ENDED, {
3772
+ ...taskDataMock,
3773
+ owner: 'test-agent-id',
3774
+ isConsulted: false,
3775
+ });
3776
+
3777
+ expect(wrapping.wrapUpRequired).toBe(true);
3778
+ expect(remaining.wrapUpRequired).toBe(false);
3779
+ });
3780
+
3781
+ it('stamps wrapUpRequired on PARTICIPANT_LEFT_CONFERENCE only when this agent left', () => {
3782
+ const selfLeft = stampWrapUp(CC_EVENTS.PARTICIPANT_LEFT_CONFERENCE, {
3783
+ ...taskDataMock,
3784
+ participantId: 'test-agent-id',
3785
+ interaction: {
3786
+ ...taskDataMock.interaction,
3787
+ owner: 'test-agent-id',
3788
+ participants: {},
3789
+ },
3790
+ });
3791
+ const otherLeft = stampWrapUp(CC_EVENTS.PARTICIPANT_LEFT_CONFERENCE, {
3792
+ ...taskDataMock,
3793
+ participantId: 'other-agent-id',
3794
+ interaction: {
3795
+ ...taskDataMock.interaction,
3796
+ owner: 'test-agent-id',
3797
+ participants: {
3798
+ 'test-agent-id': {id: 'test-agent-id', pType: 'Agent', hasLeft: false},
3799
+ },
3800
+ },
3801
+ });
3802
+
3803
+ expect(selfLeft.wrapUpRequired).toBe(true);
3804
+ expect(otherLeft.wrapUpRequired).toBe(false);
3805
+ });
3806
+
3807
+ it('stamps wrapUpRequired true when this agent left and pending is empty or absent', () => {
3808
+ const emptyPending = stampWrapUp(CC_EVENTS.PARTICIPANT_LEFT_CONFERENCE, {
3809
+ ...taskDataMock,
3810
+ participantId: 'test-agent-id',
3811
+ agentsPendingWrapUp: [],
3812
+ interaction: {
3813
+ ...taskDataMock.interaction,
3814
+ participants: {},
3815
+ },
3816
+ });
3817
+ const absentPending = stampWrapUp(CC_EVENTS.PARTICIPANT_LEFT_CONFERENCE, {
3818
+ ...taskDataMock,
3819
+ participantId: 'test-agent-id',
3820
+ interaction: {
3821
+ ...taskDataMock.interaction,
3822
+ participants: {},
3823
+ },
3824
+ });
3825
+
3826
+ expect(emptyPending.wrapUpRequired).toBe(true);
3827
+ expect(absentPending.wrapUpRequired).toBe(true);
3828
+ });
3829
+
3830
+ it('does not stamp wrapUpRequired when this agent left but pending excludes self', () => {
3831
+ const leftExcluded = stampWrapUp(CC_EVENTS.PARTICIPANT_LEFT_CONFERENCE, {
3832
+ ...taskDataMock,
3833
+ participantId: 'test-agent-id',
3834
+ wrapUpRequired: true,
3835
+ agentsPendingWrapUp: ['other-agent-id'],
3836
+ interaction: {
3837
+ ...taskDataMock.interaction,
3838
+ owner: 'test-agent-id',
3839
+ participants: {},
3840
+ },
3841
+ });
3842
+
3843
+ expect(leftExcluded.wrapUpRequired).toBe(false);
3844
+ });
3845
+
3846
+ it('does not infer self-leave from a partial participant map when another participant is named', () => {
3847
+ const remaining = stampWrapUp(CC_EVENTS.PARTICIPANT_LEFT_CONFERENCE, {
3848
+ ...taskDataMock,
3849
+ participantId: 'other-agent-id',
3850
+ agentsPendingWrapUp: [],
3851
+ interaction: {
3852
+ ...taskDataMock.interaction,
3853
+ owner: 'test-agent-id',
3854
+ participants: {
3855
+ 'other-agent-id': {id: 'other-agent-id', pType: 'Agent', hasLeft: true},
3856
+ },
3857
+ },
3858
+ });
3859
+
3860
+ expect(remaining.wrapUpRequired).toBe(false);
3861
+
3862
+ class TimerTask extends Task {
3863
+ public accept() {
3864
+ return Promise.resolve({} as any);
3865
+ }
3866
+ }
3867
+ const remainingTask = new TimerTask(
3868
+ {},
3869
+ {...taskDataMock, wrapUpRequired: false},
3870
+ {channelType: 'voice', isEndTaskEnabled: true, isEndConsultEnabled: true},
3871
+ {
3872
+ wrapUpProps: {
3873
+ autoWrapup: true,
3874
+ autoWrapupInterval: 1000,
3875
+ wrapUpReasonList: [{id: 'code-1', name: 'Default', isDefault: true, isSystem: false}],
3876
+ },
3877
+ },
3878
+ 'test-agent-id'
3879
+ );
3880
+ try {
3881
+ remainingTask.updateTaskData(remaining);
3882
+ expect(remainingTask.autoWrapup).toBeUndefined();
3883
+ } finally {
3884
+ remainingTask.cancelAutoWrapupTimer();
3885
+ }
3886
+ });
3887
+
3888
+ it('does not stamp wrapUpRequired when a consulted non-owner leaves with empty pending', () => {
3889
+ const consulted = stampWrapUp(CC_EVENTS.PARTICIPANT_LEFT_CONFERENCE, {
3890
+ ...taskDataMock,
3891
+ participantId: 'test-agent-id',
3892
+ isConsulted: true,
3893
+ agentsPendingWrapUp: [],
3894
+ interaction: {
3895
+ ...taskDataMock.interaction,
3896
+ owner: 'other-agent-id',
3897
+ participants: {},
3898
+ },
3899
+ });
3900
+ const consultedFromTask = stampWrapUp(
3901
+ CC_EVENTS.PARTICIPANT_LEFT_CONFERENCE,
3902
+ {
3903
+ ...taskDataMock,
3904
+ participantId: 'test-agent-id',
3905
+ agentsPendingWrapUp: [],
3906
+ interaction: {
3907
+ ...taskDataMock.interaction,
3908
+ participants: {},
3909
+ },
3910
+ },
3911
+ 'test-agent-id',
3912
+ {
3913
+ data: {
3914
+ isConsulted: true,
3915
+ interaction: {owner: 'other-agent-id'},
3916
+ },
3917
+ }
3918
+ );
3919
+
3920
+ expect(consulted.wrapUpRequired).toBe(false);
3921
+ expect(consultedFromTask.wrapUpRequired).toBe(false);
3922
+ });
3923
+
3924
+ it('stamps wrapUpRequired when EP-DN omits participantId and previous task included self', () => {
3925
+ const epdn = stampWrapUp(
3926
+ CC_EVENTS.PARTICIPANT_LEFT_CONFERENCE,
3927
+ {
3928
+ ...taskDataMock,
3929
+ agentsPendingWrapUp: [],
3930
+ interaction: {
3931
+ ...taskDataMock.interaction,
3932
+ owner: 'test-agent-id',
3933
+ participants: {},
3934
+ },
3935
+ },
3936
+ 'test-agent-id',
3937
+ {
3938
+ data: {
3939
+ interaction: {
3940
+ participants: {
3941
+ 'test-agent-id': {id: 'test-agent-id', pType: 'Agent', hasLeft: false},
3942
+ },
3943
+ },
3944
+ },
3945
+ }
3946
+ );
3947
+
3948
+ expect(epdn.wrapUpRequired).toBe(true);
3949
+ });
3950
+
3951
+ it('does not stamp wrapUpRequired on PARTICIPANT_LEFT_CONFERENCE when the actor ignores PARTICIPANT_LEAVE', () => {
3952
+ const selfLeave = {
3953
+ ...taskDataMock,
3954
+ participantId: 'test-agent-id',
3955
+ agentsPendingWrapUp: [],
3956
+ interaction: {
3957
+ ...taskDataMock.interaction,
3958
+ owner: 'test-agent-id',
3959
+ participants: {},
3960
+ },
3961
+ };
3962
+ const snapshotTask = (state: TaskState) => ({
3963
+ stateMachineService: {getSnapshot: () => ({value: state})},
3964
+ });
3965
+
3966
+ const holdInitiating = stampWrapUp(
3967
+ CC_EVENTS.PARTICIPANT_LEFT_CONFERENCE,
3968
+ selfLeave,
3969
+ 'test-agent-id',
3970
+ snapshotTask(TaskState.HOLD_INITIATING)
3971
+ );
3972
+ const connected = stampWrapUp(
3973
+ CC_EVENTS.PARTICIPANT_LEFT_CONFERENCE,
3974
+ selfLeave,
3975
+ 'test-agent-id',
3976
+ snapshotTask(TaskState.CONNECTED)
3977
+ );
3978
+ const confInitiating = stampWrapUp(
3979
+ CC_EVENTS.PARTICIPANT_LEFT_CONFERENCE,
3980
+ selfLeave,
3981
+ 'test-agent-id',
3982
+ snapshotTask(TaskState.CONF_INITIATING)
3983
+ );
3984
+ const conferencing = stampWrapUp(
3985
+ CC_EVENTS.PARTICIPANT_LEFT_CONFERENCE,
3986
+ selfLeave,
3987
+ 'test-agent-id',
3988
+ snapshotTask(TaskState.CONFERENCING)
3989
+ );
3990
+
3991
+ expect(holdInitiating).toBe(selfLeave);
3992
+ expect(holdInitiating.wrapUpRequired).toBeUndefined();
3993
+ expect(connected.wrapUpRequired).toBeUndefined();
3994
+ expect(confInitiating.wrapUpRequired).toBeUndefined();
3995
+ expect(conferencing.wrapUpRequired).toBe(true);
3996
+
3997
+ class TimerTask extends Task {
3998
+ public accept() {
3999
+ return Promise.resolve({} as any);
4000
+ }
4001
+ }
4002
+ const holdTask = new TimerTask(
4003
+ {},
4004
+ {...taskDataMock, wrapUpRequired: false},
4005
+ {channelType: 'voice', isEndTaskEnabled: true, isEndConsultEnabled: true},
4006
+ {
4007
+ wrapUpProps: {
4008
+ autoWrapup: true,
4009
+ autoWrapupInterval: 1000,
4010
+ wrapUpReasonList: [{id: 'code-1', name: 'Default', isDefault: true, isSystem: false}],
4011
+ },
4012
+ },
4013
+ 'test-agent-id'
4014
+ );
4015
+ try {
4016
+ holdTask.updateTaskData(holdInitiating);
4017
+ expect(holdTask.autoWrapup).toBeUndefined();
4018
+ } finally {
4019
+ holdTask.cancelAutoWrapupTimer();
4020
+ }
4021
+ });
4022
+
4023
+ it('stamps wrapUpRequired on AGENT_CONFERENCE_TRANSFERRED for the initiator only', () => {
4024
+ const initiator = stampWrapUp(CC_EVENTS.AGENT_CONFERENCE_TRANSFERRED, {
4025
+ ...taskDataMock,
4026
+ consultingAgentId: 'test-agent-id',
4027
+ });
4028
+ const observer = stampWrapUp(CC_EVENTS.AGENT_CONFERENCE_TRANSFERRED, {
4029
+ ...taskDataMock,
4030
+ consultingAgentId: 'other-agent-id',
4031
+ owner: 'test-agent-id',
4032
+ });
4033
+
4034
+ expect(initiator.wrapUpRequired).toBe(true);
4035
+ expect(observer.wrapUpRequired).toBe(false);
4036
+ });
4037
+
4038
+ it('does not let transfer initiator override a nonempty pending list that excludes self', () => {
4039
+ const consultingInitiator = stampWrapUp(CC_EVENTS.AGENT_CONFERENCE_TRANSFERRED, {
4040
+ ...taskDataMock,
4041
+ wrapUpRequired: true,
4042
+ agentsPendingWrapUp: ['other-agent-id'],
4043
+ consultingAgentId: 'test-agent-id',
4044
+ });
4045
+ const flaggedInitiator = stampWrapUp(
4046
+ CC_EVENTS.AGENT_CONFERENCE_TRANSFERRED,
4047
+ {
4048
+ ...taskDataMock,
4049
+ wrapUpRequired: true,
4050
+ agentsPendingWrapUp: ['other-agent-id'],
4051
+ },
4052
+ 'test-agent-id',
4053
+ {
4054
+ stateMachineService: {
4055
+ getSnapshot: () => ({context: {transferConferenceRequested: true}}),
4056
+ },
4057
+ }
4058
+ );
4059
+
4060
+ expect(consultingInitiator.wrapUpRequired).toBe(false);
4061
+ expect(flaggedInitiator.wrapUpRequired).toBe(false);
4062
+ });
4063
+
4064
+ it('honors transferConferenceRequested when pending is empty and consultingAgentId is omitted', () => {
4065
+ const flagged = stampWrapUp(
4066
+ CC_EVENTS.AGENT_CONFERENCE_TRANSFERRED,
4067
+ {...taskDataMock},
4068
+ 'test-agent-id',
4069
+ {
4070
+ stateMachineService: {
4071
+ getSnapshot: () => ({context: {transferConferenceRequested: true}}),
4072
+ },
4073
+ }
4074
+ );
4075
+ const unflagged = stampWrapUp(CC_EVENTS.AGENT_CONFERENCE_TRANSFERRED, {...taskDataMock});
4076
+
4077
+ expect(flagged.wrapUpRequired).toBe(true);
4078
+ expect(unflagged.wrapUpRequired).toBe(false);
4079
+ });
4080
+
4081
+ it('does not stamp wrapUpRequired when a nonempty pending list excludes this agent', () => {
4082
+ const conflictingPayload = {
4083
+ ...taskDataMock,
4084
+ wrapUpRequired: true,
4085
+ agentsPendingWrapUp: ['other-agent-id'],
4086
+ };
4087
+
4088
+ const conferenceEnded = stampWrapUp(
4089
+ CC_EVENTS.AGENT_CONSULT_CONFERENCE_ENDED,
4090
+ conflictingPayload
4091
+ );
4092
+ const conferenceTransferred = stampWrapUp(CC_EVENTS.AGENT_CONFERENCE_TRANSFERRED, {
4093
+ ...conflictingPayload,
4094
+ consultingAgentId: 'other-agent-id',
4095
+ });
4096
+ const remainingOnLeave = stampWrapUp(CC_EVENTS.PARTICIPANT_LEFT_CONFERENCE, {
4097
+ ...conflictingPayload,
4098
+ participantId: 'other-agent-id',
4099
+ interaction: {
4100
+ ...taskDataMock.interaction,
4101
+ owner: 'test-agent-id',
4102
+ participants: {
4103
+ 'test-agent-id': {id: 'test-agent-id', pType: 'Agent', hasLeft: false},
4104
+ },
4105
+ },
4106
+ });
4107
+
4108
+ expect(conferenceEnded.wrapUpRequired).toBe(false);
4109
+ expect(conferenceTransferred.wrapUpRequired).toBe(false);
4110
+ expect(remainingOnLeave.wrapUpRequired).toBe(false);
4111
+ });
4112
+
4113
+ it('stamps wrapUpRequired when pending includes this agent or pending is empty', () => {
4114
+ const pendingIncludesSelf = stampWrapUp(CC_EVENTS.AGENT_CONSULT_CONFERENCE_ENDED, {
4115
+ ...taskDataMock,
4116
+ wrapUpRequired: true,
4117
+ agentsPendingWrapUp: ['test-agent-id'],
4118
+ });
4119
+ const emptyPendingFallback = stampWrapUp(CC_EVENTS.AGENT_CONSULT_CONFERENCE_ENDED, {
4120
+ ...taskDataMock,
4121
+ wrapUpRequired: true,
4122
+ agentsPendingWrapUp: [],
4123
+ });
4124
+ const missingPendingFallback = stampWrapUp(CC_EVENTS.AGENT_CONSULT_CONFERENCE_ENDED, {
4125
+ ...taskDataMock,
4126
+ wrapUpRequired: true,
4127
+ });
4128
+
4129
+ expect(pendingIncludesSelf.wrapUpRequired).toBe(true);
4130
+ expect(emptyPendingFallback.wrapUpRequired).toBe(true);
4131
+ expect(missingPendingFallback.wrapUpRequired).toBe(true);
4132
+ });
4133
+
2466
4134
  it('sends mapped events to the task state machine service', () => {
2467
4135
  const payload = {...taskDataMock, type: CC_EVENTS.AGENT_CONTACT_ASSIGNED};
2468
4136
  const task = taskManager.getTask(taskId);
@@ -2478,4 +4146,44 @@ describe('TaskManager', () => {
2478
4146
  sendStateMachineEventSpy.mockRestore();
2479
4147
  });
2480
4148
  });
4149
+
4150
+ describe('applyEnableWxBetterTogether', () => {
4151
+ it('updates config flags and propagates to active tasks', () => {
4152
+ const taskOne = {setEnableWxBetterTogether: jest.fn()};
4153
+ const taskTwo = {setEnableWxBetterTogether: jest.fn()};
4154
+
4155
+ taskManager.setConfigFlags({
4156
+ isEndTaskEnabled: true,
4157
+ isEndConsultEnabled: true,
4158
+ enableWxBetterTogether: true,
4159
+ });
4160
+ taskManager['taskCollection'] = {
4161
+ [taskId]: taskOne,
4162
+ 'task-2': taskTwo,
4163
+ };
4164
+
4165
+ taskManager.applyEnableWxBetterTogether(false);
4166
+
4167
+ expect(taskManager['configFlags']?.enableWxBetterTogether).toBe(false);
4168
+ expect(taskOne.setEnableWxBetterTogether).toHaveBeenCalledWith(false);
4169
+ expect(taskTwo.setEnableWxBetterTogether).toHaveBeenCalledWith(false);
4170
+ });
4171
+ });
4172
+
4173
+ describe('applyWxAppMuteStateFromSync', () => {
4174
+ it('propagates mute state to all tasks', () => {
4175
+ const taskOne = {applyWxAppMuteStateFromSync: jest.fn()};
4176
+ const taskTwo = {applyWxAppMuteStateFromSync: jest.fn()};
4177
+
4178
+ taskManager['taskCollection'] = {
4179
+ [taskId]: taskOne,
4180
+ 'task-2': taskTwo,
4181
+ };
4182
+
4183
+ taskManager.applyWxAppMuteStateFromSync('call-1', true);
4184
+
4185
+ expect(taskOne.applyWxAppMuteStateFromSync).toHaveBeenCalledWith('call-1', true);
4186
+ expect(taskTwo.applyWxAppMuteStateFromSync).toHaveBeenCalledWith('call-1', true);
4187
+ });
4188
+ });
2481
4189
  });