From 808099934923f5236d63675824210eed4b6b26a2 Mon Sep 17 00:00:00 2001 From: Akula Uday Date: Thu, 7 May 2026 10:43:02 +0530 Subject: [PATCH 1/6] fix(task-refactor): consult flow bug bash fixes --- .../src/services/task/TaskManager.ts | 6 +++++ .../task/state-machine/TaskStateMachine.ts | 22 +++++++++++++++- .../services/task/state-machine/actions.ts | 26 ++++++++++++++----- .../src/services/task/state-machine/guards.ts | 17 +++++++++++- .../task/state-machine/uiControlsComputer.ts | 8 ++++++ 5 files changed, 70 insertions(+), 9 deletions(-) diff --git a/packages/@webex/contact-center/src/services/task/TaskManager.ts b/packages/@webex/contact-center/src/services/task/TaskManager.ts index f9c3ff3002a..a9f349e2b04 100644 --- a/packages/@webex/contact-center/src/services/task/TaskManager.ts +++ b/packages/@webex/contact-center/src/services/task/TaskManager.ts @@ -375,6 +375,12 @@ export default class TaskManager extends EventEmitter { task.sendStateMachineEvent(stateMachineEvent); } + // Emit TASK_POST_CALL_ACTIVITY for ParticipantPostCallActivity events so + // consumers (Widgets) can detect the interaction state change to post_call. + if (eventContext.eventType === CC_EVENTS.PARTICIPANT_POST_CALL_ACTIVITY) { + task.emit(TASK_EVENTS.TASK_POST_CALL_ACTIVITY, task); + } + // Send transcript start/stop events for relevant CC events this.requestRealTimeTranscripts(eventContext.eventType, payload.interactionId); }); diff --git a/packages/@webex/contact-center/src/services/task/state-machine/TaskStateMachine.ts b/packages/@webex/contact-center/src/services/task/state-machine/TaskStateMachine.ts index 94e6a2fa47a..90f93af86e8 100644 --- a/packages/@webex/contact-center/src/services/task/state-machine/TaskStateMachine.ts +++ b/packages/@webex/contact-center/src/services/task/state-machine/TaskStateMachine.ts @@ -12,7 +12,7 @@ import {setup} from 'xstate'; import {TaskContext, TaskEventPayload, UIControlConfig, TaskActionsMap} from './types'; import {TaskState, TaskEvent} from './constants'; import {actions, createInitialContext} from './actions'; -import {guards} from './guards'; +import {guards, shouldWrapUpForThisAgent, getTaskDataFromEvent} from './guards'; import {getIsCustomerInCall} from '../TaskUtils'; type TaskActionConfigMap = {[K in keyof typeof actions]: undefined}; @@ -467,6 +467,26 @@ export function getTaskStateMachineConfig(uiControlConfig: UIControlConfig) { target: TaskState.CONNECTED, actions: ['updateTaskData', 'clearConsultState', 'emitTaskConsultEnd'], }, + { + // Interaction terminated during consult (customer left) → WRAPPING_UP + guard: ({context, event}) => { + if (context.consultInitiator !== true) return false; + const taskData = getTaskDataFromEvent(event); + + return ( + taskData?.interaction?.isTerminated === true && + shouldWrapUpForThisAgent(context, taskData) + ); + }, + target: TaskState.WRAPPING_UP, + actions: [ + 'updateTaskData', + 'markEnded', + 'clearConsultState', + 'emitTaskWrapup', + 'requestCleanup', + ], + }, { // Initiator (no conference) → HELD guard: ({context}) => context.consultInitiator === true, diff --git a/packages/@webex/contact-center/src/services/task/state-machine/actions.ts b/packages/@webex/contact-center/src/services/task/state-machine/actions.ts index 558042f5a13..46c90eccc68 100644 --- a/packages/@webex/contact-center/src/services/task/state-machine/actions.ts +++ b/packages/@webex/contact-center/src/services/task/state-machine/actions.ts @@ -73,6 +73,19 @@ const deriveRecordingState = (taskData?: TaskData | null): RecordingStateUpdate return update; }; +const isActiveConsultState = (taskData: TaskData | undefined, selfAgentId?: string): boolean => { + if (taskData?.interaction?.state === 'consulting') return true; + if (taskData?.interaction?.state === 'post_call' && selfAgentId) { + const selfParticipant = taskData.interaction?.participants?.[selfAgentId] as any; + const hasConsultMedia = Object.values(taskData.interaction?.media ?? {}).some( + (media: any) => media?.mType === 'consult' + ); + if (selfParticipant?.consultState === 'consulting' && hasConsultMedia) return true; + } + + return false; +}; + const deriveTaskDataUpdates = (context: TaskContext, taskData: TaskData | undefined) => taskData ? (() => { @@ -81,27 +94,26 @@ const deriveTaskDataUpdates = (context: TaskContext, taskData: TaskData | undefi ...deriveRecordingState(taskData), }; + const selfAgentId = context.uiControlConfig.agentId ?? taskData?.agentId; + const consultingActive = isActiveConsultState(taskData, selfAgentId); + if (taskData.destAgentId) { updates.consultDestinationAgentId = taskData.destAgentId; } - if (taskData.interaction?.state === 'consulting' && taskData.destinationType) { + if (consultingActive && taskData.destinationType) { updates.consultDestinationType = taskData.destinationType as DestinationType; } if (!context.consultInitiator) { - const selfAgentId = context.uiControlConfig.agentId ?? taskData?.agentId; const consultInitiator = determineConsultInitiator(taskData, selfAgentId); if (consultInitiator !== undefined) { updates.consultInitiator = consultInitiator; - } else if ( - taskData.interaction?.state === 'consulting' && - taskData.isConsulted === false - ) { + } else if (consultingActive && taskData.isConsulted === false) { updates.consultInitiator = true; } } - if (taskData.interaction?.state === 'consulting') { + if (consultingActive && taskData.interaction) { if (!context.consultDestinationAgentJoined) { const hasJoinedConsultee = Boolean( taskData.interaction.participants && diff --git a/packages/@webex/contact-center/src/services/task/state-machine/guards.ts b/packages/@webex/contact-center/src/services/task/state-machine/guards.ts index be3bff1085a..6462911ed88 100644 --- a/packages/@webex/contact-center/src/services/task/state-machine/guards.ts +++ b/packages/@webex/contact-center/src/services/task/state-machine/guards.ts @@ -97,7 +97,7 @@ export const guards = { return false; }, - isInteractionConsulting: ({event}: GuardParams): boolean => { + isInteractionConsulting: ({event, context}: GuardParams): boolean => { const taskData = getTaskDataFromEvent(event); if (taskData?.interaction?.state === 'consulting') return true; @@ -108,6 +108,21 @@ export const guards = { return true; } + // Customer left during consult: interaction state is "post_call" but consult + // between agents is still active. Detect via agent's consultState + consult media. + if (taskData?.interaction?.state === 'post_call') { + const selfAgentId = getSelfAgentId(context, taskData); + const selfParticipant = selfAgentId + ? taskData?.interaction?.participants?.[selfAgentId] + : null; + const hasConsultMedia = Object.values(taskData?.interaction?.media ?? {}).some( + (media: any) => media?.mType === 'consult' + ); + if (selfParticipant?.consultState === 'consulting' && hasConsultMedia) { + return true; + } + } + return false; }, diff --git a/packages/@webex/contact-center/src/services/task/state-machine/uiControlsComputer.ts b/packages/@webex/contact-center/src/services/task/state-machine/uiControlsComputer.ts index d56a258b292..b33dc12254e 100644 --- a/packages/@webex/contact-center/src/services/task/state-machine/uiControlsComputer.ts +++ b/packages/@webex/contact-center/src/services/task/state-machine/uiControlsComputer.ts @@ -254,11 +254,13 @@ function computeVoiceInteractionUIControls( // Transfer: connected/held, not in conference transfer: (() => { if (hasParallelConsultLeg) { + if (!customerPresent) return DISABLED; if (state === TaskState.CONNECTED) return VISIBLE_ENABLED; if (state === TaskState.HELD) return VISIBLE_DISABLED; } if (isConsulting) { if (!consultInitiator) return DISABLED; + if (!customerPresent) return VISIBLE_DISABLED; if (consultLegOnHold) return VISIBLE_DISABLED; return isConsultDestinationReady ? VISIBLE_ENABLED : VISIBLE_DISABLED; @@ -307,6 +309,7 @@ function computeVoiceInteractionUIControls( recording: (() => { if (!recordingControlsAvailable || !config.isRecordingEnabled) return DISABLED; if (!hasFullControls || isConsulting || inConference) return DISABLED; + if (hasParallelConsultLeg && !customerPresent) return DISABLED; if (state === TaskState.CONNECTED || state === TaskState.HELD) { return VISIBLE_ENABLED; } @@ -318,6 +321,7 @@ function computeVoiceInteractionUIControls( // Label changes based on leg: "Conference" on main leg, "Merge" on consult leg conference: (() => { if (hasParallelConsultLeg) { + if (!customerPresent) return DISABLED; if (state === TaskState.CONNECTED) { return maxParticipants ? VISIBLE_DISABLED : VISIBLE_ENABLED; } @@ -327,6 +331,7 @@ function computeVoiceInteractionUIControls( } if (!hasFullControls || !isConsulting) return DISABLED; if (!consultInitiator) return DISABLED; + if (!customerPresent) return VISIBLE_DISABLED; if (consultLegOnHold) return VISIBLE_DISABLED; return isConsultDestinationReady && !maxParticipants ? VISIBLE_ENABLED : VISIBLE_DISABLED; @@ -356,6 +361,7 @@ function computeVoiceInteractionUIControls( // MergeToConference: mirrors conference control, enabled on both legs mergeToConference: (() => { if (!isConsulting || !consultInitiator) return DISABLED; + if (!customerPresent) return VISIBLE_DISABLED; if (consultLegOnHold) return VISIBLE_DISABLED; return isConsultDestinationReady && !maxParticipants ? VISIBLE_ENABLED : VISIBLE_DISABLED; @@ -363,8 +369,10 @@ function computeVoiceInteractionUIControls( // Switch: visible only on the currently active leg switch: (() => { + if (!customerPresent && hasParallelConsultLeg) return DISABLED; if (currentLeg === 'consult') { if (!isConsulting || !consultInitiator || consultCallHeld) return DISABLED; + if (!customerPresent) return VISIBLE_DISABLED; return isConsultDestinationReady ? VISIBLE_ENABLED : VISIBLE_DISABLED; } From 6fc42ea312f786c5382faaed2821f56c51390477 Mon Sep 17 00:00:00 2001 From: Akula Uday Date: Thu, 7 May 2026 11:49:39 +0530 Subject: [PATCH 2/6] fix(task-refactor): fix failing unit test --- .../unit/spec/services/task/state-machine/uiControlsComputer.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/@webex/contact-center/test/unit/spec/services/task/state-machine/uiControlsComputer.ts b/packages/@webex/contact-center/test/unit/spec/services/task/state-machine/uiControlsComputer.ts index bcbae43f6c2..c7a04cef458 100644 --- a/packages/@webex/contact-center/test/unit/spec/services/task/state-machine/uiControlsComputer.ts +++ b/packages/@webex/contact-center/test/unit/spec/services/task/state-machine/uiControlsComputer.ts @@ -27,7 +27,7 @@ function createConsultTaskData() { currentState: 'consulting', isConsulted: true, }, - 'customer-1': {id: 'customer-1', pType: 'CUSTOMER', hasLeft: false}, + 'customer-1': {id: 'customer-1', pType: 'Customer', hasLeft: false}, } as any, media: { 'interaction-1': { From f4f786ccbce7747978880f41fde350803d0b64a2 Mon Sep 17 00:00:00 2001 From: Akula Uday Date: Fri, 8 May 2026 14:57:24 +0530 Subject: [PATCH 3/6] fix(task-refactor): fix three party conference bugs --- .../src/services/task/TaskManager.ts | 3 +- .../task/state-machine/TaskStateMachine.ts | 23 +++++++++-- .../services/task/state-machine/actions.ts | 11 ++++- .../task/state-machine/uiControlsComputer.ts | 40 ++++++++++++++----- .../src/services/task/voice/Voice.ts | 19 +++++---- 5 files changed, 70 insertions(+), 26 deletions(-) diff --git a/packages/@webex/contact-center/src/services/task/TaskManager.ts b/packages/@webex/contact-center/src/services/task/TaskManager.ts index a9f349e2b04..5885ebeb83b 100644 --- a/packages/@webex/contact-center/src/services/task/TaskManager.ts +++ b/packages/@webex/contact-center/src/services/task/TaskManager.ts @@ -661,8 +661,7 @@ export default class TaskManager extends EventEmitter { const {payload} = context; let task = context.task; - if (payload.childInteractionId) { - // remove the child task from collection + if (payload.childInteractionId && this.taskCollection[payload.childInteractionId]) { this.removeTaskFromCollection(this.taskCollection[payload.childInteractionId]); } diff --git a/packages/@webex/contact-center/src/services/task/state-machine/TaskStateMachine.ts b/packages/@webex/contact-center/src/services/task/state-machine/TaskStateMachine.ts index 90f93af86e8..1c8286d0ca5 100644 --- a/packages/@webex/contact-center/src/services/task/state-machine/TaskStateMachine.ts +++ b/packages/@webex/contact-center/src/services/task/state-machine/TaskStateMachine.ts @@ -607,7 +607,12 @@ export function getTaskStateMachineConfig(uiControlConfig: UIControlConfig) { // AgentConsultConferenced, ParticipantJoinedConference [TaskEvent.CONFERENCE_START]: { target: TaskState.CONFERENCING, - actions: ['handleConferenceStarted', 'clearConsultState'], + actions: [ + 'updateTaskData', + 'syncTaskDataFromEvent', + 'handleConferenceStarted', + 'clearConsultState', + ], }, }, }, @@ -617,7 +622,12 @@ export function getTaskStateMachineConfig(uiControlConfig: UIControlConfig) { // AgentConsultConferenced, ParticipantJoinedConference [TaskEvent.CONFERENCE_START]: { target: TaskState.CONFERENCING, - actions: ['handleConferenceStarted'], + actions: [ + 'updateTaskData', + 'syncTaskDataFromEvent', + 'handleConferenceStarted', + 'clearConsultState', + ], }, // AgentConsultConferenceFailed [TaskEvent.CONFERENCE_FAILED]: { @@ -630,7 +640,12 @@ export function getTaskStateMachineConfig(uiControlConfig: UIControlConfig) { [TaskState.CONFERENCING]: { on: { [TaskEvent.CONFERENCE_START]: { - actions: ['updateTaskData', 'clearConsultState', 'emitTaskConferenceStarted'], + actions: [ + 'updateTaskData', + 'syncTaskDataFromEvent', + 'clearConsultState', + 'emitTaskConferenceStarted', + ], }, [TaskEvent.EXIT_CONFERENCE_SUCCESS]: [ { @@ -653,7 +668,7 @@ export function getTaskStateMachineConfig(uiControlConfig: UIControlConfig) { actions: ['updateTaskData', 'setHoldState', 'emitTaskHold'], }, [TaskEvent.UNHOLD_SUCCESS]: { - actions: ['updateTaskData', 'setHoldState', 'emitTaskResume'], + actions: ['updateTaskData', 'syncTaskDataFromEvent', 'setHoldState', 'emitTaskResume'], }, // Start a new consult from within an active conference diff --git a/packages/@webex/contact-center/src/services/task/state-machine/actions.ts b/packages/@webex/contact-center/src/services/task/state-machine/actions.ts index 46c90eccc68..0c68b3f308c 100644 --- a/packages/@webex/contact-center/src/services/task/state-machine/actions.ts +++ b/packages/@webex/contact-center/src/services/task/state-machine/actions.ts @@ -98,7 +98,11 @@ const deriveTaskDataUpdates = (context: TaskContext, taskData: TaskData | undefi const consultingActive = isActiveConsultState(taskData, selfAgentId); if (taskData.destAgentId) { - updates.consultDestinationAgentId = taskData.destAgentId; + const isEpDnWithStoredId = + context.consultDestinationType === 'entryPoint' && context.consultDestinationAgentId; + if (!isEpDnWithStoredId) { + updates.consultDestinationAgentId = taskData.destAgentId; + } } if (consultingActive && taskData.destinationType) { updates.consultDestinationType = taskData.destinationType as DestinationType; @@ -242,7 +246,10 @@ export const actions: TaskActionsMap = { const taskData = getTaskDataFromEvent(event); const consultDestinationType = 'destinationType' in event ? event.destinationType ?? null : null; - const consultDestinationAgentId = 'destAgentId' in event ? event.destAgentId ?? null : null; + const consultDestinationAgentId = + ('destAgentId' in event ? event.destAgentId : null) ?? + ('destination' in event ? (event as any).destination : null) ?? + null; return { consultDestinationType, diff --git a/packages/@webex/contact-center/src/services/task/state-machine/uiControlsComputer.ts b/packages/@webex/contact-center/src/services/task/state-machine/uiControlsComputer.ts index b33dc12254e..3171f102122 100644 --- a/packages/@webex/contact-center/src/services/task/state-machine/uiControlsComputer.ts +++ b/packages/@webex/contact-center/src/services/task/state-machine/uiControlsComputer.ts @@ -251,7 +251,7 @@ function computeVoiceInteractionUIControls( return DISABLED; })(), - // Transfer: connected/held, not in conference + // Transfer: connected/held/conference transfer: (() => { if (hasParallelConsultLeg) { if (!customerPresent) return DISABLED; @@ -265,7 +265,12 @@ function computeVoiceInteractionUIControls( return isConsultDestinationReady ? VISIBLE_ENABLED : VISIBLE_DISABLED; } - if (!hasFullControls || inConference) return DISABLED; + if (!hasFullControls) return DISABLED; + if (inConference) { + // Real conference (multiple agents): transfer is hidden + // Pending conference (only self agent): transfer remains available + return participantCount > 1 ? DISABLED : VISIBLE_ENABLED; + } if (state === TaskState.CONNECTED || state === TaskState.HELD) return VISIBLE_ENABLED; return DISABLED; @@ -280,15 +285,22 @@ function computeVoiceInteractionUIControls( return DISABLED; } - // Enabled conditions differ by state + // In conference: behavior depends on whether it's a real multi-agent conference + if (inConference) { + // Pending conference (only self agent): consult disabled + if (participantCount <= 1) return VISIBLE_DISABLED; + // Real conference: consult enabled if conditions met + const canFromConference = + !maxParticipants && customerPresent && !consultInProgress && !isConsulting; + + return {isVisible: true, isEnabled: canFromConference}; + } + + // Enabled conditions for connected/held const canFromConnected = !maxParticipants && customerPresent && !consultInProgress && !isConsulted; - const canFromConference = - !maxParticipants && customerPresent && !consultInProgress && !isConsulting; - - const isEnabled = inConference ? canFromConference : canFromConnected; - return {isVisible: true, isEnabled}; + return {isVisible: true, isEnabled: canFromConnected}; })(), // ConsultTransfer: always hidden (use transfer button) @@ -305,10 +317,15 @@ function computeVoiceInteractionUIControls( return {isVisible: true, isEnabled: consultInitiator || config.isEndConsultEnabled}; })(), - // Recording: connected/held only, not in consult/conference + // Recording: connected/held, hidden in real conference, visible in pending conference recording: (() => { if (!recordingControlsAvailable || !config.isRecordingEnabled) return DISABLED; - if (!hasFullControls || isConsulting || inConference) return DISABLED; + if (!hasFullControls || isConsulting) return DISABLED; + if (inConference) { + // Real conference (multiple agents): recording hidden + // Pending conference (only self agent): recording available + return participantCount > 1 ? DISABLED : VISIBLE_ENABLED; + } if (hasParallelConsultLeg && !customerPresent) return DISABLED; if (state === TaskState.CONNECTED || state === TaskState.HELD) { return VISIBLE_ENABLED; @@ -340,10 +357,11 @@ function computeVoiceInteractionUIControls( // Wrapup: wrapping up state wrapup: isWrappingUp ? VISIBLE_ENABLED : DISABLED, - // ExitConference: in conference, not consulting from conference + // ExitConference: in conference with multiple agents in main call exitConference: (() => { if (isConsulted && !isConferencing) return DISABLED; if (!inConference) return DISABLED; + if (participantCount <= 1) return DISABLED; const consultingFromConference = consultInitiator && isConsulting && conferenceFromBackend; return consultingFromConference ? VISIBLE_DISABLED : VISIBLE_ENABLED; diff --git a/packages/@webex/contact-center/src/services/task/voice/Voice.ts b/packages/@webex/contact-center/src/services/task/voice/Voice.ts index b364b5c5eb9..18d56057966 100644 --- a/packages/@webex/contact-center/src/services/task/voice/Voice.ts +++ b/packages/@webex/contact-center/src/services/task/voice/Voice.ts @@ -710,17 +710,22 @@ export default class Voice extends Task implements IVoice { ? calculateDestType(this.data.interaction, this.data.agentId) : ''; + // derivedDestType is most reliable as it inspects live interaction participants + const resolvedDestinationType = + derivedDestType || + this.getStateMachineSnapshot()?.context?.consultDestinationType || + this.data.destinationType || + 'agent'; + const consultationData: consultConferencePayloadData = { agentId: this.data.agentId, - destinationType: - this.getStateMachineSnapshot()?.context?.consultDestinationType || - this.data.destinationType || - derivedDestType || - 'agent', + destinationType: resolvedDestinationType, + // derivedDestAgentId is most reliable as it resolves epId for EP_DN + // and agent ID for regular agents from live interaction data destAgentId: + derivedDestAgentId || this.getStateMachineSnapshot()?.context?.consultDestinationAgentId || - this.data.destAgentId || - derivedDestAgentId, + this.data.destAgentId, }; // Send state machine event to transition to CONF_INITIATING From 55ecbeaf0c9fcf7a9fafd176932b3e3f4a8dcda5 Mon Sep 17 00:00:00 2001 From: Shreyas Sharma Date: Sun, 10 May 2026 23:37:06 +0530 Subject: [PATCH 4/6] fix(consult)L fix mute state in desktop consult --- .../src/services/task/state-machine/uiControlsComputer.ts | 7 +++++++ .../spec/services/task/state-machine/uiControlsComputer.ts | 2 ++ 2 files changed, 9 insertions(+) diff --git a/packages/@webex/contact-center/src/services/task/state-machine/uiControlsComputer.ts b/packages/@webex/contact-center/src/services/task/state-machine/uiControlsComputer.ts index d56a258b292..79c01dea4c6 100644 --- a/packages/@webex/contact-center/src/services/task/state-machine/uiControlsComputer.ts +++ b/packages/@webex/contact-center/src/services/task/state-machine/uiControlsComputer.ts @@ -144,6 +144,12 @@ function computeVoiceInteractionUIControls( Boolean(selfAgentId) && Boolean(mainCallId) && Boolean(interaction?.media?.[mainCallId]?.participants?.includes(selfAgentId as string)); + const consultMedia = Object.values(interaction?.media ?? {}).find( + (media: any) => + media?.mediaResourceId === taskData?.consultMediaResourceId || media?.mType === 'consult' + ) as {participants?: string[]} | undefined; + const selfInConsultCall = + Boolean(selfAgentId) && Boolean(consultMedia?.participants?.includes(selfAgentId as string)); const conferenceActive = isConferencing || conferenceFromBackend || consultFromConference; // Treat consult initiator as "in conference" even if mainCall participant list lags while consulting. const inConference = conferenceActive && (isConferencing || selfInMainCall || consultInitiator); @@ -214,6 +220,7 @@ function computeVoiceInteractionUIControls( mute: (() => { if (!isWebrtc) return DISABLED; if (isWrappingUp) return DISABLED; + if (currentLeg === 'consult' && !selfInConsultCall) return DISABLED; if (isConsulting) return VISIBLE_ENABLED; if (isConnected || isHeld || isConferencing) { diff --git a/packages/@webex/contact-center/test/unit/spec/services/task/state-machine/uiControlsComputer.ts b/packages/@webex/contact-center/test/unit/spec/services/task/state-machine/uiControlsComputer.ts index bcbae43f6c2..a45dc7f6006 100644 --- a/packages/@webex/contact-center/test/unit/spec/services/task/state-machine/uiControlsComputer.ts +++ b/packages/@webex/contact-center/test/unit/spec/services/task/state-machine/uiControlsComputer.ts @@ -86,6 +86,7 @@ describe('uiControlsComputer consult initiator controls', () => { expect(uiControls.main.end).toEqual({isVisible: true, isEnabled: false}); expect(uiControls.consult.hold).toEqual({isVisible: false, isEnabled: false}); + expect(uiControls.consult.mute).toEqual({isVisible: false, isEnabled: false}); expect(uiControls.consult.transfer).toEqual({isVisible: true, isEnabled: true}); expect(uiControls.consult.conference).toEqual({isVisible: true, isEnabled: true}); expect(uiControls.consult.endConsult).toEqual({isVisible: true, isEnabled: true}); @@ -109,6 +110,7 @@ describe('uiControlsComputer consult initiator controls', () => { expect(uiControls.main.end).toEqual({isVisible: true, isEnabled: false}); expect(uiControls.consult.hold).toEqual({isVisible: false, isEnabled: false}); + expect(uiControls.consult.mute).toEqual({isVisible: false, isEnabled: false}); expect(uiControls.consult.transfer).toEqual({isVisible: true, isEnabled: false}); expect(uiControls.consult.conference).toEqual({isVisible: true, isEnabled: false}); expect(uiControls.consult.endConsult).toEqual({isVisible: true, isEnabled: true}); From 91e27fb81869aedc9ea26f145cb2dcfd8e766cd5 Mon Sep 17 00:00:00 2001 From: Akula Uday Date: Mon, 11 May 2026 10:28:58 +0530 Subject: [PATCH 5/6] fix(task-refactor): address review comments --- .../services/task/state-machine/actions.ts | 16 +++++++++----- .../services/task/state-machine/constants.ts | 19 +++++++++++++++++ .../src/services/task/state-machine/guards.ts | 21 +++++++++++-------- 3 files changed, 42 insertions(+), 14 deletions(-) diff --git a/packages/@webex/contact-center/src/services/task/state-machine/actions.ts b/packages/@webex/contact-center/src/services/task/state-machine/actions.ts index 0c68b3f308c..bb29d8313ee 100644 --- a/packages/@webex/contact-center/src/services/task/state-machine/actions.ts +++ b/packages/@webex/contact-center/src/services/task/state-machine/actions.ts @@ -11,7 +11,13 @@ import { TaskActionArgs, RecordingStateUpdate, } from './types'; -import {TaskEvent, TaskState} from './constants'; +import { + TaskEvent, + TaskState, + INTERACTION_STATE, + CONSULT_STATE, + MEDIA_TYPE_CONSULT, +} from './constants'; import {DestinationType, TaskData} from '../types'; import {computeUIControls, getDefaultUIControls} from './uiControlsComputer'; @@ -74,13 +80,13 @@ const deriveRecordingState = (taskData?: TaskData | null): RecordingStateUpdate }; const isActiveConsultState = (taskData: TaskData | undefined, selfAgentId?: string): boolean => { - if (taskData?.interaction?.state === 'consulting') return true; - if (taskData?.interaction?.state === 'post_call' && selfAgentId) { + if (taskData?.interaction?.state === INTERACTION_STATE.CONSULTING) return true; + if (taskData?.interaction?.state === INTERACTION_STATE.POST_CALL && selfAgentId) { const selfParticipant = taskData.interaction?.participants?.[selfAgentId] as any; const hasConsultMedia = Object.values(taskData.interaction?.media ?? {}).some( - (media: any) => media?.mType === 'consult' + (media: any) => media?.mType === MEDIA_TYPE_CONSULT ); - if (selfParticipant?.consultState === 'consulting' && hasConsultMedia) return true; + if (selfParticipant?.consultState === CONSULT_STATE.CONSULTING && hasConsultMedia) return true; } return false; diff --git a/packages/@webex/contact-center/src/services/task/state-machine/constants.ts b/packages/@webex/contact-center/src/services/task/state-machine/constants.ts index 4115afe3597..9d2c511a529 100644 --- a/packages/@webex/contact-center/src/services/task/state-machine/constants.ts +++ b/packages/@webex/contact-center/src/services/task/state-machine/constants.ts @@ -36,6 +36,25 @@ export const MEDIA_TYPE_CONSULT = 'consult'; /** Media type for main calls */ export const MEDIA_TYPE_MAIN_CALL = 'mainCall'; +// ============================================ +// Backend Interaction State Constants +// ============================================ + +/** Backend interaction state values (from server payloads) */ +export const INTERACTION_STATE = { + CONSULTING: 'consulting', + POST_CALL: 'post_call', + CONFERENCE: 'conference', + CONNECTED: 'connected', + NEW: 'new', +} as const; + +/** Backend participant consultState values */ +export const CONSULT_STATE = { + CONSULTING: 'consulting', + CONFERENCING: 'conferencing', +} as const; + // ============================================ // State Machine Enums // ============================================ diff --git a/packages/@webex/contact-center/src/services/task/state-machine/guards.ts b/packages/@webex/contact-center/src/services/task/state-machine/guards.ts index 6462911ed88..d2788416cdc 100644 --- a/packages/@webex/contact-center/src/services/task/state-machine/guards.ts +++ b/packages/@webex/contact-center/src/services/task/state-machine/guards.ts @@ -27,7 +27,7 @@ import { getConferenceParticipantsCount, getIsConferenceInProgress, } from '../TaskUtils'; -import {TaskEvent} from './constants'; +import {TaskEvent, INTERACTION_STATE, CONSULT_STATE, MEDIA_TYPE_CONSULT} from './constants'; export const getTaskDataFromEvent = (event?: TaskEventPayload): TaskData | undefined => event && typeof event === 'object' && 'taskData' in event @@ -100,25 +100,28 @@ export const guards = { isInteractionConsulting: ({event, context}: GuardParams): boolean => { const taskData = getTaskDataFromEvent(event); - if (taskData?.interaction?.state === 'consulting') return true; + if (taskData?.interaction?.state === INTERACTION_STATE.CONSULTING) return true; // EP_DN consulted agent: backend reports state as 'connected' but CPD indicates consult const cpd = taskData?.interaction?.callProcessingDetails; - if (cpd?.relationshipType === 'consult' && taskData?.interaction?.state === 'connected') { + if ( + cpd?.relationshipType === 'consult' && + taskData?.interaction?.state === INTERACTION_STATE.CONNECTED + ) { return true; } // Customer left during consult: interaction state is "post_call" but consult // between agents is still active. Detect via agent's consultState + consult media. - if (taskData?.interaction?.state === 'post_call') { + if (taskData?.interaction?.state === INTERACTION_STATE.POST_CALL) { const selfAgentId = getSelfAgentId(context, taskData); const selfParticipant = selfAgentId ? taskData?.interaction?.participants?.[selfAgentId] : null; const hasConsultMedia = Object.values(taskData?.interaction?.media ?? {}).some( - (media: any) => media?.mType === 'consult' + (media: any) => media?.mType === MEDIA_TYPE_CONSULT ); - if (selfParticipant?.consultState === 'consulting' && hasConsultMedia) { + if (selfParticipant?.consultState === CONSULT_STATE.CONSULTING && hasConsultMedia) { return true; } } @@ -142,7 +145,7 @@ export const guards = { isInteractionConnected: ({event}: GuardParams): boolean => { const taskData = getTaskDataFromEvent(event); - return taskData?.interaction?.state === 'connected'; + return taskData?.interaction?.state === INTERACTION_STATE.CONNECTED; }, isConferencingByParticipants: ({event}: GuardParams): boolean => { @@ -191,7 +194,7 @@ export const guards = { if (!mainCallId) return false; // Don't downgrade while backend still reports conference. - if (taskData.interaction.state === 'conference') return false; + if (taskData.interaction.state === INTERACTION_STATE.CONFERENCE) return false; const agentParticipantsCount = getConferenceParticipantsCount(taskData.interaction, mainCallId); if (agentParticipantsCount >= 2) return false; @@ -233,7 +236,7 @@ export const guards = { return ( taskData.isConsulted === true || relationshipType === 'consult' || - taskData.interaction?.state === 'consulting' + taskData.interaction?.state === INTERACTION_STATE.CONSULTING ); }, From a63a37d13921dc476cfe7a60053da8d8efd1cd30 Mon Sep 17 00:00:00 2001 From: Shreyas Sharma Date: Mon, 11 May 2026 11:33:59 +0530 Subject: [PATCH 6/6] fix(recording): pause/resume recording while call is on hold --- .../task/state-machine/TaskStateMachine.ts | 6 +++++ .../task/state-machine/TaskStateMachine.ts | 22 +++++++++++++++++++ 2 files changed, 28 insertions(+) diff --git a/packages/@webex/contact-center/src/services/task/state-machine/TaskStateMachine.ts b/packages/@webex/contact-center/src/services/task/state-machine/TaskStateMachine.ts index 94e6a2fa47a..84d68121aa0 100644 --- a/packages/@webex/contact-center/src/services/task/state-machine/TaskStateMachine.ts +++ b/packages/@webex/contact-center/src/services/task/state-machine/TaskStateMachine.ts @@ -323,6 +323,12 @@ export function getTaskStateMachineConfig(uiControlConfig: UIControlConfig) { [TaskState.HELD]: { on: { + [TaskEvent.PAUSE_RECORDING]: { + actions: ['updateTaskData', 'setRecordingState', 'emitTaskRecordingPaused'], + }, + [TaskEvent.RESUME_RECORDING]: { + actions: ['updateTaskData', 'setRecordingState', 'emitTaskRecordingResumed'], + }, // Click of the unhold button [TaskEvent.UNHOLD_INITIATED]: { target: TaskState.RESUME_INITIATING, diff --git a/packages/@webex/contact-center/test/unit/spec/services/task/state-machine/TaskStateMachine.ts b/packages/@webex/contact-center/test/unit/spec/services/task/state-machine/TaskStateMachine.ts index d7ada05445a..1814c934936 100644 --- a/packages/@webex/contact-center/test/unit/spec/services/task/state-machine/TaskStateMachine.ts +++ b/packages/@webex/contact-center/test/unit/spec/services/task/state-machine/TaskStateMachine.ts @@ -124,6 +124,28 @@ describe('Task state machine', () => { service.send({type: TaskEvent.RESUME_RECORDING}); expect(service.getSnapshot().context.recordingInProgress).toBe(true); }); + + it('toggles recording state while task is held', () => { + const service = startMachine(); + const taskData = createTaskData({ + interaction: { + callProcessingDetails: {recordInProgress: true}, + } as any, + }); + + service.send({type: TaskEvent.TASK_INCOMING, taskData}); + service.send({type: TaskEvent.ASSIGN, taskData}); + service.send({type: TaskEvent.HOLD_INITIATED, mediaResourceId: taskData.mediaResourceId}); + service.send({type: TaskEvent.HOLD_SUCCESS, mediaResourceId: taskData.mediaResourceId}); + expect(service.getSnapshot().value).toBe(TaskState.HELD); + expect(service.getSnapshot().context.recordingInProgress).toBe(true); + + service.send({type: TaskEvent.PAUSE_RECORDING}); + expect(service.getSnapshot().context.recordingInProgress).toBe(false); + + service.send({type: TaskEvent.RESUME_RECORDING}); + expect(service.getSnapshot().context.recordingInProgress).toBe(true); + }); }); describe('wrap-up and completion flow', () => {