@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.
@@ -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
- const adjustedPayload =
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(payload: WebSocketPayload): ITask | undefined {
655
- const payloadInteractionIds = new Set(
656
- [
657
- payload.interactionId,
658
- payload.interaction?.interactionId,
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; 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 participant and consult removal remain backend-authoritative, state-independent, 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 event delivery are outside this module. | PRESENT |
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` | any | same | Context sync |
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` | No change | Data update only |
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 |