@webex/contact-center 3.12.0-next.129 → 3.12.0-next.130

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.
@@ -0,0 +1,78 @@
1
+ # Contact Owner Change Refactor
2
+
3
+ ## Scope
4
+
5
+ This change refactors only the owner-change propagation introduced by
6
+ webex-js-sdk PR #5187. It preserves the behavior required by Participant Drop:
7
+ when the backend promotes a new primary Agent, existing SDK tasks publish the
8
+ updated owner through the existing `task:hydrate` event so widgets immediately
9
+ recompute the Primary label, icon, and Drop permission.
10
+
11
+ There is no public API, routing-event, state-machine transition, Participant
12
+ Drop request, or widget contract change.
13
+
14
+ ## Retained behavior
15
+
16
+ | Behavior | Why it remains | Existing-flow impact |
17
+ |---|---|---|
18
+ | Map an owner-changing `ContactUpdated` to `CONTACT_OWNER_CHANGED` | Remaining conference Agents receive `ContactUpdated`, not necessarily `ContactOwnerChanged`. The existing state-machine action synchronizes task data and emits `task:hydrate`. | Preserves immediate owner/Primary UI updates without adding a new public event or state transition. Same-owner and owner-less updates remain data-only. |
19
+ | Resolve `ContactOwnerChanged` by exact ID before related IDs | Exact identity remains the safest and existing lookup contract. | Existing exact task routing is unchanged. |
20
+ | Resolve one unique related task using nested interaction IDs, the collection key, or the `mainCall` media-map key | Some supported owner-change payloads identify the surviving call through a child top-level ID and omit `mainInteractionId`. | Preserves cross-channel promotion across the documented payload variants. Task aliases are deduplicated and ambiguous matches remain ignored. |
21
+ | Accept current-Agent main-leg evidence from the existing task or authoritative incoming promotion | A local child task can be briefly stale when the owner-change payload first proves promotion. | Preserves the stale-snapshot fix while continuing to reject ordinary consult-only tasks. |
22
+ | Recover a missing promoted-Agent task from a complete `ContactOwnerChanged` payload | Reloaded or desynchronized clients may receive the authoritative promotion before normal task creation catches up. Agent Desktop supports the same recovery path. | A complete telephony main-leg payload restores the task under the stable main interaction ID, emits one `task:hydrate`, and emits no synthetic `task:incoming`. |
23
+ | Preserve a confirmed owner across a late `ParticipantLeftConference` snapshot that still names the departed owner | Participant Drop can deliver owner change and participant-left notifications in either order. | Prevents the Primary UI from reverting after it has already received the authoritative new owner. |
24
+
25
+ ## Refactoring and safety constraints
26
+
27
+ Owner-change correlation and recovery retain the following protections:
28
+
29
+ - generic lifecycle-event correlation is unchanged; collection-key and media-key
30
+ matching are enabled only for `ContactOwnerChanged`;
31
+ - only a task selected by the related-interaction fallback may be re-keyed;
32
+ - conflicting task/payload main IDs are ignored;
33
+ - a stable main key already owned by a different Task causes the event to be
34
+ ignored; and
35
+ - an exact child task is never normalized over a distinct main task;
36
+ - aliases of the same Task object are deduplicated before uniqueness is tested;
37
+ - consult-only and ambiguous matches are rejected; and
38
+ - missing-task recovery is rejected whenever any related task or canonical-key
39
+ occupant already exists.
40
+
41
+ This closes the PR review scenario where an exact child task could previously
42
+ be normalized to a main key and silently replace the actual main Task, orphaning
43
+ its listeners and resources.
44
+
45
+ Recovery is intentionally narrow. It requires a non-terminal telephony payload,
46
+ `interaction.owner` equal to the current Agent, an active participant entry for
47
+ that Agent on an `mType: mainCall` leg, and a stable main interaction ID. It does
48
+ not use `toOwner`, elect an owner locally, or create a task for a missing
49
+ owner-changing `ContactUpdated` event.
50
+
51
+ ## Resulting event flow
52
+
53
+ 1. The backend chooses the new `interaction.owner`.
54
+ 2. The promoted Agent receives `ContactOwnerChanged`; other surviving Agents may
55
+ receive an owner-changing `ContactUpdated`.
56
+ 3. TaskManager first uses an exact task match and otherwise accepts one unique,
57
+ eligible related task.
58
+ 4. If the promoted Agent has no related task, a complete authoritative payload
59
+ may recover the task under its stable main interaction ID. An internal
60
+ pre-listener `HYDRATE` restores the actor state without publishing an event.
61
+ 5. Both notification forms use the existing `CONTACT_OWNER_CHANGED` state-machine
62
+ event.
63
+ 6. The state machine updates task/context data, keeps the current task state, and
64
+ emits exactly one public `task:hydrate`.
65
+ 7. Widget consumers receive the hydrated Task and render the backend-provided
66
+ owner immediately.
67
+
68
+ `ParticipantLeftConference` continues through its existing event/state-machine
69
+ path. No client elects a successor and no Drop completion fabricates an owner.
70
+
71
+ ## Verification coverage
72
+
73
+ The focused TaskManager tests cover owner-changing versus data-only
74
+ `ContactUpdated`, exact and child-keyed correlation, collection-key and
75
+ `mainCall` media-key payloads, stale incoming promotion evidence, consult-only
76
+ and ambiguous rejection, narrow missing-task recovery, listener/alias safety,
77
+ later `AgentContact` and `ContactMerged` reuse, exact-child/main-task collision
78
+ protection, and both owner-change/participant-left orders.
@@ -690,6 +690,7 @@ class TaskManager extends _events.default {
690
690
  const interactionId = message.data.interactionId;
691
691
  const reservationInteractionId = message.data.reservationInteractionId;
692
692
  let task = this.taskCollection[interactionId];
693
+ let ownerChangeResolvedByRelatedInteraction = false;
693
694
 
694
695
  // When a campaign preview contact is accepted, the assigned event may arrive
695
696
  // with a new interactionId while the task is stored under the original
@@ -703,7 +704,35 @@ class TaskManager extends _events.default {
703
704
  }
704
705
  }
705
706
  if (!task && eventType === _types2.CC_EVENTS.CONTACT_OWNER_CHANGED) {
706
- task = this.findTaskForContactOwnerChange(message.data);
707
+ const incomingShowsCurrentAgentPromotion = Boolean(this.agentId && message.data.interaction?.owner === this.agentId && TaskManager.isParticipantActiveOnMainInteraction(message.data, this.agentId));
708
+ task = this.findUniqueTaskByRelatedInteraction(message.data, candidate => {
709
+ const currentAgentId = this.agentId || candidate.data?.agentId;
710
+ return Boolean(currentAgentId && (TaskManager.isParticipantActiveOnMainInteraction(candidate.data, currentAgentId) || incomingShowsCurrentAgentPromotion));
711
+ }, {
712
+ includeCollectionKeys: true,
713
+ includeMainCallMediaKeys: true
714
+ });
715
+ ownerChangeResolvedByRelatedInteraction = Boolean(task);
716
+ }
717
+ let ownerChangeMainInteractionId;
718
+ if (task && eventType === _types2.CC_EVENTS.CONTACT_OWNER_CHANGED) {
719
+ const taskMainInteractionId = task.data?.interaction?.mainInteractionId;
720
+ const payloadMainInteractionId = message.data.interaction?.mainInteractionId;
721
+ const hasConflictingMainInteractionIds = Boolean(taskMainInteractionId && payloadMainInteractionId && taskMainInteractionId !== payloadMainInteractionId);
722
+ // A promoted child task can retain stale embedded IDs. Prefer the
723
+ // authoritative incoming main-call identity before local snapshot IDs.
724
+ const stableMainInteractionId = payloadMainInteractionId || TaskManager.getMainCallMediaId(message.data.interaction) || TaskManager.getMainCallMediaId(task.data?.interaction) || taskMainInteractionId || message.data.interaction?.interactionId || task.data?.interaction?.interactionId || task.data?.interactionId;
725
+ const taskAtStableKey = stableMainInteractionId ? this.taskCollection[stableMainInteractionId] : undefined;
726
+ if (!stableMainInteractionId || hasConflictingMainInteractionIds || taskAtStableKey && taskAtStableKey !== task) {
727
+ _loggerProxy.default.warn('Unable to safely correlate owner change to the main interaction task', {
728
+ module: _constants.TASK_MANAGER_FILE,
729
+ method: 'prepareEventContext',
730
+ interactionId: payloadMainInteractionId || taskMainInteractionId || message.data.interactionId
731
+ });
732
+ task = undefined;
733
+ } else if (ownerChangeResolvedByRelatedInteraction) {
734
+ ownerChangeMainInteractionId = stableMainInteractionId;
735
+ }
707
736
  }
708
737
  if (!task && MAIN_INTERACTION_CORRELATED_EVENTS.has(eventType)) {
709
738
  task = this.findUniqueTaskByRelatedInteraction(message.data);
@@ -722,8 +751,11 @@ class TaskManager extends _events.default {
722
751
  ...message.data,
723
752
  wrapUpRequired: computeWrapUpRequired()
724
753
  } : message.data;
725
- if (task && eventType === _types2.CC_EVENTS.CONTACT_OWNER_CHANGED) {
726
- adjustedPayload = TaskManager.preserveMainTaskIdentity(task, adjustedPayload);
754
+ if (task && ownerChangeMainInteractionId) {
755
+ adjustedPayload = {
756
+ ...adjustedPayload,
757
+ interactionId: ownerChangeMainInteractionId
758
+ };
727
759
  }
728
760
  if (task && eventType === _types2.CC_EVENTS.PARTICIPANT_LEFT_CONFERENCE) {
729
761
  adjustedPayload = TaskManager.preserveConfirmedOwner(task, adjustedPayload);
@@ -744,47 +776,12 @@ class TaskManager extends _events.default {
744
776
  stateMachineEvent
745
777
  };
746
778
  }
747
-
748
- /**
749
- * ContactOwnerChanged can identify the promoted agent's child interaction while
750
- * the surviving task is stored under the main interaction. Accept one related
751
- * task when its existing snapshot or the authoritative incoming promotion proves
752
- * current-agent main-call membership, while excluding non-promoted and ambiguous matches.
753
- */
754
- findTaskForContactOwnerChange(payload) {
755
- const relatedCandidates = this.findRelatedTasks(payload, {
756
- includeCollectionKeys: true,
757
- includeMainCallMediaKeys: true
758
- });
759
- const incomingShowsCurrentAgentPromotion = this.isCurrentAgentPromotedOnIncomingMainCall(payload);
760
- const eligibleCandidates = relatedCandidates.filter(candidate => this.isCurrentAgentActiveOnMainInteraction(candidate.data) || incomingShowsCurrentAgentPromotion);
761
- if (eligibleCandidates.length === 1) {
762
- return eligibleCandidates[0];
763
- }
764
- if (eligibleCandidates.length > 1) {
765
- _loggerProxy.default.warn('Unable to correlate task event to a unique main interaction task', {
766
- module: _constants.TASK_MANAGER_FILE,
767
- method: 'findTaskForContactOwnerChange',
768
- interactionId: payload.interaction?.mainInteractionId || payload.interaction?.parentInteractionId || payload.interaction?.callProcessingDetails?.parentInteractionId || payload.interactionId
769
- });
770
- return undefined;
771
- }
772
- return undefined;
773
- }
774
- isCurrentAgentActiveOnMainInteraction(taskData) {
775
- const currentAgentId = this.agentId || taskData?.agentId;
776
- if (!currentAgentId) return false;
777
- return TaskManager.isParticipantActiveOnMainInteraction(taskData, currentAgentId);
778
- }
779
779
  static isParticipantActiveOnMainInteraction(taskData, participantId) {
780
780
  const interaction = taskData?.interaction;
781
781
  const participant = interaction?.participants?.[participantId];
782
782
  if (!participant || participant.hasLeft === true) return false;
783
783
  return Object.values(interaction?.media ?? {}).some(media => media?.mType === _constants3.MEDIA_TYPE_MAIN_CALL && media.participants?.includes(participantId));
784
784
  }
785
- isCurrentAgentPromotedOnIncomingMainCall(payload) {
786
- return Boolean(this.agentId && payload.interaction?.owner === this.agentId && TaskManager.isParticipantActiveOnMainInteraction(payload, this.agentId));
787
- }
788
785
  static getMainCallMediaId(interaction) {
789
786
  return Object.entries(interaction?.media ?? {}).find(([, media]) => media?.mType === _constants3.MEDIA_TYPE_MAIN_CALL)?.[0];
790
787
  }
@@ -797,22 +794,6 @@ class TaskManager extends _events.default {
797
794
  return Boolean(this.agentId && stableInteractionId && interaction?.mediaType === _types.MEDIA_CHANNEL.TELEPHONY && interaction.isTerminated !== true && interaction.owner === this.agentId && TaskManager.isParticipantActiveOnMainInteraction(payload, this.agentId));
798
795
  }
799
796
 
800
- /** Keep a related owner-change event from re-keying the surviving main task to a child ID. */
801
- static preserveMainTaskIdentity(task, payload) {
802
- const currentMainInteractionId = task.data?.interaction?.mainInteractionId;
803
- const payloadMainInteractionId = payload.interaction?.mainInteractionId;
804
- const currentMainMediaId = TaskManager.getMainCallMediaId(task.data?.interaction);
805
- const payloadMainMediaId = TaskManager.getMainCallMediaId(payload.interaction);
806
- const stableInteractionId = currentMainInteractionId || payloadMainInteractionId || currentMainMediaId || payloadMainMediaId || payload.interaction?.interactionId || task.data?.interaction?.interactionId || task.data?.interactionId;
807
- if (!stableInteractionId || stableInteractionId === payload.interactionId) {
808
- return payload;
809
- }
810
- return {
811
- ...payload,
812
- interactionId: stableInteractionId
813
- };
814
- }
815
-
816
797
  /**
817
798
  * ParticipantLeftConference may arrive after an owner update while still carrying
818
799
  * the departed owner. Keep the confirmed owner only when the new roster proves that
@@ -845,8 +826,8 @@ class TaskManager extends _events.default {
845
826
  * keys are handled before this fallback. Multiple aliases of the same task are
846
827
  * treated as one candidate; genuinely ambiguous matches are intentionally ignored.
847
828
  */
848
- findUniqueTaskByRelatedInteraction(payload, candidateFilter = () => true) {
849
- const candidates = this.findRelatedTasks(payload).filter(candidateFilter);
829
+ findUniqueTaskByRelatedInteraction(payload, candidateFilter = () => true, options = {}) {
830
+ const candidates = this.findRelatedTasks(payload, options).filter(candidateFilter);
850
831
  if (candidates.length === 1) {
851
832
  return candidates[0];
852
833
  }
@@ -864,12 +845,12 @@ class TaskManager extends _events.default {
864
845
  includeCollectionKeys = false,
865
846
  includeMainCallMediaKeys = false
866
847
  } = options;
867
- const payloadInteractionIds = new Set([payload.interactionId, payload.interaction?.interactionId, payload.interaction?.mainInteractionId, payload.interaction?.parentInteractionId, payload.interaction?.callProcessingDetails?.parentInteractionId, ...(includeMainCallMediaKeys ? [TaskManager.getMainCallMediaId(payload.interaction)] : [])].filter(interactionId => Boolean(interactionId)));
848
+ const payloadInteractionIds = new Set([payload.interactionId, payload.interaction?.interactionId, payload.interaction?.mainInteractionId, payload.interaction?.parentInteractionId, payload.interaction?.callProcessingDetails?.parentInteractionId, ...(includeMainCallMediaKeys ? [TaskManager.getMainCallMediaId(payload.interaction)] : [])].filter(relatedInteractionId => Boolean(relatedInteractionId)));
868
849
  if (payloadInteractionIds.size === 0) return [];
869
850
  return [...new Set(Object.entries(this.taskCollection).filter(([taskId, candidate]) => {
870
851
  const taskInteraction = candidate?.data?.interaction;
871
852
  const candidateInteractionIds = [...(includeCollectionKeys ? [taskId] : []), candidate?.data?.interactionId, taskInteraction?.interactionId, taskInteraction?.mainInteractionId, taskInteraction?.parentInteractionId, taskInteraction?.callProcessingDetails?.parentInteractionId, ...(includeMainCallMediaKeys ? [TaskManager.getMainCallMediaId(taskInteraction)] : [])];
872
- return candidateInteractionIds.some(interactionId => Boolean(interactionId) && payloadInteractionIds.has(interactionId));
853
+ return candidateInteractionIds.some(relatedInteractionId => Boolean(relatedInteractionId) && payloadInteractionIds.has(relatedInteractionId));
873
854
  }).map(([, candidate]) => candidate))];
874
855
  }
875
856