@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.
- package/ai-docs/features/contact-owner-change-refactor.md +78 -0
- package/dist/services/task/TaskManager.js +39 -58
- package/dist/services/task/TaskManager.js.map +1 -1
- package/dist/webex.js +1 -1
- package/package.json +1 -1
- package/src/services/task/TaskManager.ts +74 -87
- package/src/services/task/ai-docs/task-spec.md +5 -3
- package/test/unit/spec/services/task/TaskManager.ts +189 -3
- package/test/unit/spec/services/task/state-machine/TaskStateMachine.ts +33 -0
- package/umd/contact-center.min.js +2 -2
- package/umd/contact-center.min.js.map +1 -1
|
@@ -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
|
-
|
|
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 &&
|
|
726
|
-
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(
|
|
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(
|
|
853
|
+
return candidateInteractionIds.some(relatedInteractionId => Boolean(relatedInteractionId) && payloadInteractionIds.has(relatedInteractionId));
|
|
873
854
|
}).map(([, candidate]) => candidate))];
|
|
874
855
|
}
|
|
875
856
|
|