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

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 (34) hide show
  1. package/dist/services/task/TaskManager.js +8 -2
  2. package/dist/services/task/TaskManager.js.map +1 -1
  3. package/dist/services/task/state-machine/TaskStateMachine.js +45 -8
  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 +38 -9
  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 +7 -2
  15. package/dist/services/task/voice/Voice.js.map +1 -1
  16. package/dist/types/services/task/state-machine/TaskStateMachine.d.ts +44 -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/webex.js +1 -1
  21. package/package.json +1 -1
  22. package/src/services/task/TaskManager.ts +8 -2
  23. package/src/services/task/state-machine/TaskStateMachine.ts +72 -9
  24. package/src/services/task/state-machine/actions.ts +19 -10
  25. package/src/services/task/state-machine/constants.ts +19 -0
  26. package/src/services/task/state-machine/guards.ts +34 -7
  27. package/src/services/task/state-machine/uiControlsComputer.ts +37 -11
  28. package/src/services/task/types.ts +2 -0
  29. package/src/services/task/voice/Voice.ts +12 -7
  30. package/test/unit/spec/services/task/state-machine/TaskStateMachine.ts +103 -0
  31. package/test/unit/spec/services/task/state-machine/guards.ts +103 -0
  32. package/test/unit/spec/services/task/state-machine/uiControlsComputer.ts +189 -1
  33. package/umd/contact-center.min.js +2 -2
  34. package/umd/contact-center.min.js.map +1 -1
@@ -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};
@@ -467,6 +467,24 @@ export function getTaskStateMachineConfig(uiControlConfig: UIControlConfig) {
467
467
  target: TaskState.CONNECTED,
468
468
  actions: ['updateTaskData', 'clearConsultState', 'emitTaskConsultEnd'],
469
469
  },
470
+ {
471
+ // Customer left during consult → WRAPPING_UP
472
+ guard: ({context, event}) => {
473
+ const taskData = getTaskDataFromEvent(event);
474
+ const cpd = taskData?.interaction?.callProcessingDetails;
475
+ if (cpd?.hasCustomerLeft !== 'true') return false;
476
+
477
+ return shouldWrapUpForThisAgent(context, taskData);
478
+ },
479
+ target: TaskState.WRAPPING_UP,
480
+ actions: [
481
+ 'updateTaskData',
482
+ 'markEnded',
483
+ 'clearConsultState',
484
+ 'emitTaskWrapup',
485
+ 'requestCleanup',
486
+ ],
487
+ },
470
488
  {
471
489
  // Initiator (no conference) → HELD
472
490
  guard: ({context}) => context.consultInitiator === true,
@@ -587,30 +605,61 @@ export function getTaskStateMachineConfig(uiControlConfig: UIControlConfig) {
587
605
  // AgentConsultConferenced, ParticipantJoinedConference
588
606
  [TaskEvent.CONFERENCE_START]: {
589
607
  target: TaskState.CONFERENCING,
590
- actions: ['handleConferenceStarted', 'clearConsultState'],
608
+ actions: [
609
+ 'updateTaskData',
610
+ 'syncTaskDataFromEvent',
611
+ 'handleConferenceStarted',
612
+ 'clearConsultState',
613
+ ],
591
614
  },
592
615
  },
593
616
  },
594
617
 
595
618
  [TaskState.CONF_INITIATING]: {
596
619
  on: {
597
- // AgentConsultConferenced, ParticipantJoinedConference
620
+ // AgentConsultConferenced, AgentConsultConferencing, ParticipantJoinedConference
598
621
  [TaskEvent.CONFERENCE_START]: {
599
622
  target: TaskState.CONFERENCING,
600
- actions: ['handleConferenceStarted'],
623
+ actions: [
624
+ 'updateTaskData',
625
+ 'syncTaskDataFromEvent',
626
+ 'handleConferenceStarted',
627
+ 'clearConsultState',
628
+ ],
601
629
  },
602
630
  // AgentConsultConferenceFailed
603
631
  [TaskEvent.CONFERENCE_FAILED]: {
604
632
  target: TaskState.CONSULTING,
605
633
  actions: ['handleConferenceFailed', 'emitTaskConferenceFailed'],
606
634
  },
635
+ // AgentConsultEnded while conference is initiating (end call before conference completes)
636
+ [TaskEvent.CONSULT_END]: [
637
+ {
638
+ guard: ({event}) => {
639
+ const taskData = getTaskDataFromEvent(event);
640
+
641
+ return taskData?.interaction?.isTerminated === true;
642
+ },
643
+ target: TaskState.WRAPPING_UP,
644
+ actions: ['updateTaskData', 'markEnded', 'clearConsultState', 'emitTaskWrapup'],
645
+ },
646
+ {
647
+ target: TaskState.CONNECTED,
648
+ actions: ['updateTaskData', 'clearConsultState'],
649
+ },
650
+ ],
607
651
  },
608
652
  },
609
653
 
610
654
  [TaskState.CONFERENCING]: {
611
655
  on: {
612
656
  [TaskEvent.CONFERENCE_START]: {
613
- actions: ['updateTaskData', 'clearConsultState', 'emitTaskConferenceStarted'],
657
+ actions: [
658
+ 'updateTaskData',
659
+ 'syncTaskDataFromEvent',
660
+ 'clearConsultState',
661
+ 'emitTaskConferenceStarted',
662
+ ],
614
663
  },
615
664
  [TaskEvent.EXIT_CONFERENCE_SUCCESS]: [
616
665
  {
@@ -625,15 +674,29 @@ export function getTaskStateMachineConfig(uiControlConfig: UIControlConfig) {
625
674
  ],
626
675
 
627
676
  // 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
- },
677
+ [TaskEvent.CONSULT_END]: [
678
+ {
679
+ guard: ({context, event}) => {
680
+ const taskData = getTaskDataFromEvent(event);
681
+
682
+ return (
683
+ taskData?.interaction?.isTerminated === true &&
684
+ shouldWrapUpForThisAgent(context, taskData)
685
+ );
686
+ },
687
+ target: TaskState.WRAPPING_UP,
688
+ actions: ['updateTaskData', 'markEnded', 'clearConsultState', 'emitTaskWrapup'],
689
+ },
690
+ {
691
+ actions: ['updateTaskData', 'clearConsultState'],
692
+ },
693
+ ],
631
694
 
632
695
  [TaskEvent.HOLD_SUCCESS]: {
633
696
  actions: ['updateTaskData', 'setHoldState', 'emitTaskHold'],
634
697
  },
635
698
  [TaskEvent.UNHOLD_SUCCESS]: {
636
- actions: ['updateTaskData', 'setHoldState', 'emitTaskResume'],
699
+ actions: ['updateTaskData', 'syncTaskDataFromEvent', 'setHoldState', 'emitTaskResume'],
637
700
  },
638
701
 
639
702
  // 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
 
@@ -251,19 +251,26 @@ function computeVoiceInteractionUIControls(
251
251
  return DISABLED;
252
252
  })(),
253
253
 
254
- // Transfer: connected/held, not in conference
254
+ // Transfer: connected/held/conference
255
255
  transfer: (() => {
256
256
  if (hasParallelConsultLeg) {
257
+ if (!customerPresent) return DISABLED;
257
258
  if (state === TaskState.CONNECTED) return VISIBLE_ENABLED;
258
259
  if (state === TaskState.HELD) return VISIBLE_DISABLED;
259
260
  }
260
261
  if (isConsulting) {
261
262
  if (!consultInitiator) return DISABLED;
263
+ if (!customerPresent) return VISIBLE_DISABLED;
262
264
  if (consultLegOnHold) return VISIBLE_DISABLED;
263
265
 
264
266
  return isConsultDestinationReady ? VISIBLE_ENABLED : VISIBLE_DISABLED;
265
267
  }
266
- if (!hasFullControls || inConference) return DISABLED;
268
+ if (!hasFullControls) return DISABLED;
269
+ if (inConference) {
270
+ // Real conference (multiple agents): transfer is hidden
271
+ // Pending conference (only self agent): transfer remains available
272
+ return participantCount > 1 ? DISABLED : VISIBLE_ENABLED;
273
+ }
267
274
  if (state === TaskState.CONNECTED || state === TaskState.HELD) return VISIBLE_ENABLED;
268
275
 
269
276
  return DISABLED;
@@ -278,15 +285,22 @@ function computeVoiceInteractionUIControls(
278
285
  return DISABLED;
279
286
  }
280
287
 
281
- // Enabled conditions differ by state
288
+ // In conference: behavior depends on whether it's a real multi-agent conference
289
+ if (inConference) {
290
+ // Pending conference (only self agent): consult disabled
291
+ if (participantCount <= 1) return VISIBLE_DISABLED;
292
+ // Real conference: consult enabled if conditions met
293
+ const canFromConference =
294
+ !maxParticipants && customerPresent && !consultInProgress && !isConsulting;
295
+
296
+ return {isVisible: true, isEnabled: canFromConference};
297
+ }
298
+
299
+ // Enabled conditions for connected/held
282
300
  const canFromConnected =
283
301
  !maxParticipants && customerPresent && !consultInProgress && !isConsulted;
284
- const canFromConference =
285
- !maxParticipants && customerPresent && !consultInProgress && !isConsulting;
286
-
287
- const isEnabled = inConference ? canFromConference : canFromConnected;
288
302
 
289
- return {isVisible: true, isEnabled};
303
+ return {isVisible: true, isEnabled: canFromConnected};
290
304
  })(),
291
305
 
292
306
  // ConsultTransfer: always hidden (use transfer button)
@@ -303,10 +317,16 @@ function computeVoiceInteractionUIControls(
303
317
  return {isVisible: true, isEnabled: consultInitiator || config.isEndConsultEnabled};
304
318
  })(),
305
319
 
306
- // Recording: connected/held only, not in consult/conference
320
+ // Recording: connected/held, hidden in real conference, visible in pending conference
307
321
  recording: (() => {
308
322
  if (!recordingControlsAvailable || !config.isRecordingEnabled) return DISABLED;
309
- if (!hasFullControls || isConsulting || inConference) return DISABLED;
323
+ if (!hasFullControls || isConsulting) return DISABLED;
324
+ if (inConference) {
325
+ // Real conference (multiple agents): recording hidden
326
+ // Pending conference (only self agent): recording available
327
+ return participantCount > 1 ? DISABLED : VISIBLE_ENABLED;
328
+ }
329
+ if (hasParallelConsultLeg && !customerPresent) return DISABLED;
310
330
  if (state === TaskState.CONNECTED || state === TaskState.HELD) {
311
331
  return VISIBLE_ENABLED;
312
332
  }
@@ -318,6 +338,7 @@ function computeVoiceInteractionUIControls(
318
338
  // Label changes based on leg: "Conference" on main leg, "Merge" on consult leg
319
339
  conference: (() => {
320
340
  if (hasParallelConsultLeg) {
341
+ if (!customerPresent) return DISABLED;
321
342
  if (state === TaskState.CONNECTED) {
322
343
  return maxParticipants ? VISIBLE_DISABLED : VISIBLE_ENABLED;
323
344
  }
@@ -327,6 +348,7 @@ function computeVoiceInteractionUIControls(
327
348
  }
328
349
  if (!hasFullControls || !isConsulting) return DISABLED;
329
350
  if (!consultInitiator) return DISABLED;
351
+ if (!customerPresent) return VISIBLE_DISABLED;
330
352
  if (consultLegOnHold) return VISIBLE_DISABLED;
331
353
 
332
354
  return isConsultDestinationReady && !maxParticipants ? VISIBLE_ENABLED : VISIBLE_DISABLED;
@@ -335,10 +357,11 @@ function computeVoiceInteractionUIControls(
335
357
  // Wrapup: wrapping up state
336
358
  wrapup: isWrappingUp ? VISIBLE_ENABLED : DISABLED,
337
359
 
338
- // ExitConference: in conference, not consulting from conference
360
+ // ExitConference: in conference with multiple agents in main call
339
361
  exitConference: (() => {
340
362
  if (isConsulted && !isConferencing) return DISABLED;
341
363
  if (!inConference) return DISABLED;
364
+ if (participantCount <= 1) return DISABLED;
342
365
  const consultingFromConference = consultInitiator && isConsulting && conferenceFromBackend;
343
366
 
344
367
  return consultingFromConference ? VISIBLE_DISABLED : VISIBLE_ENABLED;
@@ -356,6 +379,7 @@ function computeVoiceInteractionUIControls(
356
379
  // MergeToConference: mirrors conference control, enabled on both legs
357
380
  mergeToConference: (() => {
358
381
  if (!isConsulting || !consultInitiator) return DISABLED;
382
+ if (!customerPresent) return VISIBLE_DISABLED;
359
383
  if (consultLegOnHold) return VISIBLE_DISABLED;
360
384
 
361
385
  return isConsultDestinationReady && !maxParticipants ? VISIBLE_ENABLED : VISIBLE_DISABLED;
@@ -363,8 +387,10 @@ function computeVoiceInteractionUIControls(
363
387
 
364
388
  // Switch: visible only on the currently active leg
365
389
  switch: (() => {
390
+ if (!customerPresent && hasParallelConsultLeg) return DISABLED;
366
391
  if (currentLeg === 'consult') {
367
392
  if (!isConsulting || !consultInitiator || consultCallHeld) return DISABLED;
393
+ if (!customerPresent) return VISIBLE_DISABLED;
368
394
 
369
395
  return isConsultDestinationReady ? VISIBLE_ENABLED : VISIBLE_DISABLED;
370
396
  }
@@ -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;
@@ -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
@@ -444,6 +444,109 @@ describe('Task state machine', () => {
444
444
  });
445
445
  });
446
446
 
447
+ describe('CONF_INITIATING state event handlers', () => {
448
+ it('transitions to CONFERENCING on CONFERENCE_START', () => {
449
+ const service = startMachine();
450
+ const taskData = createTaskData({consultingAgentId: 'agent-1'});
451
+
452
+ service.send({type: TaskEvent.TASK_INCOMING, taskData});
453
+ service.send({type: TaskEvent.ASSIGN, taskData});
454
+ service.send({
455
+ type: TaskEvent.CONSULT,
456
+ destination: 'agent-42',
457
+ destinationType: 'agent',
458
+ });
459
+ service.send({type: TaskEvent.CONSULT_SUCCESS, taskData});
460
+ service.send({type: TaskEvent.MERGE_TO_CONFERENCE});
461
+ expect(service.getSnapshot().value).toBe(TaskState.CONF_INITIATING);
462
+
463
+ service.send({type: TaskEvent.CONFERENCE_START, taskData});
464
+ expect(service.getSnapshot().value).toBe(TaskState.CONFERENCING);
465
+ });
466
+
467
+ it('transitions to WRAPPING_UP on CONSULT_END with isTerminated during CONF_INITIATING', () => {
468
+ const service = startMachine();
469
+ const taskData = createTaskData({consultingAgentId: 'agent-1'});
470
+
471
+ service.send({type: TaskEvent.TASK_INCOMING, taskData});
472
+ service.send({type: TaskEvent.ASSIGN, taskData});
473
+ service.send({
474
+ type: TaskEvent.CONSULT,
475
+ destination: 'agent-42',
476
+ destinationType: 'agent',
477
+ });
478
+ service.send({type: TaskEvent.CONSULT_SUCCESS, taskData});
479
+ service.send({type: TaskEvent.MERGE_TO_CONFERENCE});
480
+ expect(service.getSnapshot().value).toBe(TaskState.CONF_INITIATING);
481
+
482
+ const terminatedTaskData = createTaskData({
483
+ consultingAgentId: 'agent-1',
484
+ interaction: {
485
+ isTerminated: true,
486
+ owner: 'agent-1',
487
+ } as any,
488
+ });
489
+ service.send({type: TaskEvent.CONSULT_END, taskData: terminatedTaskData});
490
+ expect(service.getSnapshot().value).toBe(TaskState.WRAPPING_UP);
491
+ });
492
+
493
+ it('transitions to CONNECTED on CONSULT_END without isTerminated during CONF_INITIATING', () => {
494
+ const service = startMachine();
495
+ const taskData = createTaskData({consultingAgentId: 'agent-1'});
496
+
497
+ service.send({type: TaskEvent.TASK_INCOMING, taskData});
498
+ service.send({type: TaskEvent.ASSIGN, taskData});
499
+ service.send({
500
+ type: TaskEvent.CONSULT,
501
+ destination: 'agent-42',
502
+ destinationType: 'agent',
503
+ });
504
+ service.send({type: TaskEvent.CONSULT_SUCCESS, taskData});
505
+ service.send({type: TaskEvent.MERGE_TO_CONFERENCE});
506
+ expect(service.getSnapshot().value).toBe(TaskState.CONF_INITIATING);
507
+
508
+ service.send({type: TaskEvent.CONSULT_END, taskData});
509
+ expect(service.getSnapshot().value).toBe(TaskState.CONNECTED);
510
+ });
511
+
512
+ });
513
+
514
+ describe('CONFERENCING state CONSULT_END with terminated interaction', () => {
515
+ it('transitions to WRAPPING_UP when CONSULT_END arrives with isTerminated in CONFERENCING', () => {
516
+ const service = startMachine();
517
+ const taskData = createTaskData({
518
+ consultingAgentId: 'agent-1',
519
+ interaction: {
520
+ owner: 'agent-1',
521
+ state: 'conference',
522
+ } as any,
523
+ });
524
+
525
+ service.send({type: TaskEvent.TASK_INCOMING, taskData});
526
+ service.send({type: TaskEvent.ASSIGN, taskData});
527
+ service.send({
528
+ type: TaskEvent.CONSULT,
529
+ destination: 'agent-42',
530
+ destinationType: 'agent',
531
+ });
532
+ service.send({type: TaskEvent.CONSULT_SUCCESS, taskData});
533
+ service.send({type: TaskEvent.MERGE_TO_CONFERENCE});
534
+ service.send({type: TaskEvent.CONFERENCE_START, taskData});
535
+ expect(service.getSnapshot().value).toBe(TaskState.CONFERENCING);
536
+
537
+ const terminatedTaskData = createTaskData({
538
+ consultingAgentId: 'agent-1',
539
+ interaction: {
540
+ isTerminated: true,
541
+ owner: 'agent-1',
542
+ state: 'conference',
543
+ } as any,
544
+ });
545
+ service.send({type: TaskEvent.CONSULT_END, taskData: terminatedTaskData});
546
+ expect(service.getSnapshot().value).toBe(TaskState.WRAPPING_UP);
547
+ });
548
+ });
549
+
447
550
  describe('OFFERED state event handlers', () => {
448
551
  it('transitions to TERMINATED when customer disconnects before agent answers', () => {
449
552
  const service = startMachine();