@webex/contact-center 3.12.0-task-refactor.7 → 3.12.0-task-refactor.9
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/dist/cc.js +3 -4
- package/dist/cc.js.map +1 -1
- package/dist/constants.js +1 -0
- package/dist/constants.js.map +1 -1
- package/dist/metrics/constants.js +2 -0
- package/dist/metrics/constants.js.map +1 -1
- package/dist/services/ApiAiAssistant.js +74 -3
- package/dist/services/ApiAiAssistant.js.map +1 -1
- package/dist/services/config/types.js +9 -1
- package/dist/services/config/types.js.map +1 -1
- package/dist/services/task/Task.js +32 -0
- package/dist/services/task/Task.js.map +1 -1
- package/dist/services/task/TaskManager.js +7 -2
- package/dist/services/task/TaskManager.js.map +1 -1
- package/dist/services/task/TaskUtils.js +3 -1
- package/dist/services/task/TaskUtils.js.map +1 -1
- package/dist/services/task/state-machine/TaskStateMachine.js +76 -0
- package/dist/services/task/state-machine/TaskStateMachine.js.map +1 -1
- package/dist/services/task/state-machine/actions.js +113 -23
- package/dist/services/task/state-machine/actions.js.map +1 -1
- package/dist/services/task/state-machine/uiControlsComputer.js +99 -21
- package/dist/services/task/state-machine/uiControlsComputer.js.map +1 -1
- package/dist/types/constants.d.ts +1 -0
- package/dist/types/metrics/constants.d.ts +2 -0
- package/dist/types/services/ApiAiAssistant.d.ts +10 -2
- package/dist/types/services/config/types.d.ts +16 -0
- package/dist/types/services/task/Task.d.ts +10 -0
- package/dist/types/services/task/state-machine/TaskStateMachine.d.ts +110 -0
- package/dist/types/services/task/state-machine/actions.d.ts +2 -0
- package/dist/types/types.d.ts +24 -0
- package/dist/types.js +15 -0
- package/dist/types.js.map +1 -1
- package/dist/webex.js +1 -1
- package/package.json +1 -1
- package/src/cc.ts +6 -4
- package/src/constants.ts +1 -0
- package/src/metrics/constants.ts +2 -0
- package/src/services/ApiAiAssistant.ts +102 -2
- package/src/services/config/types.ts +8 -0
- package/src/services/task/Task.ts +34 -0
- package/src/services/task/TaskManager.ts +7 -2
- package/src/services/task/TaskUtils.ts +5 -3
- package/src/services/task/ai-docs/AGENTS.md +7 -0
- package/src/services/task/ai-docs/ARCHITECTURE.md +12 -0
- package/src/services/task/state-machine/TaskStateMachine.ts +104 -0
- package/src/services/task/state-machine/actions.ts +151 -25
- package/src/services/task/state-machine/uiControlsComputer.ts +173 -30
- package/src/types.ts +25 -0
- package/test/unit/spec/cc.ts +2 -0
- package/test/unit/spec/services/ApiAiAssistant.ts +105 -17
- package/test/unit/spec/services/task/Task.ts +61 -0
- package/test/unit/spec/services/task/TaskManager.ts +42 -0
- package/test/unit/spec/services/task/TaskUtils.ts +65 -0
- package/test/unit/spec/services/task/state-machine/TaskStateMachine.ts +676 -0
- package/test/unit/spec/services/task/state-machine/uiControlsComputer.ts +597 -0
- package/test/unit/spec/services/task/voice/WebRTC.ts +99 -106
- package/umd/contact-center.min.js +2 -2
- package/umd/contact-center.min.js.map +1 -1
|
@@ -66,6 +66,20 @@ export function getDefaultUIControls(): TaskUIControls {
|
|
|
66
66
|
);
|
|
67
67
|
}
|
|
68
68
|
|
|
69
|
+
/** Consult media must exist on the interaction payload, not only as a stale resource id. */
|
|
70
|
+
function hasConsultMediaInInteraction(
|
|
71
|
+
interaction: TaskData['interaction'] | undefined,
|
|
72
|
+
consultMediaResourceId?: string
|
|
73
|
+
): boolean {
|
|
74
|
+
const media = interaction?.media ?? {};
|
|
75
|
+
const hasConsultLeg = Object.values(media).some((entry: any) => entry?.mType === 'consult');
|
|
76
|
+
|
|
77
|
+
if (hasConsultLeg) return true;
|
|
78
|
+
if (!consultMediaResourceId) return false;
|
|
79
|
+
|
|
80
|
+
return Boolean(media[consultMediaResourceId]);
|
|
81
|
+
}
|
|
82
|
+
|
|
69
83
|
function computeVoiceInteractionUIControls(
|
|
70
84
|
state: TaskState,
|
|
71
85
|
context: TaskContext,
|
|
@@ -78,8 +92,8 @@ function computeVoiceInteractionUIControls(
|
|
|
78
92
|
return getDefaultInteractionUIControls();
|
|
79
93
|
}
|
|
80
94
|
|
|
81
|
-
//
|
|
82
|
-
const taskData = context.taskData ??
|
|
95
|
+
// Prefer live task.data (fallback) over stale state-machine snapshot during multi-login sync.
|
|
96
|
+
const taskData = fallbackTaskData ?? context.taskData ?? null;
|
|
83
97
|
const interaction = taskData?.interaction;
|
|
84
98
|
const mainCallId = interaction?.mainInteractionId || taskData?.interactionId;
|
|
85
99
|
const isWebrtc = config.voiceVariant === VOICE_VARIANT.WEBRTC;
|
|
@@ -125,7 +139,6 @@ function computeVoiceInteractionUIControls(
|
|
|
125
139
|
// Backend sends destinationType as 'EP-DN'; SDK method uses 'entryPoint' — check both.
|
|
126
140
|
const isEpDnConsult =
|
|
127
141
|
consultDestinationType === 'entryPoint' || consultDestinationType === ('EP-DN' as any);
|
|
128
|
-
const isConsultDestinationReady = consultDestinationAgentJoined || isEpDnConsult;
|
|
129
142
|
|
|
130
143
|
const stateImpliesHeld = state === TaskState.HELD || state === TaskState.RESUME_INITIATING;
|
|
131
144
|
const stateImpliesConnected =
|
|
@@ -151,9 +164,32 @@ function computeVoiceInteractionUIControls(
|
|
|
151
164
|
const selfParticipant = selfAgentId ? interaction?.participants?.[selfAgentId] : null;
|
|
152
165
|
const selfInConsultCall =
|
|
153
166
|
Boolean(selfAgentId) && Boolean(consultMedia?.participants?.includes(selfAgentId as string));
|
|
154
|
-
const
|
|
167
|
+
const hasConsultMedia = hasConsultMediaInInteraction(
|
|
168
|
+
interaction,
|
|
169
|
+
taskData?.consultMediaResourceId
|
|
170
|
+
);
|
|
171
|
+
const isConsultEndedForSelf =
|
|
172
|
+
taskData?.type === 'AgentConsultEnded' ||
|
|
173
|
+
taskData?.type === 'AgentConsultFailed' ||
|
|
174
|
+
(selfParticipant?.consultState === 'consultCompleted' &&
|
|
175
|
+
!hasConsultMedia &&
|
|
176
|
+
taskData?.isConsulted === false);
|
|
177
|
+
const effectiveConsultInitiator = isConsultEndedForSelf ? false : consultInitiator;
|
|
178
|
+
const effectiveConsultCallHeld = isConsultEndedForSelf ? false : consultCallHeld;
|
|
179
|
+
const effectiveConsultFromConference = isConsultEndedForSelf ? false : consultFromConference;
|
|
180
|
+
const effectiveConsultDestinationAgentJoined = isConsultEndedForSelf
|
|
181
|
+
? false
|
|
182
|
+
: consultDestinationAgentJoined;
|
|
183
|
+
// After a consult ends for self, the backend stops sending consult media but the merged
|
|
184
|
+
// task.data can still carry the stale consult-media entry (reconcileData never deletes keys).
|
|
185
|
+
// Treat it as gone so post-consult main-leg controls (consult retry) are not blocked.
|
|
186
|
+
const effectiveHasConsultMedia = isConsultEndedForSelf ? false : hasConsultMedia;
|
|
187
|
+
const isConsultDestinationReady = effectiveConsultDestinationAgentJoined || isEpDnConsult;
|
|
188
|
+
const conferenceActive =
|
|
189
|
+
isConferencing || conferenceFromBackend || effectiveConsultFromConference;
|
|
155
190
|
// Treat consult initiator as "in conference" even if mainCall participant list lags while consulting.
|
|
156
|
-
const inConference =
|
|
191
|
+
const inConference =
|
|
192
|
+
conferenceActive && (isConferencing || selfInMainCall || effectiveConsultInitiator);
|
|
157
193
|
|
|
158
194
|
// Check if this is a consulted agent (must be after isConsulting is computed).
|
|
159
195
|
const isSoleAgentOnCall =
|
|
@@ -162,7 +198,7 @@ function computeVoiceInteractionUIControls(
|
|
|
162
198
|
inConference || isSoleAgentOnCall
|
|
163
199
|
? false
|
|
164
200
|
: getIsConsultedAgentForControls(taskData, context, isConsulting) ||
|
|
165
|
-
(!
|
|
201
|
+
(!effectiveConsultInitiator &&
|
|
166
202
|
(selfParticipant?.isConsulted === true ||
|
|
167
203
|
selfParticipant?.consultState === 'consulting'));
|
|
168
204
|
|
|
@@ -177,13 +213,10 @@ function computeVoiceInteractionUIControls(
|
|
|
177
213
|
|
|
178
214
|
// Consulted agents have limited controls until they're in conference or wrapup
|
|
179
215
|
// Use inConference (not isConferencing) so controls remain enabled after state downgrade
|
|
180
|
-
const hasFullControls = !isConsulted ||
|
|
216
|
+
const hasFullControls = !isConsulted || effectiveConsultInitiator || inConference || isWrappingUp;
|
|
181
217
|
const consultOwnedBySelf =
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
taskData?.consultMediaResourceId ||
|
|
185
|
-
Object.values(interaction?.media ?? {}).some((media: any) => media?.mType === 'consult')
|
|
186
|
-
);
|
|
218
|
+
effectiveConsultInitiator ||
|
|
219
|
+
(Boolean(selfAgentId) && taskData?.consultingAgentId === selfAgentId);
|
|
187
220
|
const selfConsultPendingOnConsultMedia =
|
|
188
221
|
selfParticipant?.consultState === 'consultInitiated' &&
|
|
189
222
|
!taskData?.isConsulted &&
|
|
@@ -207,17 +240,18 @@ function computeVoiceInteractionUIControls(
|
|
|
207
240
|
})
|
|
208
241
|
);
|
|
209
242
|
const isHydratedConferenceConsultPending =
|
|
210
|
-
inConference && selfConsultPendingOnConsultMedia && !
|
|
243
|
+
inConference && selfConsultPendingOnConsultMedia && !effectiveConsultDestinationAgentJoined;
|
|
211
244
|
const hasParallelConsultLeg =
|
|
245
|
+
!isConsultEndedForSelf &&
|
|
212
246
|
consultOwnedBySelf &&
|
|
213
247
|
!isConsulting &&
|
|
214
248
|
!isConsulted &&
|
|
215
|
-
(consultInProgress ||
|
|
216
|
-
const activeLegForConferenceConsult =
|
|
249
|
+
(consultInProgress || effectiveConsultCallHeld || hasConsultMedia);
|
|
250
|
+
const activeLegForConferenceConsult = effectiveConsultCallHeld ? 'main' : 'consult';
|
|
217
251
|
const isCurrentLegActive = currentLeg === activeLegForConferenceConsult;
|
|
218
252
|
const isConferenceConsultTransferContext =
|
|
219
|
-
inConference &&
|
|
220
|
-
const consultLegOnHold = isConsulting &&
|
|
253
|
+
inConference && effectiveConsultInitiator && hasConsultMedia && isConsultDestinationReady;
|
|
254
|
+
const consultLegOnHold = isConsulting && effectiveConsultCallHeld;
|
|
221
255
|
const callProcessingDetails = interaction?.callProcessingDetails as
|
|
222
256
|
| {conferenceHoldParticipant?: boolean | string}
|
|
223
257
|
| undefined;
|
|
@@ -280,6 +314,61 @@ function computeVoiceInteractionUIControls(
|
|
|
280
314
|
currentLeg === 'main' && inConference && consultInProgress && !selfOnConsultLeg;
|
|
281
315
|
const allowHeldMainLegControlsForNonInitiator =
|
|
282
316
|
showMainLegConferenceControlsDuringConsult && !isHydratedConferenceConsultPending;
|
|
317
|
+
const isConsultRequestedPhase =
|
|
318
|
+
isConsultPendingBeforeJoin &&
|
|
319
|
+
!isConsulted &&
|
|
320
|
+
(consultInitiator ||
|
|
321
|
+
(Boolean(selfAgentId) &&
|
|
322
|
+
selfParticipant?.consultState === 'consultInitiated' &&
|
|
323
|
+
taskData?.agentId === selfAgentId));
|
|
324
|
+
const isConsultUnansweredFailure =
|
|
325
|
+
currentLeg === 'main' &&
|
|
326
|
+
!inConference &&
|
|
327
|
+
!taskData?.isConsulted &&
|
|
328
|
+
!effectiveHasConsultMedia &&
|
|
329
|
+
isHeld &&
|
|
330
|
+
selfParticipant?.consultState === 'consultCompleted' &&
|
|
331
|
+
(taskData?.type === 'AgentConsultFailed' ||
|
|
332
|
+
taskData?.type === 'AgentConsultEnded' ||
|
|
333
|
+
isConsultEndedForSelf);
|
|
334
|
+
|
|
335
|
+
if (isConsultUnansweredFailure) {
|
|
336
|
+
const recordingControl =
|
|
337
|
+
recordingControlsAvailable && config.isRecordingEnabled && !inConference
|
|
338
|
+
? VISIBLE_ENABLED
|
|
339
|
+
: DISABLED;
|
|
340
|
+
|
|
341
|
+
return {
|
|
342
|
+
...getDefaultInteractionUIControls(),
|
|
343
|
+
hold: VISIBLE_ENABLED,
|
|
344
|
+
transfer: VISIBLE_ENABLED,
|
|
345
|
+
consult: VISIBLE_ENABLED,
|
|
346
|
+
recording: recordingControl,
|
|
347
|
+
end: VISIBLE_DISABLED,
|
|
348
|
+
};
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
if (isConsultRequestedPhase) {
|
|
352
|
+
if (currentLeg === 'main') {
|
|
353
|
+
return {
|
|
354
|
+
...getDefaultInteractionUIControls(),
|
|
355
|
+
transfer: VISIBLE_DISABLED,
|
|
356
|
+
conference: VISIBLE_DISABLED,
|
|
357
|
+
end: VISIBLE_DISABLED,
|
|
358
|
+
};
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
if (currentLeg === 'consult') {
|
|
362
|
+
return {
|
|
363
|
+
...getDefaultInteractionUIControls(),
|
|
364
|
+
endConsult: VISIBLE_ENABLED,
|
|
365
|
+
switch: VISIBLE_DISABLED,
|
|
366
|
+
transfer: VISIBLE_DISABLED,
|
|
367
|
+
transferConference: inConference ? VISIBLE_DISABLED : DISABLED,
|
|
368
|
+
mergeToConference: VISIBLE_DISABLED,
|
|
369
|
+
};
|
|
370
|
+
}
|
|
371
|
+
}
|
|
283
372
|
|
|
284
373
|
return {
|
|
285
374
|
// Accept/Decline: Voice tasks in offered state
|
|
@@ -300,7 +389,10 @@ function computeVoiceInteractionUIControls(
|
|
|
300
389
|
hold: (() => {
|
|
301
390
|
if (!hasFullControls) return DISABLED;
|
|
302
391
|
if (forceHeldPostConsultControls) return VISIBLE_ENABLED;
|
|
303
|
-
if (
|
|
392
|
+
if (
|
|
393
|
+
consultOwnedBySelf &&
|
|
394
|
+
(isConsulting || hasParallelConsultLeg || effectiveConsultCallHeld)
|
|
395
|
+
) {
|
|
304
396
|
return DISABLED;
|
|
305
397
|
}
|
|
306
398
|
if (hasParallelConsultLeg) return DISABLED;
|
|
@@ -319,7 +411,17 @@ function computeVoiceInteractionUIControls(
|
|
|
319
411
|
if (!isWebrtc) return DISABLED;
|
|
320
412
|
if (isWrappingUp) return DISABLED;
|
|
321
413
|
if (currentLeg === 'consult' && !selfInConsultCall) return DISABLED;
|
|
322
|
-
|
|
414
|
+
// The consulted agent has no separate consult leg; their main leg is the active call, so
|
|
415
|
+
// mute stays available while they consult (the heuristic below targets the initiator's
|
|
416
|
+
// inactive leg, not the consultee's only leg).
|
|
417
|
+
const isConsultedActiveMainLeg = isConsulted && currentLeg === 'main';
|
|
418
|
+
if (
|
|
419
|
+
(isConsulting || hasParallelConsultLeg) &&
|
|
420
|
+
!isCurrentLegActive &&
|
|
421
|
+
!isConsultedActiveMainLeg
|
|
422
|
+
) {
|
|
423
|
+
return VISIBLE_DISABLED;
|
|
424
|
+
}
|
|
323
425
|
if (isConsulting) return VISIBLE_ENABLED;
|
|
324
426
|
|
|
325
427
|
if (isConnected || isHeld || isConferencing) {
|
|
@@ -395,6 +497,18 @@ function computeVoiceInteractionUIControls(
|
|
|
395
497
|
consult: (() => {
|
|
396
498
|
const isConnectedOrHeld = state === TaskState.CONNECTED || state === TaskState.HELD;
|
|
397
499
|
|
|
500
|
+
if (
|
|
501
|
+
isHeld &&
|
|
502
|
+
!inConference &&
|
|
503
|
+
!effectiveHasConsultMedia &&
|
|
504
|
+
selfParticipant?.consultState === 'consultCompleted' &&
|
|
505
|
+
!taskData?.isConsulted
|
|
506
|
+
) {
|
|
507
|
+
const canRetryConsult = !maxParticipants && customerPresent && !otherAgentConsultInProgress;
|
|
508
|
+
|
|
509
|
+
return {isVisible: true, isEnabled: canRetryConsult};
|
|
510
|
+
}
|
|
511
|
+
|
|
398
512
|
if (inConference && nonOwnerPostConsultCompletedHeldMainLeg) return VISIBLE_DISABLED;
|
|
399
513
|
if (hasParallelConsultLeg) return DISABLED;
|
|
400
514
|
if (!hasFullControls || !(isConnectedOrHeld || inConference)) {
|
|
@@ -591,10 +705,21 @@ function getVoiceLegState(
|
|
|
591
705
|
};
|
|
592
706
|
}
|
|
593
707
|
|
|
594
|
-
const taskData = context.taskData ??
|
|
708
|
+
const taskData = fallbackTaskData ?? context.taskData ?? null;
|
|
595
709
|
const interaction = taskData?.interaction;
|
|
596
710
|
const mainCallId = interaction?.mainInteractionId || taskData?.interactionId;
|
|
597
711
|
const selfAgentId = config.agentId ?? taskData?.agentId;
|
|
712
|
+
const selfParticipant = selfAgentId ? interaction?.participants?.[selfAgentId] : null;
|
|
713
|
+
const hasConsultMedia = hasConsultMediaInInteraction(
|
|
714
|
+
interaction,
|
|
715
|
+
taskData?.consultMediaResourceId
|
|
716
|
+
);
|
|
717
|
+
const isConsultEndedForSelf =
|
|
718
|
+
taskData?.type === 'AgentConsultEnded' ||
|
|
719
|
+
taskData?.type === 'AgentConsultFailed' ||
|
|
720
|
+
(selfParticipant?.consultState === 'consultCompleted' &&
|
|
721
|
+
!hasConsultMedia &&
|
|
722
|
+
taskData?.isConsulted === false);
|
|
598
723
|
const consultInProgress = getIsConsultInProgressForConferenceControls(
|
|
599
724
|
interaction,
|
|
600
725
|
mainCallId,
|
|
@@ -607,30 +732,48 @@ function getVoiceLegState(
|
|
|
607
732
|
const consultOwnedBySelf =
|
|
608
733
|
context.consultInitiator ||
|
|
609
734
|
(Boolean(selfAgentId) && taskData?.consultingAgentId === selfAgentId);
|
|
610
|
-
const hasConsultMedia = Boolean(
|
|
611
|
-
taskData?.consultMediaResourceId ||
|
|
612
|
-
Object.values(interaction?.media ?? {}).some((media: any) => media?.mType === 'consult')
|
|
613
|
-
);
|
|
614
|
-
const selfParticipant = selfAgentId ? interaction?.participants?.[selfAgentId] : null;
|
|
615
735
|
const selfConsultPendingOnConsultMedia =
|
|
616
736
|
selfParticipant?.consultState === 'consultInitiated' &&
|
|
617
737
|
!taskData?.isConsulted &&
|
|
618
738
|
hasConsultMedia;
|
|
619
739
|
const selfConsultingOnConsultMedia =
|
|
620
740
|
selfParticipant?.consultState === 'consulting' && hasConsultMedia;
|
|
741
|
+
const isConsultUnansweredFailure =
|
|
742
|
+
!taskData?.isConsulted &&
|
|
743
|
+
!hasConsultMedia &&
|
|
744
|
+
selfParticipant?.consultState === 'consultCompleted' &&
|
|
745
|
+
Boolean(mainCallId && taskData?.interaction?.media?.[mainCallId]?.isHold === true) &&
|
|
746
|
+
taskData?.interaction?.state !== 'conference' &&
|
|
747
|
+
!getIsConferenceInProgress(taskData);
|
|
621
748
|
const hasConsultLeg = Boolean(
|
|
622
|
-
!
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
749
|
+
!isConsultEndedForSelf &&
|
|
750
|
+
!interaction?.isTerminated &&
|
|
751
|
+
!isConsultUnansweredFailure &&
|
|
752
|
+
(consultOwnedBySelf || selfConsultingOnConsultMedia || selfConsultPendingOnConsultMedia) &&
|
|
626
753
|
(consultInProgress || isConsultingState || context.consultCallHeld || hasConsultMedia)
|
|
627
754
|
);
|
|
628
755
|
|
|
629
756
|
if (!hasConsultLeg) {
|
|
757
|
+
let mainState = currentState;
|
|
758
|
+
if (isConsultEndedForSelf && taskData?.interaction) {
|
|
759
|
+
const resolvedMainId = taskData.interaction.mainInteractionId || taskData.interactionId;
|
|
760
|
+
const isMainHeld = Boolean(
|
|
761
|
+
resolvedMainId && taskData.interaction.media?.[resolvedMainId]?.isHold === true
|
|
762
|
+
);
|
|
763
|
+
|
|
764
|
+
if (taskData.interaction.state === 'conference' || getIsConferenceInProgress(taskData)) {
|
|
765
|
+
mainState = TaskState.CONFERENCING;
|
|
766
|
+
} else if (isMainHeld || taskData.interaction.state === 'hold') {
|
|
767
|
+
mainState = TaskState.HELD;
|
|
768
|
+
} else {
|
|
769
|
+
mainState = TaskState.CONNECTED;
|
|
770
|
+
}
|
|
771
|
+
}
|
|
772
|
+
|
|
630
773
|
return {
|
|
631
774
|
hasConsultLeg: false,
|
|
632
775
|
activeLeg: 'main',
|
|
633
|
-
mainState
|
|
776
|
+
mainState,
|
|
634
777
|
consultState: TaskState.CONSULTING,
|
|
635
778
|
};
|
|
636
779
|
}
|
package/src/types.ts
CHANGED
|
@@ -846,6 +846,27 @@ export type UpdateDeviceTypeResponse = Agent.DeviceTypeUpdateSuccess | Error;
|
|
|
846
846
|
*/
|
|
847
847
|
export type TranscriptAction = 'START' | 'STOP';
|
|
848
848
|
|
|
849
|
+
/**
|
|
850
|
+
* Parameters used to request an AI Assistant suggested response.
|
|
851
|
+
* @public
|
|
852
|
+
* @example
|
|
853
|
+
* const params: SuggestedResponseParams = {
|
|
854
|
+
* interactionId: 'interaction-123',
|
|
855
|
+
* actionTimeStamp: Date.now(),
|
|
856
|
+
* context: 'Need help with credit card payment due date',
|
|
857
|
+
* };
|
|
858
|
+
*/
|
|
859
|
+
export type SuggestedResponseParams = {
|
|
860
|
+
/** Agent identifier */
|
|
861
|
+
agentId: string;
|
|
862
|
+
/** Interaction identifier for which suggestion should be generated */
|
|
863
|
+
interactionId: string;
|
|
864
|
+
/** Optional additional context that should refine the suggestion */
|
|
865
|
+
context?: string;
|
|
866
|
+
/** Optional language code for suggestions (for example, 'en'). Defaults to 'en'. */
|
|
867
|
+
languageCode?: string;
|
|
868
|
+
};
|
|
869
|
+
|
|
849
870
|
/**
|
|
850
871
|
* Supported AI Assistant event categories.
|
|
851
872
|
* @public
|
|
@@ -879,6 +900,10 @@ export type AIAssistantEventType = Enum<typeof AIAssistantEventType>;
|
|
|
879
900
|
export const AIAssistantEventName = {
|
|
880
901
|
/** Request transcript streaming for an interaction */
|
|
881
902
|
GET_TRANSCRIPTS: 'GET_TRANSCRIPTS',
|
|
903
|
+
/** Request a suggested response for an interaction */
|
|
904
|
+
GET_SUGGESTIONS: 'GET_SUGGESTIONS',
|
|
905
|
+
/** Add extra context to refine a suggested response */
|
|
906
|
+
ADD_SUGGESTIONS_EXTRA_CONTEXT: 'ADD_SUGGESTIONS_EXTRA_CONTEXT',
|
|
882
907
|
/** Request mid-call summary generation */
|
|
883
908
|
GET_MID_CALL_SUMMARY: 'GET_MID_CALL_SUMMARY',
|
|
884
909
|
/** Request post-call summary generation */
|
package/test/unit/spec/cc.ts
CHANGED
|
@@ -110,8 +110,10 @@ describe('webex.cc', () => {
|
|
|
110
110
|
|
|
111
111
|
mockApiAIAssistant = {
|
|
112
112
|
sendEvent: jest.fn(),
|
|
113
|
+
getSuggestedResponse: jest.fn(),
|
|
113
114
|
fetchHistoricTranscripts: jest.fn(),
|
|
114
115
|
setAIFeatureFlags: jest.fn(),
|
|
116
|
+
setAgentId: jest.fn(),
|
|
115
117
|
};
|
|
116
118
|
|
|
117
119
|
// Mock Services instance
|
|
@@ -57,23 +57,18 @@ describe('ApiAIAssistant', () => {
|
|
|
57
57
|
'START'
|
|
58
58
|
);
|
|
59
59
|
|
|
60
|
-
expect(mockWebex.request).
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
action: 'START',
|
|
73
|
-
}),
|
|
74
|
-
},
|
|
75
|
-
},
|
|
76
|
-
});
|
|
60
|
+
expect(mockWebex.request).toHaveBeenCalledTimes(1);
|
|
61
|
+
const requestArgs = (mockWebex.request as jest.Mock).mock.calls[0][0];
|
|
62
|
+
|
|
63
|
+
expect(requestArgs.uri).toBe('https://api-ai-assistant.produs1.ciscoccservice.com/event');
|
|
64
|
+
expect(requestArgs.method).toBe(HTTP_METHODS.POST);
|
|
65
|
+
expect(requestArgs.addAuthHeader).toBe(true);
|
|
66
|
+
expect(requestArgs.body.agentId).toBe('test-agent-id');
|
|
67
|
+
expect(requestArgs.body.orgId).toBe('test-org-id');
|
|
68
|
+
expect(requestArgs.body.eventType).toBe('CUSTOM_EVENT');
|
|
69
|
+
expect(requestArgs.body.eventName).toBe('GET_TRANSCRIPTS');
|
|
70
|
+
expect(requestArgs.body.eventDetails.data.interactionId).toBe('interaction-1');
|
|
71
|
+
expect(requestArgs.body.eventDetails.data.action).toBe('START');
|
|
77
72
|
expect(result).toEqual({ok: true});
|
|
78
73
|
});
|
|
79
74
|
|
|
@@ -97,6 +92,83 @@ describe('ApiAIAssistant', () => {
|
|
|
97
92
|
expect(result).toEqual(responseBody as any);
|
|
98
93
|
});
|
|
99
94
|
|
|
95
|
+
it('should request suggested response without extra context using sendEvent', async () => {
|
|
96
|
+
const sendEventSpy = jest.spyOn(apiAIAssistant, 'sendEvent').mockResolvedValue({ok: true});
|
|
97
|
+
apiAIAssistant.setAIFeatureFlags({suggestedResponses: {enable: true}} as any);
|
|
98
|
+
|
|
99
|
+
const result = await apiAIAssistant.getSuggestedResponse({
|
|
100
|
+
agentId: 'test-agent-id',
|
|
101
|
+
interactionId: 'interaction-1',
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
expect(sendEventSpy).toHaveBeenCalledTimes(1);
|
|
105
|
+
const [agentId, interactionId, eventType, eventName, action, context, languageCode, trackingId] =
|
|
106
|
+
sendEventSpy.mock.calls[0];
|
|
107
|
+
|
|
108
|
+
expect(agentId).toBe('test-agent-id');
|
|
109
|
+
expect(interactionId).toBe('interaction-1');
|
|
110
|
+
expect(eventType).toBe('CUSTOM_EVENT');
|
|
111
|
+
expect(eventName).toBe('GET_SUGGESTIONS');
|
|
112
|
+
expect(action).toBeUndefined();
|
|
113
|
+
expect(context).toBeUndefined();
|
|
114
|
+
expect(languageCode).toBe('en');
|
|
115
|
+
expect(typeof trackingId).toBe('string');
|
|
116
|
+
expect(trackingId.startsWith('WX_CC_SDK_')).toBe(true);
|
|
117
|
+
expect(result).toEqual({ok: true});
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it('should request suggested response with extra context using sendEvent', async () => {
|
|
121
|
+
const sendEventSpy = jest.spyOn(apiAIAssistant, 'sendEvent').mockResolvedValue({ok: true});
|
|
122
|
+
apiAIAssistant.setAIFeatureFlags({suggestedResponses: {enable: true}} as any);
|
|
123
|
+
|
|
124
|
+
const result = await apiAIAssistant.getSuggestedResponse({
|
|
125
|
+
agentId: 'test-agent-id',
|
|
126
|
+
interactionId: 'interaction-1',
|
|
127
|
+
context: 'Need assistance with credit card payment due date',
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
expect(sendEventSpy).toHaveBeenCalledTimes(1);
|
|
131
|
+
const [agentId, interactionId, eventType, eventName, action, context, languageCode, trackingId] =
|
|
132
|
+
sendEventSpy.mock.calls[0];
|
|
133
|
+
|
|
134
|
+
expect(agentId).toBe('test-agent-id');
|
|
135
|
+
expect(interactionId).toBe('interaction-1');
|
|
136
|
+
expect(eventType).toBe('CUSTOM_EVENT');
|
|
137
|
+
expect(eventName).toBe('ADD_SUGGESTIONS_EXTRA_CONTEXT');
|
|
138
|
+
expect(action).toBeUndefined();
|
|
139
|
+
expect(context).toBe('Need assistance with credit card payment due date');
|
|
140
|
+
expect(languageCode).toBe('en');
|
|
141
|
+
expect(typeof trackingId).toBe('string');
|
|
142
|
+
expect(trackingId.startsWith('WX_CC_SDK_')).toBe(true);
|
|
143
|
+
expect(result).toEqual({ok: true});
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it('should treat whitespace-only context as GET_SUGGESTIONS', async () => {
|
|
147
|
+
const sendEventSpy = jest.spyOn(apiAIAssistant, 'sendEvent').mockResolvedValue({ok: true});
|
|
148
|
+
apiAIAssistant.setAIFeatureFlags({suggestedResponses: {enable: true}} as any);
|
|
149
|
+
|
|
150
|
+
const result = await apiAIAssistant.getSuggestedResponse({
|
|
151
|
+
agentId: 'test-agent-id',
|
|
152
|
+
interactionId: 'interaction-1',
|
|
153
|
+
context: ' ',
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
expect(sendEventSpy).toHaveBeenCalledTimes(1);
|
|
157
|
+
const [agentId, interactionId, eventType, eventName, action, context, languageCode, trackingId] =
|
|
158
|
+
sendEventSpy.mock.calls[0];
|
|
159
|
+
|
|
160
|
+
expect(agentId).toBe('test-agent-id');
|
|
161
|
+
expect(interactionId).toBe('interaction-1');
|
|
162
|
+
expect(eventType).toBe('CUSTOM_EVENT');
|
|
163
|
+
expect(eventName).toBe('GET_SUGGESTIONS');
|
|
164
|
+
expect(action).toBeUndefined();
|
|
165
|
+
expect(context).toBe('');
|
|
166
|
+
expect(languageCode).toBe('en');
|
|
167
|
+
expect(typeof trackingId).toBe('string');
|
|
168
|
+
expect(trackingId.startsWith('WX_CC_SDK_')).toBe(true);
|
|
169
|
+
expect(result).toEqual({ok: true});
|
|
170
|
+
});
|
|
171
|
+
|
|
100
172
|
it('should fail when base URL mapping is not available', async () => {
|
|
101
173
|
(mockWebex.internal.services.get as jest.Mock).mockReturnValue('https://unknown-host.invalid');
|
|
102
174
|
|
|
@@ -129,4 +201,20 @@ describe('ApiAIAssistant', () => {
|
|
|
129
201
|
|
|
130
202
|
expect(errorMessage).toBe('Error while performing fetchHistoricTranscripts');
|
|
131
203
|
});
|
|
204
|
+
|
|
205
|
+
it('should fail when suggested responses feature is disabled', async () => {
|
|
206
|
+
apiAIAssistant.setAIFeatureFlags({suggestedResponses: {enable: false}} as any);
|
|
207
|
+
let errorMessage = '';
|
|
208
|
+
|
|
209
|
+
try {
|
|
210
|
+
await apiAIAssistant.getSuggestedResponse({
|
|
211
|
+
agentId: 'test-agent-id',
|
|
212
|
+
interactionId: 'interaction-1',
|
|
213
|
+
});
|
|
214
|
+
} catch (error) {
|
|
215
|
+
errorMessage = (error as Error)?.message || '';
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
expect(errorMessage).toBe('Error while performing getSuggestedResponse');
|
|
219
|
+
});
|
|
132
220
|
});
|
|
@@ -89,6 +89,67 @@ describe('Task (base class)', () => {
|
|
|
89
89
|
expect((task.data as any).foo).toBeUndefined();
|
|
90
90
|
});
|
|
91
91
|
|
|
92
|
+
it('drops stale interaction.media/participants entries the backend no longer sends (merge keeps other fields)', () => {
|
|
93
|
+
const withConsult = createTaskData({
|
|
94
|
+
interaction: {
|
|
95
|
+
callAssociatedData: {a: {value: 'keep-me'}},
|
|
96
|
+
media: {
|
|
97
|
+
main: {mediaResourceId: 'main', mType: 'mainCall', isHold: true},
|
|
98
|
+
consult: {mediaResourceId: 'consult', mType: 'consult', isHold: false},
|
|
99
|
+
},
|
|
100
|
+
participants: {
|
|
101
|
+
'customer-1': {id: 'customer-1', pType: 'Customer'},
|
|
102
|
+
'agent-1': {id: 'agent-1', pType: 'Agent'},
|
|
103
|
+
'agent-2': {id: 'agent-2', pType: 'Agent', consultState: 'consulting'},
|
|
104
|
+
},
|
|
105
|
+
} as any,
|
|
106
|
+
}) as unknown as TaskData;
|
|
107
|
+
task.updateTaskData(withConsult, true);
|
|
108
|
+
|
|
109
|
+
// Backend resume snapshot: consult leg gone (only main media + main participants).
|
|
110
|
+
const resumeData = createTaskData({
|
|
111
|
+
interaction: {
|
|
112
|
+
media: {main: {mediaResourceId: 'main', mType: 'mainCall', isHold: false}},
|
|
113
|
+
participants: {
|
|
114
|
+
'customer-1': {id: 'customer-1', pType: 'Customer'},
|
|
115
|
+
'agent-1': {id: 'agent-1', pType: 'Agent', consultState: null},
|
|
116
|
+
},
|
|
117
|
+
} as any,
|
|
118
|
+
}) as unknown as TaskData;
|
|
119
|
+
task.updateTaskData(resumeData);
|
|
120
|
+
|
|
121
|
+
const interaction = (task.data as any).interaction;
|
|
122
|
+
// Stale consult media + consultee participant (absent from the resume snapshot) are removed.
|
|
123
|
+
expect(interaction.media.consult).toBeUndefined();
|
|
124
|
+
expect(interaction.participants['agent-2']).toBeUndefined();
|
|
125
|
+
// Entries still present in the incoming snapshot survive and reflect the new values.
|
|
126
|
+
expect(interaction.media.main).toBeDefined();
|
|
127
|
+
expect(interaction.media.main.isHold).toBe(false);
|
|
128
|
+
expect(interaction.participants['agent-1']).toBeDefined();
|
|
129
|
+
expect(interaction.participants['customer-1']).toBeDefined();
|
|
130
|
+
// Unrelated merge-able fields (CAD) are preserved.
|
|
131
|
+
expect(interaction.callAssociatedData.a.value).toBe('keep-me');
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it('does not touch interaction maps when the incoming payload omits them', () => {
|
|
135
|
+
const withConsult = createTaskData({
|
|
136
|
+
interaction: {
|
|
137
|
+
media: {main: {mediaResourceId: 'main'}, consult: {mediaResourceId: 'consult'}},
|
|
138
|
+
participants: {'agent-1': {id: 'agent-1'}, 'agent-2': {id: 'agent-2'}},
|
|
139
|
+
} as any,
|
|
140
|
+
}) as unknown as TaskData;
|
|
141
|
+
task.updateTaskData(withConsult, true);
|
|
142
|
+
|
|
143
|
+
// A partial update with no interaction maps must not prune existing media/participants.
|
|
144
|
+
task.updateTaskData({foo: 'changed'} as unknown as TaskData);
|
|
145
|
+
|
|
146
|
+
const interaction = (task.data as any).interaction;
|
|
147
|
+
expect(interaction.media.main).toBeDefined();
|
|
148
|
+
expect(interaction.media.consult).toBeDefined();
|
|
149
|
+
expect(interaction.participants['agent-1']).toBeDefined();
|
|
150
|
+
expect(interaction.participants['agent-2']).toBeDefined();
|
|
151
|
+
});
|
|
152
|
+
|
|
92
153
|
it('getUIControls returns default controls shape for idle voice task', () => {
|
|
93
154
|
const controls = task.uiControls;
|
|
94
155
|
const mainControls = controls.main;
|
|
@@ -253,6 +253,19 @@ describe('TaskManager', () => {
|
|
|
253
253
|
});
|
|
254
254
|
|
|
255
255
|
it('should invoke sendEvent for configured start/stop backend events', () => {
|
|
256
|
+
taskManager.setConfigFlags({
|
|
257
|
+
isEndTaskEnabled: true,
|
|
258
|
+
isEndConsultEnabled: true,
|
|
259
|
+
webRtcEnabled: true,
|
|
260
|
+
autoWrapup: false,
|
|
261
|
+
aiFeature: {
|
|
262
|
+
id: 'ai-feature-1',
|
|
263
|
+
realtimeTranscripts: {
|
|
264
|
+
enable: true,
|
|
265
|
+
},
|
|
266
|
+
},
|
|
267
|
+
});
|
|
268
|
+
|
|
256
269
|
const interactionId = taskId;
|
|
257
270
|
const message = (type: CC_EVENTS) =>
|
|
258
271
|
JSON.stringify({
|
|
@@ -316,6 +329,35 @@ describe('TaskManager', () => {
|
|
|
316
329
|
expect(mockApiAIAssistant.sendEvent).not.toHaveBeenCalled();
|
|
317
330
|
});
|
|
318
331
|
|
|
332
|
+
it('should not invoke sendEvent when realtime transcripts config is missing', () => {
|
|
333
|
+
taskManager.setConfigFlags({
|
|
334
|
+
isEndTaskEnabled: true,
|
|
335
|
+
isEndConsultEnabled: true,
|
|
336
|
+
webRtcEnabled: true,
|
|
337
|
+
autoWrapup: false,
|
|
338
|
+
aiFeature: {
|
|
339
|
+
id: 'ai-feature-1',
|
|
340
|
+
suggestedResponses: {
|
|
341
|
+
enable: true,
|
|
342
|
+
},
|
|
343
|
+
},
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
const message = (type: CC_EVENTS) =>
|
|
347
|
+
JSON.stringify({
|
|
348
|
+
data: {
|
|
349
|
+
...taskDataMock,
|
|
350
|
+
interactionId: taskId,
|
|
351
|
+
type,
|
|
352
|
+
},
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
webSocketManagerMock.emit('message', message(CC_EVENTS.AGENT_CONTACT_ASSIGNED));
|
|
356
|
+
webSocketManagerMock.emit('message', message(CC_EVENTS.AGENT_CONSULTING));
|
|
357
|
+
|
|
358
|
+
expect(mockApiAIAssistant.sendEvent).not.toHaveBeenCalled();
|
|
359
|
+
});
|
|
360
|
+
|
|
319
361
|
it('should emit REAL_TIME_TRANSCRIPTION from RTD websocket payload', () => {
|
|
320
362
|
const task = taskManager.getTask(taskId);
|
|
321
363
|
const taskEmitSpy = jest.spyOn(task, 'emit');
|