@webex/contact-center 3.12.0-llmrefactor.3 → 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.
- package/.sdd/manifest.json +1 -1
- package/ai-docs/CONTRACTS.md +1 -1
- package/dist/services/task/TaskManager.js +185 -9
- package/dist/services/task/TaskManager.js.map +1 -1
- package/dist/webex.js +1 -1
- package/package.json +9 -9
- package/src/services/task/TaskManager.ts +298 -31
- package/src/services/task/ai-docs/task-spec.md +36 -1
- package/src/services/task/state-machine/ai-docs/task-state-machine-spec.md +11 -3
- package/test/unit/spec/services/task/TaskManager.ts +641 -0
- package/umd/contact-center.min.js +2 -2
- package/umd/contact-center.min.js.map +1 -1
|
@@ -31,6 +31,7 @@ import AnswerCallOnWebexService from '../AnswerCallOnWebexService';
|
|
|
31
31
|
import {getWebexCallingDeviceDetailsForAgent} from './WebexCallingUtils';
|
|
32
32
|
import WebRTC from './voice/WebRTC';
|
|
33
33
|
import {TaskEvent, type TaskEventPayload} from './state-machine';
|
|
34
|
+
import {MEDIA_TYPE_MAIN_CALL} from './state-machine/constants';
|
|
34
35
|
import {normalizeTaskData} from './taskDataNormalizer';
|
|
35
36
|
import {ApiAIAssistant} from '../ApiAiAssistant';
|
|
36
37
|
|
|
@@ -241,6 +242,15 @@ export default class TaskManager extends EventEmitter {
|
|
|
241
242
|
return {type: TaskEvent.HYDRATE, taskData: payload, agentId};
|
|
242
243
|
|
|
243
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
|
+
|
|
244
254
|
return {type: TaskEvent.CONTACT_UPDATED, taskData: payload};
|
|
245
255
|
case CC_EVENTS.CONTACT_OWNER_CHANGED:
|
|
246
256
|
return {type: TaskEvent.CONTACT_OWNER_CHANGED, taskData: payload};
|
|
@@ -597,6 +607,10 @@ export default class TaskManager extends EventEmitter {
|
|
|
597
607
|
}
|
|
598
608
|
}
|
|
599
609
|
|
|
610
|
+
if (!task && eventType === CC_EVENTS.CONTACT_OWNER_CHANGED) {
|
|
611
|
+
task = this.findTaskForContactOwnerChange(message.data);
|
|
612
|
+
}
|
|
613
|
+
|
|
600
614
|
if (!task && MAIN_INTERACTION_CORRELATED_EVENTS.has(eventType)) {
|
|
601
615
|
task = this.findUniqueTaskByRelatedInteraction(message.data);
|
|
602
616
|
}
|
|
@@ -613,7 +627,7 @@ export default class TaskManager extends EventEmitter {
|
|
|
613
627
|
return !wasConsultedTask;
|
|
614
628
|
};
|
|
615
629
|
|
|
616
|
-
|
|
630
|
+
let adjustedPayload: WebSocketPayload =
|
|
617
631
|
eventType === CC_EVENTS.AGENT_CONSULT_TRANSFERRED ||
|
|
618
632
|
eventType === CC_EVENTS.AGENT_BLIND_TRANSFERRED ||
|
|
619
633
|
eventType === CC_EVENTS.AGENT_VTEAM_TRANSFERRED
|
|
@@ -623,6 +637,14 @@ export default class TaskManager extends EventEmitter {
|
|
|
623
637
|
}
|
|
624
638
|
: message.data;
|
|
625
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
|
+
|
|
626
648
|
const stateMachineEvent = TaskManager.mapEventToTaskStateMachineEvent(
|
|
627
649
|
eventType,
|
|
628
650
|
adjustedPayload,
|
|
@@ -645,42 +667,173 @@ export default class TaskManager extends EventEmitter {
|
|
|
645
667
|
};
|
|
646
668
|
}
|
|
647
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
|
+
|
|
648
826
|
/**
|
|
649
827
|
* Resolve lifecycle events that are emitted for the main interaction while the
|
|
650
828
|
* local task can still be indexed by a child consult interaction. Exact task
|
|
651
829
|
* keys are handled before this fallback. Multiple aliases of the same task are
|
|
652
830
|
* treated as one candidate; genuinely ambiguous matches are intentionally ignored.
|
|
653
831
|
*/
|
|
654
|
-
private findUniqueTaskByRelatedInteraction(
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
payload.interaction?.mainInteractionId,
|
|
660
|
-
payload.interaction?.parentInteractionId,
|
|
661
|
-
payload.interaction?.callProcessingDetails?.parentInteractionId,
|
|
662
|
-
].filter((interactionId): interactionId is string => Boolean(interactionId))
|
|
663
|
-
);
|
|
664
|
-
if (payloadInteractionIds.size === 0) return undefined;
|
|
665
|
-
|
|
666
|
-
const candidates = [
|
|
667
|
-
...new Set(
|
|
668
|
-
Object.values(this.taskCollection).filter((candidate) => {
|
|
669
|
-
const taskInteraction = candidate?.data?.interaction;
|
|
670
|
-
const candidateInteractionIds = [
|
|
671
|
-
candidate?.data?.interactionId,
|
|
672
|
-
taskInteraction?.interactionId,
|
|
673
|
-
taskInteraction?.mainInteractionId,
|
|
674
|
-
taskInteraction?.parentInteractionId,
|
|
675
|
-
taskInteraction?.callProcessingDetails?.parentInteractionId,
|
|
676
|
-
];
|
|
677
|
-
|
|
678
|
-
return candidateInteractionIds.some(
|
|
679
|
-
(interactionId) => Boolean(interactionId) && payloadInteractionIds.has(interactionId)
|
|
680
|
-
);
|
|
681
|
-
})
|
|
682
|
-
),
|
|
683
|
-
];
|
|
832
|
+
private findUniqueTaskByRelatedInteraction(
|
|
833
|
+
payload: WebSocketPayload,
|
|
834
|
+
candidateFilter: (task: ITask) => boolean = () => true
|
|
835
|
+
): ITask | undefined {
|
|
836
|
+
const candidates = this.findRelatedTasks(payload).filter(candidateFilter);
|
|
684
837
|
|
|
685
838
|
if (candidates.length === 1) {
|
|
686
839
|
return candidates[0];
|
|
@@ -701,6 +854,49 @@ export default class TaskManager extends EventEmitter {
|
|
|
701
854
|
return undefined;
|
|
702
855
|
}
|
|
703
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
|
+
|
|
704
900
|
/**
|
|
705
901
|
* Handle task lifecycle events and determine required actions
|
|
706
902
|
*
|
|
@@ -726,6 +922,9 @@ export default class TaskManager extends EventEmitter {
|
|
|
726
922
|
case CC_EVENTS.CONTACT_MERGED:
|
|
727
923
|
return this.handleContactMergedEvent(context);
|
|
728
924
|
|
|
925
|
+
case CC_EVENTS.CONTACT_OWNER_CHANGED:
|
|
926
|
+
return this.handleContactOwnerChanged(context);
|
|
927
|
+
|
|
729
928
|
case CC_EVENTS.AGENT_OFFER_CAMPAIGN_RESERVATION:
|
|
730
929
|
return this.handleCampaignPreviewReservation(context);
|
|
731
930
|
|
|
@@ -737,6 +936,74 @@ export default class TaskManager extends EventEmitter {
|
|
|
737
936
|
}
|
|
738
937
|
}
|
|
739
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
|
+
|
|
740
1007
|
private handleCampaignContactUpdated(context: EventContext) {
|
|
741
1008
|
const {payload} = context;
|
|
742
1009
|
let {task} = context;
|
|
@@ -437,6 +437,7 @@ The public `TASK_EVENTS` enum contains 49 members; every member is listed below
|
|
|
437
437
|
| TASK-R-007 | Route enabled preview-campaign accept, skip, and remove operations through the dialer AQM factory using `PreviewContactPayload`, returning `Promise<TaskResponse>` from the public ContactCenter methods. Before routing skip/remove, reject the operation when the matching task's disable flag is `'true'`. | Preview reservations require typed payloads and correlated backend completion, while campaign controls must block prohibited skip/remove requests before transport begins. | `src/cc.ts`, `src/services/task/dialer.ts`, `src/services/task/types.ts` | `test/unit/spec/cc.ts`, `test/unit/spec/services/task/dialer.ts` | Public delegation and dialer requests are covered; the `campaignPreviewSkipDisabled` and `campaignPreviewRemoveDisabled` early-exit guards lack direct unit coverage. Independent review identified this gap on 2026-07-15. | PRESENT |
|
|
438
438
|
| TASK-R-008 | Voice `dropConferenceParticipant` must validate the public target, resolve the latest main interaction, POST an empty body to the encoded participant-drop route, and settle only from `ParticipantLeftConference`, `ParticipantDropConferenceFailed`, HTTP failure, or the existing AQM timeout. | Participant removal is backend-authoritative and must preserve event-driven roster/state synchronization without exposing participant identifiers through telemetry or logs. | `src/services/task/voice/Voice.ts`, `src/services/task/contact.ts`, `src/services/task/types.ts` | `test/unit/spec/services/task/voice/Voice.ts`, `test/unit/spec/services/task/contact.ts`, `test/unit/spec/services/core/aqm-reqs.ts` | Backend authorization and media removal are remote-service responsibilities. | PRESENT |
|
|
439
439
|
| TASK-R-009 | ContactCenter must inject Desktop Profile collaboration policy into TaskFactory, and every created voice/digital Task must expose ordered Consult and Transfer destination categories through `TaskUIControls.consultTransferDestinations`. | Task consumers need one already-computed policy surface and must not fetch profile flags or duplicate destination decisions. | `src/cc.ts`, `src/services/task/TaskFactory.ts`, `src/services/task/types.ts` | `test/unit/spec/cc.ts`, `test/unit/spec/services/task/TaskFactory.ts` | Host-specific UI options may only further hide an SDK-allowed category. | PRESENT |
|
|
440
|
+
| TASK-R-010 | TaskManager must propagate backend owner promotion without electing an owner locally. `ContactOwnerChanged` resolves an exact task first, then one unique related task whose existing snapshot proves that the current Agent is active on `mainCall`, or whose incoming snapshot both names that Agent as `interaction.owner` and proves the same active-main-leg membership. Active membership requires a participant entry with `hasLeft !== true`. If no task exists, recovery is limited to that incoming owner=current-Agent proof: create the normal Task under the stable main interaction ID, silently HYDRATE its actor before listener installation, then process the original owner-change event so consumers receive one `task:hydrate` and no `task:incoming`. An owner-changing `ContactUpdated` uses the existing owner-change hydrate path only for an existing task, and a late `ParticipantLeftConference` cannot replace a confirmed active owner with the departed participant. | Every surviving agent must observe the backend-selected primary Agent immediately, including stale child-keyed and narrowly recoverable desynchronization cases, without duplicating tasks or exposing an uninitialized actor. | `src/services/task/TaskManager.ts` | `test/unit/spec/services/task/TaskManager.ts`, `test/unit/spec/services/task/Task.ts` | Backend owner selection and delivery of a complete owner-bearing `ContactOwnerChanged` payload remain remote responsibilities; missing-task `ContactUpdated` remains update-only. | PRESENT |
|
|
440
441
|
|
|
441
442
|
## Design Overview
|
|
442
443
|
Task separates its stable consumption boundary from collaborators so ownership and failure behavior stay explicit. A shared Task base preserves a stable API while media-specific subclasses and a separate state engine enforce capability differences.
|
|
@@ -918,6 +919,39 @@ returns to the main-call state selected by the existing guards. Starting a
|
|
|
918
919
|
consult preserves the prior task snapshot so this membership comparison remains
|
|
919
920
|
available while the consult is initiating.
|
|
920
921
|
|
|
922
|
+
Primary-Agent promotion remains backend-authoritative and follows the two-event
|
|
923
|
+
desktop contract. `ContactOwnerChanged` updates the promoted Agent; TaskManager
|
|
924
|
+
prefers an exact task, then one unique related task resolved through nested
|
|
925
|
+
main/parent identifiers or the `mainCall` media-map identity. A related
|
|
926
|
+
candidate is eligible when its current
|
|
927
|
+
snapshot contains the current Agent's participant entry with `hasLeft !== true`
|
|
928
|
+
and membership on the `mType: mainCall` leg. The incoming snapshot can provide
|
|
929
|
+
that evidence only when it also names the current Agent as `interaction.owner`;
|
|
930
|
+
this permits an authoritative promotion payload to repair a stale child-keyed
|
|
931
|
+
snapshot while still excluding consult-only tasks. The update keeps the
|
|
932
|
+
surviving main interaction identity even when the notification names a
|
|
933
|
+
promoted-Agent child interaction.
|
|
934
|
+
|
|
935
|
+
If no related task exists, TaskManager recovers only the promoted current Agent
|
|
936
|
+
from a non-terminal telephony `ContactOwnerChanged` payload that provides the same
|
|
937
|
+
active-main-leg evidence. It creates the normal Task through TaskFactory under the
|
|
938
|
+
stable main interaction ID, sends an internal HYDRATE before installing external
|
|
939
|
+
listeners, and then processes the original owner-change event. Consumers
|
|
940
|
+
therefore receive one `task:hydrate`, never a synthetic `task:incoming`, and a
|
|
941
|
+
later canonical contact or merge event reuses the same task rather than creating
|
|
942
|
+
an alias. Missing, partial, departed, consult-only, non-promoted, and ambiguous
|
|
943
|
+
payloads are ignored for recovery.
|
|
944
|
+
|
|
945
|
+
For other surviving Agents, a `ContactUpdated` whose non-empty
|
|
946
|
+
`interaction.owner` differs from the current owner is delivered through the
|
|
947
|
+
existing `CONTACT_OWNER_CHANGED`/`task:hydrate` path; same-owner or owner-less
|
|
948
|
+
updates remain data-only, and a missing task is not created from `ContactUpdated`.
|
|
949
|
+
If a later `ParticipantLeftConference` still names the departed participant as
|
|
950
|
+
owner, an already confirmed owner is retained only when the new roster proves
|
|
951
|
+
that confirmed owner remains active on `mainCall` and explicitly names the
|
|
952
|
+
incoming owner as `participantId` or marks it `hasLeft`. Main-call omission alone
|
|
953
|
+
is not departure evidence. The SDK never chooses the successor locally.
|
|
954
|
+
|
|
921
955
|
In the Contact Center sample roster, non-owner and Supervisor targets remain
|
|
922
956
|
visible but non-actionable; the UI does not display a separate read-only label.
|
|
923
957
|
The roster is visible whenever the viewing agent remains active on the main leg
|
|
@@ -1383,7 +1417,7 @@ await cc.stationLogin({ loginOption: 'BROWSER', ... });
|
|
|
1383
1417
|
- A shared Task base preserves a stable API while media-specific subclasses and a separate state engine enforce capability differences.
|
|
1384
1418
|
|
|
1385
1419
|
## Test-Case Strategy (module)
|
|
1386
|
-
Use `test/unit/spec/services/task/Task.ts`, `TaskFactory.ts`, `TaskManager.ts`, media-specific suites, contact/dialer suites, and state-machine suites. Cover concrete-versus-interface method signatures, every TASK_EVENTS group, unsupported media rejection, primary/RTD event ownership, injected state actions, preview-campaign accept/skip/remove payloads and failure paths, the disabled skip/remove pre-guards, Participant Drop validation/correlation/privacy, and success/failure/timeout paths.
|
|
1420
|
+
Use `test/unit/spec/services/task/Task.ts`, `TaskFactory.ts`, `TaskManager.ts`, media-specific suites, contact/dialer suites, and state-machine suites. Cover concrete-versus-interface method signatures, every TASK_EVENTS group, unsupported media rejection, primary/RTD event ownership, injected state actions, preview-campaign accept/skip/remove payloads and failure paths, the disabled skip/remove pre-guards, Participant Drop validation/correlation/privacy, primary-owner promotion propagation, and success/failure/timeout paths.
|
|
1387
1421
|
|
|
1388
1422
|
| Behavior / Requirement | Existing test evidence | Gap |
|
|
1389
1423
|
|---|---|---|
|
|
@@ -1396,6 +1430,7 @@ Use `test/unit/spec/services/task/Task.ts`, `TaskFactory.ts`, `TaskManager.ts`,
|
|
|
1396
1430
|
| `TASK-R-007` | `test/unit/spec/cc.ts`, `test/unit/spec/services/task/dialer.ts` | Add direct tests proving disabled skip/remove flags throw before dialer invocation; keep public signatures, metrics/error handling, and AQM request contracts synchronized. |
|
|
1397
1431
|
| `TASK-R-008` | `test/unit/spec/services/task/Task.ts`, `test/unit/spec/services/task/voice/Voice.ts`, `test/unit/spec/services/task/contact.ts`, `test/unit/spec/services/core/aqm-reqs.ts`, `test/unit/spec/services/task/TaskManager.ts` | None. |
|
|
1398
1432
|
| `TASK-R-009` | `test/unit/spec/cc.ts`, `test/unit/spec/services/task/TaskFactory.ts`, `test/unit/spec/services/task/state-machine/uiControlsComputer.ts` | None. |
|
|
1433
|
+
| `TASK-R-010` | `test/unit/spec/services/task/TaskManager.ts`, `test/unit/spec/services/task/Task.ts` | None. |
|
|
1399
1434
|
|
|
1400
1435
|
## Traceability
|
|
1401
1436
|
- Repo architecture: `../../../../ai-docs/ARCHITECTURE.md` · Registry: `../../../../ai-docs/SPEC_INDEX.md`
|
|
@@ -78,7 +78,7 @@ See root `CONTRACTS.md` for the package-level state-control export.
|
|
|
78
78
|
| TASK_STATE_MACHINE-R-005 | Keep authentication and credentials outside the state-machine layer; it receives typed Task data/events and never invokes authenticated transport. | Pure transition logic remains reusable and cannot leak or mutate host authentication state. | `src/services/task/state-machine/TaskStateMachine.ts`, `src/services/task/state-machine/types.ts` | `test/unit/spec/services/task/state-machine/TaskStateMachine.ts` | None; security/auth applicability is explicitly N/A. | PRESENT |
|
|
79
79
|
| TASK_STATE_MACHINE-R-006 | Treat `UIControlConfig` values as Task-supplied capability configuration, not rollout flags evaluated or owned by the state machine. | Rollout and profile policy must be resolved before actor construction so transitions remain deterministic. | `src/services/task/state-machine/types.ts`, `src/services/task/Task.ts` | `test/unit/spec/services/task/Task.ts` | None; rollout ownership is explicit. | PRESENT |
|
|
80
80
|
| TASK_STATE_MACHINE-R-007 | Keep logging and metrics in Task/TaskManager integration; the state-machine implementation has no LoggerProxy or MetricsManager dependency. | Separating observability side effects from guards/actions preserves deterministic transition tests. | `src/services/task/state-machine/TaskStateMachine.ts`, `src/services/task/Task.ts` | `test/unit/spec/services/task/state-machine/TaskStateMachine.ts`, `test/unit/spec/services/task/Task.ts` | None; observability ownership is explicit. | PRESENT |
|
|
81
|
-
| TASK_STATE_MACHINE-R-008 | `CONTACT_OWNER_CHANGED` must synchronize task/context data and emit `task:hydrate` without transitioning state
|
|
81
|
+
| TASK_STATE_MACHINE-R-008 | `CONTACT_OWNER_CHANGED` must synchronize task/context data and emit `task:hydrate` without transitioning state. A TaskManager-recovered promoted-Agent task first receives an internal IDLE `HYDRATE` before listener installation, then the original owner-change event produces exactly one external hydrate and no incoming event. TaskManager maps an owner-changing `ContactUpdated` for an existing task to that owner-change event, while same-owner or owner-less updates remain `CONTACT_UPDATED`; missing-task `ContactUpdated` does not create a task. It also preserves an already confirmed active owner from a late participant-left snapshot that still names the departed owner. Participant Drop must rely on the existing `ParticipantLeftConference` mapping rather than adding an initiating state event. From every active call-control state, `PARTICIPANT_LEAVE` terminates or wraps the current Agent only when the event names that Agent, marks that Agent `hasLeft`, or removes a previously active Agent from the participant map; an event naming another participant cannot infer self-departure from a partial media roster. `CONSULT_END` evaluates the same explicit evidence before initiator recovery and additionally supports the narrow from-conference nested-consult race where a previously main-leg Agent is absent from the updated `mainCall` but remains active in the participant map and present on the consult leg. Removed accepted Agents emit `task:consultEnd` plus `task:end`, surviving initiators recover to their main call, and an unaccepted OFFERED consultee emits only `task:consultEnd`. Missing, partial, contradictory, or ordinary CONNECTED/HELD media membership is non-terminal, and starting Consult must preserve the prior task snapshot used by the guard. | Owner-sensitive consumers must rerender promptly while owner selection, participant removal, consult removal, and narrowly scoped desynchronization recovery remain backend-authoritative and compatible with existing incoming-task callback behavior. | `src/services/task/state-machine/TaskStateMachine.ts`, `src/services/task/state-machine/actions.ts`, `src/services/task/state-machine/guards.ts`, `src/services/task/TaskManager.ts` | `test/unit/spec/services/task/Task.ts`, `test/unit/spec/services/task/TaskManager.ts`, `test/unit/spec/services/task/state-machine/TaskStateMachine.ts`, `test/unit/spec/services/task/state-machine/guards.ts` | Backend ownership-successor selection and delivery of complete recovery payloads are outside this module. | PRESENT |
|
|
82
82
|
|
|
83
83
|
## Design Overview
|
|
84
84
|
TaskManager maps Contact Center notifications to `TaskEvent` values. Each Task sends those events to its XState actor built by `createTaskStateMachine()`. The configuration applies guards and named actions, updates `TaskContext`, and computes UI controls. Task supplies the integration-specific `syncTaskDataFromEvent` implementation through machine options; it is not a default action in `actions.ts`.
|
|
@@ -216,6 +216,14 @@ sequenceDiagram
|
|
|
216
216
|
|
|
217
217
|
### Hydrate and recovery
|
|
218
218
|
|
|
219
|
+
When TaskManager narrowly recovers a missing promoted-Agent task from a complete
|
|
220
|
+
`ContactOwnerChanged` payload, it sends `HYDRATE` while the new actor is still
|
|
221
|
+
unobserved so the IDLE guards restore CONNECTED, HELD, CONFERENCING, or another
|
|
222
|
+
backend-represented state. TaskManager then installs listeners and forwards the
|
|
223
|
+
original `CONTACT_OWNER_CHANGED`; only that second event emits the single public
|
|
224
|
+
`task:hydrate`. Recovery never emits `task:incoming`, and missing-task
|
|
225
|
+
`ContactUpdated` does not use this path.
|
|
226
|
+
|
|
219
227
|
```mermaid
|
|
220
228
|
sequenceDiagram
|
|
221
229
|
participant TM as TaskManager
|
|
@@ -1749,7 +1757,7 @@ Complete mapping from backend CC_EVENTS to internal TaskEvent types.
|
|
|
1749
1757
|
| `AGENT_CONTACT_RESERVED` | `TASK_INCOMING` | `IDLE` | `OFFERED` | Incoming task entry |
|
|
1750
1758
|
| `AGENT_OFFER_CONTACT` | `TASK_OFFERED` | `OFFERED` | `OFFERED` | Offer payload refresh |
|
|
1751
1759
|
| `AGENT_CONTACT` | `HYDRATE` | `IDLE` | `WRAPPING_UP` / `CONSULTING` / `HELD` / `CONNECTED` / `CONFERENCING` / `IDLE` | Guard-based restore |
|
|
1752
|
-
| `CONTACT_UPDATED` | `CONTACT_UPDATED`
|
|
1760
|
+
| `CONTACT_UPDATED` | `CONTACT_UPDATED` or `CONTACT_OWNER_CHANGED` | any | same | Owner delta uses existing hydrate path; same/missing owner is context sync only |
|
|
1753
1761
|
| `CONTACT_OWNER_CHANGED` | `CONTACT_OWNER_CHANGED` | any | same | Context/data sync + `task:hydrate` |
|
|
1754
1762
|
| `AGENT_OFFER_CONSULT` | `OFFER_CONSULT` | `OFFERED` | `OFFERED` | Receiver-side consult offer |
|
|
1755
1763
|
| `AGENT_CONTACT_ASSIGNED` | `ASSIGN` | `OFFERED` / `CONNECTED` / `CONSULTING` | `CONNECTED` | Assign/reassign |
|
|
@@ -1794,7 +1802,7 @@ Complete mapping from backend CC_EVENTS to internal TaskEvent types.
|
|
|
1794
1802
|
| `AgentOfferContact` | `TASK_OFFERED` | Stay in OFFERED | Offer confirmation |
|
|
1795
1803
|
| `AgentContact` | `HYDRATE` | Various | State restoration |
|
|
1796
1804
|
| `AgentContactAssigned` | `ASSIGN` | OFFERED → CONNECTED (also CONNECTED/CONSULTING refresh paths) | Task accepted/reassigned |
|
|
1797
|
-
| `ContactUpdated` | `CONTACT_UPDATED`
|
|
1805
|
+
| `ContactUpdated` | `CONTACT_UPDATED` or `CONTACT_OWNER_CHANGED` | No change | Changed non-empty owner also emits `task:hydrate`; same/missing owner is data-only |
|
|
1798
1806
|
| `ContactOwnerChanged` | `CONTACT_OWNER_CHANGED` | No change | Owner update plus `task:hydrate` emission |
|
|
1799
1807
|
| `ContactEnded` | `CONTACT_ENDED` | Guard-based branch | CONFERENCING / WRAPPING_UP / TERMINATED / stay |
|
|
1800
1808
|
| `AgentContactUnassigned` | None | N/A | Handled by other events |
|