@webex/contact-center 3.12.0-next.8 → 3.12.0-next.81

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 (205) hide show
  1. package/AGENTS.md +438 -0
  2. package/ai-docs/README.md +131 -0
  3. package/ai-docs/RULES.md +455 -0
  4. package/ai-docs/patterns/event-driven-patterns.md +485 -0
  5. package/ai-docs/patterns/testing-patterns.md +480 -0
  6. package/ai-docs/patterns/typescript-patterns.md +365 -0
  7. package/ai-docs/templates/README.md +102 -0
  8. package/ai-docs/templates/documentation/create-agents-md.md +240 -0
  9. package/ai-docs/templates/documentation/create-architecture-md.md +295 -0
  10. package/ai-docs/templates/existing-service/bug-fix.md +254 -0
  11. package/ai-docs/templates/existing-service/feature-enhancement.md +450 -0
  12. package/ai-docs/templates/new-method/00-master.md +80 -0
  13. package/ai-docs/templates/new-method/01-requirements.md +232 -0
  14. package/ai-docs/templates/new-method/02-implementation.md +295 -0
  15. package/ai-docs/templates/new-method/03-tests.md +201 -0
  16. package/ai-docs/templates/new-method/04-validation.md +141 -0
  17. package/ai-docs/templates/new-service/00-master.md +109 -0
  18. package/ai-docs/templates/new-service/01-pre-questions.md +159 -0
  19. package/ai-docs/templates/new-service/02-code-generation.md +346 -0
  20. package/ai-docs/templates/new-service/03-integration.md +178 -0
  21. package/ai-docs/templates/new-service/04-test-generation.md +205 -0
  22. package/ai-docs/templates/new-service/05-validation.md +145 -0
  23. package/dist/cc.js +265 -29
  24. package/dist/cc.js.map +1 -1
  25. package/dist/config.js +6 -0
  26. package/dist/config.js.map +1 -1
  27. package/dist/constants.js +16 -1
  28. package/dist/constants.js.map +1 -1
  29. package/dist/index.js +20 -5
  30. package/dist/index.js.map +1 -1
  31. package/dist/metrics/behavioral-events.js +101 -0
  32. package/dist/metrics/behavioral-events.js.map +1 -1
  33. package/dist/metrics/constants.js +23 -4
  34. package/dist/metrics/constants.js.map +1 -1
  35. package/dist/services/ApiAiAssistant.js +74 -3
  36. package/dist/services/ApiAiAssistant.js.map +1 -1
  37. package/dist/services/UserPreference.js +427 -0
  38. package/dist/services/UserPreference.js.map +1 -0
  39. package/dist/services/config/Util.js +3 -3
  40. package/dist/services/config/Util.js.map +1 -1
  41. package/dist/services/config/constants.js +23 -2
  42. package/dist/services/config/constants.js.map +1 -1
  43. package/dist/services/config/types.js +49 -9
  44. package/dist/services/config/types.js.map +1 -1
  45. package/dist/services/core/Err.js.map +1 -1
  46. package/dist/services/core/Utils.js +107 -32
  47. package/dist/services/core/Utils.js.map +1 -1
  48. package/dist/services/core/websocket/WebSocketManager.js +2 -1
  49. package/dist/services/core/websocket/WebSocketManager.js.map +1 -1
  50. package/dist/services/core/websocket/types.js.map +1 -1
  51. package/dist/services/index.js +1 -1
  52. package/dist/services/index.js.map +1 -1
  53. package/dist/services/task/Task.js +688 -0
  54. package/dist/services/task/Task.js.map +1 -0
  55. package/dist/services/task/TaskFactory.js +45 -0
  56. package/dist/services/task/TaskFactory.js.map +1 -0
  57. package/dist/services/task/TaskManager.js +725 -526
  58. package/dist/services/task/TaskManager.js.map +1 -1
  59. package/dist/services/task/TaskUtils.js +162 -26
  60. package/dist/services/task/TaskUtils.js.map +1 -1
  61. package/dist/services/task/constants.js +9 -2
  62. package/dist/services/task/constants.js.map +1 -1
  63. package/dist/services/task/dialer.js +78 -0
  64. package/dist/services/task/dialer.js.map +1 -1
  65. package/dist/services/task/digital/Digital.js +77 -0
  66. package/dist/services/task/digital/Digital.js.map +1 -0
  67. package/dist/services/task/state-machine/TaskStateMachine.js +837 -0
  68. package/dist/services/task/state-machine/TaskStateMachine.js.map +1 -0
  69. package/dist/services/task/state-machine/actions.js +543 -0
  70. package/dist/services/task/state-machine/actions.js.map +1 -0
  71. package/dist/services/task/state-machine/constants.js +161 -0
  72. package/dist/services/task/state-machine/constants.js.map +1 -0
  73. package/dist/services/task/state-machine/guards.js +340 -0
  74. package/dist/services/task/state-machine/guards.js.map +1 -0
  75. package/dist/services/task/state-machine/index.js +53 -0
  76. package/dist/services/task/state-machine/index.js.map +1 -0
  77. package/dist/services/task/state-machine/types.js +54 -0
  78. package/dist/services/task/state-machine/types.js.map +1 -0
  79. package/dist/services/task/state-machine/uiControlsComputer.js +553 -0
  80. package/dist/services/task/state-machine/uiControlsComputer.js.map +1 -0
  81. package/dist/services/task/taskDataNormalizer.js +99 -0
  82. package/dist/services/task/taskDataNormalizer.js.map +1 -0
  83. package/dist/services/task/types.js +212 -4
  84. package/dist/services/task/types.js.map +1 -1
  85. package/dist/services/task/voice/Voice.js +1042 -0
  86. package/dist/services/task/voice/Voice.js.map +1 -0
  87. package/dist/services/task/voice/WebRTC.js +149 -0
  88. package/dist/services/task/voice/WebRTC.js.map +1 -0
  89. package/dist/types/cc.d.ts +94 -1
  90. package/dist/types/config.d.ts +6 -0
  91. package/dist/types/constants.d.ts +16 -1
  92. package/dist/types/index.d.ts +21 -6
  93. package/dist/types/metrics/constants.d.ts +19 -1
  94. package/dist/types/services/ApiAiAssistant.d.ts +11 -3
  95. package/dist/types/services/UserPreference.d.ts +118 -0
  96. package/dist/types/services/config/constants.d.ts +21 -0
  97. package/dist/types/services/config/types.d.ts +171 -10
  98. package/dist/types/services/core/Err.d.ts +4 -0
  99. package/dist/types/services/core/Utils.d.ts +33 -13
  100. package/dist/types/services/core/websocket/WebSocketManager.d.ts +1 -0
  101. package/dist/types/services/core/websocket/types.d.ts +1 -1
  102. package/dist/types/services/index.d.ts +1 -1
  103. package/dist/types/services/task/Task.d.ts +157 -0
  104. package/dist/types/services/task/TaskFactory.d.ts +12 -0
  105. package/dist/types/services/task/TaskUtils.d.ts +46 -2
  106. package/dist/types/services/task/constants.d.ts +7 -0
  107. package/dist/types/services/task/dialer.d.ts +30 -0
  108. package/dist/types/services/task/digital/Digital.d.ts +22 -0
  109. package/dist/types/services/task/state-machine/TaskStateMachine.d.ts +1144 -0
  110. package/dist/types/services/task/state-machine/actions.d.ts +10 -0
  111. package/dist/types/services/task/state-machine/constants.d.ts +107 -0
  112. package/dist/types/services/task/state-machine/guards.d.ts +90 -0
  113. package/dist/types/services/task/state-machine/index.d.ts +13 -0
  114. package/dist/types/services/task/state-machine/types.d.ts +267 -0
  115. package/dist/types/services/task/state-machine/uiControlsComputer.d.ts +9 -0
  116. package/dist/types/services/task/taskDataNormalizer.d.ts +10 -0
  117. package/dist/types/services/task/types.d.ts +603 -66
  118. package/dist/types/services/task/voice/Voice.d.ts +184 -0
  119. package/dist/types/services/task/voice/WebRTC.d.ts +53 -0
  120. package/dist/types/types.d.ts +94 -0
  121. package/dist/types/webex.d.ts +1 -0
  122. package/dist/types.js +85 -0
  123. package/dist/types.js.map +1 -1
  124. package/dist/webex.js +14 -2
  125. package/dist/webex.js.map +1 -1
  126. package/package.json +15 -12
  127. package/src/cc.ts +329 -30
  128. package/src/config.ts +6 -0
  129. package/src/constants.ts +16 -1
  130. package/src/index.ts +23 -5
  131. package/src/metrics/ai-docs/AGENTS.md +348 -0
  132. package/src/metrics/ai-docs/ARCHITECTURE.md +336 -0
  133. package/src/metrics/behavioral-events.ts +106 -0
  134. package/src/metrics/constants.ts +23 -4
  135. package/src/services/ApiAiAssistant.ts +104 -3
  136. package/src/services/UserPreference.ts +509 -0
  137. package/src/services/agent/ai-docs/AGENTS.md +238 -0
  138. package/src/services/agent/ai-docs/ARCHITECTURE.md +302 -0
  139. package/src/services/ai-docs/AGENTS.md +384 -0
  140. package/src/services/config/Util.ts +3 -3
  141. package/src/services/config/ai-docs/AGENTS.md +253 -0
  142. package/src/services/config/ai-docs/ARCHITECTURE.md +424 -0
  143. package/src/services/config/constants.ts +25 -1
  144. package/src/services/config/types.ts +174 -11
  145. package/src/services/core/Err.ts +2 -0
  146. package/src/services/core/Utils.ts +123 -37
  147. package/src/services/core/ai-docs/AGENTS.md +379 -0
  148. package/src/services/core/ai-docs/ARCHITECTURE.md +696 -0
  149. package/src/services/core/websocket/WebSocketManager.ts +2 -0
  150. package/src/services/core/websocket/types.ts +1 -1
  151. package/src/services/index.ts +1 -1
  152. package/src/services/task/Task.ts +837 -0
  153. package/src/services/task/TaskFactory.ts +55 -0
  154. package/src/services/task/TaskManager.ts +738 -613
  155. package/src/services/task/TaskUtils.ts +205 -25
  156. package/src/services/task/ai-docs/AGENTS.md +455 -0
  157. package/src/services/task/ai-docs/ARCHITECTURE.md +585 -0
  158. package/src/services/task/constants.ts +7 -0
  159. package/src/services/task/dialer.ts +80 -0
  160. package/src/services/task/digital/Digital.ts +95 -0
  161. package/src/services/task/state-machine/TaskStateMachine.ts +1077 -0
  162. package/src/services/task/state-machine/actions.ts +685 -0
  163. package/src/services/task/state-machine/ai-docs/AGENTS.md +495 -0
  164. package/src/services/task/state-machine/ai-docs/ARCHITECTURE.md +1135 -0
  165. package/src/services/task/state-machine/constants.ts +172 -0
  166. package/src/services/task/state-machine/guards.ts +406 -0
  167. package/src/services/task/state-machine/index.ts +28 -0
  168. package/src/services/task/state-machine/types.ts +241 -0
  169. package/src/services/task/state-machine/uiControlsComputer.ts +867 -0
  170. package/src/services/task/taskDataNormalizer.ts +137 -0
  171. package/src/services/task/types.ts +710 -71
  172. package/src/services/task/voice/Voice.ts +1267 -0
  173. package/src/services/task/voice/WebRTC.ts +187 -0
  174. package/src/types.ts +122 -2
  175. package/src/utils/AGENTS.md +276 -0
  176. package/src/webex.js +2 -0
  177. package/test/unit/spec/cc.ts +343 -23
  178. package/test/unit/spec/logger-proxy.ts +70 -0
  179. package/test/unit/spec/services/ApiAiAssistant.ts +122 -17
  180. package/test/unit/spec/services/UserPreference.ts +401 -0
  181. package/test/unit/spec/services/WebCallingService.ts +7 -1
  182. package/test/unit/spec/services/config/index.ts +30 -30
  183. package/test/unit/spec/services/core/Utils.ts +425 -8
  184. package/test/unit/spec/services/core/websocket/WebSocketManager.ts +66 -40
  185. package/test/unit/spec/services/task/AutoWrapup.ts +63 -0
  186. package/test/unit/spec/services/task/Task.ts +477 -0
  187. package/test/unit/spec/services/task/TaskFactory.ts +62 -0
  188. package/test/unit/spec/services/task/TaskManager.ts +832 -1702
  189. package/test/unit/spec/services/task/TaskUtils.ts +206 -0
  190. package/test/unit/spec/services/task/dialer.ts +190 -0
  191. package/test/unit/spec/services/task/digital/Digital.ts +105 -0
  192. package/test/unit/spec/services/task/state-machine/TaskStateMachine.ts +1825 -0
  193. package/test/unit/spec/services/task/state-machine/guards.ts +479 -0
  194. package/test/unit/spec/services/task/state-machine/types.ts +18 -0
  195. package/test/unit/spec/services/task/state-machine/uiControlsComputer.ts +2020 -0
  196. package/test/unit/spec/services/task/taskTestUtils.ts +87 -0
  197. package/test/unit/spec/services/task/voice/Voice.ts +631 -0
  198. package/test/unit/spec/services/task/voice/WebRTC.ts +235 -0
  199. package/umd/contact-center.min.js +2 -2
  200. package/umd/contact-center.min.js.map +1 -1
  201. package/dist/services/task/index.js +0 -1525
  202. package/dist/services/task/index.js.map +0 -1
  203. package/dist/types/services/task/index.d.ts +0 -650
  204. package/src/services/task/index.ts +0 -1801
  205. package/test/unit/spec/services/task/index.ts +0 -2184
@@ -0,0 +1,631 @@
1
+ import Voice from '../../../../../../src/services/task/voice/Voice';
2
+ import {
3
+ TaskData,
4
+ CONSULT_TRANSFER_DESTINATION_TYPE,
5
+ } from '../../../../../../src/services/task/types';
6
+ import {CC_EVENTS} from '../../../../../../src/services/config/types';
7
+ import {TaskEvent, TaskState} from '../../../../../../src/services/task/state-machine';
8
+ import {computeUIControls} from '../../../../../../src/services/task/state-machine/uiControlsComputer';
9
+ import * as Utils from '../../../../../../src/services/core/Utils';
10
+ import {createTaskData} from '../taskTestUtils';
11
+
12
+ jest.mock('../../../../../../src/services/core/WebexRequest', () => ({
13
+ __esModule: true,
14
+ default: {
15
+ getInstance: () => ({uploadLogs: jest.fn()}),
16
+ },
17
+ }));
18
+
19
+ jest.mock('../../../../../../src/services/core/Utils', () => {
20
+ const actual = jest.requireActual('../../../../../../src/services/core/Utils');
21
+ return {
22
+ __esModule: true,
23
+ ...actual,
24
+ // keep tests deterministic; avoid log upload side-effects on failures
25
+ getErrorDetails: (err: any) => ({error: err}),
26
+ };
27
+ });
28
+
29
+ const dummyContact = {
30
+ hold: jest.fn().mockResolvedValue('held'),
31
+ unHold: jest.fn().mockResolvedValue('resumed'),
32
+ pauseRecording: jest.fn().mockResolvedValue('paused'),
33
+ resumeRecording: jest.fn().mockResolvedValue('resumedRecording'),
34
+ consult: jest.fn().mockResolvedValue('consulted'),
35
+ consultConference: jest.fn().mockResolvedValue('conferenceStarted'),
36
+ consultTransfer: jest.fn().mockResolvedValue('consultTransferred'),
37
+ } as any;
38
+
39
+ const createBaseData = (overrides: Partial<TaskData> = {}): TaskData =>
40
+ createTaskData({
41
+ interactionId: 'int1',
42
+ mediaResourceId: 'media1',
43
+ interaction: {
44
+ ...(overrides.interaction || {}),
45
+ media: {
46
+ media1: {mediaResourceId: 'media1', isHold: false},
47
+ ...(overrides.interaction as any)?.media,
48
+ },
49
+ },
50
+ ...overrides,
51
+ });
52
+
53
+ const primeConnectedState = (voice: Voice, taskData: TaskData) => {
54
+ voice.stateMachineService?.send({type: TaskEvent.TASK_INCOMING, taskData});
55
+ voice.stateMachineService?.send({type: TaskEvent.ASSIGN, taskData});
56
+ };
57
+
58
+ const primeHeldState = (voice: Voice, taskData: TaskData) => {
59
+ primeConnectedState(voice, taskData);
60
+ voice.stateMachineService?.send({
61
+ type: TaskEvent.HOLD_INITIATED,
62
+ mediaResourceId: taskData.mediaResourceId,
63
+ });
64
+ voice.stateMachineService?.send({
65
+ type: TaskEvent.HOLD_SUCCESS,
66
+ mediaResourceId: taskData.mediaResourceId,
67
+ });
68
+ };
69
+
70
+ describe('Voice Task', () => {
71
+ beforeEach(() => {
72
+ jest.clearAllMocks();
73
+ });
74
+
75
+ describe('emitTaskOutdialFailed', () => {
76
+ it('emits the failure reason string instead of the Task object', () => {
77
+ const taskData = createBaseData({
78
+ interaction: {
79
+ outboundType: 'OUTDIAL',
80
+ } as any,
81
+ });
82
+ const voice = new Voice(dummyContact, taskData, {});
83
+ const emitSpy = jest.spyOn(voice, 'emit');
84
+
85
+ voice.sendStateMachineEvent({
86
+ type: TaskEvent.OUTBOUND_FAILED,
87
+ taskData,
88
+ reason: 'CUSTOMER_BUSY',
89
+ });
90
+
91
+ const outdialFailedCall = emitSpy.mock.calls.find(
92
+ (call) => call[0] === 'task:outdialFailed'
93
+ );
94
+ expect(outdialFailedCall).toBeDefined();
95
+ expect(outdialFailedCall![1]).toBe('CUSTOMER_BUSY');
96
+ });
97
+ });
98
+
99
+ it('hides end and endConsult when disabled', () => {
100
+ const voice = new Voice(dummyContact, createBaseData(), {
101
+ isEndTaskEnabled: false,
102
+ isEndConsultEnabled: false,
103
+ });
104
+ voice.updateTaskData(createBaseData());
105
+ expect(voice.uiControls.main.end.isVisible).toBe(false);
106
+ expect(voice.uiControls.main.endConsult.isVisible).toBe(false);
107
+ });
108
+
109
+ it('calls contact.hold when media is not held', async () => {
110
+ const taskData = createBaseData() as any;
111
+ const voice = new Voice(dummyContact, taskData, {});
112
+ primeConnectedState(voice, taskData);
113
+ await voice.holdResume();
114
+ expect(dummyContact.hold).toHaveBeenCalledWith({
115
+ interactionId: 'int1',
116
+ data: {mediaResourceId: 'media1'},
117
+ });
118
+ });
119
+
120
+ it('calls contact.unHold when media is held', async () => {
121
+ const heldData = createBaseData({
122
+ interaction: {
123
+ media: {media1: {mediaResourceId: 'media1', isHold: true}},
124
+ } as any,
125
+ }) as any;
126
+ const voice = new Voice(dummyContact, heldData, {});
127
+ primeHeldState(voice, heldData);
128
+ await voice.holdResume();
129
+ expect(dummyContact.unHold).toHaveBeenCalledWith({
130
+ interactionId: 'int1',
131
+ data: {mediaResourceId: 'media1'},
132
+ });
133
+ });
134
+
135
+ it('calls contact.unHold when state is held even if task data hold flag is stale', async () => {
136
+ const taskData = createBaseData() as any;
137
+ const voice = new Voice(dummyContact, taskData, {});
138
+ primeHeldState(voice, taskData);
139
+ voice.data.interaction.media.media1.isHold = false;
140
+
141
+ await voice.holdResume();
142
+
143
+ expect(dummyContact.unHold).toHaveBeenCalledWith({
144
+ interactionId: 'int1',
145
+ data: {mediaResourceId: 'media1'},
146
+ });
147
+ });
148
+
149
+ it('calls contact.unHold when state is conferencing and main media is held', async () => {
150
+ const heldConferenceData = createBaseData({
151
+ interaction: {
152
+ state: 'conference',
153
+ media: {media1: {mediaResourceId: 'media1', isHold: true}},
154
+ } as any,
155
+ }) as any;
156
+ const voice = new Voice(dummyContact, heldConferenceData, {});
157
+ primeConnectedState(voice, heldConferenceData);
158
+ voice.stateMachineService?.send({type: TaskEvent.CONFERENCE_START, taskData: heldConferenceData});
159
+ expect(voice.stateMachineService?.getSnapshot().value).toBe(TaskState.CONFERENCING);
160
+
161
+ await voice.holdResume();
162
+
163
+ expect(dummyContact.unHold).toHaveBeenCalledWith({
164
+ interactionId: 'int1',
165
+ data: {mediaResourceId: 'media1'},
166
+ });
167
+ });
168
+
169
+ it('uses the main media resource when stale consult media is left in task data', async () => {
170
+ const heldData = createBaseData({
171
+ mediaResourceId: 'main-media',
172
+ interactionId: 'main-int',
173
+ interaction: {
174
+ mainInteractionId: 'main-int',
175
+ media: {
176
+ 'main-int': {mediaResourceId: 'main-media', isHold: true},
177
+ 'consult-media': {mediaResourceId: 'consult-media', isHold: false, mType: 'consult'},
178
+ },
179
+ } as any,
180
+ }) as any;
181
+ const voice = new Voice(dummyContact, heldData, {});
182
+ primeHeldState(voice, heldData);
183
+ voice.data.mediaResourceId = 'consult-media';
184
+
185
+ await voice.holdResume();
186
+
187
+ expect(dummyContact.unHold).toHaveBeenCalledWith({
188
+ interactionId: 'main-int',
189
+ data: {mediaResourceId: 'main-media'},
190
+ });
191
+ expect(dummyContact.hold).not.toHaveBeenCalled();
192
+ });
193
+
194
+ it('pauseRecording() calls contact.pauseRecording', async () => {
195
+ const taskData = createBaseData();
196
+ const voice = new Voice(dummyContact, taskData, {
197
+ isEndTaskEnabled: true,
198
+ isEndConsultEnabled: true,
199
+ });
200
+ primeConnectedState(voice, taskData);
201
+ const res = await voice.pauseRecording();
202
+ expect(dummyContact.pauseRecording).toHaveBeenCalledWith({interactionId: 'int1'});
203
+ });
204
+
205
+ it('resumeRecording() with no payload defaults to autoResumed false', async () => {
206
+ const taskData = createBaseData();
207
+ const voice = new Voice(dummyContact, taskData, {
208
+ isEndTaskEnabled: true,
209
+ isEndConsultEnabled: true,
210
+ });
211
+ primeConnectedState(voice, taskData);
212
+ voice.stateMachineService?.send({type: TaskEvent.PAUSE_RECORDING});
213
+ const res = await voice.resumeRecording();
214
+ expect(dummyContact.resumeRecording).toHaveBeenCalledWith({
215
+ interactionId: 'int1',
216
+ data: {autoResumed: false},
217
+ });
218
+ });
219
+
220
+ it('consult() calls contact.consult with payload', async () => {
221
+ const taskData = createBaseData();
222
+ const voice = new Voice(dummyContact, taskData, {
223
+ isEndTaskEnabled: true,
224
+ isEndConsultEnabled: true,
225
+ });
226
+ primeConnectedState(voice, taskData);
227
+ const payload = {destination: 'agent1', destinationType: 'agent'} as any;
228
+ const res = await voice.consult(payload);
229
+ expect(dummyContact.consult).toHaveBeenCalledWith({
230
+ interactionId: 'int1',
231
+ data: payload,
232
+ });
233
+ });
234
+
235
+ describe('transfer()', () => {
236
+ it('calls contact.consultTransfer for consult transfer to agent', async () => {
237
+ const consultTransferMock = jest.fn().mockResolvedValue('consultedA');
238
+ const dataWithState = createBaseData({
239
+ interaction: {state: 'consulting'} as any,
240
+ });
241
+ const voice = new Voice(
242
+ {...dummyContact, consultTransfer: consultTransferMock},
243
+ dataWithState as any,
244
+ {isEndTaskEnabled: true, isEndConsultEnabled: true}
245
+ );
246
+
247
+ const result = await voice.transfer({
248
+ to: 'destB',
249
+ destinationType: 'agent',
250
+ });
251
+
252
+ expect(consultTransferMock).toHaveBeenCalledWith({
253
+ interactionId: 'int1',
254
+ data: {to: 'destB', destinationType: 'agent'},
255
+ });
256
+ });
257
+
258
+ it('throws if consult transfer to QUEUE but no destAgentId set', async () => {
259
+ const dataWithState = createBaseData({
260
+ destAgentId: undefined,
261
+ interaction: {state: 'consulting'} as any,
262
+ });
263
+ const voice = new Voice(dummyContact, dataWithState as any, {
264
+ isEndTaskEnabled: true,
265
+ isEndConsultEnabled: true,
266
+ });
267
+
268
+ await expect(
269
+ voice.transfer({
270
+ to: 'queue1',
271
+ destinationType: CONSULT_TRANSFER_DESTINATION_TYPE.QUEUE,
272
+ })
273
+ ).rejects.toThrow('No agent has accepted this queue consult yet');
274
+ });
275
+
276
+ it('uses data.destAgentId for queue consult transfer', async () => {
277
+ const consultTransferMock = jest.fn().mockResolvedValue('consultedQ');
278
+ const dataWithDest = createBaseData({
279
+ destAgentId: 'agentD',
280
+ interaction: {state: 'consulting'} as any,
281
+ });
282
+ const voice = new Voice(
283
+ {...dummyContact, consultTransfer: consultTransferMock},
284
+ dataWithDest as any,
285
+ {isEndTaskEnabled: true, isEndConsultEnabled: true}
286
+ );
287
+
288
+ const result = await voice.transfer({
289
+ to: 'queueX',
290
+ destinationType: CONSULT_TRANSFER_DESTINATION_TYPE.QUEUE,
291
+ });
292
+
293
+ expect(consultTransferMock).toHaveBeenCalledWith({
294
+ interactionId: 'int1',
295
+ data: {
296
+ to: 'agentD',
297
+ destinationType: CONSULT_TRANSFER_DESTINATION_TYPE.AGENT,
298
+ },
299
+ });
300
+ });
301
+
302
+ it('uses preserved consult destination from task data for queue consult transfer', async () => {
303
+ const consultTransferMock = jest.fn().mockResolvedValue('consultedQ');
304
+ const dataWithState = createBaseData({
305
+ destAgentId: undefined,
306
+ destinationType: undefined,
307
+ interaction: {state: 'consulting'} as any,
308
+ });
309
+ const voice = new Voice(
310
+ {...dummyContact, consultTransfer: consultTransferMock},
311
+ dataWithState as any,
312
+ {isEndTaskEnabled: true, isEndConsultEnabled: true}
313
+ );
314
+
315
+ primeConnectedState(voice, dataWithState);
316
+ voice.updateTaskData(
317
+ createBaseData({
318
+ destAgentId: 'agent-preserved',
319
+ interaction: {state: 'consulting'} as any,
320
+ }) as any
321
+ );
322
+
323
+ await voice.transfer({
324
+ to: 'queueX',
325
+ destinationType: CONSULT_TRANSFER_DESTINATION_TYPE.QUEUE,
326
+ });
327
+
328
+ expect(consultTransferMock).toHaveBeenCalledWith({
329
+ interactionId: 'int1',
330
+ data: {
331
+ to: 'agent-preserved',
332
+ destinationType: CONSULT_TRANSFER_DESTINATION_TYPE.AGENT,
333
+ },
334
+ });
335
+ });
336
+ });
337
+
338
+ describe('endConsult()', () => {
339
+ it('calls contact.consultEnd with correct payload', async () => {
340
+ const consultEndMock = jest.fn().mockResolvedValue('endedC');
341
+ const voice = new Voice({...dummyContact, consultEnd: consultEndMock}, createBaseData(), {
342
+ isEndTaskEnabled: true,
343
+ isEndConsultEnabled: true,
344
+ });
345
+ const payload = {isConsult: true, queueId: 'q1', taskId: 't1'};
346
+ const result = await voice.endConsult(payload);
347
+
348
+ expect(consultEndMock).toHaveBeenCalledWith({
349
+ interactionId: 'int1',
350
+ data: payload,
351
+ });
352
+ expect(result).toBe('endedC');
353
+ });
354
+
355
+ it('uses mainInteractionId when present for consultEnd request', async () => {
356
+ const consultEndMock = jest.fn().mockResolvedValue('endedC');
357
+ const taskData = createBaseData({
358
+ interactionId: 'child-int',
359
+ interaction: {
360
+ mainInteractionId: 'main-int',
361
+ } as any,
362
+ });
363
+ const voice = new Voice({...dummyContact, consultEnd: consultEndMock}, taskData, {
364
+ isEndTaskEnabled: true,
365
+ isEndConsultEnabled: true,
366
+ });
367
+
368
+ await voice.endConsult({isConsult: true} as any);
369
+
370
+ expect(consultEndMock).toHaveBeenCalledWith({
371
+ interactionId: 'main-int',
372
+ data: {isConsult: true},
373
+ });
374
+ });
375
+ });
376
+
377
+ describe('UI controls for AGENT_CONTACT_ASSIGNED', () => {
378
+ it('shows main controls and hides accept/decline on AGENT_CONTACT_ASSIGNED', () => {
379
+ const data: any = {...createBaseData(), type: CC_EVENTS.AGENT_CONTACT_ASSIGNED};
380
+ const voice = new Voice(dummyContact, data, {
381
+ isEndTaskEnabled: true,
382
+ isEndConsultEnabled: false,
383
+ });
384
+
385
+ voice.updateTaskData(data);
386
+ primeConnectedState(voice, data);
387
+
388
+ expect(voice.uiControls.main.accept.isVisible).toBe(false);
389
+ expect(voice.uiControls.main.decline.isVisible).toBe(false);
390
+ expect(voice.uiControls.main.hold.isVisible).toBe(true);
391
+ expect(voice.uiControls.main.transfer.isVisible).toBe(true);
392
+ expect(voice.uiControls.main.consult.isVisible).toBe(true);
393
+ expect(voice.uiControls.main.recording.isVisible).toBe(true);
394
+ expect(voice.uiControls.main.end.isVisible).toBe(true);
395
+ expect(voice.uiControls.main.endConsult.isVisible).toBe(false);
396
+ });
397
+ });
398
+
399
+ describe('state machine derived controls', () => {
400
+ it('keeps uiControls in sync with state machine context', () => {
401
+ const taskData = createBaseData();
402
+ const voice = new Voice(dummyContact, taskData, {});
403
+ const initialSnapshot = voice.stateMachineService?.getSnapshot();
404
+ const initialExpected = computeUIControls(
405
+ initialSnapshot?.value as TaskState,
406
+ initialSnapshot?.context as any,
407
+ voice.data
408
+ );
409
+ expect(voice.uiControls).toEqual(initialExpected);
410
+
411
+ voice.updateTaskData(taskData);
412
+ voice.stateMachineService?.send({type: TaskEvent.TASK_INCOMING, taskData});
413
+ voice.stateMachineService?.send({type: TaskEvent.ASSIGN, taskData});
414
+
415
+ const snapshot = voice.stateMachineService?.getSnapshot();
416
+ const expected = computeUIControls(
417
+ snapshot?.value as TaskState,
418
+ snapshot?.context as any,
419
+ voice.data
420
+ );
421
+ expect(voice.uiControls).toEqual(expected);
422
+ });
423
+ });
424
+
425
+ describe('recording operations', () => {
426
+ const buildRecordingData = (recordingOverrides: Record<string, any>) =>
427
+ createBaseData({
428
+ interaction: {
429
+ callProcessingDetails: recordingOverrides,
430
+ } as any,
431
+ });
432
+
433
+ it('throws when pauseRecording is invoked without active recording', async () => {
434
+ const voice = new Voice(dummyContact, createBaseData(), {});
435
+ await expect(voice.pauseRecording()).rejects.toThrow(
436
+ 'Recording is not active or already paused'
437
+ );
438
+ expect(dummyContact.pauseRecording).not.toHaveBeenCalled();
439
+ });
440
+
441
+ it('pauses recording when state machine context indicates active recording', async () => {
442
+ const taskData = buildRecordingData({recordInProgress: true});
443
+ const voice = new Voice(dummyContact, taskData, {});
444
+ primeConnectedState(voice, taskData);
445
+
446
+ await voice.pauseRecording();
447
+ expect(dummyContact.pauseRecording).toHaveBeenCalledWith({interactionId: 'int1'});
448
+ });
449
+
450
+ it('throws if resumeRecording is invoked while recording is not paused', async () => {
451
+ const taskData = buildRecordingData({recordInProgress: true});
452
+ const voice = new Voice(dummyContact, taskData, {});
453
+ primeConnectedState(voice, taskData);
454
+
455
+ await expect(voice.resumeRecording()).rejects.toThrow('Recording is not paused');
456
+ expect(dummyContact.resumeRecording).not.toHaveBeenCalled();
457
+ });
458
+
459
+ it('resumes recording when context shows paused recording', async () => {
460
+ const taskData = buildRecordingData({recordInProgress: true});
461
+ const voice = new Voice(dummyContact, taskData, {});
462
+ primeConnectedState(voice, taskData);
463
+ voice.stateMachineService?.send({type: TaskEvent.PAUSE_RECORDING});
464
+
465
+ await voice.resumeRecording();
466
+ expect(dummyContact.resumeRecording).toHaveBeenCalledWith({
467
+ interactionId: 'int1',
468
+ data: {autoResumed: false},
469
+ });
470
+ });
471
+ });
472
+
473
+ describe('switchCall()', () => {
474
+ const buildConsultingTaskData = () =>
475
+ createBaseData({
476
+ agentId: 'agent1',
477
+ consultMediaResourceId: 'consultMedia1',
478
+ interaction: {
479
+ media: {
480
+ media1: {
481
+ mediaResourceId: 'media1',
482
+ isHold: true,
483
+ mType: 'mainCall',
484
+ participants: ['agent1', 'customer1'],
485
+ },
486
+ consultMedia1: {
487
+ mediaResourceId: 'consultMedia1',
488
+ isHold: false,
489
+ mType: 'consult',
490
+ participants: ['agent1', 'agent2'],
491
+ },
492
+ },
493
+ participants: {
494
+ agent1: {id: 'agent1', pType: 'Agent', type: 'Agent', hasLeft: false},
495
+ agent2: {id: 'agent2', pType: 'Agent', type: 'Agent', hasLeft: false},
496
+ customer1: {id: 'customer1', pType: 'Customer', type: 'Customer', hasLeft: false},
497
+ },
498
+ } as any,
499
+ });
500
+
501
+ const primeConsultingState = (voice: Voice, taskData: TaskData) => {
502
+ primeConnectedState(voice, taskData);
503
+ voice.stateMachineService?.send({
504
+ type: TaskEvent.CONSULT,
505
+ destination: 'agent2',
506
+ destinationType: 'agent' as any,
507
+ });
508
+ voice.stateMachineService?.send({type: TaskEvent.CONSULT_SUCCESS, taskData});
509
+ };
510
+
511
+ it('switches from consult leg to main leg by unholding main media', async () => {
512
+ const taskData = buildConsultingTaskData();
513
+ const voice = new Voice(dummyContact, taskData, {});
514
+ primeConsultingState(voice, taskData);
515
+
516
+ await voice.switchCall();
517
+
518
+ expect(dummyContact.unHold).toHaveBeenCalledTimes(1);
519
+ expect(dummyContact.unHold).toHaveBeenCalledWith({
520
+ interactionId: 'int1',
521
+ data: {mediaResourceId: 'media1'},
522
+ });
523
+ expect(dummyContact.hold).not.toHaveBeenCalled();
524
+ });
525
+
526
+ it('switches from main leg to consult leg by holding main media', async () => {
527
+ const taskData = buildConsultingTaskData();
528
+ taskData.interaction.media.media1.isHold = false;
529
+ taskData.interaction.media.consultMedia1.isHold = true;
530
+ const voice = new Voice(dummyContact, taskData, {});
531
+ primeConsultingState(voice, taskData);
532
+ voice.stateMachineService?.send({type: TaskEvent.SWITCH_TO_MAIN_CALL});
533
+
534
+ await voice.switchCall();
535
+
536
+ expect(dummyContact.hold).toHaveBeenCalledTimes(1);
537
+ expect(dummyContact.hold).toHaveBeenCalledWith({
538
+ interactionId: 'int1',
539
+ data: {mediaResourceId: 'media1'},
540
+ });
541
+ expect(dummyContact.unHold).not.toHaveBeenCalled();
542
+ });
543
+ });
544
+
545
+ describe('consultConference()', () => {
546
+ afterEach(() => {
547
+ jest.restoreAllMocks();
548
+ });
549
+
550
+ it('uses cached consult destination when task data destination is cleared', async () => {
551
+ const taskData = createBaseData({
552
+ agentId: 'agent1',
553
+ destAgentId: undefined,
554
+ destinationType: undefined,
555
+ interaction: {
556
+ media: {
557
+ media1: {mediaResourceId: 'media1', isHold: true},
558
+ },
559
+ participants: {
560
+ agent1: {id: 'agent1', pType: 'Agent', type: 'Agent', hasLeft: false},
561
+ },
562
+ } as any,
563
+ });
564
+
565
+ const voice = new Voice(dummyContact, taskData, {});
566
+ primeConnectedState(voice, taskData);
567
+ voice.stateMachineService?.send({
568
+ type: TaskEvent.CONSULT,
569
+ destination: 'agent2',
570
+ destAgentId: 'agent2',
571
+ destinationType: 'agent' as any,
572
+ });
573
+ voice.updateTaskData(
574
+ createBaseData({
575
+ agentId: 'agent1',
576
+ destAgentId: undefined,
577
+ destinationType: undefined,
578
+ interaction: {state: 'consulting'} as any,
579
+ }) as any
580
+ );
581
+
582
+ await voice.consultConference();
583
+
584
+ expect(dummyContact.consultConference).toHaveBeenCalledWith({
585
+ interactionId: 'int1',
586
+ data: expect.objectContaining({
587
+ to: 'agent2',
588
+ destinationType: 'agent',
589
+ }),
590
+ });
591
+ });
592
+
593
+ it('falls back to derived destination when context and task destination are unavailable', async () => {
594
+ jest.spyOn(Utils, 'calculateDestAgentId').mockReturnValueOnce('derivedAgent');
595
+ jest.spyOn(Utils, 'calculateDestType').mockReturnValueOnce('agent');
596
+
597
+ const taskData = createBaseData({
598
+ agentId: 'agent1',
599
+ destAgentId: undefined,
600
+ destinationType: undefined,
601
+ interaction: {
602
+ media: {
603
+ media1: {mediaResourceId: 'media1', isHold: true},
604
+ },
605
+ participants: {
606
+ agent1: {id: 'agent1', pType: 'Agent', type: 'Agent', hasLeft: false},
607
+ },
608
+ } as any,
609
+ });
610
+
611
+ const voice = new Voice(dummyContact, taskData, {});
612
+ primeConnectedState(voice, taskData);
613
+ voice.stateMachineService?.send({
614
+ type: TaskEvent.CONSULT,
615
+ destination: '',
616
+ destAgentId: undefined,
617
+ destinationType: 'agent' as any,
618
+ });
619
+ voice.stateMachineService?.send({type: TaskEvent.CONSULT_SUCCESS, taskData});
620
+
621
+ await voice.consultConference();
622
+
623
+ expect(dummyContact.consultConference).toHaveBeenCalledWith({
624
+ interactionId: 'int1',
625
+ data: expect.objectContaining({
626
+ to: 'derivedAgent',
627
+ }),
628
+ });
629
+ });
630
+ });
631
+ });