@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.
- package/dist/services/task/TaskManager.js +8 -2
- package/dist/services/task/TaskManager.js.map +1 -1
- package/dist/services/task/state-machine/TaskStateMachine.js +45 -8
- package/dist/services/task/state-machine/TaskStateMachine.js.map +1 -1
- package/dist/services/task/state-machine/actions.js +11 -6
- package/dist/services/task/state-machine/actions.js.map +1 -1
- package/dist/services/task/state-machine/constants.js +20 -1
- package/dist/services/task/state-machine/constants.js.map +1 -1
- package/dist/services/task/state-machine/guards.js +27 -8
- package/dist/services/task/state-machine/guards.js.map +1 -1
- package/dist/services/task/state-machine/uiControlsComputer.js +38 -9
- package/dist/services/task/state-machine/uiControlsComputer.js.map +1 -1
- package/dist/services/task/types.js.map +1 -1
- package/dist/services/task/voice/Voice.js +7 -2
- package/dist/services/task/voice/Voice.js.map +1 -1
- package/dist/types/services/task/state-machine/TaskStateMachine.d.ts +44 -4
- package/dist/types/services/task/state-machine/constants.d.ts +13 -0
- package/dist/types/services/task/state-machine/guards.d.ts +6 -1
- package/dist/types/services/task/types.d.ts +2 -0
- package/dist/webex.js +1 -1
- package/package.json +1 -1
- package/src/services/task/TaskManager.ts +8 -2
- package/src/services/task/state-machine/TaskStateMachine.ts +72 -9
- package/src/services/task/state-machine/actions.ts +19 -10
- package/src/services/task/state-machine/constants.ts +19 -0
- package/src/services/task/state-machine/guards.ts +34 -7
- package/src/services/task/state-machine/uiControlsComputer.ts +37 -11
- package/src/services/task/types.ts +2 -0
- package/src/services/task/voice/Voice.ts +12 -7
- package/test/unit/spec/services/task/state-machine/TaskStateMachine.ts +103 -0
- package/test/unit/spec/services/task/state-machine/guards.ts +103 -0
- package/test/unit/spec/services/task/state-machine/uiControlsComputer.ts +189 -1
- package/umd/contact-center.min.js +2 -2
- 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: [
|
|
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: [
|
|
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: [
|
|
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
|
-
|
|
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
|
-
|
|
91
|
+
const isEpDnWithStoredId =
|
|
92
|
+
context.consultDestinationType === 'entryPoint' && context.consultDestinationAgentId;
|
|
93
|
+
if (!isEpDnWithStoredId) {
|
|
94
|
+
updates.consultDestinationAgentId = taskData.destAgentId;
|
|
95
|
+
}
|
|
86
96
|
}
|
|
87
|
-
if (
|
|
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
|
|
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 =
|
|
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 ===
|
|
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 (
|
|
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 ===
|
|
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 ===
|
|
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 ===
|
|
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
|
|
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
|
|
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
|
-
//
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
717
|
-
|
|
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();
|