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

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 (37) hide show
  1. package/dist/services/task/TaskManager.js +9 -2
  2. package/dist/services/task/TaskManager.js.map +1 -1
  3. package/dist/services/task/state-machine/TaskStateMachine.js +51 -9
  4. package/dist/services/task/state-machine/TaskStateMachine.js.map +1 -1
  5. package/dist/services/task/state-machine/actions.js +11 -6
  6. package/dist/services/task/state-machine/actions.js.map +1 -1
  7. package/dist/services/task/state-machine/constants.js +20 -1
  8. package/dist/services/task/state-machine/constants.js.map +1 -1
  9. package/dist/services/task/state-machine/guards.js +27 -8
  10. package/dist/services/task/state-machine/guards.js.map +1 -1
  11. package/dist/services/task/state-machine/uiControlsComputer.js +45 -12
  12. package/dist/services/task/state-machine/uiControlsComputer.js.map +1 -1
  13. package/dist/services/task/types.js.map +1 -1
  14. package/dist/services/task/voice/Voice.js +16 -5
  15. package/dist/services/task/voice/Voice.js.map +1 -1
  16. package/dist/types/services/task/state-machine/TaskStateMachine.d.ts +52 -4
  17. package/dist/types/services/task/state-machine/constants.d.ts +13 -0
  18. package/dist/types/services/task/state-machine/guards.d.ts +6 -1
  19. package/dist/types/services/task/types.d.ts +2 -0
  20. package/dist/types/services/task/voice/Voice.d.ts +18 -17
  21. package/dist/webex.js +1 -1
  22. package/package.json +1 -1
  23. package/src/services/task/TaskManager.ts +9 -3
  24. package/src/services/task/state-machine/TaskStateMachine.ts +79 -10
  25. package/src/services/task/state-machine/actions.ts +19 -10
  26. package/src/services/task/state-machine/constants.ts +19 -0
  27. package/src/services/task/state-machine/guards.ts +34 -7
  28. package/src/services/task/state-machine/uiControlsComputer.ts +45 -17
  29. package/src/services/task/types.ts +2 -0
  30. package/src/services/task/voice/Voice.ts +20 -11
  31. package/test/unit/spec/services/task/TaskManager.ts +26 -0
  32. package/test/unit/spec/services/task/state-machine/TaskStateMachine.ts +164 -0
  33. package/test/unit/spec/services/task/state-machine/guards.ts +103 -0
  34. package/test/unit/spec/services/task/state-machine/uiControlsComputer.ts +237 -1
  35. package/test/unit/spec/services/task/voice/Voice.ts +24 -0
  36. package/umd/contact-center.min.js +2 -2
  37. package/umd/contact-center.min.js.map +1 -1
@@ -291,7 +291,7 @@ export default class TaskManager extends EventEmitter {
291
291
  return {type: TaskEvent.RONA, taskData: payload, reason: payload.reason};
292
292
 
293
293
  case CC_EVENTS.AGENT_OUTBOUND_FAILED:
294
- return {type: TaskEvent.OUTBOUND_FAILED, reason: payload.reason};
294
+ return {type: TaskEvent.OUTBOUND_FAILED, taskData: payload, reason: payload.reason};
295
295
 
296
296
  case CC_EVENTS.CONTACT_RECORDING_STARTED:
297
297
  return {type: TaskEvent.RECORDING_STARTED, taskData: payload};
@@ -307,6 +307,7 @@ export default class TaskManager extends EventEmitter {
307
307
 
308
308
  // Conference events - these trigger state machine transition to CONFERENCING
309
309
  case CC_EVENTS.AGENT_CONSULT_CONFERENCED:
310
+ case CC_EVENTS.AGENT_CONSULT_CONFERENCING:
310
311
  case CC_EVENTS.PARTICIPANT_JOINED_CONFERENCE:
311
312
  return {type: TaskEvent.CONFERENCE_START, taskData: payload};
312
313
 
@@ -375,6 +376,12 @@ export default class TaskManager extends EventEmitter {
375
376
  task.sendStateMachineEvent(stateMachineEvent);
376
377
  }
377
378
 
379
+ // Emit TASK_POST_CALL_ACTIVITY for ParticipantPostCallActivity events so
380
+ // consumers (Widgets) can detect the interaction state change to post_call.
381
+ if (eventContext.eventType === CC_EVENTS.PARTICIPANT_POST_CALL_ACTIVITY) {
382
+ task.emit(TASK_EVENTS.TASK_POST_CALL_ACTIVITY, task);
383
+ }
384
+
378
385
  // Send transcript start/stop events for relevant CC events
379
386
  this.requestRealTimeTranscripts(eventContext.eventType, payload.interactionId);
380
387
  });
@@ -655,8 +662,7 @@ export default class TaskManager extends EventEmitter {
655
662
  const {payload} = context;
656
663
  let task = context.task;
657
664
 
658
- if (payload.childInteractionId) {
659
- // remove the child task from collection
665
+ if (payload.childInteractionId && this.taskCollection[payload.childInteractionId]) {
660
666
  this.removeTaskFromCollection(this.taskCollection[payload.childInteractionId]);
661
667
  }
662
668
 
@@ -12,7 +12,7 @@ import {setup} from 'xstate';
12
12
  import {TaskContext, TaskEventPayload, UIControlConfig, TaskActionsMap} from './types';
13
13
  import {TaskState, TaskEvent} from './constants';
14
14
  import {actions, createInitialContext} from './actions';
15
- import {guards} from './guards';
15
+ import {guards, shouldWrapUpForThisAgent, getTaskDataFromEvent} from './guards';
16
16
  import {getIsCustomerInCall} from '../TaskUtils';
17
17
 
18
18
  type TaskActionConfigMap = {[K in keyof typeof actions]: undefined};
@@ -123,6 +123,12 @@ export function getTaskStateMachineConfig(uiControlConfig: UIControlConfig) {
123
123
  actions: ['initializeTask', 'emitTaskIncoming'],
124
124
  },
125
125
 
126
+ // AgentOutboundFailed can arrive before TASK_INCOMING due to race conditions
127
+ [TaskEvent.OUTBOUND_FAILED]: {
128
+ target: TaskState.TERMINATED,
129
+ actions: ['updateTaskData', 'markEnded', 'emitTaskOutdialFailed', 'emitTaskEnd'],
130
+ },
131
+
126
132
  // EP-DN split-leg ordering can deliver AgentConsulting before HYDRATE/TASK_INCOMING.
127
133
  // Do not drop it in IDLE; bootstrap to CONSULTING using event taskData.
128
134
  [TaskEvent.CONSULTING_ACTIVE]: {
@@ -190,7 +196,7 @@ export function getTaskStateMachineConfig(uiControlConfig: UIControlConfig) {
190
196
  },
191
197
  {
192
198
  target: TaskState.TERMINATED,
193
- actions: ['updateTaskData', 'markEnded', 'emitTaskOutdialFailed', 'emitTaskReject'],
199
+ actions: ['updateTaskData', 'markEnded', 'emitTaskOutdialFailed', 'emitTaskEnd'],
194
200
  },
195
201
  ],
196
202
  // AgentConsulting comes for received after the initial consult is accepted
@@ -467,6 +473,24 @@ export function getTaskStateMachineConfig(uiControlConfig: UIControlConfig) {
467
473
  target: TaskState.CONNECTED,
468
474
  actions: ['updateTaskData', 'clearConsultState', 'emitTaskConsultEnd'],
469
475
  },
476
+ {
477
+ // Customer left during consult → WRAPPING_UP
478
+ guard: ({context, event}) => {
479
+ const taskData = getTaskDataFromEvent(event);
480
+ const cpd = taskData?.interaction?.callProcessingDetails;
481
+ if (cpd?.hasCustomerLeft !== 'true') return false;
482
+
483
+ return shouldWrapUpForThisAgent(context, taskData);
484
+ },
485
+ target: TaskState.WRAPPING_UP,
486
+ actions: [
487
+ 'updateTaskData',
488
+ 'markEnded',
489
+ 'clearConsultState',
490
+ 'emitTaskWrapup',
491
+ 'requestCleanup',
492
+ ],
493
+ },
470
494
  {
471
495
  // Initiator (no conference) → HELD
472
496
  guard: ({context}) => context.consultInitiator === true,
@@ -587,30 +611,61 @@ export function getTaskStateMachineConfig(uiControlConfig: UIControlConfig) {
587
611
  // AgentConsultConferenced, ParticipantJoinedConference
588
612
  [TaskEvent.CONFERENCE_START]: {
589
613
  target: TaskState.CONFERENCING,
590
- actions: ['handleConferenceStarted', 'clearConsultState'],
614
+ actions: [
615
+ 'updateTaskData',
616
+ 'syncTaskDataFromEvent',
617
+ 'handleConferenceStarted',
618
+ 'clearConsultState',
619
+ ],
591
620
  },
592
621
  },
593
622
  },
594
623
 
595
624
  [TaskState.CONF_INITIATING]: {
596
625
  on: {
597
- // AgentConsultConferenced, ParticipantJoinedConference
626
+ // AgentConsultConferenced, AgentConsultConferencing, ParticipantJoinedConference
598
627
  [TaskEvent.CONFERENCE_START]: {
599
628
  target: TaskState.CONFERENCING,
600
- actions: ['handleConferenceStarted'],
629
+ actions: [
630
+ 'updateTaskData',
631
+ 'syncTaskDataFromEvent',
632
+ 'handleConferenceStarted',
633
+ 'clearConsultState',
634
+ ],
601
635
  },
602
636
  // AgentConsultConferenceFailed
603
637
  [TaskEvent.CONFERENCE_FAILED]: {
604
638
  target: TaskState.CONSULTING,
605
639
  actions: ['handleConferenceFailed', 'emitTaskConferenceFailed'],
606
640
  },
641
+ // AgentConsultEnded while conference is initiating (end call before conference completes)
642
+ [TaskEvent.CONSULT_END]: [
643
+ {
644
+ guard: ({event}) => {
645
+ const taskData = getTaskDataFromEvent(event);
646
+
647
+ return taskData?.interaction?.isTerminated === true;
648
+ },
649
+ target: TaskState.WRAPPING_UP,
650
+ actions: ['updateTaskData', 'markEnded', 'clearConsultState', 'emitTaskWrapup'],
651
+ },
652
+ {
653
+ target: TaskState.CONNECTED,
654
+ actions: ['updateTaskData', 'clearConsultState'],
655
+ },
656
+ ],
607
657
  },
608
658
  },
609
659
 
610
660
  [TaskState.CONFERENCING]: {
611
661
  on: {
612
662
  [TaskEvent.CONFERENCE_START]: {
613
- actions: ['updateTaskData', 'clearConsultState', 'emitTaskConferenceStarted'],
663
+ actions: [
664
+ 'updateTaskData',
665
+ 'syncTaskDataFromEvent',
666
+ 'clearConsultState',
667
+ 'emitTaskConferenceStarted',
668
+ ],
614
669
  },
615
670
  [TaskEvent.EXIT_CONFERENCE_SUCCESS]: [
616
671
  {
@@ -625,15 +680,29 @@ export function getTaskStateMachineConfig(uiControlConfig: UIControlConfig) {
625
680
  ],
626
681
 
627
682
  // Needed as all agents in conference get this event, hence we need to clear the consult state
628
- [TaskEvent.CONSULT_END]: {
629
- actions: ['updateTaskData', 'clearConsultState'],
630
- },
683
+ [TaskEvent.CONSULT_END]: [
684
+ {
685
+ guard: ({context, event}) => {
686
+ const taskData = getTaskDataFromEvent(event);
687
+
688
+ return (
689
+ taskData?.interaction?.isTerminated === true &&
690
+ shouldWrapUpForThisAgent(context, taskData)
691
+ );
692
+ },
693
+ target: TaskState.WRAPPING_UP,
694
+ actions: ['updateTaskData', 'markEnded', 'clearConsultState', 'emitTaskWrapup'],
695
+ },
696
+ {
697
+ actions: ['updateTaskData', 'clearConsultState'],
698
+ },
699
+ ],
631
700
 
632
701
  [TaskEvent.HOLD_SUCCESS]: {
633
702
  actions: ['updateTaskData', 'setHoldState', 'emitTaskHold'],
634
703
  },
635
704
  [TaskEvent.UNHOLD_SUCCESS]: {
636
- actions: ['updateTaskData', 'setHoldState', 'emitTaskResume'],
705
+ actions: ['updateTaskData', 'syncTaskDataFromEvent', 'setHoldState', 'emitTaskResume'],
637
706
  },
638
707
 
639
708
  // Start a new consult from within an active conference
@@ -11,9 +11,10 @@ import {
11
11
  TaskActionArgs,
12
12
  RecordingStateUpdate,
13
13
  } from './types';
14
- import {TaskEvent, TaskState} from './constants';
14
+ import {TaskEvent, TaskState, INTERACTION_STATE} from './constants';
15
15
  import {DestinationType, TaskData} from '../types';
16
16
  import {computeUIControls, getDefaultUIControls} from './uiControlsComputer';
17
+ import {hasActiveConsultInPostCall} from './guards';
17
18
 
18
19
  const determineConsultInitiator = (
19
20
  taskData: TaskData | undefined,
@@ -81,27 +82,32 @@ const deriveTaskDataUpdates = (context: TaskContext, taskData: TaskData | undefi
81
82
  ...deriveRecordingState(taskData),
82
83
  };
83
84
 
85
+ const selfAgentId = context.uiControlConfig.agentId ?? taskData?.agentId;
86
+ const consultingActive =
87
+ taskData?.interaction?.state === INTERACTION_STATE.CONSULTING ||
88
+ hasActiveConsultInPostCall(taskData, selfAgentId);
89
+
84
90
  if (taskData.destAgentId) {
85
- updates.consultDestinationAgentId = taskData.destAgentId;
91
+ const isEpDnWithStoredId =
92
+ context.consultDestinationType === 'entryPoint' && context.consultDestinationAgentId;
93
+ if (!isEpDnWithStoredId) {
94
+ updates.consultDestinationAgentId = taskData.destAgentId;
95
+ }
86
96
  }
87
- if (taskData.interaction?.state === 'consulting' && taskData.destinationType) {
97
+ if (consultingActive && taskData.destinationType) {
88
98
  updates.consultDestinationType = taskData.destinationType as DestinationType;
89
99
  }
90
100
 
91
101
  if (!context.consultInitiator) {
92
- const selfAgentId = context.uiControlConfig.agentId ?? taskData?.agentId;
93
102
  const consultInitiator = determineConsultInitiator(taskData, selfAgentId);
94
103
  if (consultInitiator !== undefined) {
95
104
  updates.consultInitiator = consultInitiator;
96
- } else if (
97
- taskData.interaction?.state === 'consulting' &&
98
- taskData.isConsulted === false
99
- ) {
105
+ } else if (consultingActive && taskData.isConsulted === false) {
100
106
  updates.consultInitiator = true;
101
107
  }
102
108
  }
103
109
 
104
- if (taskData.interaction?.state === 'consulting') {
110
+ if (consultingActive && taskData.interaction) {
105
111
  if (!context.consultDestinationAgentJoined) {
106
112
  const hasJoinedConsultee = Boolean(
107
113
  taskData.interaction.participants &&
@@ -230,7 +236,10 @@ export const actions: TaskActionsMap = {
230
236
  const taskData = getTaskDataFromEvent(event);
231
237
  const consultDestinationType =
232
238
  'destinationType' in event ? event.destinationType ?? null : null;
233
- const consultDestinationAgentId = 'destAgentId' in event ? event.destAgentId ?? null : null;
239
+ const consultDestinationAgentId =
240
+ ('destAgentId' in event ? event.destAgentId : null) ??
241
+ ('destination' in event ? (event as any).destination : null) ??
242
+ null;
234
243
 
235
244
  return {
236
245
  consultDestinationType,
@@ -36,6 +36,25 @@ export const MEDIA_TYPE_CONSULT = 'consult';
36
36
  /** Media type for main calls */
37
37
  export const MEDIA_TYPE_MAIN_CALL = 'mainCall';
38
38
 
39
+ // ============================================
40
+ // Backend Interaction State Constants
41
+ // ============================================
42
+
43
+ /** Backend interaction state values (from server payloads) */
44
+ export const INTERACTION_STATE = {
45
+ CONSULTING: 'consulting',
46
+ POST_CALL: 'post_call',
47
+ CONFERENCE: 'conference',
48
+ CONNECTED: 'connected',
49
+ NEW: 'new',
50
+ } as const;
51
+
52
+ /** Backend participant consultState values */
53
+ export const CONSULT_STATE = {
54
+ CONSULTING: 'consulting',
55
+ CONFERENCING: 'conferencing',
56
+ } as const;
57
+
39
58
  // ============================================
40
59
  // State Machine Enums
41
60
  // ============================================
@@ -27,7 +27,7 @@ import {
27
27
  getConferenceParticipantsCount,
28
28
  getIsConferenceInProgress,
29
29
  } from '../TaskUtils';
30
- import {TaskEvent} from './constants';
30
+ import {TaskEvent, INTERACTION_STATE, CONSULT_STATE, MEDIA_TYPE_CONSULT} from './constants';
31
31
 
32
32
  export const getTaskDataFromEvent = (event?: TaskEventPayload): TaskData | undefined =>
33
33
  event && typeof event === 'object' && 'taskData' in event
@@ -44,6 +44,24 @@ export const isSelfConsultingAgent = (context: TaskContext, taskData?: TaskData)
44
44
  return taskData?.consultingAgentId === selfAgentId;
45
45
  };
46
46
 
47
+ /**
48
+ * Detects an active consult during post_call state (customer left but agents still consulting).
49
+ * Shared by hydration guard (isInteractionConsulting) and action (deriveTaskDataUpdates).
50
+ */
51
+ export const hasActiveConsultInPostCall = (
52
+ taskData: TaskData | undefined,
53
+ selfAgentId?: string
54
+ ): boolean => {
55
+ if (taskData?.interaction?.state !== INTERACTION_STATE.POST_CALL || !selfAgentId) return false;
56
+
57
+ const selfParticipant = taskData.interaction?.participants?.[selfAgentId];
58
+ const hasConsultMedia = Object.values(taskData.interaction?.media ?? {}).some(
59
+ (media) => (media as {mType?: string})?.mType === MEDIA_TYPE_CONSULT
60
+ );
61
+
62
+ return selfParticipant?.consultState === CONSULT_STATE.CONSULTING && hasConsultMedia;
63
+ };
64
+
47
65
  /**
48
66
  * Determines if this agent should enter WRAPPING_UP state.
49
67
  * Priority: agentsPendingWrapUp > wrapUpRequired / participant.isWrapUp > ownership > !isConsulted
@@ -97,14 +115,23 @@ export const guards = {
97
115
  return false;
98
116
  },
99
117
 
100
- isInteractionConsulting: ({event}: GuardParams): boolean => {
118
+ isInteractionConsulting: ({event, context}: GuardParams): boolean => {
101
119
  const taskData = getTaskDataFromEvent(event);
102
120
 
103
- if (taskData?.interaction?.state === 'consulting') return true;
121
+ if (taskData?.interaction?.state === INTERACTION_STATE.CONSULTING) return true;
104
122
 
105
123
  // EP_DN consulted agent: backend reports state as 'connected' but CPD indicates consult
106
124
  const cpd = taskData?.interaction?.callProcessingDetails;
107
- if (cpd?.relationshipType === 'consult' && taskData?.interaction?.state === 'connected') {
125
+ if (
126
+ cpd?.relationshipType === 'consult' &&
127
+ taskData?.interaction?.state === INTERACTION_STATE.CONNECTED
128
+ ) {
129
+ return true;
130
+ }
131
+
132
+ // Customer left during consult: interaction state is "post_call" but consult
133
+ // between agents is still active. Detect via agent's consultState + consult media.
134
+ if (hasActiveConsultInPostCall(taskData, getSelfAgentId(context, taskData))) {
108
135
  return true;
109
136
  }
110
137
 
@@ -127,7 +154,7 @@ export const guards = {
127
154
  isInteractionConnected: ({event}: GuardParams): boolean => {
128
155
  const taskData = getTaskDataFromEvent(event);
129
156
 
130
- return taskData?.interaction?.state === 'connected';
157
+ return taskData?.interaction?.state === INTERACTION_STATE.CONNECTED;
131
158
  },
132
159
 
133
160
  isConferencingByParticipants: ({event}: GuardParams): boolean => {
@@ -176,7 +203,7 @@ export const guards = {
176
203
  if (!mainCallId) return false;
177
204
 
178
205
  // Don't downgrade while backend still reports conference.
179
- if (taskData.interaction.state === 'conference') return false;
206
+ if (taskData.interaction.state === INTERACTION_STATE.CONFERENCE) return false;
180
207
 
181
208
  const agentParticipantsCount = getConferenceParticipantsCount(taskData.interaction, mainCallId);
182
209
  if (agentParticipantsCount >= 2) return false;
@@ -218,7 +245,7 @@ export const guards = {
218
245
  return (
219
246
  taskData.isConsulted === true ||
220
247
  relationshipType === 'consult' ||
221
- taskData.interaction?.state === 'consulting'
248
+ taskData.interaction?.state === INTERACTION_STATE.CONSULTING
222
249
  );
223
250
  },
224
251
 
@@ -182,16 +182,18 @@ function computeVoiceInteractionUIControls(
182
182
 
183
183
  return {
184
184
  // Accept/Decline: Voice tasks in offered state
185
- // For outdial, accept is disabled (auto-answer handles it), decline remains enabled
186
- // For Extension mode (non-WebRTC), accept shows as disabled "Ringing" button
185
+ // Desktop/WebRTC + inbound: accept enabled (agent manually accepts)
186
+ // Desktop/WebRTC + outdial: accept disabled (auto-answer handles it; Widgets show "Accept" disabled)
187
+ // Extension mode (non-WebRTC): accept disabled (Widgets show "Ringing...")
187
188
  accept:
188
189
  state === TaskState.OFFERED && !interaction?.isTerminated
189
190
  ? {isVisible: true, isEnabled: isWebrtc && !isOutdial}
190
191
  : DISABLED,
191
- decline:
192
- isWebrtc && state === TaskState.OFFERED && !interaction?.isTerminated
193
- ? VISIBLE_ENABLED
194
- : DISABLED,
192
+ decline: (() => {
193
+ if (!isWebrtc || state !== TaskState.OFFERED || interaction?.isTerminated) return DISABLED;
194
+
195
+ return isOutdial ? VISIBLE_DISABLED : VISIBLE_ENABLED;
196
+ })(),
195
197
 
196
198
  // Hold: visible in connected/held/conference, disabled in conference/consulting
197
199
  hold: (() => {
@@ -251,19 +253,26 @@ function computeVoiceInteractionUIControls(
251
253
  return DISABLED;
252
254
  })(),
253
255
 
254
- // Transfer: connected/held, not in conference
256
+ // Transfer: connected/held/conference
255
257
  transfer: (() => {
256
258
  if (hasParallelConsultLeg) {
259
+ if (!customerPresent) return DISABLED;
257
260
  if (state === TaskState.CONNECTED) return VISIBLE_ENABLED;
258
261
  if (state === TaskState.HELD) return VISIBLE_DISABLED;
259
262
  }
260
263
  if (isConsulting) {
261
264
  if (!consultInitiator) return DISABLED;
265
+ if (!customerPresent) return VISIBLE_DISABLED;
262
266
  if (consultLegOnHold) return VISIBLE_DISABLED;
263
267
 
264
268
  return isConsultDestinationReady ? VISIBLE_ENABLED : VISIBLE_DISABLED;
265
269
  }
266
- if (!hasFullControls || inConference) return DISABLED;
270
+ if (!hasFullControls) return DISABLED;
271
+ if (inConference) {
272
+ // Real conference (multiple agents): transfer is hidden
273
+ // Pending conference (only self agent): transfer remains available
274
+ return participantCount > 1 ? DISABLED : VISIBLE_ENABLED;
275
+ }
267
276
  if (state === TaskState.CONNECTED || state === TaskState.HELD) return VISIBLE_ENABLED;
268
277
 
269
278
  return DISABLED;
@@ -278,15 +287,22 @@ function computeVoiceInteractionUIControls(
278
287
  return DISABLED;
279
288
  }
280
289
 
281
- // Enabled conditions differ by state
290
+ // In conference: behavior depends on whether it's a real multi-agent conference
291
+ if (inConference) {
292
+ // Pending conference (only self agent): consult disabled
293
+ if (participantCount <= 1) return VISIBLE_DISABLED;
294
+ // Real conference: consult enabled if conditions met
295
+ const canFromConference =
296
+ !maxParticipants && customerPresent && !consultInProgress && !isConsulting;
297
+
298
+ return {isVisible: true, isEnabled: canFromConference};
299
+ }
300
+
301
+ // Enabled conditions for connected/held
282
302
  const canFromConnected =
283
303
  !maxParticipants && customerPresent && !consultInProgress && !isConsulted;
284
- const canFromConference =
285
- !maxParticipants && customerPresent && !consultInProgress && !isConsulting;
286
304
 
287
- const isEnabled = inConference ? canFromConference : canFromConnected;
288
-
289
- return {isVisible: true, isEnabled};
305
+ return {isVisible: true, isEnabled: canFromConnected};
290
306
  })(),
291
307
 
292
308
  // ConsultTransfer: always hidden (use transfer button)
@@ -303,10 +319,16 @@ function computeVoiceInteractionUIControls(
303
319
  return {isVisible: true, isEnabled: consultInitiator || config.isEndConsultEnabled};
304
320
  })(),
305
321
 
306
- // Recording: connected/held only, not in consult/conference
322
+ // Recording: connected/held, hidden in real conference, visible in pending conference
307
323
  recording: (() => {
308
324
  if (!recordingControlsAvailable || !config.isRecordingEnabled) return DISABLED;
309
- if (!hasFullControls || isConsulting || inConference) return DISABLED;
325
+ if (!hasFullControls || isConsulting) return DISABLED;
326
+ if (inConference) {
327
+ // Real conference (multiple agents): recording hidden
328
+ // Pending conference (only self agent): recording available
329
+ return participantCount > 1 ? DISABLED : VISIBLE_ENABLED;
330
+ }
331
+ if (hasParallelConsultLeg && !customerPresent) return DISABLED;
310
332
  if (state === TaskState.CONNECTED || state === TaskState.HELD) {
311
333
  return VISIBLE_ENABLED;
312
334
  }
@@ -318,6 +340,7 @@ function computeVoiceInteractionUIControls(
318
340
  // Label changes based on leg: "Conference" on main leg, "Merge" on consult leg
319
341
  conference: (() => {
320
342
  if (hasParallelConsultLeg) {
343
+ if (!customerPresent) return DISABLED;
321
344
  if (state === TaskState.CONNECTED) {
322
345
  return maxParticipants ? VISIBLE_DISABLED : VISIBLE_ENABLED;
323
346
  }
@@ -327,6 +350,7 @@ function computeVoiceInteractionUIControls(
327
350
  }
328
351
  if (!hasFullControls || !isConsulting) return DISABLED;
329
352
  if (!consultInitiator) return DISABLED;
353
+ if (!customerPresent) return VISIBLE_DISABLED;
330
354
  if (consultLegOnHold) return VISIBLE_DISABLED;
331
355
 
332
356
  return isConsultDestinationReady && !maxParticipants ? VISIBLE_ENABLED : VISIBLE_DISABLED;
@@ -335,10 +359,11 @@ function computeVoiceInteractionUIControls(
335
359
  // Wrapup: wrapping up state
336
360
  wrapup: isWrappingUp ? VISIBLE_ENABLED : DISABLED,
337
361
 
338
- // ExitConference: in conference, not consulting from conference
362
+ // ExitConference: in conference with multiple agents in main call
339
363
  exitConference: (() => {
340
364
  if (isConsulted && !isConferencing) return DISABLED;
341
365
  if (!inConference) return DISABLED;
366
+ if (participantCount <= 1) return DISABLED;
342
367
  const consultingFromConference = consultInitiator && isConsulting && conferenceFromBackend;
343
368
 
344
369
  return consultingFromConference ? VISIBLE_DISABLED : VISIBLE_ENABLED;
@@ -356,6 +381,7 @@ function computeVoiceInteractionUIControls(
356
381
  // MergeToConference: mirrors conference control, enabled on both legs
357
382
  mergeToConference: (() => {
358
383
  if (!isConsulting || !consultInitiator) return DISABLED;
384
+ if (!customerPresent) return VISIBLE_DISABLED;
359
385
  if (consultLegOnHold) return VISIBLE_DISABLED;
360
386
 
361
387
  return isConsultDestinationReady && !maxParticipants ? VISIBLE_ENABLED : VISIBLE_DISABLED;
@@ -363,8 +389,10 @@ function computeVoiceInteractionUIControls(
363
389
 
364
390
  // Switch: visible only on the currently active leg
365
391
  switch: (() => {
392
+ if (!customerPresent && hasParallelConsultLeg) return DISABLED;
366
393
  if (currentLeg === 'consult') {
367
394
  if (!isConsulting || !consultInitiator || consultCallHeld) return DISABLED;
395
+ if (!customerPresent) return VISIBLE_DISABLED;
368
396
 
369
397
  return isConsultDestinationReady ? VISIBLE_ENABLED : VISIBLE_DISABLED;
370
398
  }
@@ -935,6 +935,8 @@ export type Interaction = {
935
935
  fcDesktopView?: string;
936
936
  /** Agent ID who initiated the outdial call */
937
937
  outdialAgentId?: string;
938
+ /** Indicates if the customer has left the call during an active consult */
939
+ hasCustomerLeft?: string;
938
940
  };
939
941
  /** Main interaction identifier for related interactions */
940
942
  mainInteractionId?: string;
@@ -25,7 +25,7 @@ import Task from '../Task';
25
25
  import LoggerProxy from '../../../logger-proxy';
26
26
  import MetricsManager from '../../../metrics/MetricsManager';
27
27
  import {METRIC_EVENT_NAMES} from '../../../metrics/constants';
28
- import {TaskState, TaskEvent} from '../state-machine';
28
+ import {TaskState, TaskEvent, TaskActionArgs} from '../state-machine';
29
29
  import {WrapupData} from '../../config/types';
30
30
  import {getConsultMediaResourceId, getIsConferenceInProgress} from '../TaskUtils';
31
31
 
@@ -710,17 +710,22 @@ export default class Voice extends Task implements IVoice {
710
710
  ? calculateDestType(this.data.interaction, this.data.agentId)
711
711
  : '';
712
712
 
713
+ // derivedDestType is most reliable as it inspects live interaction participants
714
+ const resolvedDestinationType =
715
+ derivedDestType ||
716
+ this.getStateMachineSnapshot()?.context?.consultDestinationType ||
717
+ this.data.destinationType ||
718
+ 'agent';
719
+
713
720
  const consultationData: consultConferencePayloadData = {
714
721
  agentId: this.data.agentId,
715
- destinationType:
716
- this.getStateMachineSnapshot()?.context?.consultDestinationType ||
717
- this.data.destinationType ||
718
- derivedDestType ||
719
- 'agent',
722
+ destinationType: resolvedDestinationType,
723
+ // derivedDestAgentId is most reliable as it resolves epId for EP_DN
724
+ // and agent ID for regular agents from live interaction data
720
725
  destAgentId:
726
+ derivedDestAgentId ||
721
727
  this.getStateMachineSnapshot()?.context?.consultDestinationAgentId ||
722
- this.data.destAgentId ||
723
- derivedDestAgentId,
728
+ this.data.destAgentId,
724
729
  };
725
730
 
726
731
  // Send state machine event to transition to CONF_INITIATING
@@ -1247,9 +1252,13 @@ export default class Voice extends Task implements IVoice {
1247
1252
  TASK_EVENTS.TASK_CONFERENCE_TRANSFER_FAILED,
1248
1253
  {updateTaskData: true}
1249
1254
  ),
1250
- emitTaskOutdialFailed: this.createEmitSelfAction(TASK_EVENTS.TASK_OUTDIAL_FAILED, {
1251
- updateTaskData: true,
1252
- }),
1255
+ emitTaskOutdialFailed: ({event}: TaskActionArgs) => {
1256
+ if (event && 'taskData' in event && event.taskData) {
1257
+ this.updateTaskData(event.taskData as TaskData);
1258
+ }
1259
+ const reason = (event as {reason?: string})?.reason || 'Outdial failed';
1260
+ this.emit(TASK_EVENTS.TASK_OUTDIAL_FAILED, reason);
1261
+ },
1253
1262
  };
1254
1263
  }
1255
1264
  }
@@ -1280,6 +1280,32 @@ describe('TaskManager', () => {
1280
1280
  sendStateMachineEventSpy.mockRestore();
1281
1281
  });
1282
1282
 
1283
+ it('should pass taskData in OUTBOUND_FAILED event for shouldWrapUp guard evaluation', () => {
1284
+ const task = taskManager.getTask(taskId);
1285
+ const sendStateMachineEventSpy = jest.spyOn(task, 'sendStateMachineEvent');
1286
+ const payload = {
1287
+ data: {
1288
+ type: CC_EVENTS.AGENT_OUTBOUND_FAILED,
1289
+ interactionId: taskId,
1290
+ reason: 'CUSTOMER_BUSY',
1291
+ agentsPendingWrapUp: ['agent-123'],
1292
+ interaction: {
1293
+ outboundType: 'OUTDIAL',
1294
+ isTerminated: true,
1295
+ },
1296
+ },
1297
+ };
1298
+ webSocketManagerMock.emit('message', JSON.stringify(payload));
1299
+ const stateMachineEvent = expectLastStateMachineEvent(
1300
+ sendStateMachineEventSpy,
1301
+ TaskEvent.OUTBOUND_FAILED
1302
+ );
1303
+ expect(stateMachineEvent?.taskData).toBeDefined();
1304
+ expect(stateMachineEvent?.taskData?.agentsPendingWrapUp).toEqual(['agent-123']);
1305
+ expect(stateMachineEvent?.taskData?.interaction?.outboundType).toBe('OUTDIAL');
1306
+ sendStateMachineEventSpy.mockRestore();
1307
+ });
1308
+
1283
1309
  it('should handle AGENT_OUTBOUND_FAILED gracefully when task is undefined', () => {
1284
1310
  const payload = {
1285
1311
  data: {