@webex/contact-center 3.12.0-llmrefactor.2 → 3.12.0-llmrefactor.4

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.
Files changed (187) hide show
  1. package/.sdd/manifest.json +5 -4
  2. package/ai-docs/ARCHITECTURE.md +1 -1
  3. package/ai-docs/CONTRACTS.md +6 -2
  4. package/ai-docs/SECURITY.md +1 -1
  5. package/ai-docs/contact-center-spec.md +22 -4
  6. package/ai-docs/features/consult-transfer-list-policy/spec/feature-spec.md +362 -0
  7. package/dist/cc.js +440 -10
  8. package/dist/cc.js.map +1 -1
  9. package/dist/config.js +7 -0
  10. package/dist/config.js.map +1 -1
  11. package/dist/constants.js +10 -2
  12. package/dist/constants.js.map +1 -1
  13. package/dist/index.js.map +1 -1
  14. package/dist/metrics/behavioral-events.js +13 -0
  15. package/dist/metrics/behavioral-events.js.map +1 -1
  16. package/dist/metrics/constants.js +2 -0
  17. package/dist/metrics/constants.js.map +1 -1
  18. package/dist/services/AddressBook.js +18 -15
  19. package/dist/services/AddressBook.js.map +1 -1
  20. package/dist/services/AnswerCallOnWebexService.js +174 -0
  21. package/dist/services/AnswerCallOnWebexService.js.map +1 -0
  22. package/dist/services/EntryPoint.js +46 -68
  23. package/dist/services/EntryPoint.js.map +1 -1
  24. package/dist/services/Queue.js +27 -22
  25. package/dist/services/Queue.js.map +1 -1
  26. package/dist/services/WebexCrossClientService.js +171 -0
  27. package/dist/services/WebexCrossClientService.js.map +1 -0
  28. package/dist/services/WxAppTelephonyMercurySync.js +93 -0
  29. package/dist/services/WxAppTelephonyMercurySync.js.map +1 -0
  30. package/dist/services/config/Util.js +3 -0
  31. package/dist/services/config/Util.js.map +1 -1
  32. package/dist/services/config/constants.js +10 -6
  33. package/dist/services/config/constants.js.map +1 -1
  34. package/dist/services/config/types.js +4 -0
  35. package/dist/services/config/types.js.map +1 -1
  36. package/dist/services/core/Err.js.map +1 -1
  37. package/dist/services/core/WebexRequest.js +6 -2
  38. package/dist/services/core/WebexRequest.js.map +1 -1
  39. package/dist/services/core/aqm-reqs.js +28 -14
  40. package/dist/services/core/aqm-reqs.js.map +1 -1
  41. package/dist/services/core/types.js.map +1 -1
  42. package/dist/services/task/Task.js +73 -7
  43. package/dist/services/task/Task.js.map +1 -1
  44. package/dist/services/task/TaskFactory.js +8 -4
  45. package/dist/services/task/TaskFactory.js.map +1 -1
  46. package/dist/services/task/TaskManager.js +353 -21
  47. package/dist/services/task/TaskManager.js.map +1 -1
  48. package/dist/services/task/WebexCallingUtils.js +70 -0
  49. package/dist/services/task/WebexCallingUtils.js.map +1 -0
  50. package/dist/services/task/constants.js +4 -1
  51. package/dist/services/task/constants.js.map +1 -1
  52. package/dist/services/task/contact.js +29 -0
  53. package/dist/services/task/contact.js.map +1 -1
  54. package/dist/services/task/digital/Digital.js +3 -2
  55. package/dist/services/task/digital/Digital.js.map +1 -1
  56. package/dist/services/task/state-machine/TaskStateMachine.js +135 -37
  57. package/dist/services/task/state-machine/TaskStateMachine.js.map +1 -1
  58. package/dist/services/task/state-machine/actions.js +6 -1
  59. package/dist/services/task/state-machine/actions.js.map +1 -1
  60. package/dist/services/task/state-machine/guards.js +38 -11
  61. package/dist/services/task/state-machine/guards.js.map +1 -1
  62. package/dist/services/task/state-machine/types.js.map +1 -1
  63. package/dist/services/task/state-machine/uiControlsComputer.js +118 -18
  64. package/dist/services/task/state-machine/uiControlsComputer.js.map +1 -1
  65. package/dist/services/task/types.js +16 -1
  66. package/dist/services/task/types.js.map +1 -1
  67. package/dist/services/task/voice/Voice.js +350 -14
  68. package/dist/services/task/voice/Voice.js.map +1 -1
  69. package/dist/services/task/voice/WebRTC.js +4 -1
  70. package/dist/services/task/voice/WebRTC.js.map +1 -1
  71. package/dist/services/task/voice/wxAppVoiceMethods.js +201 -0
  72. package/dist/services/task/voice/wxAppVoiceMethods.js.map +1 -0
  73. package/dist/services/wxAppTelephonyUtils.js +19 -0
  74. package/dist/services/wxAppTelephonyUtils.js.map +1 -0
  75. package/dist/types/cc.d.ts +77 -4
  76. package/dist/types/config.d.ts +7 -0
  77. package/dist/types/constants.d.ts +8 -0
  78. package/dist/types/index.d.ts +3 -1
  79. package/dist/types/metrics/constants.d.ts +2 -0
  80. package/dist/types/services/AddressBook.d.ts +2 -1
  81. package/dist/types/services/AnswerCallOnWebexService.d.ts +37 -0
  82. package/dist/types/services/EntryPoint.d.ts +7 -5
  83. package/dist/types/services/Queue.d.ts +5 -3
  84. package/dist/types/services/WebexCrossClientService.d.ts +28 -0
  85. package/dist/types/services/WxAppTelephonyMercurySync.d.ts +28 -0
  86. package/dist/types/services/config/constants.d.ts +8 -5
  87. package/dist/types/services/config/types.d.ts +17 -4
  88. package/dist/types/services/core/Err.d.ts +2 -0
  89. package/dist/types/services/core/WebexRequest.d.ts +1 -0
  90. package/dist/types/services/core/types.d.ts +2 -0
  91. package/dist/types/services/task/Task.d.ts +20 -2
  92. package/dist/types/services/task/TaskFactory.d.ts +2 -1
  93. package/dist/types/services/task/WebexCallingUtils.d.ts +11 -0
  94. package/dist/types/services/task/constants.d.ts +3 -0
  95. package/dist/types/services/task/contact.d.ts +4 -0
  96. package/dist/types/services/task/digital/Digital.d.ts +2 -2
  97. package/dist/types/services/task/state-machine/TaskStateMachine.d.ts +260 -56
  98. package/dist/types/services/task/state-machine/guards.d.ts +6 -5
  99. package/dist/types/services/task/state-machine/types.d.ts +9 -1
  100. package/dist/types/services/task/types.d.ts +81 -4
  101. package/dist/types/services/task/voice/Voice.d.ts +48 -9
  102. package/dist/types/services/task/voice/WebRTC.d.ts +2 -2
  103. package/dist/types/services/task/voice/wxAppVoiceMethods.d.ts +52 -0
  104. package/dist/types/services/wxAppTelephonyUtils.d.ts +5 -0
  105. package/dist/types/types.d.ts +14 -8
  106. package/dist/types/utils/PageCache.d.ts +20 -3
  107. package/dist/types.js +7 -2
  108. package/dist/types.js.map +1 -1
  109. package/dist/utils/PageCache.js +19 -5
  110. package/dist/utils/PageCache.js.map +1 -1
  111. package/dist/webex.js +1 -1
  112. package/package.json +9 -9
  113. package/src/cc.ts +506 -9
  114. package/src/config.ts +7 -0
  115. package/src/constants.ts +8 -0
  116. package/src/index.ts +2 -0
  117. package/src/metrics/ai-docs/metrics-spec.md +9 -3
  118. package/src/metrics/behavioral-events.ts +14 -0
  119. package/src/metrics/constants.ts +2 -0
  120. package/src/services/AddressBook.ts +17 -6
  121. package/src/services/AnswerCallOnWebexService.ts +206 -0
  122. package/src/services/EntryPoint.ts +59 -60
  123. package/src/services/Queue.ts +29 -12
  124. package/src/services/WebexCrossClientService.ts +212 -0
  125. package/src/services/WxAppTelephonyMercurySync.ts +115 -0
  126. package/src/services/ai-docs/AGENTS.md +10 -10
  127. package/src/services/ai-docs/services-spec.md +6 -1
  128. package/src/services/config/Util.ts +3 -0
  129. package/src/services/config/ai-docs/AGENTS.md +1 -1
  130. package/src/services/config/ai-docs/ARCHITECTURE.md +2 -2
  131. package/src/services/config/ai-docs/config-spec.md +6 -0
  132. package/src/services/config/constants.ts +10 -6
  133. package/src/services/config/types.ts +16 -4
  134. package/src/services/core/Err.ts +1 -0
  135. package/src/services/core/WebexRequest.ts +3 -1
  136. package/src/services/core/ai-docs/core-spec.md +5 -1
  137. package/src/services/core/aqm-reqs.ts +30 -15
  138. package/src/services/core/types.ts +2 -0
  139. package/src/services/task/Task.ts +79 -8
  140. package/src/services/task/TaskFactory.ts +8 -3
  141. package/src/services/task/TaskManager.ts +522 -16
  142. package/src/services/task/WebexCallingUtils.ts +136 -0
  143. package/src/services/task/ai-docs/ARCHITECTURE.md +1 -0
  144. package/src/services/task/ai-docs/task-spec.md +152 -2
  145. package/src/services/task/constants.ts +3 -0
  146. package/src/services/task/contact.ts +30 -0
  147. package/src/services/task/digital/Digital.ts +4 -2
  148. package/src/services/task/state-machine/TaskStateMachine.ts +210 -63
  149. package/src/services/task/state-machine/actions.ts +5 -2
  150. package/src/services/task/state-machine/ai-docs/AGENTS.md +8 -4
  151. package/src/services/task/state-machine/ai-docs/ARCHITECTURE.md +17 -8
  152. package/src/services/task/state-machine/ai-docs/task-state-machine-spec.md +56 -24
  153. package/src/services/task/state-machine/guards.ts +64 -11
  154. package/src/services/task/state-machine/types.ts +16 -1
  155. package/src/services/task/state-machine/uiControlsComputer.ts +206 -32
  156. package/src/services/task/types.ts +114 -4
  157. package/src/services/task/voice/Voice.ts +462 -12
  158. package/src/services/task/voice/WebRTC.ts +5 -1
  159. package/src/services/task/voice/wxAppVoiceMethods.ts +307 -0
  160. package/src/services/wxAppTelephonyUtils.ts +14 -0
  161. package/src/types.ts +33 -9
  162. package/src/utils/AGENTS.md +21 -10
  163. package/src/utils/PageCache.ts +38 -5
  164. package/src/utils/ai-docs/utils-spec.md +21 -11
  165. package/test/unit/spec/cc.ts +1274 -0
  166. package/test/unit/spec/metrics/behavioral-events.ts +18 -0
  167. package/test/unit/spec/services/AddressBook.ts +37 -6
  168. package/test/unit/spec/services/AnswerCallOnWebexService.ts +223 -0
  169. package/test/unit/spec/services/EntryPoint.ts +87 -40
  170. package/test/unit/spec/services/Queue.ts +123 -12
  171. package/test/unit/spec/services/WebexCrossClientService.ts +261 -0
  172. package/test/unit/spec/services/WxAppTelephonyMercurySync.ts +113 -0
  173. package/test/unit/spec/services/config/Util.ts +85 -0
  174. package/test/unit/spec/services/core/WebexRequest.ts +3 -1
  175. package/test/unit/spec/services/core/aqm-reqs.ts +113 -1
  176. package/test/unit/spec/services/task/Task.ts +200 -0
  177. package/test/unit/spec/services/task/TaskFactory.ts +39 -2
  178. package/test/unit/spec/services/task/TaskManager.ts +1294 -1
  179. package/test/unit/spec/services/task/WebexCallingUtils.ts +153 -0
  180. package/test/unit/spec/services/task/contact.ts +33 -0
  181. package/test/unit/spec/services/task/state-machine/TaskStateMachine.ts +854 -72
  182. package/test/unit/spec/services/task/state-machine/guards.ts +210 -8
  183. package/test/unit/spec/services/task/state-machine/uiControlsComputer.ts +445 -7
  184. package/test/unit/spec/services/task/voice/Voice.ts +874 -0
  185. package/test/unit/spec/services/task/voice/wxAppVoiceMethods.ts +459 -0
  186. package/umd/contact-center.min.js +2 -2
  187. package/umd/contact-center.min.js.map +1 -1
@@ -27,13 +27,21 @@ import {
27
27
  shouldAutoAnswerTask,
28
28
  } from './TaskUtils';
29
29
  import TaskFactory from './TaskFactory';
30
+ import AnswerCallOnWebexService from '../AnswerCallOnWebexService';
31
+ import {getWebexCallingDeviceDetailsForAgent} from './WebexCallingUtils';
30
32
  import WebRTC from './voice/WebRTC';
31
33
  import {TaskEvent, type TaskEventPayload} from './state-machine';
34
+ import {MEDIA_TYPE_MAIN_CALL} from './state-machine/constants';
32
35
  import {normalizeTaskData} from './taskDataNormalizer';
33
36
  import {ApiAIAssistant} from '../ApiAiAssistant';
34
37
 
35
38
  const CC_EVENT_SET = new Set<CC_EVENTS>(Object.values(CC_EVENTS) as CC_EVENTS[]);
36
39
 
40
+ const MAIN_INTERACTION_CORRELATED_EVENTS = new Set<CC_EVENTS>([
41
+ CC_EVENTS.AGENT_CONSULT_ENDED,
42
+ CC_EVENTS.PARTICIPANT_LEFT_CONFERENCE,
43
+ ]);
44
+
37
45
  const isCcEvent = (value: string): value is CC_EVENTS => CC_EVENT_SET.has(value as CC_EVENTS);
38
46
 
39
47
  /** @internal */
@@ -55,6 +63,7 @@ export default class TaskManager extends EventEmitter {
55
63
  private wrapupData: WrapupData;
56
64
  private agentId: string;
57
65
  private webRtcEnabled: boolean;
66
+ private answerCallOnWebexService?: AnswerCallOnWebexService;
58
67
  private apiAIAssistant?: ApiAIAssistant;
59
68
  /**
60
69
  * @param contact - Routing Contact layer. Talks to AQMReq layer to convert events to promises
@@ -148,6 +157,10 @@ export default class TaskManager extends EventEmitter {
148
157
  this.webRtcEnabled = webRtcEnabled;
149
158
  }
150
159
 
160
+ public setAnswerCallOnWebexService(answerCallOnWebexService: AnswerCallOnWebexService) {
161
+ this.answerCallOnWebexService = answerCallOnWebexService;
162
+ }
163
+
151
164
  private handleIncomingWebCall = (call: ICall) => {
152
165
  const currentTask = Object.values(this.taskCollection).find(
153
166
  (task) =>
@@ -187,12 +200,15 @@ export default class TaskManager extends EventEmitter {
187
200
  * @param ccEvent - The CC_EVENT type from WebSocket
188
201
  * @param payload - The event payload
189
202
  * @param agentId - Optional agent ID for state detection (needed for HYDRATE)
203
+ * @param task - Optional task instance for state-aware CONTACT_ENDED mapping
190
204
  * @returns TaskEventPayload for state machine or null if no mapping
191
205
  */
192
206
  private static mapEventToTaskStateMachineEvent(
193
207
  ccEvent: CC_EVENTS,
194
208
  payload: WebSocketPayload,
195
- agentId?: string
209
+ agentId?: string,
210
+ task?: ITask,
211
+ enableWxBetterTogether?: boolean
196
212
  ): TaskEventPayload | null {
197
213
  const mediaResourceId =
198
214
  payload.mediaResourceId ||
@@ -226,6 +242,15 @@ export default class TaskManager extends EventEmitter {
226
242
  return {type: TaskEvent.HYDRATE, taskData: payload, agentId};
227
243
 
228
244
  case CC_EVENTS.CONTACT_UPDATED:
245
+ if (
246
+ task &&
247
+ typeof payload.interaction?.owner === 'string' &&
248
+ payload.interaction.owner.trim().length > 0 &&
249
+ payload.interaction.owner !== task.data?.interaction?.owner
250
+ ) {
251
+ return {type: TaskEvent.CONTACT_OWNER_CHANGED, taskData: payload};
252
+ }
253
+
229
254
  return {type: TaskEvent.CONTACT_UPDATED, taskData: payload};
230
255
  case CC_EVENTS.CONTACT_OWNER_CHANGED:
231
256
  return {type: TaskEvent.CONTACT_OWNER_CHANGED, taskData: payload};
@@ -299,16 +324,39 @@ export default class TaskManager extends EventEmitter {
299
324
  case CC_EVENTS.AGENT_CONFERENCE_TRANSFER_FAILED:
300
325
  return {type: TaskEvent.TRANSFER_FAILED, taskData: payload};
301
326
 
302
- case CC_EVENTS.CONTACT_ENDED:
327
+ case CC_EVENTS.CONTACT_ENDED: {
328
+ const wrapUpRequired = isCampaignPreviewTask(payload)
329
+ ? false
330
+ : payload.agentsPendingWrapUp?.includes(agentId || '') || false;
331
+
332
+ // Post-accept wxApp outdial agent-end delivers ContactEnded instead of
333
+ // AgentOutboundFailed. Normalize to OUTBOUND_FAILED so state machine
334
+ // enters WRAPPING_UP and emits TASK_OUTDIAL_FAILED (AGENT_ENDS).
335
+ // Pre-accept cancelTask decline stays CONTACT_ENDED → TERMINATED (no wrapup).
336
+ if (
337
+ TaskManager.isAgentTerminatedOutdialWrapup(
338
+ payload,
339
+ wrapUpRequired,
340
+ task,
341
+ agentId,
342
+ enableWxBetterTogether
343
+ )
344
+ ) {
345
+ return {
346
+ type: TaskEvent.OUTBOUND_FAILED,
347
+ reason: 'AGENT_ENDS',
348
+ taskData: {...payload, wrapUpRequired: true},
349
+ };
350
+ }
351
+
303
352
  return {
304
353
  type: TaskEvent.CONTACT_ENDED,
305
354
  taskData: {
306
355
  ...payload,
307
- wrapUpRequired: isCampaignPreviewTask(payload)
308
- ? false
309
- : payload.agentsPendingWrapUp?.includes(agentId || '') || false,
356
+ wrapUpRequired,
310
357
  },
311
358
  };
359
+ }
312
360
 
313
361
  case CC_EVENTS.AGENT_INVITE_FAILED:
314
362
  return {type: TaskEvent.INVITE_FAILED, reason: payload.reason};
@@ -319,8 +367,23 @@ export default class TaskManager extends EventEmitter {
319
367
  case CC_EVENTS.AGENT_CONTACT_OFFER_RONA:
320
368
  return {type: TaskEvent.RONA, taskData: payload, reason: payload.reason};
321
369
 
322
- case CC_EVENTS.AGENT_OUTBOUND_FAILED:
323
- return {type: TaskEvent.OUTBOUND_FAILED, taskData: payload, reason: payload.reason};
370
+ case CC_EVENTS.AGENT_OUTBOUND_FAILED: {
371
+ const isAgentTerminatedOutdial =
372
+ payload.interaction?.outboundType === 'OUTDIAL' &&
373
+ (payload.terminatingParty === 'Agent' || payload.reason === 'AGENT_ENDS');
374
+
375
+ const suppressOutdialFailedPopup =
376
+ isAgentTerminatedOutdial &&
377
+ !TaskManager.isWxAppManagedOutdialTask(agentId, payload, task, enableWxBetterTogether);
378
+
379
+ return {
380
+ type: TaskEvent.OUTBOUND_FAILED,
381
+ taskData: suppressOutdialFailedPopup
382
+ ? {...payload, suppressOutdialFailedPopup: true}
383
+ : payload,
384
+ reason: payload.reason,
385
+ };
386
+ }
324
387
 
325
388
  case CC_EVENTS.CAMPAIGN_PREVIEW_ACCEPT_FAILED:
326
389
  return {type: TaskEvent.CAMPAIGN_PREVIEW_ACCEPT_FAILED, taskData: payload};
@@ -371,6 +434,68 @@ export default class TaskManager extends EventEmitter {
371
434
  }
372
435
  }
373
436
 
437
+ /**
438
+ * True when the current agent's task is a wxApp-managed outdial (thick-client telephony).
439
+ */
440
+ private static isWxAppManagedOutdialTask(
441
+ agentId: string | undefined,
442
+ payload: WebSocketPayload,
443
+ task: ITask | undefined,
444
+ enableWxBetterTogether?: boolean
445
+ ): boolean {
446
+ const wxAppEnabled =
447
+ (task as {uiControlConfig?: {enableWxBetterTogether?: boolean}})?.uiControlConfig
448
+ ?.enableWxBetterTogether ??
449
+ enableWxBetterTogether ??
450
+ false;
451
+
452
+ if (!wxAppEnabled) {
453
+ return false;
454
+ }
455
+
456
+ const effectiveAgentId = agentId ?? task?.data?.agentId;
457
+ const participants = payload.interaction?.participants ?? task?.data?.interaction?.participants;
458
+ const details = getWebexCallingDeviceDetailsForAgent(effectiveAgentId, participants);
459
+
460
+ return details?.deviceType === 'wxApp';
461
+ }
462
+
463
+ /**
464
+ * True when ContactEnded represents an agent-terminated wxApp outdial that should
465
+ * enter wrapup with an outdial-failed popup (legacy AgentOutboundFailed parity).
466
+ */
467
+ private static isAgentTerminatedOutdialWrapup(
468
+ payload: WebSocketPayload,
469
+ wrapUpRequired: boolean,
470
+ task?: ITask,
471
+ agentId?: string,
472
+ enableWxBetterTogether?: boolean
473
+ ): boolean {
474
+ const isOutdial = payload.interaction?.outboundType === 'OUTDIAL';
475
+ const agentTerminated = payload.terminatingParty === 'Agent';
476
+
477
+ if (!isOutdial || !agentTerminated) {
478
+ return false;
479
+ }
480
+
481
+ if (!TaskManager.isWxAppManagedOutdialTask(agentId, payload, task, enableWxBetterTogether)) {
482
+ return false;
483
+ }
484
+
485
+ const wxAppAnswerPending =
486
+ (task as {uiControlConfig?: {wxAppAnswerPending?: boolean}})?.uiControlConfig
487
+ ?.wxAppAnswerPending ?? false;
488
+
489
+ // Pre-accept decline: decline() clears wxAppAnswerPending; backend may still send state wrapUp.
490
+ if (!wxAppAnswerPending && !wrapUpRequired) {
491
+ return false;
492
+ }
493
+
494
+ const interactionWrapUp = payload.interaction?.state === 'wrapUp';
495
+
496
+ return wrapUpRequired || interactionWrapUp || wxAppAnswerPending;
497
+ }
498
+
374
499
  /**
375
500
  * Register WebSocket message listeners for task events
376
501
  *
@@ -482,6 +607,14 @@ export default class TaskManager extends EventEmitter {
482
607
  }
483
608
  }
484
609
 
610
+ if (!task && eventType === CC_EVENTS.CONTACT_OWNER_CHANGED) {
611
+ task = this.findTaskForContactOwnerChange(message.data);
612
+ }
613
+
614
+ if (!task && MAIN_INTERACTION_CORRELATED_EVENTS.has(eventType)) {
615
+ task = this.findUniqueTaskByRelatedInteraction(message.data);
616
+ }
617
+
485
618
  const wasConsultedTask = Boolean(task?.data?.isConsulted);
486
619
  const computeWrapUpRequired = () => {
487
620
  if (message.data.wrapUpRequired !== undefined) {
@@ -494,7 +627,7 @@ export default class TaskManager extends EventEmitter {
494
627
  return !wasConsultedTask;
495
628
  };
496
629
 
497
- const adjustedPayload =
630
+ let adjustedPayload: WebSocketPayload =
498
631
  eventType === CC_EVENTS.AGENT_CONSULT_TRANSFERRED ||
499
632
  eventType === CC_EVENTS.AGENT_BLIND_TRANSFERRED ||
500
633
  eventType === CC_EVENTS.AGENT_VTEAM_TRANSFERRED
@@ -504,10 +637,20 @@ export default class TaskManager extends EventEmitter {
504
637
  }
505
638
  : message.data;
506
639
 
640
+ if (task && eventType === CC_EVENTS.CONTACT_OWNER_CHANGED) {
641
+ adjustedPayload = TaskManager.preserveMainTaskIdentity(task, adjustedPayload);
642
+ }
643
+
644
+ if (task && eventType === CC_EVENTS.PARTICIPANT_LEFT_CONFERENCE) {
645
+ adjustedPayload = TaskManager.preserveConfirmedOwner(task, adjustedPayload);
646
+ }
647
+
507
648
  const stateMachineEvent = TaskManager.mapEventToTaskStateMachineEvent(
508
649
  eventType,
509
650
  adjustedPayload,
510
- this.agentId
651
+ this.agentId,
652
+ task,
653
+ this.configFlags?.enableWxBetterTogether
511
654
  );
512
655
 
513
656
  LoggerProxy.info(`Handling task event ${eventType}`, {
@@ -524,6 +667,236 @@ export default class TaskManager extends EventEmitter {
524
667
  };
525
668
  }
526
669
 
670
+ /**
671
+ * ContactOwnerChanged can identify the promoted agent's child interaction while
672
+ * the surviving task is stored under the main interaction. Accept one related
673
+ * task when its existing snapshot or the authoritative incoming promotion proves
674
+ * current-agent main-call membership, while excluding non-promoted and ambiguous matches.
675
+ */
676
+ private findTaskForContactOwnerChange(payload: WebSocketPayload): ITask | undefined {
677
+ const relatedCandidates = this.findRelatedTasks(payload, {
678
+ includeCollectionKeys: true,
679
+ includeMainCallMediaKeys: true,
680
+ });
681
+ const incomingShowsCurrentAgentPromotion =
682
+ this.isCurrentAgentPromotedOnIncomingMainCall(payload);
683
+ const eligibleCandidates = relatedCandidates.filter(
684
+ (candidate) =>
685
+ this.isCurrentAgentActiveOnMainInteraction(candidate.data) ||
686
+ incomingShowsCurrentAgentPromotion
687
+ );
688
+
689
+ if (eligibleCandidates.length === 1) {
690
+ return eligibleCandidates[0];
691
+ }
692
+
693
+ if (eligibleCandidates.length > 1) {
694
+ LoggerProxy.warn('Unable to correlate task event to a unique main interaction task', {
695
+ module: TASK_MANAGER_FILE,
696
+ method: 'findTaskForContactOwnerChange',
697
+ interactionId:
698
+ payload.interaction?.mainInteractionId ||
699
+ payload.interaction?.parentInteractionId ||
700
+ payload.interaction?.callProcessingDetails?.parentInteractionId ||
701
+ payload.interactionId,
702
+ });
703
+
704
+ return undefined;
705
+ }
706
+
707
+ return undefined;
708
+ }
709
+
710
+ private isCurrentAgentActiveOnMainInteraction(taskData: TaskData | undefined): boolean {
711
+ const currentAgentId = this.agentId || taskData?.agentId;
712
+ if (!currentAgentId) return false;
713
+
714
+ return TaskManager.isParticipantActiveOnMainInteraction(taskData, currentAgentId);
715
+ }
716
+
717
+ private static isParticipantActiveOnMainInteraction(
718
+ taskData: TaskData | WebSocketPayload | undefined,
719
+ participantId: string
720
+ ): boolean {
721
+ const interaction = taskData?.interaction;
722
+ const participant = interaction?.participants?.[participantId];
723
+ if (!participant || participant.hasLeft === true) return false;
724
+
725
+ return Object.values(interaction?.media ?? {}).some(
726
+ (media) =>
727
+ media?.mType === MEDIA_TYPE_MAIN_CALL && media.participants?.includes(participantId)
728
+ );
729
+ }
730
+
731
+ private isCurrentAgentPromotedOnIncomingMainCall(payload: WebSocketPayload): boolean {
732
+ return Boolean(
733
+ this.agentId &&
734
+ payload.interaction?.owner === this.agentId &&
735
+ TaskManager.isParticipantActiveOnMainInteraction(payload, this.agentId)
736
+ );
737
+ }
738
+
739
+ private static getMainCallMediaId(
740
+ interaction: TaskData['interaction'] | undefined
741
+ ): string | undefined {
742
+ return Object.entries(interaction?.media ?? {}).find(
743
+ ([, media]) => media?.mType === MEDIA_TYPE_MAIN_CALL
744
+ )?.[0];
745
+ }
746
+
747
+ private static getStableMainInteractionId(payload: WebSocketPayload): string | undefined {
748
+ return (
749
+ payload.interaction?.mainInteractionId ||
750
+ TaskManager.getMainCallMediaId(payload.interaction) ||
751
+ payload.interaction?.interactionId ||
752
+ payload.interactionId
753
+ );
754
+ }
755
+
756
+ private canRecoverTaskFromContactOwnerChanged(payload: WebSocketPayload): boolean {
757
+ const interaction = payload.interaction;
758
+ const stableInteractionId = TaskManager.getStableMainInteractionId(payload);
759
+
760
+ return Boolean(
761
+ this.agentId &&
762
+ stableInteractionId &&
763
+ interaction?.mediaType === MEDIA_CHANNEL.TELEPHONY &&
764
+ interaction.isTerminated !== true &&
765
+ interaction.owner === this.agentId &&
766
+ TaskManager.isParticipantActiveOnMainInteraction(payload, this.agentId)
767
+ );
768
+ }
769
+
770
+ /** Keep a related owner-change event from re-keying the surviving main task to a child ID. */
771
+ private static preserveMainTaskIdentity(
772
+ task: ITask,
773
+ payload: WebSocketPayload
774
+ ): WebSocketPayload {
775
+ const currentMainInteractionId = task.data?.interaction?.mainInteractionId;
776
+ const payloadMainInteractionId = payload.interaction?.mainInteractionId;
777
+ const currentMainMediaId = TaskManager.getMainCallMediaId(task.data?.interaction);
778
+ const payloadMainMediaId = TaskManager.getMainCallMediaId(payload.interaction);
779
+ const stableInteractionId =
780
+ currentMainInteractionId ||
781
+ payloadMainInteractionId ||
782
+ currentMainMediaId ||
783
+ payloadMainMediaId ||
784
+ payload.interaction?.interactionId ||
785
+ task.data?.interaction?.interactionId ||
786
+ task.data?.interactionId;
787
+
788
+ if (!stableInteractionId || stableInteractionId === payload.interactionId) {
789
+ return payload;
790
+ }
791
+
792
+ return {...payload, interactionId: stableInteractionId};
793
+ }
794
+
795
+ /**
796
+ * ParticipantLeftConference may arrive after an owner update while still carrying
797
+ * the departed owner. Keep the confirmed owner only when the new roster proves that
798
+ * owner is active on mainCall and identifies the incoming owner as departed.
799
+ */
800
+ private static preserveConfirmedOwner(task: ITask, payload: WebSocketPayload): WebSocketPayload {
801
+ const confirmedOwner = task.data?.interaction?.owner;
802
+ const incomingOwner = payload.interaction?.owner;
803
+ if (!confirmedOwner || !incomingOwner || confirmedOwner === incomingOwner) {
804
+ return payload;
805
+ }
806
+
807
+ const confirmedOwnerIsActive = TaskManager.isParticipantActiveOnMainInteraction(
808
+ payload,
809
+ confirmedOwner
810
+ );
811
+ const incomingOwnerIsExplicitlyDeparted =
812
+ payload.participantId === incomingOwner ||
813
+ payload.interaction?.participants?.[incomingOwner]?.hasLeft === true;
814
+
815
+ if (!confirmedOwnerIsActive || !incomingOwnerIsExplicitlyDeparted) {
816
+ return payload;
817
+ }
818
+
819
+ return {
820
+ ...payload,
821
+ owner: confirmedOwner,
822
+ interaction: {...payload.interaction, owner: confirmedOwner},
823
+ };
824
+ }
825
+
826
+ /**
827
+ * Resolve lifecycle events that are emitted for the main interaction while the
828
+ * local task can still be indexed by a child consult interaction. Exact task
829
+ * keys are handled before this fallback. Multiple aliases of the same task are
830
+ * treated as one candidate; genuinely ambiguous matches are intentionally ignored.
831
+ */
832
+ private findUniqueTaskByRelatedInteraction(
833
+ payload: WebSocketPayload,
834
+ candidateFilter: (task: ITask) => boolean = () => true
835
+ ): ITask | undefined {
836
+ const candidates = this.findRelatedTasks(payload).filter(candidateFilter);
837
+
838
+ if (candidates.length === 1) {
839
+ return candidates[0];
840
+ }
841
+
842
+ if (candidates.length > 1) {
843
+ LoggerProxy.warn('Unable to correlate task event to a unique main interaction task', {
844
+ module: TASK_MANAGER_FILE,
845
+ method: 'findUniqueTaskByRelatedInteraction',
846
+ interactionId:
847
+ payload.interaction?.mainInteractionId ||
848
+ payload.interaction?.parentInteractionId ||
849
+ payload.interaction?.callProcessingDetails?.parentInteractionId ||
850
+ payload.interactionId,
851
+ });
852
+ }
853
+
854
+ return undefined;
855
+ }
856
+
857
+ private findRelatedTasks(
858
+ payload: WebSocketPayload,
859
+ options: {includeCollectionKeys?: boolean; includeMainCallMediaKeys?: boolean} = {}
860
+ ): ITask[] {
861
+ const {includeCollectionKeys = false, includeMainCallMediaKeys = false} = options;
862
+ const payloadInteractionIds = new Set(
863
+ [
864
+ payload.interactionId,
865
+ payload.interaction?.interactionId,
866
+ payload.interaction?.mainInteractionId,
867
+ payload.interaction?.parentInteractionId,
868
+ payload.interaction?.callProcessingDetails?.parentInteractionId,
869
+ ...(includeMainCallMediaKeys ? [TaskManager.getMainCallMediaId(payload.interaction)] : []),
870
+ ].filter((interactionId): interactionId is string => Boolean(interactionId))
871
+ );
872
+ if (payloadInteractionIds.size === 0) return [];
873
+
874
+ return [
875
+ ...new Set(
876
+ Object.entries(this.taskCollection)
877
+ .filter(([taskId, candidate]) => {
878
+ const taskInteraction = candidate?.data?.interaction;
879
+ const candidateInteractionIds = [
880
+ ...(includeCollectionKeys ? [taskId] : []),
881
+ candidate?.data?.interactionId,
882
+ taskInteraction?.interactionId,
883
+ taskInteraction?.mainInteractionId,
884
+ taskInteraction?.parentInteractionId,
885
+ taskInteraction?.callProcessingDetails?.parentInteractionId,
886
+ ...(includeMainCallMediaKeys
887
+ ? [TaskManager.getMainCallMediaId(taskInteraction)]
888
+ : []),
889
+ ];
890
+
891
+ return candidateInteractionIds.some(
892
+ (interactionId) => Boolean(interactionId) && payloadInteractionIds.has(interactionId)
893
+ );
894
+ })
895
+ .map(([, candidate]) => candidate)
896
+ ),
897
+ ];
898
+ }
899
+
527
900
  /**
528
901
  * Handle task lifecycle events and determine required actions
529
902
  *
@@ -549,6 +922,9 @@ export default class TaskManager extends EventEmitter {
549
922
  case CC_EVENTS.CONTACT_MERGED:
550
923
  return this.handleContactMergedEvent(context);
551
924
 
925
+ case CC_EVENTS.CONTACT_OWNER_CHANGED:
926
+ return this.handleContactOwnerChanged(context);
927
+
552
928
  case CC_EVENTS.AGENT_OFFER_CAMPAIGN_RESERVATION:
553
929
  return this.handleCampaignPreviewReservation(context);
554
930
 
@@ -560,6 +936,74 @@ export default class TaskManager extends EventEmitter {
560
936
  }
561
937
  }
562
938
 
939
+ /**
940
+ * Recover the promoted agent's active voice task when local task state was lost.
941
+ * Existing related tasks are never replaced: an unresolved or ambiguous relation
942
+ * is safer to ignore than to create a duplicate task.
943
+ */
944
+ private handleContactOwnerChanged(context: EventContext): TaskEventActions {
945
+ if (context.task) {
946
+ return {task: context.task};
947
+ }
948
+
949
+ const {payload} = context;
950
+ if (
951
+ this.findRelatedTasks(payload, {
952
+ includeCollectionKeys: true,
953
+ includeMainCallMediaKeys: true,
954
+ }).length > 0 ||
955
+ !this.canRecoverTaskFromContactOwnerChanged(payload)
956
+ ) {
957
+ return {};
958
+ }
959
+
960
+ const stableInteractionId = TaskManager.getStableMainInteractionId(payload);
961
+ if (!stableInteractionId || this.taskCollection[stableInteractionId]) {
962
+ return {};
963
+ }
964
+
965
+ const normalizedPayload: WebSocketPayload =
966
+ payload.interactionId === stableInteractionId
967
+ ? payload
968
+ : {...payload, interactionId: stableInteractionId};
969
+ const taskData: TaskData = {
970
+ ...normalizedPayload,
971
+ owner: normalizedPayload.interaction.owner,
972
+ wrapUpRequired: normalizedPayload.interaction.participants?.[this.agentId]?.isWrapUp || false,
973
+ isConferenceInProgress: getIsConferenceInProgress(normalizedPayload),
974
+ isConsulted: false,
975
+ isAutoAnswering: false,
976
+ };
977
+ const task = TaskFactory.createTask(
978
+ this.contact,
979
+ this.webCallingService,
980
+ taskData,
981
+ this.configFlags,
982
+ this.wrapupData,
983
+ this.agentId,
984
+ this.answerCallOnWebexService
985
+ );
986
+
987
+ this.taskCollection[stableInteractionId] = task;
988
+
989
+ // Restore the actor before installing listeners so the internal hydrate does
990
+ // not leak as a second public hydrate or as an incoming-task notification.
991
+ task.sendStateMachineEvent({
992
+ type: TaskEvent.HYDRATE,
993
+ taskData,
994
+ agentId: this.agentId,
995
+ } as TaskEventPayload);
996
+
997
+ this.setupTaskListeners(task);
998
+ context.payload = taskData;
999
+ context.stateMachineEvent = {
1000
+ type: TaskEvent.CONTACT_OWNER_CHANGED,
1001
+ taskData,
1002
+ };
1003
+
1004
+ return {task};
1005
+ }
1006
+
563
1007
  private handleCampaignContactUpdated(context: EventContext) {
564
1008
  const {payload} = context;
565
1009
  let {task} = context;
@@ -647,7 +1091,8 @@ export default class TaskManager extends EventEmitter {
647
1091
  },
648
1092
  this.configFlags,
649
1093
  this.wrapupData,
650
- this.agentId
1094
+ this.agentId,
1095
+ this.answerCallOnWebexService
651
1096
  );
652
1097
  this.setupTaskListeners(task);
653
1098
  this.taskCollection[payload.interactionId] = task;
@@ -685,7 +1130,8 @@ export default class TaskManager extends EventEmitter {
685
1130
  taskData,
686
1131
  this.configFlags,
687
1132
  this.wrapupData,
688
- this.agentId
1133
+ this.agentId,
1134
+ this.answerCallOnWebexService
689
1135
  );
690
1136
 
691
1137
  this.setupTaskListeners(task);
@@ -725,7 +1171,8 @@ export default class TaskManager extends EventEmitter {
725
1171
  taskData,
726
1172
  this.configFlags,
727
1173
  this.wrapupData,
728
- this.agentId
1174
+ this.agentId,
1175
+ this.answerCallOnWebexService
729
1176
  );
730
1177
  this.setupTaskListeners(task);
731
1178
  this.taskCollection[payload.interactionId] = task;
@@ -753,6 +1200,14 @@ export default class TaskManager extends EventEmitter {
753
1200
  : taskData;
754
1201
 
755
1202
  task.updateTaskData(updateTaskData);
1203
+
1204
+ // A main-interaction lifecycle event can re-key a child consult task. Remove
1205
+ // any aliases first so consumers never observe duplicate task entries.
1206
+ Object.entries(this.taskCollection).forEach(([taskId, candidate]) => {
1207
+ if (candidate === task && taskId !== taskData.interactionId) {
1208
+ delete this.taskCollection[taskId];
1209
+ }
1210
+ });
756
1211
  this.taskCollection[taskData.interactionId] = task;
757
1212
 
758
1213
  return task;
@@ -798,8 +1253,10 @@ export default class TaskManager extends EventEmitter {
798
1253
  task.on(TASK_EVENTS.TASK_CLEANUP, (t: ITask, options?: {removeFromCollection?: boolean}) => {
799
1254
  this.handleTaskCleanup(t);
800
1255
  if (options?.removeFromCollection) {
801
- const interactionId = t?.data?.interactionId;
802
- if (interactionId && this.taskCollection[interactionId]) {
1256
+ const taskIsStillCollected = Object.values(this.taskCollection).some(
1257
+ (candidate) => candidate === t
1258
+ );
1259
+ if (taskIsStillCollected) {
803
1260
  this.removeTaskFromCollection(t);
804
1261
  }
805
1262
  }
@@ -811,7 +1268,11 @@ export default class TaskManager extends EventEmitter {
811
1268
  task.cancelAutoWrapupTimer();
812
1269
  }
813
1270
  if (task?.data?.interactionId) {
814
- delete this.taskCollection[task.data.interactionId];
1271
+ Object.entries(this.taskCollection).forEach(([taskId, candidate]) => {
1272
+ if (candidate === task) {
1273
+ delete this.taskCollection[taskId];
1274
+ }
1275
+ });
815
1276
  LoggerProxy.info(`Task removed from collection`, {
816
1277
  module: TASK_MANAGER_FILE,
817
1278
  method: METHODS.REMOVE_TASK_FROM_COLLECTION,
@@ -864,7 +1325,8 @@ export default class TaskManager extends EventEmitter {
864
1325
  taskData,
865
1326
  this.configFlags,
866
1327
  this.wrapupData,
867
- this.agentId
1328
+ this.agentId,
1329
+ this.answerCallOnWebexService
868
1330
  );
869
1331
  this.taskCollection[payload.interactionId] = task;
870
1332
 
@@ -956,6 +1418,50 @@ export default class TaskManager extends EventEmitter {
956
1418
  return {...this.taskCollection};
957
1419
  }
958
1420
 
1421
+ /**
1422
+ * Propagate runtime enableWxBetterTogether changes to config flags and active tasks.
1423
+ */
1424
+ public applyEnableWxBetterTogether(enabled: boolean): void {
1425
+ if (this.configFlags) {
1426
+ this.configFlags = {...this.configFlags, enableWxBetterTogether: enabled};
1427
+ }
1428
+
1429
+ Object.values(this.taskCollection).forEach((task) => {
1430
+ task.setEnableWxBetterTogether(enabled);
1431
+ if (enabled) {
1432
+ TaskManager.syncWxAppMuteFromCallDetailsForTask(task);
1433
+ }
1434
+ });
1435
+ }
1436
+
1437
+ /**
1438
+ * Propagate wxApp mute state from Mercury sync to active voice tasks.
1439
+ */
1440
+ public applyWxAppMuteStateFromSync(callId: string, muted: boolean): void {
1441
+ Object.values(this.taskCollection).forEach((task) => {
1442
+ task.applyWxAppMuteStateFromSync(callId, muted);
1443
+ });
1444
+ }
1445
+
1446
+ /**
1447
+ * Re-seeds wxApp mute state from GET telephony/calls/{callId} for one task.
1448
+ */
1449
+ public static syncWxAppMuteFromCallDetailsForTask(task: ITask): void {
1450
+ const sync = task.syncWxAppMuteFromCallDetails?.bind(task);
1451
+ if (typeof sync === 'function') {
1452
+ sync().catch(() => undefined);
1453
+ }
1454
+ }
1455
+
1456
+ /**
1457
+ * Re-seeds wxApp mute for all engaged voice tasks (reload / runtime enable backfill).
1458
+ */
1459
+ public syncWxAppMuteFromCallDetailsForAllTasks(): void {
1460
+ Object.values(this.taskCollection).forEach((task) => {
1461
+ TaskManager.syncWxAppMuteFromCallDetailsForTask(task);
1462
+ });
1463
+ }
1464
+
959
1465
  public static getTaskManager(
960
1466
  apiAIAssistant: ApiAIAssistant,
961
1467
  contact: ReturnType<typeof routingContact>,