@webex/contact-center 3.12.0-next.73 → 3.12.0-next.74

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 (188) 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 +53 -38
  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 +14 -1
  28. package/dist/constants.js.map +1 -1
  29. package/dist/index.js +13 -5
  30. package/dist/index.js.map +1 -1
  31. package/dist/metrics/behavioral-events.js +26 -0
  32. package/dist/metrics/behavioral-events.js.map +1 -1
  33. package/dist/metrics/constants.js +9 -3
  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/config/Util.js +2 -2
  38. package/dist/services/config/Util.js.map +1 -1
  39. package/dist/services/config/types.js +25 -9
  40. package/dist/services/config/types.js.map +1 -1
  41. package/dist/services/core/Utils.js +74 -27
  42. package/dist/services/core/Utils.js.map +1 -1
  43. package/dist/services/core/websocket/WebSocketManager.js +2 -1
  44. package/dist/services/core/websocket/WebSocketManager.js.map +1 -1
  45. package/dist/services/core/websocket/types.js.map +1 -1
  46. package/dist/services/index.js +1 -1
  47. package/dist/services/index.js.map +1 -1
  48. package/dist/services/task/Task.js +688 -0
  49. package/dist/services/task/Task.js.map +1 -0
  50. package/dist/services/task/TaskFactory.js +45 -0
  51. package/dist/services/task/TaskFactory.js.map +1 -0
  52. package/dist/services/task/TaskManager.js +725 -612
  53. package/dist/services/task/TaskManager.js.map +1 -1
  54. package/dist/services/task/TaskUtils.js +162 -26
  55. package/dist/services/task/TaskUtils.js.map +1 -1
  56. package/dist/services/task/constants.js +6 -1
  57. package/dist/services/task/constants.js.map +1 -1
  58. package/dist/services/task/digital/Digital.js +77 -0
  59. package/dist/services/task/digital/Digital.js.map +1 -0
  60. package/dist/services/task/state-machine/TaskStateMachine.js +837 -0
  61. package/dist/services/task/state-machine/TaskStateMachine.js.map +1 -0
  62. package/dist/services/task/state-machine/actions.js +543 -0
  63. package/dist/services/task/state-machine/actions.js.map +1 -0
  64. package/dist/services/task/state-machine/constants.js +161 -0
  65. package/dist/services/task/state-machine/constants.js.map +1 -0
  66. package/dist/services/task/state-machine/guards.js +340 -0
  67. package/dist/services/task/state-machine/guards.js.map +1 -0
  68. package/dist/services/task/state-machine/index.js +53 -0
  69. package/dist/services/task/state-machine/index.js.map +1 -0
  70. package/dist/services/task/state-machine/types.js +54 -0
  71. package/dist/services/task/state-machine/types.js.map +1 -0
  72. package/dist/services/task/state-machine/uiControlsComputer.js +553 -0
  73. package/dist/services/task/state-machine/uiControlsComputer.js.map +1 -0
  74. package/dist/services/task/taskDataNormalizer.js +99 -0
  75. package/dist/services/task/taskDataNormalizer.js.map +1 -0
  76. package/dist/services/task/types.js +168 -16
  77. package/dist/services/task/types.js.map +1 -1
  78. package/dist/services/task/voice/Voice.js +1042 -0
  79. package/dist/services/task/voice/Voice.js.map +1 -0
  80. package/dist/services/task/voice/WebRTC.js +149 -0
  81. package/dist/services/task/voice/WebRTC.js.map +1 -0
  82. package/dist/types/cc.d.ts +19 -18
  83. package/dist/types/config.d.ts +6 -0
  84. package/dist/types/constants.d.ts +14 -1
  85. package/dist/types/index.d.ts +11 -5
  86. package/dist/types/metrics/constants.d.ts +7 -1
  87. package/dist/types/services/ApiAiAssistant.d.ts +11 -3
  88. package/dist/types/services/config/types.d.ts +113 -9
  89. package/dist/types/services/core/Utils.d.ts +23 -10
  90. package/dist/types/services/core/websocket/WebSocketManager.d.ts +1 -0
  91. package/dist/types/services/core/websocket/types.d.ts +1 -1
  92. package/dist/types/services/index.d.ts +1 -1
  93. package/dist/types/services/task/Task.d.ts +157 -0
  94. package/dist/types/services/task/TaskFactory.d.ts +12 -0
  95. package/dist/types/services/task/TaskUtils.d.ts +46 -2
  96. package/dist/types/services/task/constants.d.ts +5 -0
  97. package/dist/types/services/task/digital/Digital.d.ts +22 -0
  98. package/dist/types/services/task/state-machine/TaskStateMachine.d.ts +1144 -0
  99. package/dist/types/services/task/state-machine/actions.d.ts +10 -0
  100. package/dist/types/services/task/state-machine/constants.d.ts +107 -0
  101. package/dist/types/services/task/state-machine/guards.d.ts +90 -0
  102. package/dist/types/services/task/state-machine/index.d.ts +13 -0
  103. package/dist/types/services/task/state-machine/types.d.ts +267 -0
  104. package/dist/types/services/task/state-machine/uiControlsComputer.d.ts +9 -0
  105. package/dist/types/services/task/taskDataNormalizer.d.ts +10 -0
  106. package/dist/types/services/task/types.d.ts +551 -78
  107. package/dist/types/services/task/voice/Voice.d.ts +184 -0
  108. package/dist/types/services/task/voice/WebRTC.d.ts +53 -0
  109. package/dist/types/types.d.ts +92 -0
  110. package/dist/types/webex.d.ts +1 -0
  111. package/dist/types.js +85 -0
  112. package/dist/types.js.map +1 -1
  113. package/dist/webex.js +14 -2
  114. package/dist/webex.js.map +1 -1
  115. package/package.json +15 -12
  116. package/src/cc.ts +63 -43
  117. package/src/config.ts +6 -0
  118. package/src/constants.ts +14 -1
  119. package/src/index.ts +14 -5
  120. package/src/metrics/ai-docs/AGENTS.md +348 -0
  121. package/src/metrics/ai-docs/ARCHITECTURE.md +336 -0
  122. package/src/metrics/behavioral-events.ts +28 -0
  123. package/src/metrics/constants.ts +9 -4
  124. package/src/services/ApiAiAssistant.ts +104 -3
  125. package/src/services/agent/ai-docs/AGENTS.md +238 -0
  126. package/src/services/agent/ai-docs/ARCHITECTURE.md +302 -0
  127. package/src/services/ai-docs/AGENTS.md +384 -0
  128. package/src/services/config/Util.ts +2 -2
  129. package/src/services/config/ai-docs/AGENTS.md +253 -0
  130. package/src/services/config/ai-docs/ARCHITECTURE.md +424 -0
  131. package/src/services/config/types.ts +116 -10
  132. package/src/services/core/Utils.ts +85 -34
  133. package/src/services/core/ai-docs/AGENTS.md +379 -0
  134. package/src/services/core/ai-docs/ARCHITECTURE.md +696 -0
  135. package/src/services/core/websocket/WebSocketManager.ts +2 -0
  136. package/src/services/core/websocket/types.ts +1 -1
  137. package/src/services/index.ts +1 -1
  138. package/src/services/task/Task.ts +837 -0
  139. package/src/services/task/TaskFactory.ts +55 -0
  140. package/src/services/task/TaskManager.ts +738 -697
  141. package/src/services/task/TaskUtils.ts +205 -25
  142. package/src/services/task/ai-docs/AGENTS.md +455 -0
  143. package/src/services/task/ai-docs/ARCHITECTURE.md +585 -0
  144. package/src/services/task/constants.ts +5 -0
  145. package/src/services/task/digital/Digital.ts +95 -0
  146. package/src/services/task/state-machine/TaskStateMachine.ts +1077 -0
  147. package/src/services/task/state-machine/actions.ts +685 -0
  148. package/src/services/task/state-machine/ai-docs/AGENTS.md +495 -0
  149. package/src/services/task/state-machine/ai-docs/ARCHITECTURE.md +1135 -0
  150. package/src/services/task/state-machine/constants.ts +172 -0
  151. package/src/services/task/state-machine/guards.ts +406 -0
  152. package/src/services/task/state-machine/index.ts +28 -0
  153. package/src/services/task/state-machine/types.ts +241 -0
  154. package/src/services/task/state-machine/uiControlsComputer.ts +867 -0
  155. package/src/services/task/taskDataNormalizer.ts +137 -0
  156. package/src/services/task/types.ts +654 -85
  157. package/src/services/task/voice/Voice.ts +1267 -0
  158. package/src/services/task/voice/WebRTC.ts +187 -0
  159. package/src/types.ts +112 -1
  160. package/src/utils/AGENTS.md +276 -0
  161. package/src/webex.js +2 -0
  162. package/test/unit/spec/cc.ts +133 -3
  163. package/test/unit/spec/logger-proxy.ts +70 -0
  164. package/test/unit/spec/services/ApiAiAssistant.ts +122 -17
  165. package/test/unit/spec/services/WebCallingService.ts +7 -1
  166. package/test/unit/spec/services/config/index.ts +27 -27
  167. package/test/unit/spec/services/core/Utils.ts +335 -1
  168. package/test/unit/spec/services/core/websocket/WebSocketManager.ts +66 -40
  169. package/test/unit/spec/services/task/AutoWrapup.ts +63 -0
  170. package/test/unit/spec/services/task/Task.ts +477 -0
  171. package/test/unit/spec/services/task/TaskFactory.ts +62 -0
  172. package/test/unit/spec/services/task/TaskManager.ts +821 -1936
  173. package/test/unit/spec/services/task/TaskUtils.ts +206 -0
  174. package/test/unit/spec/services/task/digital/Digital.ts +105 -0
  175. package/test/unit/spec/services/task/state-machine/TaskStateMachine.ts +1825 -0
  176. package/test/unit/spec/services/task/state-machine/guards.ts +479 -0
  177. package/test/unit/spec/services/task/state-machine/types.ts +18 -0
  178. package/test/unit/spec/services/task/state-machine/uiControlsComputer.ts +2020 -0
  179. package/test/unit/spec/services/task/taskTestUtils.ts +87 -0
  180. package/test/unit/spec/services/task/voice/Voice.ts +631 -0
  181. package/test/unit/spec/services/task/voice/WebRTC.ts +235 -0
  182. package/umd/contact-center.min.js +2 -2
  183. package/umd/contact-center.min.js.map +1 -1
  184. package/dist/services/task/index.js +0 -1530
  185. package/dist/services/task/index.js.map +0 -1
  186. package/dist/types/services/task/index.d.ts +0 -650
  187. package/src/services/task/index.ts +0 -1806
  188. package/test/unit/spec/services/task/index.ts +0 -2205
@@ -0,0 +1,1825 @@
1
+ import {createActor} from 'xstate';
2
+ import {
3
+ createTaskStateMachine,
4
+ TaskEvent,
5
+ TaskState,
6
+ } from '../../../../../../src/services/task/state-machine';
7
+ import {createTaskData} from '../taskTestUtils';
8
+
9
+ const createConfig = () => ({
10
+ channelType: 'voice' as const,
11
+ isEndTaskEnabled: true,
12
+ isEndConsultEnabled: true,
13
+ voiceVariant: 'pstn' as const,
14
+ isRecordingEnabled: true,
15
+ });
16
+
17
+ describe('Task state machine', () => {
18
+ const startMachine = () => {
19
+ const actor = createActor(createTaskStateMachine(createConfig()));
20
+ actor.start();
21
+
22
+ return actor;
23
+ };
24
+
25
+ describe('recording state derivation', () => {
26
+ it('captures recording flags from offer payload', () => {
27
+ const service = startMachine();
28
+ const taskData = createTaskData({
29
+ interaction: {
30
+ callProcessingDetails: {
31
+ recordInProgress: true,
32
+ isPaused: false,
33
+ },
34
+ } as any,
35
+ });
36
+
37
+ service.send({type: TaskEvent.TASK_INCOMING, taskData});
38
+
39
+ const snapshot = service.getSnapshot();
40
+ expect(snapshot.context.recordingControlsAvailable).toBe(true);
41
+ expect(snapshot.context.recordingInProgress).toBe(true);
42
+ });
43
+
44
+ it('updates recordingPaused when ASSIGN payload reports pause', () => {
45
+ const service = startMachine();
46
+ const initialTaskData = createTaskData();
47
+ const pausedTaskData = createTaskData({
48
+ interaction: {
49
+ callProcessingDetails: {
50
+ isPaused: true,
51
+ },
52
+ } as any,
53
+ });
54
+
55
+ service.send({type: TaskEvent.TASK_INCOMING, taskData: initialTaskData});
56
+ service.send({type: TaskEvent.ASSIGN, taskData: pausedTaskData});
57
+
58
+ const snapshot = service.getSnapshot();
59
+ expect(snapshot.context.recordingControlsAvailable).toBe(true);
60
+ expect(snapshot.context.recordingInProgress).toBe(false);
61
+ });
62
+
63
+ it('updates recording state when recording started event arrives', () => {
64
+ const service = startMachine();
65
+ const initialTaskData = createTaskData();
66
+ const recordingTaskData = createTaskData({
67
+ interaction: {
68
+ callProcessingDetails: {
69
+ recordingStarted: true,
70
+ recordInProgress: true,
71
+ },
72
+ } as any,
73
+ });
74
+
75
+ service.send({type: TaskEvent.TASK_INCOMING, taskData: initialTaskData});
76
+ service.send({type: TaskEvent.ASSIGN, taskData: initialTaskData});
77
+ service.send({type: TaskEvent.RECORDING_STARTED, taskData: recordingTaskData});
78
+
79
+ const snapshot = service.getSnapshot();
80
+ expect(snapshot.value).toBe(TaskState.CONNECTED);
81
+ expect(snapshot.context.recordingControlsAvailable).toBe(true);
82
+ expect(snapshot.context.recordingInProgress).toBe(true);
83
+ });
84
+ });
85
+
86
+ describe('hold and resume flow', () => {
87
+ it('moves through HOLD -> HELD -> CONNECTED on success events', () => {
88
+ const service = startMachine();
89
+ const taskData = createTaskData();
90
+
91
+ service.send({type: TaskEvent.TASK_INCOMING, taskData});
92
+ service.send({type: TaskEvent.ASSIGN, taskData});
93
+ expect(service.getSnapshot().value).toBe(TaskState.CONNECTED);
94
+
95
+ service.send({type: TaskEvent.HOLD_INITIATED, mediaResourceId: taskData.mediaResourceId});
96
+ expect(service.getSnapshot().value).toBe(TaskState.HOLD_INITIATING);
97
+
98
+ service.send({type: TaskEvent.HOLD_SUCCESS, mediaResourceId: taskData.mediaResourceId});
99
+ expect(service.getSnapshot().value).toBe(TaskState.HELD);
100
+
101
+ service.send({type: TaskEvent.UNHOLD_INITIATED, mediaResourceId: taskData.mediaResourceId});
102
+ expect(service.getSnapshot().value).toBe(TaskState.RESUME_INITIATING);
103
+
104
+ service.send({type: TaskEvent.UNHOLD_SUCCESS, mediaResourceId: taskData.mediaResourceId});
105
+ expect(service.getSnapshot().value).toBe(TaskState.CONNECTED);
106
+ });
107
+ });
108
+
109
+ describe('recording pause/resume events', () => {
110
+ it('toggles recordingPaused flag based on events', () => {
111
+ const service = startMachine();
112
+ const taskData = createTaskData({
113
+ interaction: {
114
+ callProcessingDetails: {recordInProgress: true},
115
+ } as any,
116
+ });
117
+
118
+ service.send({type: TaskEvent.TASK_INCOMING, taskData});
119
+ service.send({type: TaskEvent.ASSIGN, taskData});
120
+ expect(service.getSnapshot().context.recordingInProgress).toBe(true);
121
+
122
+ service.send({type: TaskEvent.PAUSE_RECORDING});
123
+ expect(service.getSnapshot().context.recordingInProgress).toBe(false);
124
+
125
+ service.send({type: TaskEvent.RESUME_RECORDING});
126
+ expect(service.getSnapshot().context.recordingInProgress).toBe(true);
127
+ });
128
+
129
+ it('toggles recording state while task is held', () => {
130
+ const service = startMachine();
131
+ const taskData = createTaskData({
132
+ interaction: {
133
+ callProcessingDetails: {recordInProgress: true},
134
+ } as any,
135
+ });
136
+
137
+ service.send({type: TaskEvent.TASK_INCOMING, taskData});
138
+ service.send({type: TaskEvent.ASSIGN, taskData});
139
+ service.send({type: TaskEvent.HOLD_INITIATED, mediaResourceId: taskData.mediaResourceId});
140
+ service.send({type: TaskEvent.HOLD_SUCCESS, mediaResourceId: taskData.mediaResourceId});
141
+ expect(service.getSnapshot().value).toBe(TaskState.HELD);
142
+ expect(service.getSnapshot().context.recordingInProgress).toBe(true);
143
+
144
+ service.send({type: TaskEvent.PAUSE_RECORDING});
145
+ expect(service.getSnapshot().context.recordingInProgress).toBe(false);
146
+
147
+ service.send({type: TaskEvent.RESUME_RECORDING});
148
+ expect(service.getSnapshot().context.recordingInProgress).toBe(true);
149
+ });
150
+ });
151
+
152
+ describe('wrap-up and completion flow', () => {
153
+ it('moves from CONNECTED -> WRAPPING_UP -> COMPLETED on END/WRAPUP_COMPLETE', () => {
154
+ const service = startMachine();
155
+ const taskData = createTaskData();
156
+
157
+ service.send({type: TaskEvent.TASK_INCOMING, taskData});
158
+ service.send({type: TaskEvent.ASSIGN, taskData});
159
+ expect(service.getSnapshot().value).toBe(TaskState.CONNECTED);
160
+
161
+ service.send({type: TaskEvent.TASK_WRAPUP});
162
+ expect(service.getSnapshot().value).toBe(TaskState.WRAPPING_UP);
163
+
164
+ service.send({type: TaskEvent.WRAPUP_COMPLETE});
165
+ expect(service.getSnapshot().value).toBe(TaskState.COMPLETED);
166
+ });
167
+
168
+ it('handles CONTACT_ENDED by entering wrapping up before completion', () => {
169
+ const service = startMachine();
170
+ // Primary agent (isConsulted: false) should go to WRAPPING_UP
171
+ const taskData = createTaskData({isConsulted: false} as any);
172
+
173
+ service.send({type: TaskEvent.TASK_INCOMING, taskData});
174
+ service.send({type: TaskEvent.ASSIGN, taskData});
175
+
176
+ // CONTACT_ENDED event must include taskData for shouldWrapUpForThisAgent check
177
+ service.send({type: TaskEvent.CONTACT_ENDED, taskData});
178
+ expect(service.getSnapshot().value).toBe(TaskState.WRAPPING_UP);
179
+
180
+ service.send({type: TaskEvent.WRAPUP_COMPLETE});
181
+ expect(service.getSnapshot().value).toBe(TaskState.COMPLETED);
182
+ });
183
+ });
184
+
185
+ describe('consult and conference flows', () => {
186
+ const createSingleAgentConferenceTaskData = (interactionState: string, isHold = false) =>
187
+ createTaskData({
188
+ interactionId: 'interaction-1',
189
+ mediaResourceId: 'interaction-1',
190
+ interaction: {
191
+ state: interactionState,
192
+ mainInteractionId: 'interaction-1',
193
+ interactionId: 'interaction-1',
194
+ participants: {
195
+ 'agent-1': {id: 'agent-1', pType: 'Agent', hasLeft: false},
196
+ 'customer-1': {id: 'customer-1', pType: 'Customer', hasLeft: false},
197
+ },
198
+ media: {
199
+ 'interaction-1': {
200
+ mediaResourceId: 'interaction-1',
201
+ mType: 'mainCall',
202
+ participants: ['agent-1', 'customer-1'],
203
+ isHold,
204
+ },
205
+ },
206
+ } as any,
207
+ });
208
+
209
+ const createConferenceConsultTaskData = ({
210
+ interactionState,
211
+ includeSecondAgent,
212
+ conferenceHoldParticipant,
213
+ isMainHeld = false,
214
+ }: {
215
+ interactionState: string;
216
+ includeSecondAgent: boolean;
217
+ conferenceHoldParticipant: boolean | string;
218
+ isMainHeld?: boolean;
219
+ }) =>
220
+ createTaskData({
221
+ interactionId: 'interaction-1',
222
+ mediaResourceId: 'interaction-1',
223
+ consultMediaResourceId: 'consult-media-1',
224
+ interaction: {
225
+ state: interactionState,
226
+ mainInteractionId: 'interaction-1',
227
+ interactionId: 'interaction-1',
228
+ callProcessingDetails: {
229
+ conferenceHoldParticipant,
230
+ },
231
+ participants: {
232
+ 'agent-1': {
233
+ id: 'agent-1',
234
+ pType: 'Agent',
235
+ hasLeft: false,
236
+ consultState: 'consulting',
237
+ },
238
+ ...(includeSecondAgent
239
+ ? {
240
+ 'agent-2': {
241
+ id: 'agent-2',
242
+ pType: 'Agent',
243
+ hasLeft: false,
244
+ },
245
+ }
246
+ : {}),
247
+ 'agent-3': {
248
+ id: 'agent-3',
249
+ pType: 'Agent',
250
+ hasLeft: false,
251
+ isConsulted: true,
252
+ consultState: 'consulting',
253
+ },
254
+ 'customer-1': {id: 'customer-1', pType: 'Customer', hasLeft: false},
255
+ },
256
+ media: {
257
+ 'interaction-1': {
258
+ mediaResourceId: 'interaction-1',
259
+ mType: 'mainCall',
260
+ participants: includeSecondAgent
261
+ ? ['agent-1', 'agent-2', 'customer-1']
262
+ : ['agent-1', 'customer-1'],
263
+ isHold: isMainHeld,
264
+ },
265
+ 'consult-media-1': {
266
+ mediaResourceId: 'consult-media-1',
267
+ mType: 'consult',
268
+ participants: ['agent-1', 'agent-3'],
269
+ isHold: false,
270
+ },
271
+ },
272
+ } as any,
273
+ });
274
+
275
+ it('boots from IDLE to CONSULTING on CONSULTING_ACTIVE for split-leg ordering', () => {
276
+ const service = startMachine();
277
+ const taskData = createTaskData({
278
+ consultingAgentId: 'agent-1',
279
+ isConsulted: false,
280
+ interaction: {
281
+ state: 'consulting',
282
+ } as any,
283
+ });
284
+
285
+ expect(service.getSnapshot().value).toBe(TaskState.IDLE);
286
+ service.send({
287
+ type: TaskEvent.CONSULTING_ACTIVE,
288
+ consultDestinationAgentJoined: true,
289
+ taskData,
290
+ });
291
+
292
+ expect(service.getSnapshot().value).toBe(TaskState.CONSULTING);
293
+ expect(service.getSnapshot().context.consultDestinationAgentJoined).toBe(true);
294
+ });
295
+
296
+ it('hydrates to CONSULTING when top-level state is conference but self consultState is consulting', () => {
297
+ const service = startMachine();
298
+ const taskData = createTaskData({
299
+ isConsulted: false,
300
+ interaction: {
301
+ state: 'conference',
302
+ mainInteractionId: 'interaction-1',
303
+ interactionId: 'interaction-1',
304
+ participants: {
305
+ 'agent-1': {
306
+ id: 'agent-1',
307
+ pType: 'Agent',
308
+ hasLeft: false,
309
+ consultState: 'consulting',
310
+ isConsulted: false,
311
+ },
312
+ 'agent-2': {
313
+ id: 'agent-2',
314
+ pType: 'Agent',
315
+ hasLeft: false,
316
+ consultState: 'consulting',
317
+ isConsulted: true,
318
+ },
319
+ 'customer-1': {id: 'customer-1', pType: 'Customer', hasLeft: false},
320
+ },
321
+ media: {
322
+ 'interaction-1': {
323
+ mediaResourceId: 'interaction-1',
324
+ mType: 'mainCall',
325
+ participants: ['agent-1', 'customer-1'],
326
+ isHold: true,
327
+ },
328
+ 'consult-media-1': {
329
+ mediaResourceId: 'consult-media-1',
330
+ mType: 'consult',
331
+ participants: ['agent-1', 'agent-2'],
332
+ isHold: false,
333
+ },
334
+ },
335
+ } as any,
336
+ });
337
+
338
+ service.send({type: TaskEvent.HYDRATE, taskData});
339
+
340
+ const snapshot = service.getSnapshot();
341
+ expect(snapshot.value).toBe(TaskState.CONSULTING);
342
+ expect(snapshot.context.consultInitiator).toBe(true);
343
+ expect(snapshot.context.consultFromConference).toBe(true);
344
+ });
345
+
346
+ it('hydrates consulted agent to CONSULTING when self consultState is consulting and main leg is held', () => {
347
+ const service = startMachine();
348
+ const taskData = createTaskData({
349
+ agentId: 'agent-2',
350
+ isConsulted: true,
351
+ interaction: {
352
+ state: 'conference',
353
+ mainInteractionId: 'interaction-1',
354
+ interactionId: 'interaction-1',
355
+ participants: {
356
+ 'agent-1': {
357
+ id: 'agent-1',
358
+ pType: 'Agent',
359
+ hasLeft: false,
360
+ consultState: 'consulting',
361
+ isConsulted: false,
362
+ },
363
+ 'agent-2': {
364
+ id: 'agent-2',
365
+ pType: 'Agent',
366
+ hasLeft: false,
367
+ consultState: 'consulting',
368
+ isConsulted: true,
369
+ },
370
+ 'customer-1': {id: 'customer-1', pType: 'Customer', hasLeft: false},
371
+ },
372
+ media: {
373
+ 'interaction-1': {
374
+ mediaResourceId: 'interaction-1',
375
+ mType: 'mainCall',
376
+ participants: ['agent-1', 'customer-1'],
377
+ isHold: true,
378
+ },
379
+ 'consult-media-1': {
380
+ mediaResourceId: 'consult-media-1',
381
+ mType: 'consult',
382
+ participants: ['agent-1', 'agent-2'],
383
+ isHold: false,
384
+ },
385
+ },
386
+ } as any,
387
+ });
388
+
389
+ service.send({type: TaskEvent.HYDRATE, taskData});
390
+
391
+ expect(service.getSnapshot().value).toBe(TaskState.CONSULTING);
392
+ });
393
+
394
+ it('hydrates to CONSULTING when consult is pending (self consultState is consultInitiated)', () => {
395
+ const service = startMachine();
396
+ const taskData = createTaskData({
397
+ isConsulted: false,
398
+ interaction: {
399
+ state: 'conference',
400
+ mainInteractionId: 'interaction-1',
401
+ interactionId: 'interaction-1',
402
+ participants: {
403
+ 'agent-1': {
404
+ id: 'agent-1',
405
+ pType: 'Agent',
406
+ hasLeft: false,
407
+ consultState: 'consultInitiated',
408
+ isConsulted: false,
409
+ },
410
+ 'agent-2': {
411
+ id: 'agent-2',
412
+ pType: 'Agent',
413
+ hasLeft: false,
414
+ consultState: 'consultReserved',
415
+ isConsulted: true,
416
+ },
417
+ 'customer-1': {id: 'customer-1', pType: 'Customer', hasLeft: false},
418
+ },
419
+ media: {
420
+ 'interaction-1': {
421
+ mediaResourceId: 'interaction-1',
422
+ mType: 'mainCall',
423
+ participants: ['agent-1', 'customer-1'],
424
+ isHold: true,
425
+ },
426
+ 'consult-media-1': {
427
+ mediaResourceId: 'consult-media-1',
428
+ mType: 'consult',
429
+ participants: ['agent-1', 'agent-2'],
430
+ isHold: false,
431
+ },
432
+ },
433
+ } as any,
434
+ });
435
+
436
+ service.send({type: TaskEvent.HYDRATE, taskData});
437
+
438
+ const snapshot = service.getSnapshot();
439
+ expect(snapshot.value).toBe(TaskState.CONSULTING);
440
+ expect(snapshot.context.consultInitiator).toBe(true);
441
+ expect(snapshot.context.consultFromConference).toBe(true);
442
+ });
443
+
444
+ it('tracks consult destination, agent join, and clears on consult end', () => {
445
+ const service = startMachine();
446
+ const taskData = createTaskData();
447
+
448
+ service.send({type: TaskEvent.TASK_INCOMING, taskData});
449
+ service.send({type: TaskEvent.ASSIGN, taskData});
450
+
451
+ service.send({
452
+ type: TaskEvent.CONSULT,
453
+ destination: 'agent-42',
454
+ destinationType: 'agent',
455
+ });
456
+ expect(service.getSnapshot().value).toBe(TaskState.CONSULT_INITIATING);
457
+ expect(service.getSnapshot().context.consultInitiator).toBe(true);
458
+
459
+ service.send({type: TaskEvent.CONSULT_SUCCESS});
460
+ expect(service.getSnapshot().value).toBe(TaskState.CONSULTING);
461
+
462
+ service.send({
463
+ type: TaskEvent.CONSULTING_ACTIVE,
464
+ consultDestinationAgentJoined: true,
465
+ });
466
+ expect(service.getSnapshot().context.consultDestinationAgentJoined).toBe(true);
467
+
468
+ service.send({type: TaskEvent.CONSULT_END});
469
+ const snapshotAfterEnd = service.getSnapshot();
470
+ expect(snapshotAfterEnd.value).toBe(TaskState.HELD);
471
+ expect(snapshotAfterEnd.context.consultDestinationAgentJoined).toBe(false);
472
+ });
473
+
474
+ it('returns to HELD with main-leg controls after AgentConsultEnded from Stable Prod while CONSULTING', () => {
475
+ const service = startMachine();
476
+ const baseTaskData = createTaskData({
477
+ agentId: 'agent-1',
478
+ mediaResourceId: 'interaction-1',
479
+ });
480
+
481
+ service.send({type: TaskEvent.TASK_INCOMING, taskData: baseTaskData});
482
+ service.send({type: TaskEvent.ASSIGN, taskData: baseTaskData});
483
+ service.send({
484
+ type: TaskEvent.CONSULT,
485
+ destination: 'agent-2',
486
+ destinationType: 'agent',
487
+ });
488
+ service.send({type: TaskEvent.CONSULT_SUCCESS});
489
+ service.send({type: TaskEvent.CONSULTING_ACTIVE, consultDestinationAgentJoined: true});
490
+ expect(service.getSnapshot().value).toBe(TaskState.CONSULTING);
491
+
492
+ const consultEndedTaskData = createTaskData({
493
+ agentId: 'agent-1',
494
+ mediaResourceId: 'interaction-1',
495
+ type: 'AgentConsultEnded' as any,
496
+ isConsulted: false,
497
+ interaction: {
498
+ state: 'connected',
499
+ interactionId: 'interaction-1',
500
+ mainInteractionId: 'interaction-1',
501
+ owner: 'agent-1',
502
+ participants: {
503
+ 'agent-1': {
504
+ id: 'agent-1',
505
+ pType: 'Agent',
506
+ hasLeft: false,
507
+ consultState: 'consultCompleted',
508
+ isConsulted: false,
509
+ },
510
+ 'customer-1': {id: 'customer-1', pType: 'Customer', hasLeft: false, hasJoined: true},
511
+ },
512
+ media: {
513
+ 'interaction-1': {
514
+ mediaResourceId: 'interaction-1',
515
+ mType: 'mainCall',
516
+ isHold: true,
517
+ participants: ['customer-1', 'agent-1'],
518
+ },
519
+ },
520
+ } as any,
521
+ });
522
+
523
+ service.send({type: TaskEvent.CONSULT_END, taskData: consultEndedTaskData});
524
+
525
+ const snapshot = service.getSnapshot();
526
+ expect(snapshot.value).toBe(TaskState.HELD);
527
+ expect(snapshot.context.consultInitiator).toBe(false);
528
+ expect(snapshot.context.consultCallHeld).toBe(false);
529
+ expect(snapshot.context.consultDestinationAgentJoined).toBe(false);
530
+ expect(snapshot.context.uiControls.activeLeg).toBe('main');
531
+ expect(snapshot.context.uiControls.consult.endConsult).toEqual({
532
+ isVisible: false,
533
+ isEnabled: false,
534
+ });
535
+ expect(snapshot.context.uiControls.main.hold).toEqual({
536
+ isVisible: true,
537
+ isEnabled: true,
538
+ });
539
+ expect(snapshot.context.uiControls.main.consult).toEqual({
540
+ isVisible: true,
541
+ isEnabled: true,
542
+ });
543
+ expect(snapshot.context.uiControls.main.transfer).toEqual({
544
+ isVisible: true,
545
+ isEnabled: true,
546
+ });
547
+ expect(snapshot.context.uiControls.main.recording).toEqual({
548
+ isVisible: true,
549
+ isEnabled: true,
550
+ });
551
+ });
552
+
553
+ it('enables main consult after ending consult before consultee answers (CONSULT_INITIATING -> CONSULT_END)', () => {
554
+ const service = startMachine();
555
+ const baseTaskData = createTaskData({
556
+ agentId: 'agent-1',
557
+ mediaResourceId: 'interaction-1',
558
+ });
559
+
560
+ service.send({type: TaskEvent.TASK_INCOMING, taskData: baseTaskData});
561
+ service.send({type: TaskEvent.ASSIGN, taskData: baseTaskData});
562
+ service.send({
563
+ type: TaskEvent.CONSULT,
564
+ destination: 'agent-2',
565
+ destinationType: 'agent',
566
+ });
567
+ // Consult requested but consultee (agent-2) has not answered yet.
568
+ expect(service.getSnapshot().value).toBe(TaskState.CONSULT_INITIATING);
569
+
570
+ // Agent 1 ends the consult before agent-2 answers. Backend AgentConsultEnded:
571
+ // main on hold, self consultCompleted, no consult media, consultee not in participants.
572
+ const consultEndedTaskData = createTaskData({
573
+ agentId: 'agent-1',
574
+ mediaResourceId: 'interaction-1',
575
+ consultMediaResourceId: 'consult-media',
576
+ destAgentId: 'agent-2',
577
+ destinationType: 'Agent',
578
+ type: 'AgentConsultEnded' as any,
579
+ isConsulted: false,
580
+ interaction: {
581
+ state: 'connected',
582
+ interactionId: 'interaction-1',
583
+ mainInteractionId: 'interaction-1',
584
+ owner: 'agent-1',
585
+ participants: {
586
+ 'agent-1': {
587
+ id: 'agent-1',
588
+ pType: 'Agent',
589
+ hasLeft: false,
590
+ hasJoined: true,
591
+ consultState: 'consultCompleted',
592
+ isConsulted: false,
593
+ },
594
+ 'customer-1': {id: 'customer-1', pType: 'Customer', hasLeft: false, hasJoined: true},
595
+ },
596
+ media: {
597
+ 'interaction-1': {
598
+ mediaResourceId: 'interaction-1',
599
+ mType: 'mainCall',
600
+ isHold: true,
601
+ participants: ['customer-1', 'agent-1'],
602
+ },
603
+ },
604
+ } as any,
605
+ });
606
+
607
+ service.send({type: TaskEvent.CONSULT_END, taskData: consultEndedTaskData});
608
+
609
+ const snapshot = service.getSnapshot();
610
+ expect(snapshot.value).toBe(TaskState.HELD);
611
+ expect(snapshot.context.consultInitiator).toBe(false);
612
+ expect(snapshot.context.consultDestinationAgentJoined).toBe(false);
613
+ expect(snapshot.context.uiControls.activeLeg).toBe('main');
614
+ expect(snapshot.context.uiControls.main.consult).toEqual({
615
+ isVisible: true,
616
+ isEnabled: true,
617
+ });
618
+ expect(snapshot.context.uiControls.consult.endConsult).toEqual({
619
+ isVisible: false,
620
+ isEnabled: false,
621
+ });
622
+ });
623
+
624
+ it('stays HELD and clears consult UI when AgentConsultEnded arrives on HELD state', () => {
625
+ const service = startMachine();
626
+ const heldTaskData = createTaskData({
627
+ agentId: 'agent-1',
628
+ mediaResourceId: 'interaction-1',
629
+ interaction: {
630
+ state: 'hold',
631
+ interactionId: 'interaction-1',
632
+ mainInteractionId: 'interaction-1',
633
+ owner: 'agent-1',
634
+ participants: {
635
+ 'agent-1': {
636
+ id: 'agent-1',
637
+ pType: 'Agent',
638
+ hasLeft: false,
639
+ consultState: 'consulting',
640
+ isConsulted: false,
641
+ },
642
+ 'customer-1': {id: 'customer-1', pType: 'Customer', hasLeft: false, hasJoined: true},
643
+ },
644
+ media: {
645
+ 'interaction-1': {
646
+ mediaResourceId: 'interaction-1',
647
+ mType: 'mainCall',
648
+ isHold: true,
649
+ participants: ['customer-1', 'agent-1'],
650
+ },
651
+ },
652
+ } as any,
653
+ });
654
+
655
+ service.send({type: TaskEvent.TASK_INCOMING, taskData: heldTaskData});
656
+ service.send({type: TaskEvent.ASSIGN, taskData: heldTaskData});
657
+ service.send({
658
+ type: TaskEvent.HOLD_INITIATED,
659
+ mediaResourceId: heldTaskData.mediaResourceId,
660
+ });
661
+ service.send({
662
+ type: TaskEvent.HOLD_SUCCESS,
663
+ mediaResourceId: heldTaskData.mediaResourceId,
664
+ taskData: heldTaskData,
665
+ });
666
+ expect(service.getSnapshot().value).toBe(TaskState.HELD);
667
+
668
+ const consultEndedTaskData = createTaskData({
669
+ agentId: 'agent-1',
670
+ mediaResourceId: 'interaction-1',
671
+ type: 'AgentConsultEnded' as any,
672
+ isConsulted: false,
673
+ interaction: {
674
+ state: 'hold',
675
+ interactionId: 'interaction-1',
676
+ mainInteractionId: 'interaction-1',
677
+ owner: 'agent-1',
678
+ participants: {
679
+ 'agent-1': {
680
+ id: 'agent-1',
681
+ pType: 'Agent',
682
+ hasLeft: false,
683
+ consultState: 'consultCompleted',
684
+ isConsulted: false,
685
+ },
686
+ 'customer-1': {id: 'customer-1', pType: 'Customer', hasLeft: false, hasJoined: true},
687
+ },
688
+ media: {
689
+ 'interaction-1': {
690
+ mediaResourceId: 'interaction-1',
691
+ mType: 'mainCall',
692
+ isHold: true,
693
+ participants: ['customer-1', 'agent-1'],
694
+ },
695
+ },
696
+ } as any,
697
+ });
698
+
699
+ service.send({type: TaskEvent.CONSULT_END, taskData: consultEndedTaskData});
700
+
701
+ const snapshot = service.getSnapshot();
702
+ expect(snapshot.value).toBe(TaskState.HELD);
703
+ expect(snapshot.context.consultInitiator).toBe(false);
704
+ expect(snapshot.context.uiControls.activeLeg).toBe('main');
705
+ expect(snapshot.context.uiControls.main.hold).toEqual({
706
+ isVisible: true,
707
+ isEnabled: true,
708
+ });
709
+ expect(snapshot.context.uiControls.consult.endConsult).toEqual({
710
+ isVisible: false,
711
+ isEnabled: false,
712
+ });
713
+ });
714
+
715
+ it('keeps main.consult enabled on HELD after AgentConsultFailed then AgentConsultEnded (RONA)', () => {
716
+ const service = startMachine();
717
+ const heldTaskData = createTaskData({
718
+ agentId: 'agent-1',
719
+ mediaResourceId: 'interaction-1',
720
+ type: 'AgentContactHeld' as any,
721
+ interaction: {
722
+ state: 'hold',
723
+ interactionId: 'interaction-1',
724
+ mainInteractionId: 'interaction-1',
725
+ owner: 'agent-1',
726
+ participants: {
727
+ 'agent-1': {id: 'agent-1', pType: 'Agent', hasLeft: false},
728
+ 'customer-1': {id: 'customer-1', pType: 'Customer', hasLeft: false, hasJoined: true},
729
+ } as any,
730
+ media: {
731
+ 'interaction-1': {
732
+ mediaResourceId: 'interaction-1',
733
+ mType: 'mainCall',
734
+ isHold: true,
735
+ participants: ['customer-1', 'agent-1'],
736
+ },
737
+ } as any,
738
+ } as any,
739
+ });
740
+
741
+ service.send({type: TaskEvent.TASK_INCOMING, taskData: heldTaskData});
742
+ service.send({type: TaskEvent.ASSIGN, taskData: heldTaskData});
743
+ service.send({
744
+ type: TaskEvent.HOLD_INITIATED,
745
+ mediaResourceId: heldTaskData.mediaResourceId,
746
+ });
747
+ service.send({
748
+ type: TaskEvent.HOLD_SUCCESS,
749
+ mediaResourceId: heldTaskData.mediaResourceId,
750
+ taskData: heldTaskData,
751
+ });
752
+ expect(service.getSnapshot().value).toBe(TaskState.HELD);
753
+
754
+ const consultCreatedTaskData = createTaskData({
755
+ agentId: 'agent-1',
756
+ mediaResourceId: 'interaction-1',
757
+ consultMediaResourceId: 'consult-media',
758
+ consultingAgentId: 'agent-1',
759
+ destAgentId: 'agent-2',
760
+ isConsulted: false,
761
+ type: 'AgentConsultCreated' as any,
762
+ interaction: {
763
+ state: 'consult',
764
+ interactionId: 'interaction-1',
765
+ mainInteractionId: 'interaction-1',
766
+ owner: 'agent-1',
767
+ participants: {
768
+ 'agent-1': {
769
+ id: 'agent-1',
770
+ pType: 'Agent',
771
+ hasLeft: false,
772
+ consultState: 'consultInitiated',
773
+ isConsulted: false,
774
+ },
775
+ 'agent-2': {
776
+ id: 'agent-2',
777
+ pType: 'Agent',
778
+ hasLeft: false,
779
+ consultState: 'consultReserved',
780
+ isConsulted: true,
781
+ },
782
+ 'customer-1': {id: 'customer-1', pType: 'Customer', hasLeft: false, hasJoined: true},
783
+ } as any,
784
+ media: {
785
+ 'interaction-1': {
786
+ mediaResourceId: 'interaction-1',
787
+ mType: 'mainCall',
788
+ isHold: true,
789
+ participants: ['customer-1', 'agent-1'],
790
+ },
791
+ 'consult-media': {
792
+ mediaResourceId: 'consult-media',
793
+ mType: 'consult',
794
+ participants: ['agent-2', 'agent-1'],
795
+ },
796
+ } as any,
797
+ } as any,
798
+ });
799
+ service.send({type: TaskEvent.CONSULT_CREATED, taskData: consultCreatedTaskData});
800
+
801
+ const consultFailedTaskData = createTaskData({
802
+ agentId: 'agent-1',
803
+ mediaResourceId: 'interaction-1',
804
+ consultMediaResourceId: 'consult-media',
805
+ consultingAgentId: 'agent-1',
806
+ destAgentId: 'agent-2',
807
+ isConsulted: false,
808
+ type: 'AgentConsultFailed' as any,
809
+ interaction: {
810
+ state: 'connected',
811
+ interactionId: 'interaction-1',
812
+ mainInteractionId: 'interaction-1',
813
+ owner: 'agent-1',
814
+ participants: {
815
+ 'agent-1': {
816
+ id: 'agent-1',
817
+ pType: 'Agent',
818
+ hasLeft: false,
819
+ consultState: 'consultCompleted',
820
+ isConsulted: false,
821
+ },
822
+ 'agent-2': {
823
+ id: 'agent-2',
824
+ pType: 'Agent',
825
+ hasLeft: false,
826
+ consultState: 'consultReserved',
827
+ isConsulted: true,
828
+ },
829
+ 'customer-1': {id: 'customer-1', pType: 'Customer', hasLeft: false, hasJoined: true},
830
+ } as any,
831
+ media: {
832
+ 'interaction-1': {
833
+ mediaResourceId: 'interaction-1',
834
+ mType: 'mainCall',
835
+ isHold: true,
836
+ participants: ['customer-1', 'agent-1'],
837
+ },
838
+ } as any,
839
+ } as any,
840
+ });
841
+ service.send({type: TaskEvent.CONSULT_FAILED, taskData: consultFailedTaskData});
842
+
843
+ const consultEndedTaskData = createTaskData({
844
+ agentId: 'agent-1',
845
+ mediaResourceId: 'interaction-1',
846
+ consultMediaResourceId: 'consult-media',
847
+ isConsulted: false,
848
+ type: 'AgentConsultEnded' as any,
849
+ interaction: consultFailedTaskData.interaction,
850
+ });
851
+ service.send({type: TaskEvent.CONSULT_END, taskData: consultEndedTaskData});
852
+
853
+ const snapshot = service.getSnapshot();
854
+ expect(snapshot.value).toBe(TaskState.HELD);
855
+ expect(snapshot.context.consultInitiator).toBe(false);
856
+ expect(snapshot.context.uiControls.activeLeg).toBe('main');
857
+ expect(snapshot.context.uiControls.main.consult).toEqual({
858
+ isVisible: true,
859
+ isEnabled: true,
860
+ });
861
+ expect(snapshot.context.uiControls.main.hold).toEqual({
862
+ isVisible: true,
863
+ isEnabled: true,
864
+ });
865
+ expect(snapshot.context.uiControls.main.transfer).toEqual({
866
+ isVisible: true,
867
+ isEnabled: true,
868
+ });
869
+ });
870
+
871
+ it('returns to main leg (HELD) and does not clear the task after AgentConsultFailed then AgentConsultEnded while CONSULTING (RONA)', () => {
872
+ const service = startMachine();
873
+ const heldTaskData = createTaskData({
874
+ agentId: 'agent-1',
875
+ mediaResourceId: 'interaction-1',
876
+ type: 'AgentContactHeld' as any,
877
+ interaction: {
878
+ state: 'hold',
879
+ interactionId: 'interaction-1',
880
+ mainInteractionId: 'interaction-1',
881
+ owner: 'agent-1',
882
+ participants: {
883
+ 'agent-1': {id: 'agent-1', pType: 'Agent', hasLeft: false},
884
+ 'customer-1': {id: 'customer-1', pType: 'Customer', hasLeft: false, hasJoined: true},
885
+ } as any,
886
+ media: {
887
+ 'interaction-1': {
888
+ mediaResourceId: 'interaction-1',
889
+ mType: 'mainCall',
890
+ isHold: true,
891
+ participants: ['customer-1', 'agent-1'],
892
+ },
893
+ } as any,
894
+ } as any,
895
+ });
896
+
897
+ service.send({type: TaskEvent.TASK_INCOMING, taskData: heldTaskData});
898
+ service.send({type: TaskEvent.ASSIGN, taskData: heldTaskData});
899
+ service.send({type: TaskEvent.HOLD_INITIATED, mediaResourceId: heldTaskData.mediaResourceId});
900
+ service.send({
901
+ type: TaskEvent.HOLD_SUCCESS,
902
+ mediaResourceId: heldTaskData.mediaResourceId,
903
+ taskData: heldTaskData,
904
+ });
905
+ expect(service.getSnapshot().value).toBe(TaskState.HELD);
906
+
907
+ // Agent 1 initiates a consult and AgentConsulting arrives during ringing, moving the
908
+ // initiator into CONSULTING before the consultee answers.
909
+ service.send({type: TaskEvent.CONSULT, destination: 'agent-2', destinationType: 'agent'});
910
+ expect(service.getSnapshot().value).toBe(TaskState.CONSULT_INITIATING);
911
+ service.send({type: TaskEvent.CONSULT_SUCCESS});
912
+ expect(service.getSnapshot().value).toBe(TaskState.CONSULTING);
913
+ expect(service.getSnapshot().context.consultInitiator).toBe(true);
914
+
915
+ // Consultee RONAs: AgentConsultFailed arrives while still in CONSULTING. Main stays held.
916
+ const consultFailedTaskData = createTaskData({
917
+ agentId: 'agent-1',
918
+ mediaResourceId: 'interaction-1',
919
+ consultMediaResourceId: 'consult-media',
920
+ consultingAgentId: 'agent-1',
921
+ destAgentId: 'agent-2',
922
+ isConsulted: false,
923
+ type: 'AgentConsultFailed' as any,
924
+ interaction: {
925
+ state: 'consult',
926
+ interactionId: 'interaction-1',
927
+ mainInteractionId: 'interaction-1',
928
+ owner: 'agent-1',
929
+ participants: {
930
+ 'agent-1': {
931
+ id: 'agent-1',
932
+ pType: 'Agent',
933
+ hasLeft: false,
934
+ consultState: 'consultCompleted',
935
+ isConsulted: false,
936
+ },
937
+ 'agent-2': {
938
+ id: 'agent-2',
939
+ pType: 'Agent',
940
+ hasLeft: false,
941
+ hasJoined: false,
942
+ consultState: 'consultReserved',
943
+ isConsulted: true,
944
+ },
945
+ 'customer-1': {id: 'customer-1', pType: 'Customer', hasLeft: false, hasJoined: true},
946
+ } as any,
947
+ media: {
948
+ 'interaction-1': {
949
+ mediaResourceId: 'interaction-1',
950
+ mType: 'mainCall',
951
+ isHold: true,
952
+ participants: ['customer-1', 'agent-1'],
953
+ },
954
+ 'consult-media': {
955
+ mediaResourceId: 'consult-media',
956
+ mType: 'consult',
957
+ participants: ['agent-2', 'agent-1'],
958
+ },
959
+ } as any,
960
+ } as any,
961
+ });
962
+ service.send({type: TaskEvent.CONSULT_FAILED, taskData: consultFailedTaskData});
963
+ // The initiator must leave CONSULTING and fall back to the held main leg (not stay in
964
+ // CONSULTING, which would let the trailing AgentConsultEnded terminate the task).
965
+ expect(service.getSnapshot().value).toBe(TaskState.HELD);
966
+
967
+ // AgentConsultEnded closes out the consult leg.
968
+ const consultEndedTaskData = createTaskData({
969
+ agentId: 'agent-1',
970
+ mediaResourceId: 'interaction-1',
971
+ consultMediaResourceId: 'consult-media',
972
+ isConsulted: false,
973
+ type: 'AgentConsultEnded' as any,
974
+ interaction: {
975
+ state: 'connected',
976
+ interactionId: 'interaction-1',
977
+ mainInteractionId: 'interaction-1',
978
+ owner: 'agent-1',
979
+ participants: {
980
+ 'agent-1': {
981
+ id: 'agent-1',
982
+ pType: 'Agent',
983
+ hasLeft: false,
984
+ consultState: 'consultCompleted',
985
+ isConsulted: false,
986
+ },
987
+ 'customer-1': {id: 'customer-1', pType: 'Customer', hasLeft: false, hasJoined: true},
988
+ } as any,
989
+ media: {
990
+ 'interaction-1': {
991
+ mediaResourceId: 'interaction-1',
992
+ mType: 'mainCall',
993
+ isHold: true,
994
+ participants: ['customer-1', 'agent-1'],
995
+ },
996
+ } as any,
997
+ } as any,
998
+ });
999
+ service.send({type: TaskEvent.CONSULT_END, taskData: consultEndedTaskData});
1000
+
1001
+ const snapshot = service.getSnapshot();
1002
+ // The task must remain on the main leg (HELD) and never reach TERMINATED.
1003
+ expect(snapshot.value).toBe(TaskState.HELD);
1004
+ expect(snapshot.value).not.toBe(TaskState.TERMINATED);
1005
+ expect(snapshot.context.consultInitiator).toBe(false);
1006
+ expect(snapshot.context.uiControls.activeLeg).toBe('main');
1007
+ expect(snapshot.context.uiControls.main.consult).toEqual({
1008
+ isVisible: true,
1009
+ isEnabled: true,
1010
+ });
1011
+ });
1012
+
1013
+ it('transitions CONSULT_INITIATING to CONSULTING on CONSULTING_ACTIVE and marks destination joined', () => {
1014
+ const service = startMachine();
1015
+ const baseTaskData = createTaskData();
1016
+ const consultingTaskData = createTaskData({
1017
+ agentId: 'agent-1',
1018
+ isConsulted: false,
1019
+ consultMediaResourceId: 'consult-media',
1020
+ interaction: {
1021
+ state: 'consulting',
1022
+ interactionId: 'interaction-1',
1023
+ mainInteractionId: 'interaction-1',
1024
+ participants: {
1025
+ 'agent-1': {
1026
+ id: 'agent-1',
1027
+ pType: 'Agent',
1028
+ hasLeft: false,
1029
+ consultState: 'consulting',
1030
+ isConsulted: false,
1031
+ },
1032
+ 'agent-2': {
1033
+ id: 'agent-2',
1034
+ pType: 'Agent',
1035
+ hasLeft: false,
1036
+ hasJoined: true,
1037
+ consultState: 'consulting',
1038
+ isConsulted: true,
1039
+ },
1040
+ } as any,
1041
+ media: {
1042
+ 'interaction-1': {mediaResourceId: 'interaction-1', mType: 'mainCall', isHold: true},
1043
+ 'consult-media': {
1044
+ mediaResourceId: 'consult-media',
1045
+ mType: 'consult',
1046
+ participants: ['agent-1', 'agent-2'],
1047
+ },
1048
+ } as any,
1049
+ } as any,
1050
+ });
1051
+
1052
+ service.send({type: TaskEvent.TASK_INCOMING, taskData: baseTaskData});
1053
+ service.send({type: TaskEvent.ASSIGN, taskData: baseTaskData});
1054
+ service.send({
1055
+ type: TaskEvent.CONSULT,
1056
+ destination: 'agent-2',
1057
+ destinationType: 'agent',
1058
+ });
1059
+
1060
+ expect(service.getSnapshot().value).toBe(TaskState.CONSULT_INITIATING);
1061
+
1062
+ service.send({
1063
+ type: TaskEvent.CONSULTING_ACTIVE,
1064
+ consultDestinationAgentJoined: true,
1065
+ taskData: consultingTaskData,
1066
+ });
1067
+
1068
+ const snapshot = service.getSnapshot();
1069
+ expect(snapshot.value).toBe(TaskState.CONSULTING);
1070
+ expect(snapshot.context.consultInitiator).toBe(true);
1071
+ expect(snapshot.context.consultDestinationAgentJoined).toBe(true);
1072
+ });
1073
+
1074
+ it('keeps consultDestinationAgentJoined false while consultee is only reserved, then sets true on actual join', () => {
1075
+ const service = startMachine();
1076
+ const pendingTaskData = createTaskData({
1077
+ isConsulted: false,
1078
+ interaction: {
1079
+ state: 'conference',
1080
+ mainInteractionId: 'interaction-1',
1081
+ interactionId: 'interaction-1',
1082
+ participants: {
1083
+ 'agent-1': {
1084
+ id: 'agent-1',
1085
+ pType: 'Agent',
1086
+ hasLeft: false,
1087
+ consultState: 'consultInitiated',
1088
+ isConsulted: false,
1089
+ },
1090
+ 'agent-2': {
1091
+ id: 'agent-2',
1092
+ pType: 'Agent',
1093
+ hasLeft: false,
1094
+ hasJoined: false,
1095
+ consultState: 'consultReserved',
1096
+ isConsulted: true,
1097
+ },
1098
+ 'customer-1': {id: 'customer-1', pType: 'Customer', hasLeft: false},
1099
+ },
1100
+ media: {
1101
+ 'interaction-1': {
1102
+ mediaResourceId: 'interaction-1',
1103
+ mType: 'mainCall',
1104
+ participants: ['agent-1', 'customer-1'],
1105
+ isHold: true,
1106
+ },
1107
+ 'consult-media-1': {
1108
+ mediaResourceId: 'consult-media-1',
1109
+ mType: 'consult',
1110
+ participants: ['agent-1', 'agent-2'],
1111
+ isHold: false,
1112
+ },
1113
+ },
1114
+ } as any,
1115
+ });
1116
+
1117
+ service.send({type: TaskEvent.HYDRATE, taskData: pendingTaskData});
1118
+
1119
+ expect(service.getSnapshot().value).toBe(TaskState.CONSULTING);
1120
+ expect(service.getSnapshot().context.consultDestinationAgentJoined).toBe(false);
1121
+
1122
+ const joinedTaskData = {
1123
+ ...pendingTaskData,
1124
+ interaction: {
1125
+ ...pendingTaskData.interaction,
1126
+ participants: {
1127
+ ...pendingTaskData.interaction?.participants,
1128
+ 'agent-2': {
1129
+ ...pendingTaskData.interaction?.participants?.['agent-2'],
1130
+ hasJoined: true,
1131
+ consultState: 'consulting',
1132
+ },
1133
+ },
1134
+ },
1135
+ } as any;
1136
+
1137
+ service.send({type: TaskEvent.CONTACT_UPDATED, taskData: joinedTaskData});
1138
+
1139
+ expect(service.getSnapshot().context.consultDestinationAgentJoined).toBe(true);
1140
+ });
1141
+
1142
+ it('hydrates conference consult context flags for active consult leg controls', () => {
1143
+ const service = startMachine();
1144
+ const taskData = createTaskData({
1145
+ type: 'AgentContactUnheld' as any,
1146
+ isConsulted: false,
1147
+ consultingAgentId: 'agent-1',
1148
+ consultMediaResourceId: 'consult-media-1',
1149
+ interaction: {
1150
+ state: 'conference',
1151
+ mainInteractionId: 'interaction-1',
1152
+ interactionId: 'interaction-1',
1153
+ participants: {
1154
+ 'agent-1': {
1155
+ id: 'agent-1',
1156
+ pType: 'Agent',
1157
+ hasLeft: false,
1158
+ consultState: 'consulting',
1159
+ isConsulted: false,
1160
+ },
1161
+ 'agent-2': {
1162
+ id: 'agent-2',
1163
+ pType: 'Agent',
1164
+ hasLeft: false,
1165
+ hasJoined: true,
1166
+ consultState: 'consulting',
1167
+ isConsulted: true,
1168
+ },
1169
+ 'agent-3': {
1170
+ id: 'agent-3',
1171
+ pType: 'Agent',
1172
+ hasLeft: false,
1173
+ consultState: 'conferencing',
1174
+ isConsulted: false,
1175
+ },
1176
+ 'customer-1': {id: 'customer-1', pType: 'Customer', hasLeft: false},
1177
+ },
1178
+ media: {
1179
+ 'interaction-1': {
1180
+ mediaResourceId: 'interaction-1',
1181
+ mType: 'mainCall',
1182
+ participants: ['agent-1', 'agent-3', 'customer-1'],
1183
+ isHold: false,
1184
+ },
1185
+ 'consult-media-1': {
1186
+ mediaResourceId: 'consult-media-1',
1187
+ mType: 'consult',
1188
+ participants: ['agent-1', 'agent-2'],
1189
+ isHold: false,
1190
+ },
1191
+ },
1192
+ } as any,
1193
+ });
1194
+
1195
+ service.send({type: TaskEvent.HYDRATE, taskData});
1196
+
1197
+ const snapshot = service.getSnapshot();
1198
+ expect(snapshot.value).toBe(TaskState.CONSULTING);
1199
+ expect(snapshot.context.consultInitiator).toBe(true);
1200
+ expect(snapshot.context.consultFromConference).toBe(true);
1201
+ expect(snapshot.context.consultDestinationAgentJoined).toBe(true);
1202
+ expect(snapshot.context.consultCallHeld).toBe(false);
1203
+ });
1204
+
1205
+ it('returns to connected when consult ends after switching back to the main leg', () => {
1206
+ const service = startMachine();
1207
+ const taskData = createTaskData();
1208
+
1209
+ service.send({type: TaskEvent.TASK_INCOMING, taskData});
1210
+ service.send({type: TaskEvent.ASSIGN, taskData});
1211
+ service.send({
1212
+ type: TaskEvent.CONSULT,
1213
+ destination: 'agent-42',
1214
+ destinationType: 'agent',
1215
+ });
1216
+ service.send({type: TaskEvent.CONSULT_SUCCESS});
1217
+ service.send({type: TaskEvent.SWITCH_TO_MAIN_CALL});
1218
+
1219
+ expect(service.getSnapshot().context.consultCallHeld).toBe(true);
1220
+
1221
+ service.send({type: TaskEvent.CONSULT_END});
1222
+
1223
+ const snapshotAfterEnd = service.getSnapshot();
1224
+ expect(snapshotAfterEnd.value).toBe(TaskState.CONNECTED);
1225
+ expect(snapshotAfterEnd.context.consultCallHeld).toBe(false);
1226
+ });
1227
+
1228
+ it('downgrades to HELD on CONSULT_END when conference has downgraded and conferenceHoldParticipant is true', () => {
1229
+ const service = startMachine();
1230
+ const conferenceTaskData = createConferenceConsultTaskData({
1231
+ interactionState: 'conference',
1232
+ includeSecondAgent: true,
1233
+ conferenceHoldParticipant: false,
1234
+ });
1235
+ const downgradedHeldTaskData = createConferenceConsultTaskData({
1236
+ interactionState: 'hold',
1237
+ includeSecondAgent: false,
1238
+ conferenceHoldParticipant: true,
1239
+ isMainHeld: true,
1240
+ });
1241
+
1242
+ service.send({type: TaskEvent.TASK_INCOMING, taskData: conferenceTaskData});
1243
+ service.send({type: TaskEvent.ASSIGN, taskData: conferenceTaskData});
1244
+ service.send({type: TaskEvent.CONFERENCE_START, taskData: conferenceTaskData});
1245
+ expect(service.getSnapshot().value).toBe(TaskState.CONFERENCING);
1246
+
1247
+ service.send({type: TaskEvent.CONSULT, destination: 'agent-3', destinationType: 'agent'});
1248
+ expect(service.getSnapshot().value).toBe(TaskState.CONSULT_INITIATING);
1249
+ service.send({type: TaskEvent.CONSULT_SUCCESS, taskData: conferenceTaskData});
1250
+ expect(service.getSnapshot().value).toBe(TaskState.CONSULTING);
1251
+
1252
+ service.send({type: TaskEvent.CONSULT_END, taskData: downgradedHeldTaskData});
1253
+ expect(service.getSnapshot().value).toBe(TaskState.HELD);
1254
+ });
1255
+
1256
+ it('downgrades to CONNECTED on CONSULT_END when conference has downgraded and conferenceHoldParticipant is false', () => {
1257
+ const service = startMachine();
1258
+ const conferenceTaskData = createConferenceConsultTaskData({
1259
+ interactionState: 'conference',
1260
+ includeSecondAgent: true,
1261
+ conferenceHoldParticipant: false,
1262
+ });
1263
+ const downgradedConnectedTaskData = createConferenceConsultTaskData({
1264
+ interactionState: 'connected',
1265
+ includeSecondAgent: false,
1266
+ conferenceHoldParticipant: false,
1267
+ isMainHeld: false,
1268
+ });
1269
+
1270
+ service.send({type: TaskEvent.TASK_INCOMING, taskData: conferenceTaskData});
1271
+ service.send({type: TaskEvent.ASSIGN, taskData: conferenceTaskData});
1272
+ service.send({type: TaskEvent.CONFERENCE_START, taskData: conferenceTaskData});
1273
+ expect(service.getSnapshot().value).toBe(TaskState.CONFERENCING);
1274
+
1275
+ service.send({type: TaskEvent.CONSULT, destination: 'agent-3', destinationType: 'agent'});
1276
+ expect(service.getSnapshot().value).toBe(TaskState.CONSULT_INITIATING);
1277
+ service.send({type: TaskEvent.CONSULT_SUCCESS, taskData: conferenceTaskData});
1278
+ expect(service.getSnapshot().value).toBe(TaskState.CONSULTING);
1279
+
1280
+ service.send({type: TaskEvent.CONSULT_END, taskData: downgradedConnectedTaskData});
1281
+ expect(service.getSnapshot().value).toBe(TaskState.CONNECTED);
1282
+ });
1283
+
1284
+ it('returns to CONFERENCING on CONSULT_END when conference is still active', () => {
1285
+ const service = startMachine();
1286
+ const conferenceTaskData = createConferenceConsultTaskData({
1287
+ interactionState: 'conference',
1288
+ includeSecondAgent: true,
1289
+ conferenceHoldParticipant: false,
1290
+ });
1291
+ const stillConferenceTaskData = createConferenceConsultTaskData({
1292
+ interactionState: 'conference',
1293
+ includeSecondAgent: true,
1294
+ conferenceHoldParticipant: false,
1295
+ });
1296
+
1297
+ service.send({type: TaskEvent.TASK_INCOMING, taskData: conferenceTaskData});
1298
+ service.send({type: TaskEvent.ASSIGN, taskData: conferenceTaskData});
1299
+ service.send({type: TaskEvent.CONFERENCE_START, taskData: conferenceTaskData});
1300
+ expect(service.getSnapshot().value).toBe(TaskState.CONFERENCING);
1301
+
1302
+ service.send({type: TaskEvent.CONSULT, destination: 'agent-3', destinationType: 'agent'});
1303
+ expect(service.getSnapshot().value).toBe(TaskState.CONSULT_INITIATING);
1304
+ service.send({type: TaskEvent.CONSULT_SUCCESS, taskData: conferenceTaskData});
1305
+ expect(service.getSnapshot().value).toBe(TaskState.CONSULTING);
1306
+
1307
+ service.send({type: TaskEvent.CONSULT_END, taskData: stillConferenceTaskData});
1308
+ expect(service.getSnapshot().value).toBe(TaskState.CONFERENCING);
1309
+ });
1310
+
1311
+ it('transitions CONFERENCING to CONSULTING on CONSULTING_ACTIVE and marks DN consult joined', () => {
1312
+ const service = startMachine();
1313
+ const conferenceTaskData = createConferenceConsultTaskData({
1314
+ interactionState: 'conference',
1315
+ includeSecondAgent: true,
1316
+ conferenceHoldParticipant: false,
1317
+ });
1318
+
1319
+ service.send({type: TaskEvent.TASK_INCOMING, taskData: conferenceTaskData});
1320
+ service.send({type: TaskEvent.ASSIGN, taskData: conferenceTaskData});
1321
+ service.send({type: TaskEvent.CONFERENCE_START, taskData: conferenceTaskData});
1322
+ expect(service.getSnapshot().value).toBe(TaskState.CONFERENCING);
1323
+
1324
+ const dnConsultTaskData = createTaskData({
1325
+ type: 'AgentConsulting' as any,
1326
+ agentId: 'agent-1',
1327
+ consultingAgentId: 'agent-1',
1328
+ isConsulted: false,
1329
+ destinationType: 'DN',
1330
+ consultMediaResourceId: 'consult-media-1',
1331
+ interaction: {
1332
+ state: 'conference',
1333
+ mainInteractionId: 'interaction-1',
1334
+ interactionId: 'interaction-1',
1335
+ participants: {
1336
+ 'agent-1': {
1337
+ id: 'agent-1',
1338
+ pType: 'Agent',
1339
+ hasLeft: false,
1340
+ consultState: 'consulting',
1341
+ isConsulted: false,
1342
+ },
1343
+ 'agent-2': {
1344
+ id: 'agent-2',
1345
+ pType: 'Agent',
1346
+ hasLeft: false,
1347
+ consultState: 'conferencing',
1348
+ },
1349
+ 'dn-dest': {
1350
+ id: 'dn-dest',
1351
+ pType: 'DN',
1352
+ hasLeft: false,
1353
+ hasJoined: true,
1354
+ },
1355
+ 'customer-1': {id: 'customer-1', pType: 'Customer', hasLeft: false},
1356
+ },
1357
+ media: {
1358
+ 'interaction-1': {
1359
+ mediaResourceId: 'interaction-1',
1360
+ mType: 'mainCall',
1361
+ participants: ['agent-1', 'agent-2', 'customer-1'],
1362
+ isHold: false,
1363
+ },
1364
+ 'consult-media-1': {
1365
+ mediaResourceId: 'consult-media-1',
1366
+ mType: 'consult',
1367
+ participants: ['agent-1', 'dn-dest'],
1368
+ isHold: false,
1369
+ },
1370
+ },
1371
+ } as any,
1372
+ });
1373
+
1374
+ service.send({
1375
+ type: TaskEvent.CONSULTING_ACTIVE,
1376
+ consultDestinationAgentJoined: true,
1377
+ taskData: dnConsultTaskData,
1378
+ });
1379
+
1380
+ const snapshot = service.getSnapshot();
1381
+ expect(snapshot.value).toBe(TaskState.CONSULTING);
1382
+ expect(snapshot.context.consultDestinationAgentJoined).toBe(true);
1383
+ expect(snapshot.context.consultFromConference).toBe(true);
1384
+ expect(snapshot.context.consultDestinationType).toBe('entryPoint');
1385
+ });
1386
+
1387
+ it('transitions to conferencing when merge event is received', () => {
1388
+ const service = startMachine();
1389
+ const taskData = createTaskData({consultingAgentId: 'agent-1'});
1390
+
1391
+ service.send({type: TaskEvent.TASK_INCOMING, taskData});
1392
+ service.send({type: TaskEvent.ASSIGN, taskData});
1393
+ service.send({
1394
+ type: TaskEvent.CONSULT,
1395
+ destination: 'agent-42',
1396
+ destinationType: 'agent',
1397
+ });
1398
+ expect(service.getSnapshot().value).toBe(TaskState.CONSULT_INITIATING);
1399
+ service.send({type: TaskEvent.CONSULT_SUCCESS, taskData});
1400
+ expect(service.getSnapshot().value).toBe(TaskState.CONSULTING);
1401
+
1402
+ service.send({type: TaskEvent.MERGE_TO_CONFERENCE});
1403
+ expect(service.getSnapshot().value).toBe(TaskState.CONF_INITIATING);
1404
+
1405
+ service.send({type: TaskEvent.CONFERENCE_START});
1406
+ expect(service.getSnapshot().value).toBe(TaskState.CONFERENCING);
1407
+ });
1408
+
1409
+ it('terminates via wrapup when EXIT_CONFERENCE_SUCCESS is received in conferencing', () => {
1410
+ const service = startMachine();
1411
+ const taskData = createTaskData({
1412
+ // shouldWrapUpForThisAgent will return true when owner matches self agent
1413
+ interaction: {
1414
+ owner: 'agent-1',
1415
+ state: 'conference',
1416
+ mainInteractionId: 'interaction-1',
1417
+ interactionId: 'interaction-1',
1418
+ participants: {
1419
+ 'agent-1': {
1420
+ id: 'agent-1',
1421
+ pType: 'Agent',
1422
+ type: 'Agent',
1423
+ hasJoined: true,
1424
+ hasLeft: false,
1425
+ isInPredial: false,
1426
+ },
1427
+ c1: {
1428
+ id: 'c1',
1429
+ pType: 'Customer',
1430
+ type: 'Customer',
1431
+ hasJoined: true,
1432
+ hasLeft: false,
1433
+ isInPredial: false,
1434
+ },
1435
+ },
1436
+ media: {
1437
+ 'interaction-1': {
1438
+ mediaResourceId: 'interaction-1',
1439
+ mediaType: 'telephony',
1440
+ mediaMgr: 'mm',
1441
+ participants: ['agent-1', 'c1'],
1442
+ mType: 'mainCall',
1443
+ isHold: false,
1444
+ holdTimestamp: null,
1445
+ },
1446
+ },
1447
+ } as any,
1448
+ });
1449
+
1450
+ service.send({type: TaskEvent.TASK_INCOMING, taskData});
1451
+ service.send({type: TaskEvent.ASSIGN, taskData});
1452
+ service.send({
1453
+ type: TaskEvent.CONSULT,
1454
+ destination: 'agent-42',
1455
+ destinationType: 'agent',
1456
+ });
1457
+ service.send({type: TaskEvent.CONSULT_SUCCESS, taskData});
1458
+ service.send({type: TaskEvent.MERGE_TO_CONFERENCE});
1459
+ service.send({type: TaskEvent.CONFERENCE_START, taskData});
1460
+ expect(service.getSnapshot().value).toBe(TaskState.CONFERENCING);
1461
+
1462
+ service.send({type: TaskEvent.EXIT_CONFERENCE_SUCCESS, taskData});
1463
+ expect(service.getSnapshot().value).toBe(TaskState.WRAPPING_UP);
1464
+ });
1465
+
1466
+ it('terminates when EXIT_CONFERENCE_SUCCESS is received in conferencing and wrapup is not required', () => {
1467
+ const service = startMachine();
1468
+ const taskData = createTaskData({
1469
+ isConsulted: true,
1470
+ wrapUpRequired: false,
1471
+ interaction: {
1472
+ owner: 'other-agent',
1473
+ state: 'conference',
1474
+ mainInteractionId: 'interaction-1',
1475
+ interactionId: 'interaction-1',
1476
+ participants: {
1477
+ 'agent-1': {
1478
+ id: 'agent-1',
1479
+ pType: 'Agent',
1480
+ type: 'Agent',
1481
+ hasJoined: true,
1482
+ hasLeft: false,
1483
+ isInPredial: false,
1484
+ isWrapUp: false,
1485
+ },
1486
+ c1: {
1487
+ id: 'c1',
1488
+ pType: 'Customer',
1489
+ type: 'Customer',
1490
+ hasJoined: true,
1491
+ hasLeft: false,
1492
+ isInPredial: false,
1493
+ },
1494
+ },
1495
+ media: {
1496
+ 'interaction-1': {
1497
+ mediaResourceId: 'interaction-1',
1498
+ mediaType: 'telephony',
1499
+ mediaMgr: 'mm',
1500
+ participants: ['agent-1', 'c1'],
1501
+ mType: 'mainCall',
1502
+ isHold: false,
1503
+ holdTimestamp: null,
1504
+ },
1505
+ },
1506
+ } as any,
1507
+ });
1508
+
1509
+ service.send({type: TaskEvent.TASK_INCOMING, taskData});
1510
+ service.send({type: TaskEvent.ASSIGN, taskData});
1511
+ service.send({
1512
+ type: TaskEvent.CONSULT,
1513
+ destination: 'agent-42',
1514
+ destinationType: 'agent',
1515
+ });
1516
+ service.send({type: TaskEvent.CONSULT_SUCCESS, taskData});
1517
+ service.send({type: TaskEvent.MERGE_TO_CONFERENCE});
1518
+ service.send({type: TaskEvent.CONFERENCE_START, taskData});
1519
+ expect(service.getSnapshot().value).toBe(TaskState.CONFERENCING);
1520
+
1521
+ service.send({type: TaskEvent.EXIT_CONFERENCE_SUCCESS, taskData});
1522
+ expect(service.getSnapshot().value).toBe(TaskState.TERMINATED);
1523
+ });
1524
+
1525
+ it('downgrades to HELD on HOLD_SUCCESS when no other agents remain on task', () => {
1526
+ const service = startMachine();
1527
+ const conferenceTaskData = createSingleAgentConferenceTaskData('conference');
1528
+ const heldTaskData = createSingleAgentConferenceTaskData('hold', true);
1529
+
1530
+ service.send({type: TaskEvent.TASK_INCOMING, taskData: conferenceTaskData});
1531
+ service.send({type: TaskEvent.ASSIGN, taskData: conferenceTaskData});
1532
+ service.send({type: TaskEvent.CONFERENCE_START, taskData: conferenceTaskData});
1533
+ expect(service.getSnapshot().value).toBe(TaskState.CONFERENCING);
1534
+
1535
+ service.send({
1536
+ type: TaskEvent.HOLD_SUCCESS,
1537
+ mediaResourceId: heldTaskData.mediaResourceId,
1538
+ taskData: heldTaskData,
1539
+ });
1540
+
1541
+ expect(service.getSnapshot().value).toBe(TaskState.HELD);
1542
+ });
1543
+
1544
+ it('downgrades to CONNECTED on UNHOLD_SUCCESS when no other agents remain on task', () => {
1545
+ const service = startMachine();
1546
+ const conferenceTaskData = createSingleAgentConferenceTaskData('conference');
1547
+ const connectedTaskData = createSingleAgentConferenceTaskData('connected', false);
1548
+
1549
+ service.send({type: TaskEvent.TASK_INCOMING, taskData: conferenceTaskData});
1550
+ service.send({type: TaskEvent.ASSIGN, taskData: conferenceTaskData});
1551
+ service.send({type: TaskEvent.CONFERENCE_START, taskData: conferenceTaskData});
1552
+ expect(service.getSnapshot().value).toBe(TaskState.CONFERENCING);
1553
+
1554
+ service.send({
1555
+ type: TaskEvent.UNHOLD_SUCCESS,
1556
+ mediaResourceId: connectedTaskData.mediaResourceId,
1557
+ taskData: connectedTaskData,
1558
+ });
1559
+
1560
+ expect(service.getSnapshot().value).toBe(TaskState.CONNECTED);
1561
+ });
1562
+
1563
+ it('returns to CONNECTED when CTQ cancel arrives before queue connects', () => {
1564
+ const service = startMachine();
1565
+ const taskData = createTaskData();
1566
+
1567
+ service.send({type: TaskEvent.TASK_INCOMING, taskData});
1568
+ service.send({type: TaskEvent.ASSIGN, taskData});
1569
+
1570
+ service.send({
1571
+ type: TaskEvent.CONSULT,
1572
+ destination: 'queue-1',
1573
+ destinationType: 'queue',
1574
+ });
1575
+ expect(service.getSnapshot().value).toBe(TaskState.CONSULT_INITIATING);
1576
+
1577
+ service.send({type: TaskEvent.CTQ_CANCEL});
1578
+ expect(service.getSnapshot().value).toBe(TaskState.CONNECTED);
1579
+ });
1580
+
1581
+ it('returns to CONNECTED when CTQ consult fails', () => {
1582
+ const service = startMachine();
1583
+ const taskData = createTaskData();
1584
+
1585
+ service.send({type: TaskEvent.TASK_INCOMING, taskData});
1586
+ service.send({type: TaskEvent.ASSIGN, taskData});
1587
+
1588
+ service.send({
1589
+ type: TaskEvent.CONSULT,
1590
+ destination: 'queue-1',
1591
+ destinationType: 'queue',
1592
+ });
1593
+ expect(service.getSnapshot().value).toBe(TaskState.CONSULT_INITIATING);
1594
+
1595
+ service.send({type: TaskEvent.CONSULT_FAILED, taskData});
1596
+ expect(service.getSnapshot().value).toBe(TaskState.CONNECTED);
1597
+ });
1598
+ });
1599
+
1600
+ describe('failure scenarios', () => {
1601
+ it('returns to CONNECTED when HOLD fails', () => {
1602
+ const service = startMachine();
1603
+ const taskData = createTaskData();
1604
+
1605
+ service.send({type: TaskEvent.TASK_INCOMING, taskData});
1606
+ service.send({type: TaskEvent.ASSIGN, taskData});
1607
+ service.send({type: TaskEvent.HOLD_INITIATED, mediaResourceId: taskData.mediaResourceId});
1608
+ expect(service.getSnapshot().value).toBe(TaskState.HOLD_INITIATING);
1609
+
1610
+ service.send({
1611
+ type: TaskEvent.HOLD_FAILED,
1612
+ mediaResourceId: taskData.mediaResourceId,
1613
+ });
1614
+ expect(service.getSnapshot().value).toBe(TaskState.CONNECTED);
1615
+ });
1616
+
1617
+ it('falls back to HELD when UNHOLD fails', () => {
1618
+ const service = startMachine();
1619
+ const taskData = createTaskData();
1620
+
1621
+ service.send({type: TaskEvent.TASK_INCOMING, taskData});
1622
+ service.send({type: TaskEvent.ASSIGN, taskData});
1623
+ service.send({type: TaskEvent.HOLD_INITIATED, mediaResourceId: taskData.mediaResourceId});
1624
+ service.send({type: TaskEvent.HOLD_SUCCESS, mediaResourceId: taskData.mediaResourceId});
1625
+ expect(service.getSnapshot().value).toBe(TaskState.HELD);
1626
+
1627
+ service.send({type: TaskEvent.UNHOLD_INITIATED, mediaResourceId: taskData.mediaResourceId});
1628
+ expect(service.getSnapshot().value).toBe(TaskState.RESUME_INITIATING);
1629
+
1630
+ service.send({type: TaskEvent.UNHOLD_FAILED, mediaResourceId: taskData.mediaResourceId});
1631
+ expect(service.getSnapshot().value).toBe(TaskState.HELD);
1632
+ });
1633
+ });
1634
+
1635
+ describe('CONF_INITIATING state event handlers', () => {
1636
+ it('transitions to CONFERENCING on CONFERENCE_START', () => {
1637
+ const service = startMachine();
1638
+ const taskData = createTaskData({consultingAgentId: 'agent-1'});
1639
+
1640
+ service.send({type: TaskEvent.TASK_INCOMING, taskData});
1641
+ service.send({type: TaskEvent.ASSIGN, taskData});
1642
+ service.send({
1643
+ type: TaskEvent.CONSULT,
1644
+ destination: 'agent-42',
1645
+ destinationType: 'agent',
1646
+ });
1647
+ service.send({type: TaskEvent.CONSULT_SUCCESS, taskData});
1648
+ service.send({type: TaskEvent.MERGE_TO_CONFERENCE});
1649
+ expect(service.getSnapshot().value).toBe(TaskState.CONF_INITIATING);
1650
+
1651
+ service.send({type: TaskEvent.CONFERENCE_START, taskData});
1652
+ expect(service.getSnapshot().value).toBe(TaskState.CONFERENCING);
1653
+ });
1654
+
1655
+ it('transitions to WRAPPING_UP on CONSULT_END with isTerminated during CONF_INITIATING', () => {
1656
+ const service = startMachine();
1657
+ const taskData = createTaskData({consultingAgentId: 'agent-1'});
1658
+
1659
+ service.send({type: TaskEvent.TASK_INCOMING, taskData});
1660
+ service.send({type: TaskEvent.ASSIGN, taskData});
1661
+ service.send({
1662
+ type: TaskEvent.CONSULT,
1663
+ destination: 'agent-42',
1664
+ destinationType: 'agent',
1665
+ });
1666
+ service.send({type: TaskEvent.CONSULT_SUCCESS, taskData});
1667
+ service.send({type: TaskEvent.MERGE_TO_CONFERENCE});
1668
+ expect(service.getSnapshot().value).toBe(TaskState.CONF_INITIATING);
1669
+
1670
+ const terminatedTaskData = createTaskData({
1671
+ consultingAgentId: 'agent-1',
1672
+ interaction: {
1673
+ isTerminated: true,
1674
+ owner: 'agent-1',
1675
+ } as any,
1676
+ });
1677
+ service.send({type: TaskEvent.CONSULT_END, taskData: terminatedTaskData});
1678
+ expect(service.getSnapshot().value).toBe(TaskState.WRAPPING_UP);
1679
+ });
1680
+
1681
+ it('transitions to CONNECTED on CONSULT_END without isTerminated during CONF_INITIATING', () => {
1682
+ const service = startMachine();
1683
+ const taskData = createTaskData({consultingAgentId: 'agent-1'});
1684
+
1685
+ service.send({type: TaskEvent.TASK_INCOMING, taskData});
1686
+ service.send({type: TaskEvent.ASSIGN, taskData});
1687
+ service.send({
1688
+ type: TaskEvent.CONSULT,
1689
+ destination: 'agent-42',
1690
+ destinationType: 'agent',
1691
+ });
1692
+ service.send({type: TaskEvent.CONSULT_SUCCESS, taskData});
1693
+ service.send({type: TaskEvent.MERGE_TO_CONFERENCE});
1694
+ expect(service.getSnapshot().value).toBe(TaskState.CONF_INITIATING);
1695
+
1696
+ service.send({type: TaskEvent.CONSULT_END, taskData});
1697
+ expect(service.getSnapshot().value).toBe(TaskState.CONNECTED);
1698
+ });
1699
+
1700
+ });
1701
+
1702
+ describe('CONFERENCING state CONSULT_END with terminated interaction', () => {
1703
+ it('transitions to WRAPPING_UP when CONSULT_END arrives with isTerminated in CONFERENCING', () => {
1704
+ const service = startMachine();
1705
+ const taskData = createTaskData({
1706
+ consultingAgentId: 'agent-1',
1707
+ interaction: {
1708
+ owner: 'agent-1',
1709
+ state: 'conference',
1710
+ } as any,
1711
+ });
1712
+
1713
+ service.send({type: TaskEvent.TASK_INCOMING, taskData});
1714
+ service.send({type: TaskEvent.ASSIGN, taskData});
1715
+ service.send({
1716
+ type: TaskEvent.CONSULT,
1717
+ destination: 'agent-42',
1718
+ destinationType: 'agent',
1719
+ });
1720
+ service.send({type: TaskEvent.CONSULT_SUCCESS, taskData});
1721
+ service.send({type: TaskEvent.MERGE_TO_CONFERENCE});
1722
+ service.send({type: TaskEvent.CONFERENCE_START, taskData});
1723
+ expect(service.getSnapshot().value).toBe(TaskState.CONFERENCING);
1724
+
1725
+ const terminatedTaskData = createTaskData({
1726
+ consultingAgentId: 'agent-1',
1727
+ interaction: {
1728
+ isTerminated: true,
1729
+ owner: 'agent-1',
1730
+ state: 'conference',
1731
+ } as any,
1732
+ });
1733
+ service.send({type: TaskEvent.CONSULT_END, taskData: terminatedTaskData});
1734
+ expect(service.getSnapshot().value).toBe(TaskState.WRAPPING_UP);
1735
+ });
1736
+ });
1737
+
1738
+ describe('OFFERED state event handlers', () => {
1739
+ it('transitions to TERMINATED when customer disconnects before agent answers', () => {
1740
+ const service = startMachine();
1741
+ const taskData = createTaskData({isConsulted: false});
1742
+
1743
+ service.send({type: TaskEvent.TASK_INCOMING, taskData});
1744
+ expect(service.getSnapshot().value).toBe(TaskState.OFFERED);
1745
+
1746
+ service.send({type: TaskEvent.CONTACT_ENDED, taskData});
1747
+ expect(service.getSnapshot().value).toBe(TaskState.TERMINATED);
1748
+ });
1749
+
1750
+ it('transitions to TERMINATED when consulted agent does not answer', () => {
1751
+ const service = startMachine();
1752
+ const taskData = createTaskData({
1753
+ isConsulted: true,
1754
+ consultingAgentId: 'agent-1',
1755
+ });
1756
+
1757
+ service.send({type: TaskEvent.TASK_INCOMING, taskData});
1758
+ expect(service.getSnapshot().value).toBe(TaskState.OFFERED);
1759
+
1760
+ service.send({type: TaskEvent.CONSULT_FAILED, taskData});
1761
+ expect(service.getSnapshot().value).toBe(TaskState.TERMINATED);
1762
+ });
1763
+ });
1764
+
1765
+ describe('OUTBOUND_FAILED handling', () => {
1766
+ it('transitions from IDLE to TERMINATED on OUTBOUND_FAILED (race condition)', () => {
1767
+ const service = startMachine();
1768
+ expect(service.getSnapshot().value).toBe(TaskState.IDLE);
1769
+
1770
+ const taskData = createTaskData({
1771
+ interaction: {
1772
+ outboundType: 'OUTDIAL',
1773
+ isTerminated: true,
1774
+ } as any,
1775
+ });
1776
+
1777
+ service.send({type: TaskEvent.OUTBOUND_FAILED, taskData, reason: 'CUSTOMER_BUSY'});
1778
+ expect(service.getSnapshot().value).toBe(TaskState.TERMINATED);
1779
+ });
1780
+
1781
+ it('transitions from OFFERED to TERMINATED on OUTBOUND_FAILED without wrapup', () => {
1782
+ const service = startMachine();
1783
+ const offerTaskData = createTaskData({
1784
+ interaction: {
1785
+ outboundType: 'OUTDIAL',
1786
+ } as any,
1787
+ });
1788
+
1789
+ service.send({type: TaskEvent.TASK_INCOMING, taskData: offerTaskData});
1790
+ expect(service.getSnapshot().value).toBe(TaskState.OFFERED);
1791
+
1792
+ const failedTaskData = createTaskData({
1793
+ interaction: {
1794
+ outboundType: 'OUTDIAL',
1795
+ isTerminated: true,
1796
+ } as any,
1797
+ });
1798
+ service.send({type: TaskEvent.OUTBOUND_FAILED, taskData: failedTaskData, reason: 'CUSTOMER_BUSY'});
1799
+ expect(service.getSnapshot().value).toBe(TaskState.TERMINATED);
1800
+ });
1801
+
1802
+ it('transitions from OFFERED to WRAPPING_UP on OUTBOUND_FAILED when wrapup is required', () => {
1803
+ const service = startMachine();
1804
+ const offerTaskData = createTaskData({
1805
+ interaction: {
1806
+ outboundType: 'OUTDIAL',
1807
+ } as any,
1808
+ });
1809
+
1810
+ service.send({type: TaskEvent.TASK_INCOMING, taskData: offerTaskData});
1811
+ expect(service.getSnapshot().value).toBe(TaskState.OFFERED);
1812
+
1813
+ const failedTaskData = createTaskData({
1814
+ agentId: 'agent-1',
1815
+ agentsPendingWrapUp: ['agent-1'],
1816
+ interaction: {
1817
+ outboundType: 'OUTDIAL',
1818
+ isTerminated: true,
1819
+ } as any,
1820
+ });
1821
+ service.send({type: TaskEvent.OUTBOUND_FAILED, taskData: failedTaskData, reason: 'CUSTOMER_BUSY'});
1822
+ expect(service.getSnapshot().value).toBe(TaskState.WRAPPING_UP);
1823
+ });
1824
+ });
1825
+ });