diff --git a/packages/@webex/contact-center/src/services/task/Task.ts b/packages/@webex/contact-center/src/services/task/Task.ts index 7e3057bf5de..eff9c2346be 100644 --- a/packages/@webex/contact-center/src/services/task/Task.ts +++ b/packages/@webex/contact-center/src/services/task/Task.ts @@ -27,7 +27,7 @@ import routingContact from './contact'; import MetricsManager from '../../metrics/MetricsManager'; import {METRIC_EVENT_NAMES} from '../../metrics/constants'; import LoggerProxy from '../../logger-proxy'; -import {createTaskStateMachine, TaskState} from './state-machine'; +import {createTaskStateMachine, TaskEvent, TaskState} from './state-machine'; import type { TaskEventPayload, TaskStateMachine, @@ -41,6 +41,8 @@ import { getDefaultUIControls, haveUIControlsChanged, } from './state-machine/uiControlsComputer'; +import {deriveRecordingContextPatch} from './state-machine/actions'; +import {resolveEffectiveVoiceTaskState} from './state-machine/effectiveVoiceTaskState'; import AutoWrapup from './AutoWrapup'; import {WrapupData} from '../config/types'; @@ -195,6 +197,27 @@ export default abstract class Task extends EventEmitter implements ITask { this.unsupportedMethodError('holdResume'); } + /** + * Merges recording flags from {@link Task#data} into machine context. Task payloads are refreshed + * via {@link #updateTaskData} on WebSocket updates, but the state machine assign only runs when an + * event is sent — without this merge, `recordingControlsAvailable` can stay false while CAD shows + * pause/resume is allowed. + */ + protected mergeRecordingContextFromTaskData(context: TaskContext): TaskContext { + return {...context, ...deriveRecordingContextPatch(this.data)}; + } + + /** + * Effective voice task state for API guards when the machine snapshot is still {@link TaskState.IDLE} + * but {@link Task#data} shows an active main call (e.g. post–consult transfer event ordering). + */ + protected getEffectiveVoiceTaskStateForOperation(): TaskState { + const machineState = + (this.stateMachineService?.getSnapshot?.()?.value as TaskState | undefined) ?? TaskState.IDLE; + + return resolveEffectiveVoiceTaskState(machineState, this.data); + } + /** * Latest UI controls derived from state machine state and context. */ @@ -276,7 +299,7 @@ export default abstract class Task extends EventEmitter implements ITask { } const currentState = snapshot.value as TaskState; - const context = snapshot.context as TaskContext; + const context = this.mergeRecordingContextFromTaskData(snapshot.context as TaskContext); return computeUIControls(currentState, context, this.data); } @@ -753,6 +776,12 @@ export default abstract class Task extends EventEmitter implements ITask { data: wrapupPayload, }); + // Some transfer flows can miss or delay AGENT_WRAPPEDUP; complete locally on API success. + this.stateMachineService?.send({ + type: TaskEvent.WRAPUP_COMPLETE, + taskData: response.data, + }); + this.metricsManager.trackEvent( METRIC_EVENT_NAMES.TASK_WRAPUP_SUCCESS, { diff --git a/packages/@webex/contact-center/src/services/task/TaskManager.ts b/packages/@webex/contact-center/src/services/task/TaskManager.ts index d3aff870343..abd3ff480da 100644 --- a/packages/@webex/contact-center/src/services/task/TaskManager.ts +++ b/packages/@webex/contact-center/src/services/task/TaskManager.ts @@ -19,7 +19,12 @@ import {METHODS, TRANSCRIPT_EVENT_MAP} from './constants'; import {CC_EVENTS, WrapupData} from '../config/types'; import {ConfigFlags, LoginOption, AIAssistantEventType, AIAssistantEventName} from '../../types'; import LoggerProxy from '../../logger-proxy'; -import {getIsConferenceInProgress, isSecondaryEpDnAgent, shouldAutoAnswerTask} from './TaskUtils'; +import { + getIsConferenceInProgress, + isSecondaryEpDnAgent, + isSecondaryEpDnConsultedRecipient, + shouldAutoAnswerTask, +} from './TaskUtils'; import TaskFactory from './TaskFactory'; import WebRTC from './voice/WebRTC'; import {TaskEvent, type TaskEventPayload} from './state-machine'; @@ -426,6 +431,16 @@ export default class TaskManager extends EventEmitter { const wasConsultedTask = Boolean(task?.data?.isConsulted); const computeWrapUpRequired = () => { + const pending = message.data.agentsPendingWrapUp; + if (Array.isArray(pending) && pending.length > 0 && this.agentId) { + return pending.includes(this.agentId); + } + if ( + this.agentId && + message.data.interaction?.participants?.[this.agentId]?.isWrapUp === true + ) { + return true; + } if (message.data.wrapUpRequired !== undefined) { return message.data.wrapUpRequired; } @@ -478,6 +493,18 @@ export default class TaskManager extends EventEmitter { * Note: Task-level state transitions and event emissions are handled by * the task state machine via sendStateMachineEvent() */ + /** + * Resolves `isConsulted` when building or updating task data. + * EP-DN secondary shape alone must not mark the new primary owner as consulted after transfer. + */ + private resolveIsConsultedTaskFlag(payload: TaskData): boolean { + if (payload.isConsulted === true) return true; + // Explicit false from the server must win (e.g. consult transfer recipient); do not re-infer. + if (payload.isConsulted === false) return false; + + return isSecondaryEpDnConsultedRecipient(payload.interaction, this.agentId); + } + private handleTaskLifecycleEvent(context: EventContext): TaskEventActions { const {eventType} = context; @@ -502,8 +529,6 @@ export default class TaskManager extends EventEmitter { */ private handleContactReserved(context: EventContext): TaskEventActions { const {payload} = context; - const isConsultedTask = - payload.isConsulted === true || isSecondaryEpDnAgent(payload.interaction); const shouldAutoAnswer = shouldAutoAnswerTask( payload, this.agentId, @@ -513,7 +538,7 @@ export default class TaskManager extends EventEmitter { const taskData: TaskData = { ...payload, - isConsulted: isConsultedTask, + isConsulted: this.resolveIsConsultedTaskFlag(payload), isAutoAnswering: shouldAutoAnswer, }; @@ -541,8 +566,6 @@ export default class TaskManager extends EventEmitter { const {payload} = context; if (!task) { - const isConsultedTask = - payload.isConsulted === true || isSecondaryEpDnAgent(payload.interaction); const shouldAutoAnswer = shouldAutoAnswerTask( payload, this.agentId, @@ -551,7 +574,7 @@ export default class TaskManager extends EventEmitter { ); const taskData: TaskData = { ...payload, - isConsulted: isConsultedTask, + isConsulted: this.resolveIsConsultedTaskFlag(payload), wrapUpRequired: payload.interaction?.participants?.[this.agentId]?.isWrapUp || false, isConferenceInProgress: getIsConferenceInProgress(payload), isAutoAnswering: shouldAutoAnswer, @@ -581,14 +604,20 @@ export default class TaskManager extends EventEmitter { const isConsultingFlow = snapshot?.value === 'CONSULTING' || taskData.interaction?.state === 'consulting'; + const normalizedPayload: TaskData = { + ...taskData, + isConsulted: this.resolveIsConsultedTaskFlag(taskData), + }; + const updateTaskData = isConsultingFlow ? { - ...taskData, - destAgentId: taskData.destAgentId ?? snapshot?.context?.consultDestinationAgentId ?? null, + ...normalizedPayload, + destAgentId: + normalizedPayload.destAgentId ?? snapshot?.context?.consultDestinationAgentId ?? null, destinationType: - taskData.destinationType ?? snapshot?.context?.consultDestinationType ?? null, + normalizedPayload.destinationType ?? snapshot?.context?.consultDestinationType ?? null, } - : taskData; + : normalizedPayload; task.updateTaskData(updateTaskData); this.taskCollection[taskData.interactionId] = task; diff --git a/packages/@webex/contact-center/src/services/task/TaskUtils.ts b/packages/@webex/contact-center/src/services/task/TaskUtils.ts index bbca0c3127a..05621f0e38d 100644 --- a/packages/@webex/contact-center/src/services/task/TaskUtils.ts +++ b/packages/@webex/contact-center/src/services/task/TaskUtils.ts @@ -97,6 +97,20 @@ export const getIsConsultInProgressForConferenceControls = ( }); }; +/** + * Determines whether consult is in progress for generic control visibility. + * Uses explicit task flag first and falls back to consult media detection. + */ +export const getIsConsultInProgress = (taskData: TaskData | null | undefined): boolean => { + if (!taskData) return false; + if (typeof taskData.isConsultInProgress === 'boolean') return taskData.isConsultInProgress; + + const media = taskData.interaction?.media; + if (!media) return false; + + return Object.values(media).some((m: any) => m?.mType === 'consult'); +}; + export const getIsConsultedAgentForControls = ( taskData: TaskData | null, context: TaskContext, @@ -236,6 +250,26 @@ export const isSecondaryEpDnAgent = (interaction: Interaction): boolean => { return interaction.mediaType === 'telephony' && isSecondaryAgent(interaction); }; +/** + * True when we should infer `isConsulted` from EP-DN secondary interaction shape alone. + * After a consult transfer, metadata can still match {@link isSecondaryEpDnAgent} while the + * receiving agent is the new primary owner — in that case `interaction.owner` matches self and + * we must not mark the task as consulted. + */ +export const isSecondaryEpDnConsultedRecipient = ( + interaction: Interaction | undefined, + selfAgentId: string | undefined +): boolean => { + if (!interaction || !selfAgentId || !isSecondaryEpDnAgent(interaction)) { + return false; + } + if (interaction.owner && interaction.owner === selfAgentId) { + return false; + } + + return true; +}; + /** * Checks if auto-answer is enabled for the agent participant * @param interaction - The interaction object 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..9e7b4c54464 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 @@ -78,6 +78,12 @@ export function getTaskStateMachineConfig(uiControlConfig: UIControlConfig) { [TaskEvent.HYDRATE]: { actions: ['updateTaskData', 'emitTaskHydrate'], }, + // Wrapped-up can arrive while machine still reflects CONNECTED/CONSULTING due event ordering. + // Accept it globally so completion cleanup always runs and task is removed from collection. + [TaskEvent.WRAPUP_COMPLETE]: { + target: `.${TaskState.COMPLETED}`, + actions: ['updateTaskData'], + }, }, states: { [TaskState.IDLE]: { @@ -261,7 +267,7 @@ export function getTaskStateMachineConfig(uiControlConfig: UIControlConfig) { // AgentConsultTransferred / AgentVTeamTransferred / AgentBlindTransferred [TaskEvent.TRANSFER_SUCCESS]: [ { - guard: guards.shouldWrapUpOrIsInitiator, + guard: guards.shouldWrapUp, target: TaskState.WRAPPING_UP, actions: ['updateTaskData', 'markEnded', 'clearConsultState', 'emitTaskWrapup'], }, @@ -341,7 +347,7 @@ export function getTaskStateMachineConfig(uiControlConfig: UIControlConfig) { // AgentConsultTransferred / AgentVTeamTransferred / AgentBlindTransferred [TaskEvent.TRANSFER_SUCCESS]: [ { - guard: guards.shouldWrapUpOrIsInitiator, + guard: guards.shouldWrapUp, target: TaskState.WRAPPING_UP, actions: ['updateTaskData', 'markEnded', 'emitTaskWrapup'], }, @@ -496,7 +502,7 @@ export function getTaskStateMachineConfig(uiControlConfig: UIControlConfig) { [TaskEvent.TRANSFER_SUCCESS]: [ { - guard: guards.shouldWrapUpOrIsInitiator, + guard: guards.shouldWrapUp, target: TaskState.WRAPPING_UP, actions: ['updateTaskData', 'markEnded', 'clearConsultState', 'emitTaskWrapup'], }, 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 80d1c818bdd..9804508bd1b 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 @@ -13,6 +13,7 @@ import { } from './types'; import {TaskEvent, TaskState} from './constants'; import {DestinationType, TaskData} from '../types'; +import {getIsConsultInProgressForConferenceControls} from '../TaskUtils'; import {computeUIControls, getDefaultUIControls} from './uiControlsComputer'; const determineConsultInitiator = ( @@ -30,7 +31,11 @@ const determineConsultInitiator = ( return undefined; }; -const deriveRecordingState = (taskData?: TaskData | null): RecordingStateUpdate => { +/** + * Derives recording UI / guard flags from {@link TaskData.interaction.callProcessingDetails}. + * Exported so {@link Task} can merge fresh task payloads into UI when no state machine event ran. + */ +export function deriveRecordingContextPatch(taskData?: TaskData | null): RecordingStateUpdate { const callProcessingDetails = taskData?.interaction?.callProcessingDetails; if (!callProcessingDetails) { @@ -67,18 +72,40 @@ const deriveRecordingState = (taskData?: TaskData | null): RecordingStateUpdate if (isPaused !== undefined) { update.recordingControlsAvailable = true; - update.recordingInProgress = !isPaused; + const paused = isPaused === true || String(isPaused).toLowerCase() === 'true'; + update.recordingInProgress = !paused; + } + + // Match {@link TaskFactory} default: pause/resume is allowed when pauseResumeEnabled is omitted. + // Many payloads include queue/CAD fields but omit recordingStarted/recordInProgress until later; + // without this, recording stays hidden and {@link Voice.pauseRecording} keeps failing prechecks. + const pauseResumeAllowed = + (callProcessingDetails as {pauseResumeEnabled?: boolean}).pauseResumeEnabled ?? true; + + if (update.recordingControlsAvailable === undefined && pauseResumeAllowed) { + update.recordingControlsAvailable = true; + if (update.recordingInProgress === undefined) { + update.recordingInProgress = true; + } + } + + if ( + update.recordingControlsAvailable === true && + update.recordingInProgress === undefined && + pauseResumeAllowed + ) { + update.recordingInProgress = true; } return update; -}; +} const deriveTaskDataUpdates = (context: TaskContext, taskData: TaskData | undefined) => taskData ? (() => { const updates: Partial = { taskData, - ...deriveRecordingState(taskData), + ...deriveRecordingContextPatch(taskData), }; if (taskData.destAgentId) { @@ -103,13 +130,43 @@ const deriveTaskDataUpdates = (context: TaskContext, taskData: TaskData | undefi if (taskData.interaction?.state === 'consulting') { if (!context.consultDestinationAgentJoined) { - const hasJoinedConsultee = Boolean( - taskData.interaction.participants && - Object.values(taskData.interaction.participants).some( - (p: any) => p?.isConsulted === true && !p?.hasLeft + const selfAgentId = context.uiControlConfig.agentId ?? taskData?.agentId; + const participants = taskData.interaction.participants; + const hasJoinedConsulteeFromFlags = Boolean( + participants && + Object.values(participants).some((p: any) => p?.isConsulted === true && !p?.hasLeft) + ); + // EP-DN / telephony consult: consulted party may not carry isConsulted on participants + const hasRemotePartyOnConsultMedia = Boolean( + selfAgentId && + taskData.interaction.media && + Object.values(taskData.interaction.media).some((m: any) => { + if (m?.mType !== 'consult' || !Array.isArray(m.participants)) return false; + + return m.participants.some((participantId: string) => { + if (participantId === selfAgentId) return false; + const p: any = participants?.[participantId]; + + return Boolean(p && !p.hasLeft); + }); + }) + ); + const mainCallId = taskData.interaction.mainInteractionId || taskData.interactionId; + const hasJoinedPerConferenceHeuristic = Boolean( + selfAgentId && + getIsConsultInProgressForConferenceControls( + taskData.interaction, + mainCallId, + selfAgentId ) ); - if (hasJoinedConsultee) updates.consultDestinationAgentJoined = true; + if ( + hasJoinedConsulteeFromFlags || + hasRemotePartyOnConsultMedia || + hasJoinedPerConferenceHeuristic + ) { + updates.consultDestinationAgentJoined = true; + } } const effectiveConsultInitiator = updates.consultInitiator ?? context.consultInitiator; diff --git a/packages/@webex/contact-center/src/services/task/state-machine/effectiveVoiceTaskState.ts b/packages/@webex/contact-center/src/services/task/state-machine/effectiveVoiceTaskState.ts new file mode 100644 index 00000000000..0f1a88901db --- /dev/null +++ b/packages/@webex/contact-center/src/services/task/state-machine/effectiveVoiceTaskState.ts @@ -0,0 +1,84 @@ +/** + * When the XState snapshot lags (e.g. after consult transfer) but {@link TaskData} reflects an + * active voice call, align operational and UI behavior with observable interaction state. + */ + +import {TaskState} from './constants'; +import type {TaskData} from '../types'; + +function shouldForceWrapUpForCurrentAgent(taskData: TaskData): boolean { + const selfId = taskData.agentId; + if (!selfId) return Boolean(taskData.wrapUpRequired); + + const pending = taskData.agentsPendingWrapUp; + if (Array.isArray(pending) && pending.length > 0) { + return pending.includes(selfId); + } + + const participantWrapUp = taskData.interaction?.participants?.[selfId]?.isWrapUp === true; + if (participantWrapUp) return true; + const participants = taskData.interaction?.participants; + if (participants) { + const selfParticipant: any = participants[selfId]; + if (!selfParticipant || selfParticipant.hasLeft === true) { + return true; + } + } + + return taskData.wrapUpRequired === true; +} + +/** + * Maps machine + latest task payload to the task state call controls should assume for voice. + * Used by {@link computeUIControls} and {@link Voice} operation guards. + */ +export function resolveEffectiveVoiceTaskState( + machineState: TaskState, + taskData?: TaskData | null +): TaskState { + if (!taskData?.interaction) { + return machineState; + } + if ( + machineState !== TaskState.WRAPPING_UP && + machineState !== TaskState.COMPLETED && + machineState !== TaskState.TERMINATED && + shouldForceWrapUpForCurrentAgent(taskData) + ) { + return TaskState.WRAPPING_UP; + } + if (machineState !== TaskState.IDLE) return machineState; + if (taskData.interaction.isTerminated === true) { + return machineState; + } + + const raw = taskData.interaction.state; + if (typeof raw === 'string') { + const s = raw.trim().toLowerCase(); + if (s === 'connected') return TaskState.CONNECTED; + if (s === 'consulting') return TaskState.CONSULTING; + if (s === 'hold') return TaskState.HELD; + if (s === 'conference') return TaskState.CONFERENCING; + if (s === 'wrap-up' || s === 'wrapup' || s === 'wrapping_up') return TaskState.WRAPPING_UP; + if (s === 'new' || s === 'reserved' || s === 'offered') return machineState; + } + + const interaction = taskData.interaction; + const mainId = interaction.mainInteractionId || taskData.interactionId; + const selfId = taskData.agentId; + const media = mainId ? interaction.media?.[mainId] : undefined; + const ids = media?.participants; + if (selfId && Array.isArray(ids) && ids.includes(selfId) && interaction.participants) { + const hasOtherActiveParty = ids.some((participantId: string) => { + if (participantId === selfId) return false; + const p: any = interaction.participants?.[participantId]; + + return Boolean(p && !p.hasLeft); + }); + if (hasOtherActiveParty) { + return media?.isHold ? TaskState.HELD : TaskState.CONNECTED; + } + } + + return machineState; +} 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 be5456e6b15..4a0f875d3ca 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 @@ -34,6 +34,14 @@ export const getTaskDataFromEvent = (event?: TaskEventPayload): TaskData | undef ? (event as {taskData?: TaskData}).taskData : undefined; +/** Lowercase trim of `interaction.state` for guard comparisons (backend casing varies). */ +const interactionStateLc = (taskData: TaskData | undefined): string | undefined => { + const raw = taskData?.interaction?.state; + if (typeof raw !== 'string') return undefined; + + return raw.trim().toLowerCase(); +}; + export const getSelfAgentId = (context: TaskContext, taskData?: TaskData): string | undefined => context.uiControlConfig?.agentId ?? context.taskData?.agentId ?? taskData?.agentId; @@ -62,6 +70,14 @@ export const shouldWrapUpForThisAgent = (context: TaskContext, taskData: TaskDat if (wrapUpRequired || participantWrapUp) { return true; } + const participants = taskData?.interaction?.participants; + if (participants) { + const selfParticipant: any = participants[selfAgentId]; + // Transfer payloads can omit/mark this participant left before explicit wrapup flags. + if (!selfParticipant || selfParticipant.hasLeft === true) { + return true; + } + } const owner = taskData?.interaction?.owner; if (owner && owner === selfAgentId) { @@ -100,13 +116,13 @@ export const guards = { isInteractionConsulting: ({event}: GuardParams): boolean => { const taskData = getTaskDataFromEvent(event); - return taskData?.interaction?.state === 'consulting'; + return interactionStateLc(taskData) === 'consulting'; }, isInteractionHeld: ({event}: GuardParams): boolean => { const taskData = getTaskDataFromEvent(event); - if (taskData?.interaction?.state === 'hold') return true; + if (interactionStateLc(taskData) === 'hold') return true; const mainMediaId = taskData?.interaction?.mainInteractionId || taskData?.interactionId; if (mainMediaId && taskData?.interaction?.media?.[mainMediaId]?.isHold === true) { @@ -119,7 +135,7 @@ export const guards = { isInteractionConnected: ({event}: GuardParams): boolean => { const taskData = getTaskDataFromEvent(event); - return taskData?.interaction?.state === 'connected'; + return interactionStateLc(taskData) === 'connected'; }, isConferencingByParticipants: ({event}: GuardParams): boolean => { @@ -168,7 +184,7 @@ export const guards = { if (!mainCallId) return false; // Don't downgrade while backend still reports conference. - if (taskData.interaction.state === 'conference') return false; + if (interactionStateLc(taskData) === 'conference') return false; const agentParticipantsCount = getConferenceParticipantsCount(taskData.interaction, mainCallId); if (agentParticipantsCount >= 2) return false; diff --git a/packages/@webex/contact-center/src/services/task/state-machine/index.ts b/packages/@webex/contact-center/src/services/task/state-machine/index.ts index eabe009a79f..86032b542e8 100644 --- a/packages/@webex/contact-center/src/services/task/state-machine/index.ts +++ b/packages/@webex/contact-center/src/services/task/state-machine/index.ts @@ -11,6 +11,7 @@ export type {TaskStateMachine} from './TaskStateMachine'; // Types & enums export {TaskState, TaskEvent} from './constants'; export {isEventOfType} from './types'; +export {resolveEffectiveVoiceTaskState} from './effectiveVoiceTaskState'; export type { TaskContext, TaskEventPayload, @@ -25,4 +26,4 @@ export {guards} from './guards'; export type {GuardParams, GuardFunction} from './guards'; // Actions -export {actions, createInitialContext} from './actions'; +export {actions, createInitialContext, deriveRecordingContextPatch} from './actions'; 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 2d5f5e0a6b7..ea52c4e738c 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 @@ -12,13 +12,16 @@ import { } from '../types'; import {TaskContext, UIControlConfig} from './types'; import {TaskState, MAX_PARTICIPANTS_IN_MULTIPARTY_CONFERENCE} from './constants'; +import {resolveEffectiveVoiceTaskState} from './effectiveVoiceTaskState'; import { getIsCustomerInCall, getConferenceParticipantsCount, getIsConferenceInProgress, + getIsConsultInProgress, getIsConsultInProgressForConferenceControls, getIsConsultedAgentForControls, getServerHoldStateForControls, + isSecondaryAgent, } from '../TaskUtils'; const DISABLED = {isVisible: false, isEnabled: false} as const; @@ -108,7 +111,17 @@ function computeVoiceInteractionUIControls( mainCallId, selfAgentId ); + const consultInProgressForVisibility = getIsConsultInProgress(taskData); const conferenceFromBackend = taskData ? getIsConferenceInProgress(taskData) : false; + const consultRelationship = interaction?.callProcessingDetails?.relationshipType === 'consult'; + // Stale relationshipType=consult often remains after consult transfer to a new primary owner. + // Only treat it as an active consult session for UI when the interaction is consulting or this + // task is the secondary (EP-DN) consult leg — not a warm-transferred main call. + const consultRelationshipAffectsConsultingUi = Boolean( + consultRelationship && + interaction && + (interaction.state === 'consulting' || isSecondaryAgent(interaction)) + ); // Note: ownership is used by some controls; keep computations local to those controls // Context flags (set by state machine actions) @@ -126,7 +139,9 @@ function computeVoiceInteractionUIControls( const isConsulting = state === TaskState.CONSULTING || state === TaskState.CONSULT_INITIATING || - state === TaskState.CONF_INITIATING; + state === TaskState.CONF_INITIATING || + consultInProgressForVisibility || + consultRelationshipAffectsConsultingUi; const isConferencing = state === TaskState.CONFERENCING; const isWrappingUp = state === TaskState.WRAPPING_UP; const selfInMainCall = @@ -203,7 +218,7 @@ function computeVoiceInteractionUIControls( mute: (() => { if (!isWebrtc) return DISABLED; if (isWrappingUp) return DISABLED; - if (isConsulting) return VISIBLE_ENABLED; + if (isConsulting) return hasFullControls ? VISIBLE_ENABLED : DISABLED; if (isConnected || isHeld || isConferencing) { if (inConference) return VISIBLE_ENABLED; @@ -221,6 +236,10 @@ function computeVoiceInteractionUIControls( if (isConsulting) { if (currentLeg === 'consult' && consultCallHeld) return DISABLED; + // Parked customer (main held) while consult is active — same UX as hasParallelConsultLeg + if (currentLeg === 'main' && stateImpliesHeld && consultInitiator) { + return VISIBLE_DISABLED; + } return consultInitiator && consultCallHeld ? VISIBLE_ENABLED : DISABLED; } @@ -246,7 +265,12 @@ function computeVoiceInteractionUIControls( } if (isConsulting) { if (!consultInitiator) return DISABLED; - if (consultLegOnHold) return VISIBLE_DISABLED; + // EP-DN / payloads without consult media: parked main must not mirror consult-leg actions + if (currentLeg === 'main' && stateImpliesHeld) { + return VISIBLE_DISABLED; + } + // consultLegOnHold = consult parked while on main; disable transfer only on the consult card + if (consultLegOnHold && currentLeg === 'consult') return VISIBLE_DISABLED; return consultDestinationAgentJoined ? VISIBLE_ENABLED : VISIBLE_DISABLED; } @@ -314,7 +338,10 @@ function computeVoiceInteractionUIControls( } if (!hasFullControls || !isConsulting) return DISABLED; if (!consultInitiator) return DISABLED; - if (consultLegOnHold) return VISIBLE_DISABLED; + if (currentLeg === 'main' && stateImpliesHeld) { + return VISIBLE_DISABLED; + } + if (consultLegOnHold && currentLeg === 'consult') return VISIBLE_DISABLED; return consultDestinationAgentJoined && !maxParticipants ? VISIBLE_ENABLED : VISIBLE_DISABLED; })(), @@ -343,7 +370,10 @@ function computeVoiceInteractionUIControls( // MergeToConference: mirrors conference control, enabled on both legs mergeToConference: (() => { if (!isConsulting || !consultInitiator) return DISABLED; - if (consultLegOnHold) return VISIBLE_DISABLED; + if (currentLeg === 'main' && stateImpliesHeld) { + return VISIBLE_DISABLED; + } + if (consultLegOnHold && currentLeg === 'consult') return VISIBLE_DISABLED; return consultDestinationAgentJoined && !maxParticipants ? VISIBLE_ENABLED : VISIBLE_DISABLED; })(), @@ -360,6 +390,17 @@ function computeVoiceInteractionUIControls( return consultDestinationAgentJoined ? VISIBLE_ENABLED : VISIBLE_DISABLED; } + // On main with consult held (e.g. after switch); hasParallelConsultLeg is false when isConsulting stays true + if ( + currentLeg === 'main' && + state === TaskState.CONNECTED && + consultCallHeld && + consultInitiator && + isConsulting + ) { + return consultDestinationAgentJoined ? VISIBLE_ENABLED : VISIBLE_DISABLED; + } + return DISABLED; })(), }; @@ -420,6 +461,7 @@ function getVoiceLegState( mainCallId, selfAgentId ); + const consultInProgressForVisibility = getIsConsultInProgress(taskData); const isConsultingState = currentState === TaskState.CONSULTING || currentState === TaskState.CONSULT_INITIATING || @@ -435,7 +477,11 @@ function getVoiceLegState( consultOwnedBySelf && !taskData?.isConsulted && !interaction?.isTerminated && - (consultInProgress || isConsultingState || context.consultCallHeld || hasConsultMedia) + (consultInProgress || + consultInProgressForVisibility || + isConsultingState || + context.consultCallHeld || + hasConsultMedia) ); if (!hasConsultLeg) { @@ -466,8 +512,10 @@ export function computeUIControls( switch (context.uiControlConfig.channelType) { case TASK_CHANNEL_TYPE.VOICE: { + const taskData = fallbackTaskData ?? context.taskData; + const effectiveState = resolveEffectiveVoiceTaskState(currentState, taskData); const {hasConsultLeg, activeLeg, mainState, consultState} = getVoiceLegState( - currentState, + effectiveState, context, context.uiControlConfig, fallbackTaskData 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..41c966b03f4 100644 --- a/packages/@webex/contact-center/src/services/task/voice/Voice.ts +++ b/packages/@webex/contact-center/src/services/task/voice/Voice.ts @@ -25,7 +25,7 @@ import Task from '../Task'; import LoggerProxy from '../../../logger-proxy'; import MetricsManager from '../../../metrics/MetricsManager'; import {METRIC_EVENT_NAMES} from '../../../metrics/constants'; -import {TaskState, TaskEvent} from '../state-machine'; +import {TaskState, TaskEvent, type TaskContext} from '../state-machine'; import {WrapupData} from '../../config/types'; import {getConsultMediaResourceId, getIsConferenceInProgress} from '../TaskUtils'; @@ -121,7 +121,7 @@ export default class Voice extends Task implements IVoice { If the media resource is not found, default to resuming the task */ const snapshot = this.getStateMachineSnapshot(); - const snapshotState = snapshot?.value as TaskState | undefined; + const effectiveState = this.getEffectiveVoiceTaskStateForOperation(); const mainInteractionId = this.data.interaction?.mainInteractionId || this.data.interactionId; const mainMediaResource = this.data.interaction?.media?.[mainInteractionId]?.mediaResourceId || @@ -130,18 +130,17 @@ export default class Voice extends Task implements IVoice { this.data.interaction?.media?.[mainInteractionId]?.isHold ?? this.data.interaction.media?.[mainMediaResource]?.isHold; let shouldHold = !(mediaHoldState ?? false); - if (snapshotState === TaskState.HELD) { + if (effectiveState === TaskState.HELD) { shouldHold = false; - } else if (snapshotState === TaskState.CONNECTED) { + } else if (effectiveState === TaskState.CONNECTED) { shouldHold = true; } - // Validate operation is allowed in current state - const state = snapshot; - if (state) { - const currentState = state.value as TaskState; + // Validate operation is allowed in current state (effective state when machine lags IDLE) + if (snapshot) { + const currentState = effectiveState; if (shouldHold) { - if (!state.matches(TaskState.CONNECTED)) { + if (currentState !== TaskState.CONNECTED) { const error = new Error(`Cannot hold call in current state: ${currentState}`); LoggerProxy.error('Hold operation not allowed', { module: CC_FILE, @@ -150,7 +149,7 @@ export default class Voice extends Task implements IVoice { }); throw error; } - } else if (!state.matches(TaskState.HELD)) { + } else if (currentState !== TaskState.HELD) { const error = new Error(`Cannot resume call in current state: ${currentState}`); LoggerProxy.error('Resume operation not allowed', { module: CC_FILE, @@ -287,10 +286,8 @@ export default class Voice extends Task implements IVoice { // Validate recording is active const state = this.getStateMachineSnapshot(); if (state) { - const {recordingControlsAvailable, recordingInProgress} = state.context as { - recordingControlsAvailable?: boolean; - recordingInProgress?: boolean; - }; + const {recordingControlsAvailable, recordingInProgress} = + this.mergeRecordingContextFromTaskData(state.context as TaskContext); const recordingActive = Boolean(recordingControlsAvailable && recordingInProgress); if (!recordingActive) { const error = new Error('Recording is not active or already paused'); @@ -361,10 +358,8 @@ export default class Voice extends Task implements IVoice { // Validate recording is paused const state = this.getStateMachineSnapshot(); if (state) { - const {recordingControlsAvailable, recordingInProgress} = state.context as { - recordingControlsAvailable?: boolean; - recordingInProgress?: boolean; - }; + const {recordingControlsAvailable, recordingInProgress} = + this.mergeRecordingContextFromTaskData(state.context as TaskContext); const recordingPaused = Boolean(recordingControlsAvailable && !recordingInProgress); if (!recordingPaused) { const error = new Error('Recording is not paused'); @@ -439,17 +434,15 @@ export default class Voice extends Task implements IVoice { * ``` * */ public async consult(consultPayload?: ConsultPayload): Promise { - // Validate consult is allowed - const state = this.getStateMachineSnapshot(); + // Validate consult is allowed (use effective state when machine lags IDLE after transfer) + const effectiveState = this.getEffectiveVoiceTaskStateForOperation(); const canConsult = - state && - (state.matches(TaskState.CONNECTED) || - state.matches(TaskState.HELD) || - state.matches(TaskState.CONFERENCING)); + effectiveState === TaskState.CONNECTED || + effectiveState === TaskState.HELD || + effectiveState === TaskState.CONFERENCING; if (!canConsult) { - const currentState = state?.value as TaskState; - const error = new Error(`Cannot initiate consult in ${currentState} state`); + const error = new Error(`Cannot initiate consult in ${effectiveState} state`); LoggerProxy.error('Consult operation not allowed', { module: CC_FILE, method: 'consult', diff --git a/packages/@webex/contact-center/test/unit/spec/services/task/TaskUtils.ts b/packages/@webex/contact-center/test/unit/spec/services/task/TaskUtils.ts index 3db331cb0b2..4f4b1263ba3 100644 --- a/packages/@webex/contact-center/test/unit/spec/services/task/TaskUtils.ts +++ b/packages/@webex/contact-center/test/unit/spec/services/task/TaskUtils.ts @@ -12,6 +12,7 @@ import { getConferenceParticipantsCount, isSecondaryAgent, isSecondaryEpDnAgent, + isSecondaryEpDnConsultedRecipient, getConsultMediaResourceId, } from '../../../../../src/services/task/TaskUtils'; import {ITask, Interaction, TaskData} from '../../../../../src/services/task/types'; @@ -470,6 +471,22 @@ describe('TaskUtils', () => { interaction.callProcessingDetails = {relationshipType: 'consult', parentInteractionId: 'parent-456'}; expect(isSecondaryEpDnAgent(interaction)).toBe(true); }); + + it('isSecondaryEpDnConsultedRecipient is false when owner is self (post consult transfer)', () => { + const interaction = createInteraction(); + interaction.mediaType = 'telephony'; + interaction.owner = 'agent-self'; + interaction.callProcessingDetails = {relationshipType: 'consult', parentInteractionId: 'parent-456'}; + expect(isSecondaryEpDnConsultedRecipient(interaction, 'agent-self')).toBe(false); + }); + + it('isSecondaryEpDnConsultedRecipient is true for secondary EP-DN when owner is not self', () => { + const interaction = createInteraction(); + interaction.mediaType = 'telephony'; + interaction.owner = 'other-agent'; + interaction.callProcessingDetails = {relationshipType: 'consult', parentInteractionId: 'parent-456'}; + expect(isSecondaryEpDnConsultedRecipient(interaction, 'agent-self')).toBe(true); + }); }); describe('getConsultMediaResourceId', () => { 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..bfd75a680f0 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 @@ -12,6 +12,7 @@ const createConfig = () => ({ isEndConsultEnabled: true, voiceVariant: 'pstn' as const, isRecordingEnabled: true, + agentId: 'agent-1', }); describe('Task state machine', () => { @@ -157,6 +158,18 @@ describe('Task state machine', () => { service.send({type: TaskEvent.WRAPUP_COMPLETE}); expect(service.getSnapshot().value).toBe(TaskState.COMPLETED); }); + + it('completes from non-wrapup states when WRAPUP_COMPLETE arrives', () => { + const service = startMachine(); + const taskData = createTaskData(); + + service.send({type: TaskEvent.TASK_INCOMING, taskData}); + service.send({type: TaskEvent.ASSIGN, taskData}); + expect(service.getSnapshot().value).toBe(TaskState.CONNECTED); + + service.send({type: TaskEvent.WRAPUP_COMPLETE, taskData}); + expect(service.getSnapshot().value).toBe(TaskState.COMPLETED); + }); }); describe('consult and conference flows', () => { @@ -181,6 +194,46 @@ describe('Task state machine', () => { expect(service.getSnapshot().context.consultDestinationAgentJoined).toBe(true); }); + it('HYDRATE sets consultDestinationAgentJoined when consult media has a remote party without isConsulted (EP-DN)', () => { + const service = startMachine(); + const hydratedTask = createTaskData({ + agentId: 'agent-1', + consultingAgentId: 'agent-1', + isConsulted: false, + consultMediaResourceId: 'consult-media-1', + interaction: { + state: 'consulting', + interactionId: 'interaction-1', + mainInteractionId: 'interaction-1', + participants: { + 'agent-1': {id: 'agent-1', pType: 'AGENT', hasLeft: false}, + 'customer-1': {id: 'customer-1', pType: 'CUSTOMER', hasLeft: false}, + 'ep-dn-1': {id: 'ep-dn-1', pType: 'CUSTOMER', hasLeft: false}, + } as any, + media: { + 'interaction-1': { + mediaResourceId: 'interaction-1', + isHold: true, + mType: 'mainCall', + participants: ['agent-1', 'customer-1'], + }, + 'consult-media-1': { + mediaResourceId: 'consult-media-1', + mType: 'consult', + isHold: false, + participants: ['agent-1', 'ep-dn-1'], + }, + } as any, + } as any, + }); + + service.send({type: TaskEvent.HYDRATE, taskData: hydratedTask}); + + const snapshot = service.getSnapshot(); + expect(snapshot.value).toBe(TaskState.CONSULTING); + expect(snapshot.context.consultDestinationAgentJoined).toBe(true); + }); + it('tracks consult destination, agent join, and clears on consult end', () => { const service = startMachine(); const taskData = createTaskData(); diff --git a/packages/@webex/contact-center/test/unit/spec/services/task/state-machine/effectiveVoiceTaskState.ts b/packages/@webex/contact-center/test/unit/spec/services/task/state-machine/effectiveVoiceTaskState.ts new file mode 100644 index 00000000000..c35c31e4d9c --- /dev/null +++ b/packages/@webex/contact-center/test/unit/spec/services/task/state-machine/effectiveVoiceTaskState.ts @@ -0,0 +1,56 @@ +import {TaskState} from '../../../../../../src/services/task/state-machine/constants'; +import {resolveEffectiveVoiceTaskState} from '../../../../../../src/services/task/state-machine/effectiveVoiceTaskState'; +import {createTaskData} from '../taskTestUtils'; + +describe('resolveEffectiveVoiceTaskState', () => { + it('forces WRAPPING_UP when wrapUpRequired is true after transfer', () => { + const taskData = createTaskData({ + agentId: 'agent-1', + wrapUpRequired: true, + interaction: { + state: 'connected', + participants: { + 'agent-1': {id: 'agent-1', pType: 'Agent', hasLeft: false}, + 'customer-1': {id: 'customer-1', pType: 'Customer', hasLeft: true}, + }, + } as any, + }); + + const effective = resolveEffectiveVoiceTaskState(TaskState.CONNECTED, taskData); + + expect(effective).toBe(TaskState.WRAPPING_UP); + }); + + it('forces WRAPPING_UP when current agent is listed in agentsPendingWrapUp', () => { + const taskData = createTaskData({ + agentId: 'agent-2', + wrapUpRequired: false, + agentsPendingWrapUp: ['agent-2'], + interaction: { + state: 'connected', + } as any, + }); + + const effective = resolveEffectiveVoiceTaskState(TaskState.IDLE, taskData); + + expect(effective).toBe(TaskState.WRAPPING_UP); + }); + + it('keeps machine state when transfer wrap-up markers do not apply to current agent', () => { + const taskData = createTaskData({ + agentId: 'agent-1', + wrapUpRequired: false, + agentsPendingWrapUp: ['agent-2'], + interaction: { + state: 'connected', + participants: { + 'agent-1': {id: 'agent-1', pType: 'Agent', hasLeft: false, isWrapUp: false}, + }, + } as any, + }); + + const effective = resolveEffectiveVoiceTaskState(TaskState.CONNECTED, taskData); + + expect(effective).toBe(TaskState.CONNECTED); + }); +}); 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..c1b1f4fe8df 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 @@ -144,4 +144,231 @@ describe('uiControlsComputer consult initiator controls', () => { expect(uiControls.main.wrapup).toEqual({isVisible: true, isEnabled: true}); expect(uiControls.consult).toEqual(getDefaultUIControls().consult); }); + + it('derives consult leg from isConsultInProgress when consult media is unavailable', () => { + const consultFlagOnlyTaskData = createTaskData({ + agentId: 'agent-1', + mediaResourceId: 'interaction-1', + consultMediaResourceId: undefined, + consultingAgentId: 'agent-1', + isConsultInProgress: true, + interaction: { + interactionId: 'interaction-1', + mainInteractionId: 'interaction-1', + participants: { + 'agent-1': {id: 'agent-1', pType: 'AGENT', hasLeft: false}, + 'customer-1': {id: 'customer-1', pType: 'CUSTOMER', hasLeft: false}, + } as any, + media: { + 'interaction-1': { + mediaResourceId: 'interaction-1', + isHold: false, + participants: ['agent-1', 'customer-1'], + }, + } as any, + } as any, + }); + const context = createVoiceContext({ + taskData: consultFlagOnlyTaskData, + consultDestinationAgentJoined: false, + }); + + const uiControls = computeUIControls(TaskState.CONNECTED, context, context.taskData); + + expect(uiControls.activeLeg).toBe('consult'); + expect(uiControls.consult.endConsult).toEqual({isVisible: true, isEnabled: true}); + }); + + it('keeps parked main transfer/conference/end visible-disabled when consult media is absent (EP-DN)', () => { + const consultFlagOnlyTaskData = createTaskData({ + agentId: 'agent-1', + mediaResourceId: 'interaction-1', + consultMediaResourceId: undefined, + consultingAgentId: 'agent-1', + isConsultInProgress: true, + interaction: { + interactionId: 'interaction-1', + mainInteractionId: 'interaction-1', + participants: { + 'agent-1': {id: 'agent-1', pType: 'AGENT', hasLeft: false}, + 'customer-1': {id: 'customer-1', pType: 'CUSTOMER', hasLeft: false}, + } as any, + media: { + 'interaction-1': { + mediaResourceId: 'interaction-1', + isHold: false, + participants: ['agent-1', 'customer-1'], + }, + } as any, + } as any, + }); + const context = createVoiceContext({ + taskData: consultFlagOnlyTaskData, + consultDestinationAgentJoined: true, + }); + + const uiControls = computeUIControls(TaskState.CONSULTING, context, context.taskData); + + expect(uiControls.activeLeg).toBe('consult'); + expect(uiControls.main.transfer).toEqual({isVisible: true, isEnabled: false}); + expect(uiControls.main.conference).toEqual({isVisible: true, isEnabled: false}); + expect(uiControls.main.mergeToConference).toEqual({isVisible: true, isEnabled: false}); + expect(uiControls.main.end).toEqual({isVisible: true, isEnabled: false}); + expect(uiControls.consult.transfer).toEqual({isVisible: true, isEnabled: true}); + }); + + it('enables main switch, transfer, and conference after switch when hasParallelConsultLeg is false', () => { + const consultFlagOnlyTaskData = createTaskData({ + agentId: 'agent-1', + mediaResourceId: 'interaction-1', + consultMediaResourceId: undefined, + consultingAgentId: 'agent-1', + isConsultInProgress: true, + interaction: { + state: 'consulting', + interactionId: 'interaction-1', + mainInteractionId: 'interaction-1', + participants: { + 'agent-1': {id: 'agent-1', pType: 'AGENT', hasLeft: false}, + 'customer-1': {id: 'customer-1', pType: 'CUSTOMER', hasLeft: false}, + } as any, + media: { + 'interaction-1': { + mediaResourceId: 'interaction-1', + isHold: false, + participants: ['agent-1', 'customer-1'], + }, + } as any, + } as any, + }); + const context = createVoiceContext({ + taskData: consultFlagOnlyTaskData, + consultDestinationAgentJoined: true, + consultCallHeld: true, + }); + + const uiControls = computeUIControls(TaskState.CONSULTING, context, context.taskData); + + expect(uiControls.activeLeg).toBe('main'); + expect(uiControls.main.switch).toEqual({isVisible: true, isEnabled: true}); + expect(uiControls.main.transfer).toEqual({isVisible: true, isEnabled: true}); + expect(uiControls.main.conference).toEqual({isVisible: true, isEnabled: true}); + expect(uiControls.main.mergeToConference).toEqual({isVisible: true, isEnabled: true}); + }); + + it('shows only endConsult for consult relationship assignment payloads', () => { + const consultRelationshipTaskData = createTaskData({ + agentId: 'agent-1', + isConsulted: false, + consultMediaResourceId: undefined, + interaction: { + interactionId: 'interaction-1', + mainInteractionId: 'main-interaction-1', + state: 'connected', + callProcessingDetails: { + relationshipType: 'consult', + parentInteractionId: 'main-interaction-1', + hasCustomerLeft: 'true', + }, + participants: { + 'agent-1': { + id: 'agent-1', + pType: 'AGENT', + hasLeft: false, + currentState: 'post_call', + isConsulted: false, + }, + 'customer-1': { + id: 'customer-1', + pType: 'CUSTOMER', + hasLeft: false, + }, + } as any, + media: { + 'interaction-1': { + mediaResourceId: 'interaction-1', + isHold: false, + mType: 'mainCall', + participants: ['agent-1', 'customer-1'], + }, + } as any, + } as any, + }); + const context = createVoiceContext({ + taskData: consultRelationshipTaskData, + consultInitiator: false, + consultDestinationAgentJoined: false, + consultCallHeld: false, + }); + + const uiControls = computeUIControls(TaskState.CONNECTED, context, context.taskData); + const controls = uiControls.main; + + expect(controls.endConsult).toEqual({isVisible: true, isEnabled: true}); + expect(controls.accept).toEqual({isVisible: false, isEnabled: false}); + expect(controls.decline).toEqual({isVisible: false, isEnabled: false}); + expect(controls.hold).toEqual({isVisible: false, isEnabled: false}); + expect(controls.mute).toEqual({isVisible: false, isEnabled: false}); + expect(controls.end).toEqual({isVisible: false, isEnabled: false}); + expect(controls.transfer).toEqual({isVisible: false, isEnabled: false}); + expect(controls.consult).toEqual({isVisible: false, isEnabled: false}); + expect(controls.conference).toEqual({isVisible: false, isEnabled: false}); + expect(controls.switch).toEqual({isVisible: false, isEnabled: false}); + expect(controls.wrapup).toEqual({isVisible: false, isEnabled: false}); + expect(controls.recording).toEqual({isVisible: false, isEnabled: false}); + expect(controls.exitConference).toEqual({isVisible: false, isEnabled: false}); + expect(controls.transferConference).toEqual({isVisible: false, isEnabled: false}); + expect(controls.mergeToConference).toEqual({isVisible: false, isEnabled: false}); + }); + + it('full controls for new primary owner when stale relationshipType consult remains on main call', () => { + const transferredOwnerTask = createTaskData({ + agentId: 'agent-2', + isConsulted: false, + consultMediaResourceId: undefined, + interaction: { + interactionId: 'main-1', + mainInteractionId: 'main-1', + state: 'connected', + owner: 'agent-2', + callProcessingDetails: { + relationshipType: 'consult', + parentInteractionId: 'main-1', + }, + participants: { + 'agent-2': {id: 'agent-2', pType: 'AGENT', hasLeft: false}, + 'customer-1': {id: 'customer-1', pType: 'CUSTOMER', hasLeft: false}, + } as any, + media: { + 'main-1': { + mediaResourceId: 'main-1', + isHold: false, + participants: ['agent-2', 'customer-1'], + }, + } as any, + } as any, + }); + const base = createVoiceContext({ + taskData: transferredOwnerTask, + consultInitiator: false, + consultDestinationAgentJoined: false, + consultCallHeld: false, + }); + const context: TaskContext = { + ...base, + uiControlConfig: { + ...base.uiControlConfig, + agentId: 'agent-2', + }, + }; + + const uiControls = computeUIControls(TaskState.CONNECTED, context, context.taskData); + const {main} = uiControls; + + expect(main.hold).toEqual({isVisible: true, isEnabled: true}); + expect(main.consult).toEqual({isVisible: true, isEnabled: true}); + expect(main.transfer).toEqual({isVisible: true, isEnabled: true}); + expect(main.recording).toEqual({isVisible: true, isEnabled: true}); + expect(main.end).toEqual({isVisible: true, isEnabled: true}); + }); });