@webex/contact-center 3.10.0-next.18 → 3.10.0-next.19

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.
@@ -18,6 +18,7 @@ import {
18
18
  isParticipantInMainInteraction,
19
19
  isPrimary,
20
20
  isSecondaryEpDnAgent,
21
+ shouldAutoAnswerTask,
21
22
  } from './TaskUtils';
22
23
 
23
24
  /** @internal */
@@ -36,6 +37,7 @@ export default class TaskManager extends EventEmitter {
36
37
  private static taskManager;
37
38
  private wrapupData: WrapupData;
38
39
  private agentId: string;
40
+ private webRtcEnabled: boolean;
39
41
  /**
40
42
  * @param contact - Routing Contact layer. Talks to AQMReq layer to convert events to promises
41
43
  * @param webCallingService - Webrtc Service Layer
@@ -73,6 +75,10 @@ export default class TaskManager extends EventEmitter {
73
75
  return this.agentId;
74
76
  }
75
77
 
78
+ public setWebRtcEnabled(webRtcEnabled: boolean) {
79
+ this.webRtcEnabled = webRtcEnabled;
80
+ }
81
+
76
82
  private handleIncomingWebCall = (call: ICall) => {
77
83
  const currentTask = Object.values(this.taskCollection).find(
78
84
  (task) => task.data.interaction.mediaType === 'telephony'
@@ -130,6 +136,14 @@ export default class TaskManager extends EventEmitter {
130
136
  interactionId: payload.data.interactionId,
131
137
  });
132
138
 
139
+ // Check if auto-answer should happen for this task
140
+ const shouldAutoAnswer = shouldAutoAnswerTask(
141
+ payload.data,
142
+ this.agentId,
143
+ this.webCallingService.loginOption,
144
+ this.webRtcEnabled
145
+ );
146
+
133
147
  task = new Task(
134
148
  this.contact,
135
149
  this.webCallingService,
@@ -138,6 +152,7 @@ export default class TaskManager extends EventEmitter {
138
152
  wrapUpRequired:
139
153
  payload.data.interaction?.participants?.[this.agentId]?.isWrapUp || false,
140
154
  isConferenceInProgress: getIsConferenceInProgress(payload.data),
155
+ isAutoAnswering: shouldAutoAnswer, // Set flag before emitting
141
156
  },
142
157
  this.wrapupData,
143
158
  this.agentId
@@ -169,13 +184,22 @@ export default class TaskManager extends EventEmitter {
169
184
  }
170
185
  break;
171
186
 
172
- case CC_EVENTS.AGENT_CONTACT_RESERVED:
187
+ case CC_EVENTS.AGENT_CONTACT_RESERVED: {
188
+ // Check if auto-answer should happen for this task
189
+ const shouldAutoAnswerReserved = shouldAutoAnswerTask(
190
+ payload.data,
191
+ this.agentId,
192
+ this.webCallingService.loginOption,
193
+ this.webRtcEnabled
194
+ );
195
+
173
196
  task = new Task(
174
197
  this.contact,
175
198
  this.webCallingService,
176
199
  {
177
200
  ...payload.data,
178
201
  isConsulted: false,
202
+ isAutoAnswering: shouldAutoAnswerReserved, // Set flag before emitting
179
203
  },
180
204
  this.wrapupData,
181
205
  this.agentId
@@ -190,6 +214,7 @@ export default class TaskManager extends EventEmitter {
190
214
  this.emit(TASK_EVENTS.TASK_INCOMING, task);
191
215
  }
192
216
  break;
217
+ }
193
218
  case CC_EVENTS.AGENT_OFFER_CONTACT:
194
219
  // We don't have to emit any event here since this will be result of promise.
195
220
  task = this.updateTaskData(task, payload.data);
@@ -199,24 +224,29 @@ export default class TaskManager extends EventEmitter {
199
224
  interactionId: payload.data?.interactionId,
200
225
  });
201
226
  this.emit(TASK_EVENTS.TASK_OFFER_CONTACT, task);
227
+
228
+ // Handle auto-answer for offer contact
229
+ this.handleAutoAnswer(task);
202
230
  break;
203
231
  case CC_EVENTS.AGENT_OUTBOUND_FAILED:
204
- task = this.updateTaskData(task, payload.data);
205
- this.metricsManager.trackEvent(
206
- METRIC_EVENT_NAMES.TASK_OUTDIAL_FAILED,
207
- {
208
- ...MetricsManager.getCommonTrackingFieldForAQMResponse(payload.data),
209
- taskId: payload.data.interactionId,
210
- reason: payload.data.reasonCode || payload.data.reason,
211
- },
212
- ['behavioral', 'operational']
213
- );
214
- LoggerProxy.log(`Agent outbound failed for task`, {
215
- module: TASK_MANAGER_FILE,
216
- method: METHODS.REGISTER_TASK_LISTENERS,
217
- interactionId: payload.data.interactionId,
218
- });
219
- task.emit(TASK_EVENTS.TASK_OUTDIAL_FAILED, payload.data.reason ?? 'UNKNOWN_REASON');
232
+ if (task) {
233
+ task = this.updateTaskData(task, payload.data);
234
+ this.metricsManager.trackEvent(
235
+ METRIC_EVENT_NAMES.TASK_OUTDIAL_FAILED,
236
+ {
237
+ ...MetricsManager.getCommonTrackingFieldForAQMResponse(payload.data),
238
+ taskId: payload.data.interactionId,
239
+ reason: payload.data.reasonCode || payload.data.reason,
240
+ },
241
+ ['behavioral', 'operational']
242
+ );
243
+ LoggerProxy.log(`Agent outbound failed for task`, {
244
+ module: TASK_MANAGER_FILE,
245
+ method: METHODS.REGISTER_TASK_LISTENERS,
246
+ interactionId: payload.data.interactionId,
247
+ });
248
+ task.emit(TASK_EVENTS.TASK_OUTDIAL_FAILED, payload.data.reason ?? 'UNKNOWN_REASON');
249
+ }
220
250
  break;
221
251
  case CC_EVENTS.AGENT_CONTACT_ASSIGNED:
222
252
  task = this.updateTaskData(task, payload.data);
@@ -256,18 +286,19 @@ export default class TaskManager extends EventEmitter {
256
286
  }
257
287
  case CC_EVENTS.CONTACT_ENDED:
258
288
  // Update task data
259
- task = this.updateTaskData(task, {
260
- ...payload.data,
261
- wrapUpRequired:
262
- payload.data.interaction.state !== 'new' &&
263
- !isSecondaryEpDnAgent(payload.data.interaction),
264
- });
265
-
266
- // Handle cleanup based on whether task should be deleted
267
- this.handleTaskCleanup(task);
289
+ if (task) {
290
+ task = this.updateTaskData(task, {
291
+ ...payload.data,
292
+ wrapUpRequired:
293
+ payload.data.interaction.state !== 'new' &&
294
+ !isSecondaryEpDnAgent(payload.data.interaction),
295
+ });
268
296
 
269
- task?.emit(TASK_EVENTS.TASK_END, task);
297
+ // Handle cleanup based on whether task should be deleted
298
+ this.handleTaskCleanup(task);
270
299
 
300
+ task?.emit(TASK_EVENTS.TASK_END, task);
301
+ }
271
302
  break;
272
303
  case CC_EVENTS.CONTACT_MERGED:
273
304
  task = this.handleContactMerged(task, payload.data);
@@ -308,6 +339,9 @@ export default class TaskManager extends EventEmitter {
308
339
  isConsulted: true, // This ensures that the task is marked as us being requested for a consult
309
340
  });
310
341
  task.emit(TASK_EVENTS.TASK_OFFER_CONSULT, task);
342
+
343
+ // Handle auto-answer for consult offer
344
+ this.handleAutoAnswer(task);
311
345
  break;
312
346
  case CC_EVENTS.AGENT_CONSULTING:
313
347
  // Received when agent is in an active consult state
@@ -545,6 +579,70 @@ export default class TaskManager extends EventEmitter {
545
579
  }
546
580
  }
547
581
 
582
+ /**
583
+ * Handles auto-answer logic for incoming tasks
584
+ * Automatically accepts tasks when isAutoAnswering flag is set
585
+ * The flag is set during task creation based on:
586
+ * 1. WebRTC calls with auto-answer enabled in agent profile
587
+ * 2. Agent-initiated WebRTC outdial calls
588
+ * 3. Agent-initiated digital outbound (Email/SMS) without previous transfers
589
+ *
590
+ * @param task - The task to auto-answer
591
+ * @private
592
+ */
593
+ private async handleAutoAnswer(task: ITask): Promise<void> {
594
+ if (!task || !task.data || !task.data.isAutoAnswering) {
595
+ return;
596
+ }
597
+
598
+ LoggerProxy.info(`Auto-answering task`, {
599
+ module: TASK_MANAGER_FILE,
600
+ method: 'handleAutoAnswer',
601
+ interactionId: task.data.interactionId,
602
+ });
603
+
604
+ try {
605
+ await task.accept();
606
+ LoggerProxy.info(`Task auto-answered successfully`, {
607
+ module: TASK_MANAGER_FILE,
608
+ method: 'handleAutoAnswer',
609
+ interactionId: task.data.interactionId,
610
+ });
611
+
612
+ // Track successful auto-answer
613
+ this.metricsManager.trackEvent(
614
+ METRIC_EVENT_NAMES.TASK_AUTO_ANSWER_SUCCESS,
615
+ {
616
+ taskId: task.data.interactionId,
617
+ mediaType: task.data.interaction.mediaType,
618
+ isAutoAnswered: true,
619
+ },
620
+ ['behavioral', 'operational']
621
+ );
622
+ } catch (error) {
623
+ // Reset isAutoAnswering flag on failure
624
+ task.updateTaskData({...task.data, isAutoAnswering: false});
625
+ LoggerProxy.error(`Failed to auto-answer task`, {
626
+ module: TASK_MANAGER_FILE,
627
+ method: 'handleAutoAnswer',
628
+ interactionId: task.data.interactionId,
629
+ error,
630
+ });
631
+
632
+ // Track auto-answer failure
633
+ this.metricsManager.trackEvent(
634
+ METRIC_EVENT_NAMES.TASK_AUTO_ANSWER_FAILED,
635
+ {
636
+ taskId: task.data.interactionId,
637
+ mediaType: task.data.interaction.mediaType,
638
+ error: error?.message || 'Unknown error',
639
+ isAutoAnswered: false,
640
+ },
641
+ ['behavioral', 'operational']
642
+ );
643
+ }
644
+ }
645
+
548
646
  /**
549
647
  * Handles cleanup of task resources including Desktop/WebRTC call cleanup and task removal
550
648
  * @param task - The task to clean up
@@ -562,13 +660,13 @@ export default class TaskManager extends EventEmitter {
562
660
 
563
661
  const isOutdial = task.data.interaction.outboundType === 'OUTDIAL';
564
662
  const isNew = task.data.interaction.state === 'new';
565
- const isTerminated = task.data.interaction.isTerminated;
663
+ const needsWrapUp = task.data.agentsPendingWrapUp?.length > 0;
566
664
 
567
665
  // For OUTDIAL: only remove if NOT terminated (user-declined, no wrap-up follows)
568
666
  // If terminated, keep task for wrap-up flow (CONTACT_ENDED → AGENT_WRAPUP)
569
667
  // For non-OUTDIAL: remove if state is 'new'
570
668
  // Always remove if secondary EpDn agent
571
- if ((isNew && !(isOutdial && isTerminated)) || isSecondaryEpDnAgent(task.data.interaction)) {
669
+ if ((isNew && !(isOutdial && needsWrapUp)) || isSecondaryEpDnAgent(task.data.interaction)) {
572
670
  this.removeTaskFromCollection(task);
573
671
  }
574
672
  }
@@ -1,5 +1,7 @@
1
1
  /* eslint-disable import/prefer-default-export */
2
- import {Interaction, ITask, TaskData} from './types';
2
+ import {Interaction, ITask, TaskData, MEDIA_CHANNEL} from './types';
3
+ import {OUTDIAL_DIRECTION, OUTDIAL_MEDIA_TYPE, OUTBOUND_TYPE} from '../../constants';
4
+ import {LoginOption} from '../../types';
3
5
 
4
6
  /**
5
7
  * Determines if the given agent is the primary agent (owner) of the task
@@ -111,3 +113,109 @@ export const isSecondaryEpDnAgent = (interaction: Interaction): boolean => {
111
113
 
112
114
  return interaction.mediaType === 'telephony' && isSecondaryAgent(interaction);
113
115
  };
116
+
117
+ /**
118
+ * Checks if auto-answer is enabled for the agent participant
119
+ * @param interaction - The interaction object
120
+ * @param agentId - Current agent ID
121
+ * @returns true if auto-answer is enabled, false otherwise
122
+ */
123
+ export const isAutoAnswerEnabled = (interaction: Interaction, agentId: string): boolean => {
124
+ return interaction.participants?.[agentId]?.autoAnswerEnabled === true;
125
+ };
126
+
127
+ /**
128
+ * Checks if the interaction is a WebRTC call eligible for auto-answer
129
+ * @param interaction - The interaction object
130
+ * @param loginOption - The agent's login option (BROWSER, AGENT_DN, etc.)
131
+ * @param webRtcEnabled - Whether WebRTC is enabled for the agent
132
+ * @returns true if this is a WebRTC call, false otherwise
133
+ */
134
+ export const isWebRTCCall = (
135
+ interaction: Interaction,
136
+ loginOption: string,
137
+ webRtcEnabled: boolean
138
+ ): boolean => {
139
+ return (
140
+ webRtcEnabled &&
141
+ loginOption === LoginOption.BROWSER &&
142
+ interaction.mediaType === OUTDIAL_MEDIA_TYPE
143
+ );
144
+ };
145
+
146
+ /**
147
+ * Checks if the interaction is a digital outbound (Email/SMS)
148
+ * @param interaction - The interaction object
149
+ * @returns true if this is a digital outbound, false otherwise
150
+ */
151
+ export const isDigitalOutbound = (interaction: Interaction): boolean => {
152
+ return (
153
+ interaction.contactDirection?.type === OUTDIAL_DIRECTION &&
154
+ interaction.outboundType === OUTBOUND_TYPE &&
155
+ (interaction.mediaChannel === MEDIA_CHANNEL.EMAIL ||
156
+ interaction.mediaChannel === MEDIA_CHANNEL.SMS)
157
+ );
158
+ };
159
+
160
+ /**
161
+ * Checks if the outdial was initiated by the current agent
162
+ * @param interaction - The interaction object
163
+ * @param agentId - Current agent ID
164
+ * @returns true if agent initiated the outdial, false otherwise
165
+ */
166
+ export const hasAgentInitiatedOutdial = (interaction: Interaction, agentId: string): boolean => {
167
+ return (
168
+ interaction.contactDirection?.type === OUTDIAL_DIRECTION &&
169
+ interaction.outboundType === OUTBOUND_TYPE &&
170
+ interaction.callProcessingDetails?.outdialAgentId === agentId &&
171
+ interaction.owner === agentId &&
172
+ !interaction.callProcessingDetails?.BLIND_TRANSFER_IN_PROGRESS
173
+ );
174
+ };
175
+
176
+ /**
177
+ * Determines if a task should be auto-answered based on interaction data
178
+ * Auto-answer logic handles:
179
+ * 1. WebRTC calls with auto-answer enabled in agent profile
180
+ * 2. Agent-initiated WebRTC outdial calls
181
+ * 3. Agent-initiated digital outbound (Email/SMS) without previous transfers
182
+ *
183
+ * @param taskData - The task data
184
+ * @param agentId - Current agent ID
185
+ * @param loginOption - Agent's login option
186
+ * @param webRtcEnabled - Whether WebRTC is enabled for the agent
187
+ * @returns true if task should be auto-answered, false otherwise
188
+ */
189
+ export const shouldAutoAnswerTask = (
190
+ taskData: TaskData,
191
+ agentId: string,
192
+ loginOption: string,
193
+ webRtcEnabled: boolean
194
+ ): boolean => {
195
+ const {interaction} = taskData;
196
+
197
+ if (!interaction || !agentId) {
198
+ return false;
199
+ }
200
+
201
+ // Check if auto-answer is enabled for this agent
202
+ const autoAnswerEnabled = isAutoAnswerEnabled(interaction, agentId);
203
+
204
+ // Check if this is an agent-initiated outdial
205
+ const agentInitiatedOutdial = hasAgentInitiatedOutdial(interaction, agentId);
206
+
207
+ // WebRTC telephony calls
208
+ if (isWebRTCCall(interaction, loginOption, webRtcEnabled)) {
209
+ return autoAnswerEnabled || agentInitiatedOutdial;
210
+ }
211
+
212
+ // Digital outbound (Email/SMS)
213
+ if (isDigitalOutbound(interaction) && agentInitiatedOutdial) {
214
+ // Don't auto-answer if task has been transferred (has previous vteams)
215
+ const hasPreviousVteams = interaction.previousVTeams && interaction.previousVTeams.length > 0;
216
+
217
+ return !hasPreviousVteams;
218
+ }
219
+
220
+ return false;
221
+ };
@@ -34,6 +34,8 @@ export const PRESERVED_TASK_DATA_FIELDS = {
34
34
  WRAP_UP_REQUIRED: 'wrapUpRequired',
35
35
  /** Indicates if a conference is currently in progress (2+ active agents) */
36
36
  IS_CONFERENCE_IN_PROGRESS: 'isConferenceInProgress',
37
+ /** Indicates if auto-answer is in progress for this task */
38
+ IS_AUTO_ANSWERING: 'isAutoAnswering',
37
39
  };
38
40
 
39
41
  /**
@@ -672,6 +672,8 @@ export type Interaction = {
672
672
  BLIND_TRANSFER_IN_PROGRESS?: boolean;
673
673
  /** Desktop view configuration for Flow Control */
674
674
  fcDesktopView?: string;
675
+ /** Agent ID who initiated the outdial call */
676
+ outdialAgentId?: string;
675
677
  };
676
678
  /** Main interaction identifier for related interactions */
677
679
  mainInteractionId?: string;
@@ -797,6 +799,10 @@ export type TaskData = {
797
799
  reservedAgentChannelId?: string;
798
800
  /** Indicates if wrap-up is required for this task */
799
801
  wrapUpRequired?: boolean;
802
+ /** Indicates if auto-answer is in progress for this task */
803
+ isAutoAnswering?: boolean;
804
+ /** Indicates if wrap-up is required for this task */
805
+ agentsPendingWrapUp?: string[];
800
806
  };
801
807
 
802
808
  /**
@@ -141,6 +141,7 @@ describe('webex.cc', () => {
141
141
  task: undefined,
142
142
  setWrapupData: jest.fn(),
143
143
  setAgentId: jest.fn(),
144
+ setWebRtcEnabled: jest.fn(),
144
145
  registerIncomingCallEvent: jest.fn(),
145
146
  registerTaskListeners: jest.fn(),
146
147
  getTask: jest.fn(),
@@ -152,6 +152,20 @@ describe('metrics/behavioral-events', () => {
152
152
  verb: 'fail',
153
153
  });
154
154
 
155
+ expect(getEventTaxonomy(METRIC_EVENT_NAMES.TASK_AUTO_ANSWER_SUCCESS)).toEqual({
156
+ product,
157
+ agent: 'user',
158
+ target: 'task_auto_answer',
159
+ verb: 'complete',
160
+ });
161
+
162
+ expect(getEventTaxonomy(METRIC_EVENT_NAMES.TASK_AUTO_ANSWER_FAILED)).toEqual({
163
+ product,
164
+ agent: 'user',
165
+ target: 'task_auto_answer',
166
+ verb: 'fail',
167
+ });
168
+
155
169
  expect(getEventTaxonomy('' as METRIC_EVENT_NAMES)).toEqual(undefined);
156
170
  });
157
171
  });
@@ -736,6 +736,148 @@ describe('TaskManager', () => {
736
736
  expect(taskEmitSpy).toHaveBeenCalledWith(TASK_EVENTS.TASK_OUTDIAL_FAILED, 'CUSTOMER_BUSY');
737
737
  });
738
738
 
739
+ it('should handle AGENT_OUTBOUND_FAILED gracefully when task is undefined', () => {
740
+ const payload = {
741
+ data: {
742
+ type: CC_EVENTS.AGENT_OUTBOUND_FAILED,
743
+ interactionId: 'non-existent-task-id',
744
+ reason: 'CUSTOMER_BUSY',
745
+ },
746
+ };
747
+ // Should not throw error when task doesn't exist
748
+ expect(() => {
749
+ webSocketManagerMock.emit('message', JSON.stringify(payload));
750
+ }).not.toThrow();
751
+ });
752
+
753
+ it('should NOT remove OUTDIAL task on CONTACT_ENDED when agentsPendingWrapUp exists', () => {
754
+ const task = taskManager.getTask(taskId);
755
+ task.updateTaskData = jest.fn().mockImplementation((newData) => {
756
+ task.data = {
757
+ ...task.data,
758
+ ...newData,
759
+ interaction: {
760
+ ...task.data.interaction,
761
+ outboundType: 'OUTDIAL',
762
+ state: 'new',
763
+ mediaType: 'telephony',
764
+ },
765
+ agentsPendingWrapUp: ['agent-123'],
766
+ };
767
+ return task;
768
+ });
769
+ task.unregisterWebCallListeners = jest.fn();
770
+ const removeTaskSpy = jest.spyOn(taskManager, 'removeTaskFromCollection');
771
+
772
+ const payload = {
773
+ data: {
774
+ type: CC_EVENTS.CONTACT_ENDED,
775
+ interactionId: taskId,
776
+ interaction: {
777
+ outboundType: 'OUTDIAL',
778
+ state: 'new',
779
+ mediaType: 'telephony',
780
+ },
781
+ agentsPendingWrapUp: ['agent-123'],
782
+ },
783
+ };
784
+
785
+ webSocketManagerMock.emit('message', JSON.stringify(payload));
786
+
787
+ expect(removeTaskSpy).not.toHaveBeenCalled();
788
+ expect(taskManager.getTask(taskId)).toBeDefined();
789
+ });
790
+
791
+ it('should remove OUTDIAL task on CONTACT_ENDED when agentsPendingWrapUp is empty', () => {
792
+ const task = taskManager.getTask(taskId);
793
+ task.updateTaskData = jest.fn().mockImplementation((newData) => {
794
+ task.data = {
795
+ ...task.data,
796
+ ...newData,
797
+ interaction: {
798
+ ...task.data.interaction,
799
+ outboundType: 'OUTDIAL',
800
+ state: 'new',
801
+ mediaType: 'telephony',
802
+ },
803
+ agentsPendingWrapUp: [],
804
+ };
805
+ return task;
806
+ });
807
+ task.unregisterWebCallListeners = jest.fn();
808
+ const removeTaskSpy = jest.spyOn(taskManager, 'removeTaskFromCollection');
809
+
810
+ const payload = {
811
+ data: {
812
+ type: CC_EVENTS.CONTACT_ENDED,
813
+ interactionId: taskId,
814
+ interaction: {
815
+ outboundType: 'OUTDIAL',
816
+ state: 'new',
817
+ mediaType: 'telephony',
818
+ },
819
+ agentsPendingWrapUp: [],
820
+ },
821
+ };
822
+
823
+ webSocketManagerMock.emit('message', JSON.stringify(payload));
824
+
825
+ expect(removeTaskSpy).toHaveBeenCalled();
826
+ });
827
+
828
+ it('should remove OUTDIAL task on CONTACT_ENDED when agentsPendingWrapUp is undefined', () => {
829
+ const task = taskManager.getTask(taskId);
830
+ task.updateTaskData = jest.fn().mockImplementation((newData) => {
831
+ task.data = {
832
+ ...task.data,
833
+ ...newData,
834
+ interaction: {
835
+ ...task.data.interaction,
836
+ outboundType: 'OUTDIAL',
837
+ state: 'new',
838
+ mediaType: 'telephony',
839
+ },
840
+ // agentsPendingWrapUp is undefined
841
+ };
842
+ return task;
843
+ });
844
+ task.unregisterWebCallListeners = jest.fn();
845
+ const removeTaskSpy = jest.spyOn(taskManager, 'removeTaskFromCollection');
846
+
847
+ const payload = {
848
+ data: {
849
+ type: CC_EVENTS.CONTACT_ENDED,
850
+ interactionId: taskId,
851
+ interaction: {
852
+ outboundType: 'OUTDIAL',
853
+ state: 'new',
854
+ mediaType: 'telephony',
855
+ },
856
+ // agentsPendingWrapUp not included
857
+ },
858
+ };
859
+
860
+ webSocketManagerMock.emit('message', JSON.stringify(payload));
861
+
862
+ expect(removeTaskSpy).toHaveBeenCalled();
863
+ });
864
+
865
+ it('should handle CONTACT_ENDED gracefully when task is undefined', () => {
866
+ const payload = {
867
+ data: {
868
+ type: CC_EVENTS.CONTACT_ENDED,
869
+ interactionId: 'non-existent-task-id',
870
+ interaction: {
871
+ state: 'new',
872
+ },
873
+ },
874
+ };
875
+ // Should not throw error when task doesn't exist
876
+ expect(() => {
877
+ webSocketManagerMock.emit('message', JSON.stringify(payload));
878
+ }).not.toThrow();
879
+ });
880
+
739
881
  it('should remove OUTDIAL task from taskCollection on AGENT_CONTACT_ASSIGN_FAILED when NOT terminated (user-declined)', () => {
740
882
  const task = taskManager.getTask(taskId);
741
883
  task.updateTaskData = jest.fn().mockImplementation((newData) => {
@@ -2191,5 +2333,6 @@ describe('TaskManager', () => {
2191
2333
  );
2192
2334
  });
2193
2335
  });
2336
+
2194
2337
  });
2195
2338