@webex/contact-center 3.12.0-llmrefactor.1 → 3.12.0-llmrefactor.11

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 (198) hide show
  1. package/.sdd/manifest.json +7 -5
  2. package/ai-docs/ARCHITECTURE.md +1 -1
  3. package/ai-docs/CONTRACTS.md +8 -3
  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 +544 -12
  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/MetricsManager.js +16 -0
  15. package/dist/metrics/MetricsManager.js.map +1 -1
  16. package/dist/metrics/behavioral-events.js +104 -0
  17. package/dist/metrics/behavioral-events.js.map +1 -1
  18. package/dist/metrics/constants.js +18 -0
  19. package/dist/metrics/constants.js.map +1 -1
  20. package/dist/services/AddressBook.js +18 -15
  21. package/dist/services/AddressBook.js.map +1 -1
  22. package/dist/services/AnswerCallOnWebexService.js +202 -0
  23. package/dist/services/AnswerCallOnWebexService.js.map +1 -0
  24. package/dist/services/EntryPoint.js +46 -68
  25. package/dist/services/EntryPoint.js.map +1 -1
  26. package/dist/services/Queue.js +27 -22
  27. package/dist/services/Queue.js.map +1 -1
  28. package/dist/services/WebexCrossClientService.js +222 -0
  29. package/dist/services/WebexCrossClientService.js.map +1 -0
  30. package/dist/services/WxAppTelephonyMercurySync.js +108 -0
  31. package/dist/services/WxAppTelephonyMercurySync.js.map +1 -0
  32. package/dist/services/config/Util.js +3 -0
  33. package/dist/services/config/Util.js.map +1 -1
  34. package/dist/services/config/constants.js +10 -6
  35. package/dist/services/config/constants.js.map +1 -1
  36. package/dist/services/config/types.js +4 -0
  37. package/dist/services/config/types.js.map +1 -1
  38. package/dist/services/core/Err.js.map +1 -1
  39. package/dist/services/core/WebexRequest.js +6 -2
  40. package/dist/services/core/WebexRequest.js.map +1 -1
  41. package/dist/services/core/aqm-reqs.js +28 -14
  42. package/dist/services/core/aqm-reqs.js.map +1 -1
  43. package/dist/services/core/types.js.map +1 -1
  44. package/dist/services/task/Task.js +73 -7
  45. package/dist/services/task/Task.js.map +1 -1
  46. package/dist/services/task/TaskFactory.js +8 -4
  47. package/dist/services/task/TaskFactory.js.map +1 -1
  48. package/dist/services/task/TaskManager.js +490 -21
  49. package/dist/services/task/TaskManager.js.map +1 -1
  50. package/dist/services/task/WebexCallingUtils.js +70 -0
  51. package/dist/services/task/WebexCallingUtils.js.map +1 -0
  52. package/dist/services/task/constants.js +4 -1
  53. package/dist/services/task/constants.js.map +1 -1
  54. package/dist/services/task/contact.js +29 -0
  55. package/dist/services/task/contact.js.map +1 -1
  56. package/dist/services/task/digital/Digital.js +3 -2
  57. package/dist/services/task/digital/Digital.js.map +1 -1
  58. package/dist/services/task/state-machine/TaskStateMachine.js +162 -53
  59. package/dist/services/task/state-machine/TaskStateMachine.js.map +1 -1
  60. package/dist/services/task/state-machine/actions.js +6 -1
  61. package/dist/services/task/state-machine/actions.js.map +1 -1
  62. package/dist/services/task/state-machine/guards.js +7 -10
  63. package/dist/services/task/state-machine/guards.js.map +1 -1
  64. package/dist/services/task/state-machine/types.js.map +1 -1
  65. package/dist/services/task/state-machine/uiControlsComputer.js +118 -18
  66. package/dist/services/task/state-machine/uiControlsComputer.js.map +1 -1
  67. package/dist/services/task/types.js +16 -1
  68. package/dist/services/task/types.js.map +1 -1
  69. package/dist/services/task/voice/Voice.js +456 -15
  70. package/dist/services/task/voice/Voice.js.map +1 -1
  71. package/dist/services/task/voice/WebRTC.js +4 -1
  72. package/dist/services/task/voice/WebRTC.js.map +1 -1
  73. package/dist/services/task/voice/wxAppVoiceMethods.js +354 -0
  74. package/dist/services/task/voice/wxAppVoiceMethods.js.map +1 -0
  75. package/dist/services/wxAppDiagnosticLogging.js +122 -0
  76. package/dist/services/wxAppDiagnosticLogging.js.map +1 -0
  77. package/dist/services/wxAppTelephonyUtils.js +19 -0
  78. package/dist/services/wxAppTelephonyUtils.js.map +1 -0
  79. package/dist/types/cc.d.ts +78 -4
  80. package/dist/types/config.d.ts +7 -0
  81. package/dist/types/constants.d.ts +8 -0
  82. package/dist/types/index.d.ts +3 -1
  83. package/dist/types/metrics/MetricsManager.d.ts +6 -0
  84. package/dist/types/metrics/constants.d.ts +17 -0
  85. package/dist/types/services/AddressBook.d.ts +2 -1
  86. package/dist/types/services/AnswerCallOnWebexService.d.ts +38 -0
  87. package/dist/types/services/EntryPoint.d.ts +7 -5
  88. package/dist/types/services/Queue.d.ts +5 -3
  89. package/dist/types/services/WebexCrossClientService.d.ts +32 -0
  90. package/dist/types/services/WxAppTelephonyMercurySync.d.ts +28 -0
  91. package/dist/types/services/config/constants.d.ts +8 -5
  92. package/dist/types/services/config/types.d.ts +17 -4
  93. package/dist/types/services/core/Err.d.ts +2 -0
  94. package/dist/types/services/core/WebexRequest.d.ts +1 -0
  95. package/dist/types/services/core/types.d.ts +2 -0
  96. package/dist/types/services/task/Task.d.ts +20 -2
  97. package/dist/types/services/task/TaskFactory.d.ts +2 -1
  98. package/dist/types/services/task/WebexCallingUtils.d.ts +11 -0
  99. package/dist/types/services/task/constants.d.ts +3 -0
  100. package/dist/types/services/task/contact.d.ts +4 -0
  101. package/dist/types/services/task/digital/Digital.d.ts +2 -2
  102. package/dist/types/services/task/state-machine/TaskStateMachine.d.ts +304 -56
  103. package/dist/types/services/task/state-machine/guards.d.ts +6 -5
  104. package/dist/types/services/task/state-machine/types.d.ts +9 -1
  105. package/dist/types/services/task/types.d.ts +81 -4
  106. package/dist/types/services/task/voice/Voice.d.ts +52 -9
  107. package/dist/types/services/task/voice/WebRTC.d.ts +2 -2
  108. package/dist/types/services/task/voice/wxAppVoiceMethods.d.ts +55 -0
  109. package/dist/types/services/wxAppDiagnosticLogging.d.ts +51 -0
  110. package/dist/types/services/wxAppTelephonyUtils.d.ts +5 -0
  111. package/dist/types/types.d.ts +14 -8
  112. package/dist/types/utils/PageCache.d.ts +20 -3
  113. package/dist/types.js +7 -2
  114. package/dist/types.js.map +1 -1
  115. package/dist/utils/PageCache.js +19 -5
  116. package/dist/utils/PageCache.js.map +1 -1
  117. package/dist/webex.js +1 -1
  118. package/package.json +8 -8
  119. package/src/cc.ts +657 -12
  120. package/src/config.ts +7 -0
  121. package/src/constants.ts +8 -0
  122. package/src/index.ts +2 -0
  123. package/src/metrics/MetricsManager.ts +16 -0
  124. package/src/metrics/ai-docs/metrics-spec.md +78 -10
  125. package/src/metrics/behavioral-events.ts +106 -0
  126. package/src/metrics/constants.ts +20 -0
  127. package/src/services/AddressBook.ts +17 -6
  128. package/src/services/AnswerCallOnWebexService.ts +239 -0
  129. package/src/services/EntryPoint.ts +59 -60
  130. package/src/services/Queue.ts +29 -12
  131. package/src/services/WebexCrossClientService.ts +293 -0
  132. package/src/services/WxAppTelephonyMercurySync.ts +142 -0
  133. package/src/services/agent/ai-docs/agent-spec.md +1 -0
  134. package/src/services/ai-docs/AGENTS.md +10 -10
  135. package/src/services/ai-docs/services-spec.md +6 -1
  136. package/src/services/config/Util.ts +3 -0
  137. package/src/services/config/ai-docs/AGENTS.md +1 -1
  138. package/src/services/config/ai-docs/ARCHITECTURE.md +2 -2
  139. package/src/services/config/ai-docs/config-spec.md +6 -0
  140. package/src/services/config/constants.ts +10 -6
  141. package/src/services/config/types.ts +16 -4
  142. package/src/services/core/Err.ts +1 -0
  143. package/src/services/core/WebexRequest.ts +3 -1
  144. package/src/services/core/ai-docs/core-spec.md +5 -1
  145. package/src/services/core/aqm-reqs.ts +30 -15
  146. package/src/services/core/types.ts +2 -0
  147. package/src/services/task/Task.ts +79 -8
  148. package/src/services/task/TaskFactory.ts +8 -3
  149. package/src/services/task/TaskManager.ts +716 -17
  150. package/src/services/task/WebexCallingUtils.ts +136 -0
  151. package/src/services/task/ai-docs/ARCHITECTURE.md +1 -0
  152. package/src/services/task/ai-docs/task-spec.md +156 -3
  153. package/src/services/task/constants.ts +3 -0
  154. package/src/services/task/contact.ts +30 -0
  155. package/src/services/task/digital/Digital.ts +4 -2
  156. package/src/services/task/state-machine/TaskStateMachine.ts +235 -81
  157. package/src/services/task/state-machine/actions.ts +5 -2
  158. package/src/services/task/state-machine/ai-docs/AGENTS.md +6 -4
  159. package/src/services/task/state-machine/ai-docs/ARCHITECTURE.md +35 -16
  160. package/src/services/task/state-machine/ai-docs/task-state-machine-spec.md +103 -33
  161. package/src/services/task/state-machine/guards.ts +7 -10
  162. package/src/services/task/state-machine/types.ts +16 -1
  163. package/src/services/task/state-machine/uiControlsComputer.ts +206 -32
  164. package/src/services/task/types.ts +114 -4
  165. package/src/services/task/voice/Voice.ts +595 -14
  166. package/src/services/task/voice/WebRTC.ts +5 -1
  167. package/src/services/task/voice/wxAppVoiceMethods.ts +516 -0
  168. package/src/services/wxAppDiagnosticLogging.ts +174 -0
  169. package/src/services/wxAppTelephonyUtils.ts +14 -0
  170. package/src/types.ts +33 -9
  171. package/src/utils/AGENTS.md +21 -10
  172. package/src/utils/PageCache.ts +38 -5
  173. package/src/utils/ai-docs/utils-spec.md +21 -11
  174. package/test/unit/spec/cc.ts +1522 -19
  175. package/test/unit/spec/metrics/MetricsManager.ts +25 -0
  176. package/test/unit/spec/metrics/behavioral-events.ts +60 -0
  177. package/test/unit/spec/services/AddressBook.ts +37 -6
  178. package/test/unit/spec/services/AnswerCallOnWebexService.ts +264 -0
  179. package/test/unit/spec/services/EntryPoint.ts +87 -40
  180. package/test/unit/spec/services/Queue.ts +123 -12
  181. package/test/unit/spec/services/WebexCrossClientService.ts +467 -0
  182. package/test/unit/spec/services/WxAppTelephonyMercurySync.ts +136 -0
  183. package/test/unit/spec/services/config/Util.ts +85 -0
  184. package/test/unit/spec/services/core/WebexRequest.ts +3 -1
  185. package/test/unit/spec/services/core/aqm-reqs.ts +113 -1
  186. package/test/unit/spec/services/task/Task.ts +274 -6
  187. package/test/unit/spec/services/task/TaskFactory.ts +39 -2
  188. package/test/unit/spec/services/task/TaskManager.ts +1717 -9
  189. package/test/unit/spec/services/task/WebexCallingUtils.ts +153 -0
  190. package/test/unit/spec/services/task/contact.ts +33 -0
  191. package/test/unit/spec/services/task/state-machine/TaskStateMachine.ts +1485 -109
  192. package/test/unit/spec/services/task/state-machine/guards.ts +210 -8
  193. package/test/unit/spec/services/task/state-machine/uiControlsComputer.ts +445 -7
  194. package/test/unit/spec/services/task/voice/Voice.ts +1412 -0
  195. package/test/unit/spec/services/task/voice/wxAppVoiceMethods.ts +603 -0
  196. package/test/unit/spec/services/wxAppDiagnosticLogging.ts +143 -0
  197. package/umd/contact-center.min.js +2 -2
  198. package/umd/contact-center.min.js.map +1 -1
@@ -27,15 +27,38 @@ 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
- import {TaskEvent, type TaskEventPayload} from './state-machine';
33
+ import {TaskEvent, TaskState, 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
 
47
+ const WRAP_UP_STAMP_EVENTS = new Set<CC_EVENTS>([
48
+ CC_EVENTS.AGENT_WRAPUP,
49
+ CC_EVENTS.AGENT_CONSULT_CONFERENCE_ENDED,
50
+ CC_EVENTS.PARTICIPANT_LEFT_CONFERENCE,
51
+ CC_EVENTS.AGENT_CONFERENCE_TRANSFERRED,
52
+ ]);
53
+
54
+ const PARTICIPANT_LEAVE_WRAP_STATES = new Set<TaskState>([
55
+ TaskState.HELD,
56
+ TaskState.RESUME_INITIATING,
57
+ TaskState.CONSULTING,
58
+ TaskState.CONSULT_INITIATING,
59
+ TaskState.CONFERENCING,
60
+ ]);
61
+
39
62
  /** @internal */
40
63
  export default class TaskManager extends EventEmitter {
41
64
  private call: ICall;
@@ -55,6 +78,7 @@ export default class TaskManager extends EventEmitter {
55
78
  private wrapupData: WrapupData;
56
79
  private agentId: string;
57
80
  private webRtcEnabled: boolean;
81
+ private answerCallOnWebexService?: AnswerCallOnWebexService;
58
82
  private apiAIAssistant?: ApiAIAssistant;
59
83
  /**
60
84
  * @param contact - Routing Contact layer. Talks to AQMReq layer to convert events to promises
@@ -148,6 +172,10 @@ export default class TaskManager extends EventEmitter {
148
172
  this.webRtcEnabled = webRtcEnabled;
149
173
  }
150
174
 
175
+ public setAnswerCallOnWebexService(answerCallOnWebexService: AnswerCallOnWebexService) {
176
+ this.answerCallOnWebexService = answerCallOnWebexService;
177
+ }
178
+
151
179
  private handleIncomingWebCall = (call: ICall) => {
152
180
  const currentTask = Object.values(this.taskCollection).find(
153
181
  (task) =>
@@ -182,17 +210,189 @@ export default class TaskManager extends EventEmitter {
182
210
  this.webCallingService.off(LINE_EVENTS.INCOMING_CALL, this.handleIncomingWebCall);
183
211
  }
184
212
 
213
+ /**
214
+ * True when this agent left on the current payload.
215
+ * A named other participantId is never self-leave (even if the participants
216
+ * map is partial). Missing-map fallback is EP-DN only: participantId absent
217
+ * and self omitted now, with previous-task evidence when a prior map exists.
218
+ */
219
+ private static didSelfLeaveConference(
220
+ payload: WebSocketPayload,
221
+ agentId?: string,
222
+ task?: ITask
223
+ ): boolean {
224
+ if (!agentId) {
225
+ return false;
226
+ }
227
+ if (payload.participantId === agentId) {
228
+ return true;
229
+ }
230
+ if (payload.participantId && payload.participantId !== agentId) {
231
+ return false;
232
+ }
233
+
234
+ const participants = payload.interaction?.participants;
235
+ if (!participants || agentId in participants) {
236
+ return false;
237
+ }
238
+
239
+ const previousParticipants = task?.data?.interaction?.participants;
240
+ if (previousParticipants && !(agentId in previousParticipants)) {
241
+ return false;
242
+ }
243
+
244
+ return true;
245
+ }
246
+
247
+ /**
248
+ * Consulted non-owner: Voice exitConference / shouldWrapUpForThisAgent stays
249
+ * false unless an explicit wrap-up signal includes this agent.
250
+ */
251
+ private static isConsultedNonOwner(
252
+ payload: WebSocketPayload,
253
+ agentId?: string,
254
+ task?: ITask
255
+ ): boolean {
256
+ const isConsulted = payload.isConsulted ?? task?.data?.isConsulted;
257
+ if (isConsulted !== true) {
258
+ return false;
259
+ }
260
+ const owner = payload.interaction?.owner ?? task?.data?.interaction?.owner;
261
+
262
+ return Boolean(agentId && owner && owner !== agentId);
263
+ }
264
+
265
+ private static hasNonemptyPendingWrapUp(payload: WebSocketPayload): boolean {
266
+ return Array.isArray(payload.agentsPendingWrapUp) && payload.agentsPendingWrapUp.length > 0;
267
+ }
268
+
269
+ /**
270
+ * Transfer initiator fallback: widgets set transferConferenceRequested on the
271
+ * actor; desktop success payloads may include consultingAgentId. Only used
272
+ * when agentsPendingWrapUp is empty or absent.
273
+ */
274
+ private static isSelfConferenceTransferInitiator(
275
+ payload: WebSocketPayload,
276
+ agentId?: string,
277
+ task?: ITask
278
+ ): boolean {
279
+ const snapshotContext = task?.stateMachineService?.getSnapshot?.()?.context as
280
+ | {transferConferenceRequested?: boolean}
281
+ | undefined;
282
+ if (snapshotContext?.transferConferenceRequested === true) {
283
+ return true;
284
+ }
285
+
286
+ return Boolean(agentId && payload.consultingAgentId === agentId);
287
+ }
288
+
289
+ /**
290
+ * Explicit wrap-up signals only (no owner / isConsulted fallback).
291
+ * Used for events that remaining conference agents also receive.
292
+ * nonempty agentsPendingWrapUp is authoritative, then wrapUpRequired / isWrapUp.
293
+ */
294
+ private static wrapUpRequiredFromExplicitSignals(
295
+ payload: WebSocketPayload,
296
+ agentId?: string
297
+ ): boolean {
298
+ const pending = payload.agentsPendingWrapUp;
299
+ if (Array.isArray(pending) && pending.length > 0) {
300
+ return Boolean(agentId && pending.includes(agentId));
301
+ }
302
+
303
+ if (payload.wrapUpRequired === true) {
304
+ return true;
305
+ }
306
+ if (!agentId) {
307
+ return false;
308
+ }
309
+
310
+ return payload.interaction?.participants?.[agentId]?.isWrapUp === true;
311
+ }
312
+
313
+ /**
314
+ * Stamp wrapUpRequired once at ingress (prepareEventContext) so updateTaskData
315
+ * and the mapper see the same flag. Remaining agents stay false.
316
+ */
317
+ private static stampWrapUpRequiredForEvent(
318
+ eventType: CC_EVENTS,
319
+ payload: WebSocketPayload,
320
+ agentId?: string,
321
+ task?: ITask
322
+ ): WebSocketPayload {
323
+ if (!WRAP_UP_STAMP_EVENTS.has(eventType)) {
324
+ return payload;
325
+ }
326
+
327
+ if (eventType === CC_EVENTS.AGENT_WRAPUP) {
328
+ return {...payload, wrapUpRequired: true};
329
+ }
330
+
331
+ if (eventType === CC_EVENTS.AGENT_CONSULT_CONFERENCE_ENDED) {
332
+ return {
333
+ ...payload,
334
+ wrapUpRequired: TaskManager.wrapUpRequiredFromExplicitSignals(payload, agentId),
335
+ };
336
+ }
337
+
338
+ if (eventType === CC_EVENTS.PARTICIPANT_LEFT_CONFERENCE) {
339
+ const actorState = task?.stateMachineService?.getSnapshot?.()?.value;
340
+ if (actorState && !PARTICIPANT_LEAVE_WRAP_STATES.has(actorState as TaskState)) {
341
+ return payload;
342
+ }
343
+
344
+ const left = TaskManager.didSelfLeaveConference(payload, agentId, task);
345
+ if (!left) {
346
+ return {
347
+ ...payload,
348
+ wrapUpRequired: TaskManager.wrapUpRequiredFromExplicitSignals(payload, agentId),
349
+ };
350
+ }
351
+ if (TaskManager.hasNonemptyPendingWrapUp(payload)) {
352
+ return {
353
+ ...payload,
354
+ wrapUpRequired: Boolean(agentId && payload.agentsPendingWrapUp?.includes(agentId)),
355
+ };
356
+ }
357
+
358
+ return {
359
+ ...payload,
360
+ wrapUpRequired:
361
+ TaskManager.wrapUpRequiredFromExplicitSignals(payload, agentId) ||
362
+ !TaskManager.isConsultedNonOwner(payload, agentId, task),
363
+ };
364
+ }
365
+
366
+ // AGENT_CONFERENCE_TRANSFERRED — pending list wins; initiator only if empty/absent
367
+ if (TaskManager.hasNonemptyPendingWrapUp(payload)) {
368
+ return {
369
+ ...payload,
370
+ wrapUpRequired: TaskManager.wrapUpRequiredFromExplicitSignals(payload, agentId),
371
+ };
372
+ }
373
+
374
+ return {
375
+ ...payload,
376
+ wrapUpRequired:
377
+ TaskManager.wrapUpRequiredFromExplicitSignals(payload, agentId) ||
378
+ TaskManager.isSelfConferenceTransferInitiator(payload, agentId, task),
379
+ };
380
+ }
381
+
185
382
  /**
186
383
  * Map WebSocket CC_EVENTS to state machine TaskEvent
187
384
  * @param ccEvent - The CC_EVENT type from WebSocket
188
385
  * @param payload - The event payload
189
386
  * @param agentId - Optional agent ID for state detection (needed for HYDRATE)
387
+ * @param task - Optional task instance for state-aware CONTACT_ENDED mapping
190
388
  * @returns TaskEventPayload for state machine or null if no mapping
191
389
  */
192
390
  private static mapEventToTaskStateMachineEvent(
193
391
  ccEvent: CC_EVENTS,
194
392
  payload: WebSocketPayload,
195
- agentId?: string
393
+ agentId?: string,
394
+ task?: ITask,
395
+ enableWxBetterTogether?: boolean
196
396
  ): TaskEventPayload | null {
197
397
  const mediaResourceId =
198
398
  payload.mediaResourceId ||
@@ -226,6 +426,15 @@ export default class TaskManager extends EventEmitter {
226
426
  return {type: TaskEvent.HYDRATE, taskData: payload, agentId};
227
427
 
228
428
  case CC_EVENTS.CONTACT_UPDATED:
429
+ if (
430
+ task &&
431
+ typeof payload.interaction?.owner === 'string' &&
432
+ payload.interaction.owner.trim().length > 0 &&
433
+ payload.interaction.owner !== task.data?.interaction?.owner
434
+ ) {
435
+ return {type: TaskEvent.CONTACT_OWNER_CHANGED, taskData: payload};
436
+ }
437
+
229
438
  return {type: TaskEvent.CONTACT_UPDATED, taskData: payload};
230
439
  case CC_EVENTS.CONTACT_OWNER_CHANGED:
231
440
  return {type: TaskEvent.CONTACT_OWNER_CHANGED, taskData: payload};
@@ -299,16 +508,39 @@ export default class TaskManager extends EventEmitter {
299
508
  case CC_EVENTS.AGENT_CONFERENCE_TRANSFER_FAILED:
300
509
  return {type: TaskEvent.TRANSFER_FAILED, taskData: payload};
301
510
 
302
- case CC_EVENTS.CONTACT_ENDED:
511
+ case CC_EVENTS.CONTACT_ENDED: {
512
+ const wrapUpRequired = isCampaignPreviewTask(payload)
513
+ ? false
514
+ : payload.agentsPendingWrapUp?.includes(agentId || '') || false;
515
+
516
+ // Post-accept wxApp outdial agent-end delivers ContactEnded instead of
517
+ // AgentOutboundFailed. Normalize to OUTBOUND_FAILED so state machine
518
+ // enters WRAPPING_UP and emits TASK_OUTDIAL_FAILED (AGENT_ENDS).
519
+ // Pre-accept cancelTask decline stays CONTACT_ENDED → TERMINATED (no wrapup).
520
+ if (
521
+ TaskManager.isAgentTerminatedOutdialWrapup(
522
+ payload,
523
+ wrapUpRequired,
524
+ task,
525
+ agentId,
526
+ enableWxBetterTogether
527
+ )
528
+ ) {
529
+ return {
530
+ type: TaskEvent.OUTBOUND_FAILED,
531
+ reason: 'AGENT_ENDS',
532
+ taskData: {...payload, wrapUpRequired: true},
533
+ };
534
+ }
535
+
303
536
  return {
304
537
  type: TaskEvent.CONTACT_ENDED,
305
538
  taskData: {
306
539
  ...payload,
307
- wrapUpRequired: isCampaignPreviewTask(payload)
308
- ? false
309
- : payload.agentsPendingWrapUp?.includes(agentId || '') || false,
540
+ wrapUpRequired,
310
541
  },
311
542
  };
543
+ }
312
544
 
313
545
  case CC_EVENTS.AGENT_INVITE_FAILED:
314
546
  return {type: TaskEvent.INVITE_FAILED, reason: payload.reason};
@@ -319,8 +551,23 @@ export default class TaskManager extends EventEmitter {
319
551
  case CC_EVENTS.AGENT_CONTACT_OFFER_RONA:
320
552
  return {type: TaskEvent.RONA, taskData: payload, reason: payload.reason};
321
553
 
322
- case CC_EVENTS.AGENT_OUTBOUND_FAILED:
323
- return {type: TaskEvent.OUTBOUND_FAILED, taskData: payload, reason: payload.reason};
554
+ case CC_EVENTS.AGENT_OUTBOUND_FAILED: {
555
+ const isAgentTerminatedOutdial =
556
+ payload.interaction?.outboundType === 'OUTDIAL' &&
557
+ (payload.terminatingParty === 'Agent' || payload.reason === 'AGENT_ENDS');
558
+
559
+ const suppressOutdialFailedPopup =
560
+ isAgentTerminatedOutdial &&
561
+ !TaskManager.isWxAppManagedOutdialTask(agentId, payload, task, enableWxBetterTogether);
562
+
563
+ return {
564
+ type: TaskEvent.OUTBOUND_FAILED,
565
+ taskData: suppressOutdialFailedPopup
566
+ ? {...payload, suppressOutdialFailedPopup: true}
567
+ : payload,
568
+ reason: payload.reason,
569
+ };
570
+ }
324
571
 
325
572
  case CC_EVENTS.CAMPAIGN_PREVIEW_ACCEPT_FAILED:
326
573
  return {type: TaskEvent.CAMPAIGN_PREVIEW_ACCEPT_FAILED, taskData: payload};
@@ -371,6 +618,68 @@ export default class TaskManager extends EventEmitter {
371
618
  }
372
619
  }
373
620
 
621
+ /**
622
+ * True when the current agent's task is a wxApp-managed outdial (thick-client telephony).
623
+ */
624
+ private static isWxAppManagedOutdialTask(
625
+ agentId: string | undefined,
626
+ payload: WebSocketPayload,
627
+ task: ITask | undefined,
628
+ enableWxBetterTogether?: boolean
629
+ ): boolean {
630
+ const wxAppEnabled =
631
+ (task as {uiControlConfig?: {enableWxBetterTogether?: boolean}})?.uiControlConfig
632
+ ?.enableWxBetterTogether ??
633
+ enableWxBetterTogether ??
634
+ false;
635
+
636
+ if (!wxAppEnabled) {
637
+ return false;
638
+ }
639
+
640
+ const effectiveAgentId = agentId ?? task?.data?.agentId;
641
+ const participants = payload.interaction?.participants ?? task?.data?.interaction?.participants;
642
+ const details = getWebexCallingDeviceDetailsForAgent(effectiveAgentId, participants);
643
+
644
+ return details?.deviceType === 'wxApp';
645
+ }
646
+
647
+ /**
648
+ * True when ContactEnded represents an agent-terminated wxApp outdial that should
649
+ * enter wrapup with an outdial-failed popup (legacy AgentOutboundFailed parity).
650
+ */
651
+ private static isAgentTerminatedOutdialWrapup(
652
+ payload: WebSocketPayload,
653
+ wrapUpRequired: boolean,
654
+ task?: ITask,
655
+ agentId?: string,
656
+ enableWxBetterTogether?: boolean
657
+ ): boolean {
658
+ const isOutdial = payload.interaction?.outboundType === 'OUTDIAL';
659
+ const agentTerminated = payload.terminatingParty === 'Agent';
660
+
661
+ if (!isOutdial || !agentTerminated) {
662
+ return false;
663
+ }
664
+
665
+ if (!TaskManager.isWxAppManagedOutdialTask(agentId, payload, task, enableWxBetterTogether)) {
666
+ return false;
667
+ }
668
+
669
+ const wxAppAnswerPending =
670
+ (task as {uiControlConfig?: {wxAppAnswerPending?: boolean}})?.uiControlConfig
671
+ ?.wxAppAnswerPending ?? false;
672
+
673
+ // Pre-accept decline: decline() clears wxAppAnswerPending; backend may still send state wrapUp.
674
+ if (!wxAppAnswerPending && !wrapUpRequired) {
675
+ return false;
676
+ }
677
+
678
+ const interactionWrapUp = payload.interaction?.state === 'wrapUp';
679
+
680
+ return wrapUpRequired || interactionWrapUp || wxAppAnswerPending;
681
+ }
682
+
374
683
  /**
375
684
  * Register WebSocket message listeners for task events
376
685
  *
@@ -482,6 +791,14 @@ export default class TaskManager extends EventEmitter {
482
791
  }
483
792
  }
484
793
 
794
+ if (!task && eventType === CC_EVENTS.CONTACT_OWNER_CHANGED) {
795
+ task = this.findTaskForContactOwnerChange(message.data);
796
+ }
797
+
798
+ if (!task && MAIN_INTERACTION_CORRELATED_EVENTS.has(eventType)) {
799
+ task = this.findUniqueTaskByRelatedInteraction(message.data);
800
+ }
801
+
485
802
  const wasConsultedTask = Boolean(task?.data?.isConsulted);
486
803
  const computeWrapUpRequired = () => {
487
804
  if (message.data.wrapUpRequired !== undefined) {
@@ -494,7 +811,7 @@ export default class TaskManager extends EventEmitter {
494
811
  return !wasConsultedTask;
495
812
  };
496
813
 
497
- const adjustedPayload =
814
+ let adjustedPayload: WebSocketPayload =
498
815
  eventType === CC_EVENTS.AGENT_CONSULT_TRANSFERRED ||
499
816
  eventType === CC_EVENTS.AGENT_BLIND_TRANSFERRED ||
500
817
  eventType === CC_EVENTS.AGENT_VTEAM_TRANSFERRED
@@ -504,10 +821,29 @@ export default class TaskManager extends EventEmitter {
504
821
  }
505
822
  : message.data;
506
823
 
824
+ if (task && eventType === CC_EVENTS.CONTACT_OWNER_CHANGED) {
825
+ adjustedPayload = TaskManager.preserveMainTaskIdentity(task, adjustedPayload);
826
+ }
827
+
828
+ if (task && eventType === CC_EVENTS.PARTICIPANT_LEFT_CONFERENCE) {
829
+ adjustedPayload = TaskManager.preserveConfirmedOwner(task, adjustedPayload);
830
+ }
831
+
832
+ if (WRAP_UP_STAMP_EVENTS.has(eventType)) {
833
+ adjustedPayload = TaskManager.stampWrapUpRequiredForEvent(
834
+ eventType,
835
+ adjustedPayload,
836
+ this.agentId,
837
+ task
838
+ );
839
+ }
840
+
507
841
  const stateMachineEvent = TaskManager.mapEventToTaskStateMachineEvent(
508
842
  eventType,
509
843
  adjustedPayload,
510
- this.agentId
844
+ this.agentId,
845
+ task,
846
+ this.configFlags?.enableWxBetterTogether
511
847
  );
512
848
 
513
849
  LoggerProxy.info(`Handling task event ${eventType}`, {
@@ -524,6 +860,236 @@ export default class TaskManager extends EventEmitter {
524
860
  };
525
861
  }
526
862
 
863
+ /**
864
+ * ContactOwnerChanged can identify the promoted agent's child interaction while
865
+ * the surviving task is stored under the main interaction. Accept one related
866
+ * task when its existing snapshot or the authoritative incoming promotion proves
867
+ * current-agent main-call membership, while excluding non-promoted and ambiguous matches.
868
+ */
869
+ private findTaskForContactOwnerChange(payload: WebSocketPayload): ITask | undefined {
870
+ const relatedCandidates = this.findRelatedTasks(payload, {
871
+ includeCollectionKeys: true,
872
+ includeMainCallMediaKeys: true,
873
+ });
874
+ const incomingShowsCurrentAgentPromotion =
875
+ this.isCurrentAgentPromotedOnIncomingMainCall(payload);
876
+ const eligibleCandidates = relatedCandidates.filter(
877
+ (candidate) =>
878
+ this.isCurrentAgentActiveOnMainInteraction(candidate.data) ||
879
+ incomingShowsCurrentAgentPromotion
880
+ );
881
+
882
+ if (eligibleCandidates.length === 1) {
883
+ return eligibleCandidates[0];
884
+ }
885
+
886
+ if (eligibleCandidates.length > 1) {
887
+ LoggerProxy.warn('Unable to correlate task event to a unique main interaction task', {
888
+ module: TASK_MANAGER_FILE,
889
+ method: 'findTaskForContactOwnerChange',
890
+ interactionId:
891
+ payload.interaction?.mainInteractionId ||
892
+ payload.interaction?.parentInteractionId ||
893
+ payload.interaction?.callProcessingDetails?.parentInteractionId ||
894
+ payload.interactionId,
895
+ });
896
+
897
+ return undefined;
898
+ }
899
+
900
+ return undefined;
901
+ }
902
+
903
+ private isCurrentAgentActiveOnMainInteraction(taskData: TaskData | undefined): boolean {
904
+ const currentAgentId = this.agentId || taskData?.agentId;
905
+ if (!currentAgentId) return false;
906
+
907
+ return TaskManager.isParticipantActiveOnMainInteraction(taskData, currentAgentId);
908
+ }
909
+
910
+ private static isParticipantActiveOnMainInteraction(
911
+ taskData: TaskData | WebSocketPayload | undefined,
912
+ participantId: string
913
+ ): boolean {
914
+ const interaction = taskData?.interaction;
915
+ const participant = interaction?.participants?.[participantId];
916
+ if (!participant || participant.hasLeft === true) return false;
917
+
918
+ return Object.values(interaction?.media ?? {}).some(
919
+ (media) =>
920
+ media?.mType === MEDIA_TYPE_MAIN_CALL && media.participants?.includes(participantId)
921
+ );
922
+ }
923
+
924
+ private isCurrentAgentPromotedOnIncomingMainCall(payload: WebSocketPayload): boolean {
925
+ return Boolean(
926
+ this.agentId &&
927
+ payload.interaction?.owner === this.agentId &&
928
+ TaskManager.isParticipantActiveOnMainInteraction(payload, this.agentId)
929
+ );
930
+ }
931
+
932
+ private static getMainCallMediaId(
933
+ interaction: TaskData['interaction'] | undefined
934
+ ): string | undefined {
935
+ return Object.entries(interaction?.media ?? {}).find(
936
+ ([, media]) => media?.mType === MEDIA_TYPE_MAIN_CALL
937
+ )?.[0];
938
+ }
939
+
940
+ private static getStableMainInteractionId(payload: WebSocketPayload): string | undefined {
941
+ return (
942
+ payload.interaction?.mainInteractionId ||
943
+ TaskManager.getMainCallMediaId(payload.interaction) ||
944
+ payload.interaction?.interactionId ||
945
+ payload.interactionId
946
+ );
947
+ }
948
+
949
+ private canRecoverTaskFromContactOwnerChanged(payload: WebSocketPayload): boolean {
950
+ const interaction = payload.interaction;
951
+ const stableInteractionId = TaskManager.getStableMainInteractionId(payload);
952
+
953
+ return Boolean(
954
+ this.agentId &&
955
+ stableInteractionId &&
956
+ interaction?.mediaType === MEDIA_CHANNEL.TELEPHONY &&
957
+ interaction.isTerminated !== true &&
958
+ interaction.owner === this.agentId &&
959
+ TaskManager.isParticipantActiveOnMainInteraction(payload, this.agentId)
960
+ );
961
+ }
962
+
963
+ /** Keep a related owner-change event from re-keying the surviving main task to a child ID. */
964
+ private static preserveMainTaskIdentity(
965
+ task: ITask,
966
+ payload: WebSocketPayload
967
+ ): WebSocketPayload {
968
+ const currentMainInteractionId = task.data?.interaction?.mainInteractionId;
969
+ const payloadMainInteractionId = payload.interaction?.mainInteractionId;
970
+ const currentMainMediaId = TaskManager.getMainCallMediaId(task.data?.interaction);
971
+ const payloadMainMediaId = TaskManager.getMainCallMediaId(payload.interaction);
972
+ const stableInteractionId =
973
+ currentMainInteractionId ||
974
+ payloadMainInteractionId ||
975
+ currentMainMediaId ||
976
+ payloadMainMediaId ||
977
+ payload.interaction?.interactionId ||
978
+ task.data?.interaction?.interactionId ||
979
+ task.data?.interactionId;
980
+
981
+ if (!stableInteractionId || stableInteractionId === payload.interactionId) {
982
+ return payload;
983
+ }
984
+
985
+ return {...payload, interactionId: stableInteractionId};
986
+ }
987
+
988
+ /**
989
+ * ParticipantLeftConference may arrive after an owner update while still carrying
990
+ * the departed owner. Keep the confirmed owner only when the new roster proves that
991
+ * owner is active on mainCall and identifies the incoming owner as departed.
992
+ */
993
+ private static preserveConfirmedOwner(task: ITask, payload: WebSocketPayload): WebSocketPayload {
994
+ const confirmedOwner = task.data?.interaction?.owner;
995
+ const incomingOwner = payload.interaction?.owner;
996
+ if (!confirmedOwner || !incomingOwner || confirmedOwner === incomingOwner) {
997
+ return payload;
998
+ }
999
+
1000
+ const confirmedOwnerIsActive = TaskManager.isParticipantActiveOnMainInteraction(
1001
+ payload,
1002
+ confirmedOwner
1003
+ );
1004
+ const incomingOwnerIsExplicitlyDeparted =
1005
+ payload.participantId === incomingOwner ||
1006
+ payload.interaction?.participants?.[incomingOwner]?.hasLeft === true;
1007
+
1008
+ if (!confirmedOwnerIsActive || !incomingOwnerIsExplicitlyDeparted) {
1009
+ return payload;
1010
+ }
1011
+
1012
+ return {
1013
+ ...payload,
1014
+ owner: confirmedOwner,
1015
+ interaction: {...payload.interaction, owner: confirmedOwner},
1016
+ };
1017
+ }
1018
+
1019
+ /**
1020
+ * Resolve lifecycle events that are emitted for the main interaction while the
1021
+ * local task can still be indexed by a child consult interaction. Exact task
1022
+ * keys are handled before this fallback. Multiple aliases of the same task are
1023
+ * treated as one candidate; genuinely ambiguous matches are intentionally ignored.
1024
+ */
1025
+ private findUniqueTaskByRelatedInteraction(
1026
+ payload: WebSocketPayload,
1027
+ candidateFilter: (task: ITask) => boolean = () => true
1028
+ ): ITask | undefined {
1029
+ const candidates = this.findRelatedTasks(payload).filter(candidateFilter);
1030
+
1031
+ if (candidates.length === 1) {
1032
+ return candidates[0];
1033
+ }
1034
+
1035
+ if (candidates.length > 1) {
1036
+ LoggerProxy.warn('Unable to correlate task event to a unique main interaction task', {
1037
+ module: TASK_MANAGER_FILE,
1038
+ method: 'findUniqueTaskByRelatedInteraction',
1039
+ interactionId:
1040
+ payload.interaction?.mainInteractionId ||
1041
+ payload.interaction?.parentInteractionId ||
1042
+ payload.interaction?.callProcessingDetails?.parentInteractionId ||
1043
+ payload.interactionId,
1044
+ });
1045
+ }
1046
+
1047
+ return undefined;
1048
+ }
1049
+
1050
+ private findRelatedTasks(
1051
+ payload: WebSocketPayload,
1052
+ options: {includeCollectionKeys?: boolean; includeMainCallMediaKeys?: boolean} = {}
1053
+ ): ITask[] {
1054
+ const {includeCollectionKeys = false, includeMainCallMediaKeys = false} = options;
1055
+ const payloadInteractionIds = new Set(
1056
+ [
1057
+ payload.interactionId,
1058
+ payload.interaction?.interactionId,
1059
+ payload.interaction?.mainInteractionId,
1060
+ payload.interaction?.parentInteractionId,
1061
+ payload.interaction?.callProcessingDetails?.parentInteractionId,
1062
+ ...(includeMainCallMediaKeys ? [TaskManager.getMainCallMediaId(payload.interaction)] : []),
1063
+ ].filter((interactionId): interactionId is string => Boolean(interactionId))
1064
+ );
1065
+ if (payloadInteractionIds.size === 0) return [];
1066
+
1067
+ return [
1068
+ ...new Set(
1069
+ Object.entries(this.taskCollection)
1070
+ .filter(([taskId, candidate]) => {
1071
+ const taskInteraction = candidate?.data?.interaction;
1072
+ const candidateInteractionIds = [
1073
+ ...(includeCollectionKeys ? [taskId] : []),
1074
+ candidate?.data?.interactionId,
1075
+ taskInteraction?.interactionId,
1076
+ taskInteraction?.mainInteractionId,
1077
+ taskInteraction?.parentInteractionId,
1078
+ taskInteraction?.callProcessingDetails?.parentInteractionId,
1079
+ ...(includeMainCallMediaKeys
1080
+ ? [TaskManager.getMainCallMediaId(taskInteraction)]
1081
+ : []),
1082
+ ];
1083
+
1084
+ return candidateInteractionIds.some(
1085
+ (interactionId) => Boolean(interactionId) && payloadInteractionIds.has(interactionId)
1086
+ );
1087
+ })
1088
+ .map(([, candidate]) => candidate)
1089
+ ),
1090
+ ];
1091
+ }
1092
+
527
1093
  /**
528
1094
  * Handle task lifecycle events and determine required actions
529
1095
  *
@@ -549,6 +1115,9 @@ export default class TaskManager extends EventEmitter {
549
1115
  case CC_EVENTS.CONTACT_MERGED:
550
1116
  return this.handleContactMergedEvent(context);
551
1117
 
1118
+ case CC_EVENTS.CONTACT_OWNER_CHANGED:
1119
+ return this.handleContactOwnerChanged(context);
1120
+
552
1121
  case CC_EVENTS.AGENT_OFFER_CAMPAIGN_RESERVATION:
553
1122
  return this.handleCampaignPreviewReservation(context);
554
1123
 
@@ -560,6 +1129,74 @@ export default class TaskManager extends EventEmitter {
560
1129
  }
561
1130
  }
562
1131
 
1132
+ /**
1133
+ * Recover the promoted agent's active voice task when local task state was lost.
1134
+ * Existing related tasks are never replaced: an unresolved or ambiguous relation
1135
+ * is safer to ignore than to create a duplicate task.
1136
+ */
1137
+ private handleContactOwnerChanged(context: EventContext): TaskEventActions {
1138
+ if (context.task) {
1139
+ return {task: context.task};
1140
+ }
1141
+
1142
+ const {payload} = context;
1143
+ if (
1144
+ this.findRelatedTasks(payload, {
1145
+ includeCollectionKeys: true,
1146
+ includeMainCallMediaKeys: true,
1147
+ }).length > 0 ||
1148
+ !this.canRecoverTaskFromContactOwnerChanged(payload)
1149
+ ) {
1150
+ return {};
1151
+ }
1152
+
1153
+ const stableInteractionId = TaskManager.getStableMainInteractionId(payload);
1154
+ if (!stableInteractionId || this.taskCollection[stableInteractionId]) {
1155
+ return {};
1156
+ }
1157
+
1158
+ const normalizedPayload: WebSocketPayload =
1159
+ payload.interactionId === stableInteractionId
1160
+ ? payload
1161
+ : {...payload, interactionId: stableInteractionId};
1162
+ const taskData: TaskData = {
1163
+ ...normalizedPayload,
1164
+ owner: normalizedPayload.interaction.owner,
1165
+ wrapUpRequired: normalizedPayload.interaction.participants?.[this.agentId]?.isWrapUp || false,
1166
+ isConferenceInProgress: getIsConferenceInProgress(normalizedPayload),
1167
+ isConsulted: false,
1168
+ isAutoAnswering: false,
1169
+ };
1170
+ const task = TaskFactory.createTask(
1171
+ this.contact,
1172
+ this.webCallingService,
1173
+ taskData,
1174
+ this.configFlags,
1175
+ this.wrapupData,
1176
+ this.agentId,
1177
+ this.answerCallOnWebexService
1178
+ );
1179
+
1180
+ this.taskCollection[stableInteractionId] = task;
1181
+
1182
+ // Restore the actor before installing listeners so the internal hydrate does
1183
+ // not leak as a second public hydrate or as an incoming-task notification.
1184
+ task.sendStateMachineEvent({
1185
+ type: TaskEvent.HYDRATE,
1186
+ taskData,
1187
+ agentId: this.agentId,
1188
+ } as TaskEventPayload);
1189
+
1190
+ this.setupTaskListeners(task);
1191
+ context.payload = taskData;
1192
+ context.stateMachineEvent = {
1193
+ type: TaskEvent.CONTACT_OWNER_CHANGED,
1194
+ taskData,
1195
+ };
1196
+
1197
+ return {task};
1198
+ }
1199
+
563
1200
  private handleCampaignContactUpdated(context: EventContext) {
564
1201
  const {payload} = context;
565
1202
  let {task} = context;
@@ -647,7 +1284,8 @@ export default class TaskManager extends EventEmitter {
647
1284
  },
648
1285
  this.configFlags,
649
1286
  this.wrapupData,
650
- this.agentId
1287
+ this.agentId,
1288
+ this.answerCallOnWebexService
651
1289
  );
652
1290
  this.setupTaskListeners(task);
653
1291
  this.taskCollection[payload.interactionId] = task;
@@ -685,7 +1323,8 @@ export default class TaskManager extends EventEmitter {
685
1323
  taskData,
686
1324
  this.configFlags,
687
1325
  this.wrapupData,
688
- this.agentId
1326
+ this.agentId,
1327
+ this.answerCallOnWebexService
689
1328
  );
690
1329
 
691
1330
  this.setupTaskListeners(task);
@@ -725,7 +1364,8 @@ export default class TaskManager extends EventEmitter {
725
1364
  taskData,
726
1365
  this.configFlags,
727
1366
  this.wrapupData,
728
- this.agentId
1367
+ this.agentId,
1368
+ this.answerCallOnWebexService
729
1369
  );
730
1370
  this.setupTaskListeners(task);
731
1371
  this.taskCollection[payload.interactionId] = task;
@@ -753,6 +1393,14 @@ export default class TaskManager extends EventEmitter {
753
1393
  : taskData;
754
1394
 
755
1395
  task.updateTaskData(updateTaskData);
1396
+
1397
+ // A main-interaction lifecycle event can re-key a child consult task. Remove
1398
+ // any aliases first so consumers never observe duplicate task entries.
1399
+ Object.entries(this.taskCollection).forEach(([taskId, candidate]) => {
1400
+ if (candidate === task && taskId !== taskData.interactionId) {
1401
+ delete this.taskCollection[taskId];
1402
+ }
1403
+ });
756
1404
  this.taskCollection[taskData.interactionId] = task;
757
1405
 
758
1406
  return task;
@@ -798,8 +1446,10 @@ export default class TaskManager extends EventEmitter {
798
1446
  task.on(TASK_EVENTS.TASK_CLEANUP, (t: ITask, options?: {removeFromCollection?: boolean}) => {
799
1447
  this.handleTaskCleanup(t);
800
1448
  if (options?.removeFromCollection) {
801
- const interactionId = t?.data?.interactionId;
802
- if (interactionId && this.taskCollection[interactionId]) {
1449
+ const taskIsStillCollected = Object.values(this.taskCollection).some(
1450
+ (candidate) => candidate === t
1451
+ );
1452
+ if (taskIsStillCollected) {
803
1453
  this.removeTaskFromCollection(t);
804
1454
  }
805
1455
  }
@@ -811,7 +1461,11 @@ export default class TaskManager extends EventEmitter {
811
1461
  task.cancelAutoWrapupTimer();
812
1462
  }
813
1463
  if (task?.data?.interactionId) {
814
- delete this.taskCollection[task.data.interactionId];
1464
+ Object.entries(this.taskCollection).forEach(([taskId, candidate]) => {
1465
+ if (candidate === task) {
1466
+ delete this.taskCollection[taskId];
1467
+ }
1468
+ });
815
1469
  LoggerProxy.info(`Task removed from collection`, {
816
1470
  module: TASK_MANAGER_FILE,
817
1471
  method: METHODS.REMOVE_TASK_FROM_COLLECTION,
@@ -864,7 +1518,8 @@ export default class TaskManager extends EventEmitter {
864
1518
  taskData,
865
1519
  this.configFlags,
866
1520
  this.wrapupData,
867
- this.agentId
1521
+ this.agentId,
1522
+ this.answerCallOnWebexService
868
1523
  );
869
1524
  this.taskCollection[payload.interactionId] = task;
870
1525
 
@@ -956,6 +1611,50 @@ export default class TaskManager extends EventEmitter {
956
1611
  return {...this.taskCollection};
957
1612
  }
958
1613
 
1614
+ /**
1615
+ * Propagate runtime enableWxBetterTogether changes to config flags and active tasks.
1616
+ */
1617
+ public applyEnableWxBetterTogether(enabled: boolean): void {
1618
+ if (this.configFlags) {
1619
+ this.configFlags = {...this.configFlags, enableWxBetterTogether: enabled};
1620
+ }
1621
+
1622
+ Object.values(this.taskCollection).forEach((task) => {
1623
+ task.setEnableWxBetterTogether(enabled);
1624
+ if (enabled) {
1625
+ TaskManager.syncWxAppMuteFromCallDetailsForTask(task);
1626
+ }
1627
+ });
1628
+ }
1629
+
1630
+ /**
1631
+ * Propagate wxApp mute state from Mercury sync to active voice tasks.
1632
+ */
1633
+ public applyWxAppMuteStateFromSync(callId: string, muted: boolean): void {
1634
+ Object.values(this.taskCollection).forEach((task) => {
1635
+ task.applyWxAppMuteStateFromSync(callId, muted);
1636
+ });
1637
+ }
1638
+
1639
+ /**
1640
+ * Re-seeds wxApp mute state from GET telephony/calls/{callId} for one task.
1641
+ */
1642
+ public static syncWxAppMuteFromCallDetailsForTask(task: ITask): void {
1643
+ const sync = task.syncWxAppMuteFromCallDetails?.bind(task);
1644
+ if (typeof sync === 'function') {
1645
+ sync().catch(() => undefined);
1646
+ }
1647
+ }
1648
+
1649
+ /**
1650
+ * Re-seeds wxApp mute for all engaged voice tasks (reload / runtime enable backfill).
1651
+ */
1652
+ public syncWxAppMuteFromCallDetailsForAllTasks(): void {
1653
+ Object.values(this.taskCollection).forEach((task) => {
1654
+ TaskManager.syncWxAppMuteFromCallDetailsForTask(task);
1655
+ });
1656
+ }
1657
+
959
1658
  public static getTaskManager(
960
1659
  apiAIAssistant: ApiAIAssistant,
961
1660
  contact: ReturnType<typeof routingContact>,