@webex/contact-center 3.12.0-task-refactor.4 → 3.12.0-task-refactor.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -50,6 +50,10 @@ export declare function getTaskStateMachineConfig(uiControlConfig: UIControlConf
50
50
  target: TaskState;
51
51
  actions: string[];
52
52
  };
53
+ OUTBOUND_FAILED: {
54
+ target: TaskState;
55
+ actions: string[];
56
+ };
53
57
  CONSULTING_ACTIVE: {
54
58
  target: TaskState;
55
59
  actions: string[];
@@ -522,6 +526,10 @@ export declare function createTaskStateMachine(uiControlConfig: UIControlConfig,
522
526
  target: TaskState;
523
527
  actions: string[];
524
528
  };
529
+ OUTBOUND_FAILED: {
530
+ target: TaskState;
531
+ actions: string[];
532
+ };
525
533
  CONSULTING_ACTIVE: {
526
534
  target: TaskState;
527
535
  actions: string[];
@@ -1,6 +1,7 @@
1
1
  import routingContact from '../contact';
2
2
  import { ConsultPayload, ConsultEndPayload, ResumeRecordingPayload, TaskData, TaskResponse, IVoice, VoiceUIControlOptions, TransferPayLoad } from '../types';
3
3
  import Task from '../Task';
4
+ import { TaskActionArgs } from '../state-machine';
4
5
  import { WrapupData } from '../../config/types';
5
6
  export default class Voice extends Task implements IVoice {
6
7
  constructor(contact: ReturnType<typeof routingContact>, data: TaskData, callOptions?: VoiceUIControlOptions, wrapupData?: WrapupData, agentId?: string);
@@ -162,22 +163,22 @@ export default class Voice extends Task implements IVoice {
162
163
  */
163
164
  switchCall(): Promise<TaskResponse>;
164
165
  protected getChannelSpecificActionOverrides(): {
165
- emitTaskHold: ({ event }: import("../state-machine").TaskActionArgs) => void;
166
- emitTaskResume: ({ event }: import("../state-machine").TaskActionArgs) => void;
167
- emitTaskRecordingStarted: ({ event }: import("../state-machine").TaskActionArgs) => void;
168
- emitTaskRecordingPaused: ({ event }: import("../state-machine").TaskActionArgs) => void;
169
- emitTaskRecordingPauseFailed: ({ event }: import("../state-machine").TaskActionArgs) => void;
170
- emitTaskRecordingResumed: ({ event }: import("../state-machine").TaskActionArgs) => void;
171
- emitTaskRecordingResumeFailed: ({ event }: import("../state-machine").TaskActionArgs) => void;
172
- emitTaskParticipantJoined: ({ event }: import("../state-machine").TaskActionArgs) => void;
173
- emitTaskParticipantLeft: ({ event }: import("../state-machine").TaskActionArgs) => void;
174
- emitTaskConferenceStarted: ({ event }: import("../state-machine").TaskActionArgs) => void;
175
- emitTaskConferenceEnded: ({ event }: import("../state-machine").TaskActionArgs) => void;
176
- emitTaskConferenceFailed: ({ event }: import("../state-machine").TaskActionArgs) => void;
177
- emitTaskExitConference: ({ event }: import("../state-machine").TaskActionArgs) => void;
178
- emitTaskTransferConference: ({ event }: import("../state-machine").TaskActionArgs) => void;
179
- emitTaskSwitchCall: ({ event }: import("../state-machine").TaskActionArgs) => void;
180
- emitTaskTransferConferenceFailed: ({ event }: import("../state-machine").TaskActionArgs) => void;
181
- emitTaskOutdialFailed: ({ event }: import("../state-machine").TaskActionArgs) => void;
166
+ emitTaskHold: ({ event }: TaskActionArgs) => void;
167
+ emitTaskResume: ({ event }: TaskActionArgs) => void;
168
+ emitTaskRecordingStarted: ({ event }: TaskActionArgs) => void;
169
+ emitTaskRecordingPaused: ({ event }: TaskActionArgs) => void;
170
+ emitTaskRecordingPauseFailed: ({ event }: TaskActionArgs) => void;
171
+ emitTaskRecordingResumed: ({ event }: TaskActionArgs) => void;
172
+ emitTaskRecordingResumeFailed: ({ event }: TaskActionArgs) => void;
173
+ emitTaskParticipantJoined: ({ event }: TaskActionArgs) => void;
174
+ emitTaskParticipantLeft: ({ event }: TaskActionArgs) => void;
175
+ emitTaskConferenceStarted: ({ event }: TaskActionArgs) => void;
176
+ emitTaskConferenceEnded: ({ event }: TaskActionArgs) => void;
177
+ emitTaskConferenceFailed: ({ event }: TaskActionArgs) => void;
178
+ emitTaskExitConference: ({ event }: TaskActionArgs) => void;
179
+ emitTaskTransferConference: ({ event }: TaskActionArgs) => void;
180
+ emitTaskSwitchCall: ({ event }: TaskActionArgs) => void;
181
+ emitTaskTransferConferenceFailed: ({ event }: TaskActionArgs) => void;
182
+ emitTaskOutdialFailed: ({ event }: TaskActionArgs) => void;
182
183
  };
183
184
  }
package/dist/webex.js CHANGED
@@ -41,7 +41,7 @@ if (!global.Buffer) {
41
41
  */
42
42
  const Webex = _webexCore.default.extend({
43
43
  webex: true,
44
- version: `3.12.0-task-refactor.4`
44
+ version: `3.12.0-task-refactor.5`
45
45
  });
46
46
 
47
47
  /**
package/package.json CHANGED
@@ -83,5 +83,5 @@
83
83
  "typedoc": "^0.25.0",
84
84
  "typescript": "5.4.5"
85
85
  },
86
- "version": "3.12.0-task-refactor.4"
86
+ "version": "3.12.0-task-refactor.5"
87
87
  }
@@ -291,7 +291,7 @@ export default class TaskManager extends EventEmitter {
291
291
  return {type: TaskEvent.RONA, taskData: payload, reason: payload.reason};
292
292
 
293
293
  case CC_EVENTS.AGENT_OUTBOUND_FAILED:
294
- return {type: TaskEvent.OUTBOUND_FAILED, reason: payload.reason};
294
+ return {type: TaskEvent.OUTBOUND_FAILED, taskData: payload, reason: payload.reason};
295
295
 
296
296
  case CC_EVENTS.CONTACT_RECORDING_STARTED:
297
297
  return {type: TaskEvent.RECORDING_STARTED, taskData: payload};
@@ -123,6 +123,12 @@ export function getTaskStateMachineConfig(uiControlConfig: UIControlConfig) {
123
123
  actions: ['initializeTask', 'emitTaskIncoming'],
124
124
  },
125
125
 
126
+ // AgentOutboundFailed can arrive before TASK_INCOMING due to race conditions
127
+ [TaskEvent.OUTBOUND_FAILED]: {
128
+ target: TaskState.TERMINATED,
129
+ actions: ['updateTaskData', 'markEnded', 'emitTaskOutdialFailed', 'emitTaskEnd'],
130
+ },
131
+
126
132
  // EP-DN split-leg ordering can deliver AgentConsulting before HYDRATE/TASK_INCOMING.
127
133
  // Do not drop it in IDLE; bootstrap to CONSULTING using event taskData.
128
134
  [TaskEvent.CONSULTING_ACTIVE]: {
@@ -190,7 +196,7 @@ export function getTaskStateMachineConfig(uiControlConfig: UIControlConfig) {
190
196
  },
191
197
  {
192
198
  target: TaskState.TERMINATED,
193
- actions: ['updateTaskData', 'markEnded', 'emitTaskOutdialFailed', 'emitTaskReject'],
199
+ actions: ['updateTaskData', 'markEnded', 'emitTaskOutdialFailed', 'emitTaskEnd'],
194
200
  },
195
201
  ],
196
202
  // AgentConsulting comes for received after the initial consult is accepted
@@ -182,16 +182,18 @@ function computeVoiceInteractionUIControls(
182
182
 
183
183
  return {
184
184
  // Accept/Decline: Voice tasks in offered state
185
- // For outdial, accept is disabled (auto-answer handles it), decline remains enabled
186
- // For Extension mode (non-WebRTC), accept shows as disabled "Ringing" button
185
+ // Desktop/WebRTC + inbound: accept enabled (agent manually accepts)
186
+ // Desktop/WebRTC + outdial: accept disabled (auto-answer handles it; Widgets show "Accept" disabled)
187
+ // Extension mode (non-WebRTC): accept disabled (Widgets show "Ringing...")
187
188
  accept:
188
189
  state === TaskState.OFFERED && !interaction?.isTerminated
189
190
  ? {isVisible: true, isEnabled: isWebrtc && !isOutdial}
190
191
  : DISABLED,
191
- decline:
192
- isWebrtc && state === TaskState.OFFERED && !interaction?.isTerminated
193
- ? VISIBLE_ENABLED
194
- : DISABLED,
192
+ decline: (() => {
193
+ if (!isWebrtc || state !== TaskState.OFFERED || interaction?.isTerminated) return DISABLED;
194
+
195
+ return isOutdial ? VISIBLE_DISABLED : VISIBLE_ENABLED;
196
+ })(),
195
197
 
196
198
  // Hold: visible in connected/held/conference, disabled in conference/consulting
197
199
  hold: (() => {
@@ -25,7 +25,7 @@ import Task from '../Task';
25
25
  import LoggerProxy from '../../../logger-proxy';
26
26
  import MetricsManager from '../../../metrics/MetricsManager';
27
27
  import {METRIC_EVENT_NAMES} from '../../../metrics/constants';
28
- import {TaskState, TaskEvent} from '../state-machine';
28
+ import {TaskState, TaskEvent, TaskActionArgs} from '../state-machine';
29
29
  import {WrapupData} from '../../config/types';
30
30
  import {getConsultMediaResourceId, getIsConferenceInProgress} from '../TaskUtils';
31
31
 
@@ -1252,9 +1252,13 @@ export default class Voice extends Task implements IVoice {
1252
1252
  TASK_EVENTS.TASK_CONFERENCE_TRANSFER_FAILED,
1253
1253
  {updateTaskData: true}
1254
1254
  ),
1255
- emitTaskOutdialFailed: this.createEmitSelfAction(TASK_EVENTS.TASK_OUTDIAL_FAILED, {
1256
- updateTaskData: true,
1257
- }),
1255
+ emitTaskOutdialFailed: ({event}: TaskActionArgs) => {
1256
+ if (event && 'taskData' in event && event.taskData) {
1257
+ this.updateTaskData(event.taskData as TaskData);
1258
+ }
1259
+ const reason = (event as {reason?: string})?.reason || 'Outdial failed';
1260
+ this.emit(TASK_EVENTS.TASK_OUTDIAL_FAILED, reason);
1261
+ },
1258
1262
  };
1259
1263
  }
1260
1264
  }
@@ -1280,6 +1280,32 @@ describe('TaskManager', () => {
1280
1280
  sendStateMachineEventSpy.mockRestore();
1281
1281
  });
1282
1282
 
1283
+ it('should pass taskData in OUTBOUND_FAILED event for shouldWrapUp guard evaluation', () => {
1284
+ const task = taskManager.getTask(taskId);
1285
+ const sendStateMachineEventSpy = jest.spyOn(task, 'sendStateMachineEvent');
1286
+ const payload = {
1287
+ data: {
1288
+ type: CC_EVENTS.AGENT_OUTBOUND_FAILED,
1289
+ interactionId: taskId,
1290
+ reason: 'CUSTOMER_BUSY',
1291
+ agentsPendingWrapUp: ['agent-123'],
1292
+ interaction: {
1293
+ outboundType: 'OUTDIAL',
1294
+ isTerminated: true,
1295
+ },
1296
+ },
1297
+ };
1298
+ webSocketManagerMock.emit('message', JSON.stringify(payload));
1299
+ const stateMachineEvent = expectLastStateMachineEvent(
1300
+ sendStateMachineEventSpy,
1301
+ TaskEvent.OUTBOUND_FAILED
1302
+ );
1303
+ expect(stateMachineEvent?.taskData).toBeDefined();
1304
+ expect(stateMachineEvent?.taskData?.agentsPendingWrapUp).toEqual(['agent-123']);
1305
+ expect(stateMachineEvent?.taskData?.interaction?.outboundType).toBe('OUTDIAL');
1306
+ sendStateMachineEventSpy.mockRestore();
1307
+ });
1308
+
1283
1309
  it('should handle AGENT_OUTBOUND_FAILED gracefully when task is undefined', () => {
1284
1310
  const payload = {
1285
1311
  data: {
@@ -573,4 +573,65 @@ describe('Task state machine', () => {
573
573
  expect(service.getSnapshot().value).toBe(TaskState.TERMINATED);
574
574
  });
575
575
  });
576
+
577
+ describe('OUTBOUND_FAILED handling', () => {
578
+ it('transitions from IDLE to TERMINATED on OUTBOUND_FAILED (race condition)', () => {
579
+ const service = startMachine();
580
+ expect(service.getSnapshot().value).toBe(TaskState.IDLE);
581
+
582
+ const taskData = createTaskData({
583
+ interaction: {
584
+ outboundType: 'OUTDIAL',
585
+ isTerminated: true,
586
+ } as any,
587
+ });
588
+
589
+ service.send({type: TaskEvent.OUTBOUND_FAILED, taskData, reason: 'CUSTOMER_BUSY'});
590
+ expect(service.getSnapshot().value).toBe(TaskState.TERMINATED);
591
+ });
592
+
593
+ it('transitions from OFFERED to TERMINATED on OUTBOUND_FAILED without wrapup', () => {
594
+ const service = startMachine();
595
+ const offerTaskData = createTaskData({
596
+ interaction: {
597
+ outboundType: 'OUTDIAL',
598
+ } as any,
599
+ });
600
+
601
+ service.send({type: TaskEvent.TASK_INCOMING, taskData: offerTaskData});
602
+ expect(service.getSnapshot().value).toBe(TaskState.OFFERED);
603
+
604
+ const failedTaskData = createTaskData({
605
+ interaction: {
606
+ outboundType: 'OUTDIAL',
607
+ isTerminated: true,
608
+ } as any,
609
+ });
610
+ service.send({type: TaskEvent.OUTBOUND_FAILED, taskData: failedTaskData, reason: 'CUSTOMER_BUSY'});
611
+ expect(service.getSnapshot().value).toBe(TaskState.TERMINATED);
612
+ });
613
+
614
+ it('transitions from OFFERED to WRAPPING_UP on OUTBOUND_FAILED when wrapup is required', () => {
615
+ const service = startMachine();
616
+ const offerTaskData = createTaskData({
617
+ interaction: {
618
+ outboundType: 'OUTDIAL',
619
+ } as any,
620
+ });
621
+
622
+ service.send({type: TaskEvent.TASK_INCOMING, taskData: offerTaskData});
623
+ expect(service.getSnapshot().value).toBe(TaskState.OFFERED);
624
+
625
+ const failedTaskData = createTaskData({
626
+ agentId: 'agent-1',
627
+ agentsPendingWrapUp: ['agent-1'],
628
+ interaction: {
629
+ outboundType: 'OUTDIAL',
630
+ isTerminated: true,
631
+ } as any,
632
+ });
633
+ service.send({type: TaskEvent.OUTBOUND_FAILED, taskData: failedTaskData, reason: 'CUSTOMER_BUSY'});
634
+ expect(service.getSnapshot().value).toBe(TaskState.WRAPPING_UP);
635
+ });
636
+ });
576
637
  });
@@ -146,6 +146,54 @@ describe('uiControlsComputer consult initiator controls', () => {
146
146
  });
147
147
  });
148
148
 
149
+ describe('uiControlsComputer outdial accept/decline controls', () => {
150
+ function createOutdialContext(voiceVariant: 'webrtc' | 'pstn' = 'webrtc'): TaskContext {
151
+ const taskData = createTaskData({
152
+ interaction: {
153
+ outboundType: 'OUTDIAL',
154
+ state: 'new',
155
+ isTerminated: false,
156
+ } as any,
157
+ });
158
+ return {
159
+ taskData,
160
+ consultInitiator: false,
161
+ exitingConference: false,
162
+ consultFromConference: false,
163
+ transferConferenceRequested: false,
164
+ consultDestinationType: null,
165
+ consultDestinationAgentId: null,
166
+ consultDestinationAgentJoined: false,
167
+ consultCallHeld: false,
168
+ recordingControlsAvailable: false,
169
+ recordingInProgress: false,
170
+ uiControlConfig: {
171
+ isEndTaskEnabled: true,
172
+ isEndConsultEnabled: true,
173
+ channelType: TASK_CHANNEL_TYPE.VOICE,
174
+ isRecordingEnabled: false,
175
+ agentId: 'agent-1',
176
+ voiceVariant,
177
+ },
178
+ uiControls: getDefaultUIControls(),
179
+ };
180
+ }
181
+
182
+ it('accept is visible but disabled for WebRTC outdial in OFFERED state', () => {
183
+ const context = createOutdialContext('webrtc');
184
+ const uiControls = computeUIControls(TaskState.OFFERED, context, context.taskData);
185
+
186
+ expect(uiControls.main.accept).toEqual({isVisible: true, isEnabled: false});
187
+ });
188
+
189
+ it('decline is visible but disabled for WebRTC outdial in OFFERED state', () => {
190
+ const context = createOutdialContext('webrtc');
191
+ const uiControls = computeUIControls(TaskState.OFFERED, context, context.taskData);
192
+
193
+ expect(uiControls.main.decline).toEqual({isVisible: true, isEnabled: false});
194
+ });
195
+ });
196
+
149
197
  describe('uiControlsComputer conference controls', () => {
150
198
  function createConferenceTaskData(participantCount: number) {
151
199
  const participants: Record<string, any> = {
@@ -72,6 +72,30 @@ describe('Voice Task', () => {
72
72
  jest.clearAllMocks();
73
73
  });
74
74
 
75
+ describe('emitTaskOutdialFailed', () => {
76
+ it('emits the failure reason string instead of the Task object', () => {
77
+ const taskData = createBaseData({
78
+ interaction: {
79
+ outboundType: 'OUTDIAL',
80
+ } as any,
81
+ });
82
+ const voice = new Voice(dummyContact, taskData, {});
83
+ const emitSpy = jest.spyOn(voice, 'emit');
84
+
85
+ voice.sendStateMachineEvent({
86
+ type: TaskEvent.OUTBOUND_FAILED,
87
+ taskData,
88
+ reason: 'CUSTOMER_BUSY',
89
+ });
90
+
91
+ const outdialFailedCall = emitSpy.mock.calls.find(
92
+ (call) => call[0] === 'task:outdialFailed'
93
+ );
94
+ expect(outdialFailedCall).toBeDefined();
95
+ expect(outdialFailedCall![1]).toBe('CUSTOMER_BUSY');
96
+ });
97
+ });
98
+
75
99
  it('hides end and endConsult when disabled', () => {
76
100
  const voice = new Voice(dummyContact, createBaseData(), {
77
101
  isEndTaskEnabled: false,