-
Notifications
You must be signed in to change notification settings - Fork 405
fix(contact-center): route transfers through consult transfer when consult leg remains active after conference downgrade #5129
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: next
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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<TaskResponse> { | ||
| 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) { | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
This changes the routing semantics of the public AGENTS.md reference: packages/@webex/contact-center/AGENTS.md:L66-L70 Useful? React with 👍 / 👎. |
||
| 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 || {}), | ||
| }, | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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'), | ||
|
Comment on lines
+310
to
+313
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
The newly added transfer-contact helper creates its mocks with AGENTS.md reference: AGENTS.md:L43-L47 Useful? React with 👍 / 👎. |
||
| }); | ||
|
|
||
| /** | ||
| * 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({ | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When an incoming or EP-DN consult is assigned with
isConsulted: truewhile the raw interaction remainsconnected,guards.isConsultingAssignmentmoves the receiver's state machine toCONSULTING, even thoughuiControlsComputer.tsdeliberately disables transfer when!consultInitiator. This new snapshot check therefore makes a direct call to the publictransfer()method issuecontact.consultTransferfrom the consulted receiver, which cannot bridge the customer and consult legs. Include initiator/ownership in this decision rather than treating everyCONSULTINGsnapshot as transferable.Useful? React with 👍 / 👎.