@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.
Files changed (34) hide show
  1. package/dist/services/task/TaskUtils.js +51 -3
  2. package/dist/services/task/TaskUtils.js.map +1 -1
  3. package/dist/services/task/state-machine/TaskStateMachine.js +50 -14
  4. package/dist/services/task/state-machine/TaskStateMachine.js.map +1 -1
  5. package/dist/services/task/state-machine/actions.js +57 -33
  6. package/dist/services/task/state-machine/actions.js.map +1 -1
  7. package/dist/services/task/state-machine/guards.js +42 -0
  8. package/dist/services/task/state-machine/guards.js.map +1 -1
  9. package/dist/services/task/state-machine/types.js.map +1 -1
  10. package/dist/services/task/state-machine/uiControlsComputer.js +78 -28
  11. package/dist/services/task/state-machine/uiControlsComputer.js.map +1 -1
  12. package/dist/services/task/voice/Voice.js +5 -3
  13. package/dist/services/task/voice/Voice.js.map +1 -1
  14. package/dist/types/services/task/TaskUtils.d.ts +2 -0
  15. package/dist/types/services/task/state-machine/TaskStateMachine.d.ts +72 -22
  16. package/dist/types/services/task/state-machine/guards.d.ts +12 -0
  17. package/dist/types/services/task/state-machine/types.d.ts +2 -0
  18. package/dist/webex.js +1 -1
  19. package/package.json +2 -2
  20. package/src/services/task/TaskUtils.ts +100 -2
  21. package/src/services/task/state-machine/TaskStateMachine.ts +101 -12
  22. package/src/services/task/state-machine/actions.ts +88 -35
  23. package/src/services/task/state-machine/ai-docs/task-state-machine-spec.md +73 -11
  24. package/src/services/task/state-machine/guards.ts +39 -0
  25. package/src/services/task/state-machine/types.ts +2 -0
  26. package/src/services/task/state-machine/uiControlsComputer.ts +135 -41
  27. package/src/services/task/voice/Voice.ts +6 -3
  28. package/test/unit/spec/services/task/TaskUtils.ts +29 -0
  29. package/test/unit/spec/services/task/state-machine/TaskStateMachine.ts +826 -0
  30. package/test/unit/spec/services/task/state-machine/guards.ts +158 -0
  31. package/test/unit/spec/services/task/state-machine/uiControlsComputer.ts +648 -5
  32. package/test/unit/spec/services/task/voice/Voice.ts +18 -0
  33. package/umd/contact-center.min.js +2 -2
  34. 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: inline `!context.consultInitiator`
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: inline `!context.consultInitiator`
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: inline `context.transferConferenceRequested !== true`
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` | `WRAPPING_UP` / `TERMINATED` / `CONNECTED` / same | Ownership + downgrade guards |
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
- (selfParticipant?.consultState === 'consultCompleted' &&
175
- !hasConsultMedia &&
176
- taskData?.isConsulted === false);
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 consultOwnedBySelf =
218
- effectiveConsultInitiator ||
219
- (Boolean(selfAgentId) && taskData?.consultingAgentId === selfAgentId);
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
- (consultInProgress || effectiveConsultCallHeld || hasConsultMedia);
285
+ (effectiveConsultInProgress || effectiveConsultCallHeld || hasConsultMedia);
250
286
  const activeLegForConferenceConsult = effectiveConsultCallHeld ? 'main' : 'consult';
251
287
  const isCurrentLegActive = currentLeg === activeLegForConferenceConsult;
252
288
  const isConferenceConsultTransferContext =
253
- inConference && effectiveConsultInitiator && hasConsultMedia && isConsultDestinationReady;
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
- consultInProgress ||
323
+ effectiveConsultInProgress ||
288
324
  isConsulting);
289
325
  const hideExitConferenceDuringActiveConsultFromConference =
290
326
  inConference &&
291
- consultInitiator &&
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
- consultInitiator &&
336
+ selfActiveConsultInitiator &&
301
337
  (isConsulting ||
302
- consultInProgress ||
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 && consultInProgress && !selfOnConsultLeg;
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: VISIBLE_DISABLED,
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 (consultInProgress) return VISIBLE_DISABLED;
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
- return participantCount > 1 ? DISABLED : VISIBLE_ENABLED;
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
- !consultInProgress &&
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 && !consultInProgress && !isConsulted;
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, hidden in real conference, visible in pending conference
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
- // Real conference (multiple agents): recording hidden
560
- // Pending conference (only self agent): recording available
561
- return participantCount > 1 ? DISABLED : VISIBLE_ENABLED;
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 (consultInProgress) return VISIBLE_DISABLED;
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 && consultInitiator && hasConsultMedia;
672
+ currentLeg === 'consult' && inConference && selfActiveConsultInitiator && hasConsultMedia;
617
673
  const selfConsultingOnParticipantState = selfParticipant?.consultState === 'consulting';
618
674
  const conferenceTransferAvailable =
619
675
  consultLegTransferAvailable ||
620
676
  (inConference &&
621
- consultInitiator &&
677
+ selfActiveConsultInitiator &&
622
678
  hasConsultMedia &&
623
- (isConsulting || consultInProgress || selfConsultingOnParticipantState));
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 (!consultInitiator || isConsulted) return DISABLED;
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
- (selfParticipant?.consultState === 'consultCompleted' &&
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.consultInitiator ||
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
- !taskData?.isConsulted &&
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
- mainState = TaskState.CONNECTED;
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
  });