@webex/contact-center 3.12.0-task-refactor.5 → 3.12.0-task-refactor.6

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 (30) hide show
  1. package/dist/services/task/TaskUtils.js +8 -6
  2. package/dist/services/task/TaskUtils.js.map +1 -1
  3. package/dist/services/task/state-machine/TaskStateMachine.js +71 -13
  4. package/dist/services/task/state-machine/TaskStateMachine.js.map +1 -1
  5. package/dist/services/task/state-machine/actions.js +85 -13
  6. package/dist/services/task/state-machine/actions.js.map +1 -1
  7. package/dist/services/task/state-machine/guards.js +35 -0
  8. package/dist/services/task/state-machine/guards.js.map +1 -1
  9. package/dist/services/task/state-machine/uiControlsComputer.js +69 -7
  10. package/dist/services/task/state-machine/uiControlsComputer.js.map +1 -1
  11. package/dist/services/task/voice/Voice.js +1 -1
  12. package/dist/services/task/voice/Voice.js.map +1 -1
  13. package/dist/types/services/task/state-machine/TaskStateMachine.d.ts +60 -8
  14. package/dist/types/services/task/state-machine/guards.d.ts +5 -0
  15. package/dist/webex.js +1 -1
  16. package/package.json +1 -1
  17. package/src/services/task/TaskUtils.ts +8 -6
  18. package/src/services/task/state-machine/TaskStateMachine.ts +94 -15
  19. package/src/services/task/state-machine/actions.ts +148 -24
  20. package/src/services/task/state-machine/guards.ts +46 -0
  21. package/src/services/task/state-machine/uiControlsComputer.ts +150 -9
  22. package/src/services/task/voice/Voice.ts +4 -1
  23. package/test/unit/spec/services/WebCallingService.ts +7 -1
  24. package/test/unit/spec/services/task/TaskUtils.ts +16 -0
  25. package/test/unit/spec/services/task/state-machine/TaskStateMachine.ts +512 -0
  26. package/test/unit/spec/services/task/state-machine/guards.ts +88 -0
  27. package/test/unit/spec/services/task/state-machine/uiControlsComputer.ts +929 -0
  28. package/test/unit/spec/services/task/voice/Voice.ts +20 -0
  29. package/umd/contact-center.min.js +2 -2
  30. package/umd/contact-center.min.js.map +1 -1
@@ -134,11 +134,11 @@ export function getTaskStateMachineConfig(uiControlConfig: UIControlConfig) {
134
134
  [TaskEvent.CONSULTING_ACTIVE]: {
135
135
  target: TaskState.CONSULTING,
136
136
  actions: [
137
- 'updateTaskData',
138
137
  'setConsultInitiator',
139
138
  'setConsultDestination',
140
139
  'setConsultFromConference',
141
140
  'setConsultAgentJoined',
141
+ 'updateTaskData',
142
142
  'emitTaskConsultAccepted',
143
143
  'emitTaskConsulting',
144
144
  ],
@@ -204,8 +204,9 @@ export function getTaskStateMachineConfig(uiControlConfig: UIControlConfig) {
204
204
  {
205
205
  target: TaskState.CONSULTING,
206
206
  actions: [
207
- 'updateTaskData',
208
207
  'setConsultAgentJoined',
208
+ 'setConsultDestination',
209
+ 'updateTaskData',
209
210
  'emitTaskConsultAccepted',
210
211
  'emitTaskConsulting',
211
212
  ],
@@ -232,15 +233,25 @@ export function getTaskStateMachineConfig(uiControlConfig: UIControlConfig) {
232
233
 
233
234
  [TaskState.CONNECTED]: {
234
235
  on: {
236
+ // AgentConsultConferenced / ParticipantJoinedConference can arrive while connected.
237
+ [TaskEvent.CONFERENCE_START]: {
238
+ target: TaskState.CONFERENCING,
239
+ actions: [
240
+ 'updateTaskData',
241
+ 'syncTaskDataFromEvent',
242
+ 'clearConsultState',
243
+ 'emitTaskConferenceStarted',
244
+ ],
245
+ },
235
246
  // AgentConsulting may arrive while machine is CONNECTED (EP-DN/event ordering).
236
247
  // Derive consultInitiator from payload so controls are set correctly.
237
248
  [TaskEvent.CONSULTING_ACTIVE]: {
238
249
  target: TaskState.CONSULTING,
239
250
  actions: [
240
- 'updateTaskData',
241
251
  'setConsultInitiator',
242
252
  'setConsultDestination',
243
253
  'setConsultAgentJoined',
254
+ 'updateTaskData',
244
255
  'emitTaskConsultAccepted',
245
256
  'emitTaskConsulting',
246
257
  ],
@@ -329,6 +340,22 @@ export function getTaskStateMachineConfig(uiControlConfig: UIControlConfig) {
329
340
 
330
341
  [TaskState.HELD]: {
331
342
  on: {
343
+ // Conference can be merged while this agent is in held state after refresh/recovery.
344
+ [TaskEvent.CONFERENCE_START]: {
345
+ target: TaskState.CONFERENCING,
346
+ actions: [
347
+ 'updateTaskData',
348
+ 'syncTaskDataFromEvent',
349
+ 'clearConsultState',
350
+ 'emitTaskConferenceStarted',
351
+ ],
352
+ },
353
+ [TaskEvent.PAUSE_RECORDING]: {
354
+ actions: ['updateTaskData', 'setRecordingState', 'emitTaskRecordingPaused'],
355
+ },
356
+ [TaskEvent.RESUME_RECORDING]: {
357
+ actions: ['updateTaskData', 'setRecordingState', 'emitTaskRecordingResumed'],
358
+ },
332
359
  // Click of the unhold button
333
360
  [TaskEvent.UNHOLD_INITIATED]: {
334
361
  target: TaskState.RESUME_INITIATING,
@@ -448,9 +475,9 @@ export function getTaskStateMachineConfig(uiControlConfig: UIControlConfig) {
448
475
  // AgentConsulting updates consulted agent arrival
449
476
  [TaskEvent.CONSULTING_ACTIVE]: {
450
477
  actions: [
451
- 'updateTaskData',
452
478
  'setConsultAgentJoined',
453
479
  'setConsultDestination',
480
+ 'updateTaskData',
454
481
  'emitTaskConsulting',
455
482
  ],
456
483
  },
@@ -458,14 +485,33 @@ export function getTaskStateMachineConfig(uiControlConfig: UIControlConfig) {
458
485
  // AgentConsultEnded
459
486
  [TaskEvent.CONSULT_END]: [
460
487
  {
461
- // Initiator returning to conference (flag set OR backend still shows conference)
488
+ // Initiator returning to conference only while conference is still active.
462
489
  guard: ({context, event}) =>
463
490
  context.consultInitiator === true &&
464
- (context.consultFromConference === true ||
465
- guards.conferenceInProgressFromEvent({context, event})),
491
+ guards.conferenceInProgressFromEvent({context, event}),
466
492
  target: TaskState.CONFERENCING,
467
493
  actions: ['updateTaskData', 'clearConsultState', 'emitTaskConsultEnd'],
468
494
  },
495
+ {
496
+ // Conference consult ended after conference downgrade while main leg is held.
497
+ guard: ({context, event}) =>
498
+ context.consultInitiator === true &&
499
+ context.consultFromConference === true &&
500
+ !guards.conferenceInProgressFromEvent({context, event}) &&
501
+ guards.isConferenceHoldParticipantFromEvent({context, event}),
502
+ target: TaskState.HELD,
503
+ actions: ['updateTaskData', 'clearConsultState', 'emitTaskConsultEnd'],
504
+ },
505
+ {
506
+ // Conference consult ended after conference downgrade while main leg is connected.
507
+ guard: ({context, event}) =>
508
+ context.consultInitiator === true &&
509
+ context.consultFromConference === true &&
510
+ !guards.conferenceInProgressFromEvent({context, event}) &&
511
+ !guards.isConferenceHoldParticipantFromEvent({context, event}),
512
+ target: TaskState.CONNECTED,
513
+ actions: ['updateTaskData', 'clearConsultState', 'emitTaskConsultEnd'],
514
+ },
469
515
  {
470
516
  // Initiator already switched back to the main/customer leg
471
517
  guard: ({context}) =>
@@ -474,13 +520,18 @@ export function getTaskStateMachineConfig(uiControlConfig: UIControlConfig) {
474
520
  actions: ['updateTaskData', 'clearConsultState', 'emitTaskConsultEnd'],
475
521
  },
476
522
  {
477
- // Customer left during consult → WRAPPING_UP
523
+ // Interaction terminated during consult (customer left) → WRAPPING_UP
478
524
  guard: ({context, event}) => {
525
+ // if (context.consultInitiator !== true) return false;
526
+ // const taskData = getTaskDataFromEvent(event);
479
527
  const taskData = getTaskDataFromEvent(event);
480
528
  const cpd = taskData?.interaction?.callProcessingDetails;
481
529
  if (cpd?.hasCustomerLeft !== 'true') return false;
482
530
 
483
- return shouldWrapUpForThisAgent(context, taskData);
531
+ return (
532
+ taskData?.interaction?.isTerminated === true &&
533
+ shouldWrapUpForThisAgent(context, taskData)
534
+ );
484
535
  },
485
536
  target: TaskState.WRAPPING_UP,
486
537
  actions: [
@@ -698,12 +749,40 @@ export function getTaskStateMachineConfig(uiControlConfig: UIControlConfig) {
698
749
  },
699
750
  ],
700
751
 
701
- [TaskEvent.HOLD_SUCCESS]: {
702
- actions: ['updateTaskData', 'setHoldState', 'emitTaskHold'],
703
- },
704
- [TaskEvent.UNHOLD_SUCCESS]: {
705
- actions: ['updateTaskData', 'syncTaskDataFromEvent', 'setHoldState', 'emitTaskResume'],
706
- },
752
+ [TaskEvent.HOLD_SUCCESS]: [
753
+ {
754
+ // Conference already downgraded (no other agents) and backend hold arrives.
755
+ // Move to HELD so the UI renders resume action.
756
+ guard: guards.shouldDowngradeConferenceToConnected,
757
+ target: TaskState.HELD,
758
+ actions: ['updateTaskData', 'setHoldState', 'emitTaskHold'],
759
+ },
760
+ {
761
+ actions: ['updateTaskData', 'setHoldState', 'emitTaskHold'],
762
+ },
763
+ ],
764
+ [TaskEvent.UNHOLD_SUCCESS]: [
765
+ {
766
+ // Conference already downgraded (no other agents) and backend unhold arrives.
767
+ // Move to CONNECTED so hold action is available again.
768
+ guard: guards.shouldDowngradeConferenceToConnected,
769
+ target: TaskState.CONNECTED,
770
+ actions: [
771
+ 'updateTaskData',
772
+ 'syncTaskDataFromEvent',
773
+ 'setHoldState',
774
+ 'emitTaskResume',
775
+ ],
776
+ },
777
+ {
778
+ actions: [
779
+ 'updateTaskData',
780
+ 'syncTaskDataFromEvent',
781
+ 'setHoldState',
782
+ 'emitTaskResume',
783
+ ],
784
+ },
785
+ ],
707
786
 
708
787
  // Start a new consult from within an active conference
709
788
  [TaskEvent.CONSULT]: {
@@ -11,9 +11,16 @@ import {
11
11
  TaskActionArgs,
12
12
  RecordingStateUpdate,
13
13
  } from './types';
14
- import {TaskEvent, TaskState, INTERACTION_STATE} from './constants';
14
+ import {
15
+ TaskEvent,
16
+ TaskState,
17
+ INTERACTION_STATE,
18
+ CONSULT_STATE,
19
+ MEDIA_TYPE_CONSULT,
20
+ } from './constants';
15
21
  import {DestinationType, TaskData} from '../types';
16
22
  import {computeUIControls, getDefaultUIControls} from './uiControlsComputer';
23
+ import {getIsConferenceInProgress} from '../TaskUtils';
17
24
  import {hasActiveConsultInPostCall} from './guards';
18
25
 
19
26
  const determineConsultInitiator = (
@@ -74,6 +81,113 @@ const deriveRecordingState = (taskData?: TaskData | null): RecordingStateUpdate
74
81
  return update;
75
82
  };
76
83
 
84
+ const isActiveConsultState = (taskData: TaskData | undefined, selfAgentId?: string): boolean => {
85
+ if (taskData?.interaction?.state === INTERACTION_STATE.CONSULTING) return true;
86
+ if (selfAgentId) {
87
+ const selfParticipant = taskData?.interaction?.participants?.[selfAgentId] as any;
88
+ const hasConsultMedia = Object.values(taskData?.interaction?.media ?? {}).some(
89
+ (media: any) => media?.mType === MEDIA_TYPE_CONSULT
90
+ );
91
+ const isPendingOrActiveSelfConsult =
92
+ selfParticipant?.consultState === CONSULT_STATE.CONSULTING ||
93
+ selfParticipant?.consultState === 'consultInitiated';
94
+ if (isPendingOrActiveSelfConsult && hasConsultMedia && taskData?.isConsulted === false) {
95
+ return true;
96
+ }
97
+ }
98
+ if (taskData?.interaction?.state === INTERACTION_STATE.POST_CALL && selfAgentId) {
99
+ const selfParticipant = taskData.interaction?.participants?.[selfAgentId] as any;
100
+ const hasConsultMedia = Object.values(taskData.interaction?.media ?? {}).some(
101
+ (media: any) => media?.mType === MEDIA_TYPE_CONSULT
102
+ );
103
+ if (selfParticipant?.consultState === CONSULT_STATE.CONSULTING && hasConsultMedia) return true;
104
+ }
105
+
106
+ return false;
107
+ };
108
+
109
+ const isSelfConsultingOrPending = (
110
+ taskData: TaskData | undefined,
111
+ selfAgentId?: string
112
+ ): boolean => {
113
+ if (!taskData || !selfAgentId) return false;
114
+ const selfParticipant = taskData?.interaction?.participants?.[selfAgentId] as any;
115
+
116
+ return (
117
+ selfParticipant?.consultState === CONSULT_STATE.CONSULTING ||
118
+ selfParticipant?.consultState === 'consultInitiated'
119
+ );
120
+ };
121
+
122
+ const hasJoinedConsultDestination = (taskData: TaskData | undefined): boolean => {
123
+ if (!taskData?.interaction) return false;
124
+ const participants = taskData.interaction.participants as any;
125
+ const cpd = taskData.interaction.callProcessingDetails as any;
126
+ const backendSaysJoined = cpd?.consultDestinationAgentJoined === 'true';
127
+ if (backendSaysJoined) return true;
128
+ if (!participants) return false;
129
+
130
+ return Object.values(participants).some((p: any) => {
131
+ if (!p || p.isConsulted !== true || p.hasLeft) return false;
132
+
133
+ return p.hasJoined === true || p.consultState === CONSULT_STATE.CONSULTING;
134
+ });
135
+ };
136
+
137
+ const deriveConsultCallHeldFromTaskData = (taskData: TaskData | undefined): boolean | undefined => {
138
+ if (!taskData?.interaction) return undefined;
139
+
140
+ const eventType = taskData.type;
141
+ if (eventType === 'AgentContactHeld') return true;
142
+ if (eventType === 'AgentContactUnheld') return false;
143
+
144
+ const consultMediaId = taskData.consultMediaResourceId;
145
+
146
+ const consultMedia: any = consultMediaId
147
+ ? taskData.interaction.media?.[consultMediaId]
148
+ : Object.values(taskData.interaction.media ?? {}).find(
149
+ (m: any) => m?.mType === MEDIA_TYPE_CONSULT
150
+ );
151
+
152
+ if (!consultMedia) return undefined;
153
+
154
+ return Boolean(consultMedia.isHold);
155
+ };
156
+
157
+ const getTaskStateForUiControls = (
158
+ taskData: TaskData | undefined,
159
+ selfAgentId: string | undefined
160
+ ): TaskState => {
161
+ if (!taskData?.interaction) {
162
+ return TaskState.IDLE;
163
+ }
164
+
165
+ if (taskData.interaction.isTerminated === true) {
166
+ return TaskState.WRAPPING_UP;
167
+ }
168
+
169
+ if (isActiveConsultState(taskData, selfAgentId)) {
170
+ return TaskState.CONSULTING;
171
+ }
172
+
173
+ if (
174
+ taskData.interaction.state === INTERACTION_STATE.CONFERENCE ||
175
+ getIsConferenceInProgress(taskData)
176
+ ) {
177
+ return TaskState.CONFERENCING;
178
+ }
179
+
180
+ const mainMediaId = taskData.interaction.mainInteractionId || taskData.interactionId;
181
+ const isMainHeld = Boolean(
182
+ mainMediaId && taskData.interaction.media?.[mainMediaId]?.isHold === true
183
+ );
184
+ if (taskData.interaction.state === 'hold' || isMainHeld) {
185
+ return TaskState.HELD;
186
+ }
187
+
188
+ return TaskState.CONNECTED;
189
+ };
190
+
77
191
  const deriveTaskDataUpdates = (context: TaskContext, taskData: TaskData | undefined) =>
78
192
  taskData
79
193
  ? (() => {
@@ -84,8 +198,14 @@ const deriveTaskDataUpdates = (context: TaskContext, taskData: TaskData | undefi
84
198
 
85
199
  const selfAgentId = context.uiControlConfig.agentId ?? taskData?.agentId;
86
200
  const consultingActive =
87
- taskData?.interaction?.state === INTERACTION_STATE.CONSULTING ||
201
+ isActiveConsultState(taskData, selfAgentId) ||
88
202
  hasActiveConsultInPostCall(taskData, selfAgentId);
203
+ const conferenceFromPayload =
204
+ taskData?.interaction?.state === INTERACTION_STATE.CONFERENCE ||
205
+ getIsConferenceInProgress(taskData);
206
+ const selfConsultingOrPending = isSelfConsultingOrPending(taskData, selfAgentId);
207
+ const inferredConsultingInitiator =
208
+ selfConsultingOrPending && taskData?.isConsulted === false;
89
209
 
90
210
  if (taskData.destAgentId) {
91
211
  const isEpDnWithStoredId =
@@ -102,24 +222,26 @@ const deriveTaskDataUpdates = (context: TaskContext, taskData: TaskData | undefi
102
222
  const consultInitiator = determineConsultInitiator(taskData, selfAgentId);
103
223
  if (consultInitiator !== undefined) {
104
224
  updates.consultInitiator = consultInitiator;
105
- } else if (consultingActive && taskData.isConsulted === false) {
225
+ } else if (
226
+ inferredConsultingInitiator ||
227
+ (consultingActive && taskData.isConsulted === false)
228
+ ) {
106
229
  updates.consultInitiator = true;
107
230
  }
108
231
  }
109
232
 
233
+ const effectiveConsultInitiator = updates.consultInitiator ?? context.consultInitiator;
234
+ if (
235
+ effectiveConsultInitiator &&
236
+ conferenceFromPayload &&
237
+ (consultingActive || selfConsultingOrPending || Boolean(taskData?.consultMediaResourceId))
238
+ ) {
239
+ updates.consultFromConference = true;
240
+ }
241
+
110
242
  if (consultingActive && taskData.interaction) {
111
- if (!context.consultDestinationAgentJoined) {
112
- const hasJoinedConsultee = Boolean(
113
- taskData.interaction.participants &&
114
- Object.values(taskData.interaction.participants).some(
115
- (p: any) => p?.isConsulted === true && !p?.hasLeft
116
- )
117
- );
118
- const cpd = taskData.interaction?.callProcessingDetails;
119
- const backendSaysJoined = cpd?.consultDestinationAgentJoined === 'true';
120
- if (hasJoinedConsultee || backendSaysJoined)
121
- updates.consultDestinationAgentJoined = true;
122
- }
243
+ const joinedConsultee = hasJoinedConsultDestination(taskData);
244
+ if (joinedConsultee) updates.consultDestinationAgentJoined = true;
123
245
 
124
246
  if (!context.consultDestinationType && !updates.consultDestinationType) {
125
247
  const hasEpDnParticipant = Boolean(
@@ -131,20 +253,22 @@ const deriveTaskDataUpdates = (context: TaskContext, taskData: TaskData | undefi
131
253
  if (hasEpDnParticipant) updates.consultDestinationType = 'entryPoint' as any;
132
254
  }
133
255
 
134
- const effectiveConsultInitiator = updates.consultInitiator ?? context.consultInitiator;
135
256
  if (effectiveConsultInitiator) {
136
- const consultMediaId = taskData.consultMediaResourceId;
137
- const consultMedia: any = consultMediaId
138
- ? taskData.interaction.media?.[consultMediaId]
139
- : Object.values(taskData.interaction.media ?? {}).find(
140
- (m: any) => m?.mType === 'consult'
141
- );
142
- if (consultMedia) {
143
- updates.consultCallHeld = Boolean(consultMedia.isHold);
257
+ const consultCallHeld = deriveConsultCallHeldFromTaskData(taskData);
258
+ if (consultCallHeld !== undefined) {
259
+ updates.consultCallHeld = consultCallHeld;
144
260
  }
145
261
  }
146
262
  }
147
263
 
264
+ const nextContext = {
265
+ ...context,
266
+ ...updates,
267
+ } as TaskContext;
268
+ const inferredState = getTaskStateForUiControls(taskData, selfAgentId);
269
+
270
+ updates.uiControls = computeUIControls(inferredState, nextContext, taskData);
271
+
148
272
  return updates;
149
273
  })()
150
274
  : {};
@@ -120,6 +120,20 @@ export const guards = {
120
120
 
121
121
  if (taskData?.interaction?.state === INTERACTION_STATE.CONSULTING) return true;
122
122
 
123
+ // Hydrate can report interaction as conference/hold while this agent is actively consulting.
124
+ // Detect consult by participant consultState + consult media, independent of top-level state.
125
+ const selfAgentId = getSelfAgentId(context, taskData);
126
+ const selfParticipant = selfAgentId ? taskData?.interaction?.participants?.[selfAgentId] : null;
127
+ const hasConsultMedia = Object.values(taskData?.interaction?.media ?? {}).some(
128
+ (media: any) => media?.mType === MEDIA_TYPE_CONSULT
129
+ );
130
+ const selfActiveConsult = selfParticipant?.consultState === CONSULT_STATE.CONSULTING;
131
+ const selfPendingConsultInitiated =
132
+ selfParticipant?.consultState === 'consultInitiated' && taskData?.isConsulted === false;
133
+ if ((selfActiveConsult || selfPendingConsultInitiated) && hasConsultMedia) {
134
+ return true;
135
+ }
136
+
123
137
  // EP_DN consulted agent: backend reports state as 'connected' but CPD indicates consult
124
138
  const cpd = taskData?.interaction?.callProcessingDetails;
125
139
  if (
@@ -135,6 +149,24 @@ export const guards = {
135
149
  return true;
136
150
  }
137
151
 
152
+ // Customer left during consult: interaction state is "post_call" but consult
153
+ // between agents is still active. Detect via agent's consultState + consult media.
154
+ if (taskData?.interaction?.state === INTERACTION_STATE.POST_CALL) {
155
+ const postCallSelfAgentId = getSelfAgentId(context, taskData);
156
+ const postCallSelfParticipant = postCallSelfAgentId
157
+ ? taskData?.interaction?.participants?.[postCallSelfAgentId]
158
+ : null;
159
+ const hasPostCallConsultMedia = Object.values(taskData?.interaction?.media ?? {}).some(
160
+ (media: any) => media?.mType === MEDIA_TYPE_CONSULT
161
+ );
162
+ if (
163
+ postCallSelfParticipant?.consultState === CONSULT_STATE.CONSULTING &&
164
+ hasPostCallConsultMedia
165
+ ) {
166
+ return true;
167
+ }
168
+ }
169
+
138
170
  return false;
139
171
  },
140
172
 
@@ -183,6 +215,20 @@ export const guards = {
183
215
  return getIsConferenceInProgress(taskData);
184
216
  },
185
217
 
218
+ /**
219
+ * Conference hold signal for conference-downgraded consult-end transitions.
220
+ * Backend can report this as boolean or string.
221
+ */
222
+ isConferenceHoldParticipantFromEvent: ({context, event}: GuardParams): boolean => {
223
+ const taskData = getTaskDataFromEvent(event) ?? context.taskData;
224
+ const callProcessingDetails = taskData?.interaction?.callProcessingDetails as
225
+ | {conferenceHoldParticipant?: boolean | string}
226
+ | undefined;
227
+ const conferenceHoldParticipant = callProcessingDetails?.conferenceHoldParticipant;
228
+
229
+ return conferenceHoldParticipant === true || conferenceHoldParticipant === 'true';
230
+ },
231
+
186
232
  /**
187
233
  * Conference downgrade check specifically for transitioning back to CONNECTED.
188
234
  *