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

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
@@ -1,13 +1,19 @@
1
1
  import Voice from '../../../../../../src/services/task/voice/Voice';
2
+ import LoggerProxy from '../../../../../../src/logger-proxy';
2
3
  import {
3
4
  TaskData,
4
5
  CONSULT_TRANSFER_DESTINATION_TYPE,
6
+ TASK_EVENTS,
7
+ VOICE_VARIANT,
5
8
  } from '../../../../../../src/services/task/types';
6
9
  import {CC_EVENTS} from '../../../../../../src/services/config/types';
7
10
  import {TaskEvent, TaskState} from '../../../../../../src/services/task/state-machine';
8
11
  import {computeUIControls} from '../../../../../../src/services/task/state-machine/uiControlsComputer';
9
12
  import * as Utils from '../../../../../../src/services/core/Utils';
10
13
  import {createTaskData} from '../taskTestUtils';
14
+ import MetricsManager from '../../../../../../src/metrics/MetricsManager';
15
+ import {METRIC_EVENT_NAMES} from '../../../../../../src/metrics/constants';
16
+ import * as wxAppDiagnosticLogging from '../../../../../../src/services/wxAppDiagnosticLogging';
11
17
 
12
18
  jest.mock('../../../../../../src/services/core/WebexRequest', () => ({
13
19
  __esModule: true,
@@ -33,7 +39,17 @@ const dummyContact = {
33
39
  resumeRecording: jest.fn().mockResolvedValue('resumedRecording'),
34
40
  consult: jest.fn().mockResolvedValue('consulted'),
35
41
  consultConference: jest.fn().mockResolvedValue('conferenceStarted'),
42
+ dropConferenceParticipant: jest.fn().mockResolvedValue({
43
+ type: 'RoutingMessage',
44
+ trackingId: 'drop-tracking-id',
45
+ data: {},
46
+ }),
36
47
  consultTransfer: jest.fn().mockResolvedValue('consultTransferred'),
48
+ cancelTask: jest.fn().mockResolvedValue(undefined),
49
+ exitConference: jest.fn().mockResolvedValue({
50
+ trackingId: 'exit-tracking-id',
51
+ data: {},
52
+ }),
37
53
  } as any;
38
54
 
39
55
  const createBaseData = (overrides: Partial<TaskData> = {}): TaskData =>
@@ -94,6 +110,28 @@ describe('Voice Task', () => {
94
110
  expect(outdialFailedCall).toBeDefined();
95
111
  expect(outdialFailedCall![1]).toBe('CUSTOMER_BUSY');
96
112
  });
113
+
114
+ it('does not emit task:outdialFailed when suppressOutdialFailedPopup is set', () => {
115
+ const taskData = createBaseData({
116
+ interaction: {
117
+ outboundType: 'OUTDIAL',
118
+ } as any,
119
+ suppressOutdialFailedPopup: true,
120
+ });
121
+ const voice = new Voice(dummyContact, taskData, {});
122
+ const emitSpy = jest.spyOn(voice, 'emit');
123
+
124
+ voice.sendStateMachineEvent({
125
+ type: TaskEvent.OUTBOUND_FAILED,
126
+ taskData,
127
+ reason: 'AGENT_ENDS',
128
+ });
129
+
130
+ const outdialFailedCall = emitSpy.mock.calls.find(
131
+ (call) => call[0] === 'task:outdialFailed'
132
+ );
133
+ expect(outdialFailedCall).toBeUndefined();
134
+ });
97
135
  });
98
136
 
99
137
  it('hides end and endConsult when disabled', () => {
@@ -560,6 +598,160 @@ describe('Voice Task', () => {
560
598
  });
561
599
  });
562
600
 
601
+ describe('exitConference()', () => {
602
+ const conferenceTaskData = () =>
603
+ createBaseData({
604
+ agentId: 'agent-1',
605
+ interaction: {
606
+ state: 'conference',
607
+ owner: 'agent-1',
608
+ mainInteractionId: 'int1',
609
+ interactionId: 'int1',
610
+ participants: {
611
+ 'agent-1': {id: 'agent-1', pType: 'Agent', type: 'Agent', hasJoined: true, hasLeft: false},
612
+ 'agent-2': {id: 'agent-2', pType: 'Agent', type: 'Agent', hasJoined: true, hasLeft: false},
613
+ c1: {id: 'c1', pType: 'Customer', type: 'Customer', hasJoined: true, hasLeft: false},
614
+ },
615
+ media: {
616
+ media1: {mediaResourceId: 'media1', isHold: false},
617
+ int1: {
618
+ mediaResourceId: 'int1',
619
+ mType: 'mainCall',
620
+ participants: ['agent-1', 'agent-2', 'c1'],
621
+ isHold: false,
622
+ },
623
+ },
624
+ } as any,
625
+ });
626
+
627
+ const primeConferencing = (voice: Voice, taskData: TaskData) => {
628
+ primeConnectedState(voice, taskData);
629
+ voice.stateMachineService?.send({type: TaskEvent.CONFERENCE_START, taskData});
630
+ expect(voice.stateMachineService?.getSnapshot().value).toBe(TaskState.CONFERENCING);
631
+ };
632
+
633
+ it('transitions to WRAPPING_UP after exitConference when the actor stays CONNECTED', async () => {
634
+ const taskData = conferenceTaskData();
635
+ const voice = new Voice(dummyContact, taskData, {}, undefined, 'agent-1');
636
+ primeConnectedState(voice, taskData);
637
+ expect(voice.stateMachineService?.getSnapshot().value).toBe(TaskState.CONNECTED);
638
+
639
+ await voice.exitConference();
640
+
641
+ expect(dummyContact.exitConference).toHaveBeenCalled();
642
+ expect(voice.stateMachineService?.getSnapshot().value).toBe(TaskState.WRAPPING_UP);
643
+ });
644
+
645
+ it('stamps wrapUpRequired on EXIT_CONFERENCE_SUCCESS when this agent should wrap', async () => {
646
+ const taskData = conferenceTaskData();
647
+ const voice = new Voice(dummyContact, taskData, {}, undefined, 'agent-1');
648
+ primeConferencing(voice, taskData);
649
+ const sendSpy = jest.spyOn(voice.stateMachineService as any, 'send');
650
+
651
+ await voice.exitConference();
652
+
653
+ const successEvent = sendSpy.mock.calls
654
+ .map((call) => call[0])
655
+ .find((event) => event?.type === TaskEvent.EXIT_CONFERENCE_SUCCESS);
656
+
657
+ expect(dummyContact.exitConference).toHaveBeenCalled();
658
+ expect(successEvent?.taskData.wrapUpRequired).toBe(true);
659
+ });
660
+
661
+ it('stamps wrapUpRequired false on EXIT_CONFERENCE_SUCCESS when this agent should not wrap', async () => {
662
+ const taskData = createBaseData({
663
+ agentId: 'agent-2',
664
+ isConsulted: true,
665
+ wrapUpRequired: false,
666
+ interaction: {
667
+ state: 'conference',
668
+ owner: 'agent-1',
669
+ mainInteractionId: 'int1',
670
+ interactionId: 'int1',
671
+ participants: {
672
+ 'agent-1': {id: 'agent-1', pType: 'Agent', type: 'Agent', hasJoined: true, hasLeft: false},
673
+ 'agent-2': {
674
+ id: 'agent-2',
675
+ pType: 'Agent',
676
+ type: 'Agent',
677
+ hasJoined: true,
678
+ hasLeft: false,
679
+ isWrapUp: false,
680
+ },
681
+ },
682
+ } as any,
683
+ });
684
+ const voice = new Voice(dummyContact, taskData, {}, undefined, 'agent-2');
685
+ primeConferencing(voice, taskData);
686
+ const sendSpy = jest.spyOn(voice.stateMachineService as any, 'send');
687
+
688
+ await voice.exitConference();
689
+
690
+ const successEvent = sendSpy.mock.calls
691
+ .map((call) => call[0])
692
+ .find((event) => event?.type === TaskEvent.EXIT_CONFERENCE_SUCCESS);
693
+
694
+ expect(successEvent?.taskData.wrapUpRequired).toBe(false);
695
+ });
696
+
697
+ it('stamps wrapUpRequired true on owner-changed exit when isConsulted is omitted', async () => {
698
+ const taskData = createBaseData({
699
+ agentId: 'agent-1',
700
+ wrapUpRequired: false,
701
+ interaction: {
702
+ state: 'conference',
703
+ owner: 'agent-2',
704
+ mainInteractionId: 'int1',
705
+ interactionId: 'int1',
706
+ participants: {
707
+ 'agent-1': {id: 'agent-1', pType: 'Agent', type: 'Agent', hasJoined: true, hasLeft: false},
708
+ 'agent-2': {id: 'agent-2', pType: 'Agent', type: 'Agent', hasJoined: true, hasLeft: false},
709
+ },
710
+ } as any,
711
+ });
712
+ const voice = new Voice(dummyContact, taskData, {}, undefined, 'agent-1');
713
+ primeConferencing(voice, taskData);
714
+ const sendSpy = jest.spyOn(voice.stateMachineService as any, 'send');
715
+
716
+ await voice.exitConference();
717
+
718
+ const successEvent = sendSpy.mock.calls
719
+ .map((call) => call[0])
720
+ .find((event) => event?.type === TaskEvent.EXIT_CONFERENCE_SUCCESS);
721
+
722
+ expect(successEvent?.taskData.wrapUpRequired).toBe(true);
723
+ });
724
+
725
+ it('ignores a stale cached pending list when the exit response omits it', async () => {
726
+ const taskData = createBaseData({
727
+ agentId: 'agent-1',
728
+ wrapUpRequired: false,
729
+ agentsPendingWrapUp: ['agent-2'],
730
+ interaction: {
731
+ state: 'conference',
732
+ owner: 'agent-2',
733
+ mainInteractionId: 'int1',
734
+ interactionId: 'int1',
735
+ participants: {
736
+ 'agent-1': {id: 'agent-1', pType: 'Agent', type: 'Agent', hasJoined: true, hasLeft: false},
737
+ 'agent-2': {id: 'agent-2', pType: 'Agent', type: 'Agent', hasJoined: true, hasLeft: false},
738
+ },
739
+ } as any,
740
+ });
741
+ const voice = new Voice(dummyContact, taskData, {}, undefined, 'agent-1');
742
+ primeConferencing(voice, taskData);
743
+ const sendSpy = jest.spyOn(voice.stateMachineService as any, 'send');
744
+
745
+ await voice.exitConference();
746
+
747
+ const successEvent = sendSpy.mock.calls
748
+ .map((call) => call[0])
749
+ .find((event) => event?.type === TaskEvent.EXIT_CONFERENCE_SUCCESS);
750
+
751
+ expect(successEvent?.taskData.wrapUpRequired).toBe(true);
752
+ });
753
+ });
754
+
563
755
  describe('consultConference()', () => {
564
756
  afterEach(() => {
565
757
  jest.restoreAllMocks();
@@ -646,4 +838,1224 @@ describe('Voice Task', () => {
646
838
  });
647
839
  });
648
840
  });
841
+
842
+ describe('dropConferenceParticipant()', () => {
843
+ afterEach(() => {
844
+ jest.restoreAllMocks();
845
+ });
846
+
847
+ it.each([
848
+ ['missing payload', undefined],
849
+ ['missing participantId', {}],
850
+ ['non-string participantId', {participantId: 42}],
851
+ ['blank participantId', {participantId: ' '}],
852
+ ])('rejects %s before registering a request', async (_label, payload) => {
853
+ const taskData = createBaseData();
854
+ const voice = new Voice(dummyContact, taskData, {});
855
+ const timeEventSpy = jest.spyOn(MetricsManager.getInstance(), 'timeEvent');
856
+
857
+ await expect(voice.dropConferenceParticipant(payload as any)).rejects.toThrow(
858
+ 'participantId must be a non-empty string'
859
+ );
860
+
861
+ expect(dummyContact.dropConferenceParticipant).not.toHaveBeenCalled();
862
+ expect(timeEventSpy).not.toHaveBeenCalled();
863
+ });
864
+
865
+ it('uses the latest main interaction ID and records PII-safe success telemetry', async () => {
866
+ const participantId = '+1/participant-secret';
867
+ const taskData = createBaseData({
868
+ interaction: {mainInteractionId: 'main-interaction-id'} as any,
869
+ });
870
+ const voice = new Voice(dummyContact, taskData, {}, undefined, 'current-agent-id');
871
+ const metricsManager = MetricsManager.getInstance();
872
+ const timeEventSpy = jest.spyOn(metricsManager, 'timeEvent').mockImplementation();
873
+ const trackEventSpy = jest.spyOn(metricsManager, 'trackEvent').mockImplementation();
874
+ const infoSpy = jest.spyOn(LoggerProxy, 'info').mockImplementation();
875
+ const logSpy = jest.spyOn(LoggerProxy, 'log').mockImplementation();
876
+
877
+ await voice.dropConferenceParticipant({participantId});
878
+
879
+ expect(dummyContact.dropConferenceParticipant).toHaveBeenCalledWith({
880
+ interactionId: 'main-interaction-id',
881
+ participantId,
882
+ });
883
+ expect(timeEventSpy).toHaveBeenCalledWith([
884
+ METRIC_EVENT_NAMES.TASK_CONFERENCE_PARTICIPANT_DROP_SUCCESS,
885
+ METRIC_EVENT_NAMES.TASK_CONFERENCE_PARTICIPANT_DROP_FAILED,
886
+ ]);
887
+ expect(trackEventSpy).toHaveBeenCalledWith(
888
+ METRIC_EVENT_NAMES.TASK_CONFERENCE_PARTICIPANT_DROP_SUCCESS,
889
+ expect.objectContaining({
890
+ taskId: 'int1',
891
+ requestInteractionId: 'main-interaction-id',
892
+ agentId: 'current-agent-id',
893
+ }),
894
+ ['operational', 'behavioral', 'business']
895
+ );
896
+
897
+ const telemetryAndLogs = JSON.stringify([
898
+ timeEventSpy.mock.calls,
899
+ trackEventSpy.mock.calls,
900
+ infoSpy.mock.calls,
901
+ logSpy.mock.calls,
902
+ ]);
903
+ expect(telemetryAndLogs).not.toContain(participantId);
904
+ });
905
+
906
+ it('records PII-safe failure telemetry and rethrows the AQM error', async () => {
907
+ const participantId = '+1/failure-secret';
908
+ const failure = {
909
+ details: {
910
+ type: 'ParticipantDropConferenceFailed',
911
+ trackingId: 'failure-tracking-id',
912
+ data: {reason: participantId},
913
+ },
914
+ };
915
+ dummyContact.dropConferenceParticipant.mockRejectedValueOnce(failure);
916
+ const taskData = createBaseData({
917
+ interaction: {mainInteractionId: 'main-interaction-id'} as any,
918
+ });
919
+ const voice = new Voice(dummyContact, taskData, {}, undefined, 'current-agent-id');
920
+ const metricsManager = MetricsManager.getInstance();
921
+ jest.spyOn(metricsManager, 'timeEvent').mockImplementation();
922
+ const trackEventSpy = jest.spyOn(metricsManager, 'trackEvent').mockImplementation();
923
+ const errorSpy = jest.spyOn(LoggerProxy, 'error').mockImplementation();
924
+
925
+ await expect(voice.dropConferenceParticipant({participantId})).rejects.toBe(failure);
926
+
927
+ expect(trackEventSpy).toHaveBeenCalledWith(
928
+ METRIC_EVENT_NAMES.TASK_CONFERENCE_PARTICIPANT_DROP_FAILED,
929
+ expect.objectContaining({
930
+ taskId: 'int1',
931
+ requestInteractionId: 'main-interaction-id',
932
+ agentId: 'current-agent-id',
933
+ }),
934
+ ['operational', 'behavioral', 'business']
935
+ );
936
+ expect(errorSpy).toHaveBeenCalledWith('Failed to drop conference participant', {
937
+ module: 'cc',
938
+ method: 'dropConferenceParticipant',
939
+ trackingId: 'failure-tracking-id',
940
+ interactionId: 'main-interaction-id',
941
+ });
942
+ expect(JSON.stringify([trackEventSpy.mock.calls, errorSpy.mock.calls])).not.toContain(
943
+ participantId
944
+ );
945
+ });
946
+
947
+ it.each([
948
+ ['null', null],
949
+ ['undefined', undefined],
950
+ ['string', 'primitive failure'],
951
+ ['number', 503],
952
+ ])('records failure telemetry and preserves a %s rejection', async (_label, failure) => {
953
+ dummyContact.dropConferenceParticipant.mockRejectedValueOnce(failure);
954
+ const voice = new Voice(
955
+ dummyContact,
956
+ createBaseData(),
957
+ {},
958
+ undefined,
959
+ 'current-agent-id'
960
+ );
961
+ const metricsManager = MetricsManager.getInstance();
962
+ const failureFieldsSpy = jest.spyOn(
963
+ MetricsManager,
964
+ 'getCommonTrackingFieldForAQMResponseFailed'
965
+ );
966
+ jest.spyOn(metricsManager, 'timeEvent').mockImplementation();
967
+ const trackEventSpy = jest.spyOn(metricsManager, 'trackEvent').mockImplementation();
968
+ const errorSpy = jest.spyOn(LoggerProxy, 'error').mockImplementation();
969
+
970
+ await expect(
971
+ voice.dropConferenceParticipant({participantId: 'participant-id'})
972
+ ).rejects.toBe(failure);
973
+
974
+ expect(failureFieldsSpy).toHaveBeenCalledWith({});
975
+ expect(trackEventSpy).toHaveBeenCalledWith(
976
+ METRIC_EVENT_NAMES.TASK_CONFERENCE_PARTICIPANT_DROP_FAILED,
977
+ expect.objectContaining({
978
+ taskId: 'int1',
979
+ requestInteractionId: 'int1',
980
+ agentId: 'current-agent-id',
981
+ }),
982
+ ['operational', 'behavioral', 'business']
983
+ );
984
+ expect(errorSpy).toHaveBeenCalledWith('Failed to drop conference participant', {
985
+ module: 'cc',
986
+ method: 'dropConferenceParticipant',
987
+ trackingId: undefined,
988
+ interactionId: 'int1',
989
+ });
990
+ });
991
+ });
992
+
993
+ describe('setEnableWxBetterTogether', () => {
994
+ it('updates runtime flag, uiControlConfig, and emits ui control updates', () => {
995
+ const voice = new Voice(dummyContact, createBaseData(), {enableWxBetterTogether: true});
996
+ const emitSpy = jest.spyOn(voice, 'emit');
997
+
998
+ voice.setEnableWxBetterTogether(false);
999
+
1000
+ expect(voice['enableWxBetterTogether']).toBe(false);
1001
+ expect(voice['uiControlConfig'].enableWxBetterTogether).toBe(false);
1002
+ expect(emitSpy).toHaveBeenCalledWith(
1003
+ TASK_EVENTS.TASK_UI_CONTROLS_UPDATED,
1004
+ expect.any(Object)
1005
+ );
1006
+ });
1007
+ });
1008
+
1009
+ describe('wxApp offer decision logging', () => {
1010
+ const wxAppParticipant = {
1011
+ deviceType: 'wxApp',
1012
+ deviceId: 'device-id-1',
1013
+ deviceCallId: 'call-id-1',
1014
+ };
1015
+
1016
+ const makeWxAppOfferTaskData = () =>
1017
+ createBaseData({
1018
+ agentId: 'agent-1',
1019
+ interaction: {
1020
+ participants: {
1021
+ 'agent-1': {id: 'agent-1', ...wxAppParticipant},
1022
+ },
1023
+ } as any,
1024
+ });
1025
+
1026
+ it('logs evolving accept reasons through the normal wxApp accept flow', () => {
1027
+ const logSpy = jest.spyOn(wxAppDiagnosticLogging, 'logWxAppOfferDecision');
1028
+ const taskData = makeWxAppOfferTaskData();
1029
+ const voice = new Voice(dummyContact, taskData, {enableWxBetterTogether: true});
1030
+
1031
+ voice.stateMachineService?.send({type: TaskEvent.TASK_INCOMING, taskData});
1032
+
1033
+ expect(logSpy).toHaveBeenCalledWith(
1034
+ expect.objectContaining({acceptReason: 'wxApp_offer_ready'})
1035
+ );
1036
+
1037
+ logSpy.mockClear();
1038
+ voice['setWxAppAcceptInFlight'](true);
1039
+
1040
+ expect(logSpy).toHaveBeenCalledWith(
1041
+ expect.objectContaining({acceptReason: 'wxApp_accept_in_flight'})
1042
+ );
1043
+
1044
+ logSpy.mockClear();
1045
+ voice['setWxAppAcceptInFlight'](false);
1046
+ voice['setWxAppAnswerPending'](true);
1047
+
1048
+ expect(logSpy).toHaveBeenCalledWith(
1049
+ expect.objectContaining({acceptReason: 'wxApp_answer_pending'})
1050
+ );
1051
+ });
1052
+
1053
+ it('logs browser_webrtc_offer when voice variant is WebRTC on a non-wxApp inbound offer', () => {
1054
+ const logSpy = jest.spyOn(wxAppDiagnosticLogging, 'logWxAppOfferDecision');
1055
+ const taskData = createBaseData({
1056
+ agentId: 'agent-1',
1057
+ interaction: {
1058
+ participants: {
1059
+ 'agent-1': {id: 'agent-1', deviceType: 'phone', deviceId: 'device-id-1'},
1060
+ },
1061
+ } as any,
1062
+ });
1063
+ const voice = new Voice(dummyContact, taskData, {
1064
+ enableWxBetterTogether: true,
1065
+ voiceVariant: VOICE_VARIANT.WEBRTC,
1066
+ });
1067
+
1068
+ voice.stateMachineService?.send({type: TaskEvent.TASK_INCOMING, taskData});
1069
+
1070
+ expect(logSpy).toHaveBeenCalledWith(
1071
+ expect.objectContaining({acceptReason: 'browser_webrtc_offer'})
1072
+ );
1073
+ });
1074
+ });
1075
+
1076
+ describe('applyWxAppMuteStateFromSync', () => {
1077
+ const wxAppParticipant = {
1078
+ deviceType: 'wxApp',
1079
+ deviceId: 'device-id-1',
1080
+ deviceCallId: 'call-id-1',
1081
+ };
1082
+
1083
+ const makeWxAppTaskData = () =>
1084
+ createBaseData({
1085
+ agentId: 'agent-1',
1086
+ interaction: {
1087
+ participants: {
1088
+ 'agent-1': {id: 'agent-1', ...wxAppParticipant},
1089
+ },
1090
+ } as any,
1091
+ });
1092
+
1093
+ it('emits TASK_WXAPP_MUTE_STATE_UPDATED when callId matches via endsWith', () => {
1094
+ const taskData = makeWxAppTaskData();
1095
+ const voice = new Voice(dummyContact, taskData, {enableWxBetterTogether: true});
1096
+ primeConnectedState(voice, taskData);
1097
+ const emitSpy = jest.spyOn(voice, 'emit');
1098
+
1099
+ voice.applyWxAppMuteStateFromSync('prefix:call-id-1', true);
1100
+
1101
+ expect(emitSpy).toHaveBeenCalledWith(TASK_EVENTS.TASK_WXAPP_MUTE_STATE_UPDATED, {muted: true});
1102
+ });
1103
+
1104
+ it('no-ops when callId does not match active call', () => {
1105
+ const taskData = makeWxAppTaskData();
1106
+ const voice = new Voice(dummyContact, taskData, {enableWxBetterTogether: true});
1107
+ primeConnectedState(voice, taskData);
1108
+ const emitSpy = jest.spyOn(voice, 'emit');
1109
+
1110
+ voice.applyWxAppMuteStateFromSync('other-call-id', true);
1111
+
1112
+ expect(emitSpy).not.toHaveBeenCalledWith(
1113
+ TASK_EVENTS.TASK_WXAPP_MUTE_STATE_UPDATED,
1114
+ expect.anything()
1115
+ );
1116
+ });
1117
+
1118
+ it('no-ops when enableWxBetterTogether is false', () => {
1119
+ const taskData = makeWxAppTaskData();
1120
+ const voice = new Voice(dummyContact, taskData, {enableWxBetterTogether: false});
1121
+ primeConnectedState(voice, taskData);
1122
+ const emitSpy = jest.spyOn(voice, 'emit');
1123
+
1124
+ voice.applyWxAppMuteStateFromSync('prefix:call-id-1', true);
1125
+
1126
+ expect(emitSpy).not.toHaveBeenCalledWith(
1127
+ TASK_EVENTS.TASK_WXAPP_MUTE_STATE_UPDATED,
1128
+ expect.anything()
1129
+ );
1130
+ });
1131
+
1132
+ it('no-ops when muted state is unchanged', () => {
1133
+ const taskData = makeWxAppTaskData();
1134
+ const voice = new Voice(dummyContact, taskData, {enableWxBetterTogether: true});
1135
+ primeConnectedState(voice, taskData);
1136
+ const emitSpy = jest.spyOn(voice, 'emit');
1137
+
1138
+ voice.applyWxAppMuteStateFromSync('prefix:call-id-1', true);
1139
+ emitSpy.mockClear();
1140
+
1141
+ voice.applyWxAppMuteStateFromSync('prefix:call-id-1', true);
1142
+
1143
+ expect(emitSpy).not.toHaveBeenCalledWith(
1144
+ TASK_EVENTS.TASK_WXAPP_MUTE_STATE_UPDATED,
1145
+ expect.anything()
1146
+ );
1147
+ });
1148
+ });
1149
+
1150
+ describe('syncWxAppMuteFromCallDetails', () => {
1151
+ const wxAppParticipant = {
1152
+ deviceType: 'wxApp',
1153
+ deviceId: 'device-id-1',
1154
+ deviceCallId: 'call-id-1',
1155
+ };
1156
+
1157
+ const makeWxAppTaskData = () =>
1158
+ createBaseData({
1159
+ agentId: 'agent-1',
1160
+ interaction: {
1161
+ participants: {
1162
+ 'agent-1': {id: 'agent-1', ...wxAppParticipant},
1163
+ },
1164
+ } as any,
1165
+ });
1166
+
1167
+ it('seeds mute state from telephony GET call details on engaged wxApp call', async () => {
1168
+ const mockSvc = {
1169
+ getCallDetails: jest.fn().mockResolvedValue({muted: true}),
1170
+ };
1171
+ const taskData = makeWxAppTaskData();
1172
+ const voice = new Voice(dummyContact, taskData, {
1173
+ enableWxBetterTogether: true,
1174
+ answerCallOnWebexService: mockSvc as any,
1175
+ });
1176
+ primeConnectedState(voice, taskData);
1177
+ const emitSpy = jest.spyOn(voice, 'emit');
1178
+
1179
+ await voice.syncWxAppMuteFromCallDetails();
1180
+
1181
+ expect(mockSvc.getCallDetails).toHaveBeenCalledWith({callId: 'call-id-1'});
1182
+ expect(emitSpy).toHaveBeenCalledWith(TASK_EVENTS.TASK_WXAPP_MUTE_STATE_UPDATED, {
1183
+ muted: true,
1184
+ });
1185
+ expect(voice.getWxAppMuted()).toBe(true);
1186
+ });
1187
+
1188
+ it('returns resolved mute state from syncWxAppMuteFromCallDetails', async () => {
1189
+ const mockSvc = {
1190
+ getCallDetails: jest.fn().mockResolvedValue({muted: true}),
1191
+ };
1192
+ const taskData = makeWxAppTaskData();
1193
+ const voice = new Voice(dummyContact, taskData, {
1194
+ enableWxBetterTogether: true,
1195
+ answerCallOnWebexService: mockSvc as any,
1196
+ });
1197
+ primeConnectedState(voice, taskData);
1198
+
1199
+ const result = await voice.syncWxAppMuteFromCallDetails();
1200
+
1201
+ expect(result).toBe(true);
1202
+ expect(voice.getWxAppMuted()).toBe(true);
1203
+ });
1204
+
1205
+ it('seeds mute via participant deviceCallId when state machine is still IDLE', async () => {
1206
+ const mockSvc = {
1207
+ getCallDetails: jest.fn().mockResolvedValue({muted: true}),
1208
+ };
1209
+ const taskData = makeWxAppTaskData();
1210
+ const voice = new Voice(dummyContact, taskData, {
1211
+ enableWxBetterTogether: true,
1212
+ answerCallOnWebexService: mockSvc as any,
1213
+ });
1214
+ const emitSpy = jest.spyOn(voice, 'emit');
1215
+
1216
+ const result = await voice.syncWxAppMuteFromCallDetails();
1217
+
1218
+ expect(mockSvc.getCallDetails).toHaveBeenCalledWith({callId: 'call-id-1'});
1219
+ expect(result).toBe(true);
1220
+ expect(voice.getWxAppMuted()).toBe(true);
1221
+ expect(emitSpy).toHaveBeenCalledWith(TASK_EVENTS.TASK_WXAPP_MUTE_STATE_UPDATED, {
1222
+ muted: true,
1223
+ });
1224
+ });
1225
+
1226
+ it('no-ops when enableWxBetterTogether is false', async () => {
1227
+ const mockSvc = {getCallDetails: jest.fn()};
1228
+ const taskData = makeWxAppTaskData();
1229
+ const voice = new Voice(dummyContact, taskData, {
1230
+ enableWxBetterTogether: false,
1231
+ answerCallOnWebexService: mockSvc as any,
1232
+ });
1233
+ primeConnectedState(voice, taskData);
1234
+
1235
+ await voice.syncWxAppMuteFromCallDetails();
1236
+
1237
+ expect(mockSvc.getCallDetails).not.toHaveBeenCalled();
1238
+ });
1239
+
1240
+ it('syncs mute state after task assignment once CONNECTED (onTaskAssigned)', async () => {
1241
+ const mockSvc = {
1242
+ getCallDetails: jest.fn().mockResolvedValue({muted: true}),
1243
+ };
1244
+ const taskData = makeWxAppTaskData();
1245
+ const voice = new Voice(dummyContact, taskData, {
1246
+ enableWxBetterTogether: true,
1247
+ answerCallOnWebexService: mockSvc as any,
1248
+ });
1249
+
1250
+ voice.stateMachineService?.send({type: TaskEvent.TASK_INCOMING, taskData});
1251
+ expect(voice.getWxAppMuted()).toBe(false);
1252
+ voice.stateMachineService?.send({type: TaskEvent.ASSIGN, taskData});
1253
+
1254
+ await new Promise<void>((resolve) => {
1255
+ setImmediate(resolve);
1256
+ });
1257
+
1258
+ expect(mockSvc.getCallDetails).toHaveBeenCalledWith({callId: 'call-id-1'});
1259
+ expect(voice.getWxAppMuted()).toBe(true);
1260
+ });
1261
+
1262
+ it('seeds mute on ASSIGN when wxApp offer was accepted on another session', async () => {
1263
+ const mockSvc = {
1264
+ getCallDetails: jest.fn().mockResolvedValue({muted: false}),
1265
+ };
1266
+ const taskData = makeWxAppTaskData();
1267
+ const voice = new Voice(dummyContact, taskData, {
1268
+ enableWxBetterTogether: true,
1269
+ answerCallOnWebexService: mockSvc as any,
1270
+ });
1271
+
1272
+ voice.stateMachineService?.send({type: TaskEvent.TASK_INCOMING, taskData});
1273
+ expect(voice['uiControlConfig']?.wxAppAnswerPending).toBeFalsy();
1274
+ voice.stateMachineService?.send({type: TaskEvent.ASSIGN, taskData});
1275
+
1276
+ await new Promise<void>((resolve) => {
1277
+ setImmediate(resolve);
1278
+ });
1279
+
1280
+ expect(mockSvc.getCallDetails).toHaveBeenCalledWith({callId: 'call-id-1'});
1281
+ });
1282
+
1283
+ it('syncs mute state after task hydrate (onTaskHydrated)', async () => {
1284
+ const mockSvc = {
1285
+ getCallDetails: jest.fn().mockResolvedValue({muted: true}),
1286
+ };
1287
+ const taskData = makeWxAppTaskData();
1288
+ const voice = new Voice(dummyContact, taskData, {
1289
+ enableWxBetterTogether: true,
1290
+ answerCallOnWebexService: mockSvc as any,
1291
+ });
1292
+ primeConnectedState(voice, taskData);
1293
+ const emitSpy = jest.spyOn(voice, 'emit');
1294
+
1295
+ voice.onTaskHydrated();
1296
+ await voice.syncWxAppMuteFromCallDetails();
1297
+
1298
+ expect(mockSvc.getCallDetails).toHaveBeenCalledWith({callId: 'call-id-1'});
1299
+ expect(emitSpy).toHaveBeenCalledWith(TASK_EVENTS.TASK_WXAPP_MUTE_STATE_UPDATED, {
1300
+ muted: true,
1301
+ });
1302
+ });
1303
+
1304
+ it('preserves wxAppAnswerPending on hydrate while OFFERED and answer is pending', async () => {
1305
+ const mockSvc = {
1306
+ answerCall: jest.fn().mockResolvedValue(undefined),
1307
+ getCallDetails: jest.fn().mockResolvedValue({muted: false}),
1308
+ };
1309
+ const taskData = makeWxAppTaskData();
1310
+ const voice = new Voice(dummyContact, taskData, {
1311
+ enableWxBetterTogether: true,
1312
+ answerCallOnWebexService: mockSvc as any,
1313
+ });
1314
+ voice.stateMachineService?.send({type: TaskEvent.TASK_INCOMING, taskData});
1315
+
1316
+ await voice.accept();
1317
+ expect(voice.uiControlConfig.wxAppAnswerPending).toBe(true);
1318
+
1319
+ voice.onTaskHydrated();
1320
+
1321
+ expect(voice.uiControlConfig.wxAppAnswerPending).toBe(true);
1322
+ });
1323
+
1324
+ it('clears wxAppAnswerPending on hydrate when task is no longer OFFERED', async () => {
1325
+ const mockSvc = {
1326
+ getCallDetails: jest.fn().mockResolvedValue({muted: false}),
1327
+ };
1328
+ const taskData = makeWxAppTaskData();
1329
+ const voice = new Voice(dummyContact, taskData, {
1330
+ enableWxBetterTogether: true,
1331
+ answerCallOnWebexService: mockSvc as any,
1332
+ });
1333
+ voice.stateMachineService?.send({type: TaskEvent.TASK_INCOMING, taskData});
1334
+ voice.uiControlConfig = {...voice.uiControlConfig, wxAppAnswerPending: true};
1335
+ primeConnectedState(voice, taskData);
1336
+
1337
+ voice.onTaskHydrated();
1338
+
1339
+ expect(voice.uiControlConfig.wxAppAnswerPending).toBe(false);
1340
+ });
1341
+
1342
+ it('coalesces parallel syncWxAppMuteFromCallDetails into one getCallDetails', async () => {
1343
+ let resolveGetCallDetails: (value: {muted: boolean}) => void;
1344
+ const getCallDetailsPromise = new Promise<{muted: boolean}>((resolve) => {
1345
+ resolveGetCallDetails = resolve;
1346
+ });
1347
+ const mockSvc = {
1348
+ getCallDetails: jest.fn().mockReturnValue(getCallDetailsPromise),
1349
+ };
1350
+ const taskData = makeWxAppTaskData();
1351
+ const voice = new Voice(dummyContact, taskData, {
1352
+ enableWxBetterTogether: true,
1353
+ answerCallOnWebexService: mockSvc as any,
1354
+ });
1355
+ primeConnectedState(voice, taskData);
1356
+
1357
+ const sync1 = voice.syncWxAppMuteFromCallDetails();
1358
+ const sync2 = voice.syncWxAppMuteFromCallDetails();
1359
+
1360
+ resolveGetCallDetails!({muted: true});
1361
+ const [result1, result2] = await Promise.all([sync1, sync2]);
1362
+
1363
+ expect(result1).toBe(true);
1364
+ expect(result2).toBe(true);
1365
+ expect(mockSvc.getCallDetails).toHaveBeenCalledTimes(1);
1366
+ });
1367
+
1368
+ it('issues a new getCallDetails on sequential syncWxAppMuteFromCallDetails after the first completes', async () => {
1369
+ const mockSvc = {
1370
+ getCallDetails: jest
1371
+ .fn()
1372
+ .mockResolvedValueOnce({muted: false})
1373
+ .mockResolvedValueOnce({muted: true}),
1374
+ };
1375
+ const taskData = makeWxAppTaskData();
1376
+ const voice = new Voice(dummyContact, taskData, {
1377
+ enableWxBetterTogether: true,
1378
+ answerCallOnWebexService: mockSvc as any,
1379
+ });
1380
+ primeConnectedState(voice, taskData);
1381
+
1382
+ await voice.syncWxAppMuteFromCallDetails();
1383
+ await voice.syncWxAppMuteFromCallDetails();
1384
+
1385
+ expect(mockSvc.getCallDetails).toHaveBeenCalledTimes(2);
1386
+ });
1387
+
1388
+ it('skips mute GET for terminated wxApp outdial with deviceCallId', async () => {
1389
+ const mockSvc = {getCallDetails: jest.fn()};
1390
+ const taskData = createBaseData({
1391
+ agentId: 'agent-1',
1392
+ interaction: {
1393
+ outboundType: 'OUTDIAL',
1394
+ isTerminated: true,
1395
+ participants: {
1396
+ 'agent-1': {
1397
+ id: 'agent-1',
1398
+ deviceType: 'wxApp',
1399
+ deviceId: 'device-id-1',
1400
+ deviceCallId: 'callhalf-dead',
1401
+ },
1402
+ },
1403
+ } as any,
1404
+ });
1405
+ const voice = new Voice(dummyContact, taskData, {
1406
+ enableWxBetterTogether: true,
1407
+ answerCallOnWebexService: mockSvc as any,
1408
+ });
1409
+ voice.stateMachineService?.send({type: TaskEvent.TASK_INCOMING, taskData});
1410
+
1411
+ await voice.syncWxAppMuteFromCallDetails();
1412
+
1413
+ expect(mockSvc.getCallDetails).not.toHaveBeenCalled();
1414
+ });
1415
+
1416
+ it('skips mute GET for pre-accept wxApp OFFERED offer', async () => {
1417
+ const mockSvc = {getCallDetails: jest.fn()};
1418
+ const wxAppParticipant = {
1419
+ deviceType: 'wxApp',
1420
+ deviceId: 'device-id-1',
1421
+ deviceCallId: 'call-id-1',
1422
+ };
1423
+ const taskData = createBaseData({
1424
+ agentId: 'agent-1',
1425
+ interaction: {
1426
+ outboundType: 'OUTDIAL',
1427
+ participants: {
1428
+ 'agent-1': {id: 'agent-1', ...wxAppParticipant},
1429
+ },
1430
+ } as any,
1431
+ });
1432
+ const voice = new Voice(dummyContact, taskData, {
1433
+ enableWxBetterTogether: true,
1434
+ answerCallOnWebexService: mockSvc as any,
1435
+ });
1436
+ voice.stateMachineService?.send({type: TaskEvent.TASK_INCOMING, taskData});
1437
+
1438
+ await voice.syncWxAppMuteFromCallDetails();
1439
+
1440
+ expect(mockSvc.getCallDetails).not.toHaveBeenCalled();
1441
+ });
1442
+
1443
+ it('still calls mute GET for post-accept OFFERED wxApp offer', async () => {
1444
+ const mockSvc = {
1445
+ answerCall: jest.fn().mockResolvedValue(undefined),
1446
+ getCallDetails: jest.fn().mockResolvedValue({muted: false}),
1447
+ };
1448
+ const wxAppParticipant = {
1449
+ deviceType: 'wxApp',
1450
+ deviceId: 'device-id-1',
1451
+ deviceCallId: 'call-id-1',
1452
+ };
1453
+ const taskData = createBaseData({
1454
+ agentId: 'agent-1',
1455
+ interaction: {
1456
+ outboundType: 'OUTDIAL',
1457
+ participants: {
1458
+ 'agent-1': {id: 'agent-1', ...wxAppParticipant},
1459
+ },
1460
+ } as any,
1461
+ });
1462
+ const voice = new Voice(dummyContact, taskData, {
1463
+ enableWxBetterTogether: true,
1464
+ answerCallOnWebexService: mockSvc as any,
1465
+ });
1466
+ voice.stateMachineService?.send({type: TaskEvent.TASK_INCOMING, taskData});
1467
+ await voice.accept();
1468
+
1469
+ expect(voice.uiControlConfig.wxAppAcceptInFlight).toBe(false);
1470
+ await voice.syncWxAppMuteFromCallDetails();
1471
+
1472
+ expect(mockSvc.getCallDetails).toHaveBeenCalledWith({callId: 'call-id-1'});
1473
+ });
1474
+
1475
+ it('does not log error for expected wxApp call-not-found on mute sync', async () => {
1476
+ const errorSpy = jest.spyOn(LoggerProxy, 'error');
1477
+ const mockSvc = {
1478
+ getCallDetails: jest.fn().mockRejectedValue({status: 400, message: 'Call not found'}),
1479
+ };
1480
+ const wxAppParticipant = {
1481
+ deviceType: 'wxApp',
1482
+ deviceId: 'device-id-1',
1483
+ deviceCallId: 'call-id-1',
1484
+ };
1485
+ const taskData = createBaseData({
1486
+ agentId: 'agent-1',
1487
+ interaction: {
1488
+ participants: {
1489
+ 'agent-1': {id: 'agent-1', ...wxAppParticipant},
1490
+ },
1491
+ } as any,
1492
+ });
1493
+ const voice = new Voice(dummyContact, taskData, {
1494
+ enableWxBetterTogether: true,
1495
+ answerCallOnWebexService: mockSvc as any,
1496
+ });
1497
+ primeConnectedState(voice, taskData);
1498
+
1499
+ await voice.syncWxAppMuteFromCallDetails();
1500
+
1501
+ expect(mockSvc.getCallDetails).toHaveBeenCalled();
1502
+ expect(errorSpy).not.toHaveBeenCalled();
1503
+ errorSpy.mockRestore();
1504
+ });
1505
+ });
1506
+
1507
+ describe('decline', () => {
1508
+ const wxAppParticipant = {
1509
+ deviceType: 'wxApp',
1510
+ deviceId: 'device-id-1',
1511
+ deviceCallId: 'call-id-1',
1512
+ };
1513
+
1514
+ const makeWxAppOutdialTaskData = () =>
1515
+ createBaseData({
1516
+ agentId: 'agent-1',
1517
+ interaction: {
1518
+ outboundType: 'OUTDIAL',
1519
+ participants: {
1520
+ 'agent-1': {id: 'agent-1', ...wxAppParticipant},
1521
+ },
1522
+ } as any,
1523
+ });
1524
+
1525
+ it('cancels wxApp outdial via contact.cancelTask', async () => {
1526
+ const taskData = makeWxAppOutdialTaskData();
1527
+ const voice = new Voice(dummyContact, taskData, {enableWxBetterTogether: true});
1528
+ voice.stateMachineService?.send({type: TaskEvent.TASK_INCOMING, taskData});
1529
+
1530
+ await voice.decline();
1531
+
1532
+ expect(dummyContact.cancelTask).toHaveBeenCalledWith({interactionId: 'int1'});
1533
+ });
1534
+
1535
+ it('emits TASK_DECLINE and WXAPP_TASK_DECLINE success metrics for wxApp outdial decline', async () => {
1536
+ const taskData = makeWxAppOutdialTaskData();
1537
+ const voice = new Voice(dummyContact, taskData, {enableWxBetterTogether: true});
1538
+ voice.stateMachineService?.send({type: TaskEvent.TASK_INCOMING, taskData});
1539
+ const metricsManager = MetricsManager.getInstance();
1540
+ const trackEventSpy = jest.spyOn(metricsManager, 'trackEvent');
1541
+
1542
+ await voice.decline();
1543
+
1544
+ expect(trackEventSpy).toHaveBeenCalledWith(
1545
+ METRIC_EVENT_NAMES.WXAPP_TASK_DECLINE_SUCCESS,
1546
+ expect.objectContaining({taskId: 'int1'}),
1547
+ ['operational', 'behavioral']
1548
+ );
1549
+ expect(trackEventSpy).toHaveBeenCalledWith(
1550
+ METRIC_EVENT_NAMES.TASK_DECLINE_SUCCESS,
1551
+ expect.objectContaining({taskId: 'int1'}),
1552
+ ['operational', 'behavioral']
1553
+ );
1554
+ });
1555
+
1556
+ it('emits TASK_DECLINE and WXAPP_TASK_DECLINE failed metrics when outdial cancel fails', async () => {
1557
+ const cancelError = Object.assign(new Error('AQM failed'), {
1558
+ details: {
1559
+ trackingId: 'aqm-track-1',
1560
+ orgId: 'org-1',
1561
+ type: 'Service.aqm.task.cancel',
1562
+ data: {reason: 'TASK_NOT_FOUND', reasonCode: 404, agentId: 'agent-1'},
1563
+ },
1564
+ });
1565
+ dummyContact.cancelTask = jest.fn().mockRejectedValue(cancelError);
1566
+ const taskData = makeWxAppOutdialTaskData();
1567
+ const voice = new Voice(dummyContact, taskData, {enableWxBetterTogether: true});
1568
+ voice.stateMachineService?.send({type: TaskEvent.TASK_INCOMING, taskData});
1569
+ const metricsManager = MetricsManager.getInstance();
1570
+ const trackEventSpy = jest.spyOn(metricsManager, 'trackEvent');
1571
+
1572
+ await expect(voice.decline()).rejects.toThrow('AQM failed');
1573
+
1574
+ expect(trackEventSpy).toHaveBeenCalledWith(
1575
+ METRIC_EVENT_NAMES.WXAPP_TASK_DECLINE_FAILED,
1576
+ expect.objectContaining({
1577
+ taskId: 'int1',
1578
+ trackingId: 'aqm-track-1',
1579
+ failureReason: 'TASK_NOT_FOUND',
1580
+ }),
1581
+ ['operational', 'behavioral']
1582
+ );
1583
+ expect(trackEventSpy).toHaveBeenCalledWith(
1584
+ METRIC_EVENT_NAMES.TASK_DECLINE_FAILED,
1585
+ expect.objectContaining({
1586
+ taskId: 'int1',
1587
+ trackingId: 'aqm-track-1',
1588
+ failureReason: 'TASK_NOT_FOUND',
1589
+ }),
1590
+ ['operational', 'behavioral']
1591
+ );
1592
+ dummyContact.cancelTask = jest.fn().mockResolvedValue(undefined);
1593
+ });
1594
+
1595
+ it('clears wxAppAnswerPending when declining outdial', async () => {
1596
+ const mockSvc = {
1597
+ answerCall: jest.fn().mockResolvedValue(undefined),
1598
+ getCallDetails: jest.fn().mockResolvedValue({muted: false}),
1599
+ };
1600
+ const taskData = makeWxAppOutdialTaskData();
1601
+ const voice = new Voice(dummyContact, taskData, {
1602
+ enableWxBetterTogether: true,
1603
+ answerCallOnWebexService: mockSvc as any,
1604
+ });
1605
+ voice.stateMachineService?.send({type: TaskEvent.TASK_INCOMING, taskData});
1606
+
1607
+ await voice.accept();
1608
+ expect(voice.uiControlConfig.wxAppAnswerPending).toBe(true);
1609
+ expect(voice.uiControlConfig.wxAppAcceptInFlight).toBe(false);
1610
+
1611
+ await voice.decline();
1612
+
1613
+ expect(voice.uiControlConfig.wxAppAnswerPending).toBe(false);
1614
+ expect(dummyContact.cancelTask).toHaveBeenCalledWith({interactionId: 'int1'});
1615
+ });
1616
+
1617
+ it('throws unsupported for non-outdial voice tasks', async () => {
1618
+ const taskData = createBaseData();
1619
+ const voice = new Voice(dummyContact, taskData, {enableWxBetterTogether: true});
1620
+
1621
+ await expect(voice.decline()).rejects.toThrow('Unsupported operation: decline');
1622
+ expect(dummyContact.cancelTask).not.toHaveBeenCalled();
1623
+ });
1624
+
1625
+ it('throws unsupported when enableWxBetterTogether is false', async () => {
1626
+ const taskData = makeWxAppOutdialTaskData();
1627
+ const voice = new Voice(dummyContact, taskData, {enableWxBetterTogether: false});
1628
+
1629
+ await expect(voice.decline()).rejects.toThrow('Unsupported operation: decline');
1630
+ expect(dummyContact.cancelTask).not.toHaveBeenCalled();
1631
+ });
1632
+ });
1633
+
1634
+ describe('unified task API routing (WXCC-6026)', () => {
1635
+ const wxAppParticipant = {
1636
+ deviceType: 'wxApp',
1637
+ deviceId: 'device-id-1',
1638
+ deviceCallId: 'call-id-1',
1639
+ };
1640
+
1641
+ it('accept() routes wxApp inbound offer to telephony answerCall', async () => {
1642
+ const mockSvc = {
1643
+ answerCall: jest.fn().mockResolvedValue(undefined),
1644
+ getCallDetails: jest.fn().mockResolvedValue({muted: false}),
1645
+ };
1646
+ const taskData = createBaseData({
1647
+ agentId: 'agent-1',
1648
+ interaction: {
1649
+ participants: {
1650
+ 'agent-1': {id: 'agent-1', ...wxAppParticipant},
1651
+ },
1652
+ } as any,
1653
+ });
1654
+ const voice = new Voice(dummyContact, taskData, {
1655
+ enableWxBetterTogether: true,
1656
+ answerCallOnWebexService: mockSvc as any,
1657
+ });
1658
+ voice.stateMachineService?.send({type: TaskEvent.TASK_INCOMING, taskData});
1659
+
1660
+ await voice.accept();
1661
+
1662
+ expect(mockSvc.answerCall).toHaveBeenCalledWith({
1663
+ callId: 'call-id-1',
1664
+ endpointId: 'device-id-1',
1665
+ lineOwnerId: undefined,
1666
+ });
1667
+ });
1668
+
1669
+ it('decline() routes wxApp inbound offer to telephony rejectCall', async () => {
1670
+ const mockSvc = {
1671
+ rejectCall: jest.fn().mockResolvedValue(undefined),
1672
+ };
1673
+ const taskData = createBaseData({
1674
+ agentId: 'agent-1',
1675
+ interaction: {
1676
+ participants: {
1677
+ 'agent-1': {id: 'agent-1', ...wxAppParticipant},
1678
+ },
1679
+ } as any,
1680
+ });
1681
+ const voice = new Voice(dummyContact, taskData, {
1682
+ enableWxBetterTogether: true,
1683
+ answerCallOnWebexService: mockSvc as any,
1684
+ });
1685
+ voice.stateMachineService?.send({type: TaskEvent.TASK_INCOMING, taskData});
1686
+
1687
+ await voice.decline();
1688
+
1689
+ expect(mockSvc.rejectCall).toHaveBeenCalledWith({
1690
+ callId: 'call-id-1',
1691
+ lineOwnerId: undefined,
1692
+ });
1693
+ expect(dummyContact.cancelTask).not.toHaveBeenCalled();
1694
+ });
1695
+
1696
+ it('toggleMute() routes wxApp engaged call to telephony muteCall', async () => {
1697
+ const mockSvc = {
1698
+ muteCall: jest.fn().mockResolvedValue(undefined),
1699
+ };
1700
+ const taskData = createBaseData({
1701
+ agentId: 'agent-1',
1702
+ interaction: {
1703
+ participants: {
1704
+ 'agent-1': {id: 'agent-1', ...wxAppParticipant},
1705
+ },
1706
+ } as any,
1707
+ });
1708
+ const voice = new Voice(dummyContact, taskData, {
1709
+ enableWxBetterTogether: true,
1710
+ answerCallOnWebexService: mockSvc as any,
1711
+ });
1712
+ primeConnectedState(voice, taskData);
1713
+
1714
+ await voice.toggleMute({muted: true});
1715
+
1716
+ expect(mockSvc.muteCall).toHaveBeenCalledWith({callId: 'call-id-1', lineOwnerId: undefined});
1717
+ });
1718
+
1719
+ it('serializes concurrent no-arg toggleMute() so mute then unmute', async () => {
1720
+ let resolveMute: () => void;
1721
+ const muteGate = new Promise<void>((resolve) => {
1722
+ resolveMute = resolve;
1723
+ });
1724
+ const mockSvc = {
1725
+ muteCall: jest.fn().mockImplementation(() => muteGate),
1726
+ unmuteCall: jest.fn().mockResolvedValue(undefined),
1727
+ };
1728
+ const taskData = createBaseData({
1729
+ agentId: 'agent-1',
1730
+ interaction: {
1731
+ participants: {
1732
+ 'agent-1': {id: 'agent-1', ...wxAppParticipant},
1733
+ },
1734
+ } as any,
1735
+ });
1736
+ const voice = new Voice(dummyContact, taskData, {
1737
+ enableWxBetterTogether: true,
1738
+ answerCallOnWebexService: mockSvc as any,
1739
+ });
1740
+ primeConnectedState(voice, taskData);
1741
+ expect(voice.getWxAppMuted()).toBe(false);
1742
+
1743
+ const firstToggle = voice.toggleMute();
1744
+ const secondToggle = voice.toggleMute();
1745
+
1746
+ await new Promise<void>((resolve) => {
1747
+ setImmediate(resolve);
1748
+ });
1749
+
1750
+ expect(mockSvc.muteCall).toHaveBeenCalledTimes(1);
1751
+ expect(mockSvc.unmuteCall).not.toHaveBeenCalled();
1752
+
1753
+ resolveMute!();
1754
+ await Promise.all([firstToggle, secondToggle]);
1755
+
1756
+ expect(mockSvc.muteCall).toHaveBeenCalledTimes(1);
1757
+ expect(mockSvc.unmuteCall).toHaveBeenCalledTimes(1);
1758
+ expect(voice.getWxAppMuted()).toBe(false);
1759
+ });
1760
+
1761
+ it('queues three concurrent no-arg toggleMute() calls in order', async () => {
1762
+ let resolveFirstMute: () => void;
1763
+ const firstMuteGate = new Promise<void>((resolve) => {
1764
+ resolveFirstMute = resolve;
1765
+ });
1766
+ const mockSvc = {
1767
+ muteCall: jest
1768
+ .fn()
1769
+ .mockImplementationOnce(() => firstMuteGate)
1770
+ .mockResolvedValue(undefined),
1771
+ unmuteCall: jest.fn().mockResolvedValue(undefined),
1772
+ };
1773
+ const taskData = createBaseData({
1774
+ agentId: 'agent-1',
1775
+ interaction: {
1776
+ participants: {
1777
+ 'agent-1': {id: 'agent-1', ...wxAppParticipant},
1778
+ },
1779
+ } as any,
1780
+ });
1781
+ const voice = new Voice(dummyContact, taskData, {
1782
+ enableWxBetterTogether: true,
1783
+ answerCallOnWebexService: mockSvc as any,
1784
+ });
1785
+ primeConnectedState(voice, taskData);
1786
+ expect(voice.getWxAppMuted()).toBe(false);
1787
+
1788
+ const firstToggle = voice.toggleMute();
1789
+ const secondToggle = voice.toggleMute();
1790
+ const thirdToggle = voice.toggleMute();
1791
+
1792
+ await new Promise<void>((resolve) => {
1793
+ setImmediate(resolve);
1794
+ });
1795
+
1796
+ expect(mockSvc.muteCall).toHaveBeenCalledTimes(1);
1797
+ expect(mockSvc.unmuteCall).not.toHaveBeenCalled();
1798
+
1799
+ resolveFirstMute!();
1800
+ await Promise.all([firstToggle, secondToggle, thirdToggle]);
1801
+
1802
+ expect(mockSvc.muteCall).toHaveBeenCalledTimes(2);
1803
+ expect(mockSvc.unmuteCall).toHaveBeenCalledTimes(1);
1804
+ expect(voice.getWxAppMuted()).toBe(true);
1805
+ });
1806
+
1807
+ it('records duration_ms on each serialized toggleMute success metric', async () => {
1808
+ let resolveFirstMute: () => void;
1809
+ const firstMuteGate = new Promise<void>((resolve) => {
1810
+ resolveFirstMute = resolve;
1811
+ });
1812
+ const mockSvc = {
1813
+ muteCall: jest.fn().mockImplementationOnce(() => firstMuteGate),
1814
+ unmuteCall: jest.fn().mockResolvedValue(undefined),
1815
+ };
1816
+ const taskData = createBaseData({
1817
+ agentId: 'agent-1',
1818
+ interaction: {
1819
+ participants: {
1820
+ 'agent-1': {id: 'agent-1', ...wxAppParticipant},
1821
+ },
1822
+ } as any,
1823
+ });
1824
+ const voice = new Voice(dummyContact, taskData, {
1825
+ enableWxBetterTogether: true,
1826
+ answerCallOnWebexService: mockSvc as any,
1827
+ });
1828
+ primeConnectedState(voice, taskData);
1829
+ const metricsManager = MetricsManager.getInstance();
1830
+ const trackEventSpy = jest.spyOn(metricsManager, 'trackEvent');
1831
+
1832
+ const firstToggle = voice.toggleMute();
1833
+ const secondToggle = voice.toggleMute();
1834
+
1835
+ resolveFirstMute!();
1836
+ await Promise.all([firstToggle, secondToggle]);
1837
+
1838
+ const successCalls = trackEventSpy.mock.calls.filter(
1839
+ ([eventName]) => eventName === METRIC_EVENT_NAMES.WXAPP_TASK_MUTE_SUCCESS
1840
+ );
1841
+ expect(successCalls).toHaveLength(2);
1842
+ successCalls.forEach(([, payload]) => {
1843
+ expect(payload).toEqual(
1844
+ expect.objectContaining({
1845
+ duration_ms: expect.any(Number),
1846
+ })
1847
+ );
1848
+ });
1849
+ });
1850
+
1851
+ it('continues queued toggleMute after predecessor failure', async () => {
1852
+ const mockSvc = {
1853
+ muteCall: jest.fn().mockRejectedValue(new Error('mute failed')),
1854
+ unmuteCall: jest.fn().mockResolvedValue(undefined),
1855
+ };
1856
+ const taskData = createBaseData({
1857
+ agentId: 'agent-1',
1858
+ interaction: {
1859
+ participants: {
1860
+ 'agent-1': {id: 'agent-1', ...wxAppParticipant},
1861
+ },
1862
+ } as any,
1863
+ });
1864
+ const voice = new Voice(dummyContact, taskData, {
1865
+ enableWxBetterTogether: true,
1866
+ answerCallOnWebexService: mockSvc as any,
1867
+ });
1868
+ primeConnectedState(voice, taskData);
1869
+
1870
+ const firstToggle = voice.toggleMute({muted: true});
1871
+ const secondToggle = voice.toggleMute({muted: false});
1872
+
1873
+ await expect(firstToggle).rejects.toThrow('mute failed');
1874
+ await secondToggle;
1875
+
1876
+ expect(mockSvc.muteCall).toHaveBeenCalledTimes(1);
1877
+ expect(mockSvc.unmuteCall).toHaveBeenCalledTimes(1);
1878
+ });
1879
+
1880
+ it('transmitDtmf() routes wxApp engaged call to telephony transmitDtmf', async () => {
1881
+ const mockSvc = {
1882
+ transmitDtmf: jest.fn().mockResolvedValue(undefined),
1883
+ };
1884
+ const taskData = createBaseData({
1885
+ agentId: 'agent-1',
1886
+ interaction: {
1887
+ participants: {
1888
+ 'agent-1': {id: 'agent-1', ...wxAppParticipant},
1889
+ },
1890
+ } as any,
1891
+ });
1892
+ const voice = new Voice(dummyContact, taskData, {
1893
+ enableWxBetterTogether: true,
1894
+ answerCallOnWebexService: mockSvc as any,
1895
+ });
1896
+ primeConnectedState(voice, taskData);
1897
+
1898
+ await voice.transmitDtmf({dtmf: '5'});
1899
+
1900
+ expect(mockSvc.transmitDtmf).toHaveBeenCalledWith({
1901
+ callId: 'call-id-1',
1902
+ dtmf: '5',
1903
+ lineOwnerId: undefined,
1904
+ });
1905
+ });
1906
+
1907
+ it('serializes concurrent transmitDtmf() so each digit completes before the next', async () => {
1908
+ let resolveFirst: () => void;
1909
+ const firstGate = new Promise<void>((resolve) => {
1910
+ resolveFirst = resolve;
1911
+ });
1912
+ const mockSvc = {
1913
+ transmitDtmf: jest
1914
+ .fn()
1915
+ .mockImplementationOnce(() => firstGate)
1916
+ .mockResolvedValueOnce(undefined),
1917
+ };
1918
+ const taskData = createBaseData({
1919
+ agentId: 'agent-1',
1920
+ interaction: {
1921
+ participants: {
1922
+ 'agent-1': {id: 'agent-1', ...wxAppParticipant},
1923
+ },
1924
+ } as any,
1925
+ });
1926
+ const voice = new Voice(dummyContact, taskData, {
1927
+ enableWxBetterTogether: true,
1928
+ answerCallOnWebexService: mockSvc as any,
1929
+ });
1930
+ primeConnectedState(voice, taskData);
1931
+
1932
+ const firstDtmf = voice.transmitDtmf({dtmf: '1'});
1933
+ const secondDtmf = voice.transmitDtmf({dtmf: '2'});
1934
+
1935
+ await new Promise<void>((resolve) => {
1936
+ setImmediate(resolve);
1937
+ });
1938
+
1939
+ expect(mockSvc.transmitDtmf).toHaveBeenCalledTimes(1);
1940
+ expect(mockSvc.transmitDtmf).toHaveBeenCalledWith({
1941
+ callId: 'call-id-1',
1942
+ dtmf: '1',
1943
+ lineOwnerId: undefined,
1944
+ });
1945
+
1946
+ resolveFirst!();
1947
+ await Promise.all([firstDtmf, secondDtmf]);
1948
+
1949
+ expect(mockSvc.transmitDtmf).toHaveBeenCalledTimes(2);
1950
+ expect(mockSvc.transmitDtmf).toHaveBeenNthCalledWith(2, {
1951
+ callId: 'call-id-1',
1952
+ dtmf: '2',
1953
+ lineOwnerId: undefined,
1954
+ });
1955
+ });
1956
+
1957
+ it('queues three concurrent transmitDtmf() calls in order', async () => {
1958
+ let resolveFirst: () => void;
1959
+ const firstGate = new Promise<void>((resolve) => {
1960
+ resolveFirst = resolve;
1961
+ });
1962
+ const mockSvc = {
1963
+ transmitDtmf: jest
1964
+ .fn()
1965
+ .mockImplementationOnce(() => firstGate)
1966
+ .mockResolvedValue(undefined),
1967
+ };
1968
+ const taskData = createBaseData({
1969
+ agentId: 'agent-1',
1970
+ interaction: {
1971
+ participants: {
1972
+ 'agent-1': {id: 'agent-1', ...wxAppParticipant},
1973
+ },
1974
+ } as any,
1975
+ });
1976
+ const voice = new Voice(dummyContact, taskData, {
1977
+ enableWxBetterTogether: true,
1978
+ answerCallOnWebexService: mockSvc as any,
1979
+ });
1980
+ primeConnectedState(voice, taskData);
1981
+
1982
+ const firstDtmf = voice.transmitDtmf({dtmf: '1'});
1983
+ const secondDtmf = voice.transmitDtmf({dtmf: '2'});
1984
+ const thirdDtmf = voice.transmitDtmf({dtmf: '3'});
1985
+
1986
+ await new Promise<void>((resolve) => {
1987
+ setImmediate(resolve);
1988
+ });
1989
+
1990
+ expect(mockSvc.transmitDtmf).toHaveBeenCalledTimes(1);
1991
+ expect(mockSvc.transmitDtmf).toHaveBeenCalledWith({
1992
+ callId: 'call-id-1',
1993
+ dtmf: '1',
1994
+ lineOwnerId: undefined,
1995
+ });
1996
+
1997
+ resolveFirst!();
1998
+ await Promise.all([firstDtmf, secondDtmf, thirdDtmf]);
1999
+
2000
+ expect(mockSvc.transmitDtmf).toHaveBeenCalledTimes(3);
2001
+ expect(mockSvc.transmitDtmf).toHaveBeenNthCalledWith(2, {
2002
+ callId: 'call-id-1',
2003
+ dtmf: '2',
2004
+ lineOwnerId: undefined,
2005
+ });
2006
+ expect(mockSvc.transmitDtmf).toHaveBeenNthCalledWith(3, {
2007
+ callId: 'call-id-1',
2008
+ dtmf: '3',
2009
+ lineOwnerId: undefined,
2010
+ });
2011
+ });
2012
+
2013
+ it('records duration_ms on each serialized transmitDtmf success metric', async () => {
2014
+ let resolveFirst: () => void;
2015
+ const firstGate = new Promise<void>((resolve) => {
2016
+ resolveFirst = resolve;
2017
+ });
2018
+ const mockSvc = {
2019
+ transmitDtmf: jest
2020
+ .fn()
2021
+ .mockImplementationOnce(() => firstGate)
2022
+ .mockResolvedValueOnce(undefined),
2023
+ };
2024
+ const taskData = createBaseData({
2025
+ agentId: 'agent-1',
2026
+ interaction: {
2027
+ participants: {
2028
+ 'agent-1': {id: 'agent-1', ...wxAppParticipant},
2029
+ },
2030
+ } as any,
2031
+ });
2032
+ const voice = new Voice(dummyContact, taskData, {
2033
+ enableWxBetterTogether: true,
2034
+ answerCallOnWebexService: mockSvc as any,
2035
+ });
2036
+ primeConnectedState(voice, taskData);
2037
+ const metricsManager = MetricsManager.getInstance();
2038
+ const trackEventSpy = jest.spyOn(metricsManager, 'trackEvent');
2039
+
2040
+ const firstDtmf = voice.transmitDtmf({dtmf: '1'});
2041
+ const secondDtmf = voice.transmitDtmf({dtmf: '2'});
2042
+
2043
+ resolveFirst!();
2044
+ await Promise.all([firstDtmf, secondDtmf]);
2045
+
2046
+ const successCalls = trackEventSpy.mock.calls.filter(
2047
+ ([eventName]) => eventName === METRIC_EVENT_NAMES.WXAPP_TASK_DTMF_SUCCESS
2048
+ );
2049
+ expect(successCalls).toHaveLength(2);
2050
+ successCalls.forEach(([, payload]) => {
2051
+ expect(payload).toEqual(
2052
+ expect.objectContaining({
2053
+ duration_ms: expect.any(Number),
2054
+ })
2055
+ );
2056
+ });
2057
+
2058
+ trackEventSpy.mockRestore();
2059
+ });
2060
+ });
649
2061
  });