diff --git a/packages/@webex/contact-center/src/services/task/ai-docs/ARCHITECTURE.md b/packages/@webex/contact-center/src/services/task/ai-docs/ARCHITECTURE.md index 21c1ddda3fd..6e9fb23bb99 100644 --- a/packages/@webex/contact-center/src/services/task/ai-docs/ARCHITECTURE.md +++ b/packages/@webex/contact-center/src/services/task/ai-docs/ARCHITECTURE.md @@ -255,7 +255,7 @@ Returns an object of AQM request methods for outbound dialing. - Constructor accepts `contact: ReturnType`. - Uses: - `contact.vteamTransfer` / `contact.blindTransfer` in `transfer(...)`. - - While in consulting state, `transfer(...)` internally routes through consult-transfer behavior. + - While in consulting state, `transfer(...)` internally routes through consult-transfer behavior. "Consulting state" is the task's derived lifecycle state (state machine `CONSULTING`, else `getTaskStateForUiControls(...) === CONSULTING`), never the raw `interaction.state` string, which reports only the main call leg and flips to `connected`/`hold` when a conference downgrades while a consult is still live. - `contact.end` in `end()`. - `contact.wrapup` in `wrapup(...)`. diff --git a/packages/@webex/contact-center/src/services/task/ai-docs/task-spec.md b/packages/@webex/contact-center/src/services/task/ai-docs/task-spec.md index ae72ec9f8fe..5ec6a9b50d3 100644 --- a/packages/@webex/contact-center/src/services/task/ai-docs/task-spec.md +++ b/packages/@webex/contact-center/src/services/task/ai-docs/task-spec.md @@ -516,7 +516,7 @@ Returns an object of AQM request methods for outbound dialing. - `contact.vteamTransfer` / `contact.blindTransfer` in `transfer(...)`. -- While in consulting state, `transfer(...)` internally routes through consult-transfer behavior. +- While in consulting state, `transfer(...)` internally routes through consult-transfer behavior. "Consulting state" is the task's derived lifecycle state (state machine `CONSULTING`, else `getTaskStateForUiControls(...) === CONSULTING`), never the raw `interaction.state` string, which reports only the main call leg and flips to `connected`/`hold` when a conference downgrades while a consult is still live. - `contact.end` in `end()`. diff --git a/packages/@webex/contact-center/src/services/task/state-machine/index.ts b/packages/@webex/contact-center/src/services/task/state-machine/index.ts index eabe009a79f..9810728ae17 100644 --- a/packages/@webex/contact-center/src/services/task/state-machine/index.ts +++ b/packages/@webex/contact-center/src/services/task/state-machine/index.ts @@ -25,4 +25,4 @@ export {guards} from './guards'; export type {GuardParams, GuardFunction} from './guards'; // Actions -export {actions, createInitialContext} from './actions'; +export {actions, createInitialContext, getTaskStateForUiControls} from './actions'; diff --git a/packages/@webex/contact-center/src/services/task/voice/Voice.ts b/packages/@webex/contact-center/src/services/task/voice/Voice.ts index 46a8b897d4b..0fcc81608c1 100644 --- a/packages/@webex/contact-center/src/services/task/voice/Voice.ts +++ b/packages/@webex/contact-center/src/services/task/voice/Voice.ts @@ -25,7 +25,7 @@ import Task from '../Task'; import LoggerProxy from '../../../logger-proxy'; import MetricsManager from '../../../metrics/MetricsManager'; import {METRIC_EVENT_NAMES} from '../../../metrics/constants'; -import {TaskState, TaskEvent, TaskActionArgs} from '../state-machine'; +import {TaskState, TaskEvent, TaskActionArgs, getTaskStateForUiControls} from '../state-machine'; import {WrapupData} from '../../config/types'; import {getConsultMediaResourceId, getIsConferenceInProgress} from '../TaskUtils'; @@ -59,6 +59,24 @@ export default class Voice extends Task implements IVoice { return this.stateMachineService?.getSnapshot?.(); } + /** + * Whether this agent has a live consult leg, and therefore whether `transfer()` must bridge + * that leg via consult transfer rather than blind transfer the main call. + * + * `interaction.state` reports the main call leg only, so it cannot be used on its own: when a + * conference downgrades to a 1:1 call while a consult is still active (another agent exits the + * conference mid-consult), the backend reports `connected`/`hold` even though this agent's + * consult leg is untouched. Derive the state the same way the UI controls do, so the transfer + * button and this method can never disagree about which kind of transfer they mean. + */ + private hasActiveConsultLeg(): boolean { + if (this.getStateMachineSnapshot()?.matches(TaskState.CONSULTING)) { + return true; + } + + return getTaskStateForUiControls(this.data, this.data?.agentId) === TaskState.CONSULTING; + } + /** * This method is used to accept the task. * It is expected to be overridden by child classes. @@ -618,6 +636,8 @@ export default class Voice extends Task implements IVoice { * ``` */ public async transfer(payload: TransferPayLoad): Promise { + const isConsultTransfer = this.hasActiveConsultLeg(); + try { LoggerProxy.info(`Transferring task to ${payload.to}`, { module: CC_FILE, @@ -630,7 +650,7 @@ export default class Voice extends Task implements IVoice { ]); // consult transfer path - if (this.data.interaction.state === 'consulting') { + if (isConsultTransfer) { const normalizedDestinationType = payload.destinationType === 'Agent' || payload.destinationType === 'Queue' ? (payload.destinationType.toLowerCase() as ConsultTransferPayLoad['destinationType']) @@ -653,7 +673,7 @@ export default class Voice extends Task implements IVoice { } const result = await this.contact.consultTransfer({ - interactionId: this.data.interactionId, + interactionId: this.data.interaction?.mainInteractionId || this.data.interactionId, data: consultPayload, }); this.metricsManager.trackEvent( @@ -690,7 +710,7 @@ export default class Voice extends Task implements IVoice { taskId: this.data.interactionId, destination: payload.to, destinationType: payload.destinationType, - isConsultTransfer: this.data.interaction.state === 'consulting', + isConsultTransfer, error: err.toString(), ...MetricsManager.getCommonTrackingFieldForAQMResponseFailed(err.details || {}), }, diff --git a/packages/@webex/contact-center/test/unit/spec/services/task/voice/Voice.ts b/packages/@webex/contact-center/test/unit/spec/services/task/voice/Voice.ts index 24f77b42ad6..9a75d106d7b 100644 --- a/packages/@webex/contact-center/test/unit/spec/services/task/voice/Voice.ts +++ b/packages/@webex/contact-center/test/unit/spec/services/task/voice/Voice.ts @@ -299,6 +299,147 @@ describe('Voice Task', () => { }); }); + describe('consult transfer after conference downgrade', () => { + const CUSTOMER = 'customer1'; + const SELF = 'agent1'; + const EXITED_AGENT = 'agent2'; + const CONSULT_DEST = 'agent3'; + const CONSULT_MEDIA = 'consult-media'; + + const createTransferContact = () => ({ + ...dummyContact, + consultTransfer: jest.fn().mockResolvedValue('consultTransferred'), + blindTransfer: jest.fn().mockResolvedValue('blindTransferred'), + vteamTransfer: jest.fn().mockResolvedValue('vteamTransferred'), + }); + + /** + * Customer + self + a second agent were conferenced and self is consulting CONSULT_DEST. + * The second agent then exits, so the backend downgrades the main leg back to a 1:1 + * `connected` customer call while self's consult leg stays live. + */ + const createDowngradedConferenceConsultData = ( + mainInteractionId: string, + owner: string + ): TaskData => + createTaskData({ + interactionId: 'int1', + agentId: SELF, + mediaResourceId: mainInteractionId, + consultMediaResourceId: CONSULT_MEDIA, + isConsulted: false, + consultingAgentId: SELF, + interaction: { + state: 'connected', + mainInteractionId, + owner, + participants: { + [CUSTOMER]: {id: CUSTOMER, pType: 'Customer', hasLeft: false}, + [SELF]: {id: SELF, pType: 'Agent', hasLeft: false, consultState: 'consulting'}, + [EXITED_AGENT]: {id: EXITED_AGENT, pType: 'Agent', hasLeft: true}, + [CONSULT_DEST]: { + id: CONSULT_DEST, + pType: 'Agent', + hasLeft: false, + isConsulted: true, + hasJoined: true, + consultState: 'consulting', + }, + }, + media: { + [mainInteractionId]: { + mediaResourceId: mainInteractionId, + isHold: false, + mType: 'mainCall', + participants: [CUSTOMER, SELF], + }, + [CONSULT_MEDIA]: { + mediaResourceId: CONSULT_MEDIA, + isHold: false, + mType: 'consult', + participants: [SELF, CONSULT_DEST], + }, + }, + callProcessingDetails: {consultDestinationAgentJoined: 'true'}, + } as any, + }); + + it.each([ + { + scenario: 'TC-15: self is the conference owner', + mainInteractionId: 'int1', + owner: SELF, + }, + { + scenario: 'TC-16: the exiting agent was the owner', + mainInteractionId: 'main-int', + owner: EXITED_AGENT, + }, + ])( + 'routes to consult transfer and targets the main interaction ($scenario)', + async ({mainInteractionId, owner}) => { + const contact = createTransferContact(); + const taskData = createDowngradedConferenceConsultData(mainInteractionId, owner); + const voice = new Voice(contact as any, taskData as any, {}); + + await voice.transfer({to: CONSULT_DEST, destinationType: 'agent'} as any); + + expect(contact.consultTransfer).toHaveBeenCalledWith({ + interactionId: mainInteractionId, + data: {to: CONSULT_DEST, destinationType: 'agent'}, + }); + expect(contact.blindTransfer).not.toHaveBeenCalled(); + } + ); + + it('routes to consult transfer when only the state machine reports consulting', async () => { + const contact = createTransferContact(); + // Backend reports a plain connected main leg and sends no consult media, so the + // consult can only be detected from the task's own lifecycle state. + const taskData = createBaseData({ + agentId: SELF, + interaction: {state: 'connected'} as any, + }); + const voice = new Voice(contact as any, taskData as any, {}); + + primeConnectedState(voice, taskData); + voice.stateMachineService?.send({ + type: TaskEvent.CONSULT, + destination: CONSULT_DEST, + destinationType: 'agent', + }); + voice.stateMachineService?.send({type: TaskEvent.CONSULT_SUCCESS, taskData}); + expect(voice.stateMachineService?.getSnapshot().value).toBe(TaskState.CONSULTING); + + await voice.transfer({to: CONSULT_DEST, destinationType: 'agent'} as any); + + expect(contact.consultTransfer).toHaveBeenCalledWith({ + interactionId: 'int1', + data: {to: CONSULT_DEST, destinationType: 'agent'}, + }); + expect(contact.blindTransfer).not.toHaveBeenCalled(); + }); + + it('still blind transfers a connected task with no consult leg', async () => { + const contact = createTransferContact(); + const taskData = createBaseData({ + agentId: SELF, + interaction: {state: 'connected'} as any, + }); + const voice = new Voice(contact as any, taskData as any, {}); + + primeConnectedState(voice, taskData); + + await voice.transfer({to: CONSULT_DEST, destinationType: 'agent'} as any); + + expect(contact.blindTransfer).toHaveBeenCalledWith({ + interactionId: 'int1', + data: {to: CONSULT_DEST, destinationType: 'agent'}, + }); + expect(contact.consultTransfer).not.toHaveBeenCalled(); + }); + }); + it('uses preserved consult destination from task data for queue consult transfer', async () => { const consultTransferMock = jest.fn().mockResolvedValue('consultedQ'); const dataWithState = createBaseData({