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
33 changes: 31 additions & 2 deletions packages/@webex/contact-center/src/services/task/Task.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ import routingContact from './contact';
import MetricsManager from '../../metrics/MetricsManager';
import {METRIC_EVENT_NAMES} from '../../metrics/constants';
import LoggerProxy from '../../logger-proxy';
import {createTaskStateMachine, TaskState} from './state-machine';
import {createTaskStateMachine, TaskEvent, TaskState} from './state-machine';
import type {
TaskEventPayload,
TaskStateMachine,
Expand All @@ -41,6 +41,8 @@ import {
getDefaultUIControls,
haveUIControlsChanged,
} from './state-machine/uiControlsComputer';
import {deriveRecordingContextPatch} from './state-machine/actions';
import {resolveEffectiveVoiceTaskState} from './state-machine/effectiveVoiceTaskState';
import AutoWrapup from './AutoWrapup';
import {WrapupData} from '../config/types';

Expand Down Expand Up @@ -195,6 +197,27 @@ export default abstract class Task extends EventEmitter implements ITask {
this.unsupportedMethodError('holdResume');
}

/**
* Merges recording flags from {@link Task#data} into machine context. Task payloads are refreshed
* via {@link #updateTaskData} on WebSocket updates, but the state machine assign only runs when an
* event is sent — without this merge, `recordingControlsAvailable` can stay false while CAD shows
* pause/resume is allowed.
*/
protected mergeRecordingContextFromTaskData(context: TaskContext): TaskContext {
return {...context, ...deriveRecordingContextPatch(this.data)};
}

/**
* Effective voice task state for API guards when the machine snapshot is still {@link TaskState.IDLE}
* but {@link Task#data} shows an active main call (e.g. post–consult transfer event ordering).
*/
protected getEffectiveVoiceTaskStateForOperation(): TaskState {
const machineState =
(this.stateMachineService?.getSnapshot?.()?.value as TaskState | undefined) ?? TaskState.IDLE;

return resolveEffectiveVoiceTaskState(machineState, this.data);
}

/**
* Latest UI controls derived from state machine state and context.
*/
Expand Down Expand Up @@ -276,7 +299,7 @@ export default abstract class Task extends EventEmitter implements ITask {
}

const currentState = snapshot.value as TaskState;
const context = snapshot.context as TaskContext;
const context = this.mergeRecordingContextFromTaskData(snapshot.context as TaskContext);

return computeUIControls(currentState, context, this.data);
}
Expand Down Expand Up @@ -753,6 +776,12 @@ export default abstract class Task extends EventEmitter implements ITask {
data: wrapupPayload,
});

// Some transfer flows can miss or delay AGENT_WRAPPEDUP; complete locally on API success.
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Inline note: local WRAPUP_COMPLETE dispatch on wrapup API success is a reliability guard. It prevents stale wrapup dropdown/task cards when websocket AGENT_WRAPPEDUP is delayed or dropped.

this.stateMachineService?.send({
type: TaskEvent.WRAPUP_COMPLETE,
taskData: response.data,
});

this.metricsManager.trackEvent(
METRIC_EVENT_NAMES.TASK_WRAPUP_SUCCESS,
{
Expand Down
51 changes: 40 additions & 11 deletions packages/@webex/contact-center/src/services/task/TaskManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,12 @@ import {METHODS, TRANSCRIPT_EVENT_MAP} from './constants';
import {CC_EVENTS, WrapupData} from '../config/types';
import {ConfigFlags, LoginOption, AIAssistantEventType, AIAssistantEventName} from '../../types';
import LoggerProxy from '../../logger-proxy';
import {getIsConferenceInProgress, isSecondaryEpDnAgent, shouldAutoAnswerTask} from './TaskUtils';
import {
getIsConferenceInProgress,
isSecondaryEpDnAgent,
isSecondaryEpDnConsultedRecipient,
shouldAutoAnswerTask,
} from './TaskUtils';
import TaskFactory from './TaskFactory';
import WebRTC from './voice/WebRTC';
import {TaskEvent, type TaskEventPayload} from './state-machine';
Expand Down Expand Up @@ -426,6 +431,16 @@ export default class TaskManager extends EventEmitter {

const wasConsultedTask = Boolean(task?.data?.isConsulted);
const computeWrapUpRequired = () => {
const pending = message.data.agentsPendingWrapUp;
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Inline note: this transfer-specific wrap-up derivation prioritizes agentsPendingWrapUp and participant isWrapUp for the current agent before fallback heuristics. That prevents Agent-2 from staying on active controls when backend transfer payloads don’t set wrapUpRequired consistently.

if (Array.isArray(pending) && pending.length > 0 && this.agentId) {
return pending.includes(this.agentId);
}
if (
this.agentId &&
message.data.interaction?.participants?.[this.agentId]?.isWrapUp === true
) {
return true;
}
if (message.data.wrapUpRequired !== undefined) {
return message.data.wrapUpRequired;
}
Expand Down Expand Up @@ -478,6 +493,18 @@ export default class TaskManager extends EventEmitter {
* Note: Task-level state transitions and event emissions are handled by
* the task state machine via sendStateMachineEvent()
*/
/**
* Resolves `isConsulted` when building or updating task data.
* EP-DN secondary shape alone must not mark the new primary owner as consulted after transfer.
*/
private resolveIsConsultedTaskFlag(payload: TaskData): boolean {
if (payload.isConsulted === true) return true;
// Explicit false from the server must win (e.g. consult transfer recipient); do not re-infer.
if (payload.isConsulted === false) return false;

return isSecondaryEpDnConsultedRecipient(payload.interaction, this.agentId);
}

private handleTaskLifecycleEvent(context: EventContext): TaskEventActions {
const {eventType} = context;

Expand All @@ -502,8 +529,6 @@ export default class TaskManager extends EventEmitter {
*/
private handleContactReserved(context: EventContext): TaskEventActions {
const {payload} = context;
const isConsultedTask =
payload.isConsulted === true || isSecondaryEpDnAgent(payload.interaction);
const shouldAutoAnswer = shouldAutoAnswerTask(
payload,
this.agentId,
Expand All @@ -513,7 +538,7 @@ export default class TaskManager extends EventEmitter {

const taskData: TaskData = {
...payload,
isConsulted: isConsultedTask,
isConsulted: this.resolveIsConsultedTaskFlag(payload),
isAutoAnswering: shouldAutoAnswer,
};

Expand Down Expand Up @@ -541,8 +566,6 @@ export default class TaskManager extends EventEmitter {
const {payload} = context;

if (!task) {
const isConsultedTask =
payload.isConsulted === true || isSecondaryEpDnAgent(payload.interaction);
const shouldAutoAnswer = shouldAutoAnswerTask(
payload,
this.agentId,
Expand All @@ -551,7 +574,7 @@ export default class TaskManager extends EventEmitter {
);
const taskData: TaskData = {
...payload,
isConsulted: isConsultedTask,
isConsulted: this.resolveIsConsultedTaskFlag(payload),
wrapUpRequired: payload.interaction?.participants?.[this.agentId]?.isWrapUp || false,
isConferenceInProgress: getIsConferenceInProgress(payload),
isAutoAnswering: shouldAutoAnswer,
Expand Down Expand Up @@ -581,14 +604,20 @@ export default class TaskManager extends EventEmitter {
const isConsultingFlow =
snapshot?.value === 'CONSULTING' || taskData.interaction?.state === 'consulting';

const normalizedPayload: TaskData = {
...taskData,
isConsulted: this.resolveIsConsultedTaskFlag(taskData),
};
Comment on lines +607 to +610
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 Preserve consulted flag when updates omit isConsulted

updateTaskData() now unconditionally rewrites isConsulted from resolveIsConsultedTaskFlag(taskData), which returns false whenever incoming payloads omit isConsulted (common for non-consult-specific updates like recording/contact refresh events). For an already-consulted task, this clears consulted status and enables full main-call controls incorrectly until another event restores it, changing transfer/end behavior for the consulted agent.

Useful? React with 👍 / 👎.


const updateTaskData = isConsultingFlow
? {
...taskData,
destAgentId: taskData.destAgentId ?? snapshot?.context?.consultDestinationAgentId ?? null,
...normalizedPayload,
destAgentId:
normalizedPayload.destAgentId ?? snapshot?.context?.consultDestinationAgentId ?? null,
destinationType:
taskData.destinationType ?? snapshot?.context?.consultDestinationType ?? null,
normalizedPayload.destinationType ?? snapshot?.context?.consultDestinationType ?? null,
}
: taskData;
: normalizedPayload;

task.updateTaskData(updateTaskData);
this.taskCollection[taskData.interactionId] = task;
Expand Down
34 changes: 34 additions & 0 deletions packages/@webex/contact-center/src/services/task/TaskUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,20 @@ export const getIsConsultInProgressForConferenceControls = (
});
};

/**
* Determines whether consult is in progress for generic control visibility.
* Uses explicit task flag first and falls back to consult media detection.
*/
export const getIsConsultInProgress = (taskData: TaskData | null | undefined): boolean => {
if (!taskData) return false;
if (typeof taskData.isConsultInProgress === 'boolean') return taskData.isConsultInProgress;

const media = taskData.interaction?.media;
if (!media) return false;

return Object.values(media).some((m: any) => m?.mType === 'consult');
};

export const getIsConsultedAgentForControls = (
taskData: TaskData | null,
context: TaskContext,
Expand Down Expand Up @@ -236,6 +250,26 @@ export const isSecondaryEpDnAgent = (interaction: Interaction): boolean => {
return interaction.mediaType === 'telephony' && isSecondaryAgent(interaction);
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Inline note: owner-aware consulted detection is critical after consult transfer. Secondary EP-DN shape can still be present, but if interaction.owner is self, this task is now primary and must not remain in consulted-only UI/state handling.

};

/**
* True when we should infer `isConsulted` from EP-DN secondary interaction shape alone.
* After a consult transfer, metadata can still match {@link isSecondaryEpDnAgent} while the
* receiving agent is the new primary owner — in that case `interaction.owner` matches self and
* we must not mark the task as consulted.
*/
export const isSecondaryEpDnConsultedRecipient = (
interaction: Interaction | undefined,
selfAgentId: string | undefined
): boolean => {
if (!interaction || !selfAgentId || !isSecondaryEpDnAgent(interaction)) {
return false;
}
if (interaction.owner && interaction.owner === selfAgentId) {
return false;
}

return true;
};

/**
* Checks if auto-answer is enabled for the agent participant
* @param interaction - The interaction object
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,12 @@ export function getTaskStateMachineConfig(uiControlConfig: UIControlConfig) {
[TaskEvent.HYDRATE]: {
actions: ['updateTaskData', 'emitTaskHydrate'],
},
// Wrapped-up can arrive while machine still reflects CONNECTED/CONSULTING due event ordering.
// Accept it globally so completion cleanup always runs and task is removed from collection.
[TaskEvent.WRAPUP_COMPLETE]: {
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Inline note: this root-level WRAPUP_COMPLETE transition is added to handle event-ordering gaps where wrapped-up arrives before state reaches WRAPPING_UP; it guarantees completion/cleanup instead of leaving stale task cards.

target: `.${TaskState.COMPLETED}`,
actions: ['updateTaskData'],
},
},
states: {
[TaskState.IDLE]: {
Expand Down Expand Up @@ -261,7 +267,7 @@ export function getTaskStateMachineConfig(uiControlConfig: UIControlConfig) {
// AgentConsultTransferred / AgentVTeamTransferred / AgentBlindTransferred
[TaskEvent.TRANSFER_SUCCESS]: [
{
guard: guards.shouldWrapUpOrIsInitiator,
guard: guards.shouldWrapUp,
target: TaskState.WRAPPING_UP,
actions: ['updateTaskData', 'markEnded', 'clearConsultState', 'emitTaskWrapup'],
},
Expand Down Expand Up @@ -341,7 +347,7 @@ export function getTaskStateMachineConfig(uiControlConfig: UIControlConfig) {
// AgentConsultTransferred / AgentVTeamTransferred / AgentBlindTransferred
[TaskEvent.TRANSFER_SUCCESS]: [
{
guard: guards.shouldWrapUpOrIsInitiator,
guard: guards.shouldWrapUp,
target: TaskState.WRAPPING_UP,
actions: ['updateTaskData', 'markEnded', 'emitTaskWrapup'],
},
Expand Down Expand Up @@ -496,7 +502,7 @@ export function getTaskStateMachineConfig(uiControlConfig: UIControlConfig) {

[TaskEvent.TRANSFER_SUCCESS]: [
{
guard: guards.shouldWrapUpOrIsInitiator,
guard: guards.shouldWrapUp,
target: TaskState.WRAPPING_UP,
actions: ['updateTaskData', 'markEnded', 'clearConsultState', 'emitTaskWrapup'],
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
} from './types';
import {TaskEvent, TaskState} from './constants';
import {DestinationType, TaskData} from '../types';
import {getIsConsultInProgressForConferenceControls} from '../TaskUtils';
import {computeUIControls, getDefaultUIControls} from './uiControlsComputer';

const determineConsultInitiator = (
Expand All @@ -30,7 +31,11 @@ const determineConsultInitiator = (
return undefined;
};

const deriveRecordingState = (taskData?: TaskData | null): RecordingStateUpdate => {
/**
* Derives recording UI / guard flags from {@link TaskData.interaction.callProcessingDetails}.
* Exported so {@link Task} can merge fresh task payloads into UI when no state machine event ran.
*/
export function deriveRecordingContextPatch(taskData?: TaskData | null): RecordingStateUpdate {
const callProcessingDetails = taskData?.interaction?.callProcessingDetails;

if (!callProcessingDetails) {
Expand Down Expand Up @@ -67,18 +72,40 @@ const deriveRecordingState = (taskData?: TaskData | null): RecordingStateUpdate

if (isPaused !== undefined) {
update.recordingControlsAvailable = true;
update.recordingInProgress = !isPaused;
const paused = isPaused === true || String(isPaused).toLowerCase() === 'true';
update.recordingInProgress = !paused;
}

// Match {@link TaskFactory} default: pause/resume is allowed when pauseResumeEnabled is omitted.
// Many payloads include queue/CAD fields but omit recordingStarted/recordInProgress until later;
// without this, recording stays hidden and {@link Voice.pauseRecording} keeps failing prechecks.
const pauseResumeAllowed =
(callProcessingDetails as {pauseResumeEnabled?: boolean}).pauseResumeEnabled ?? true;

if (update.recordingControlsAvailable === undefined && pauseResumeAllowed) {
update.recordingControlsAvailable = true;
if (update.recordingInProgress === undefined) {
update.recordingInProgress = true;
}
}

if (
update.recordingControlsAvailable === true &&
update.recordingInProgress === undefined &&
pauseResumeAllowed
) {
update.recordingInProgress = true;
}

return update;
};
}

const deriveTaskDataUpdates = (context: TaskContext, taskData: TaskData | undefined) =>
taskData
? (() => {
const updates: Partial<TaskContext> = {
taskData,
...deriveRecordingState(taskData),
...deriveRecordingContextPatch(taskData),
};

if (taskData.destAgentId) {
Expand All @@ -103,13 +130,43 @@ const deriveTaskDataUpdates = (context: TaskContext, taskData: TaskData | undefi

if (taskData.interaction?.state === 'consulting') {
if (!context.consultDestinationAgentJoined) {
const hasJoinedConsultee = Boolean(
taskData.interaction.participants &&
Object.values(taskData.interaction.participants).some(
(p: any) => p?.isConsulted === true && !p?.hasLeft
const selfAgentId = context.uiControlConfig.agentId ?? taskData?.agentId;
const participants = taskData.interaction.participants;
const hasJoinedConsulteeFromFlags = Boolean(
participants &&
Object.values(participants).some((p: any) => p?.isConsulted === true && !p?.hasLeft)
);
// EP-DN / telephony consult: consulted party may not carry isConsulted on participants
const hasRemotePartyOnConsultMedia = Boolean(
selfAgentId &&
taskData.interaction.media &&
Object.values(taskData.interaction.media).some((m: any) => {
if (m?.mType !== 'consult' || !Array.isArray(m.participants)) return false;

return m.participants.some((participantId: string) => {
if (participantId === selfAgentId) return false;
const p: any = participants?.[participantId];

return Boolean(p && !p.hasLeft);
});
})
);
const mainCallId = taskData.interaction.mainInteractionId || taskData.interactionId;
const hasJoinedPerConferenceHeuristic = Boolean(
selfAgentId &&
getIsConsultInProgressForConferenceControls(
taskData.interaction,
mainCallId,
selfAgentId
)
);
if (hasJoinedConsultee) updates.consultDestinationAgentJoined = true;
if (
hasJoinedConsulteeFromFlags ||
hasRemotePartyOnConsultMedia ||
hasJoinedPerConferenceHeuristic
) {
updates.consultDestinationAgentJoined = true;
}
}

const effectiveConsultInitiator = updates.consultInitiator ?? context.consultInitiator;
Expand Down
Loading
Loading