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

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 (35) hide show
  1. package/dist/services/task/TaskManager.js +1 -0
  2. package/dist/services/task/TaskManager.js.map +1 -1
  3. package/dist/services/task/TaskUtils.js +8 -6
  4. package/dist/services/task/TaskUtils.js.map +1 -1
  5. package/dist/services/task/state-machine/TaskStateMachine.js +77 -14
  6. package/dist/services/task/state-machine/TaskStateMachine.js.map +1 -1
  7. package/dist/services/task/state-machine/actions.js +85 -13
  8. package/dist/services/task/state-machine/actions.js.map +1 -1
  9. package/dist/services/task/state-machine/guards.js +35 -0
  10. package/dist/services/task/state-machine/guards.js.map +1 -1
  11. package/dist/services/task/state-machine/uiControlsComputer.js +76 -10
  12. package/dist/services/task/state-machine/uiControlsComputer.js.map +1 -1
  13. package/dist/services/task/voice/Voice.js +10 -4
  14. package/dist/services/task/voice/Voice.js.map +1 -1
  15. package/dist/types/services/task/state-machine/TaskStateMachine.d.ts +68 -8
  16. package/dist/types/services/task/state-machine/guards.d.ts +5 -0
  17. package/dist/types/services/task/voice/Voice.d.ts +18 -17
  18. package/dist/webex.js +1 -1
  19. package/package.json +1 -1
  20. package/src/services/task/TaskManager.ts +1 -1
  21. package/src/services/task/TaskUtils.ts +8 -6
  22. package/src/services/task/state-machine/TaskStateMachine.ts +101 -16
  23. package/src/services/task/state-machine/actions.ts +148 -24
  24. package/src/services/task/state-machine/guards.ts +46 -0
  25. package/src/services/task/state-machine/uiControlsComputer.ts +158 -15
  26. package/src/services/task/voice/Voice.ts +12 -5
  27. package/test/unit/spec/services/WebCallingService.ts +7 -1
  28. package/test/unit/spec/services/task/TaskManager.ts +26 -0
  29. package/test/unit/spec/services/task/TaskUtils.ts +16 -0
  30. package/test/unit/spec/services/task/state-machine/TaskStateMachine.ts +573 -0
  31. package/test/unit/spec/services/task/state-machine/guards.ts +88 -0
  32. package/test/unit/spec/services/task/state-machine/uiControlsComputer.ts +1023 -46
  33. package/test/unit/spec/services/task/voice/Voice.ts +44 -0
  34. package/umd/contact-center.min.js +2 -2
  35. package/umd/contact-center.min.js.map +1 -1
package/package.json CHANGED
@@ -83,5 +83,5 @@
83
83
  "typedoc": "^0.25.0",
84
84
  "typescript": "5.4.5"
85
85
  },
86
- "version": "3.12.0-task-refactor.4"
86
+ "version": "3.12.0-task-refactor.6"
87
87
  }
@@ -291,7 +291,7 @@ export default class TaskManager extends EventEmitter {
291
291
  return {type: TaskEvent.RONA, taskData: payload, reason: payload.reason};
292
292
 
293
293
  case CC_EVENTS.AGENT_OUTBOUND_FAILED:
294
- return {type: TaskEvent.OUTBOUND_FAILED, reason: payload.reason};
294
+ return {type: TaskEvent.OUTBOUND_FAILED, taskData: payload, reason: payload.reason};
295
295
 
296
296
  case CC_EVENTS.CONTACT_RECORDING_STARTED:
297
297
  return {type: TaskEvent.RECORDING_STARTED, taskData: payload};
@@ -13,13 +13,14 @@ import {TaskContext} from './state-machine/types';
13
13
  * @returns true if customer is in the call
14
14
  */
15
15
  export const getIsCustomerInCall = (interaction: Interaction, interactionId: string): boolean => {
16
- const mainCallMedia = interaction.media[interactionId];
17
- if (!mainCallMedia?.participants) {
16
+ const mainCallMedia = interaction.media?.[interactionId];
17
+ const participants = interaction.participants;
18
+ if (!mainCallMedia?.participants || !participants) {
18
19
  return false;
19
20
  }
20
21
 
21
22
  return mainCallMedia.participants.some((participantId: string) => {
22
- const participant = interaction.participants[participantId];
23
+ const participant = participants[participantId];
23
24
 
24
25
  return participant?.pType === PARTICIPANT_TYPE.CUSTOMER && !participant.hasLeft;
25
26
  });
@@ -37,14 +38,15 @@ export const getConferenceParticipantsCount = (
37
38
  interaction: Interaction,
38
39
  interactionId: string
39
40
  ): number => {
40
- const mainCallMedia = interaction.media[interactionId];
41
- if (!mainCallMedia?.participants) {
41
+ const mainCallMedia = interaction.media?.[interactionId];
42
+ const participants = interaction.participants;
43
+ if (!mainCallMedia?.participants || !participants) {
42
44
  return 0;
43
45
  }
44
46
 
45
47
  let count = 0;
46
48
  for (const participantId of mainCallMedia.participants) {
47
- const participant = interaction.participants[participantId];
49
+ const participant = participants[participantId];
48
50
  if (
49
51
  participant &&
50
52
  participant.pType !== PARTICIPANT_TYPE.CUSTOMER &&
@@ -123,16 +123,22 @@ export function getTaskStateMachineConfig(uiControlConfig: UIControlConfig) {
123
123
  actions: ['initializeTask', 'emitTaskIncoming'],
124
124
  },
125
125
 
126
+ // AgentOutboundFailed can arrive before TASK_INCOMING due to race conditions
127
+ [TaskEvent.OUTBOUND_FAILED]: {
128
+ target: TaskState.TERMINATED,
129
+ actions: ['updateTaskData', 'markEnded', 'emitTaskOutdialFailed', 'emitTaskEnd'],
130
+ },
131
+
126
132
  // EP-DN split-leg ordering can deliver AgentConsulting before HYDRATE/TASK_INCOMING.
127
133
  // Do not drop it in IDLE; bootstrap to CONSULTING using event taskData.
128
134
  [TaskEvent.CONSULTING_ACTIVE]: {
129
135
  target: TaskState.CONSULTING,
130
136
  actions: [
131
- 'updateTaskData',
132
137
  'setConsultInitiator',
133
138
  'setConsultDestination',
134
139
  'setConsultFromConference',
135
140
  'setConsultAgentJoined',
141
+ 'updateTaskData',
136
142
  'emitTaskConsultAccepted',
137
143
  'emitTaskConsulting',
138
144
  ],
@@ -190,7 +196,7 @@ export function getTaskStateMachineConfig(uiControlConfig: UIControlConfig) {
190
196
  },
191
197
  {
192
198
  target: TaskState.TERMINATED,
193
- actions: ['updateTaskData', 'markEnded', 'emitTaskOutdialFailed', 'emitTaskReject'],
199
+ actions: ['updateTaskData', 'markEnded', 'emitTaskOutdialFailed', 'emitTaskEnd'],
194
200
  },
195
201
  ],
196
202
  // AgentConsulting comes for received after the initial consult is accepted
@@ -198,8 +204,9 @@ export function getTaskStateMachineConfig(uiControlConfig: UIControlConfig) {
198
204
  {
199
205
  target: TaskState.CONSULTING,
200
206
  actions: [
201
- 'updateTaskData',
202
207
  'setConsultAgentJoined',
208
+ 'setConsultDestination',
209
+ 'updateTaskData',
203
210
  'emitTaskConsultAccepted',
204
211
  'emitTaskConsulting',
205
212
  ],
@@ -226,15 +233,25 @@ export function getTaskStateMachineConfig(uiControlConfig: UIControlConfig) {
226
233
 
227
234
  [TaskState.CONNECTED]: {
228
235
  on: {
236
+ // AgentConsultConferenced / ParticipantJoinedConference can arrive while connected.
237
+ [TaskEvent.CONFERENCE_START]: {
238
+ target: TaskState.CONFERENCING,
239
+ actions: [
240
+ 'updateTaskData',
241
+ 'syncTaskDataFromEvent',
242
+ 'clearConsultState',
243
+ 'emitTaskConferenceStarted',
244
+ ],
245
+ },
229
246
  // AgentConsulting may arrive while machine is CONNECTED (EP-DN/event ordering).
230
247
  // Derive consultInitiator from payload so controls are set correctly.
231
248
  [TaskEvent.CONSULTING_ACTIVE]: {
232
249
  target: TaskState.CONSULTING,
233
250
  actions: [
234
- 'updateTaskData',
235
251
  'setConsultInitiator',
236
252
  'setConsultDestination',
237
253
  'setConsultAgentJoined',
254
+ 'updateTaskData',
238
255
  'emitTaskConsultAccepted',
239
256
  'emitTaskConsulting',
240
257
  ],
@@ -323,6 +340,22 @@ export function getTaskStateMachineConfig(uiControlConfig: UIControlConfig) {
323
340
 
324
341
  [TaskState.HELD]: {
325
342
  on: {
343
+ // Conference can be merged while this agent is in held state after refresh/recovery.
344
+ [TaskEvent.CONFERENCE_START]: {
345
+ target: TaskState.CONFERENCING,
346
+ actions: [
347
+ 'updateTaskData',
348
+ 'syncTaskDataFromEvent',
349
+ 'clearConsultState',
350
+ 'emitTaskConferenceStarted',
351
+ ],
352
+ },
353
+ [TaskEvent.PAUSE_RECORDING]: {
354
+ actions: ['updateTaskData', 'setRecordingState', 'emitTaskRecordingPaused'],
355
+ },
356
+ [TaskEvent.RESUME_RECORDING]: {
357
+ actions: ['updateTaskData', 'setRecordingState', 'emitTaskRecordingResumed'],
358
+ },
326
359
  // Click of the unhold button
327
360
  [TaskEvent.UNHOLD_INITIATED]: {
328
361
  target: TaskState.RESUME_INITIATING,
@@ -442,9 +475,9 @@ export function getTaskStateMachineConfig(uiControlConfig: UIControlConfig) {
442
475
  // AgentConsulting updates consulted agent arrival
443
476
  [TaskEvent.CONSULTING_ACTIVE]: {
444
477
  actions: [
445
- 'updateTaskData',
446
478
  'setConsultAgentJoined',
447
479
  'setConsultDestination',
480
+ 'updateTaskData',
448
481
  'emitTaskConsulting',
449
482
  ],
450
483
  },
@@ -452,14 +485,33 @@ export function getTaskStateMachineConfig(uiControlConfig: UIControlConfig) {
452
485
  // AgentConsultEnded
453
486
  [TaskEvent.CONSULT_END]: [
454
487
  {
455
- // Initiator returning to conference (flag set OR backend still shows conference)
488
+ // Initiator returning to conference only while conference is still active.
456
489
  guard: ({context, event}) =>
457
490
  context.consultInitiator === true &&
458
- (context.consultFromConference === true ||
459
- guards.conferenceInProgressFromEvent({context, event})),
491
+ guards.conferenceInProgressFromEvent({context, event}),
460
492
  target: TaskState.CONFERENCING,
461
493
  actions: ['updateTaskData', 'clearConsultState', 'emitTaskConsultEnd'],
462
494
  },
495
+ {
496
+ // Conference consult ended after conference downgrade while main leg is held.
497
+ guard: ({context, event}) =>
498
+ context.consultInitiator === true &&
499
+ context.consultFromConference === true &&
500
+ !guards.conferenceInProgressFromEvent({context, event}) &&
501
+ guards.isConferenceHoldParticipantFromEvent({context, event}),
502
+ target: TaskState.HELD,
503
+ actions: ['updateTaskData', 'clearConsultState', 'emitTaskConsultEnd'],
504
+ },
505
+ {
506
+ // Conference consult ended after conference downgrade while main leg is connected.
507
+ guard: ({context, event}) =>
508
+ context.consultInitiator === true &&
509
+ context.consultFromConference === true &&
510
+ !guards.conferenceInProgressFromEvent({context, event}) &&
511
+ !guards.isConferenceHoldParticipantFromEvent({context, event}),
512
+ target: TaskState.CONNECTED,
513
+ actions: ['updateTaskData', 'clearConsultState', 'emitTaskConsultEnd'],
514
+ },
463
515
  {
464
516
  // Initiator already switched back to the main/customer leg
465
517
  guard: ({context}) =>
@@ -468,13 +520,18 @@ export function getTaskStateMachineConfig(uiControlConfig: UIControlConfig) {
468
520
  actions: ['updateTaskData', 'clearConsultState', 'emitTaskConsultEnd'],
469
521
  },
470
522
  {
471
- // Customer left during consult → WRAPPING_UP
523
+ // Interaction terminated during consult (customer left) → WRAPPING_UP
472
524
  guard: ({context, event}) => {
525
+ // if (context.consultInitiator !== true) return false;
526
+ // const taskData = getTaskDataFromEvent(event);
473
527
  const taskData = getTaskDataFromEvent(event);
474
528
  const cpd = taskData?.interaction?.callProcessingDetails;
475
529
  if (cpd?.hasCustomerLeft !== 'true') return false;
476
530
 
477
- return shouldWrapUpForThisAgent(context, taskData);
531
+ return (
532
+ taskData?.interaction?.isTerminated === true &&
533
+ shouldWrapUpForThisAgent(context, taskData)
534
+ );
478
535
  },
479
536
  target: TaskState.WRAPPING_UP,
480
537
  actions: [
@@ -692,12 +749,40 @@ export function getTaskStateMachineConfig(uiControlConfig: UIControlConfig) {
692
749
  },
693
750
  ],
694
751
 
695
- [TaskEvent.HOLD_SUCCESS]: {
696
- actions: ['updateTaskData', 'setHoldState', 'emitTaskHold'],
697
- },
698
- [TaskEvent.UNHOLD_SUCCESS]: {
699
- actions: ['updateTaskData', 'syncTaskDataFromEvent', 'setHoldState', 'emitTaskResume'],
700
- },
752
+ [TaskEvent.HOLD_SUCCESS]: [
753
+ {
754
+ // Conference already downgraded (no other agents) and backend hold arrives.
755
+ // Move to HELD so the UI renders resume action.
756
+ guard: guards.shouldDowngradeConferenceToConnected,
757
+ target: TaskState.HELD,
758
+ actions: ['updateTaskData', 'setHoldState', 'emitTaskHold'],
759
+ },
760
+ {
761
+ actions: ['updateTaskData', 'setHoldState', 'emitTaskHold'],
762
+ },
763
+ ],
764
+ [TaskEvent.UNHOLD_SUCCESS]: [
765
+ {
766
+ // Conference already downgraded (no other agents) and backend unhold arrives.
767
+ // Move to CONNECTED so hold action is available again.
768
+ guard: guards.shouldDowngradeConferenceToConnected,
769
+ target: TaskState.CONNECTED,
770
+ actions: [
771
+ 'updateTaskData',
772
+ 'syncTaskDataFromEvent',
773
+ 'setHoldState',
774
+ 'emitTaskResume',
775
+ ],
776
+ },
777
+ {
778
+ actions: [
779
+ 'updateTaskData',
780
+ 'syncTaskDataFromEvent',
781
+ 'setHoldState',
782
+ 'emitTaskResume',
783
+ ],
784
+ },
785
+ ],
701
786
 
702
787
  // Start a new consult from within an active conference
703
788
  [TaskEvent.CONSULT]: {
@@ -11,9 +11,16 @@ import {
11
11
  TaskActionArgs,
12
12
  RecordingStateUpdate,
13
13
  } from './types';
14
- import {TaskEvent, TaskState, INTERACTION_STATE} from './constants';
14
+ import {
15
+ TaskEvent,
16
+ TaskState,
17
+ INTERACTION_STATE,
18
+ CONSULT_STATE,
19
+ MEDIA_TYPE_CONSULT,
20
+ } from './constants';
15
21
  import {DestinationType, TaskData} from '../types';
16
22
  import {computeUIControls, getDefaultUIControls} from './uiControlsComputer';
23
+ import {getIsConferenceInProgress} from '../TaskUtils';
17
24
  import {hasActiveConsultInPostCall} from './guards';
18
25
 
19
26
  const determineConsultInitiator = (
@@ -74,6 +81,113 @@ const deriveRecordingState = (taskData?: TaskData | null): RecordingStateUpdate
74
81
  return update;
75
82
  };
76
83
 
84
+ const isActiveConsultState = (taskData: TaskData | undefined, selfAgentId?: string): boolean => {
85
+ if (taskData?.interaction?.state === INTERACTION_STATE.CONSULTING) return true;
86
+ if (selfAgentId) {
87
+ const selfParticipant = taskData?.interaction?.participants?.[selfAgentId] as any;
88
+ const hasConsultMedia = Object.values(taskData?.interaction?.media ?? {}).some(
89
+ (media: any) => media?.mType === MEDIA_TYPE_CONSULT
90
+ );
91
+ const isPendingOrActiveSelfConsult =
92
+ selfParticipant?.consultState === CONSULT_STATE.CONSULTING ||
93
+ selfParticipant?.consultState === 'consultInitiated';
94
+ if (isPendingOrActiveSelfConsult && hasConsultMedia && taskData?.isConsulted === false) {
95
+ return true;
96
+ }
97
+ }
98
+ if (taskData?.interaction?.state === INTERACTION_STATE.POST_CALL && selfAgentId) {
99
+ const selfParticipant = taskData.interaction?.participants?.[selfAgentId] as any;
100
+ const hasConsultMedia = Object.values(taskData.interaction?.media ?? {}).some(
101
+ (media: any) => media?.mType === MEDIA_TYPE_CONSULT
102
+ );
103
+ if (selfParticipant?.consultState === CONSULT_STATE.CONSULTING && hasConsultMedia) return true;
104
+ }
105
+
106
+ return false;
107
+ };
108
+
109
+ const isSelfConsultingOrPending = (
110
+ taskData: TaskData | undefined,
111
+ selfAgentId?: string
112
+ ): boolean => {
113
+ if (!taskData || !selfAgentId) return false;
114
+ const selfParticipant = taskData?.interaction?.participants?.[selfAgentId] as any;
115
+
116
+ return (
117
+ selfParticipant?.consultState === CONSULT_STATE.CONSULTING ||
118
+ selfParticipant?.consultState === 'consultInitiated'
119
+ );
120
+ };
121
+
122
+ const hasJoinedConsultDestination = (taskData: TaskData | undefined): boolean => {
123
+ if (!taskData?.interaction) return false;
124
+ const participants = taskData.interaction.participants as any;
125
+ const cpd = taskData.interaction.callProcessingDetails as any;
126
+ const backendSaysJoined = cpd?.consultDestinationAgentJoined === 'true';
127
+ if (backendSaysJoined) return true;
128
+ if (!participants) return false;
129
+
130
+ return Object.values(participants).some((p: any) => {
131
+ if (!p || p.isConsulted !== true || p.hasLeft) return false;
132
+
133
+ return p.hasJoined === true || p.consultState === CONSULT_STATE.CONSULTING;
134
+ });
135
+ };
136
+
137
+ const deriveConsultCallHeldFromTaskData = (taskData: TaskData | undefined): boolean | undefined => {
138
+ if (!taskData?.interaction) return undefined;
139
+
140
+ const eventType = taskData.type;
141
+ if (eventType === 'AgentContactHeld') return true;
142
+ if (eventType === 'AgentContactUnheld') return false;
143
+
144
+ const consultMediaId = taskData.consultMediaResourceId;
145
+
146
+ const consultMedia: any = consultMediaId
147
+ ? taskData.interaction.media?.[consultMediaId]
148
+ : Object.values(taskData.interaction.media ?? {}).find(
149
+ (m: any) => m?.mType === MEDIA_TYPE_CONSULT
150
+ );
151
+
152
+ if (!consultMedia) return undefined;
153
+
154
+ return Boolean(consultMedia.isHold);
155
+ };
156
+
157
+ const getTaskStateForUiControls = (
158
+ taskData: TaskData | undefined,
159
+ selfAgentId: string | undefined
160
+ ): TaskState => {
161
+ if (!taskData?.interaction) {
162
+ return TaskState.IDLE;
163
+ }
164
+
165
+ if (taskData.interaction.isTerminated === true) {
166
+ return TaskState.WRAPPING_UP;
167
+ }
168
+
169
+ if (isActiveConsultState(taskData, selfAgentId)) {
170
+ return TaskState.CONSULTING;
171
+ }
172
+
173
+ if (
174
+ taskData.interaction.state === INTERACTION_STATE.CONFERENCE ||
175
+ getIsConferenceInProgress(taskData)
176
+ ) {
177
+ return TaskState.CONFERENCING;
178
+ }
179
+
180
+ const mainMediaId = taskData.interaction.mainInteractionId || taskData.interactionId;
181
+ const isMainHeld = Boolean(
182
+ mainMediaId && taskData.interaction.media?.[mainMediaId]?.isHold === true
183
+ );
184
+ if (taskData.interaction.state === 'hold' || isMainHeld) {
185
+ return TaskState.HELD;
186
+ }
187
+
188
+ return TaskState.CONNECTED;
189
+ };
190
+
77
191
  const deriveTaskDataUpdates = (context: TaskContext, taskData: TaskData | undefined) =>
78
192
  taskData
79
193
  ? (() => {
@@ -84,8 +198,14 @@ const deriveTaskDataUpdates = (context: TaskContext, taskData: TaskData | undefi
84
198
 
85
199
  const selfAgentId = context.uiControlConfig.agentId ?? taskData?.agentId;
86
200
  const consultingActive =
87
- taskData?.interaction?.state === INTERACTION_STATE.CONSULTING ||
201
+ isActiveConsultState(taskData, selfAgentId) ||
88
202
  hasActiveConsultInPostCall(taskData, selfAgentId);
203
+ const conferenceFromPayload =
204
+ taskData?.interaction?.state === INTERACTION_STATE.CONFERENCE ||
205
+ getIsConferenceInProgress(taskData);
206
+ const selfConsultingOrPending = isSelfConsultingOrPending(taskData, selfAgentId);
207
+ const inferredConsultingInitiator =
208
+ selfConsultingOrPending && taskData?.isConsulted === false;
89
209
 
90
210
  if (taskData.destAgentId) {
91
211
  const isEpDnWithStoredId =
@@ -102,24 +222,26 @@ const deriveTaskDataUpdates = (context: TaskContext, taskData: TaskData | undefi
102
222
  const consultInitiator = determineConsultInitiator(taskData, selfAgentId);
103
223
  if (consultInitiator !== undefined) {
104
224
  updates.consultInitiator = consultInitiator;
105
- } else if (consultingActive && taskData.isConsulted === false) {
225
+ } else if (
226
+ inferredConsultingInitiator ||
227
+ (consultingActive && taskData.isConsulted === false)
228
+ ) {
106
229
  updates.consultInitiator = true;
107
230
  }
108
231
  }
109
232
 
233
+ const effectiveConsultInitiator = updates.consultInitiator ?? context.consultInitiator;
234
+ if (
235
+ effectiveConsultInitiator &&
236
+ conferenceFromPayload &&
237
+ (consultingActive || selfConsultingOrPending || Boolean(taskData?.consultMediaResourceId))
238
+ ) {
239
+ updates.consultFromConference = true;
240
+ }
241
+
110
242
  if (consultingActive && taskData.interaction) {
111
- if (!context.consultDestinationAgentJoined) {
112
- const hasJoinedConsultee = Boolean(
113
- taskData.interaction.participants &&
114
- Object.values(taskData.interaction.participants).some(
115
- (p: any) => p?.isConsulted === true && !p?.hasLeft
116
- )
117
- );
118
- const cpd = taskData.interaction?.callProcessingDetails;
119
- const backendSaysJoined = cpd?.consultDestinationAgentJoined === 'true';
120
- if (hasJoinedConsultee || backendSaysJoined)
121
- updates.consultDestinationAgentJoined = true;
122
- }
243
+ const joinedConsultee = hasJoinedConsultDestination(taskData);
244
+ if (joinedConsultee) updates.consultDestinationAgentJoined = true;
123
245
 
124
246
  if (!context.consultDestinationType && !updates.consultDestinationType) {
125
247
  const hasEpDnParticipant = Boolean(
@@ -131,20 +253,22 @@ const deriveTaskDataUpdates = (context: TaskContext, taskData: TaskData | undefi
131
253
  if (hasEpDnParticipant) updates.consultDestinationType = 'entryPoint' as any;
132
254
  }
133
255
 
134
- const effectiveConsultInitiator = updates.consultInitiator ?? context.consultInitiator;
135
256
  if (effectiveConsultInitiator) {
136
- const consultMediaId = taskData.consultMediaResourceId;
137
- const consultMedia: any = consultMediaId
138
- ? taskData.interaction.media?.[consultMediaId]
139
- : Object.values(taskData.interaction.media ?? {}).find(
140
- (m: any) => m?.mType === 'consult'
141
- );
142
- if (consultMedia) {
143
- updates.consultCallHeld = Boolean(consultMedia.isHold);
257
+ const consultCallHeld = deriveConsultCallHeldFromTaskData(taskData);
258
+ if (consultCallHeld !== undefined) {
259
+ updates.consultCallHeld = consultCallHeld;
144
260
  }
145
261
  }
146
262
  }
147
263
 
264
+ const nextContext = {
265
+ ...context,
266
+ ...updates,
267
+ } as TaskContext;
268
+ const inferredState = getTaskStateForUiControls(taskData, selfAgentId);
269
+
270
+ updates.uiControls = computeUIControls(inferredState, nextContext, taskData);
271
+
148
272
  return updates;
149
273
  })()
150
274
  : {};
@@ -120,6 +120,20 @@ export const guards = {
120
120
 
121
121
  if (taskData?.interaction?.state === INTERACTION_STATE.CONSULTING) return true;
122
122
 
123
+ // Hydrate can report interaction as conference/hold while this agent is actively consulting.
124
+ // Detect consult by participant consultState + consult media, independent of top-level state.
125
+ const selfAgentId = getSelfAgentId(context, taskData);
126
+ const selfParticipant = selfAgentId ? taskData?.interaction?.participants?.[selfAgentId] : null;
127
+ const hasConsultMedia = Object.values(taskData?.interaction?.media ?? {}).some(
128
+ (media: any) => media?.mType === MEDIA_TYPE_CONSULT
129
+ );
130
+ const selfActiveConsult = selfParticipant?.consultState === CONSULT_STATE.CONSULTING;
131
+ const selfPendingConsultInitiated =
132
+ selfParticipant?.consultState === 'consultInitiated' && taskData?.isConsulted === false;
133
+ if ((selfActiveConsult || selfPendingConsultInitiated) && hasConsultMedia) {
134
+ return true;
135
+ }
136
+
123
137
  // EP_DN consulted agent: backend reports state as 'connected' but CPD indicates consult
124
138
  const cpd = taskData?.interaction?.callProcessingDetails;
125
139
  if (
@@ -135,6 +149,24 @@ export const guards = {
135
149
  return true;
136
150
  }
137
151
 
152
+ // Customer left during consult: interaction state is "post_call" but consult
153
+ // between agents is still active. Detect via agent's consultState + consult media.
154
+ if (taskData?.interaction?.state === INTERACTION_STATE.POST_CALL) {
155
+ const postCallSelfAgentId = getSelfAgentId(context, taskData);
156
+ const postCallSelfParticipant = postCallSelfAgentId
157
+ ? taskData?.interaction?.participants?.[postCallSelfAgentId]
158
+ : null;
159
+ const hasPostCallConsultMedia = Object.values(taskData?.interaction?.media ?? {}).some(
160
+ (media: any) => media?.mType === MEDIA_TYPE_CONSULT
161
+ );
162
+ if (
163
+ postCallSelfParticipant?.consultState === CONSULT_STATE.CONSULTING &&
164
+ hasPostCallConsultMedia
165
+ ) {
166
+ return true;
167
+ }
168
+ }
169
+
138
170
  return false;
139
171
  },
140
172
 
@@ -183,6 +215,20 @@ export const guards = {
183
215
  return getIsConferenceInProgress(taskData);
184
216
  },
185
217
 
218
+ /**
219
+ * Conference hold signal for conference-downgraded consult-end transitions.
220
+ * Backend can report this as boolean or string.
221
+ */
222
+ isConferenceHoldParticipantFromEvent: ({context, event}: GuardParams): boolean => {
223
+ const taskData = getTaskDataFromEvent(event) ?? context.taskData;
224
+ const callProcessingDetails = taskData?.interaction?.callProcessingDetails as
225
+ | {conferenceHoldParticipant?: boolean | string}
226
+ | undefined;
227
+ const conferenceHoldParticipant = callProcessingDetails?.conferenceHoldParticipant;
228
+
229
+ return conferenceHoldParticipant === true || conferenceHoldParticipant === 'true';
230
+ },
231
+
186
232
  /**
187
233
  * Conference downgrade check specifically for transitioning back to CONNECTED.
188
234
  *