diff --git a/packages/contact-center/ai-docs/migration/migration-overview.md b/packages/contact-center/ai-docs/migration/migration-overview.md index 833cfacf4..66b8686ea 100644 --- a/packages/contact-center/ai-docs/migration/migration-overview.md +++ b/packages/contact-center/ai-docs/migration/migration-overview.md @@ -269,5 +269,52 @@ const controls = currentTask?.uiControls ?? getDefaultUIControls(); --- +### 2026-06-04 — Consult button disabled after consult ends/fails before consultee answers (multi-login) + +**Issue:** On the initiating agent (Agent 1), after a consult to Agent 2 ends or fails before Agent 2 answers (RONA `AgentConsultFailed`, or `AgentConsultEnded` from either Stable Prod or Task Refactor), the held main leg showed `main.consult` visible but disabled, blocking a new consult. + +**Root cause:** +- `CONSULT_END`/`CONSULT_FAILED` were not wired on all states the initiator can be in when the consult ends externally (`HELD`, `CONNECTED`, `CONSULT_INITIATING`), so `clearConsultState`/`handleConsultFailed` did not run and consult context flags stayed stale. +- `getIsConsultInProgressForConferenceControls` treated a RONA consultee (`isConsulted: true`, `consultState: consultReserved`, `hasJoined: false`) as an active consult, keeping `consultInProgress` true. + +**Fix (SDK, kesari-aligned at the wiring/action layers):** +- `TaskStateMachine.ts`: wire `CONSULT_END` on `HELD` / `CONNECTED` / `CONSULT_INITIATING` and `CONSULT_FAILED` at root, reusing existing `clearConsultState` / `handleConsultFailed` actions and `isPrimaryMediaOnHold` guard. +- `actions.ts`: `deriveTaskDataUpdates` clears consult flags on terminal events; `clearConsultState` recomputes `uiControls`. +- `TaskUtils.ts`: `getIsConsultInProgressForConferenceControls` ignores RONA-pending consultees (`consultReserved` + `!hasJoined`). +- Tests: `uiControlsComputer.ts`, `TaskStateMachine.ts`, `TaskUtils.ts` (149 passing). Widgets consume `task.uiControls.main.consult` — no widget code change. + +**Known deviation / planned follow-up (kesari principle 3 & 4):** `uiControlsComputer.ts` currently re-derives consult-ended state from `taskData` (`isConsultEndedForSelf` + `effectiveConsult*` shadow flags, the `isConsultUnansweredFailure` early-return, the consult retry path, and `getVoiceLegState` main-state inference). This duplicates flag-clearing already done in `actions.ts` and is UI-layer inference the principles discourage. Follow-up: remove these blocks and let `uiControlsComputer` read only the cleared context flags + `state`, relying on the existing `canFromConnected` path; keep all current tests green to confirm behavior is unchanged. The 4th touched SDK file (`TaskUtils.ts`) exceeds the 3-file guidance but is a shared selector, so the location is justified. + +#### 2026-06-04 (follow-up) — Same button still disabled live, but enabled after refresh: stale consult media in merged `task.data` + +**Symptom refinement:** After the wiring/action fixes above, the live consult button was still disabled when Agent 1 ended the consult before Agent 2 answered, yet a page refresh enabled it. A runtime diagnostic marker proved the fresh SDK was loaded and that `Task.computeUIControls()` (not just the state-machine action path) produces the final `uiControls` the widget consumes — computed with `this.data`, not the raw event payload. + +**Root cause (data layer):** `Task.reconcileData` (`Task.ts`) is a recursive deep-merge that **never deletes keys**. `AgentConsultCreated` adds the consult-media entry (and consultee participant) into `task.data.interaction.media`/`participants`. The subsequent `AgentConsultEnded` payload contains only the main media, but the merge **retains** the stale consult-media key. `computeUIControls` then sees `hasConsultMedia === true` and the consult-enable branches (`isConsultUnansweredFailure`, the held-main consult retry path) — both gated on `!hasConsultMedia` — fail, leaving consult disabled. Refresh works because hydration overwrites `this.data` cleanly (no stale consult media). + +**Fix (SDK, minimal, scoped to consult-ended):** In `uiControlsComputer.ts`, added `effectiveHasConsultMedia = isConsultEndedForSelf ? false : hasConsultMedia` (mirrors the existing `effectiveConsult*` shadow-flag pattern) and used it in the two consult-enable branches. A consult that has ended for self no longer counts its lingering media as active. No change to `reconcileData` (kesari principle 3 — avoid new/changed task-data merge layers and broad blast radius). + +**Tests:** `uiControlsComputer.ts` now has a regression test that feeds the **reconciled** `this.data` (stale consult media + stale consultee participant retained) and asserts `main.consult` enabled (43 passing). The earlier end-before-answer test used the clean payload and therefore did not catch this. + +**Note on kesari adherence:** This fix extends the same `uiControlsComputer` `taskData`-inference deviation already flagged above (it remains UI-layer compensation for a data-layer merge quirk). The architecturally clean fix is to make `reconcileData` treat the backend `interaction.media`/`participants` snapshot as authoritative (drop removed keys), which would let the `effectiveHasConsultMedia`/`isConsultUnansweredFailure` compensations be deleted — folded into the existing planned follow-up. + +#### 2026-06-04 (root-cause fix) — Stale consult media/participant persists across subsequent events (consult disabled after resume) + +**Symptom:** With the UI-layer gate above, consult was correctly enabled on the held main leg right after the consult ended. But on the **next** event — Agent 1 resumes the call (`AgentContactUnheld`, a clean snapshot: main media only, `consultState: null`) — the consult button went disabled again. The UI gate (`effectiveHasConsultMedia`) only applies on the consult-end/fail tick (`isConsultEndedForSelf`), so it no longer fired, while the stale consult media still lingered in `this.data`. + +**Root cause (the real one, data layer):** `Task.reconcileData` deep-merges and never deletes keys. `interaction.media` and `interaction.participants` are **complete snapshots** from the backend, but the merge keeps consult-leg entries the backend already dropped. So every event after the consult ends still sees a stale consult-media key → `hasConsultMedia === true` → consult blocked. Refresh worked only because hydration overwrites `this.data` cleanly. + +**Fix (SDK, `Task.ts`, scoped):** Added `pruneStaleInteractionMaps(incoming)`, called from `updateTaskData` only on the merge path (`shouldOverwrite === false`). It makes **only** `interaction.media` and `interaction.participants` authoritative to the incoming payload (removes keys absent from the incoming snapshot **when** that snapshot provides the map). Every other field stays on the existing generic deep-merge (CAD and other partial updates merge exactly as before — covered by a test). This removes the whack-a-mole: the stale consult leg is gone for resume, transfer, next-consult, and all later events. + +**Why both fixes stay:** `pruneStaleInteractionMaps` handles events where the backend already dropped the consult media; `effectiveHasConsultMedia` still handles the consult-end/fail tick where the payload itself can still carry consult media (e.g. `AgentConsultFailed` RONA). They are complementary, not redundant. + +**Tests:** `Task.ts` gains two tests — (1) stale consult media + consultee participant are pruned on a clean resume snapshot while CAD merge is preserved; (2) a partial update that omits the interaction maps does **not** prune them. Full task suite green: `Task`, `uiControlsComputer`, `TaskStateMachine`, `TaskUtils`, `TaskManager`, `Voice` — **285 passing**. + +**Kesari note:** Fixing the existing `reconcileData` snapshot semantics (rather than adding UI heuristics) is the correct, root-cause layer. It is scoped to the two snapshot maps to avoid disturbing the intended partial-merge behavior, and it is the change that makes the earlier `uiControlsComputer` compensations eventually removable (still tracked as the planned follow-up). + +--- + _Created: 2026-03-09_ _Updated: 2026-05-20 (migration complete reference; per-leg TaskUIControls; SDK→store→widgets flow; outdial fix log; popup model)_ +_Updated: 2026-06-04 (consult-button-disabled-after-end-before-answer fix log + kesari deviation follow-up note)_ +_Updated: 2026-06-04 (stale-consult-media-in-reconciled-task-data root cause + effectiveHasConsultMedia fix + regression test)_ +_Updated: 2026-06-04 (root-cause data-layer fix: Task.pruneStaleInteractionMaps makes interaction.media/participants authoritative; fixes consult disabled after resume; 285 task tests green)_ diff --git a/packages/contact-center/cc-components/src/components/task/CallControl/call-control.tsx b/packages/contact-center/cc-components/src/components/task/CallControl/call-control.tsx index 41501e7ad..b5729290b 100644 --- a/packages/contact-center/cc-components/src/components/task/CallControl/call-control.tsx +++ b/packages/contact-center/cc-components/src/components/task/CallControl/call-control.tsx @@ -22,6 +22,7 @@ import { isTelephonyMediaType, buildCallControlButtons, filterButtonsForConsultation, + getConsultFilterPhase, updateCallStateFromTask, } from './call-control.utils'; import {withMetrics} from '@webex/cc-ui-logging'; @@ -144,8 +145,8 @@ function CallControlComponent(props: CallControlComponentProps) { conferenceEnabled ); - const isConsulting = (controls?.consult?.endConsult?.isVisible || controls?.main?.endConsult?.isVisible) ?? false; - const filteredButtons = filterButtonsForConsultation(buttons, isConsulting, isTelephony, logger); + const consultFilterPhase = getConsultFilterPhase(currentTask, controls); + const filteredButtons = filterButtonsForConsultation(buttons, consultFilterPhase, isTelephony, logger); if (!currentTask) return null; diff --git a/packages/contact-center/cc-components/src/components/task/CallControl/call-control.utils.ts b/packages/contact-center/cc-components/src/components/task/CallControl/call-control.utils.ts index 2edf7951a..592e2f8ab 100644 --- a/packages/contact-center/cc-components/src/components/task/CallControl/call-control.utils.ts +++ b/packages/contact-center/cc-components/src/components/task/CallControl/call-control.utils.ts @@ -210,6 +210,12 @@ export const buildCallControlButtons = ( const isTransferVisible = mainCtrl?.transfer?.isVisible ?? false; const isTransferEnabled = mainCtrl?.transfer?.isEnabled ?? false; const isConsulting = (controls?.consult?.endConsult?.isVisible || controls?.main?.endConsult?.isVisible) ?? false; + // Consult requested: main leg shows transfer menu only (no hold/resume, no transferConsult duplicate). + const isConsultPendingOnMain = + isConsulting && + isTransferVisible && + (controls?.consult?.switch?.isVisible ?? false) && + !(controls?.consult?.switch?.isEnabled ?? false); const shouldPrioritizeTransferConference = isTransferConferenceVisible; return [ { @@ -240,7 +246,7 @@ export const buildCallControlButtons = ( tooltip: isHeld ? RESUME_CALL : HOLD_CALL, className: 'call-control-button', disabled: !(mainCtrl?.hold?.isEnabled ?? false), - isVisible: mainCtrl?.hold?.isVisible ?? false, + isVisible: (mainCtrl?.hold?.isVisible ?? false) && !isConsulting, dataTestId: 'call-control:hold-toggle', }, { @@ -260,7 +266,11 @@ export const buildCallControlButtons = ( onClick: onTransferConsult || (() => {}), className: 'call-control-button', disabled: shouldPrioritizeTransferConference ? !isTransferConferenceEnabled : !isTransferEnabled, - isVisible: (isTransferVisible || shouldPrioritizeTransferConference) && isConsulting && !!onTransferConsult, + isVisible: + (isTransferVisible || shouldPrioritizeTransferConference) && + isConsulting && + !isConsultPendingOnMain && + !!onTransferConsult, }, { id: 'conference', @@ -324,25 +334,63 @@ export const buildCallControlButtons = ( } }; +export type ConsultFilterPhase = 'none' | 'pending' | 'active'; + +/** + * Distinguishes consult-requested (destination not joined) from active consult. + * Do not use endConsult visibility alone — it spans both phases. + */ +export const getConsultFilterPhase = ( + currentTask: ITask | null | undefined, + controls: TaskUIControls | undefined +): ConsultFilterPhase => { + const endConsultVisible = controls?.consult?.endConsult?.isVisible || controls?.main?.endConsult?.isVisible; + if (!endConsultVisible) { + return 'none'; + } + + const agentId = currentTask?.data?.agentId; + const selfConsultState = agentId && currentTask?.data?.interaction?.participants?.[agentId]?.consultState; + + if (currentTask?.data?.consultStatus === 'consultInitiated') { + return 'pending'; + } + if (selfConsultState === 'consultInitiated') { + return 'pending'; + } + + // Pending: main transfer stays visible (disabled) while consult leg controls are not ready. + if ( + controls?.main?.transfer?.isVisible && + controls?.consult?.switch?.isVisible && + !controls?.consult?.switch?.isEnabled + ) { + return 'pending'; + } + + return 'active'; +}; + /** - * Filters buttons based on consultation state - * During consulting: - * - Hide: hold, consult, and blind transfer buttons - * - Respect SDK enabled/disabled state for consulting buttons (transferConsult, conference) - * They will be enabled when on main call, disabled when on consult call - * - Show as-is: mute, switchToConsult, recording, exitConference, end + * Filters buttons based on consultation phase. + * Pending (consult requested): hide hold/consult/record; keep transfer on main (Stable Prod parity). + * Active (destination joined): also hide blind transfer on main. */ export const filterButtonsForConsultation = ( buttons: CallControlButton[], - consultInitiated: boolean, + consultPhase: ConsultFilterPhase, isTelephony: boolean, - logger? + logger?: ILogger ): CallControlButton[] => { try { - if (!consultInitiated || !isTelephony) { + if (!isTelephony || consultPhase === 'none') { return buttons; } + if (consultPhase === 'pending') { + return buttons.filter((button) => !['hold', 'consult', 'record'].includes(button.id)); + } + return buttons.filter((button) => !['hold', 'consult', 'transfer', 'record'].includes(button.id)); } catch (error) { logger?.error('CC-Widgets: CallControl: Error in filterButtonsForConsultation', { @@ -350,7 +398,6 @@ export const filterButtonsForConsultation = ( method: 'filterButtonsForConsultation', error: error.message, }); - // Return original buttons as safe fallback return buttons || []; } }; diff --git a/packages/contact-center/cc-components/src/components/task/CallControlCAD/call-control-cad.tsx b/packages/contact-center/cc-components/src/components/task/CallControlCAD/call-control-cad.tsx index a04f46774..f5f29d1f1 100644 --- a/packages/contact-center/cc-components/src/components/task/CallControlCAD/call-control-cad.tsx +++ b/packages/contact-center/cc-components/src/components/task/CallControlCAD/call-control-cad.tsx @@ -20,6 +20,7 @@ import { CUSTOMER_NAME, } from '../constants'; import {withMetrics} from '@webex/cc-ui-logging'; +import {isSecondaryAgent} from '@webex/cc-store'; const CallControlCADComponent: React.FC = (props) => { const { @@ -63,7 +64,14 @@ const CallControlCADComponent: React.FC = (props) => const isTelephony = mediaChannel === MediaChannelType.TELEPHONY; const participantsCount = conferenceParticipants?.length || 1; const participantsLabel = participantsCount === 1 ? 'Participant' : 'Participants'; - const shouldShowParticipantsList = (conferenceParticipants?.length || 0) > 1; + const interactionState = currentTask?.data?.interaction?.state; + const isConferenceActive = + controls?.main?.exitConference?.isVisible || + currentTask?.data?.isConferenceInProgress === true || + interactionState === 'conference'; + const isConsultOnlyAgent = isSecondaryAgent(currentTask); + const shouldShowParticipantsList = + isConferenceActive && !isConsultOnlyAgent && (conferenceParticipants?.length ?? 0) > 0; const customerName = currentTask?.data?.interaction?.callAssociatedDetails?.customerName; diff --git a/packages/contact-center/cc-components/tests/components/task/CallControl/call-control.utils.tsx b/packages/contact-center/cc-components/tests/components/task/CallControl/call-control.utils.tsx index 83f4b3109..2142c19e8 100644 --- a/packages/contact-center/cc-components/tests/components/task/CallControl/call-control.utils.tsx +++ b/packages/contact-center/cc-components/tests/components/task/CallControl/call-control.utils.tsx @@ -11,6 +11,7 @@ import { isTelephonyMediaType, buildCallControlButtons, filterButtonsForConsultation, + getConsultFilterPhase, updateCallStateFromTask, handleCloseButtonPress, handleWrapupReasonChange, @@ -689,6 +690,47 @@ describe('CallControl Utils', () => { expect(muteButton?.disabled).toBe(true); }); + it('should hide hold and transferConsult but keep transfer menu during consult requested', () => { + const consultRequestedControls = { + activeLeg: 'consult', + main: { + hold: {isVisible: true, isEnabled: true}, + transfer: {isVisible: true, isEnabled: false}, + conference: {isVisible: true, isEnabled: false}, + end: {isVisible: true, isEnabled: false}, + }, + consult: { + endConsult: {isVisible: true, isEnabled: true}, + switch: {isVisible: true, isEnabled: false}, + }, + }; + + const buttons = buildCallControlButtons( + false, + false, + false, + mockMediaTypeInfo, + consultRequestedControls as never, + true, + mockFunctions.handleMuteToggleFunc, + mockFunctions.handleToggleHoldFunc, + mockFunctions.toggleRecording, + mockFunctions.endCall, + mockFunctions.exitConference, + mockFunctions.switchToConsult, + jest.fn(), + jest.fn() + ); + + const holdButton = buttons.find((b) => b.id === 'hold'); + const transferButton = buttons.find((b) => b.id === 'transfer'); + const transferConsultButton = buttons.find((b) => b.id === 'transferConsult'); + + expect(holdButton?.isVisible).toBe(false); + expect(transferButton?.isVisible).toBe(true); + expect(transferConsultButton?.isVisible).toBe(false); + }); + it('should prioritize transferConference over transfer on main leg', () => { const nestedControls = { activeLeg: 'main', @@ -759,6 +801,54 @@ describe('CallControl Utils', () => { }); }); + describe('getConsultFilterPhase', () => { + it('returns none when endConsult is not visible', () => { + expect(getConsultFilterPhase(null, {main: {}, consult: {}} as never)).toBe('none'); + }); + + it('returns pending when self participant is consultInitiated', () => { + const task = { + data: { + agentId: 'agent-1', + interaction: { + participants: { + 'agent-1': {consultState: 'consultInitiated'}, + }, + }, + }, + } as never; + + expect( + getConsultFilterPhase(task, { + consult: {endConsult: {isVisible: true, isEnabled: true}}, + } as never) + ).toBe('pending'); + }); + + it('returns active when consult destination has joined controls', () => { + const task = { + data: { + agentId: 'agent-1', + interaction: { + participants: { + 'agent-1': {consultState: 'consulting'}, + }, + }, + }, + } as never; + + expect( + getConsultFilterPhase(task, { + main: {transfer: {isVisible: true, isEnabled: true}}, + consult: { + endConsult: {isVisible: true, isEnabled: true}, + switch: {isVisible: true, isEnabled: true}, + }, + } as never) + ).toBe('active'); + }); + }); + describe('filterButtonsForConsultation', () => { const mockButtons = [ {id: 'mute', icon: '', tooltip: '', className: '', disabled: false, isVisible: true}, @@ -769,29 +859,27 @@ describe('CallControl Utils', () => { {id: 'end', icon: '', tooltip: '', className: '', disabled: false, isVisible: true}, ]; - it('should filter out hold and consult buttons when consultation is initiated and telephony', () => { - const result = filterButtonsForConsultation(mockButtons, true, true); + it('should filter hold and consult but keep transfer during pending consult', () => { + const result = filterButtonsForConsultation(mockButtons, 'pending', true); - expect(result).toHaveLength(2); - expect(result.map((b) => b.id)).toEqual(['mute', 'end']); + expect(result.map((b) => b.id)).toEqual(['mute', 'transfer', 'end']); }); - it('should not filter buttons when consultation is not initiated', () => { - const result = filterButtonsForConsultation(mockButtons, false, true); + it('should filter hold, consult, and transfer during active consult', () => { + const result = filterButtonsForConsultation(mockButtons, 'active', true); - expect(result).toHaveLength(6); - expect(result).toBe(mockButtons); + expect(result.map((b) => b.id)).toEqual(['mute', 'end']); }); - it('should not filter buttons when not telephony', () => { - const result = filterButtonsForConsultation(mockButtons, true, false); + it('should not filter buttons when consult phase is none', () => { + const result = filterButtonsForConsultation(mockButtons, 'none', true); expect(result).toHaveLength(6); expect(result).toBe(mockButtons); }); - it('should not filter buttons when neither consultation initiated nor telephony', () => { - const result = filterButtonsForConsultation(mockButtons, false, false); + it('should not filter buttons when not telephony', () => { + const result = filterButtonsForConsultation(mockButtons, 'active', false); expect(result).toHaveLength(6); expect(result).toBe(mockButtons); diff --git a/packages/contact-center/cc-components/tests/components/task/CallControlCAD/call-control-cad.tsx b/packages/contact-center/cc-components/tests/components/task/CallControlCAD/call-control-cad.tsx index f076fc90f..099cad046 100644 --- a/packages/contact-center/cc-components/tests/components/task/CallControlCAD/call-control-cad.tsx +++ b/packages/contact-center/cc-components/tests/components/task/CallControlCAD/call-control-cad.tsx @@ -381,7 +381,19 @@ describe('CallControlCADComponent', () => { }); describe('conference participants list visibility', () => { - it('shows participants list when there are more than two participants total', () => { + it('shows participants list when conference is active and other agents are present', () => { + const screen = render( + + ); + + expect(screen.getByTestId('call-control:participants-trigger')).toBeInTheDocument(); + }); + + it('hides participants list when exitConference is not visible and conference is not in progress', () => { const screen = render( { /> ); + expect(screen.queryByTestId('call-control:participants-trigger')).not.toBeInTheDocument(); + }); + + it('shows participants list during nested consult when interaction state is conference', () => { + const conferenceTask = { + ...defaultProps.currentTask, + data: { + ...defaultProps.currentTask.data, + isConferenceInProgress: false, + interaction: { + ...defaultProps.currentTask.data.interaction, + state: 'conference', + }, + }, + }; + + const screen = render( + + ); + expect(screen.getByTestId('call-control:participants-trigger')).toBeInTheDocument(); }); - it('hides participants list when two or fewer participants are present in total', () => { + it('hides participants list for consult-only secondary agents even when participants exist', () => { + const consultTask = { + ...defaultProps.currentTask, + data: { + ...defaultProps.currentTask.data, + isConferenceInProgress: false, + interaction: { + ...defaultProps.currentTask.data.interaction, + state: 'consult', + interactionId: 'child-interaction-id', + callProcessingDetails: { + relationshipType: 'consult', + parentInteractionId: 'parent-interaction-id', + }, + }, + }, + }; + const screen = render( + ); + + expect(screen.queryByTestId('call-control:participants-trigger')).not.toBeInTheDocument(); + }); + + it('hides participants list when conference is active but no other agents are present', () => { + const screen = render( + ); diff --git a/packages/contact-center/store/src/task-utils.ts b/packages/contact-center/store/src/task-utils.ts index ad7d9c032..f3dd96ca6 100644 --- a/packages/contact-center/store/src/task-utils.ts +++ b/packages/contact-center/store/src/task-utils.ts @@ -26,13 +26,17 @@ export const isIncomingTask = (task: ITask, agentId: string): boolean => { * @returns {boolean} True if this is a secondary agent (consulted party) */ export function isSecondaryAgent(task: ITask): boolean { - const interaction = task.data.interaction; + const interaction = task?.data?.interaction; + const callProcessingDetails = interaction?.callProcessingDetails; + + if (!callProcessingDetails) { + return false; + } return ( - !!interaction.callProcessingDetails && - interaction.callProcessingDetails.relationshipType === RELATIONSHIP_TYPE_CONSULT && - interaction.callProcessingDetails.parentInteractionId && - interaction.callProcessingDetails.parentInteractionId !== interaction.interactionId + callProcessingDetails.relationshipType === RELATIONSHIP_TYPE_CONSULT && + Boolean(callProcessingDetails.parentInteractionId) && + callProcessingDetails.parentInteractionId !== interaction?.interactionId ); } @@ -41,9 +45,41 @@ export function isSecondaryAgent(task: ITask): boolean { * This is specifically for telephony consultations to external numbers/entry points. */ export function isSecondaryEpDnAgent(task: ITask): boolean { - return task.data.interaction.mediaType === MEDIA_TYPE_TELEPHONY_LOWER && isSecondaryAgent(task); + return task?.data?.interaction?.mediaType === MEDIA_TYPE_TELEPHONY_LOWER && isSecondaryAgent(task); } +const isMainCallMedia = (mType: string | undefined): boolean => mType === 'mainCall' || mType === 'main'; + +/** + * Resolves the main-call media leg for conference participant lookup. + * During nested consult, task.data.interactionId can point at the consult leg; + * conference participants remain on the mainCall media entry. + */ +const getMainCallMediaEntry = (task: ITask) => { + const media = task?.data?.interaction?.media; + if (!media) { + return undefined; + } + + const mainCallMediaId = findMediaResourceId(task, 'mainCall'); + if (mainCallMediaId && media[mainCallMediaId]) { + return media[mainCallMediaId]; + } + + const typedMainCallMedia = Object.values(media).find((entry) => isMainCallMedia(entry.mType)); + if (typedMainCallMedia) { + return typedMainCallMedia; + } + + // Legacy payloads map interactionId directly to main-call media and may omit mType. + const interactionMedia = task.data.interactionId ? media[task.data.interactionId] : undefined; + if (interactionMedia && interactionMedia.mType !== 'consult') { + return interactionMedia; + } + + return undefined; +}; + /** * Retrieves the list of active conference participants excluding the current agent * Filters out customers, supervisors, VVAs, and participants who have left @@ -55,13 +91,24 @@ export function isSecondaryEpDnAgent(task: ITask): boolean { export const getConferenceParticipants = (task: ITask, agentId: string): Participant[] => { const participantsList: Participant[] = []; - // Early return if required data is missing - if (!task?.data?.interaction?.media || !task?.data?.interactionId) { + if (!task?.data?.interaction?.media) { return participantsList; } - const mediaMainCall = task.data.interaction.media?.[task.data.interactionId]; + // Consult-only child tasks (EP-DN) inherit parent data but are not conference members. + if (isSecondaryAgent(task)) { + return participantsList; + } + + const mediaMainCall = getMainCallMediaEntry(task); const participantsInMainCall = new Set(mediaMainCall?.participants ?? []); + + // Nested consult during conference: consulted agent inherits parent interaction state + // and mainCall media, but is only joined on the consult leg — not a conference member. + if (agentId && !participantsInMainCall.has(agentId)) { + return participantsList; + } + const participants = task.data.interaction.participants ?? {}; if (participantsInMainCall.size > 0 && participants) { diff --git a/packages/contact-center/store/tests/task-utils.ts b/packages/contact-center/store/tests/task-utils.ts index fdf5b4a7e..557448c68 100644 --- a/packages/contact-center/store/tests/task-utils.ts +++ b/packages/contact-center/store/tests/task-utils.ts @@ -493,6 +493,112 @@ describe('getConferenceParticipants', () => { name: 'agent2', }); }); + + it('should resolve mainCall media by mType when interactionId points to consult leg', () => { + const mainCallMediaId = '4da1b819-f461-444b-b817-0c983f235cde'; + const consultMediaId = '8d18e6c0-4377-4431-8b9d-ed22cdde94cb'; + + const task = createMockTask({ + interactionId: consultMediaId, + interaction: createPartialInteraction({ + state: 'conference', + media: { + [mainCallMediaId]: { + mType: 'mainCall', + mediaResourceId: mainCallMediaId, + participants: ['agent1', 'agent2', 'customer1'], + }, + [consultMediaId]: { + mType: 'consult', + mediaResourceId: consultMediaId, + participants: ['agent1', 'agent3'], + }, + }, + participants: { + agent1: {id: 'agent1', pType: 'Agent', name: 'Agent One', hasLeft: false}, + agent2: {id: 'agent2', pType: 'Agent', name: 'Agent Two', hasLeft: false}, + agent3: {id: 'agent3', pType: 'Agent', name: 'Agent Three', hasLeft: false}, + customer1: {id: 'customer1', pType: 'Customer', name: 'Customer One', hasLeft: false}, + }, + }), + }); + + const result = getConferenceParticipants(task, currentAgentId); + + expect(result).toEqual([ + { + id: 'agent2', + pType: 'Agent', + name: 'Agent Two', + }, + ]); + }); + + it('should return empty array for consult-only secondary agents not in conference', () => { + const task = createMockTask({ + interactionId: 'child-interaction', + interaction: createPartialInteraction({ + state: 'consult', + interactionId: 'child-interaction', + callProcessingDetails: { + relationshipType: 'consult', + parentInteractionId: 'parent-interaction', + }, + media: { + main: { + mType: 'mainCall', + mediaResourceId: 'main', + participants: ['agent3', 'agent1', 'agent2'], + }, + }, + participants: { + agent1: {id: 'agent1', pType: 'Agent', name: 'Agent One', hasLeft: false}, + agent2: {id: 'agent2', pType: 'Agent', name: 'Agent Two', hasLeft: false}, + agent3: {id: 'agent3', pType: 'Agent', name: 'Agent Three', hasLeft: false}, + }, + }), + }); + + expect(getConferenceParticipants(task, 'agent3')).toEqual([]); + }); + + it('should return empty array for agent-name consulted agent during conference nested consult', () => { + const mainCallMediaId = 'b3629886-1f6b-4de9-b037-8f09667abac8'; + const consultMediaId = '10bd2a1e-fc74-4c2c-af61-4f82de268cf7'; + + const task = createMockTask({ + interactionId: consultMediaId, + interaction: createPartialInteraction({ + state: 'conference', + interactionId: mainCallMediaId, + media: { + [mainCallMediaId]: { + mType: 'mainCall', + mediaResourceId: mainCallMediaId, + participants: ['customer1', 'agent1', 'agent2', 'dn1'], + }, + [consultMediaId]: { + mType: 'consult', + mediaResourceId: consultMediaId, + participants: ['agent3', 'agent1'], + }, + }, + participants: { + agent1: {id: 'agent1', pType: 'Agent', name: 'Agent One', hasLeft: false, isConsulted: false}, + agent2: {id: 'agent2', pType: 'Agent', name: 'Agent Two', hasLeft: false, isConsulted: false}, + agent3: {id: 'agent3', pType: 'Agent', name: 'Agent Three', hasLeft: false, isConsulted: true}, + customer1: {id: 'customer1', pType: 'Customer', name: 'Customer', hasLeft: false}, + dn1: {id: 'dn1', pType: 'DN', name: 'DN', hasLeft: false}, + }, + }), + }); + + expect(getConferenceParticipants(task, 'agent3')).toEqual([]); + expect(getConferenceParticipants(task, 'agent1')).toEqual([ + {id: 'agent2', pType: 'Agent', name: 'Agent Two'}, + {id: 'dn1', pType: 'DN', name: 'DN'}, + ]); + }); }); describe('findHoldTimestamp', () => { diff --git a/packages/contact-center/task/src/Utils/main-cad-hold.util.ts b/packages/contact-center/task/src/Utils/main-cad-hold.util.ts new file mode 100644 index 000000000..26bbf7a34 --- /dev/null +++ b/packages/contact-center/task/src/Utils/main-cad-hold.util.ts @@ -0,0 +1,251 @@ +import {ITask, isInteractionOnHold, isSecondaryAgent, isSecondaryEpDnAgent} from '@webex/cc-store'; +import {Interaction, TaskUIControls} from '@webex/contact-center'; +import {resolveMainCadHoldTimestampMs} from './task-util'; + +type MediaEntry = { + mType?: string; + isHold?: boolean; +}; + +type InteractionParticipant = { + pType?: string; + hasLeft?: boolean; + isConsulted?: boolean; +}; + +type InteractionWithMedia = Interaction & { + media?: Record; + participants?: Record; +}; + +type TaskStateSnapshot = { + type?: string; + interaction?: InteractionWithMedia; +}; + +type TaskWithStateSnapshot = ITask & { + state?: { + context?: { + taskData?: TaskStateSnapshot; + }; + }; +}; + +const getMediaEntries = (media: InteractionWithMedia['media']): MediaEntry[] => + media ? (Object.values(media) as MediaEntry[]) : []; + +const getParticipants = (participants: InteractionWithMedia['participants']): InteractionParticipant[] => + participants ? (Object.values(participants) as InteractionParticipant[]) : []; + +export type MainCadHoldInputs = { + currentTask: ITask | null; + controls: TaskUIControls; + agentId?: string; + holdDataVersion?: number; +}; + +export type MainCadHoldResult = { + isHeld: boolean; + interaction: Interaction | undefined; + holdTimestampMs: number | null; +}; + +/** + * Single source of truth for main CAD "On Hold" chip + timer inputs. + * + * Covers: simple consult (Agent 2), EP/DN consult, conference nested consult, + * plain conference hold, and refresh timer continuity (via resolveMainCadHoldTimestampMs). + */ +export function deriveMainCadHoldState({ + currentTask, + controls, + agentId, + holdDataVersion = 0, +}: MainCadHoldInputs): MainCadHoldResult { + void holdDataVersion; + + if (!currentTask?.data) { + return {isHeld: false, interaction: undefined, holdTimestampMs: null}; + } + + // Prefer the latest state-machine taskData snapshot when available. + // currentTask.data can lag one event behind in conference transitions. + const taskWithSnapshot = currentTask as TaskWithStateSnapshot; + const latestTaskData = taskWithSnapshot.state?.context?.taskData; + const snapInteraction = latestTaskData?.interaction; + const dataInteraction = currentTask?.data?.interaction as InteractionWithMedia | undefined; + + const isConsulted = + Boolean(currentTask?.data?.isConsulted) || + Boolean(currentTask && isSecondaryAgent(currentTask)) || + Boolean(controls?.main?.endConsult?.isVisible && !controls?.consult?.endConsult?.isVisible) || + Boolean( + agentId && + (dataInteraction?.participants?.[agentId]?.isConsulted || snapInteraction?.participants?.[agentId]?.isConsulted) + ); + + const isInConference = (snapInteraction?.state ?? dataInteraction?.state) === 'conference'; + + // Consulted agents: task.data is freshest on AgentContactHeld/Unheld (simple consult + conference). + const interaction = isConsulted ? (dataInteraction ?? snapInteraction) : (snapInteraction ?? dataInteraction); + + const taskEventType = latestTaskData?.type ?? currentTask?.data?.type; + const isExplicitUnheldEvent = taskEventType === 'AgentContactUnheld'; + const isExplicitHeldEvent = taskEventType === 'AgentContactHeld'; + const currentCallProcessingDetails = interaction?.callProcessingDetails as Record | undefined; + const latestCallProcessingDetails = latestTaskData?.interaction?.callProcessingDetails as + | Record + | undefined; + const conferenceHoldParticipant = + currentCallProcessingDetails?.conferenceHoldParticipant ?? latestCallProcessingDetails?.conferenceHoldParticipant; + const conferenceHoldKnown = + conferenceHoldParticipant === true || + conferenceHoldParticipant === false || + conferenceHoldParticipant === 'true' || + conferenceHoldParticipant === 'false'; + const isConferenceParticipantHeld = conferenceHoldParticipant === true || conferenceHoldParticipant === 'true'; + + const epDnConsultRelationship = + currentCallProcessingDetails?.relationshipType === 'consult' || + latestCallProcessingDetails?.relationshipType === 'consult'; + const parentInteractionId = + currentCallProcessingDetails?.parentInteractionId ?? latestCallProcessingDetails?.parentInteractionId; + const hasConsultMedia = Boolean(getMediaEntries(interaction?.media).some((media) => media.mType === 'consult')); + const isEpDnUiPattern = Boolean( + controls?.main?.endConsult?.isVisible && + !controls?.consult?.endConsult?.isVisible && + controls?.activeLeg === 'main' && + !hasConsultMedia + ); + const isEpDnConsultedAgent = + Boolean(currentTask && isSecondaryEpDnAgent(currentTask)) || + Boolean( + interaction?.mediaType === 'telephony' && + epDnConsultRelationship && + parentInteractionId && + interaction?.interactionId && + parentInteractionId !== interaction.interactionId + ) || + isEpDnUiPattern; + + const isConsulting = + controls?.consult?.endConsult?.isVisible || + controls?.main?.endConsult?.isVisible || + Boolean(currentTask && isSecondaryEpDnAgent(currentTask) && epDnConsultRelationship); + const customerPresent = Boolean( + getParticipants(interaction?.participants).some((p) => p.pType === 'Customer' && !p.hasLeft) + ); + const mainCallMediaHeld = Boolean( + getMediaEntries(interaction?.media).some( + (media) => (media.mType === 'mainCall' || media.mType === 'main') && media.isHold === true + ) + ); + const consultMediaHeld = Boolean( + getMediaEntries(interaction?.media).some((media) => media.mType === 'consult' && media.isHold === true) + ); + + if (!interaction) { + return {isHeld: false, interaction: undefined, holdTimestampMs: null}; + } + + const isHeld = getMainCadHold({ + currentTask, + controls, + isConsulted, + isEpDnConsultedAgent, + isInConference, + isConsulting, + customerPresent, + mainCallMediaHeld, + consultMediaHeld, + isExplicitUnheldEvent, + isExplicitHeldEvent, + conferenceHoldKnown, + isConferenceParticipantHeld, + }); + + const holdTimestampMs = resolveMainCadHoldTimestampMs(interaction, isHeld, interaction.interactionId); + + return {isHeld, interaction, holdTimestampMs}; +} + +type MainCadHoldContext = { + currentTask: ITask; + controls: TaskUIControls; + isConsulted: boolean; + isEpDnConsultedAgent: boolean; + isInConference: boolean; + isConsulting: boolean; + customerPresent: boolean; + mainCallMediaHeld: boolean; + consultMediaHeld: boolean; + isExplicitUnheldEvent: boolean; + isExplicitHeldEvent: boolean; + conferenceHoldKnown: boolean; + isConferenceParticipantHeld: boolean; +}; + +function getMainCadHold({ + currentTask, + controls, + isConsulted, + isEpDnConsultedAgent, + isInConference, + isConsulting, + customerPresent, + mainCallMediaHeld, + consultMediaHeld, + isExplicitUnheldEvent, + isExplicitHeldEvent, + conferenceHoldKnown, + isConferenceParticipantHeld, +}: MainCadHoldContext): boolean { + const nestedConsultContext = Boolean(isConsulting && customerPresent); + const isAgentNameConsulted = isConsulted && !isEpDnConsultedAgent; + + // EP/DN consulted agent: single mainCall leg; activeLeg stays "main" (never "consult"). + // mainCall.isHold = own leg parked (hide). !isHold + customer = customer on hold (show). + if (isEpDnConsultedAgent) { + if (mainCallMediaHeld) { + return false; + } + return customerPresent; + } + + // Consult leg parked — not customer/main on hold (wins over stale mainCall.isHold). + if (consultMediaHeld) { + return false; + } + + if (mainCallMediaHeld) { + return true; + } + + // Agent-name consulted agent: activeLeg can lead media; activeLeg main without main hold = parked. + if (isAgentNameConsulted) { + if (controls?.activeLeg === 'main') { + return false; + } + return Boolean(controls?.activeLeg === 'consult' && customerPresent); + } + + // Initiator nested consult — activeLeg before mainCall isHold catches up. + if (nestedConsultContext) { + return Boolean(controls?.activeLeg === 'consult' && customerPresent); + } + + // Plain conference hold — event type overrides stale conferenceHoldParticipant. + if (isInConference) { + if (isExplicitUnheldEvent) { + return false; + } + if (isExplicitHeldEvent) { + return true; + } + if (conferenceHoldKnown) { + return isConferenceParticipantHeld; + } + } + + return isInteractionOnHold(currentTask); +} diff --git a/packages/contact-center/task/src/Utils/task-util.ts b/packages/contact-center/task/src/Utils/task-util.ts index 5c869a286..334e96365 100644 --- a/packages/contact-center/task/src/Utils/task-util.ts +++ b/packages/contact-center/task/src/Utils/task-util.ts @@ -1,5 +1,111 @@ import {Interaction} from '@webex/contact-center'; +const HOLD_ANCHOR_PREFIX = 'cc-widget-hold-anchor:'; +const CONSULT_HOLD_ANCHOR_PREFIX = 'cc-widget-consult-hold-anchor:'; + +type ConsultMediaEntry = { + holdTimestamp?: number | null; +}; + +export const getHoldAnchorStorageKey = (interactionId: string): string => `${HOLD_ANCHOR_PREFIX}${interactionId}`; + +export const getConsultHoldAnchorStorageKey = (interactionId: string): string => + `${CONSULT_HOLD_ANCHOR_PREFIX}${interactionId}`; + +export const normalizeHoldTimestampMs = (raw: number): number => (raw < 10000000000 ? raw * 1000 : raw); + +export const readHoldAnchor = (interactionId: string | undefined): number | null => { + if (!interactionId || typeof sessionStorage === 'undefined') { + return null; + } + + try { + const stored = sessionStorage.getItem(getHoldAnchorStorageKey(interactionId)); + if (!stored) { + return null; + } + const parsed = Number(stored); + return Number.isFinite(parsed) && parsed > 0 ? parsed : null; + } catch { + return null; + } +}; + +export const writeHoldAnchor = (interactionId: string | undefined, timestampMs: number): void => { + if (!interactionId || typeof sessionStorage === 'undefined') { + return; + } + + try { + sessionStorage.setItem(getHoldAnchorStorageKey(interactionId), String(timestampMs)); + } catch { + // Ignore quota / private-mode errors. + } +}; + +export const clearHoldAnchor = (interactionId: string | undefined): void => { + if (!interactionId || typeof sessionStorage === 'undefined') { + return; + } + + try { + sessionStorage.removeItem(getHoldAnchorStorageKey(interactionId)); + } catch { + // Ignore storage errors. + } +}; + +export const readConsultHoldAnchor = (interactionId: string | undefined): number | null => { + if (!interactionId || typeof sessionStorage === 'undefined') { + return null; + } + + try { + const stored = sessionStorage.getItem(getConsultHoldAnchorStorageKey(interactionId)); + if (!stored) { + return null; + } + const parsed = Number(stored); + return Number.isFinite(parsed) && parsed > 0 ? parsed : null; + } catch { + return null; + } +}; + +export const writeConsultHoldAnchor = (interactionId: string | undefined, timestampMs: number): void => { + if (!interactionId || typeof sessionStorage === 'undefined') { + return; + } + + try { + sessionStorage.setItem(getConsultHoldAnchorStorageKey(interactionId), String(timestampMs)); + } catch { + // Ignore quota / private-mode errors. + } +}; + +export const clearConsultHoldAnchor = (interactionId: string | undefined): void => { + if (!interactionId || typeof sessionStorage === 'undefined') { + return; + } + + try { + sessionStorage.removeItem(getConsultHoldAnchorStorageKey(interactionId)); + } catch { + // Ignore storage errors. + } +}; + +const getMatchingMedia = (interaction: Interaction, mType: string) => { + if (!interaction?.media) { + return []; + } + + return Object.values(interaction.media).filter( + (media) => media.mType === mType || (mType === 'mainCall' && media.mType === 'main') + ); +}; + /** * Finds the hold timestamp for a specific media type from an interaction. * Used by useHoldTimer for hold duration display. @@ -8,9 +114,61 @@ import {Interaction} from '@webex/contact-center'; * This one takes Interaction directly. */ export function findHoldTimestamp(interaction: Interaction, mType = 'mainCall'): number | null { - if (interaction?.media) { - const media = Object.values(interaction.media).find((m) => m.mType === mType); - return media?.holdTimestamp ?? null; + const matchingMedia = getMatchingMedia(interaction, mType); + + if (matchingMedia.length === 0) { + return null; + } + + for (const media of matchingMedia) { + if (media.isHold === true && media.holdTimestamp != null && media.holdTimestamp > 0) { + return media.holdTimestamp; + } + } + + // Hydrate/refresh: backend may retain holdTimestamp while isHold lags (consulted Agent 2). + let latestTimestamp: number | null = null; + for (const media of matchingMedia) { + if (media.holdTimestamp != null && media.holdTimestamp > 0) { + latestTimestamp = latestTimestamp == null ? media.holdTimestamp : Math.max(latestTimestamp, media.holdTimestamp); + } + } + + return latestTimestamp; +} + +/** + * Resolve main CAD hold timer anchor in milliseconds. + * Prefers interaction media; falls back to session anchor for refresh continuity. + */ +export function resolveMainCadHoldTimestampMs( + interaction: Interaction | undefined, + isHeld: boolean, + interactionId?: string +): number | null { + if (!isHeld || !interaction) { + return null; } - return null; + + const mediaTimestamp = findHoldTimestamp(interaction, 'mainCall'); + if (mediaTimestamp != null && mediaTimestamp > 0) { + return normalizeHoldTimestampMs(mediaTimestamp); + } + + return readHoldAnchor(interactionId ?? interaction.interactionId); +} + +/** + * Resolve consult-leg hold timer anchor in milliseconds (Agent 1 initiator CAD). + */ +export function resolveConsultHoldTimestampMs( + consultMedia: ConsultMediaEntry | null | undefined, + interactionId?: string +): number { + const raw = consultMedia?.holdTimestamp; + if (raw != null && raw > 0) { + return normalizeHoldTimestampMs(raw); + } + + return readConsultHoldAnchor(interactionId) ?? Date.now(); } diff --git a/packages/contact-center/task/src/Utils/timer-utils.ts b/packages/contact-center/task/src/Utils/timer-utils.ts index 53c14b212..d0c3ab709 100644 --- a/packages/contact-center/task/src/Utils/timer-utils.ts +++ b/packages/contact-center/task/src/Utils/timer-utils.ts @@ -1,4 +1,5 @@ import {ITask, TaskUIControls} from '@webex/cc-store'; +import {Interaction} from '@webex/contact-center'; import { TIMER_LABEL_WRAP_UP, TIMER_LABEL_POST_CALL, @@ -6,6 +7,7 @@ import { TIMER_LABEL_CONSULT_REQUESTED, TIMER_LABEL_CONSULTING, } from './constants'; +import {resolveConsultHoldTimestampMs} from './task-util'; /** * Timer data structure containing label and timestamp @@ -15,26 +17,121 @@ export interface TimerData { timestamp: number; } +type MediaEntry = { + mType?: string; + isHold?: boolean; + holdTimestamp?: number | null; + lastUpdated?: number; + joinTimestamp?: number; + eventTime?: number; + createdAt?: number; + mediaResourceId?: string; + participants?: string[]; +}; + +type InteractionParticipant = { + consultTimestamp?: number; + lastUpdated?: number; + consultState?: string; + isConsulted?: boolean; + isWrapUp?: boolean; + wrapUpTimestamp?: number | null; + currentState?: string | null; + currentStateTimestamp?: number | null; +}; + +type InteractionWithMedia = Interaction & { + media?: Record; + participants?: Record; +}; + +type TaskStateSnapshot = { + interaction?: InteractionWithMedia; +}; + +type TaskWithStateSnapshot = ITask & { + state?: { + context?: { + taskData?: TaskStateSnapshot; + }; + }; +}; + +const getMediaEntries = (media: InteractionWithMedia['media']): MediaEntry[] => + media ? (Object.values(media) as MediaEntry[]) : []; + +const getMediaRecencyScore = (media: MediaEntry, fallbackIndex = 0): number => { + const candidateTimestamps = [ + media.lastUpdated, + media.holdTimestamp, + media.joinTimestamp, + media.eventTime, + media.createdAt, + ]; + + for (const value of candidateTimestamps) { + if (typeof value === 'number' && Number.isFinite(value) && value > 0) { + return value; + } + } + + return fallbackIndex; +}; + /** * Find the latest (most recently added) consult media from the interaction. * * After transfer → re-consult the backend may leave the OLD consult media - * in the interaction alongside the NEW one. Using Array.find() would return - * the first (stale) entry; we need the last one which is the active consult. + * in the interaction alongside the NEW one. Prefer held consult entries when + * present (switch-to-main), otherwise pick the most recent consult leg. */ -// eslint-disable-next-line @typescript-eslint/no-explicit-any -export function findLatestConsultMedia(interaction: any): any { - if (!interaction?.media) return null; - const allMedia = Object.values(interaction.media); - let latest = null; - for (const m of allMedia) { - if ((m as {mType: string}).mType === 'consult') { - latest = m; - } +export function findLatestConsultMedia(interaction: InteractionWithMedia | undefined): MediaEntry | null { + if (!interaction?.media) { + return null; + } + + const consultEntries = getMediaEntries(interaction.media).filter((media) => media.mType === 'consult'); + + if (consultEntries.length === 0) { + return null; + } + + if (consultEntries.length === 1) { + return consultEntries[0]; } - return latest; + + const heldEntries = consultEntries.filter((media) => media.isHold === true); + const candidates = heldEntries.length > 0 ? heldEntries : consultEntries; + + return candidates.reduce((latest, current, index) => { + const latestScore = getMediaRecencyScore(latest, index - 1); + const currentScore = getMediaRecencyScore(current, index); + return currentScore >= latestScore ? current : latest; + }); } +const resolveConsultInteraction = ( + currentTask: ITask, + agentId: string +): { + interaction: InteractionWithMedia | undefined; + participant: InteractionParticipant | undefined; + isConsultedAgent: boolean; +} => { + const taskWithSnapshot = currentTask as TaskWithStateSnapshot; + const latestTaskData = taskWithSnapshot.state?.context?.taskData; + const snapInteraction = latestTaskData?.interaction; + const dataInteraction = currentTask.data?.interaction as InteractionWithMedia | undefined; + const participant = dataInteraction?.participants?.[agentId] ?? snapInteraction?.participants?.[agentId]; + + const isConsultedAgent = Boolean(currentTask.data?.isConsulted || participant?.isConsulted === true); + + // Initiator (Agent 1): snapshot is fresher on switch-to-main. Consulted (Agent 2): task.data is fresher. + const interaction = isConsultedAgent ? (dataInteraction ?? snapInteraction) : (snapInteraction ?? dataInteraction); + + return {interaction, participant, isConsultedAgent}; +}; + /** * Calculate state timer label and timestamp based on task state. * Priority: Wrap Up > Post Call @@ -50,7 +147,7 @@ export function calculateStateTimerData( return defaultTimer; } - const interaction = currentTask.data?.interaction; + const interaction = currentTask.data?.interaction as InteractionWithMedia | undefined; const participant = interaction?.participants?.[agentId]; if (!participant) { @@ -93,11 +190,8 @@ export function calculateStateTimerData( * Calculate consult timer label and timestamp based on consult state. * Handles consult on hold vs active consulting states. * - * Approach mirrors the original next-branch pattern: derive consultCallHeld - * from the consult media's isHold flag (task data), NOT from SDK uiControls - * properties like activeLeg or switch button visibility. Those UI properties - * have different lifecycle timing and broader semantics that cause false - * positives (e.g., switch.isVisible is true during CONSULT_INITIATING). + * Derives consult on hold from consult media isHold (not uiControls alone). + * EP/DN and agent-name: isHold may be true before holdTimestamp arrives — still show Consult on Hold. */ export function calculateConsultTimerData( currentTask: ITask | null, @@ -110,10 +204,9 @@ export function calculateConsultTimerData( return defaultTimer; } - const interaction = currentTask.data?.interaction; - const participant = interaction?.participants?.[agentId]; + const {interaction, participant, isConsultedAgent} = resolveConsultInteraction(currentTask, agentId); - if (!participant) { + if (!participant || !interaction) { return defaultTimer; } @@ -128,34 +221,37 @@ export function calculateConsultTimerData( return defaultTimer; } - // Use the LATEST consult media, not the first. After transfer → re-consult - // the backend keeps the old consult media (with stale isHold=true) alongside - // the new one. Array.find() would return the old stale entry. let consultMedia = findLatestConsultMedia(interaction); - // Consulted agent (Agent 2): their call is mType "mainCall" not "consult". - // When the initiator switches away, Agent 2's mainCall is put on hold. - if (!consultMedia && interaction?.media) { - const mainMedia = Object.values(interaction.media).find((m) => (m as {mType: string}).mType === 'mainCall'); + // Consulted agent (Agent 2 EP/DN): single mainCall leg — only when no consult media exists. + if (!consultMedia && isConsultedAgent && interaction.media) { + const mainMedia = getMediaEntries(interaction.media).find((media) => media.mType === 'mainCall'); if (mainMedia) { consultMedia = mainMedia; } } const isConsultMediaHeld = consultMedia?.isHold === true; - const consultHoldTimestamp = consultMedia?.holdTimestamp ?? null; - const consultCallHeld = isConsultMediaHeld && consultHoldTimestamp !== null && consultHoldTimestamp > 0; + const isConsultInitiated = + participant.consultState === 'consultInitiated' || currentTask.data?.consultStatus === 'consultInitiated'; + const consultAccepted = !isConsultInitiated && Boolean(participant.consultTimestamp); + + // Initiator parked on main before media catches up (common in EP/DN switch-to-main). + const consultParkedByActiveLeg = + !isConsultedAgent && + consultAccepted && + controls.activeLeg === 'main' && + Boolean(controls.consult?.endConsult?.isVisible || controls.main?.endConsult?.isVisible); - if (consultCallHeld) { + const isConsultOnHold = isConsultMediaHeld || consultParkedByActiveLeg; + + if (isConsultOnHold) { return { label: TIMER_LABEL_CONSULT_ON_HOLD, - timestamp: consultHoldTimestamp, + timestamp: resolveConsultHoldTimestampMs(consultMedia, interaction.interactionId), }; } - // Distinguish "Consult Requested" from "Consulting" using participant data. - const isConsultInitiated = - participant?.consultState === 'consultInitiated' || currentTask.data?.consultStatus === 'consultInitiated'; const label = isConsultInitiated ? TIMER_LABEL_CONSULT_REQUESTED : TIMER_LABEL_CONSULTING; return { diff --git a/packages/contact-center/task/src/Utils/useHoldTimer.ts b/packages/contact-center/task/src/Utils/useHoldTimer.ts index cc186f762..7a103ed07 100644 --- a/packages/contact-center/task/src/Utils/useHoldTimer.ts +++ b/packages/contact-center/task/src/Utils/useHoldTimer.ts @@ -1,7 +1,5 @@ import {useEffect, useRef, useState} from 'react'; -import {ITask, isInteractionOnHold} from '@webex/cc-store'; -import {TaskUIControls} from '@webex/contact-center'; -import {findHoldTimestamp} from './task-util'; +import {clearHoldAnchor, readHoldAnchor, writeHoldAnchor} from './task-util'; const HOLD_TIMER_WORKER_SCRIPT = ` let intervalId = null; @@ -22,44 +20,18 @@ const HOLD_TIMER_WORKER_SCRIPT = ` `; /** - * Custom hook to manage hold timer using a Web Worker. - * - * Derives two stable primitives from props — a boolean hold flag and a - * numeric timestamp — and uses them as the sole effect dependencies. - * This prevents the worker from being killed/recreated on every - * currentTask or controls reference change. - * - * @param currentTask - The current task object - * @param controls - SDK-computed UI controls with activeLeg - * @returns holdTime - The elapsed time in seconds since the call was put on hold + * Hold timer only — main CAD on-hold boolean is computed in useCallControl (helper.ts). */ -export const useHoldTimer = (currentTask: ITask | null, controls?: TaskUIControls): number => { +export const useHoldTimer = ( + mainCallOnHold: boolean, + holdTimestampMs: number | null, + holdDataVersion = 0, + interactionId?: string +): number => { const [holdTime, setHoldTime] = useState(0); const workerRef = useRef(null); - // --- Derive stable primitives (compared by value, not reference) --- - - const isConsulting = controls?.consult?.endConsult?.isVisible || controls?.main?.endConsult?.isVisible; - - const customerPresent = Boolean( - currentTask?.data?.interaction?.participants && - Object.values(currentTask.data.interaction.participants).some((p) => p?.pType === 'Customer' && !p?.hasLeft) - ); - - // During consulting, activeLeg='consult' means the main call is on hold. - // Outside consulting, fall back to the actual media hold state. - // When customer has left, never show the hold timer (follows Agent Desktop behavior). - const mainCallOnHold = - isConsulting && customerPresent - ? controls?.activeLeg === 'consult' - : currentTask - ? isInteractionOnHold(currentTask) - : false; - - const rawTs = currentTask?.data?.interaction ? findHoldTimestamp(currentTask.data.interaction, 'mainCall') : null; - const holdTimestampMs: number | null = rawTs ? (rawTs < 10000000000 ? rawTs * 1000 : rawTs) : null; - - // --- Effect: only re-runs when the boolean or timestamp actually change --- + void holdDataVersion; useEffect(() => { if (workerRef.current) { @@ -69,14 +41,16 @@ export const useHoldTimer = (currentTask: ITask | null, controls?: TaskUIControl } if (!mainCallOnHold) { + clearHoldAnchor(interactionId); setHoldTime(0); return; } - // Use real backend timestamp when available, otherwise Date.now() so the - // timer starts immediately (backend AgentContactHeld arrives ~100-200ms - // later and triggers a re-run via the holdTimestampMs dependency). - const eventTime = holdTimestampMs || Date.now(); + let eventTime = holdTimestampMs; + if (!eventTime) { + eventTime = readHoldAnchor(interactionId) ?? Date.now(); + } + writeHoldAnchor(interactionId, eventTime); const blob = new Blob([HOLD_TIMER_WORKER_SCRIPT], {type: 'application/javascript'}); const workerUrl = URL.createObjectURL(blob); @@ -98,7 +72,7 @@ export const useHoldTimer = (currentTask: ITask | null, controls?: TaskUIControl workerRef.current = null; } }; - }, [mainCallOnHold, holdTimestampMs]); + }, [mainCallOnHold, holdTimestampMs, holdDataVersion, interactionId]); return holdTime; }; diff --git a/packages/contact-center/task/src/helper.ts b/packages/contact-center/task/src/helper.ts index d4d337762..a9dca2b3a 100644 --- a/packages/contact-center/task/src/helper.ts +++ b/packages/contact-center/task/src/helper.ts @@ -22,7 +22,6 @@ import store, { getConferenceParticipants, Participant, findMediaResourceId, - isInteractionOnHold, MEDIA_TYPE_TELEPHONY_LOWER, } from '@webex/cc-store'; import { @@ -32,6 +31,8 @@ import { TIMER_LABEL_WRAP_UP, } from './Utils/constants'; import {calculateStateTimerData, calculateConsultTimerData, findLatestConsultMedia} from './Utils/timer-utils'; +import {deriveMainCadHoldState} from './Utils/main-cad-hold.util'; +import {writeConsultHoldAnchor, clearConsultHoldAnchor} from './Utils/task-util'; import {useHoldTimer} from './Utils/useHoldTimer'; import {OutdialAniEntriesResponse} from '@webex/contact-center/dist/types/services/config/types'; @@ -304,7 +305,7 @@ export const useCallControl = (props: useCallControlProps) => { } = props; const [isRecording, setIsRecording] = useState(true); const [controls, setControls] = useState(currentTask?.uiControls ?? getDefaultUIControls()); - const [isHeld, setIsHeld] = useState(() => (currentTask ? isInteractionOnHold(currentTask) : false)); + const [holdDataVersion, setHoldDataVersion] = useState(0); const [buddyAgents, setBuddyAgents] = useState([]); const [loadingBuddyAgents, setLoadingBuddyAgents] = useState(false); const [consultAgentName, setConsultAgentName] = useState('Consult Agent'); @@ -340,83 +341,43 @@ export const useCallControl = (props: useCallControlProps) => { setControls(updatedControls); }; currentTask.on(TASK_EVENTS.TASK_UI_CONTROLS_UPDATED, onControlsUpdated); + const bumpHoldDataVersion = () => setHoldDataVersion((version) => version + 1); + // Agent 2 receives AgentContactHeld/Unheld (TASK_HOLD/TASK_RESUME), not TASK_SWITCH_CALL. + currentTask.on(TASK_EVENTS.TASK_SWITCH_CALL, bumpHoldDataVersion); + currentTask.on(TASK_EVENTS.TASK_HOLD, bumpHoldDataVersion); + currentTask.on(TASK_EVENTS.TASK_RESUME, bumpHoldDataVersion); return () => { currentTask.off(TASK_EVENTS.TASK_UI_CONTROLS_UPDATED, onControlsUpdated); + currentTask.off(TASK_EVENTS.TASK_SWITCH_CALL, bumpHoldDataVersion); + currentTask.off(TASK_EVENTS.TASK_HOLD, bumpHoldDataVersion); + currentTask.off(TASK_EVENTS.TASK_RESUME, bumpHoldDataVersion); }; }, [currentTask]); - useEffect(() => { - // Prefer the latest state-machine taskData snapshot when available. - // currentTask.data can lag one event behind in conference transitions. - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const latestTaskData = (currentTask as any)?.state?.context?.taskData; - const interaction = latestTaskData?.interaction ?? currentTask?.data?.interaction; - const isInConference = interaction?.state === 'conference'; - const taskEventType = latestTaskData?.type ?? currentTask?.data?.type; - const isExplicitUnheldEvent = taskEventType === 'AgentContactUnheld'; - const isExplicitHeldEvent = taskEventType === 'AgentContactHeld'; - const currentCallProcessingDetails = interaction?.callProcessingDetails as Record | undefined; - const latestCallProcessingDetails = latestTaskData?.interaction?.callProcessingDetails as - | Record - | undefined; - const conferenceHoldParticipant = - currentCallProcessingDetails?.conferenceHoldParticipant ?? latestCallProcessingDetails?.conferenceHoldParticipant; - const conferenceHoldKnown = - conferenceHoldParticipant === true || - conferenceHoldParticipant === false || - conferenceHoldParticipant === 'true' || - conferenceHoldParticipant === 'false'; - const isConferenceParticipantHeld = conferenceHoldParticipant === true || conferenceHoldParticipant === 'true'; - - // During consulting, derive hold state from activeLeg (set synchronously - // by the SDK on switch). Raw media data has a timing gap — the backend - // hold/unhold response arrives after the switch event, so media.isHold - // is stale at the time the controls update. - const isConsulting = controls?.consult?.endConsult?.isVisible || controls?.main?.endConsult?.isVisible; - if (isInConference) { - // Event type is the strongest signal for hold/unhold transitions in - // conference flows and should override stale callProcessingDetails. - if (isExplicitUnheldEvent) { - setIsHeld(false); - return; - } - if (isExplicitHeldEvent) { - setIsHeld(true); - return; - } - - // In conference, hold can be represented either by main leg media hold - // or by callProcessingDetails.conferenceHoldParticipant. - const mainCallHeld = interaction?.media - ? Object.values(interaction.media).some( - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (media: any) => (media?.mType === 'mainCall' || media?.mType === 'main') && media?.isHold === true - ) - : false; - if (conferenceHoldKnown) { - setIsHeld(mainCallHeld || isConferenceParticipantHeld); - } else { - // No explicit conference hold signal -> trust current media hold only. - // This avoids stale "Resume"/On Hold UI when previous snapshots were held. - setIsHeld(mainCallHeld); - } - } else if (isConsulting) { - setIsHeld(controls?.activeLeg === 'consult'); - } else { - const mainCallHeld = interaction?.media - ? Object.values(interaction.media).some( - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (media: any) => (media?.mType === 'mainCall' || media?.mType === 'main') && media?.isHold === true - ) - : currentTask - ? isInteractionOnHold(currentTask) - : false; - setIsHeld(mainCallHeld); - } - }, [currentTask, controls]); + // MobX — read task.data media during render so hold updates without TASK_* events. + void ( + currentTask?.data?.interaction?.media && + Object.values(currentTask.data.interaction.media).some( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (media: any) => (media?.mType === 'mainCall' || media?.mType === 'main') && media?.isHold === true + ) + ); + void ( + currentTask?.data?.interaction?.media && + Object.values(currentTask.data.interaction.media).some( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (media: any) => media?.mType === 'consult' && media?.isHold === true + ) + ); + void holdDataVersion; - // Use custom hook for hold timer management - const holdTime = useHoldTimer(currentTask, controls); + const {isHeld, interaction, holdTimestampMs} = deriveMainCadHoldState({ + currentTask, + controls, + agentId, + holdDataVersion, + }); + const holdTime = useHoldTimer(isHeld, holdTimestampMs, holdDataVersion, interaction?.interactionId); useEffect(() => { const isConsulting = !!(controls?.consult?.endConsult?.isVisible || controls?.main?.endConsult?.isVisible); @@ -630,7 +591,6 @@ export const useCallControl = (props: useCallControlProps) => { const holdCallback = () => { try { - setIsHeld(true); if (onHoldResume) { onHoldResume({ isHeld: true, @@ -647,7 +607,6 @@ export const useCallControl = (props: useCallControlProps) => { const resumeCallback = () => { try { - setIsHeld(false); if (onHoldResume) { onHoldResume({ isHeld: false, @@ -1194,13 +1153,23 @@ export const useCallControl = (props: useCallControlProps) => { const consultTimerData = calculateConsultTimerData(currentTask, controls, agentId); const justBecameConsulting = isConsulting && !wasConsulting; + const interactionId = currentTask?.data?.interaction?.interactionId; + + const nextConsultLabel = consultTimerData.label; + const nextConsultTimestamp = consultTimerData.timestamp; + + if (nextConsultLabel === TIMER_LABEL_CONSULT_ON_HOLD) { + writeConsultHoldAnchor(interactionId, nextConsultTimestamp); + } else { + clearConsultHoldAnchor(interactionId); + } - if (justBecameConsulting && consultTimerData.label === TIMER_LABEL_CONSULT_ON_HOLD) { + if (justBecameConsulting && nextConsultLabel === TIMER_LABEL_CONSULT_ON_HOLD) { setConsultTimerLabel(TIMER_LABEL_CONSULT_REQUESTED); setConsultTimerTimestamp(0); } else { - setConsultTimerLabel(consultTimerData.label); - setConsultTimerTimestamp(consultTimerData.timestamp); + setConsultTimerLabel(nextConsultLabel); + setConsultTimerTimestamp(nextConsultTimestamp); } }, [currentTask, controls, agentId, consultMediaIsHold, consultMediaId, participantConsultState]); diff --git a/packages/contact-center/task/tests/helper.ts b/packages/contact-center/task/tests/helper.ts index d9a3f8bd3..54f6c7c07 100644 --- a/packages/contact-center/task/tests/helper.ts +++ b/packages/contact-center/task/tests/helper.ts @@ -826,8 +826,8 @@ describe('useCallControl', () => { }) ); - // 7 store callbacks + TASK_UI_CONTROLS_UPDATED on task - expect(onSpy).toHaveBeenCalledTimes(8); + // 7 store callbacks + TASK_UI_CONTROLS_UPDATED + TASK_SWITCH_CALL + TASK_HOLD + TASK_RESUME on task + expect(onSpy).toHaveBeenCalledTimes(11); // Unmount the component act(() => { @@ -875,6 +875,7 @@ describe('useCallControl', () => { }); it('should call onHoldResume with hold=true and handle success', async () => { + const setTaskCallbackSpy = jest.spyOn(store, 'setTaskCallback'); const {result} = renderHook(() => useCallControl({ currentTask: mockCurrentTask, @@ -890,14 +891,17 @@ describe('useCallControl', () => { await act(async () => { await result.current.toggleHold(true); - mockCurrentTask.on.mock.calls.find((call) => call[0] === TASK_EVENTS.TASK_HOLD)?.[1](); + const holdCallback = setTaskCallbackSpy.mock.calls.find((call) => call[0] === TASK_EVENTS.TASK_HOLD)?.[1]; + holdCallback?.(); }); expect(mockCurrentTask.hold).toHaveBeenCalled(); expect(mockOnHoldResume).toHaveBeenCalledWith({isHeld: true, task: mockCurrentTask}); + setTaskCallbackSpy.mockRestore(); }); it('should call onHoldResume with hold=false and handle success', async () => { + const setTaskCallbackSpy = jest.spyOn(store, 'setTaskCallback'); const {result} = renderHook(() => useCallControl({ currentTask: mockCurrentTask, @@ -911,75 +915,806 @@ describe('useCallControl', () => { }) ); - await act(async () => { - await result.current.toggleHold(false); - mockCurrentTask.on.mock.calls.find((call) => call[0] === TASK_EVENTS.TASK_RESUME)?.[1](); + await act(async () => { + await result.current.toggleHold(false); + const resumeCallback = setTaskCallbackSpy.mock.calls.find((call) => call[0] === TASK_EVENTS.TASK_RESUME)?.[1]; + resumeCallback?.(); + }); + + expect(mockCurrentTask.resume).toHaveBeenCalled(); + expect(mockOnHoldResume).toHaveBeenCalledWith({isHeld: false, task: mockCurrentTask}); + setTaskCallbackSpy.mockRestore(); + }); + + describe('conference hold precedence', () => { + const buildConferenceTask = ( + overrides: { + eventType?: string; + conferenceHoldParticipant?: boolean | string; + mainCallIsHold?: boolean; + } = {} + ) => { + const {eventType = 'AgentConsultConferenced', conferenceHoldParticipant, mainCallIsHold = false} = overrides; + const interaction = { + ...mockCurrentTask.data.interaction, + state: 'conference', + callProcessingDetails: { + ...mockCurrentTask.data.interaction.callProcessingDetails, + ...(conferenceHoldParticipant !== undefined ? {conferenceHoldParticipant} : {}), + }, + media: { + main: { + mType: 'mainCall', + isHold: mainCallIsHold, + mediaResourceId: 'main', + participants: ['agent1'], + }, + }, + }; + + return { + ...mockCurrentTask, + data: { + ...mockCurrentTask.data, + type: eventType, + interaction, + }, + state: { + context: { + taskData: { + type: eventType, + interaction, + }, + }, + }, + uiControls: { + main: { + ...mockCurrentTask.uiControls?.main, + endConsult: {isVisible: false, isEnabled: false}, + }, + consult: { + ...mockCurrentTask.uiControls?.consult, + endConsult: {isVisible: false, isEnabled: false}, + }, + activeLeg: 'main', + }, + }; + }; + + it('sets isHeld=false for AgentConsultConferenced when no hold signals exist', async () => { + const task = buildConferenceTask({ + eventType: 'AgentConsultConferenced', + mainCallIsHold: false, + }); + + const {result} = renderHook(() => + useCallControl({ + currentTask: task as unknown as ITask, + logger: mockLogger, + isMuted: false, + conferenceEnabled: true, + agentId: 'agent1', + }) + ); + + await waitFor(() => { + expect(result.current.isHeld).toBe(false); + }); + }); + + it('forces isHeld=false on AgentContactUnheld even if conferenceHoldParticipant is stale true', async () => { + const task = buildConferenceTask({ + eventType: 'AgentContactUnheld', + conferenceHoldParticipant: 'true', + mainCallIsHold: false, + }); + + const {result} = renderHook(() => + useCallControl({ + currentTask: task as unknown as ITask, + logger: mockLogger, + isMuted: false, + conferenceEnabled: true, + agentId: 'agent1', + }) + ); + + await waitFor(() => { + expect(result.current.isHeld).toBe(false); + }); + }); + + it('forces isHeld=true on AgentContactHeld regardless of media lag', async () => { + const task = buildConferenceTask({ + eventType: 'AgentContactHeld', + mainCallIsHold: false, + }); + + const {result} = renderHook(() => + useCallControl({ + currentTask: task as unknown as ITask, + logger: mockLogger, + isMuted: false, + conferenceEnabled: true, + agentId: 'agent1', + }) + ); + + await waitFor(() => { + expect(result.current.isHeld).toBe(true); + }); + }); + + it('sets isHeld=false for consulted agent (Agent 2) in simple consult when only consult media is on hold', async () => { + const consultMediaId = '21a53d3d-db4c-4684-8aec-8ad619da63c0'; + const agentId = '9eab6238-d8f4-4a09-a26f-92efa647dbb0'; + const interaction = { + ...mockCurrentTask.data.interaction, + state: 'consulting', + media: { + '23e77b4e-bc34-4a8d-8852-75f94b062a3b': { + mType: 'mainCall', + isHold: false, + mediaResourceId: '23e77b4e-bc34-4a8d-8852-75f94b062a3b', + }, + [consultMediaId]: { + mType: 'consult', + isHold: true, + holdTimestamp: 1780382174932, + mediaResourceId: consultMediaId, + }, + }, + participants: { + '+14696762938': {pType: 'Customer', hasLeft: false}, + [agentId]: {pType: 'Agent', isConsulted: true}, + }, + }; + + const task = { + ...mockCurrentTask, + data: { + ...mockCurrentTask.data, + type: 'AgentContactHeld', + mediaResourceId: consultMediaId, + interaction, + }, + state: { + context: { + taskData: { + type: 'AgentContactHeld', + mediaResourceId: consultMediaId, + interaction, + }, + }, + }, + uiControls: createMockTaskUIControls({ + activeLeg: 'consult', + consult: {endConsult: enabledControl}, + }), + }; + + const {result} = renderHook(() => + useCallControl({ + currentTask: task as unknown as ITask, + logger: mockLogger, + isMuted: false, + conferenceEnabled: true, + agentId, + }) + ); + + await waitFor(() => { + expect(result.current.isHeld).toBe(false); + }); + }); + + it('sets isHeld=false for consulted agent (Agent 2) when activeLeg is main and consult is parked', async () => { + const consultMediaId = '21a53d3d-db4c-4684-8aec-8ad619da63c0'; + const agentId = '9eab6238-d8f4-4a09-a26f-92efa647dbb0'; + const interaction = { + ...mockCurrentTask.data.interaction, + state: 'consulting', + media: { + '23e77b4e-bc34-4a8d-8852-75f94b062a3b': { + mType: 'mainCall', + isHold: false, + mediaResourceId: '23e77b4e-bc34-4a8d-8852-75f94b062a3b', + }, + [consultMediaId]: { + mType: 'consult', + isHold: false, + mediaResourceId: consultMediaId, + }, + }, + participants: { + '+14696762938': {pType: 'Customer', hasLeft: false}, + [agentId]: {pType: 'Agent', isConsulted: true}, + }, + }; + + const task = { + ...mockCurrentTask, + data: { + ...mockCurrentTask.data, + type: 'AgentContactHeld', + mediaResourceId: consultMediaId, + interaction, + }, + uiControls: createMockTaskUIControls({ + activeLeg: 'main', + consult: {endConsult: enabledControl}, + }), + }; + + const {result} = renderHook(() => + useCallControl({ + currentTask: task as unknown as ITask, + logger: mockLogger, + isMuted: false, + conferenceEnabled: true, + agentId, + }) + ); + + await waitFor(() => { + expect(result.current.isHeld).toBe(false); + }); + }); + + it('sets isHeld=true for consulted agent (Agent 2) in simple consult when mainCall is on hold', async () => { + const consultMediaId = '21a53d3d-db4c-4684-8aec-8ad619da63c0'; + const agentId = '9eab6238-d8f4-4a09-a26f-92efa647dbb0'; + const interaction = { + ...mockCurrentTask.data.interaction, + state: 'consulting', + media: { + '23e77b4e-bc34-4a8d-8852-75f94b062a3b': { + mType: 'mainCall', + isHold: true, + holdTimestamp: 1780382271896, + mediaResourceId: '23e77b4e-bc34-4a8d-8852-75f94b062a3b', + }, + [consultMediaId]: { + mType: 'consult', + isHold: false, + mediaResourceId: consultMediaId, + }, + }, + participants: { + '+14696762938': {pType: 'Customer', hasLeft: false}, + [agentId]: {pType: 'Agent', isConsulted: true}, + }, + }; + + const task = { + ...mockCurrentTask, + data: { + ...mockCurrentTask.data, + type: 'AgentContactUnheld', + mediaResourceId: consultMediaId, + interaction, + }, + state: { + context: { + taskData: { + type: 'AgentContactUnheld', + mediaResourceId: consultMediaId, + interaction, + }, + }, + }, + uiControls: createMockTaskUIControls({ + activeLeg: 'main', + consult: {endConsult: enabledControl}, + }), + }; + + const {result} = renderHook(() => + useCallControl({ + currentTask: task as unknown as ITask, + logger: mockLogger, + isMuted: false, + conferenceEnabled: true, + agentId, + }) + ); + + await waitFor(() => { + expect(result.current.isHeld).toBe(true); + }); + }); + + it('updates isHeld for Agent 2 when TASK_HOLD fires after consult parked (AgentContactHeld)', async () => { + const consultMediaId = '21a53d3d-db4c-4684-8aec-8ad619da63c0'; + const agentId = '9eab6238-d8f4-4a09-a26f-92efa647dbb0'; + const heldInteraction = { + ...mockCurrentTask.data.interaction, + state: 'consulting', + media: { + '23e77b4e-bc34-4a8d-8852-75f94b062a3b': { + mType: 'mainCall', + isHold: true, + holdTimestamp: 1780381977056, + mediaResourceId: '23e77b4e-bc34-4a8d-8852-75f94b062a3b', + }, + [consultMediaId]: { + mType: 'consult', + isHold: false, + mediaResourceId: consultMediaId, + }, + }, + participants: { + '+14696762938': {pType: 'Customer', hasLeft: false}, + [agentId]: {pType: 'Agent', isConsulted: true}, + }, + }; + const parkedInteraction = { + ...heldInteraction, + media: { + '23e77b4e-bc34-4a8d-8852-75f94b062a3b': { + mType: 'mainCall', + isHold: false, + mediaResourceId: '23e77b4e-bc34-4a8d-8852-75f94b062a3b', + }, + [consultMediaId]: { + mType: 'consult', + isHold: true, + holdTimestamp: 1780382174932, + mediaResourceId: consultMediaId, + }, + }, + }; + + const initialTask = { + ...mockCurrentTask, + data: { + ...mockCurrentTask.data, + type: 'AgentConsulting', + interaction: heldInteraction, + }, + state: { + context: { + taskData: { + type: 'AgentConsulting', + interaction: heldInteraction, + }, + }, + }, + uiControls: createMockTaskUIControls({ + activeLeg: 'consult', + main: {endConsult: enabledControl}, + consult: {endConsult: disabledControl}, + }), + on: jest.fn(), + off: jest.fn(), + }; + + const {result, rerender} = renderHook( + ({task}) => + useCallControl({ + currentTask: task as unknown as ITask, + logger: mockLogger, + isMuted: false, + conferenceEnabled: true, + agentId, + }), + {initialProps: {task: initialTask}} + ); + + await waitFor(() => { + expect(result.current.isHeld).toBe(true); + }); + + const parkedTask = { + ...initialTask, + data: { + ...initialTask.data, + type: 'AgentContactHeld', + mediaResourceId: consultMediaId, + interaction: parkedInteraction, + }, + state: { + context: { + taskData: { + type: 'AgentContactHeld', + mediaResourceId: consultMediaId, + interaction: parkedInteraction, + }, + }, + }, + }; + + rerender({task: parkedTask}); + + const holdListener = (parkedTask.on as jest.Mock).mock.calls.find( + (call) => call[0] === TASK_EVENTS.TASK_HOLD + )?.[1]; + + act(() => { + holdListener?.(); + }); + + await waitFor(() => { + expect(result.current.isHeld).toBe(false); + }); + }); + + it('sets isHeld=false for consulted agent (Agent 3) in conference when only consult media is on hold', async () => { + const consultMediaId = '8d18e6c0-4377-4431-8b9d-ed22cdde94cb'; + const interaction = { + ...mockCurrentTask.data.interaction, + state: 'conference', + callProcessingDetails: { + ...mockCurrentTask.data.interaction.callProcessingDetails, + conferenceHoldParticipant: 'true', + }, + media: { + '4da1b819-f461-444b-b817-0c983f235cde': { + mType: 'mainCall', + isHold: false, + mediaResourceId: '4da1b819-f461-444b-b817-0c983f235cde', + }, + [consultMediaId]: { + mType: 'consult', + isHold: true, + holdTimestamp: Date.now() - 5000, + mediaResourceId: consultMediaId, + }, + }, + participants: { + customer1: {pType: 'Customer', hasLeft: false}, + }, + }; + + const task = { + ...mockCurrentTask, + data: { + ...mockCurrentTask.data, + isConsulted: true, + type: 'AgentContactHeld', + mediaResourceId: consultMediaId, + interaction, + }, + state: { + context: { + taskData: { + type: 'AgentContactHeld', + mediaResourceId: consultMediaId, + interaction, + }, + }, + }, + uiControls: createMockTaskUIControls({ + activeLeg: 'consult', + consult: {endConsult: enabledControl}, + }), + }; + + const {result} = renderHook(() => + useCallControl({ + currentTask: task as unknown as ITask, + logger: mockLogger, + isMuted: false, + conferenceEnabled: true, + agentId: 'agent3', + }) + ); + + await waitFor(() => { + expect(result.current.isHeld).toBe(false); + }); + }); + + it('sets isHeld=true for consulted agent (Agent 3) when mainCall is held in conference', async () => { + const consultMediaId = '50fa138f-d9fc-4204-9db1-7d7140061ec8'; + const interaction = { + ...mockCurrentTask.data.interaction, + state: 'conference', + media: { + 'e6ccf577-db2b-4137-b0b3-2a1756dfee29': { + mType: 'mainCall', + isHold: true, + holdTimestamp: Date.now() - 3000, + mediaResourceId: 'e6ccf577-db2b-4137-b0b3-2a1756dfee29', + }, + [consultMediaId]: { + mType: 'consult', + isHold: false, + mediaResourceId: consultMediaId, + }, + }, + participants: { + customer1: {pType: 'Customer', hasLeft: false}, + }, + }; + + const task = { + ...mockCurrentTask, + data: { + ...mockCurrentTask.data, + isConsulted: true, + interaction, + }, + uiControls: createMockTaskUIControls({ + activeLeg: 'consult', + consult: {endConsult: enabledControl}, + }), + }; + + const {result} = renderHook(() => + useCallControl({ + currentTask: task as unknown as ITask, + logger: mockLogger, + isMuted: false, + conferenceEnabled: true, + agentId: 'agent3', + }) + ); + + await waitFor(() => { + expect(result.current.isHeld).toBe(true); + }); + }); + + it('sets isHeld=true for consulted agent on consult unhold when activeLeg is consult (media lag)', async () => { + const consultMediaId = '50fa138f-d9fc-4204-9db1-7d7140061ec8'; + const interaction = { + ...mockCurrentTask.data.interaction, + state: 'conference', + media: { + 'e6ccf577-db2b-4137-b0b3-2a1756dfee29': { + mType: 'mainCall', + isHold: false, + mediaResourceId: 'e6ccf577-db2b-4137-b0b3-2a1756dfee29', + }, + [consultMediaId]: { + mType: 'consult', + isHold: false, + mediaResourceId: consultMediaId, + }, + }, + participants: { + customer1: {pType: 'Customer', hasLeft: false}, + }, + }; + + const task = { + ...mockCurrentTask, + data: { + ...mockCurrentTask.data, + isConsulted: true, + type: 'AgentContactUnheld', + mediaResourceId: consultMediaId, + interaction, + }, + state: { + context: { + taskData: { + type: 'AgentContactUnheld', + mediaResourceId: consultMediaId, + interaction, + }, + }, + }, + uiControls: createMockTaskUIControls({ + activeLeg: 'consult', + consult: {endConsult: enabledControl}, + }), + }; + + const {result} = renderHook(() => + useCallControl({ + currentTask: task as unknown as ITask, + logger: mockLogger, + isMuted: false, + conferenceEnabled: true, + agentId: 'agent3', + }) + ); + + await waitFor(() => { + expect(result.current.isHeld).toBe(true); + }); + }); + + it('sets isHeld=true for consulted agent when mainCall held despite stale AgentContactHeld type', async () => { + const consultMediaId = '8d18e6c0-4377-4431-8b9d-ed22cdde94cb'; + const holdTimestamp = Date.now() - 4000; + const interaction = { + ...mockCurrentTask.data.interaction, + state: 'conference', + media: { + '4da1b819-f461-444b-b817-0c983f235cde': { + mType: 'mainCall', + isHold: true, + holdTimestamp, + mediaResourceId: '4da1b819-f461-444b-b817-0c983f235cde', + }, + [consultMediaId]: { + mType: 'consult', + isHold: false, + mediaResourceId: consultMediaId, + }, + }, + participants: { + customer1: {pType: 'Customer', hasLeft: false}, + }, + }; + + const task = { + ...mockCurrentTask, + data: { + ...mockCurrentTask.data, + isConsulted: true, + type: 'AgentContactHeld', + mediaResourceId: consultMediaId, + interaction, + }, + uiControls: createMockTaskUIControls({ + activeLeg: 'consult', + consult: {endConsult: enabledControl}, + }), + }; + + const {result} = renderHook(() => + useCallControl({ + currentTask: task as unknown as ITask, + logger: mockLogger, + isMuted: false, + conferenceEnabled: true, + agentId: 'agent3', + }) + ); + + await waitFor(() => { + expect(result.current.isHeld).toBe(true); + }); + }); + + it('sets isHeld=true for conference participant (Agent 2) when mainCall is held during nested consult', async () => { + const task = { + ...buildConferenceTask({ + eventType: 'AgentConsultConferenced', + conferenceHoldParticipant: false, + mainCallIsHold: true, + }), + uiControls: createMockTaskUIControls({ + activeLeg: 'consult', + main: {endConsult: enabledControl}, + consult: {endConsult: enabledControl}, + }), + }; + + const {result} = renderHook(() => + useCallControl({ + currentTask: task as unknown as ITask, + logger: mockLogger, + isMuted: false, + conferenceEnabled: true, + agentId: 'agent2', + }) + ); + + await waitFor(() => { + expect(result.current.isHeld).toBe(true); + }); }); - expect(mockCurrentTask.resume).toHaveBeenCalled(); - expect(mockOnHoldResume).toHaveBeenCalledWith({isHeld: false, task: mockCurrentTask}); - }); - - describe('conference hold precedence', () => { - const buildConferenceTask = ( - overrides: { - eventType?: string; - conferenceHoldParticipant?: boolean | string; - mainCallIsHold?: boolean; - } = {} - ) => { - const {eventType = 'AgentConsultConferenced', conferenceHoldParticipant, mainCallIsHold = false} = overrides; + it('sets isHeld=false for conference participant (Agent 2) when only consult media is on hold during nested consult', async () => { + const consultMediaId = '8d18e6c0-4377-4431-8b9d-ed22cdde94cb'; const interaction = { ...mockCurrentTask.data.interaction, state: 'conference', callProcessingDetails: { ...mockCurrentTask.data.interaction.callProcessingDetails, - ...(conferenceHoldParticipant !== undefined ? {conferenceHoldParticipant} : {}), + conferenceHoldParticipant: 'true', }, media: { - main: { + '4da1b819-f461-444b-b817-0c983f235cde': { mType: 'mainCall', - isHold: mainCallIsHold, - mediaResourceId: 'main', - participants: ['agent1'], + isHold: false, + mediaResourceId: '4da1b819-f461-444b-b817-0c983f235cde', + }, + [consultMediaId]: { + mType: 'consult', + isHold: true, + holdTimestamp: Date.now() - 5000, + mediaResourceId: consultMediaId, }, }, + participants: { + customer1: {pType: 'Customer', hasLeft: false}, + }, }; - return { + const task = { ...mockCurrentTask, data: { ...mockCurrentTask.data, - type: eventType, + type: 'AgentContactHeld', + mediaResourceId: consultMediaId, interaction, }, state: { context: { taskData: { - type: eventType, + type: 'AgentContactHeld', + mediaResourceId: consultMediaId, interaction, }, }, }, - uiControls: { - main: { - ...mockCurrentTask.uiControls?.main, - endConsult: {isVisible: false, isEnabled: false}, - }, - consult: { - ...mockCurrentTask.uiControls?.consult, - endConsult: {isVisible: false, isEnabled: false}, + uiControls: createMockTaskUIControls({ + activeLeg: 'consult', + main: {endConsult: enabledControl}, + consult: {endConsult: enabledControl}, + }), + }; + + const {result} = renderHook(() => + useCallControl({ + currentTask: task as unknown as ITask, + logger: mockLogger, + isMuted: false, + conferenceEnabled: true, + agentId: 'agent2', + }) + ); + + await waitFor(() => { + expect(result.current.isHeld).toBe(false); + }); + }); + + it('sets isHeld=false for EP/DN consulted agent when own mainCall leg is parked', async () => { + const agentId = '9eab6238-d8f4-4a09-a26f-92efa647dbb0'; + const interaction = { + ...mockCurrentTask.data.interaction, + state: 'connected', + interactionId: '492c08c1-a5ff-4b90-bf39-454608082dc5', + mediaType: 'telephony', + callProcessingDetails: { + relationshipType: 'consult', + parentInteractionId: 'bf252a79-8d29-4710-ba9e-d93e9ab232e0', + }, + media: { + '492c08c1-a5ff-4b90-bf39-454608082dc5': { + mType: 'mainCall', + isHold: true, + holdTimestamp: 1780387247498, + mediaResourceId: '492c08c1-a5ff-4b90-bf39-454608082dc5', + participants: ['+14696762938', agentId], }, - activeLeg: 'main', + }, + participants: { + '+14696762938': {pType: 'Customer', hasLeft: false}, + [agentId]: {pType: 'Agent', hasJoined: true, isConsulted: false}, }, }; - }; - it('sets isHeld=false for AgentConsultConferenced when no hold signals exist', async () => { - const task = buildConferenceTask({ - eventType: 'AgentConsultConferenced', - mainCallIsHold: false, - }); + const task = { + ...mockCurrentTask, + data: { + ...mockCurrentTask.data, + type: 'AgentContactHeld', + mediaResourceId: '492c08c1-a5ff-4b90-bf39-454608082dc5', + interaction, + }, + state: { + context: { + taskData: { + type: 'AgentContactHeld', + mediaResourceId: '492c08c1-a5ff-4b90-bf39-454608082dc5', + interaction, + }, + }, + }, + uiControls: createMockTaskUIControls({ + activeLeg: 'main', + main: {endConsult: enabledControl}, + consult: {endConsult: disabledControl}, + }), + }; const {result} = renderHook(() => useCallControl({ @@ -987,7 +1722,7 @@ describe('useCallControl', () => { logger: mockLogger, isMuted: false, conferenceEnabled: true, - agentId: 'agent1', + agentId, }) ); @@ -996,12 +1731,111 @@ describe('useCallControl', () => { }); }); - it('forces isHeld=false on AgentContactUnheld even if conferenceHoldParticipant is stale true', async () => { - const task = buildConferenceTask({ - eventType: 'AgentContactUnheld', - conferenceHoldParticipant: 'true', - mainCallIsHold: false, + it('sets isHeld=true for EP/DN consulted agent on accept when activeLeg is main', async () => { + const agentId = '9eab6238-d8f4-4a09-a26f-92efa647dbb0'; + const interaction = { + ...mockCurrentTask.data.interaction, + state: 'connected', + interactionId: '492c08c1-a5ff-4b90-bf39-454608082dc5', + mediaType: 'telephony', + callProcessingDetails: { + relationshipType: 'consult', + parentInteractionId: 'bf252a79-8d29-4710-ba9e-d93e9ab232e0', + }, + media: { + '492c08c1-a5ff-4b90-bf39-454608082dc5': { + mType: 'mainCall', + isHold: false, + holdTimestamp: null, + mediaResourceId: '492c08c1-a5ff-4b90-bf39-454608082dc5', + participants: ['+14696762938', agentId], + }, + }, + participants: { + '+14696762938': {pType: 'Customer', hasLeft: false}, + [agentId]: {pType: 'Agent', hasJoined: true, isConsulted: false}, + }, + }; + + const task = { + ...mockCurrentTask, + data: { + ...mockCurrentTask.data, + type: 'AgentContactAssigned', + mediaResourceId: '492c08c1-a5ff-4b90-bf39-454608082dc5', + interaction, + }, + uiControls: createMockTaskUIControls({ + activeLeg: 'main', + main: {endConsult: enabledControl}, + consult: {endConsult: disabledControl}, + }), + }; + + const {result} = renderHook(() => + useCallControl({ + currentTask: task as unknown as ITask, + logger: mockLogger, + isMuted: false, + conferenceEnabled: true, + agentId, + }) + ); + + await waitFor(() => { + expect(result.current.isHeld).toBe(true); }); + }); + + it('sets isHeld=true for EP/DN consulted agent after AgentContactUnheld when activeLeg is main', async () => { + const agentId = '9eab6238-d8f4-4a09-a26f-92efa647dbb0'; + const interaction = { + ...mockCurrentTask.data.interaction, + state: 'connected', + interactionId: '492c08c1-a5ff-4b90-bf39-454608082dc5', + mediaType: 'telephony', + callProcessingDetails: { + relationshipType: 'consult', + parentInteractionId: 'bf252a79-8d29-4710-ba9e-d93e9ab232e0', + }, + media: { + '492c08c1-a5ff-4b90-bf39-454608082dc5': { + mType: 'mainCall', + isHold: false, + holdTimestamp: null, + mediaResourceId: '492c08c1-a5ff-4b90-bf39-454608082dc5', + participants: ['+14696762938', agentId], + }, + }, + participants: { + '+14696762938': {pType: 'Customer', hasLeft: false}, + [agentId]: {pType: 'Agent', hasJoined: true, isConsulted: false}, + }, + }; + + const task = { + ...mockCurrentTask, + data: { + ...mockCurrentTask.data, + type: 'AgentContactUnheld', + mediaResourceId: '492c08c1-a5ff-4b90-bf39-454608082dc5', + interaction, + }, + state: { + context: { + taskData: { + type: 'AgentContactUnheld', + mediaResourceId: '492c08c1-a5ff-4b90-bf39-454608082dc5', + interaction, + }, + }, + }, + uiControls: createMockTaskUIControls({ + activeLeg: 'main', + main: {endConsult: enabledControl}, + consult: {endConsult: disabledControl}, + }), + }; const {result} = renderHook(() => useCallControl({ @@ -1009,20 +1843,123 @@ describe('useCallControl', () => { logger: mockLogger, isMuted: false, conferenceEnabled: true, - agentId: 'agent1', + agentId, }) ); await waitFor(() => { - expect(result.current.isHeld).toBe(false); + expect(result.current.isHeld).toBe(true); }); }); - it('forces isHeld=true on AgentContactHeld regardless of media lag', async () => { - const task = buildConferenceTask({ - eventType: 'AgentContactHeld', - mainCallIsHold: false, + it('sets isHeld=true for EP/DN consulted agent when initiator holds customer on consult leg', async () => { + const agentId = 'epdn-agent-2'; + const interaction = { + ...mockCurrentTask.data.interaction, + state: 'consulting', + interactionId: 'child-interaction-id', + mediaType: 'telephony', + callProcessingDetails: { + relationshipType: 'consult', + parentInteractionId: 'parent-interaction-id', + }, + media: { + 'epdn-main-id': { + mType: 'mainCall', + isHold: false, + mediaResourceId: 'epdn-main-id', + participants: [agentId], + }, + }, + participants: { + customer1: {pType: 'Customer', hasLeft: false}, + [agentId]: {pType: 'Agent', hasJoined: true}, + }, + }; + + const task = { + ...mockCurrentTask, + data: { + ...mockCurrentTask.data, + type: 'AgentContactUnheld', + mediaResourceId: 'epdn-main-id', + interaction, + }, + state: { + context: { + taskData: { + type: 'AgentContactUnheld', + mediaResourceId: 'epdn-main-id', + interaction: { + ...interaction, + media: { + 'epdn-main-id': { + ...interaction.media['epdn-main-id'], + isHold: false, + }, + }, + }, + }, + }, + }, + uiControls: createMockTaskUIControls({ + activeLeg: 'main', + main: {endConsult: enabledControl}, + consult: {endConsult: disabledControl}, + }), + }; + + const {result} = renderHook(() => + useCallControl({ + currentTask: task as unknown as ITask, + logger: mockLogger, + isMuted: false, + conferenceEnabled: true, + agentId, + }) + ); + + await waitFor(() => { + expect(result.current.isHeld).toBe(true); }); + }); + + it('sets isHeld=true for EP/DN consulted agent detected via main.endConsult uiControls only', async () => { + const agentId = '9eab6238-d8f4-4a09-a26f-92efa647dbb0'; + const interaction = { + ...mockCurrentTask.data.interaction, + state: 'connected', + interactionId: '492c08c1-a5ff-4b90-bf39-454608082dc5', + mediaType: 'telephony', + media: { + '492c08c1-a5ff-4b90-bf39-454608082dc5': { + mType: 'mainCall', + isHold: false, + holdTimestamp: null, + mediaResourceId: '492c08c1-a5ff-4b90-bf39-454608082dc5', + participants: ['+14696762938', agentId], + }, + }, + participants: { + '+14696762938': {pType: 'Customer', hasLeft: false}, + [agentId]: {pType: 'Agent', hasJoined: true, isConsulted: false}, + }, + }; + + const task = { + ...mockCurrentTask, + data: { + ...mockCurrentTask.data, + type: 'AgentContactAssigned', + mediaResourceId: '492c08c1-a5ff-4b90-bf39-454608082dc5', + interaction, + }, + uiControls: createMockTaskUIControls({ + activeLeg: 'main', + main: {endConsult: enabledControl}, + consult: {endConsult: disabledControl}, + }), + }; const {result} = renderHook(() => useCallControl({ @@ -1030,7 +1967,7 @@ describe('useCallControl', () => { logger: mockLogger, isMuted: false, conferenceEnabled: true, - agentId: 'agent1', + agentId, }) ); @@ -1040,6 +1977,56 @@ describe('useCallControl', () => { }); }); + it('sets isHeld=false for consulted agent when only consult media is on hold', async () => { + const consultedTask = { + ...mockCurrentTask, + data: { + ...mockCurrentTask.data, + isConsulted: true, + interaction: { + ...mockCurrentTask.data.interaction, + state: 'consulting', + media: { + 'main-id': { + mType: 'mainCall', + isHold: false, + }, + 'consult-id': { + mType: 'consult', + isHold: true, + holdTimestamp: Date.now() - 5000, + }, + }, + participants: { + customer1: {pType: 'Customer', hasLeft: false}, + }, + }, + }, + uiControls: createMockTaskUIControls({ + activeLeg: 'consult', + main: {endConsult: enabledControl}, + consult: {endConsult: enabledControl}, + }), + }; + + const {result} = renderHook(() => + useCallControl({ + currentTask: consultedTask as unknown as ITask, + onHoldResume: mockOnHoldResume, + onEnd: mockOnEnd, + onWrapUp: mockOnWrapUp, + logger: mockLogger, + isMuted: false, + conferenceEnabled: true, + agentId: 'test-agent-id', + }) + ); + + await waitFor(() => { + expect(result.current.isHeld).toBe(false); + }); + }); + it('should log an error if hold fails', async () => { mockCurrentTask.hold.mockRejectedValueOnce(new Error('Hold error')); @@ -3297,8 +4284,12 @@ describe('useCallControl', () => { const onToggleMute = jest.fn(); const logger = mockCC.LoggerProxy; + afterEach(() => { + jest.restoreAllMocks(); + }); + it('should handle errors in extractConsultingAgent', () => { - jest.spyOn(logger, 'info').mockImplementation(() => { + const infoSpy = jest.spyOn(logger, 'info').mockImplementation(() => { throw new Error('Participants access error'); }); const problematicTask = { @@ -3358,6 +4349,8 @@ describe('useCallControl', () => { method: 'extractConsultingAgent', } ); + + infoSpy.mockRestore(); }); it('should handle errors in holdCallback', () => { @@ -3399,6 +4392,7 @@ describe('useCallControl', () => { }); it('should handle errors in toggleHold', () => { + jest.spyOn(logger, 'info').mockImplementation(() => {}); const mockErrorTask = { ...taskMock, hold: jest.fn().mockImplementation(() => { @@ -3425,7 +4419,7 @@ describe('useCallControl', () => { result.current.toggleHold(true); }); - expect(logger.error).toHaveBeenCalledWith('CC-Widgets: Task: Error in toggleHold - Participants access error', { + expect(logger.error).toHaveBeenCalledWith('CC-Widgets: Task: Error in toggleHold - Hold method error', { module: 'useCallControl', method: 'toggleHold', }); @@ -3592,7 +4586,7 @@ describe('useCallControl', () => { 'consult-id': { mType: 'consult', isHold: true, - holdTimestamp: 5000, + holdTimestamp: 1700000005000, mediaResourceId: 'consult-id', participants: ['agent1'], }, @@ -3620,7 +4614,7 @@ describe('useCallControl', () => { await waitFor(() => { expect(result.current.consultTimerLabel).toBe(TIMER_LABEL_CONSULT_ON_HOLD); - expect(result.current.consultTimerTimestamp).toBe(5000); + expect(result.current.consultTimerTimestamp).toBe(1700000005000); }); }); @@ -4128,7 +5122,7 @@ describe('useCallControl', () => { 'consult-id': { mType: 'consult', isHold: true, - holdTimestamp: 5000, + holdTimestamp: 1700000005000, mediaResourceId: 'consult-id', participants: ['agent1'], }, @@ -4161,7 +5155,7 @@ describe('useCallControl', () => { ); await waitFor(() => { - expect(result.current.consultTimerTimestamp).toBe(5000); + expect(result.current.consultTimerTimestamp).toBe(1700000005000); }); // Resume from hold diff --git a/packages/contact-center/task/tests/utils/task-util.ts b/packages/contact-center/task/tests/utils/task-util.ts index 4894b7fdb..1b79a915e 100644 --- a/packages/contact-center/task/tests/utils/task-util.ts +++ b/packages/contact-center/task/tests/utils/task-util.ts @@ -1,5 +1,11 @@ import {mockTask} from '@webex/test-fixtures'; -import {findHoldTimestamp} from '../../src/Utils/task-util'; +import { + clearHoldAnchor, + findHoldTimestamp, + normalizeHoldTimestampMs, + resolveMainCadHoldTimestampMs, + writeHoldAnchor, +} from '../../src/Utils/task-util'; import {getConferenceParticipants} from '@webex/cc-store'; import {ITask, TaskData, Interaction} from '@webex/contact-center'; @@ -52,13 +58,13 @@ describe('findHoldTimestamp', () => { expect(findHoldTimestamp(interaction, 'mainCall')).toBeNull(); }); - it('returns 0 if holdTimestamp is 0', () => { + it('returns null if holdTimestamp is 0', () => { const interaction = { media: { main: {mType: 'mainCall', holdTimestamp: 0}, }, } as unknown as Interaction; - expect(findHoldTimestamp(interaction, 'mainCall')).toBe(0); + expect(findHoldTimestamp(interaction, 'mainCall')).toBeNull(); }); it('works with extra unknown properties', () => { @@ -70,6 +76,69 @@ describe('findHoldTimestamp', () => { } as unknown as Interaction; expect(findHoldTimestamp(interaction, 'mainCall')).toBe(42); }); + + it('returns retained holdTimestamp when isHold is false (consulted agent hydrate)', () => { + const interaction = { + media: { + main: { + mType: 'mainCall', + isHold: false, + holdTimestamp: 1780382271896, + }, + }, + } as unknown as Interaction; + + expect(findHoldTimestamp(interaction, 'mainCall')).toBe(1780382271896); + }); +}); + +describe('resolveMainCadHoldTimestampMs', () => { + const interactionId = 'child-interaction-id'; + + beforeEach(() => { + clearHoldAnchor(interactionId); + }); + + afterEach(() => { + clearHoldAnchor(interactionId); + }); + + it('returns null when not held', () => { + const interaction = { + interactionId, + media: { + main: {mType: 'mainCall', isHold: false, holdTimestamp: 1780382271896}, + }, + } as unknown as Interaction; + + expect(resolveMainCadHoldTimestampMs(interaction, false, interactionId)).toBeNull(); + }); + + it('normalizes media holdTimestamp to milliseconds', () => { + const interaction = { + interactionId, + media: { + main: {mType: 'mainCall', isHold: true, holdTimestamp: 1780382271896}, + }, + } as unknown as Interaction; + + expect(resolveMainCadHoldTimestampMs(interaction, true, interactionId)).toBe(1780382271896); + expect(normalizeHoldTimestampMs(1780382271)).toBe(1780382271000); + }); + + it('falls back to session anchor when media timestamp is missing', () => { + const anchorMs = Date.now() - 8000; + writeHoldAnchor(interactionId, anchorMs); + + const interaction = { + interactionId, + media: { + main: {mType: 'mainCall', isHold: false, holdTimestamp: null}, + }, + } as unknown as Interaction; + + expect(resolveMainCadHoldTimestampMs(interaction, true, interactionId)).toBe(anchorMs); + }); }); describe('getConferenceParticipants', () => { diff --git a/packages/contact-center/task/tests/utils/timer-utils.test.ts b/packages/contact-center/task/tests/utils/timer-utils.test.ts index c5eb5c763..b0fe98687 100644 --- a/packages/contact-center/task/tests/utils/timer-utils.test.ts +++ b/packages/contact-center/task/tests/utils/timer-utils.test.ts @@ -7,7 +7,12 @@ import { TIMER_LABEL_CONSULT_REQUESTED, } from '../../src/Utils/constants'; import {ITask} from '@webex/cc-store'; -import {createEnabledMainTaskUIControls, disabledControl, enabledControl} from '@webex/test-fixtures'; +import { + createEnabledMainTaskUIControls, + createMockTaskUIControls, + disabledControl, + enabledControl, +} from '@webex/test-fixtures'; const defaultControls = createEnabledMainTaskUIControls(); const wrapUpControls = createEnabledMainTaskUIControls({wrapup: enabledControl}); @@ -216,7 +221,7 @@ describe('timer-utils', () => { 'consult-id': { mType: 'consult', isHold: true, - holdTimestamp: 5000, + holdTimestamp: 1700000005000, mediaResourceId: 'consult-id', participants: ['agent1'], }, @@ -232,13 +237,15 @@ describe('timer-utils', () => { const result = calculateConsultTimerData(mockTask, defaultControls, 'agent1'); expect(result.label).toBe(TIMER_LABEL_CONSULT_ON_HOLD); - expect(result.timestamp).toBe(5000); + expect(result.timestamp).toBe(1700000005000); }); - it('should fallback to consulting when consult hold timestamp is 0', () => { + it('should return Consult on Hold when consult is held without holdTimestamp (EP/DN lag)', () => { + const before = Date.now(); const mockTask = { data: { interaction: { + interactionId: 'epdn-interaction-1', media: { 'consult-id': { mType: 'consult', @@ -258,8 +265,96 @@ describe('timer-utils', () => { } as unknown as ITask; const result = calculateConsultTimerData(mockTask, defaultControls, 'agent1'); - expect(result.label).toBe(TIMER_LABEL_CONSULTING); - expect(result.timestamp).toBe(2000); + expect(result.label).toBe(TIMER_LABEL_CONSULT_ON_HOLD); + expect(result.timestamp).toBeGreaterThanOrEqual(before); + }); + + it('should return Consult on Hold when initiator switches to main before consult media isHold (EP/DN)', () => { + const mockTask = { + data: { + interaction: { + interactionId: 'epdn-interaction-2', + media: { + 'consult-id': { + mType: 'consult', + isHold: false, + mediaResourceId: 'consult-id', + participants: ['agent1'], + }, + }, + participants: { + agent1: { + consultTimestamp: 2000, + }, + }, + }, + }, + state: { + context: { + taskData: { + interaction: { + interactionId: 'epdn-interaction-2', + media: { + 'consult-id': { + mType: 'consult', + isHold: true, + holdTimestamp: 1700000005000, + mediaResourceId: 'consult-id', + participants: ['agent1'], + }, + }, + participants: { + agent1: { + consultTimestamp: 2000, + }, + }, + }, + }, + }, + }, + } as unknown as ITask; + + const controls = createMockTaskUIControls({ + activeLeg: 'main', + consult: {endConsult: enabledControl}, + }); + + const result = calculateConsultTimerData(mockTask, controls, 'agent1'); + expect(result.label).toBe(TIMER_LABEL_CONSULT_ON_HOLD); + expect(result.timestamp).toBe(1700000005000); + }); + + it('should return Consult on Hold via activeLeg main when consult media lags (EP/DN switch-to-main)', () => { + const before = Date.now(); + const mockTask = { + data: { + interaction: { + interactionId: 'epdn-interaction-3', + media: { + 'consult-id': { + mType: 'consult', + isHold: false, + mediaResourceId: 'consult-id', + participants: ['agent1'], + }, + }, + participants: { + agent1: { + consultTimestamp: 2000, + }, + }, + }, + }, + } as unknown as ITask; + + const controls = createMockTaskUIControls({ + activeLeg: 'main', + main: {endConsult: enabledControl}, + }); + + const result = calculateConsultTimerData(mockTask, controls, 'agent1'); + expect(result.label).toBe(TIMER_LABEL_CONSULT_ON_HOLD); + expect(result.timestamp).toBeGreaterThanOrEqual(before); }); it('should return default when no consult timestamp available', () => { diff --git a/packages/contact-center/task/tests/utils/useHoldTimer.test.ts b/packages/contact-center/task/tests/utils/useHoldTimer.test.ts index 0f40f09b6..db54f5270 100644 --- a/packages/contact-center/task/tests/utils/useHoldTimer.test.ts +++ b/packages/contact-center/task/tests/utils/useHoldTimer.test.ts @@ -1,15 +1,6 @@ import {act, renderHook, waitFor} from '@testing-library/react'; import {useHoldTimer} from '../../src/Utils/useHoldTimer'; -import {ITask} from '@webex/cc-store'; -import {createEnabledMainTaskUIControls, createMockTaskUIControls, enabledControl} from '@webex/test-fixtures'; - -jest.mock('../../src/Utils/task-util', () => ({ - findHoldTimestamp: jest.fn(), -})); - -import {findHoldTimestamp} from '../../src/Utils/task-util'; - -const mockFindHoldTimestamp = findHoldTimestamp as jest.MockedFunction; +import {clearHoldAnchor, getHoldAnchorStorageKey, readHoldAnchor, writeHoldAnchor} from '../../src/Utils/task-util'; interface WorkerMessage { type: string; @@ -54,256 +45,94 @@ class MockWorker { global.Worker = MockWorker as unknown as typeof Worker; global.URL.createObjectURL = jest.fn(() => 'mock-url'); -const defaultControls = createEnabledMainTaskUIControls(); - describe('useHoldTimer', () => { + const interactionId = 'interaction-123'; + beforeEach(() => { jest.clearAllMocks(); jest.useFakeTimers(); + clearHoldAnchor(interactionId); }); afterEach(() => { + clearHoldAnchor(interactionId); act(() => { jest.runOnlyPendingTimers(); }); jest.useRealTimers(); }); - it('should return 0 when currentTask is null', () => { - mockFindHoldTimestamp.mockReturnValue(null); - - const {result} = renderHook(() => useHoldTimer(null, defaultControls)); + it('should return 0 when mainCallOnHold is false', () => { + const {result} = renderHook(() => useHoldTimer(false, null)); expect(result.current).toBe(0); }); - it('should return 0 when no hold timestamp found', () => { - mockFindHoldTimestamp.mockReturnValue(null); + it('should set initial hold time when hold timestamp is provided', () => { + const holdTimestampMs = Date.now() - 5000; - const mockTask = { - data: { - interaction: { - media: {}, - }, - }, - } as unknown as ITask; - - const {result} = renderHook(() => useHoldTimer(mockTask, defaultControls)); - - expect(result.current).toBe(0); - }); - - it('should set initial hold time when call is on hold', () => { - const holdTimestamp = Date.now() - 5000; - mockFindHoldTimestamp.mockImplementation((_task, mType) => { - if (mType === 'consult') return null; - if (mType === 'mainCall') return holdTimestamp; - return null; - }); - - const mockTask = { - data: { - interaction: { - media: { - 'main-id': { - mType: 'mainCall', - isHold: true, - holdTimestamp: holdTimestamp, - }, - }, - participants: { - customer1: {pType: 'Customer', hasLeft: false}, - }, - }, - }, - } as unknown as ITask; - - const {result} = renderHook(() => useHoldTimer(mockTask, defaultControls)); + const {result} = renderHook(() => useHoldTimer(true, holdTimestampMs, 0, interactionId)); expect(result.current).toBeGreaterThanOrEqual(4); expect(result.current).toBeLessThanOrEqual(6); }); - it('should show hold timer on main call while consulting when activeLeg is consult', () => { - const mainCallHoldTimestamp = Date.now() - 3000; + it('should reuse session anchor on refresh when backend timestamp is missing', () => { + const anchorMs = Date.now() - 12000; + writeHoldAnchor(interactionId, anchorMs); - mockFindHoldTimestamp.mockImplementation((_task, mType) => { - if (mType === 'mainCall') return mainCallHoldTimestamp; - return null; - }); + const {result} = renderHook(() => useHoldTimer(true, null, 0, interactionId)); - const mockTask = { - data: { - interaction: { - media: { - 'main-id': { - mType: 'mainCall', - isHold: false, - holdTimestamp: mainCallHoldTimestamp, - }, - }, - participants: { - customer1: {pType: 'Customer', hasLeft: false}, - }, - }, - }, - } as unknown as ITask; - - const controls = createMockTaskUIControls({ - activeLeg: 'consult', - main: {endConsult: enabledControl}, - consult: {endConsult: enabledControl}, - }); - - const {result} = renderHook(() => useHoldTimer(mockTask, controls)); - - expect(result.current).toBeGreaterThanOrEqual(2); - expect(result.current).toBeLessThanOrEqual(4); + expect(result.current).toBeGreaterThanOrEqual(11); + expect(result.current).toBeLessThanOrEqual(13); + expect(readHoldAnchor(interactionId)).toBe(anchorMs); }); - it('should handle timestamp in seconds and convert to milliseconds', () => { - const timestampInSeconds = Math.floor(Date.now() / 1000) - 7; - mockFindHoldTimestamp.mockImplementation((_task, mType) => { - if (mType === 'mainCall') return timestampInSeconds; - return null; - }); + it('should persist a new anchor when hold starts without backend timestamp', () => { + const now = Date.now(); + jest.setSystemTime(now); - const mockTask = { - data: { - interaction: { - media: { - 'main-id': { - mType: 'mainCall', - isHold: true, - holdTimestamp: timestampInSeconds, - }, - }, - participants: { - customer1: {pType: 'Customer', hasLeft: false}, - }, - }, - }, - } as unknown as ITask; - - const {result} = renderHook(() => useHoldTimer(mockTask, defaultControls)); - - expect(result.current).toBeGreaterThanOrEqual(6); - expect(result.current).toBeLessThanOrEqual(8); - }); + renderHook(() => useHoldTimer(true, null, 0, interactionId)); - it('should update hold time when currentTask changes', async () => { - const initialHoldTimestamp = Date.now() - 5000; - mockFindHoldTimestamp.mockImplementation((_task, mType) => { - if (mType === 'mainCall') return initialHoldTimestamp; - return null; - }); + expect(readHoldAnchor(interactionId)).toBe(now); + }); - const mockTask1 = { - data: { - interaction: { - media: { - 'main-id': { - mType: 'mainCall', - isHold: true, - holdTimestamp: initialHoldTimestamp, - }, - }, - participants: { - customer1: {pType: 'Customer', hasLeft: false}, - }, - }, - }, - } as unknown as ITask; - - const {result, rerender} = renderHook(({task}) => useHoldTimer(task, defaultControls), { - initialProps: {task: mockTask1}, - }); + it('should reset to 0 and clear anchor when mainCallOnHold becomes false', async () => { + const holdTimestampMs = Date.now() - 5000; + writeHoldAnchor(interactionId, holdTimestampMs); - const initialHoldTime = result.current; - expect(initialHoldTime).toBeGreaterThan(0); + const {result, rerender} = renderHook( + ({onHold, timestampMs}) => useHoldTimer(onHold, timestampMs, 0, interactionId), + {initialProps: {onHold: true, timestampMs: holdTimestampMs}} + ); - const newHoldTimestamp = Date.now() - 10000; - mockFindHoldTimestamp.mockImplementation((_task, mType) => { - if (mType === 'mainCall') return newHoldTimestamp; - return null; - }); + expect(result.current).toBeGreaterThan(0); - const mockTask2 = { - data: { - interaction: { - media: { - 'main-id': { - mType: 'mainCall', - isHold: true, - holdTimestamp: newHoldTimestamp, - }, - }, - participants: { - customer1: {pType: 'Customer', hasLeft: false}, - }, - }, - }, - } as unknown as ITask; - - rerender({task: mockTask2}); + rerender({onHold: false, timestampMs: null}); await waitFor(() => { - expect(result.current).toBeGreaterThan(initialHoldTime); + expect(result.current).toBe(0); }); + expect(readHoldAnchor(interactionId)).toBeNull(); }); - it('should reset to 0 when call is resumed', async () => { - const holdTimestamp = Date.now() - 5000; - mockFindHoldTimestamp.mockImplementation((_task, mType) => { - if (mType === 'mainCall') return holdTimestamp; - return null; - }); + it('should restart timer when holdDataVersion bumps', async () => { + const holdTimestampMs = Date.now() - 3000; - const mockTask1 = { - data: { - interaction: { - media: { - 'main-id': { - mType: 'mainCall', - isHold: true, - holdTimestamp: holdTimestamp, - }, - }, - participants: { - customer1: {pType: 'Customer', hasLeft: false}, - }, - }, - }, - } as unknown as ITask; - - const {result, rerender} = renderHook(({task}) => useHoldTimer(task, defaultControls), { - initialProps: {task: mockTask1}, + const {result, rerender} = renderHook(({version}) => useHoldTimer(true, holdTimestampMs, version, interactionId), { + initialProps: {version: 0}, }); expect(result.current).toBeGreaterThan(0); - mockFindHoldTimestamp.mockReturnValue(null); - - const mockTask2 = { - data: { - interaction: { - media: { - 'main-id': { - mType: 'mainCall', - isHold: false, - }, - }, - participants: { - customer1: {pType: 'Customer', hasLeft: false}, - }, - }, - }, - } as unknown as ITask; - - rerender({task: mockTask2}); + rerender({version: 1}); await waitFor(() => { - expect(result.current).toBe(0); + expect(result.current).toBeGreaterThan(0); }); }); + + it('uses expected session storage key', () => { + expect(getHoldAnchorStorageKey(interactionId)).toBe(`cc-widget-hold-anchor:${interactionId}`); + }); });