@webex/contact-center 3.12.0-next.95 → 3.12.0-next.97
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/TaskUtils.js +51 -3
- package/dist/services/task/TaskUtils.js.map +1 -1
- package/dist/services/task/state-machine/TaskStateMachine.js +50 -14
- package/dist/services/task/state-machine/TaskStateMachine.js.map +1 -1
- package/dist/services/task/state-machine/actions.js +57 -33
- package/dist/services/task/state-machine/actions.js.map +1 -1
- package/dist/services/task/state-machine/guards.js +42 -0
- package/dist/services/task/state-machine/guards.js.map +1 -1
- package/dist/services/task/state-machine/types.js.map +1 -1
- package/dist/services/task/state-machine/uiControlsComputer.js +78 -28
- package/dist/services/task/state-machine/uiControlsComputer.js.map +1 -1
- package/dist/services/task/voice/Voice.js +5 -3
- package/dist/services/task/voice/Voice.js.map +1 -1
- package/dist/types/services/task/TaskUtils.d.ts +2 -0
- package/dist/types/services/task/state-machine/TaskStateMachine.d.ts +72 -22
- package/dist/types/services/task/state-machine/guards.d.ts +12 -0
- package/dist/types/services/task/state-machine/types.d.ts +2 -0
- package/dist/webex.js +1 -1
- package/package.json +2 -2
- package/src/services/task/TaskUtils.ts +100 -2
- package/src/services/task/state-machine/TaskStateMachine.ts +101 -12
- package/src/services/task/state-machine/actions.ts +88 -35
- package/src/services/task/state-machine/ai-docs/task-state-machine-spec.md +73 -11
- package/src/services/task/state-machine/guards.ts +39 -0
- package/src/services/task/state-machine/types.ts +2 -0
- package/src/services/task/state-machine/uiControlsComputer.ts +135 -41
- package/src/services/task/voice/Voice.ts +6 -3
- package/test/unit/spec/services/task/TaskUtils.ts +29 -0
- package/test/unit/spec/services/task/state-machine/TaskStateMachine.ts +826 -0
- package/test/unit/spec/services/task/state-machine/guards.ts +158 -0
- package/test/unit/spec/services/task/state-machine/uiControlsComputer.ts +648 -5
- package/test/unit/spec/services/task/voice/Voice.ts +18 -0
- package/umd/contact-center.min.js +2 -2
- package/umd/contact-center.min.js.map +1 -1
|
@@ -516,6 +516,32 @@ didCurrentAgentLeaveConference(context, event) {
|
|
|
516
516
|
const participantId = participantIdFromEvent ?? event.taskData?.participantId;
|
|
517
517
|
return Boolean(participantId) && participantId === selfAgentId;
|
|
518
518
|
}
|
|
519
|
+
|
|
520
|
+
// True when this agent initiated the conference transfer (widgets or desktop).
|
|
521
|
+
// Mirrors determineConsultInitiator — consultingAgentId === self only (not consultState).
|
|
522
|
+
isSelfConferenceTransferInitiator(context, event) {
|
|
523
|
+
if (context.transferConferenceRequested === true) return true;
|
|
524
|
+
if (context.consultInitiator === true) return true;
|
|
525
|
+
|
|
526
|
+
const taskData = event.taskData;
|
|
527
|
+
const selfAgentId = getSelfAgentId(context, taskData);
|
|
528
|
+
if (!selfAgentId || !taskData) return false;
|
|
529
|
+
|
|
530
|
+
return taskData.consultingAgentId === selfAgentId;
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
// Passive observer: another agent transferred; refresh data only.
|
|
534
|
+
isPassiveConferenceTransferObserver(context, event) {
|
|
535
|
+
if (isSelfConferenceTransferInitiator(context, event)) return false;
|
|
536
|
+
|
|
537
|
+
const taskData = event.taskData;
|
|
538
|
+
const selfAgentId = getSelfAgentId(context, taskData);
|
|
539
|
+
if (selfAgentId && taskData?.interaction?.participants) {
|
|
540
|
+
if (!(selfAgentId in taskData.interaction.participants)) return false;
|
|
541
|
+
if (taskData.interaction.participants[selfAgentId]?.hasLeft === true) return false;
|
|
542
|
+
}
|
|
543
|
+
return true;
|
|
544
|
+
}
|
|
519
545
|
```
|
|
520
546
|
|
|
521
547
|
```typescript
|
|
@@ -1259,12 +1285,6 @@ It is instantiated by `Task` and receives mapped backend/user events through `se
|
|
|
1259
1285
|
|
|
1260
1286
|
- Actions: `setTransferConferenceRequested`, `emitTaskTransferConference`
|
|
1261
1287
|
|
|
1262
|
-
- `TRANSFER_CONFERENCE_SUCCESS` -> stay `CONSULTING`
|
|
1263
|
-
|
|
1264
|
-
- Guard: inline `context.transferConferenceRequested !== true`
|
|
1265
|
-
|
|
1266
|
-
- Actions: `updateTaskData`, `handleTransferConferenceSuccess`, `clearTransferConferenceRequested`
|
|
1267
|
-
|
|
1268
1288
|
- `TRANSFER_CONFERENCE_SUCCESS` -> `WRAPPING_UP`
|
|
1269
1289
|
|
|
1270
1290
|
- Guard: `guards.shouldWrapUp`
|
|
@@ -1273,7 +1293,7 @@ It is instantiated by `Task` and receives mapped backend/user events through `se
|
|
|
1273
1293
|
|
|
1274
1294
|
- `TRANSFER_CONFERENCE_SUCCESS` -> `CONFERENCING`
|
|
1275
1295
|
|
|
1276
|
-
- Guard:
|
|
1296
|
+
- Guard: `!guards.isSelfConferenceTransferInitiator`
|
|
1277
1297
|
|
|
1278
1298
|
- Actions: `updateTaskData`, `clearConsultState`, `handleTransferConferenceSuccess`, `clearTransferConferenceRequested`
|
|
1279
1299
|
|
|
@@ -1289,6 +1309,30 @@ It is instantiated by `Task` and receives mapped backend/user events through `se
|
|
|
1289
1309
|
|
|
1290
1310
|
- Actions: `clearTransferConferenceRequested`
|
|
1291
1311
|
|
|
1312
|
+
- `PARTICIPANT_LEAVE` -> `WRAPPING_UP`
|
|
1313
|
+
|
|
1314
|
+
- Guard: `guards.didCurrentAgentLeaveConference && guards.shouldWrapUp`
|
|
1315
|
+
|
|
1316
|
+
- Actions: `updateTaskData`, `handleParticipantLeft`, `markEnded`, `clearConsultState`, `emitTaskParticipantLeft`, `emitTaskWrapup`
|
|
1317
|
+
|
|
1318
|
+
- `PARTICIPANT_LEAVE` -> `TERMINATED`
|
|
1319
|
+
|
|
1320
|
+
- Guard: `guards.didCurrentAgentLeaveConference`
|
|
1321
|
+
|
|
1322
|
+
- Actions: `updateTaskData`, `handleParticipantLeft`, `markEnded`, `clearConsultState`, `emitTaskParticipantLeft`, `emitTaskEnd`
|
|
1323
|
+
|
|
1324
|
+
- `PARTICIPANT_LEAVE` -> `CONNECTED`
|
|
1325
|
+
|
|
1326
|
+
- Guard: `!guards.didCurrentAgentLeaveConference && guards.shouldDowngradeConferenceToConnected`
|
|
1327
|
+
|
|
1328
|
+
- Actions: `updateTaskData`, `handleParticipantLeft`, `clearConsultState`, `emitTaskParticipantLeft`, `emitTaskConferenceEnded`
|
|
1329
|
+
|
|
1330
|
+
- `PARTICIPANT_LEAVE` -> stay `CONSULTING` (default)
|
|
1331
|
+
|
|
1332
|
+
- Guard: default
|
|
1333
|
+
|
|
1334
|
+
- Actions: `updateTaskData`, `handleParticipantLeft`, `emitTaskParticipantLeft`
|
|
1335
|
+
|
|
1292
1336
|
- `ASSIGN` -> `CONNECTED`
|
|
1293
1337
|
|
|
1294
1338
|
- Guard: none
|
|
@@ -1379,7 +1423,7 @@ It is instantiated by `Task` and receives mapped backend/user events through `se
|
|
|
1379
1423
|
|
|
1380
1424
|
- `CONSULTING --TRANSFER_CONFERENCE_SUCCESS--> CONFERENCING`
|
|
1381
1425
|
|
|
1382
|
-
- Guard:
|
|
1426
|
+
- Guard: `!guards.isSelfConferenceTransferInitiator`
|
|
1383
1427
|
|
|
1384
1428
|
- Actions: `updateTaskData`, `clearConsultState`, `handleTransferConferenceSuccess`, `clearTransferConferenceRequested`
|
|
1385
1429
|
|
|
@@ -1417,10 +1461,28 @@ It is instantiated by `Task` and receives mapped backend/user events through `se
|
|
|
1417
1461
|
|
|
1418
1462
|
- `TRANSFER_CONFERENCE_SUCCESS` -> stay `CONFERENCING`
|
|
1419
1463
|
|
|
1420
|
-
- Guard:
|
|
1464
|
+
- Guard: `guards.isPassiveConferenceTransferObserver`
|
|
1421
1465
|
|
|
1422
1466
|
- Actions: `updateTaskData`, `handleTransferConferenceSuccess`, `clearTransferConferenceRequested`
|
|
1423
1467
|
|
|
1468
|
+
- `TRANSFER_CONFERENCE_SUCCESS` -> `WRAPPING_UP`
|
|
1469
|
+
|
|
1470
|
+
- Guard: `guards.shouldWrapUp`
|
|
1471
|
+
|
|
1472
|
+
- Actions: `updateTaskData`, `markEnded`, `clearConsultState`, `handleTransferConferenceSuccess`, `clearTransferConferenceRequested`, `emitTaskWrapup`
|
|
1473
|
+
|
|
1474
|
+
- `TRANSFER_CONFERENCE_SUCCESS` -> `CONFERENCING`
|
|
1475
|
+
|
|
1476
|
+
- Guard: `!guards.isSelfConferenceTransferInitiator`
|
|
1477
|
+
|
|
1478
|
+
- Actions: `updateTaskData`, `clearConsultState`, `handleTransferConferenceSuccess`, `clearTransferConferenceRequested`
|
|
1479
|
+
|
|
1480
|
+
- `TRANSFER_CONFERENCE_SUCCESS` -> `TERMINATED` (default branch)
|
|
1481
|
+
|
|
1482
|
+
- Guard: default
|
|
1483
|
+
|
|
1484
|
+
- Actions: `updateTaskData`, `markEnded`, `clearConsultState`, `handleTransferConferenceSuccess`, `clearTransferConferenceRequested`, `emitTaskEnd`
|
|
1485
|
+
|
|
1424
1486
|
- `TRANSFER_CONFERENCE_FAILED` -> stay `CONFERENCING`
|
|
1425
1487
|
|
|
1426
1488
|
- Guard: none
|
|
@@ -1702,7 +1764,7 @@ Complete mapping from backend CC_EVENTS to internal TaskEvent types.
|
|
|
1702
1764
|
| `PARTICIPANT_JOINED_CONFERENCE` | `CONFERENCE_START` | `CONSULTING` / `CONF_INITIATING` / `CONFERENCING` | `CONFERENCING` / same | Conference participant joined |
|
|
1703
1765
|
| `AGENT_CONSULT_CONFERENCE_FAILED` | `CONFERENCE_FAILED` | `CONF_INITIATING` | `CONSULTING` | Merge fail fallback |
|
|
1704
1766
|
| `AGENT_CONSULT_CONFERENCE_ENDED` | `CONFERENCE_END` | `CONFERENCING` | `WRAPPING_UP` / `CONNECTED` / `TERMINATED` | Guard-driven |
|
|
1705
|
-
| `PARTICIPANT_LEFT_CONFERENCE` | `PARTICIPANT_LEAVE` | `CONFERENCING`
|
|
1767
|
+
| `PARTICIPANT_LEFT_CONFERENCE` | `PARTICIPANT_LEAVE` | `CONSULTING` / `CONFERENCING` | `WRAPPING_UP` / `TERMINATED` / `CONNECTED` / same | Ownership + downgrade guards |
|
|
1706
1768
|
| `AGENT_CONFERENCE_TRANSFERRED` | `TRANSFER_CONFERENCE_SUCCESS` | `CONSULTING` / `CONFERENCING` | `WRAPPING_UP` / `CONFERENCING` / `TERMINATED` / same | Initiator/receiver dependent |
|
|
1707
1769
|
|
|
1708
1770
|
- `AGENT_CONTACT_UNASSIGNED` -> returns `null` in mapper (`TaskManager.mapEventToTaskStateMachineEvent`)
|
|
@@ -1750,7 +1812,7 @@ Complete mapping from backend CC_EVENTS to internal TaskEvent types.
|
|
|
1750
1812
|
| API `task.consultConference()` | `MERGE_TO_CONFERENCE` | CONSULTING → CONF_INITIATING | Starts merge flow |
|
|
1751
1813
|
| `AgentConsultConferenced` | `CONFERENCE_START` | CONSULTING/CONF_INITIATING → CONFERENCING | `handleConferenceStarted` path |
|
|
1752
1814
|
| `ParticipantJoinedConference` | `CONFERENCE_START` | CONFERENCING → CONFERENCING | Refresh + emit conference started |
|
|
1753
|
-
| `ParticipantLeftConference` | `PARTICIPANT_LEAVE` | CONFERENCING → WRAPPING_UP / TERMINATED / CONNECTED / stay | Uses `didCurrentAgentLeaveConference`, `shouldWrapUp`, `shouldDowngradeConferenceToConnected` |
|
|
1815
|
+
| `ParticipantLeftConference` | `PARTICIPANT_LEAVE` | CONSULTING / CONFERENCING → WRAPPING_UP / TERMINATED / CONNECTED / stay | Uses `didCurrentAgentLeaveConference`, `shouldWrapUp`, `shouldDowngradeConferenceToConnected` |
|
|
1754
1816
|
| `AgentConsultConferenceEnded` | `CONFERENCE_END` | CONFERENCING → WRAPPING_UP / CONNECTED / TERMINATED | Guard-based branch |
|
|
1755
1817
|
| `AgentConsultConferenceFailed` | `CONFERENCE_FAILED` | CONF_INITIATING → CONSULTING | Merge failed fallback |
|
|
1756
1818
|
| `AgentConferenceTransferred` | `TRANSFER_CONFERENCE_SUCCESS` | CONSULTING/CONFERENCING branch logic | Initiator/receiver dependent |
|
|
@@ -27,6 +27,7 @@ import {
|
|
|
27
27
|
getConferenceParticipantsCount,
|
|
28
28
|
getIsConferenceInProgress,
|
|
29
29
|
isCampaignPreviewTask,
|
|
30
|
+
isEpDnConsultPendingConferenceMerge,
|
|
30
31
|
} from '../TaskUtils';
|
|
31
32
|
import {TaskEvent, INTERACTION_STATE, CONSULT_STATE, MEDIA_TYPE_CONSULT} from './constants';
|
|
32
33
|
|
|
@@ -171,6 +172,12 @@ export const guards = {
|
|
|
171
172
|
return false;
|
|
172
173
|
},
|
|
173
174
|
|
|
175
|
+
isEpDnPendingConferenceMergeHydrate: ({event, context}: GuardParams): boolean => {
|
|
176
|
+
const taskData = getTaskDataFromEvent(event);
|
|
177
|
+
|
|
178
|
+
return isEpDnConsultPendingConferenceMerge(taskData, getSelfAgentId(context, taskData));
|
|
179
|
+
},
|
|
180
|
+
|
|
174
181
|
isInteractionHeld: ({event}: GuardParams): boolean => {
|
|
175
182
|
const taskData = getTaskDataFromEvent(event);
|
|
176
183
|
|
|
@@ -388,6 +395,38 @@ export const guards = {
|
|
|
388
395
|
return false;
|
|
389
396
|
},
|
|
390
397
|
|
|
398
|
+
/**
|
|
399
|
+
* True when this agent initiated the conference transfer (widgets or desktop).
|
|
400
|
+
* Mirrors determineConsultInitiator: consultingAgentId === self only (not consultState).
|
|
401
|
+
*/
|
|
402
|
+
isSelfConferenceTransferInitiator: ({context, event}: GuardParams): boolean => {
|
|
403
|
+
if (context.transferConferenceRequested === true) return true;
|
|
404
|
+
|
|
405
|
+
const taskData = getTaskDataFromEvent(event);
|
|
406
|
+
const selfAgentId = getSelfAgentId(context, taskData);
|
|
407
|
+
if (!selfAgentId || !taskData) return false;
|
|
408
|
+
|
|
409
|
+
return taskData.consultingAgentId === selfAgentId;
|
|
410
|
+
},
|
|
411
|
+
|
|
412
|
+
/**
|
|
413
|
+
* True when another agent performed conference transfer and this agent should
|
|
414
|
+
* only refresh task data (stay in the current state). False when this agent
|
|
415
|
+
* initiated the transfer (desktop or widgets) or has left the interaction.
|
|
416
|
+
*/
|
|
417
|
+
isPassiveConferenceTransferObserver: ({context, event}: GuardParams): boolean => {
|
|
418
|
+
if (guards.isSelfConferenceTransferInitiator({context, event})) return false;
|
|
419
|
+
|
|
420
|
+
const taskData = getTaskDataFromEvent(event);
|
|
421
|
+
const selfAgentId = getSelfAgentId(context, taskData);
|
|
422
|
+
if (selfAgentId && taskData?.interaction?.participants) {
|
|
423
|
+
if (!(selfAgentId in taskData.interaction.participants)) return false;
|
|
424
|
+
if (taskData.interaction.participants[selfAgentId]?.hasLeft === true) return false;
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
return true;
|
|
428
|
+
},
|
|
429
|
+
|
|
391
430
|
isCampaignReservationAccept: ({event}: GuardParams): boolean => {
|
|
392
431
|
return event?.type === TaskEvent.TASK_INCOMING && event.isCampaignReservationAccept === true;
|
|
393
432
|
},
|
|
@@ -61,6 +61,8 @@ export interface TaskContext {
|
|
|
61
61
|
consultDestinationAgentId: string | null;
|
|
62
62
|
consultDestinationAgentJoined: boolean;
|
|
63
63
|
consultCallHeld: boolean;
|
|
64
|
+
/** Set by deriveTaskDataUpdates / clearConsultState when EP-DN consult is merged to conference before Agent 2 joins (CAI-8329). */
|
|
65
|
+
hideBlindTransferForEpDnPendingMerge: boolean;
|
|
64
66
|
|
|
65
67
|
// Recording
|
|
66
68
|
recordingControlsAvailable: boolean;
|
|
@@ -11,7 +11,7 @@ import {
|
|
|
11
11
|
VOICE_VARIANT,
|
|
12
12
|
} from '../types';
|
|
13
13
|
import {TaskContext, UIControlConfig} from './types';
|
|
14
|
-
import {TaskState, MAX_PARTICIPANTS_IN_MULTIPARTY_CONFERENCE} from './constants';
|
|
14
|
+
import {TaskState, MAX_PARTICIPANTS_IN_MULTIPARTY_CONFERENCE, INTERACTION_STATE} from './constants';
|
|
15
15
|
import {
|
|
16
16
|
getIsCustomerInCall,
|
|
17
17
|
getConferenceParticipantsCount,
|
|
@@ -80,6 +80,23 @@ function hasConsultMediaInInteraction(
|
|
|
80
80
|
return Boolean(media[consultMediaResourceId]);
|
|
81
81
|
}
|
|
82
82
|
|
|
83
|
+
/** True when this agent is actively initiating a consult, not a stale context flag alone (CAI-8329). */
|
|
84
|
+
function isSelfActiveConsultInitiator(
|
|
85
|
+
consultInitiator: boolean,
|
|
86
|
+
selfAgentId: string | undefined,
|
|
87
|
+
selfParticipant: {consultState?: string} | null | undefined,
|
|
88
|
+
selfInConsultCall: boolean,
|
|
89
|
+
consultingAgentId?: string
|
|
90
|
+
): boolean {
|
|
91
|
+
return (
|
|
92
|
+
consultInitiator &&
|
|
93
|
+
(selfParticipant?.consultState === 'consultInitiated' ||
|
|
94
|
+
selfParticipant?.consultState === 'consulting' ||
|
|
95
|
+
selfInConsultCall ||
|
|
96
|
+
(Boolean(consultingAgentId) && consultingAgentId === selfAgentId))
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
|
|
83
100
|
function computeVoiceInteractionUIControls(
|
|
84
101
|
state: TaskState,
|
|
85
102
|
context: TaskContext,
|
|
@@ -132,6 +149,7 @@ function computeVoiceInteractionUIControls(
|
|
|
132
149
|
consultDestinationType,
|
|
133
150
|
consultCallHeld,
|
|
134
151
|
consultFromConference,
|
|
152
|
+
hideBlindTransferForEpDnPendingMerge,
|
|
135
153
|
} = context;
|
|
136
154
|
const {recordingControlsAvailable} = context;
|
|
137
155
|
|
|
@@ -168,12 +186,25 @@ function computeVoiceInteractionUIControls(
|
|
|
168
186
|
interaction,
|
|
169
187
|
taskData?.consultMediaResourceId
|
|
170
188
|
);
|
|
189
|
+
const isSecondaryConsultRejectedWhileEpDnActive =
|
|
190
|
+
taskData?.type === 'AgentConsultFailed' && hideBlindTransferForEpDnPendingMerge;
|
|
191
|
+
const isSelfConsultCompleted =
|
|
192
|
+
selfParticipant?.consultState === 'consultCompleted' && taskData?.isConsulted === false;
|
|
171
193
|
const isConsultEndedForSelf =
|
|
172
194
|
taskData?.type === 'AgentConsultEnded' ||
|
|
173
|
-
taskData?.type === 'AgentConsultFailed' ||
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
195
|
+
(taskData?.type === 'AgentConsultFailed' && !isSecondaryConsultRejectedWhileEpDnActive) ||
|
|
196
|
+
isSelfConsultCompleted;
|
|
197
|
+
// EP-DN consult merged to conference before destination joins main (CAI-8329).
|
|
198
|
+
// Context flag is set in actions.ts; treat as main-leg conference UI only here.
|
|
199
|
+
const isEpDnPendingConferenceMergeUi =
|
|
200
|
+
hideBlindTransferForEpDnPendingMerge &&
|
|
201
|
+
participantCount <= 1 &&
|
|
202
|
+
(isConferencing ||
|
|
203
|
+
conferenceFromBackend ||
|
|
204
|
+
interaction?.state === INTERACTION_STATE.POST_CALL) &&
|
|
205
|
+
!isConsultEndedForSelf;
|
|
206
|
+
const isEpDnPostCallCustomerLeft = isEpDnPendingConferenceMergeUi && !customerPresent;
|
|
207
|
+
const effectiveConsultInProgress = isEpDnPendingConferenceMergeUi ? false : consultInProgress;
|
|
177
208
|
const effectiveConsultInitiator = isConsultEndedForSelf ? false : consultInitiator;
|
|
178
209
|
const effectiveConsultCallHeld = isConsultEndedForSelf ? false : consultCallHeld;
|
|
179
210
|
const effectiveConsultFromConference = isConsultEndedForSelf ? false : consultFromConference;
|
|
@@ -214,9 +245,14 @@ function computeVoiceInteractionUIControls(
|
|
|
214
245
|
// Consulted agents have limited controls until they're in conference or wrapup
|
|
215
246
|
// Use inConference (not isConferencing) so controls remain enabled after state downgrade
|
|
216
247
|
const hasFullControls = !isConsulted || effectiveConsultInitiator || inConference || isWrappingUp;
|
|
217
|
-
const
|
|
218
|
-
effectiveConsultInitiator
|
|
219
|
-
|
|
248
|
+
const selfActiveConsultInitiator = isSelfActiveConsultInitiator(
|
|
249
|
+
effectiveConsultInitiator,
|
|
250
|
+
selfAgentId,
|
|
251
|
+
selfParticipant,
|
|
252
|
+
selfInConsultCall,
|
|
253
|
+
taskData?.consultingAgentId
|
|
254
|
+
);
|
|
255
|
+
const consultOwnedBySelf = !isEpDnPendingConferenceMergeUi && selfActiveConsultInitiator;
|
|
220
256
|
const selfConsultPendingOnConsultMedia =
|
|
221
257
|
selfParticipant?.consultState === 'consultInitiated' &&
|
|
222
258
|
!taskData?.isConsulted &&
|
|
@@ -229,7 +265,7 @@ function computeVoiceInteractionUIControls(
|
|
|
229
265
|
Object.values(interaction.participants).some((participant: any) => {
|
|
230
266
|
if (!participant || participant.hasLeft) return false;
|
|
231
267
|
if (participant.id === selfAgentId) return false;
|
|
232
|
-
if (participant.pType !== 'AGENT') return false;
|
|
268
|
+
if (String(participant.pType ?? '').toUpperCase() !== 'AGENT') return false;
|
|
233
269
|
|
|
234
270
|
return (
|
|
235
271
|
participant.consultState === 'consultInitiated' ||
|
|
@@ -246,11 +282,11 @@ function computeVoiceInteractionUIControls(
|
|
|
246
282
|
consultOwnedBySelf &&
|
|
247
283
|
!isConsulting &&
|
|
248
284
|
!isConsulted &&
|
|
249
|
-
(
|
|
285
|
+
(effectiveConsultInProgress || effectiveConsultCallHeld || hasConsultMedia);
|
|
250
286
|
const activeLegForConferenceConsult = effectiveConsultCallHeld ? 'main' : 'consult';
|
|
251
287
|
const isCurrentLegActive = currentLeg === activeLegForConferenceConsult;
|
|
252
288
|
const isConferenceConsultTransferContext =
|
|
253
|
-
inConference &&
|
|
289
|
+
inConference && selfActiveConsultInitiator && hasConsultMedia && isConsultDestinationReady;
|
|
254
290
|
const consultLegOnHold = isConsulting && effectiveConsultCallHeld;
|
|
255
291
|
const callProcessingDetails = interaction?.callProcessingDetails as
|
|
256
292
|
| {conferenceHoldParticipant?: boolean | string}
|
|
@@ -284,11 +320,11 @@ function computeVoiceInteractionUIControls(
|
|
|
284
320
|
(consultFromConference ||
|
|
285
321
|
consultInitiator ||
|
|
286
322
|
taskData?.type === 'AgentConsultCreated' ||
|
|
287
|
-
|
|
323
|
+
effectiveConsultInProgress ||
|
|
288
324
|
isConsulting);
|
|
289
325
|
const hideExitConferenceDuringActiveConsultFromConference =
|
|
290
326
|
inConference &&
|
|
291
|
-
|
|
327
|
+
selfActiveConsultInitiator &&
|
|
292
328
|
consultDestinationAgentJoined &&
|
|
293
329
|
(isConsulting ||
|
|
294
330
|
taskData?.type === 'AgentConsulting' ||
|
|
@@ -297,9 +333,9 @@ function computeVoiceInteractionUIControls(
|
|
|
297
333
|
currentLeg === 'main' &&
|
|
298
334
|
inConference &&
|
|
299
335
|
consultFromConference &&
|
|
300
|
-
|
|
336
|
+
selfActiveConsultInitiator &&
|
|
301
337
|
(isConsulting ||
|
|
302
|
-
|
|
338
|
+
effectiveConsultInProgress ||
|
|
303
339
|
taskData?.type === 'AgentConsultCreated' ||
|
|
304
340
|
taskData?.type === 'AgentConsulting' ||
|
|
305
341
|
selfParticipant?.consultState === 'consultInitiated' ||
|
|
@@ -311,7 +347,7 @@ function computeVoiceInteractionUIControls(
|
|
|
311
347
|
selfParticipant?.consultState === 'consulting' ||
|
|
312
348
|
selfParticipant?.currentState === 'consulting';
|
|
313
349
|
const showMainLegConferenceControlsDuringConsult =
|
|
314
|
-
currentLeg === 'main' && inConference &&
|
|
350
|
+
currentLeg === 'main' && inConference && effectiveConsultInProgress && !selfOnConsultLeg;
|
|
315
351
|
const allowHeldMainLegControlsForNonInitiator =
|
|
316
352
|
showMainLegConferenceControlsDuringConsult && !isHydratedConferenceConsultPending;
|
|
317
353
|
const isConsultRequestedPhase =
|
|
@@ -331,6 +367,8 @@ function computeVoiceInteractionUIControls(
|
|
|
331
367
|
(taskData?.type === 'AgentConsultFailed' ||
|
|
332
368
|
taskData?.type === 'AgentConsultEnded' ||
|
|
333
369
|
isConsultEndedForSelf);
|
|
370
|
+
// EP-DN consult merged to conference before a second agent joins main: hide blind transfer (CAI-8329).
|
|
371
|
+
const shouldHideBlindTransferForEpDnPendingConferenceMerge = isEpDnPendingConferenceMergeUi;
|
|
334
372
|
|
|
335
373
|
if (isConsultUnansweredFailure) {
|
|
336
374
|
const recordingControl =
|
|
@@ -348,11 +386,13 @@ function computeVoiceInteractionUIControls(
|
|
|
348
386
|
};
|
|
349
387
|
}
|
|
350
388
|
|
|
351
|
-
if (isConsultRequestedPhase) {
|
|
389
|
+
if (isConsultRequestedPhase && !isEpDnPendingConferenceMergeUi) {
|
|
352
390
|
if (currentLeg === 'main') {
|
|
353
391
|
return {
|
|
354
392
|
...getDefaultInteractionUIControls(),
|
|
355
|
-
transfer:
|
|
393
|
+
transfer: shouldHideBlindTransferForEpDnPendingConferenceMerge
|
|
394
|
+
? DISABLED
|
|
395
|
+
: VISIBLE_DISABLED,
|
|
356
396
|
conference: VISIBLE_DISABLED,
|
|
357
397
|
end: VISIBLE_DISABLED,
|
|
358
398
|
};
|
|
@@ -388,6 +428,7 @@ function computeVoiceInteractionUIControls(
|
|
|
388
428
|
// Hold: visible in connected/held/conference, disabled in conference/consulting
|
|
389
429
|
hold: (() => {
|
|
390
430
|
if (!hasFullControls) return DISABLED;
|
|
431
|
+
if (isEpDnPostCallCustomerLeft) return VISIBLE_DISABLED;
|
|
391
432
|
if (forceHeldPostConsultControls) return VISIBLE_ENABLED;
|
|
392
433
|
if (
|
|
393
434
|
consultOwnedBySelf &&
|
|
@@ -410,6 +451,7 @@ function computeVoiceInteractionUIControls(
|
|
|
410
451
|
mute: (() => {
|
|
411
452
|
if (!isWebrtc) return DISABLED;
|
|
412
453
|
if (isWrappingUp) return DISABLED;
|
|
454
|
+
if (isEpDnPostCallCustomerLeft && inConference) return VISIBLE_DISABLED;
|
|
413
455
|
if (currentLeg === 'consult' && !selfInConsultCall) return DISABLED;
|
|
414
456
|
// The consulted agent has no separate consult leg; their main leg is the active call, so
|
|
415
457
|
// mute stays available while they consult (the heuristic below targets the initiator's
|
|
@@ -453,7 +495,7 @@ function computeVoiceInteractionUIControls(
|
|
|
453
495
|
if (isConsulted) return DISABLED;
|
|
454
496
|
if (forceHeldPostConsultControls) return VISIBLE_DISABLED;
|
|
455
497
|
|
|
456
|
-
if (
|
|
498
|
+
if (effectiveConsultInProgress) return VISIBLE_DISABLED;
|
|
457
499
|
|
|
458
500
|
return isWrappingUp ? VISIBLE_DISABLED : VISIBLE_ENABLED;
|
|
459
501
|
}
|
|
@@ -472,6 +514,7 @@ function computeVoiceInteractionUIControls(
|
|
|
472
514
|
if (inConference && isConsulting && consultInitiator) return DISABLED;
|
|
473
515
|
if (hasParallelConsultLeg) {
|
|
474
516
|
if (!customerPresent) return DISABLED;
|
|
517
|
+
if (shouldHideBlindTransferForEpDnPendingConferenceMerge) return DISABLED;
|
|
475
518
|
if (state === TaskState.CONNECTED) return VISIBLE_ENABLED;
|
|
476
519
|
if (state === TaskState.HELD) return VISIBLE_DISABLED;
|
|
477
520
|
}
|
|
@@ -486,7 +529,17 @@ function computeVoiceInteractionUIControls(
|
|
|
486
529
|
if (inConference) {
|
|
487
530
|
// Real conference (multiple agents): transfer is hidden
|
|
488
531
|
// Pending conference (only self agent): transfer remains available
|
|
489
|
-
|
|
532
|
+
// EP-DN consult merged before destination joins: hide blind transfer on main
|
|
533
|
+
// (Agent 2 may still be ringing on the consult leg — blind transfer causes stuck ringing)
|
|
534
|
+
if (shouldHideBlindTransferForEpDnPendingConferenceMerge) {
|
|
535
|
+
return DISABLED;
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
if (participantCount > 1) {
|
|
539
|
+
return DISABLED;
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
return customerPresent ? VISIBLE_ENABLED : DISABLED;
|
|
490
543
|
}
|
|
491
544
|
if (state === TaskState.CONNECTED || state === TaskState.HELD) return VISIBLE_ENABLED;
|
|
492
545
|
|
|
@@ -523,7 +576,7 @@ function computeVoiceInteractionUIControls(
|
|
|
523
576
|
const canFromConference =
|
|
524
577
|
!maxParticipants &&
|
|
525
578
|
customerPresent &&
|
|
526
|
-
!
|
|
579
|
+
!effectiveConsultInProgress &&
|
|
527
580
|
!otherAgentConsultInProgress &&
|
|
528
581
|
!isConsulting;
|
|
529
582
|
|
|
@@ -532,7 +585,7 @@ function computeVoiceInteractionUIControls(
|
|
|
532
585
|
|
|
533
586
|
// Enabled conditions for connected/held
|
|
534
587
|
const canFromConnected =
|
|
535
|
-
!maxParticipants && customerPresent && !
|
|
588
|
+
!maxParticipants && customerPresent && !effectiveConsultInProgress && !isConsulted;
|
|
536
589
|
|
|
537
590
|
return {isVisible: true, isEnabled: canFromConnected};
|
|
538
591
|
})(),
|
|
@@ -551,14 +604,16 @@ function computeVoiceInteractionUIControls(
|
|
|
551
604
|
return {isVisible: true, isEnabled: consultInitiator || config.isEndConsultEnabled};
|
|
552
605
|
})(),
|
|
553
606
|
|
|
554
|
-
// Recording: connected/held
|
|
607
|
+
// Recording: connected/held/conference when backend exposes recording controls
|
|
555
608
|
recording: (() => {
|
|
556
609
|
if (!recordingControlsAvailable || !config.isRecordingEnabled) return DISABLED;
|
|
557
610
|
if (!hasFullControls || isConsulting) return DISABLED;
|
|
558
611
|
if (inConference) {
|
|
559
|
-
//
|
|
560
|
-
//
|
|
561
|
-
|
|
612
|
+
// Recording stays available in conference when backend exposes controls
|
|
613
|
+
// (including after EP-DN pending merge transitions to real conference — CAI-8329)
|
|
614
|
+
if (isEpDnPostCallCustomerLeft) return VISIBLE_DISABLED;
|
|
615
|
+
|
|
616
|
+
return VISIBLE_ENABLED;
|
|
562
617
|
}
|
|
563
618
|
if (hasParallelConsultLeg && !customerPresent) return DISABLED;
|
|
564
619
|
if (state === TaskState.CONNECTED || state === TaskState.HELD) {
|
|
@@ -602,8 +657,9 @@ function computeVoiceInteractionUIControls(
|
|
|
602
657
|
if (forceHeldPostConsultControls) return VISIBLE_DISABLED;
|
|
603
658
|
if (isConsulted && !isConferencing) return DISABLED;
|
|
604
659
|
if (!inConference) return DISABLED;
|
|
660
|
+
if (isEpDnPendingConferenceMergeUi) return VISIBLE_ENABLED;
|
|
605
661
|
if (participantCount <= 1) return DISABLED;
|
|
606
|
-
if (
|
|
662
|
+
if (effectiveConsultInProgress) return VISIBLE_DISABLED;
|
|
607
663
|
const consultingFromConference = consultInitiator && isConsulting && conferenceFromBackend;
|
|
608
664
|
|
|
609
665
|
return consultingFromConference ? VISIBLE_DISABLED : VISIBLE_ENABLED;
|
|
@@ -613,18 +669,18 @@ function computeVoiceInteractionUIControls(
|
|
|
613
669
|
transferConference: (() => {
|
|
614
670
|
if (isConferenceConsultTransferContext && !isCurrentLegActive) return VISIBLE_DISABLED;
|
|
615
671
|
const consultLegTransferAvailable =
|
|
616
|
-
currentLeg === 'consult' && inConference &&
|
|
672
|
+
currentLeg === 'consult' && inConference && selfActiveConsultInitiator && hasConsultMedia;
|
|
617
673
|
const selfConsultingOnParticipantState = selfParticipant?.consultState === 'consulting';
|
|
618
674
|
const conferenceTransferAvailable =
|
|
619
675
|
consultLegTransferAvailable ||
|
|
620
676
|
(inConference &&
|
|
621
|
-
|
|
677
|
+
selfActiveConsultInitiator &&
|
|
622
678
|
hasConsultMedia &&
|
|
623
|
-
(isConsulting ||
|
|
679
|
+
(isConsulting || effectiveConsultInProgress || selfConsultingOnParticipantState));
|
|
624
680
|
if (consultLegOnHold) return DISABLED;
|
|
625
681
|
if (hasParallelConsultLeg && !conferenceTransferAvailable) return DISABLED;
|
|
626
682
|
if (!conferenceTransferAvailable && (!inConference || !isConsulting)) return DISABLED;
|
|
627
|
-
if (!
|
|
683
|
+
if (!selfActiveConsultInitiator || isConsulted) return DISABLED;
|
|
628
684
|
|
|
629
685
|
return isConsultDestinationReady ? VISIBLE_ENABLED : VISIBLE_DISABLED;
|
|
630
686
|
})(),
|
|
@@ -709,17 +765,38 @@ function getVoiceLegState(
|
|
|
709
765
|
const interaction = taskData?.interaction;
|
|
710
766
|
const mainCallId = interaction?.mainInteractionId || taskData?.interactionId;
|
|
711
767
|
const selfAgentId = config.agentId ?? taskData?.agentId;
|
|
768
|
+
|
|
769
|
+
if (context.hideBlindTransferForEpDnPendingMerge && taskData) {
|
|
770
|
+
const conferenceFromBackend = getIsConferenceInProgress(taskData);
|
|
771
|
+
const participantCount =
|
|
772
|
+
interaction && mainCallId ? getConferenceParticipantsCount(interaction, mainCallId) : 0;
|
|
773
|
+
|
|
774
|
+
if (
|
|
775
|
+
participantCount <= 1 &&
|
|
776
|
+
(currentState === TaskState.CONFERENCING || conferenceFromBackend)
|
|
777
|
+
) {
|
|
778
|
+
return {
|
|
779
|
+
hasConsultLeg: false,
|
|
780
|
+
activeLeg: 'main',
|
|
781
|
+
mainState: TaskState.CONFERENCING,
|
|
782
|
+
consultState: TaskState.CONSULTING,
|
|
783
|
+
};
|
|
784
|
+
}
|
|
785
|
+
}
|
|
786
|
+
|
|
712
787
|
const selfParticipant = selfAgentId ? interaction?.participants?.[selfAgentId] : null;
|
|
713
788
|
const hasConsultMedia = hasConsultMediaInInteraction(
|
|
714
789
|
interaction,
|
|
715
790
|
taskData?.consultMediaResourceId
|
|
716
791
|
);
|
|
792
|
+
const isSecondaryConsultRejectedWhileEpDnActive =
|
|
793
|
+
taskData?.type === 'AgentConsultFailed' && context.hideBlindTransferForEpDnPendingMerge;
|
|
794
|
+
const isSelfConsultCompleted =
|
|
795
|
+
selfParticipant?.consultState === 'consultCompleted' && taskData?.isConsulted === false;
|
|
717
796
|
const isConsultEndedForSelf =
|
|
718
797
|
taskData?.type === 'AgentConsultEnded' ||
|
|
719
|
-
taskData?.type === 'AgentConsultFailed' ||
|
|
720
|
-
|
|
721
|
-
!hasConsultMedia &&
|
|
722
|
-
taskData?.isConsulted === false);
|
|
798
|
+
(taskData?.type === 'AgentConsultFailed' && !isSecondaryConsultRejectedWhileEpDnActive) ||
|
|
799
|
+
isSelfConsultCompleted;
|
|
723
800
|
const consultInProgress = getIsConsultInProgressForConferenceControls(
|
|
724
801
|
interaction,
|
|
725
802
|
mainCallId,
|
|
@@ -729,9 +806,21 @@ function getVoiceLegState(
|
|
|
729
806
|
currentState === TaskState.CONSULTING ||
|
|
730
807
|
currentState === TaskState.CONSULT_INITIATING ||
|
|
731
808
|
currentState === TaskState.CONF_INITIATING;
|
|
809
|
+
const consultMedia = Object.values(interaction?.media ?? {}).find(
|
|
810
|
+
(media: any) =>
|
|
811
|
+
media?.mediaResourceId === taskData?.consultMediaResourceId || media?.mType === 'consult'
|
|
812
|
+
) as {participants?: string[]} | undefined;
|
|
813
|
+
const selfInConsultCall =
|
|
814
|
+
Boolean(selfAgentId) && Boolean(consultMedia?.participants?.includes(selfAgentId as string));
|
|
815
|
+
const selfActiveConsultInitiator = isSelfActiveConsultInitiator(
|
|
816
|
+
context.consultInitiator,
|
|
817
|
+
selfAgentId,
|
|
818
|
+
selfParticipant,
|
|
819
|
+
selfInConsultCall,
|
|
820
|
+
taskData?.consultingAgentId
|
|
821
|
+
);
|
|
732
822
|
const consultOwnedBySelf =
|
|
733
|
-
context.
|
|
734
|
-
(Boolean(selfAgentId) && taskData?.consultingAgentId === selfAgentId);
|
|
823
|
+
!context.hideBlindTransferForEpDnPendingMerge && selfActiveConsultInitiator;
|
|
735
824
|
const selfConsultPendingOnConsultMedia =
|
|
736
825
|
selfParticipant?.consultState === 'consultInitiated' &&
|
|
737
826
|
!taskData?.isConsulted &&
|
|
@@ -739,9 +828,7 @@ function getVoiceLegState(
|
|
|
739
828
|
const selfConsultingOnConsultMedia =
|
|
740
829
|
selfParticipant?.consultState === 'consulting' && hasConsultMedia;
|
|
741
830
|
const isConsultUnansweredFailure =
|
|
742
|
-
|
|
743
|
-
!hasConsultMedia &&
|
|
744
|
-
selfParticipant?.consultState === 'consultCompleted' &&
|
|
831
|
+
isSelfConsultCompleted &&
|
|
745
832
|
Boolean(mainCallId && taskData?.interaction?.media?.[mainCallId]?.isHold === true) &&
|
|
746
833
|
taskData?.interaction?.state !== 'conference' &&
|
|
747
834
|
!getIsConferenceInProgress(taskData);
|
|
@@ -782,7 +869,14 @@ function getVoiceLegState(
|
|
|
782
869
|
if (currentState === TaskState.CONFERENCING) {
|
|
783
870
|
mainState = TaskState.CONFERENCING;
|
|
784
871
|
} else if (context.consultCallHeld) {
|
|
785
|
-
|
|
872
|
+
const mainMediaId = taskData?.interaction?.mainInteractionId || taskData?.interactionId;
|
|
873
|
+
const isMainHeld = Boolean(
|
|
874
|
+
mainMediaId && taskData?.interaction?.media?.[mainMediaId]?.isHold === true
|
|
875
|
+
);
|
|
876
|
+
mainState =
|
|
877
|
+
isMainHeld || taskData?.interaction?.state === 'hold' ? TaskState.HELD : TaskState.CONNECTED;
|
|
878
|
+
} else if (currentState === TaskState.CONNECTED || currentState === TaskState.HELD) {
|
|
879
|
+
mainState = currentState;
|
|
786
880
|
}
|
|
787
881
|
|
|
788
882
|
return {
|
|
@@ -130,9 +130,11 @@ export default class Voice extends Task implements IVoice {
|
|
|
130
130
|
this.data.interaction?.media?.[mainInteractionId]?.isHold ??
|
|
131
131
|
this.data.interaction.media?.[mainMediaResource]?.isHold;
|
|
132
132
|
let shouldHold = !(mediaHoldState ?? false);
|
|
133
|
-
if (snapshotState === TaskState.HELD) {
|
|
133
|
+
if (snapshotState === TaskState.HELD || snapshotState === TaskState.RESUME_INITIATING) {
|
|
134
134
|
shouldHold = false;
|
|
135
|
-
} else if (snapshotState === TaskState.CONNECTED) {
|
|
135
|
+
} else if (snapshotState === TaskState.CONNECTED && mediaHoldState !== true) {
|
|
136
|
+
// CONNECTED with isHold:true can happen after consult RONA/decline when event ordering
|
|
137
|
+
// leaves the machine in CONNECTED while main media is still on hold — resume on first click.
|
|
136
138
|
shouldHold = true;
|
|
137
139
|
}
|
|
138
140
|
|
|
@@ -152,7 +154,8 @@ export default class Voice extends Task implements IVoice {
|
|
|
152
154
|
}
|
|
153
155
|
} else if (
|
|
154
156
|
!state.matches(TaskState.HELD) &&
|
|
155
|
-
!(state.matches(TaskState.CONFERENCING) && mediaHoldState === true)
|
|
157
|
+
!(state.matches(TaskState.CONFERENCING) && mediaHoldState === true) &&
|
|
158
|
+
!(state.matches(TaskState.CONNECTED) && mediaHoldState === true)
|
|
156
159
|
) {
|
|
157
160
|
const error = new Error(`Cannot resume call in current state: ${currentState}`);
|
|
158
161
|
LoggerProxy.error('Resume operation not allowed', {
|
|
@@ -635,5 +635,34 @@ describe('TaskUtils', () => {
|
|
|
635
635
|
getIsConsultInProgressForConferenceControls(interaction, 'interaction-1', 'agent-1')
|
|
636
636
|
).toBe(true);
|
|
637
637
|
});
|
|
638
|
+
|
|
639
|
+
it('returns true when EP-DN participant is on consult leg but not main call (CAI-8329)', () => {
|
|
640
|
+
const interaction = {
|
|
641
|
+
participants: {
|
|
642
|
+
'agent-1': {id: 'agent-1', pType: 'Agent', hasLeft: false, consultState: 'conferencing'},
|
|
643
|
+
'+13159998087': {
|
|
644
|
+
id: '+13159998087',
|
|
645
|
+
pType: 'EP-DN',
|
|
646
|
+
type: 'EpDn',
|
|
647
|
+
hasLeft: false,
|
|
648
|
+
},
|
|
649
|
+
'customer-1': {id: 'customer-1', pType: 'Customer', hasLeft: false, hasJoined: true},
|
|
650
|
+
},
|
|
651
|
+
media: {
|
|
652
|
+
'interaction-1': {
|
|
653
|
+
mType: 'mainCall',
|
|
654
|
+
participants: ['customer-1', 'agent-1'],
|
|
655
|
+
},
|
|
656
|
+
'consult-media-1': {
|
|
657
|
+
mType: 'consult',
|
|
658
|
+
participants: ['+13159998087', 'agent-1'],
|
|
659
|
+
},
|
|
660
|
+
},
|
|
661
|
+
} as any;
|
|
662
|
+
|
|
663
|
+
expect(
|
|
664
|
+
getIsConsultInProgressForConferenceControls(interaction, 'interaction-1', 'agent-1')
|
|
665
|
+
).toBe(true);
|
|
666
|
+
});
|
|
638
667
|
});
|
|
639
668
|
});
|