Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -255,7 +255,7 @@ Returns an object of AQM request methods for outbound dialing.
- Constructor accepts `contact: ReturnType<typeof routingContact>`.
- 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(...)`.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()`.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
28 changes: 24 additions & 4 deletions packages/@webex/contact-center/src/services/task/voice/Voice.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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;
Comment on lines +73 to +74

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Require consult ownership before selecting consult transfer

When an incoming or EP-DN consult is assigned with isConsulted: true while the raw interaction remains connected, guards.isConsultingAssignment moves the receiver's state machine to CONSULTING, even though uiControlsComputer.ts deliberately disables transfer when !consultInitiator. This new snapshot check therefore makes a direct call to the public transfer() method issue contact.consultTransfer from the consulted receiver, which cannot bridge the customer and consult legs. Include initiator/ownership in this decision rather than treating every CONSULTING snapshot as transferable.

Useful? React with 👍 / 👎.

}

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.
Expand Down Expand Up @@ -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,
Expand All @@ -630,7 +650,7 @@ export default class Voice extends Task implements IVoice {
]);

// consult transfer path
if (this.data.interaction.state === 'consulting') {
if (isConsultTransfer) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Record the transfer-routing change in the contract catalog

This changes the routing semantics of the public Voice.transfer() API, but the commit updates only the task spec/source-material architecture document and leaves ai-docs/CONTRACTS.md and .sdd/manifest.json unchanged. The package workflow explicitly requires both artifacts to be updated when public behavior or routing changes, so add the corresponding contract and manifest updates to keep the authoritative catalog aligned with runtime behavior.

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'])
Expand All @@ -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(
Expand Down Expand Up @@ -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 || {}),
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Replace the new Jest mocks with Sinon stubs

The newly added transfer-contact helper creates its mocks with jest.fn(), contrary to the repository's explicit requirement that mocks and stubs use Sinon. Converting these new doubles to Sinon also prevents this change from further coupling the test helper to Jest-specific mock APIs.

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({
Expand Down
Loading