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 @@ -375,6 +375,12 @@ export default class TaskManager extends EventEmitter {
task.sendStateMachineEvent(stateMachineEvent);
}

// Emit TASK_POST_CALL_ACTIVITY for ParticipantPostCallActivity events so
// consumers (Widgets) can detect the interaction state change to post_call.
if (eventContext.eventType === CC_EVENTS.PARTICIPANT_POST_CALL_ACTIVITY) {
task.emit(TASK_EVENTS.TASK_POST_CALL_ACTIVITY, task);
}

// Send transcript start/stop events for relevant CC events
this.requestRealTimeTranscripts(eventContext.eventType, payload.interactionId);
});
Expand Down Expand Up @@ -655,8 +661,7 @@ export default class TaskManager extends EventEmitter {
const {payload} = context;
let task = context.task;

if (payload.childInteractionId) {
// remove the child task from collection
if (payload.childInteractionId && this.taskCollection[payload.childInteractionId]) {
this.removeTaskFromCollection(this.taskCollection[payload.childInteractionId]);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import {setup} from 'xstate';
import {TaskContext, TaskEventPayload, UIControlConfig, TaskActionsMap} from './types';
import {TaskState, TaskEvent} from './constants';
import {actions, createInitialContext} from './actions';
import {guards} from './guards';
import {guards, shouldWrapUpForThisAgent, getTaskDataFromEvent} from './guards';
import {getIsCustomerInCall} from '../TaskUtils';

type TaskActionConfigMap = {[K in keyof typeof actions]: undefined};
Expand Down Expand Up @@ -323,6 +323,12 @@ export function getTaskStateMachineConfig(uiControlConfig: UIControlConfig) {

[TaskState.HELD]: {
on: {
[TaskEvent.PAUSE_RECORDING]: {
actions: ['updateTaskData', 'setRecordingState', 'emitTaskRecordingPaused'],
},
[TaskEvent.RESUME_RECORDING]: {
actions: ['updateTaskData', 'setRecordingState', 'emitTaskRecordingResumed'],
},
// Click of the unhold button
[TaskEvent.UNHOLD_INITIATED]: {
target: TaskState.RESUME_INITIATING,
Expand Down Expand Up @@ -467,6 +473,26 @@ export function getTaskStateMachineConfig(uiControlConfig: UIControlConfig) {
target: TaskState.CONNECTED,
actions: ['updateTaskData', 'clearConsultState', 'emitTaskConsultEnd'],
},
{
// Interaction terminated during consult (customer left) → WRAPPING_UP
guard: ({context, event}) => {
if (context.consultInitiator !== true) return false;
const taskData = getTaskDataFromEvent(event);

Comment on lines +477 to +481

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 Reorder consult-end guards to honor terminated consults

The new CONSULT_END wrap-up branch is placed after the existing consultCallHeld branch, but XState evaluates transition options in order and takes the first passing guard. When an initiator has switched back to the main leg (consultCallHeld === true) and the customer disconnects (interaction.isTerminated === true), the machine will still transition to CONNECTED via the earlier branch, so the new WRAPPING_UP path never runs and wrap-up/cleanup can be skipped for that ended interaction.

Useful? React with 👍 / 👎.

return (
taskData?.interaction?.isTerminated === true &&
shouldWrapUpForThisAgent(context, taskData)
);
},
target: TaskState.WRAPPING_UP,
actions: [
'updateTaskData',
'markEnded',
'clearConsultState',
'emitTaskWrapup',
'requestCleanup',
],
},
{
// Initiator (no conference) → HELD
guard: ({context}) => context.consultInitiator === true,
Expand Down Expand Up @@ -587,7 +613,12 @@ export function getTaskStateMachineConfig(uiControlConfig: UIControlConfig) {
// AgentConsultConferenced, ParticipantJoinedConference
[TaskEvent.CONFERENCE_START]: {
target: TaskState.CONFERENCING,
actions: ['handleConferenceStarted', 'clearConsultState'],
actions: [
'updateTaskData',
'syncTaskDataFromEvent',
'handleConferenceStarted',
'clearConsultState',
],
},
},
},
Expand All @@ -597,7 +628,12 @@ export function getTaskStateMachineConfig(uiControlConfig: UIControlConfig) {
// AgentConsultConferenced, ParticipantJoinedConference
[TaskEvent.CONFERENCE_START]: {
target: TaskState.CONFERENCING,
actions: ['handleConferenceStarted'],
actions: [
'updateTaskData',
'syncTaskDataFromEvent',
'handleConferenceStarted',
'clearConsultState',
],
},
// AgentConsultConferenceFailed
[TaskEvent.CONFERENCE_FAILED]: {
Expand All @@ -610,7 +646,12 @@ export function getTaskStateMachineConfig(uiControlConfig: UIControlConfig) {
[TaskState.CONFERENCING]: {
on: {
[TaskEvent.CONFERENCE_START]: {
actions: ['updateTaskData', 'clearConsultState', 'emitTaskConferenceStarted'],
actions: [
'updateTaskData',
'syncTaskDataFromEvent',
'clearConsultState',
'emitTaskConferenceStarted',
],
},
[TaskEvent.EXIT_CONFERENCE_SUCCESS]: [
{
Expand All @@ -633,7 +674,7 @@ export function getTaskStateMachineConfig(uiControlConfig: UIControlConfig) {
actions: ['updateTaskData', 'setHoldState', 'emitTaskHold'],
},
[TaskEvent.UNHOLD_SUCCESS]: {
actions: ['updateTaskData', 'setHoldState', 'emitTaskResume'],
actions: ['updateTaskData', 'syncTaskDataFromEvent', 'setHoldState', 'emitTaskResume'],
},

// Start a new consult from within an active conference
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,13 @@ import {
TaskActionArgs,
RecordingStateUpdate,
} from './types';
import {TaskEvent, TaskState} from './constants';
import {
TaskEvent,
TaskState,
INTERACTION_STATE,
CONSULT_STATE,
MEDIA_TYPE_CONSULT,
} from './constants';
import {DestinationType, TaskData} from '../types';
import {computeUIControls, getDefaultUIControls} from './uiControlsComputer';

Expand Down Expand Up @@ -73,6 +79,19 @@ const deriveRecordingState = (taskData?: TaskData | null): RecordingStateUpdate
return update;
};

const isActiveConsultState = (taskData: TaskData | undefined, selfAgentId?: string): boolean => {
if (taskData?.interaction?.state === INTERACTION_STATE.CONSULTING) return true;
if (taskData?.interaction?.state === INTERACTION_STATE.POST_CALL && selfAgentId) {
const selfParticipant = taskData.interaction?.participants?.[selfAgentId] as any;
const hasConsultMedia = Object.values(taskData.interaction?.media ?? {}).some(
(media: any) => media?.mType === MEDIA_TYPE_CONSULT
);
if (selfParticipant?.consultState === CONSULT_STATE.CONSULTING && hasConsultMedia) return true;
}

return false;
};

const deriveTaskDataUpdates = (context: TaskContext, taskData: TaskData | undefined) =>
taskData
? (() => {
Expand All @@ -81,27 +100,30 @@ const deriveTaskDataUpdates = (context: TaskContext, taskData: TaskData | undefi
...deriveRecordingState(taskData),
};

const selfAgentId = context.uiControlConfig.agentId ?? taskData?.agentId;
const consultingActive = isActiveConsultState(taskData, selfAgentId);

if (taskData.destAgentId) {
updates.consultDestinationAgentId = taskData.destAgentId;
const isEpDnWithStoredId =
context.consultDestinationType === 'entryPoint' && context.consultDestinationAgentId;
if (!isEpDnWithStoredId) {
updates.consultDestinationAgentId = taskData.destAgentId;
}
}
if (taskData.interaction?.state === 'consulting' && taskData.destinationType) {
if (consultingActive && taskData.destinationType) {
updates.consultDestinationType = taskData.destinationType as DestinationType;
}

if (!context.consultInitiator) {
const selfAgentId = context.uiControlConfig.agentId ?? taskData?.agentId;
const consultInitiator = determineConsultInitiator(taskData, selfAgentId);
if (consultInitiator !== undefined) {
updates.consultInitiator = consultInitiator;
} else if (
taskData.interaction?.state === 'consulting' &&
taskData.isConsulted === false
) {
} else if (consultingActive && taskData.isConsulted === false) {
updates.consultInitiator = true;
}
}

if (taskData.interaction?.state === 'consulting') {
if (consultingActive && taskData.interaction) {
if (!context.consultDestinationAgentJoined) {
const hasJoinedConsultee = Boolean(
taskData.interaction.participants &&
Expand Down Expand Up @@ -230,7 +252,10 @@ export const actions: TaskActionsMap = {
const taskData = getTaskDataFromEvent(event);
const consultDestinationType =
'destinationType' in event ? event.destinationType ?? null : null;
const consultDestinationAgentId = 'destAgentId' in event ? event.destAgentId ?? null : null;
const consultDestinationAgentId =
('destAgentId' in event ? event.destAgentId : null) ??
('destination' in event ? (event as any).destination : null) ??
null;

return {
consultDestinationType,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,25 @@ export const MEDIA_TYPE_CONSULT = 'consult';
/** Media type for main calls */
export const MEDIA_TYPE_MAIN_CALL = 'mainCall';

// ============================================
// Backend Interaction State Constants
// ============================================

/** Backend interaction state values (from server payloads) */
export const INTERACTION_STATE = {
CONSULTING: 'consulting',
POST_CALL: 'post_call',
CONFERENCE: 'conference',
CONNECTED: 'connected',
NEW: 'new',
} as const;

/** Backend participant consultState values */
export const CONSULT_STATE = {
CONSULTING: 'consulting',
CONFERENCING: 'conferencing',
} as const;

// ============================================
// State Machine Enums
// ============================================
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ import {
getConferenceParticipantsCount,
getIsConferenceInProgress,
} from '../TaskUtils';
import {TaskEvent} from './constants';
import {TaskEvent, INTERACTION_STATE, CONSULT_STATE, MEDIA_TYPE_CONSULT} from './constants';

export const getTaskDataFromEvent = (event?: TaskEventPayload): TaskData | undefined =>
event && typeof event === 'object' && 'taskData' in event
Expand Down Expand Up @@ -97,17 +97,35 @@ export const guards = {
return false;
},

isInteractionConsulting: ({event}: GuardParams): boolean => {
isInteractionConsulting: ({event, context}: GuardParams): boolean => {
const taskData = getTaskDataFromEvent(event);

if (taskData?.interaction?.state === 'consulting') return true;
if (taskData?.interaction?.state === INTERACTION_STATE.CONSULTING) return true;

// EP_DN consulted agent: backend reports state as 'connected' but CPD indicates consult
const cpd = taskData?.interaction?.callProcessingDetails;
if (cpd?.relationshipType === 'consult' && taskData?.interaction?.state === 'connected') {
if (
cpd?.relationshipType === 'consult' &&
taskData?.interaction?.state === INTERACTION_STATE.CONNECTED
) {
return true;
}

// Customer left during consult: interaction state is "post_call" but consult
// between agents is still active. Detect via agent's consultState + consult media.
if (taskData?.interaction?.state === INTERACTION_STATE.POST_CALL) {
const selfAgentId = getSelfAgentId(context, taskData);
const selfParticipant = selfAgentId
? taskData?.interaction?.participants?.[selfAgentId]
: null;
const hasConsultMedia = Object.values(taskData?.interaction?.media ?? {}).some(
(media: any) => media?.mType === MEDIA_TYPE_CONSULT
);
if (selfParticipant?.consultState === CONSULT_STATE.CONSULTING && hasConsultMedia) {
return true;
}
}

return false;
},

Expand All @@ -127,7 +145,7 @@ export const guards = {
isInteractionConnected: ({event}: GuardParams): boolean => {
const taskData = getTaskDataFromEvent(event);

return taskData?.interaction?.state === 'connected';
return taskData?.interaction?.state === INTERACTION_STATE.CONNECTED;
},

isConferencingByParticipants: ({event}: GuardParams): boolean => {
Expand Down Expand Up @@ -176,7 +194,7 @@ export const guards = {
if (!mainCallId) return false;

// Don't downgrade while backend still reports conference.
if (taskData.interaction.state === 'conference') return false;
if (taskData.interaction.state === INTERACTION_STATE.CONFERENCE) return false;

const agentParticipantsCount = getConferenceParticipantsCount(taskData.interaction, mainCallId);
if (agentParticipantsCount >= 2) return false;
Expand Down Expand Up @@ -218,7 +236,7 @@ export const guards = {
return (
taskData.isConsulted === true ||
relationshipType === 'consult' ||
taskData.interaction?.state === 'consulting'
taskData.interaction?.state === INTERACTION_STATE.CONSULTING
);
},

Expand Down
Loading
Loading