@webex/contact-center 3.12.0-next.147 → 3.12.0-next.149
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 +4 -2
- package/ai-docs/CONTRACTS.md +2 -0
- package/dist/cc.js +3 -1
- package/dist/cc.js.map +1 -1
- package/dist/constants.js +10 -1
- package/dist/constants.js.map +1 -1
- package/dist/index.js +9 -2
- package/dist/index.js.map +1 -1
- package/dist/metrics/constants.js +9 -0
- package/dist/metrics/constants.js.map +1 -1
- package/dist/services/ApiAiAssistant.js +87 -10
- package/dist/services/ApiAiAssistant.js.map +1 -1
- package/dist/services/config/types.js +10 -4
- package/dist/services/config/types.js.map +1 -1
- package/dist/services/core/types.js.map +1 -1
- package/dist/services/task/Task.js +202 -1
- package/dist/services/task/Task.js.map +1 -1
- package/dist/services/task/TaskFactory.js +4 -4
- package/dist/services/task/TaskFactory.js.map +1 -1
- package/dist/services/task/TaskManager.js +113 -26
- package/dist/services/task/TaskManager.js.map +1 -1
- package/dist/services/task/TaskUtils.js +11 -1
- package/dist/services/task/TaskUtils.js.map +1 -1
- package/dist/services/task/constants.js +11 -2
- package/dist/services/task/constants.js.map +1 -1
- package/dist/services/task/digital/Digital.js +2 -2
- package/dist/services/task/digital/Digital.js.map +1 -1
- package/dist/services/task/types.js +31 -0
- package/dist/services/task/types.js.map +1 -1
- package/dist/services/task/voice/Voice.js +2 -2
- package/dist/services/task/voice/Voice.js.map +1 -1
- package/dist/services/task/voice/WebRTC.js +2 -2
- package/dist/services/task/voice/WebRTC.js.map +1 -1
- package/dist/types/constants.d.ts +9 -0
- package/dist/types/index.d.ts +4 -5
- package/dist/types/metrics/constants.d.ts +8 -0
- package/dist/types/services/ApiAiAssistant.d.ts +4 -2
- package/dist/types/services/config/types.d.ts +17 -7
- package/dist/types/services/task/Task.d.ts +15 -4
- package/dist/types/services/task/TaskFactory.d.ts +3 -2
- package/dist/types/services/task/TaskUtils.d.ts +6 -0
- package/dist/types/services/task/constants.d.ts +9 -0
- package/dist/types/services/task/digital/Digital.d.ts +3 -2
- package/dist/types/services/task/types.d.ts +109 -1
- package/dist/types/services/task/voice/Voice.d.ts +3 -2
- package/dist/types/services/task/voice/WebRTC.d.ts +3 -2
- package/dist/types/types.d.ts +10 -4
- package/dist/types.js +8 -4
- package/dist/types.js.map +1 -1
- package/dist/webex.js +1 -1
- package/package.json +3 -3
- package/src/cc.ts +4 -0
- package/src/constants.ts +12 -0
- package/src/index.ts +10 -2
- package/src/metrics/ai-docs/metrics-spec.md +12 -4
- package/src/metrics/constants.ts +10 -0
- package/src/services/ApiAiAssistant.ts +120 -12
- package/src/services/config/types.ts +10 -4
- package/src/services/core/types.ts +31 -1
- package/src/services/task/Task.ts +308 -5
- package/src/services/task/TaskFactory.ts +26 -5
- package/src/services/task/TaskManager.ts +148 -29
- package/src/services/task/TaskUtils.ts +13 -0
- package/src/services/task/ai-docs/task-spec.md +30 -5
- package/src/services/task/constants.ts +10 -0
- package/src/services/task/digital/Digital.ts +8 -3
- package/src/services/task/types.ts +139 -0
- package/src/services/task/voice/Voice.ts +8 -3
- package/src/services/task/voice/WebRTC.ts +6 -3
- package/src/types.ts +10 -4
- package/test/unit/fixtures/aiSummaryTestUtils.ts +40 -0
- package/test/unit/spec/cc.ts +1314 -32
- package/test/unit/spec/index.ts +881 -0
- package/test/unit/spec/services/ApiAiAssistant.ts +122 -6
- package/test/unit/spec/services/core/WebexRequest.ts +5 -6
- package/test/unit/spec/services/task/Task.ts +1988 -10
- package/test/unit/spec/services/task/TaskFactory.ts +17 -0
- package/test/unit/spec/services/task/TaskManager.ts +2327 -180
- package/test/unit/spec/services/task/TaskUtils.ts +72 -0
- package/test/unit/spec/services/task/voice/Voice.ts +353 -18
- package/umd/contact-center.min.js +2 -2
- package/umd/contact-center.min.js.map +1 -1
|
@@ -1,19 +1,195 @@
|
|
|
1
1
|
import 'jsdom-global/register';
|
|
2
2
|
import EventEmitter from 'events';
|
|
3
|
-
import {LoginOption, WebexSDK} from '../../../../../src/types';
|
|
3
|
+
import {ConfigFlags, LoginOption, WebexSDK} from '../../../../../src/types';
|
|
4
4
|
import {CALL_EVENT_KEYS, CallingClientConfig, LINE_EVENTS} from '@webex/calling';
|
|
5
|
-
import {
|
|
5
|
+
import {
|
|
6
|
+
CC_AGENT_EVENTS,
|
|
7
|
+
CC_TASK_EVENTS,
|
|
8
|
+
CC_EVENTS,
|
|
9
|
+
WrapupData,
|
|
10
|
+
} from '../../../../../src/services/config/types';
|
|
6
11
|
import TaskManager from '../../../../../src/services/task/TaskManager';
|
|
7
|
-
import * as contact from '../../../../../src/services/task/contact';
|
|
8
12
|
import Task from '../../../../../src/services/task/Task';
|
|
13
|
+
import * as contact from '../../../../../src/services/task/contact';
|
|
9
14
|
import {TASK_EVENTS} from '../../../../../src/services/task/types';
|
|
10
15
|
import {TaskEvent, TaskState} from '../../../../../src/services/task/state-machine';
|
|
11
16
|
import WebRTC from '../../../../../src/services/task/voice/WebRTC';
|
|
12
|
-
import {Profile} from '../../../../../src/services/config/types';
|
|
13
17
|
import WebCallingService from '../../../../../src/services/WebCallingService';
|
|
14
18
|
import config from '../../../../../src/config';
|
|
15
|
-
import {CC_TASK_EVENTS} from '../../../../../src/services/config/types';
|
|
16
19
|
import TaskFactory from '../../../../../src/services/task/TaskFactory';
|
|
20
|
+
import {
|
|
21
|
+
AI_SUMMARY_DURATION_MS,
|
|
22
|
+
AI_SUMMARY_REQUEST_CANCELLED,
|
|
23
|
+
METHODS,
|
|
24
|
+
} from '../../../../../src/services/task/constants';
|
|
25
|
+
import {AI_SUMMARY_ERROR_CODES, TASK_MANAGER_FILE} from '../../../../../src/constants';
|
|
26
|
+
import {
|
|
27
|
+
checkParticipantNotInInteraction,
|
|
28
|
+
getConferenceParticipantsCount,
|
|
29
|
+
getConsultMediaResourceId,
|
|
30
|
+
getIsConsultInProgressForConferenceControls,
|
|
31
|
+
getIsConsultedAgentForControls,
|
|
32
|
+
getIsCustomerInCall,
|
|
33
|
+
getServerHoldStateForControls,
|
|
34
|
+
hasAgentInitiatedOutdial,
|
|
35
|
+
isAutoAnswerEnabled,
|
|
36
|
+
isCampaignPreviewReservation,
|
|
37
|
+
isCampaignPreviewTask,
|
|
38
|
+
isDigitalOutbound,
|
|
39
|
+
isParticipantInMainInteraction,
|
|
40
|
+
isPrimary,
|
|
41
|
+
isSecondaryAgent,
|
|
42
|
+
isSecondaryEpDnAgent,
|
|
43
|
+
isWebRTCCall,
|
|
44
|
+
shouldAutoAnswerTask,
|
|
45
|
+
} from '../../../../../src/services/task/TaskUtils';
|
|
46
|
+
|
|
47
|
+
jest.mock('../../../../../src/logger-proxy', () => ({
|
|
48
|
+
__esModule: true,
|
|
49
|
+
default: {
|
|
50
|
+
error: jest.fn(),
|
|
51
|
+
info: jest.fn(),
|
|
52
|
+
log: jest.fn(),
|
|
53
|
+
warn: jest.fn(),
|
|
54
|
+
},
|
|
55
|
+
}));
|
|
56
|
+
|
|
57
|
+
jest.mock('../../../../../src/services/task/contact', () => ({
|
|
58
|
+
__esModule: true,
|
|
59
|
+
default: jest.fn(),
|
|
60
|
+
}));
|
|
61
|
+
|
|
62
|
+
jest.mock('../../../../../src/services/WebCallingService', () => {
|
|
63
|
+
const EventEmitter = require('events');
|
|
64
|
+
|
|
65
|
+
return {
|
|
66
|
+
__esModule: true,
|
|
67
|
+
default: class MockWebCallingService extends EventEmitter {
|
|
68
|
+
loginOption?: string;
|
|
69
|
+
call?: unknown;
|
|
70
|
+
mapCallToTask = jest.fn();
|
|
71
|
+
cleanUpCall = jest.fn(() => {
|
|
72
|
+
(this.call as {off?: (event: string, listener: () => void) => void})?.off?.(
|
|
73
|
+
'remote_media',
|
|
74
|
+
() => undefined
|
|
75
|
+
);
|
|
76
|
+
(this.call as {off?: (event: string, listener: () => void) => void})?.off?.(
|
|
77
|
+
'disconnect',
|
|
78
|
+
() => undefined
|
|
79
|
+
);
|
|
80
|
+
});
|
|
81
|
+
},
|
|
82
|
+
};
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
jest.mock('../../../../../src/services/task/TaskFactory', () => ({
|
|
86
|
+
__esModule: true,
|
|
87
|
+
default: {
|
|
88
|
+
createTask: jest.fn(),
|
|
89
|
+
},
|
|
90
|
+
}));
|
|
91
|
+
|
|
92
|
+
jest.mock('../../../../../src/services/task/state-machine', () => {
|
|
93
|
+
const TaskEvent = {
|
|
94
|
+
TASK_INCOMING: 'TASK_INCOMING',
|
|
95
|
+
TASK_OFFERED: 'TASK_OFFERED',
|
|
96
|
+
OFFER_CONSULT: 'OFFER_CONSULT',
|
|
97
|
+
HYDRATE: 'HYDRATE',
|
|
98
|
+
CONTACT_UPDATED: 'CONTACT_UPDATED',
|
|
99
|
+
CONTACT_OWNER_CHANGED: 'CONTACT_OWNER_CHANGED',
|
|
100
|
+
ASSIGN: 'ASSIGN',
|
|
101
|
+
HOLD_SUCCESS: 'HOLD_SUCCESS',
|
|
102
|
+
UNHOLD_SUCCESS: 'UNHOLD_SUCCESS',
|
|
103
|
+
CONSULT_CREATED: 'CONSULT_CREATED',
|
|
104
|
+
CONSULTING_ACTIVE: 'CONSULTING_ACTIVE',
|
|
105
|
+
CONSULT_END: 'CONSULT_END',
|
|
106
|
+
CONSULT_FAILED: 'CONSULT_FAILED',
|
|
107
|
+
CTQ_CANCEL: 'CTQ_CANCEL',
|
|
108
|
+
CTQ_CANCEL_FAILED: 'CTQ_CANCEL_FAILED',
|
|
109
|
+
CONFERENCE_START: 'CONFERENCE_START',
|
|
110
|
+
CONFERENCE_FAILED: 'CONFERENCE_FAILED',
|
|
111
|
+
CONFERENCE_END: 'CONFERENCE_END',
|
|
112
|
+
PARTICIPANT_LEAVE: 'PARTICIPANT_LEAVE',
|
|
113
|
+
TRANSFER_SUCCESS: 'TRANSFER_SUCCESS',
|
|
114
|
+
TRANSFER_FAILED: 'TRANSFER_FAILED',
|
|
115
|
+
TRANSFER_CONFERENCE_SUCCESS: 'TRANSFER_CONFERENCE_SUCCESS',
|
|
116
|
+
TASK_WRAPUP: 'TASK_WRAPUP',
|
|
117
|
+
CONTACT_ENDED: 'CONTACT_ENDED',
|
|
118
|
+
WRAPUP_COMPLETE: 'WRAPUP_COMPLETE',
|
|
119
|
+
ASSIGN_FAILED: 'ASSIGN_FAILED',
|
|
120
|
+
INVITE_FAILED: 'INVITE_FAILED',
|
|
121
|
+
RONA: 'RONA',
|
|
122
|
+
OUTBOUND_FAILED: 'OUTBOUND_FAILED',
|
|
123
|
+
PAUSE_RECORDING: 'PAUSE_RECORDING',
|
|
124
|
+
RECORDING_STARTED: 'RECORDING_STARTED',
|
|
125
|
+
RESUME_RECORDING: 'RESUME_RECORDING',
|
|
126
|
+
CAMPAIGN_PREVIEW_ACCEPT_FAILED: 'CAMPAIGN_PREVIEW_ACCEPT_FAILED',
|
|
127
|
+
CAMPAIGN_PREVIEW_SKIP_FAILED: 'CAMPAIGN_PREVIEW_SKIP_FAILED',
|
|
128
|
+
CAMPAIGN_PREVIEW_REMOVE_FAILED: 'CAMPAIGN_PREVIEW_REMOVE_FAILED',
|
|
129
|
+
END: 'END',
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
const TaskState = {
|
|
133
|
+
IDLE: 'IDLE',
|
|
134
|
+
OFFERED: 'OFFERED',
|
|
135
|
+
CONNECTED: 'CONNECTED',
|
|
136
|
+
HOLD_INITIATING: 'HOLD_INITIATING',
|
|
137
|
+
HELD: 'HELD',
|
|
138
|
+
RESUME_INITIATING: 'RESUME_INITIATING',
|
|
139
|
+
CONSULT_INITIATING: 'CONSULT_INITIATING',
|
|
140
|
+
CONSULTING: 'CONSULTING',
|
|
141
|
+
CONF_INITIATING: 'CONF_INITIATING',
|
|
142
|
+
CONFERENCING: 'CONFERENCING',
|
|
143
|
+
TERMINATED: 'TERMINATED',
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
return {
|
|
147
|
+
__esModule: true,
|
|
148
|
+
TaskEvent,
|
|
149
|
+
TaskState,
|
|
150
|
+
createTaskStateMachine: jest.requireActual(
|
|
151
|
+
'../../../../../src/services/task/state-machine'
|
|
152
|
+
).createTaskStateMachine,
|
|
153
|
+
};
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
jest.mock('../../../../../src/services/task/voice/WebRTC', () => {
|
|
157
|
+
const EventEmitter = require('events');
|
|
158
|
+
|
|
159
|
+
return {
|
|
160
|
+
__esModule: true,
|
|
161
|
+
default: class MockWebRTC extends EventEmitter {
|
|
162
|
+
data: Record<string, unknown>;
|
|
163
|
+
webCallingService: EventEmitter;
|
|
164
|
+
handleRemoteMedia = jest.fn();
|
|
165
|
+
unregisterWebCallListeners = jest.fn(() => {
|
|
166
|
+
this.webCallingService.off('remote_media', this.handleRemoteMedia);
|
|
167
|
+
});
|
|
168
|
+
cancelAutoWrapupTimer = jest.fn();
|
|
169
|
+
updateUiControls = jest.fn();
|
|
170
|
+
updateTaskData = jest.fn((newData) => {
|
|
171
|
+
this.data = {...this.data, ...newData};
|
|
172
|
+
|
|
173
|
+
return this;
|
|
174
|
+
});
|
|
175
|
+
sendStateMachineEvent = jest.fn((event) => {
|
|
176
|
+
if (event.taskData) {
|
|
177
|
+
this.updateTaskData(event.taskData);
|
|
178
|
+
}
|
|
179
|
+
if (event.type === 'CONTACT_ENDED') {
|
|
180
|
+
this.emit('task:cleanup', this, {removeFromCollection: false});
|
|
181
|
+
}
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
constructor(_contact: unknown, _webCallingService: unknown, data: Record<string, unknown>) {
|
|
185
|
+
super();
|
|
186
|
+
this.data = data;
|
|
187
|
+
this.webCallingService = _webCallingService as EventEmitter;
|
|
188
|
+
this.webCallingService.on('remote_media', this.handleRemoteMedia);
|
|
189
|
+
}
|
|
190
|
+
},
|
|
191
|
+
};
|
|
192
|
+
});
|
|
17
193
|
|
|
18
194
|
describe('TaskManager', () => {
|
|
19
195
|
let mockCall;
|
|
@@ -28,9 +204,118 @@ describe('TaskManager', () => {
|
|
|
28
204
|
let webCallingService;
|
|
29
205
|
let webex: WebexSDK;
|
|
30
206
|
const taskId = '0ae913a4-c857-4705-8d49-76dd3dde75e4';
|
|
207
|
+
const createRtdError = (errorCode: string) => {
|
|
208
|
+
const error = new Error(errorCode) as Error & {data?: Record<string, unknown>};
|
|
209
|
+
error.data = {errorCode};
|
|
210
|
+
|
|
211
|
+
return error;
|
|
212
|
+
};
|
|
213
|
+
const createPendingRtdRequest = (
|
|
214
|
+
api: any,
|
|
215
|
+
correlationId: string,
|
|
216
|
+
eventType: string,
|
|
217
|
+
timeoutCode: string
|
|
218
|
+
) =>
|
|
219
|
+
({
|
|
220
|
+
result: api.requestAndWaitForRtd({
|
|
221
|
+
correlationId,
|
|
222
|
+
rtdEventType: eventType,
|
|
223
|
+
timeoutMs: AI_SUMMARY_DURATION_MS,
|
|
224
|
+
createTimeoutError: () => createRtdError(timeoutCode),
|
|
225
|
+
agentId: 'agent-1',
|
|
226
|
+
interactionId: 'interaction-1',
|
|
227
|
+
eventType: 'CTI_EVENT',
|
|
228
|
+
eventName: 'GET_POST_CALL_SUMMARY',
|
|
229
|
+
}),
|
|
230
|
+
});
|
|
231
|
+
const createSummaryApiMock = () => {
|
|
232
|
+
const pendingRequests = new Map<string, any>();
|
|
233
|
+
const api: any = {
|
|
234
|
+
sendEvent: jest.fn().mockResolvedValue({}),
|
|
235
|
+
pendingRequests,
|
|
236
|
+
};
|
|
237
|
+
api.requestAndWaitForRtd = jest.fn(async (options: any) => {
|
|
238
|
+
const key = JSON.stringify([options.rtdEventType, options.correlationId]);
|
|
239
|
+
const previousRequest = pendingRequests.get(key);
|
|
240
|
+
if (previousRequest) {
|
|
241
|
+
clearTimeout(previousRequest.timeoutId);
|
|
242
|
+
}
|
|
243
|
+
let resolveResult: (payload: any) => void = () => undefined;
|
|
244
|
+
let rejectResult: (error: Error) => void = () => undefined;
|
|
245
|
+
const result = new Promise((resolve, reject) => {
|
|
246
|
+
resolveResult = resolve;
|
|
247
|
+
rejectResult = reject;
|
|
248
|
+
});
|
|
249
|
+
const request = {
|
|
250
|
+
correlationId: options.correlationId,
|
|
251
|
+
eventType: options.rtdEventType,
|
|
252
|
+
result,
|
|
253
|
+
resolve: resolveResult,
|
|
254
|
+
reject: rejectResult,
|
|
255
|
+
timeoutId: setTimeout(() => {
|
|
256
|
+
pendingRequests.delete(key);
|
|
257
|
+
rejectResult(options.createTimeoutError());
|
|
258
|
+
}, options.timeoutMs),
|
|
259
|
+
};
|
|
260
|
+
pendingRequests.set(key, request);
|
|
261
|
+
|
|
262
|
+
const acknowledgement = api.sendEvent(
|
|
263
|
+
options.agentId,
|
|
264
|
+
options.interactionId,
|
|
265
|
+
options.eventType,
|
|
266
|
+
options.eventName,
|
|
267
|
+
options.eventMetaData,
|
|
268
|
+
undefined,
|
|
269
|
+
undefined,
|
|
270
|
+
options.publishTimestamp,
|
|
271
|
+
options.timeout
|
|
272
|
+
);
|
|
273
|
+
try {
|
|
274
|
+
const [payload] = await Promise.all([result, acknowledgement]);
|
|
275
|
+
return payload;
|
|
276
|
+
} catch (error) {
|
|
277
|
+
clearTimeout(request.timeoutId);
|
|
278
|
+
if (pendingRequests.get(key) === request) {
|
|
279
|
+
pendingRequests.delete(key);
|
|
280
|
+
}
|
|
281
|
+
throw error;
|
|
282
|
+
}
|
|
283
|
+
});
|
|
284
|
+
api.resolveFromRtdEvent = jest.fn((eventType: string, correlationId: string, payload: any) => {
|
|
285
|
+
const key = JSON.stringify([eventType, correlationId]);
|
|
286
|
+
const request = pendingRequests.get(key);
|
|
287
|
+
if (!request) return 'not-found';
|
|
288
|
+
clearTimeout(request.timeoutId);
|
|
289
|
+
pendingRequests.delete(key);
|
|
290
|
+
request.resolve(payload);
|
|
291
|
+
return 'resolved';
|
|
292
|
+
});
|
|
293
|
+
api.clearAllRtdRequests = jest.fn(() => {
|
|
294
|
+
Array.from(pendingRequests.values()).forEach((request: any) => {
|
|
295
|
+
clearTimeout(request.timeoutId);
|
|
296
|
+
request.reject(createRtdError(AI_SUMMARY_REQUEST_CANCELLED));
|
|
297
|
+
});
|
|
298
|
+
pendingRequests.clear();
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
return api;
|
|
302
|
+
};
|
|
303
|
+
const getLoggerProxy = (): Record<string, jest.Mock> =>
|
|
304
|
+
require('../../../../../src/logger-proxy').default;
|
|
305
|
+
const expectNoSensitiveDiagnostics = (...sensitiveValues: string[]) => {
|
|
306
|
+
const diagnostics = JSON.stringify({
|
|
307
|
+
warnings: getLoggerProxy().warn.mock.calls,
|
|
308
|
+
errors: getLoggerProxy().error.mock.calls,
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
sensitiveValues.forEach((value) => {
|
|
312
|
+
expect(diagnostics).not.toContain(value);
|
|
313
|
+
});
|
|
314
|
+
};
|
|
31
315
|
|
|
32
|
-
const
|
|
316
|
+
const createEventEmitterTask = (data = taskDataMock) => {
|
|
33
317
|
const task = new EventEmitter() as any;
|
|
318
|
+
const originalOn = task.on.bind(task);
|
|
34
319
|
|
|
35
320
|
const updateTaskData = jest.fn().mockImplementation((newData) => {
|
|
36
321
|
task.data = {...task.data, ...newData};
|
|
@@ -39,13 +324,21 @@ describe('TaskManager', () => {
|
|
|
39
324
|
|
|
40
325
|
Object.assign(task, {
|
|
41
326
|
data,
|
|
327
|
+
on: jest.fn((...args) => originalOn(...args)),
|
|
42
328
|
accept: jest.fn(),
|
|
43
329
|
decline: jest.fn(),
|
|
44
330
|
updateTaskData,
|
|
331
|
+
setFeatureEnablement: jest.fn(),
|
|
45
332
|
unregisterWebCallListeners: jest.fn(),
|
|
46
333
|
cancelAutoWrapupTimer: jest.fn(),
|
|
47
334
|
});
|
|
48
335
|
|
|
336
|
+
return task;
|
|
337
|
+
};
|
|
338
|
+
|
|
339
|
+
const createStateMachineTask = (data = taskDataMock) => {
|
|
340
|
+
const task = createEventEmitterTask(data);
|
|
341
|
+
|
|
49
342
|
const taskEventMap: Partial<Record<TaskEvent, string>> = {
|
|
50
343
|
[TaskEvent.TASK_INCOMING]: TASK_EVENTS.TASK_INCOMING,
|
|
51
344
|
[TaskEvent.TASK_OFFERED]: TASK_EVENTS.TASK_OFFER_CONTACT,
|
|
@@ -73,12 +366,30 @@ describe('TaskManager', () => {
|
|
|
73
366
|
[TaskEvent.WRAPUP_COMPLETE]: TASK_EVENTS.TASK_WRAPPEDUP,
|
|
74
367
|
};
|
|
75
368
|
|
|
369
|
+
// Mirrors the real state machine's offered (ringing) and answered states.
|
|
370
|
+
const taskStateMap: Partial<Record<TaskEvent, string>> = {
|
|
371
|
+
[TaskEvent.TASK_INCOMING]: TaskState.OFFERED,
|
|
372
|
+
[TaskEvent.TASK_OFFERED]: TaskState.OFFERED,
|
|
373
|
+
[TaskEvent.OFFER_CONSULT]: TaskState.OFFERED,
|
|
374
|
+
[TaskEvent.ASSIGN]: TaskState.CONNECTED,
|
|
375
|
+
[TaskEvent.CONSULTING_ACTIVE]: TaskState.CONSULTING,
|
|
376
|
+
[TaskEvent.HYDRATE]: TaskState.CONNECTED,
|
|
377
|
+
// Offers that end without being answered.
|
|
378
|
+
[TaskEvent.RONA]: TaskState.TERMINATED,
|
|
379
|
+
[TaskEvent.ASSIGN_FAILED]: TaskState.TERMINATED,
|
|
380
|
+
[TaskEvent.CONSULT_END]: TaskState.TERMINATED,
|
|
381
|
+
};
|
|
382
|
+
|
|
76
383
|
task.sendStateMachineEvent = jest.fn().mockImplementation((event) => {
|
|
77
384
|
if (event.taskData) {
|
|
78
385
|
task.updateTaskData(event.taskData);
|
|
79
386
|
}
|
|
80
387
|
|
|
81
|
-
|
|
388
|
+
let mappedEvent = taskEventMap[event.type as TaskEvent];
|
|
389
|
+
if (event.type === TaskEvent.TASK_INCOMING && event.isCampaignReservationAccept === true) {
|
|
390
|
+
mappedEvent = TASK_EVENTS.TASK_CAMPAIGN_PREVIEW_RESERVATION;
|
|
391
|
+
}
|
|
392
|
+
|
|
82
393
|
if (mappedEvent) {
|
|
83
394
|
if (
|
|
84
395
|
[TaskEvent.ASSIGN_FAILED, TaskEvent.RONA, TaskEvent.INVITE_FAILED].includes(
|
|
@@ -129,11 +440,18 @@ describe('TaskManager', () => {
|
|
|
129
440
|
const removeFromCollection = eventType !== TaskEvent.CONTACT_ENDED;
|
|
130
441
|
task.emit(TASK_EVENTS.TASK_CLEANUP, task, {removeFromCollection});
|
|
131
442
|
}
|
|
443
|
+
|
|
444
|
+
// Like Task's state machine subscriber, `state` updates after the transition's emissions.
|
|
445
|
+
if (taskStateMap[eventType]) {
|
|
446
|
+
task.state = {value: taskStateMap[eventType]};
|
|
447
|
+
}
|
|
132
448
|
});
|
|
133
449
|
|
|
134
450
|
return task;
|
|
135
451
|
};
|
|
136
452
|
|
|
453
|
+
const createMockTask = createStateMachineTask;
|
|
454
|
+
|
|
137
455
|
taskDataMock = {
|
|
138
456
|
type: CC_EVENTS.AGENT_CONTACT_RESERVED,
|
|
139
457
|
agentId: '723a8ffb-a26e-496d-b14a-ff44fb83b64f',
|
|
@@ -197,10 +515,7 @@ describe('TaskManager', () => {
|
|
|
197
515
|
onSpy = jest.spyOn(webCallingService, 'on');
|
|
198
516
|
offSpy = jest.spyOn(webCallingService, 'off');
|
|
199
517
|
|
|
200
|
-
mockApiAIAssistant =
|
|
201
|
-
sendEvent: jest.fn().mockResolvedValue({}),
|
|
202
|
-
};
|
|
203
|
-
|
|
518
|
+
mockApiAIAssistant = createSummaryApiMock();
|
|
204
519
|
taskManager = new TaskManager(
|
|
205
520
|
mockApiAIAssistant as any,
|
|
206
521
|
contactMock,
|
|
@@ -208,17 +523,20 @@ describe('TaskManager', () => {
|
|
|
208
523
|
webSocketManagerMock as any,
|
|
209
524
|
rtdWebSocketManagerMock as any
|
|
210
525
|
);
|
|
211
|
-
taskManager.taskCollection[taskId] =
|
|
526
|
+
taskManager.taskCollection[taskId] = createStateMachineTask(taskDataMock);
|
|
212
527
|
(taskManager as any).setupTaskListeners?.(taskManager.taskCollection[taskId]);
|
|
213
528
|
taskManager.call = mockCall;
|
|
214
529
|
taskManager.setAgentId('test-agent-id');
|
|
215
530
|
|
|
216
531
|
jest
|
|
217
532
|
.spyOn(TaskFactory, 'createTask')
|
|
218
|
-
.mockImplementation((contact, webCallingService, data, configFlags) =>
|
|
533
|
+
.mockImplementation((contact, webCallingService, data, configFlags) =>
|
|
534
|
+
createStateMachineTask(data)
|
|
535
|
+
);
|
|
219
536
|
});
|
|
220
537
|
|
|
221
538
|
afterEach(() => {
|
|
539
|
+
jest.useRealTimers();
|
|
222
540
|
jest.clearAllMocks();
|
|
223
541
|
jest.resetAllMocks();
|
|
224
542
|
});
|
|
@@ -251,188 +569,1978 @@ describe('TaskManager', () => {
|
|
|
251
569
|
expect(webCallingService.listenerCount(LINE_EVENTS.INCOMING_CALL)).toBe(1);
|
|
252
570
|
expect(webSocketManagerMock.listenerCount('message')).toBe(1);
|
|
253
571
|
|
|
254
|
-
webSocketManagerMock.emit('message', JSON.stringify(dummyPayload));
|
|
572
|
+
webSocketManagerMock.emit('message', JSON.stringify(dummyPayload));
|
|
573
|
+
|
|
574
|
+
expect(taskEmitSpy).toHaveBeenCalledWith(
|
|
575
|
+
TASK_EVENTS.TASK_CONSULT_ACCEPTED,
|
|
576
|
+
taskManager.getTask(taskId)
|
|
577
|
+
);
|
|
578
|
+
});
|
|
579
|
+
|
|
580
|
+
it('should invoke sendEvent for configured start/stop backend events', () => {
|
|
581
|
+
taskManager.setConfigFlags({
|
|
582
|
+
isEndTaskEnabled: true,
|
|
583
|
+
isEndConsultEnabled: true,
|
|
584
|
+
webRtcEnabled: true,
|
|
585
|
+
autoWrapup: false,
|
|
586
|
+
aiFeature: {
|
|
587
|
+
id: 'ai-feature-1',
|
|
588
|
+
realtimeTranscripts: {
|
|
589
|
+
enable: true,
|
|
590
|
+
},
|
|
591
|
+
},
|
|
592
|
+
});
|
|
593
|
+
|
|
594
|
+
const interactionId = taskId;
|
|
595
|
+
const message = (type: CC_EVENTS) =>
|
|
596
|
+
JSON.stringify({
|
|
597
|
+
data: {
|
|
598
|
+
...taskDataMock,
|
|
599
|
+
interactionId,
|
|
600
|
+
type,
|
|
601
|
+
},
|
|
602
|
+
});
|
|
603
|
+
|
|
604
|
+
webSocketManagerMock.emit('message', message(CC_EVENTS.AGENT_CONTACT_ASSIGNED));
|
|
605
|
+
webSocketManagerMock.emit('message', message(CC_EVENTS.AGENT_CONSULTING));
|
|
606
|
+
webSocketManagerMock.emit('message', message(CC_EVENTS.AGENT_CONSULT_CONFERENCED));
|
|
607
|
+
webSocketManagerMock.emit('message', message(CC_EVENTS.AGENT_CONSULT_ENDED));
|
|
608
|
+
webSocketManagerMock.emit('message', message(CC_EVENTS.PARTICIPANT_LEFT_CONFERENCE));
|
|
609
|
+
webSocketManagerMock.emit('message', message(CC_EVENTS.AGENT_WRAPUP));
|
|
610
|
+
|
|
611
|
+
expect(mockApiAIAssistant.sendEvent).toHaveBeenCalledTimes(6);
|
|
612
|
+
expect(mockApiAIAssistant.sendEvent).toHaveBeenCalledWith(
|
|
613
|
+
'test-agent-id',
|
|
614
|
+
interactionId,
|
|
615
|
+
'CUSTOM_EVENT',
|
|
616
|
+
'GET_TRANSCRIPTS',
|
|
617
|
+
{action: 'START'}
|
|
618
|
+
);
|
|
619
|
+
expect(mockApiAIAssistant.sendEvent).toHaveBeenCalledWith(
|
|
620
|
+
'test-agent-id',
|
|
621
|
+
interactionId,
|
|
622
|
+
'CUSTOM_EVENT',
|
|
623
|
+
'GET_TRANSCRIPTS',
|
|
624
|
+
{action: 'STOP'}
|
|
625
|
+
);
|
|
626
|
+
});
|
|
627
|
+
|
|
628
|
+
it('should not invoke sendEvent when realtime transcripts are disabled in aiFeature', () => {
|
|
629
|
+
taskManager.setConfigFlags({
|
|
630
|
+
isEndTaskEnabled: true,
|
|
631
|
+
isEndConsultEnabled: true,
|
|
632
|
+
webRtcEnabled: true,
|
|
633
|
+
autoWrapup: false,
|
|
634
|
+
aiFeature: {
|
|
635
|
+
id: 'ai-feature-1',
|
|
636
|
+
realtimeTranscripts: {
|
|
637
|
+
enable: false,
|
|
638
|
+
},
|
|
639
|
+
},
|
|
640
|
+
});
|
|
641
|
+
|
|
642
|
+
const message = (type: CC_EVENTS) =>
|
|
643
|
+
JSON.stringify({
|
|
644
|
+
data: {
|
|
645
|
+
...taskDataMock,
|
|
646
|
+
interactionId: taskId,
|
|
647
|
+
type,
|
|
648
|
+
},
|
|
649
|
+
});
|
|
650
|
+
|
|
651
|
+
webSocketManagerMock.emit('message', message(CC_EVENTS.AGENT_CONTACT_ASSIGNED));
|
|
652
|
+
webSocketManagerMock.emit('message', message(CC_EVENTS.AGENT_CONSULTING));
|
|
653
|
+
|
|
654
|
+
expect(mockApiAIAssistant.sendEvent).not.toHaveBeenCalled();
|
|
655
|
+
});
|
|
656
|
+
|
|
657
|
+
it('should not invoke sendEvent when realtime transcripts config is missing', () => {
|
|
658
|
+
taskManager.setConfigFlags({
|
|
659
|
+
isEndTaskEnabled: true,
|
|
660
|
+
isEndConsultEnabled: true,
|
|
661
|
+
webRtcEnabled: true,
|
|
662
|
+
autoWrapup: false,
|
|
663
|
+
aiFeature: {
|
|
664
|
+
id: 'ai-feature-1',
|
|
665
|
+
suggestedResponses: {
|
|
666
|
+
enable: true,
|
|
667
|
+
},
|
|
668
|
+
},
|
|
669
|
+
});
|
|
670
|
+
|
|
671
|
+
const message = (type: CC_EVENTS) =>
|
|
672
|
+
JSON.stringify({
|
|
673
|
+
data: {
|
|
674
|
+
...taskDataMock,
|
|
675
|
+
interactionId: taskId,
|
|
676
|
+
type,
|
|
677
|
+
},
|
|
678
|
+
});
|
|
679
|
+
|
|
680
|
+
webSocketManagerMock.emit('message', message(CC_EVENTS.AGENT_CONTACT_ASSIGNED));
|
|
681
|
+
webSocketManagerMock.emit('message', message(CC_EVENTS.AGENT_CONSULTING));
|
|
682
|
+
|
|
683
|
+
expect(mockApiAIAssistant.sendEvent).not.toHaveBeenCalled();
|
|
684
|
+
});
|
|
685
|
+
|
|
686
|
+
it('should preserve REAL_TIME_TRANSCRIPTION payloads exactly once across AI summary frames', () => {
|
|
687
|
+
const task = taskManager.getTask(taskId);
|
|
688
|
+
const taskEmitSpy = jest.spyOn(task, 'emit');
|
|
689
|
+
const observedOrder: string[] = [];
|
|
690
|
+
const transcriptHandler = jest.fn((payload) => {
|
|
691
|
+
observedOrder.push(`transcript:${payload.data.messageId}`);
|
|
692
|
+
});
|
|
693
|
+
const createRealtimePayload = (messageId: string, content: string) => ({
|
|
694
|
+
data: {
|
|
695
|
+
agentId: 'test-agent-id',
|
|
696
|
+
data: {
|
|
697
|
+
content,
|
|
698
|
+
conversationId: taskId,
|
|
699
|
+
isFinal: true,
|
|
700
|
+
languageCode: 'en-US',
|
|
701
|
+
messageId,
|
|
702
|
+
orgId: 'org-id',
|
|
703
|
+
publishTimestamp: 1773807297475,
|
|
704
|
+
role: 'AGENT',
|
|
705
|
+
trackingId: `tracking-id-${messageId}`,
|
|
706
|
+
utteranceId: `utterance-id-${messageId}`,
|
|
707
|
+
},
|
|
708
|
+
notifDetails: {
|
|
709
|
+
actionEvent: 'REAL_TIME_TRANSCRIPTION',
|
|
710
|
+
},
|
|
711
|
+
notifType: 'REAL_TIME_TRANSCRIPTION',
|
|
712
|
+
orgId: 'org-id',
|
|
713
|
+
},
|
|
714
|
+
orgId: 'org-id',
|
|
715
|
+
trackingId: 'notifs_tracking-id',
|
|
716
|
+
type: 'REAL_TIME_TRANSCRIPTION',
|
|
717
|
+
});
|
|
718
|
+
const firstRealtimePayload = createRealtimePayload('1', 'Thank you. Okay.');
|
|
719
|
+
const secondRealtimePayload = createRealtimePayload('2', 'I can help with that.');
|
|
720
|
+
|
|
721
|
+
task.on(CC_EVENTS.REAL_TIME_TRANSCRIPTION, transcriptHandler);
|
|
722
|
+
taskManager.handleRealtimeWebsocketEvent(JSON.stringify(firstRealtimePayload));
|
|
723
|
+
taskManager.handleRealtimeWebsocketEvent(
|
|
724
|
+
JSON.stringify({
|
|
725
|
+
type: CC_TASK_EVENTS.POST_CALL_SUMMARY,
|
|
726
|
+
data: {data: {conversationId: taskId, summaryText: {invalid: true}}},
|
|
727
|
+
})
|
|
728
|
+
);
|
|
729
|
+
taskManager.handleRealtimeWebsocketEvent(JSON.stringify(secondRealtimePayload));
|
|
730
|
+
|
|
731
|
+
expect(taskEmitSpy).toHaveBeenCalledTimes(2);
|
|
732
|
+
expect(taskEmitSpy).toHaveBeenNthCalledWith(
|
|
733
|
+
1,
|
|
734
|
+
CC_EVENTS.REAL_TIME_TRANSCRIPTION,
|
|
735
|
+
firstRealtimePayload.data
|
|
736
|
+
);
|
|
737
|
+
expect(taskEmitSpy).toHaveBeenNthCalledWith(
|
|
738
|
+
2,
|
|
739
|
+
CC_EVENTS.REAL_TIME_TRANSCRIPTION,
|
|
740
|
+
secondRealtimePayload.data
|
|
741
|
+
);
|
|
742
|
+
expect(transcriptHandler).toHaveBeenCalledTimes(2);
|
|
743
|
+
expect(transcriptHandler).toHaveBeenNthCalledWith(1, firstRealtimePayload.data);
|
|
744
|
+
expect(transcriptHandler).toHaveBeenNthCalledWith(2, secondRealtimePayload.data);
|
|
745
|
+
expect(observedOrder).toEqual(['transcript:1', 'transcript:2']);
|
|
746
|
+
});
|
|
747
|
+
|
|
748
|
+
it('should ignore RTD transcript events when task is not found', () => {
|
|
749
|
+
const realtimePayload = {
|
|
750
|
+
data: {
|
|
751
|
+
data: {
|
|
752
|
+
content: 'Thank you. Okay.',
|
|
753
|
+
conversationId: 'missing-task-id',
|
|
754
|
+
isFinal: true,
|
|
755
|
+
languageCode: 'en-US',
|
|
756
|
+
messageId: '1',
|
|
757
|
+
orgId: 'org-id',
|
|
758
|
+
publishTimestamp: 1773807297475,
|
|
759
|
+
role: 'AGENT',
|
|
760
|
+
trackingId: 'tracking-id',
|
|
761
|
+
utteranceId: 'utterance-id',
|
|
762
|
+
},
|
|
763
|
+
notifDetails: {
|
|
764
|
+
actionEvent: 'REAL_TIME_TRANSCRIPTION',
|
|
765
|
+
},
|
|
766
|
+
notifType: 'REAL_TIME_TRANSCRIPTION',
|
|
767
|
+
orgId: 'org-id',
|
|
768
|
+
},
|
|
769
|
+
orgId: 'org-id',
|
|
770
|
+
trackingId: 'notifs_tracking-id',
|
|
771
|
+
type: 'REAL_TIME_TRANSCRIPTION',
|
|
772
|
+
};
|
|
773
|
+
|
|
774
|
+
const existingTaskEmitSpy = jest.spyOn(taskManager.getTask(taskId), 'emit');
|
|
775
|
+
|
|
776
|
+
taskManager.handleRealtimeWebsocketEvent(JSON.stringify(realtimePayload));
|
|
777
|
+
|
|
778
|
+
expect(existingTaskEmitSpy).not.toHaveBeenCalled();
|
|
779
|
+
});
|
|
780
|
+
|
|
781
|
+
it('should update the matching task with the latest feature enablement frame', () => {
|
|
782
|
+
const payload = {
|
|
783
|
+
type: CC_TASK_EVENTS.FEATURE_ENABLEMENT,
|
|
784
|
+
data: {
|
|
785
|
+
data: {
|
|
786
|
+
interactionId: taskId,
|
|
787
|
+
postCallEnabled: true,
|
|
788
|
+
midCallEnabled: false,
|
|
789
|
+
actionTimestamp: 10,
|
|
790
|
+
},
|
|
791
|
+
},
|
|
792
|
+
};
|
|
793
|
+
|
|
794
|
+
taskManager.handleRealtimeWebsocketEvent(JSON.stringify(payload));
|
|
795
|
+
taskManager.handleRealtimeWebsocketEvent(
|
|
796
|
+
JSON.stringify({
|
|
797
|
+
type: CC_TASK_EVENTS.FEATURE_ENABLEMENT,
|
|
798
|
+
data: {
|
|
799
|
+
data: {
|
|
800
|
+
interactionId: taskId,
|
|
801
|
+
postCallEnabled: undefined,
|
|
802
|
+
midCallEnabled: true,
|
|
803
|
+
},
|
|
804
|
+
},
|
|
805
|
+
})
|
|
806
|
+
);
|
|
807
|
+
|
|
808
|
+
expect((taskManager.getTask(taskId) as any).setFeatureEnablement).toHaveBeenLastCalledWith({
|
|
809
|
+
interactionId: taskId,
|
|
810
|
+
postCallEnabled: undefined,
|
|
811
|
+
midCallEnabled: true,
|
|
812
|
+
});
|
|
813
|
+
});
|
|
814
|
+
|
|
815
|
+
it('should emit task:featureEnablement on the matching task when a feature frame arrives with the task registered', () => {
|
|
816
|
+
const taskEmitSpy = jest.spyOn(taskManager.getTask(taskId), 'emit');
|
|
817
|
+
const featurePayload = {
|
|
818
|
+
interactionId: taskId,
|
|
819
|
+
postCallEnabled: true,
|
|
820
|
+
midCallEnabled: false,
|
|
821
|
+
actionTimestamp: 10,
|
|
822
|
+
};
|
|
823
|
+
|
|
824
|
+
taskManager.handleRealtimeWebsocketEvent(
|
|
825
|
+
JSON.stringify({
|
|
826
|
+
type: 'FEATURE_ENABLEMENT',
|
|
827
|
+
data: {data: featurePayload},
|
|
828
|
+
})
|
|
829
|
+
);
|
|
830
|
+
|
|
831
|
+
expect(taskEmitSpy).toHaveBeenCalledWith(TASK_EVENTS.TASK_FEATURE_ENABLEMENT, featurePayload);
|
|
832
|
+
});
|
|
833
|
+
|
|
834
|
+
it('should replay retained feature enablement after the public incoming task event', () => {
|
|
835
|
+
const interactionId = 'retained-feature-task';
|
|
836
|
+
const featurePayload = {
|
|
837
|
+
interactionId,
|
|
838
|
+
postCallEnabled: true,
|
|
839
|
+
midCallEnabled: true,
|
|
840
|
+
actionTimestamp: 10,
|
|
841
|
+
};
|
|
842
|
+
const incomingPayload = {
|
|
843
|
+
...taskDataMock,
|
|
844
|
+
interactionId,
|
|
845
|
+
mediaResourceId: interactionId,
|
|
846
|
+
};
|
|
847
|
+
const featureHandler = jest.fn();
|
|
848
|
+
|
|
849
|
+
taskManager.handleRealtimeWebsocketEvent(
|
|
850
|
+
JSON.stringify({
|
|
851
|
+
type: CC_TASK_EVENTS.FEATURE_ENABLEMENT,
|
|
852
|
+
data: {data: featurePayload},
|
|
853
|
+
})
|
|
854
|
+
);
|
|
855
|
+
taskManager.taskCollection = {};
|
|
856
|
+
taskManager.on(TASK_EVENTS.TASK_INCOMING, (task) => {
|
|
857
|
+
task.on(TASK_EVENTS.TASK_FEATURE_ENABLEMENT, featureHandler);
|
|
858
|
+
});
|
|
859
|
+
|
|
860
|
+
webSocketManagerMock.emit(
|
|
861
|
+
'message',
|
|
862
|
+
JSON.stringify({data: {...incomingPayload, type: CC_EVENTS.AGENT_CONTACT_RESERVED}})
|
|
863
|
+
);
|
|
864
|
+
|
|
865
|
+
expect(featureHandler).toHaveBeenCalledTimes(1);
|
|
866
|
+
expect(featureHandler).toHaveBeenCalledWith(featurePayload);
|
|
867
|
+
});
|
|
868
|
+
|
|
869
|
+
it('should apply retained feature flags before publishing the task and emit them afterward', () => {
|
|
870
|
+
const newInteractionId = 'orphan-task-id';
|
|
871
|
+
const featurePayload = {
|
|
872
|
+
interactionId: newInteractionId,
|
|
873
|
+
postCallEnabled: true,
|
|
874
|
+
midCallEnabled: true,
|
|
875
|
+
actionTimestamp: 5,
|
|
876
|
+
};
|
|
877
|
+
|
|
878
|
+
// Feature frame arrives before task registration — stored as orphan
|
|
879
|
+
taskManager.handleRealtimeWebsocketEvent(
|
|
880
|
+
JSON.stringify({
|
|
881
|
+
type: CC_TASK_EVENTS.FEATURE_ENABLEMENT,
|
|
882
|
+
data: {data: featurePayload},
|
|
883
|
+
})
|
|
884
|
+
);
|
|
885
|
+
|
|
886
|
+
// Register through the task lifecycle so the consumer can attach its listener.
|
|
887
|
+
const orphanTask = createStateMachineTask({
|
|
888
|
+
...taskDataMock,
|
|
889
|
+
interactionId: newInteractionId,
|
|
890
|
+
mediaResourceId: newInteractionId,
|
|
891
|
+
interaction: {mediaType: 'telephony'},
|
|
892
|
+
});
|
|
893
|
+
const taskEmitSpy = jest.spyOn(orphanTask, 'emit');
|
|
894
|
+
const featureHandler = jest.fn();
|
|
895
|
+
(TaskFactory.createTask as jest.Mock).mockReturnValueOnce(orphanTask);
|
|
896
|
+
taskManager.once(TASK_EVENTS.TASK_INCOMING, (publishedTask) => {
|
|
897
|
+
expect(publishedTask).toBe(orphanTask);
|
|
898
|
+
expect(orphanTask.setFeatureEnablement).toHaveBeenCalledWith(featurePayload);
|
|
899
|
+
expect((taskManager as any).pendingFeatureEnablement.get(newInteractionId)).toEqual(
|
|
900
|
+
featurePayload
|
|
901
|
+
);
|
|
902
|
+
expect(taskEmitSpy).not.toHaveBeenCalledWith(
|
|
903
|
+
TASK_EVENTS.TASK_FEATURE_ENABLEMENT,
|
|
904
|
+
featurePayload
|
|
905
|
+
);
|
|
906
|
+
publishedTask.on(TASK_EVENTS.TASK_FEATURE_ENABLEMENT, featureHandler);
|
|
907
|
+
});
|
|
908
|
+
|
|
909
|
+
webSocketManagerMock.emit(
|
|
910
|
+
'message',
|
|
911
|
+
JSON.stringify({data: {...orphanTask.data, type: CC_EVENTS.AGENT_CONTACT_RESERVED}})
|
|
912
|
+
);
|
|
913
|
+
|
|
914
|
+
expect(taskEmitSpy).toHaveBeenCalledWith(TASK_EVENTS.TASK_FEATURE_ENABLEMENT, featurePayload);
|
|
915
|
+
expect(featureHandler).toHaveBeenCalledTimes(1);
|
|
916
|
+
expect(featureHandler).toHaveBeenCalledWith(featurePayload);
|
|
917
|
+
expect((taskManager as any).pendingFeatureEnablement.has(newInteractionId)).toBe(false);
|
|
918
|
+
});
|
|
919
|
+
|
|
920
|
+
it('should retain an unmatched feature enablement snapshot until session cleanup', () => {
|
|
921
|
+
jest.useFakeTimers();
|
|
922
|
+
const interactionId = 'unmatched-feature-interaction';
|
|
923
|
+
const featurePayload = {
|
|
924
|
+
interactionId,
|
|
925
|
+
postCallEnabled: true,
|
|
926
|
+
midCallEnabled: true,
|
|
927
|
+
};
|
|
928
|
+
|
|
929
|
+
taskManager.handleRealtimeWebsocketEvent(
|
|
930
|
+
JSON.stringify({
|
|
931
|
+
type: CC_TASK_EVENTS.FEATURE_ENABLEMENT,
|
|
932
|
+
data: {data: featurePayload},
|
|
933
|
+
})
|
|
934
|
+
);
|
|
935
|
+
|
|
936
|
+
expect((taskManager as any).pendingFeatureEnablement.get(interactionId)).toEqual(featurePayload);
|
|
937
|
+
expect(jest.getTimerCount()).toBe(0);
|
|
938
|
+
|
|
939
|
+
jest.advanceTimersByTime(AI_SUMMARY_DURATION_MS);
|
|
940
|
+
|
|
941
|
+
expect((taskManager as any).pendingFeatureEnablement.get(interactionId)).toEqual(featurePayload);
|
|
942
|
+
|
|
943
|
+
taskManager.clearAISummaryState();
|
|
944
|
+
|
|
945
|
+
expect((taskManager as any).pendingFeatureEnablement.get(interactionId)).toBeUndefined();
|
|
946
|
+
expect(jest.getTimerCount()).toBe(0);
|
|
947
|
+
});
|
|
948
|
+
|
|
949
|
+
it('should not use a main interaction feature frame as fallback for a child task key', () => {
|
|
950
|
+
jest.useFakeTimers();
|
|
951
|
+
const coordinator = (taskManager as any).apiAIAssistant;
|
|
952
|
+
const childInteractionId = 'child-task';
|
|
953
|
+
const conversationId = 'conversation-1';
|
|
954
|
+
const childTask = createStateMachineTask({
|
|
955
|
+
...taskDataMock,
|
|
956
|
+
interactionId: childInteractionId,
|
|
957
|
+
interaction: {
|
|
958
|
+
mediaType: 'telephony',
|
|
959
|
+
mainInteractionId: conversationId,
|
|
960
|
+
},
|
|
961
|
+
});
|
|
962
|
+
const featurePayload = {
|
|
963
|
+
interactionId: conversationId,
|
|
964
|
+
postCallEnabled: true,
|
|
965
|
+
midCallEnabled: true,
|
|
966
|
+
actionTimestamp: 10,
|
|
967
|
+
};
|
|
968
|
+
|
|
969
|
+
taskManager.taskCollection = {
|
|
970
|
+
[childInteractionId]: childTask,
|
|
971
|
+
};
|
|
972
|
+
|
|
973
|
+
taskManager.handleRealtimeWebsocketEvent(
|
|
974
|
+
JSON.stringify({
|
|
975
|
+
type: CC_TASK_EVENTS.FEATURE_ENABLEMENT,
|
|
976
|
+
data: {data: featurePayload},
|
|
977
|
+
})
|
|
978
|
+
);
|
|
979
|
+
|
|
980
|
+
expect((taskManager as any).pendingFeatureEnablement.get(conversationId)).toEqual(featurePayload);
|
|
981
|
+
expect(
|
|
982
|
+
(taskManager as any).pendingFeatureEnablement.get(childInteractionId)
|
|
983
|
+
).toBeUndefined();
|
|
984
|
+
|
|
985
|
+
taskManager.clearAISummaryState();
|
|
986
|
+
});
|
|
987
|
+
|
|
988
|
+
it.each([
|
|
989
|
+
[
|
|
990
|
+
'post-call',
|
|
991
|
+
'POST_CALL_SUMMARY',
|
|
992
|
+
'POST_CALL_SUMMARY',
|
|
993
|
+
'POST_CALL_SUMMARY_TIMEOUT',
|
|
994
|
+
{
|
|
995
|
+
conversationId: taskId,
|
|
996
|
+
adaptiveCard: {body: ['card']},
|
|
997
|
+
adaptiveCardId: 'adaptive-card-id',
|
|
998
|
+
editAdaptiveCard: {body: ['edit-card']},
|
|
999
|
+
editAdaptiveCardId: 'edit-adaptive-card-id',
|
|
1000
|
+
languageCode: 'en-US',
|
|
1001
|
+
summaryText: 'safe summary',
|
|
1002
|
+
resolution: 'resolved',
|
|
1003
|
+
areTranscriptsAvailable: true,
|
|
1004
|
+
sections: {initialContactReason: 'billing', safeKey: 'safe value'},
|
|
1005
|
+
suggestedWrapUpCodes: [{name: 'Resolved', id: 'wrap-up-code-id'}],
|
|
1006
|
+
suggestedWrapUpCodesMessage: 'Use resolved wrap-up',
|
|
1007
|
+
timestamp: 1773807297475,
|
|
1008
|
+
},
|
|
1009
|
+
],
|
|
1010
|
+
[
|
|
1011
|
+
'mid-call',
|
|
1012
|
+
'MID_CALL_SUMMARY',
|
|
1013
|
+
'MID_CALL_SUMMARY',
|
|
1014
|
+
'MID_CALL_SUMMARY_TIMEOUT',
|
|
1015
|
+
{
|
|
1016
|
+
conversationId: 'mid-summary-conversation',
|
|
1017
|
+
adaptiveCard: {body: ['mid-card']},
|
|
1018
|
+
adaptiveCardId: 'mid-adaptive-card-id',
|
|
1019
|
+
editAdaptiveCard: {body: ['mid-edit-card']},
|
|
1020
|
+
editAdaptiveCardId: 'mid-edit-adaptive-card-id',
|
|
1021
|
+
languageCode: 'en-US',
|
|
1022
|
+
summaryText: 'safe mid summary',
|
|
1023
|
+
resolution: 'transferred',
|
|
1024
|
+
areTranscriptsAvailable: false,
|
|
1025
|
+
sections: {reasonForTransferOrConsult: 'specialist', safeKey: 'safe value'},
|
|
1026
|
+
timestamp: 1773807297476,
|
|
1027
|
+
},
|
|
1028
|
+
],
|
|
1029
|
+
] as const)(
|
|
1030
|
+
'should resolve matching %s initiator summary payload through the request Promise only',
|
|
1031
|
+
async (_summaryKind, realtimeEvent, inboundType, timeoutCode, summaryPayload) => {
|
|
1032
|
+
const coordinator = (taskManager as any).apiAIAssistant;
|
|
1033
|
+
const registration = createPendingRtdRequest(
|
|
1034
|
+
coordinator,
|
|
1035
|
+
summaryPayload.conversationId,
|
|
1036
|
+
inboundType,
|
|
1037
|
+
timeoutCode
|
|
1038
|
+
);
|
|
1039
|
+
const task = createStateMachineTask({
|
|
1040
|
+
...taskDataMock,
|
|
1041
|
+
interactionId: summaryPayload.conversationId,
|
|
1042
|
+
mediaResourceId: summaryPayload.conversationId,
|
|
1043
|
+
});
|
|
1044
|
+
const taskEmitSpy = jest.spyOn(task, 'emit');
|
|
1045
|
+
const taskManagerEmitSpy = jest.spyOn(taskManager, 'emit');
|
|
1046
|
+
|
|
1047
|
+
taskManager.taskCollection[summaryPayload.conversationId] = task;
|
|
1048
|
+
|
|
1049
|
+
taskManager.handleRealtimeWebsocketEvent(
|
|
1050
|
+
JSON.stringify({
|
|
1051
|
+
type: realtimeEvent,
|
|
1052
|
+
data: {data: summaryPayload},
|
|
1053
|
+
})
|
|
1054
|
+
);
|
|
1055
|
+
|
|
1056
|
+
await expect(registration.result).resolves.toStrictEqual(summaryPayload);
|
|
1057
|
+
|
|
1058
|
+
const taskEmittedEvents = taskEmitSpy.mock.calls.map(([eventName]) => eventName);
|
|
1059
|
+
const taskManagerEmittedEvents = taskManagerEmitSpy.mock.calls.map(
|
|
1060
|
+
([eventName]) => eventName
|
|
1061
|
+
);
|
|
1062
|
+
|
|
1063
|
+
[CC_TASK_EVENTS.POST_CALL_SUMMARY, CC_TASK_EVENTS.MID_CALL_SUMMARY].forEach(
|
|
1064
|
+
(summaryInboundEvent) => {
|
|
1065
|
+
expect(taskEmittedEvents).not.toContain(summaryInboundEvent);
|
|
1066
|
+
expect(taskManagerEmittedEvents).not.toContain(summaryInboundEvent);
|
|
1067
|
+
}
|
|
1068
|
+
);
|
|
1069
|
+
}
|
|
1070
|
+
);
|
|
1071
|
+
|
|
1072
|
+
it('should resolve only the matching active mid-call initiator slot through TaskManager RTD routing', async () => {
|
|
1073
|
+
jest.useFakeTimers();
|
|
1074
|
+
const coordinator = (taskManager as any).apiAIAssistant;
|
|
1075
|
+
const conversationId = 'shared-summary-conversation';
|
|
1076
|
+
const midSummaryPayload = {
|
|
1077
|
+
conversationId,
|
|
1078
|
+
adaptiveCard: {body: ['mid-card']},
|
|
1079
|
+
adaptiveCardId: 'mid-adaptive-card-id',
|
|
1080
|
+
editAdaptiveCard: {body: ['mid-edit-card']},
|
|
1081
|
+
editAdaptiveCardId: 'mid-edit-adaptive-card-id',
|
|
1082
|
+
languageCode: 'en-US',
|
|
1083
|
+
summaryText: 'private mid summary',
|
|
1084
|
+
resolution: 'transferred',
|
|
1085
|
+
areTranscriptsAvailable: true,
|
|
1086
|
+
sections: {reasonForTransferOrConsult: 'specialist'},
|
|
1087
|
+
timestamp: 1773807297476,
|
|
1088
|
+
};
|
|
1089
|
+
const postRegistration = createPendingRtdRequest(
|
|
1090
|
+
coordinator,
|
|
1091
|
+
conversationId,
|
|
1092
|
+
'POST_CALL_SUMMARY',
|
|
1093
|
+
'POST_CALL_SUMMARY_TIMEOUT'
|
|
1094
|
+
);
|
|
1095
|
+
const midRegistration = createPendingRtdRequest(
|
|
1096
|
+
coordinator,
|
|
1097
|
+
conversationId,
|
|
1098
|
+
'MID_CALL_SUMMARY',
|
|
1099
|
+
'MID_CALL_SUMMARY_TIMEOUT'
|
|
1100
|
+
);
|
|
1101
|
+
const postResolved = jest.fn();
|
|
1102
|
+
const postRejected = jest.fn();
|
|
1103
|
+
const midResolved = jest.fn();
|
|
1104
|
+
const midRejected = jest.fn();
|
|
1105
|
+
const initiatorTask = createStateMachineTask({
|
|
1106
|
+
...taskDataMock,
|
|
1107
|
+
interactionId: conversationId,
|
|
1108
|
+
mediaResourceId: conversationId,
|
|
1109
|
+
});
|
|
1110
|
+
const initiatorTaskEmitSpy = jest.spyOn(initiatorTask, 'emit');
|
|
1111
|
+
const taskManagerEmitSpy = jest.spyOn(taskManager, 'emit');
|
|
1112
|
+
|
|
1113
|
+
postRegistration.result.then(postResolved, postRejected);
|
|
1114
|
+
midRegistration.result.then(midResolved, midRejected);
|
|
1115
|
+
taskManager.taskCollection = {[conversationId]: initiatorTask};
|
|
1116
|
+
|
|
1117
|
+
taskManager.handleRealtimeWebsocketEvent(
|
|
1118
|
+
JSON.stringify({
|
|
1119
|
+
type: CC_TASK_EVENTS.MID_CALL_SUMMARY,
|
|
1120
|
+
data: {data: midSummaryPayload},
|
|
1121
|
+
})
|
|
1122
|
+
);
|
|
1123
|
+
await expect(midRegistration.result).resolves.toEqual(midSummaryPayload);
|
|
1124
|
+
expect(midRejected).not.toHaveBeenCalled();
|
|
1125
|
+
expect(postResolved).not.toHaveBeenCalled();
|
|
1126
|
+
expect(postRejected).not.toHaveBeenCalled();
|
|
1127
|
+
[CC_TASK_EVENTS.POST_CALL_SUMMARY, CC_TASK_EVENTS.MID_CALL_SUMMARY].forEach(
|
|
1128
|
+
(summaryInboundEvent) => {
|
|
1129
|
+
expect(initiatorTaskEmitSpy).not.toHaveBeenCalledWith(
|
|
1130
|
+
summaryInboundEvent,
|
|
1131
|
+
expect.anything()
|
|
1132
|
+
);
|
|
1133
|
+
expect(taskManagerEmitSpy).not.toHaveBeenCalledWith(summaryInboundEvent, expect.anything());
|
|
1134
|
+
}
|
|
1135
|
+
);
|
|
1136
|
+
|
|
1137
|
+
taskManager.handleRealtimeWebsocketEvent(
|
|
1138
|
+
JSON.stringify({
|
|
1139
|
+
type: CC_TASK_EVENTS.MID_CALL_SUMMARY,
|
|
1140
|
+
data: {data: midSummaryPayload},
|
|
1141
|
+
})
|
|
1142
|
+
);
|
|
1143
|
+
|
|
1144
|
+
expectNoSensitiveDiagnostics('private mid summary', 'specialist');
|
|
1145
|
+
|
|
1146
|
+
expect(coordinator.pendingRequests.size).toBe(1);
|
|
1147
|
+
});
|
|
1148
|
+
|
|
1149
|
+
it('should reject on timeout and drop one later mid-call frame without changing settlement', async () => {
|
|
1150
|
+
jest.useFakeTimers();
|
|
1151
|
+
const coordinator = (taskManager as any).apiAIAssistant;
|
|
1152
|
+
const registration = createPendingRtdRequest(
|
|
1153
|
+
coordinator,
|
|
1154
|
+
'mid-timeout-conversation',
|
|
1155
|
+
'MID_CALL_SUMMARY',
|
|
1156
|
+
'MID_CALL_SUMMARY_TIMEOUT'
|
|
1157
|
+
);
|
|
1158
|
+
const rejection = registration.result.catch((error) => error);
|
|
1159
|
+
|
|
1160
|
+
jest.advanceTimersByTime(AI_SUMMARY_DURATION_MS);
|
|
1161
|
+
|
|
1162
|
+
const timeoutError = await rejection;
|
|
1163
|
+
|
|
1164
|
+
expect(timeoutError).toEqual(
|
|
1165
|
+
expect.objectContaining({
|
|
1166
|
+
message: AI_SUMMARY_ERROR_CODES.MID_CALL_SUMMARY_TIMEOUT,
|
|
1167
|
+
data: {errorCode: AI_SUMMARY_ERROR_CODES.MID_CALL_SUMMARY_TIMEOUT},
|
|
1168
|
+
})
|
|
1169
|
+
);
|
|
1170
|
+
expect(jest.getTimerCount()).toBe(0);
|
|
1171
|
+
|
|
1172
|
+
taskManager.handleRealtimeWebsocketEvent(
|
|
1173
|
+
JSON.stringify({
|
|
1174
|
+
type: CC_TASK_EVENTS.MID_CALL_SUMMARY,
|
|
1175
|
+
data: {
|
|
1176
|
+
data: {
|
|
1177
|
+
conversationId: 'mid-timeout-conversation',
|
|
1178
|
+
summaryText: 'late summary must not resettle the request',
|
|
1179
|
+
},
|
|
1180
|
+
},
|
|
1181
|
+
})
|
|
1182
|
+
);
|
|
1183
|
+
|
|
1184
|
+
await expect(registration.result).rejects.toBe(timeoutError);
|
|
1185
|
+
expect(jest.getTimerCount()).toBe(0);
|
|
1186
|
+
});
|
|
1187
|
+
|
|
1188
|
+
it('should drop late post-call initiator summary events once', async () => {
|
|
1189
|
+
const coordinator = (taskManager as any).apiAIAssistant;
|
|
1190
|
+
const postSummaryPayload = {
|
|
1191
|
+
conversationId: taskId,
|
|
1192
|
+
adaptiveCard: {body: ['card']},
|
|
1193
|
+
adaptiveCardId: 'adaptive-card-id',
|
|
1194
|
+
editAdaptiveCard: {body: ['edit-card']},
|
|
1195
|
+
editAdaptiveCardId: 'edit-adaptive-card-id',
|
|
1196
|
+
languageCode: 'en-US',
|
|
1197
|
+
summaryText: 'safe summary',
|
|
1198
|
+
resolution: 'resolved',
|
|
1199
|
+
areTranscriptsAvailable: true,
|
|
1200
|
+
sections: {initialContactReason: 'billing', safeKey: 'safe value'},
|
|
1201
|
+
suggestedWrapUpCodes: [{name: 'Resolved', id: 'wrap-up-code-id'}],
|
|
1202
|
+
suggestedWrapUpCodesMessage: 'Use resolved wrap-up',
|
|
1203
|
+
timestamp: 1773807297475,
|
|
1204
|
+
};
|
|
1205
|
+
const postRegistration = createPendingRtdRequest(
|
|
1206
|
+
coordinator,
|
|
1207
|
+
taskId,
|
|
1208
|
+
'POST_CALL_SUMMARY',
|
|
1209
|
+
'POST_CALL_SUMMARY_TIMEOUT'
|
|
1210
|
+
);
|
|
1211
|
+
|
|
1212
|
+
taskManager.handleRealtimeWebsocketEvent(
|
|
1213
|
+
JSON.stringify({
|
|
1214
|
+
type: CC_TASK_EVENTS.POST_CALL_SUMMARY,
|
|
1215
|
+
data: {data: postSummaryPayload},
|
|
1216
|
+
})
|
|
1217
|
+
);
|
|
1218
|
+
|
|
1219
|
+
await expect(postRegistration.result).resolves.toStrictEqual(postSummaryPayload);
|
|
1220
|
+
|
|
1221
|
+
taskManager.handleRealtimeWebsocketEvent(
|
|
1222
|
+
JSON.stringify({
|
|
1223
|
+
type: CC_TASK_EVENTS.POST_CALL_SUMMARY,
|
|
1224
|
+
data: {data: postSummaryPayload},
|
|
1225
|
+
})
|
|
1226
|
+
);
|
|
1227
|
+
|
|
1228
|
+
});
|
|
1229
|
+
|
|
1230
|
+
it.each(['target-first', 'other-first'] as const)(
|
|
1231
|
+
'should emit a receiving summary immediately on the answered task indexed by conversation id for %s order',
|
|
1232
|
+
(registryOrder) => {
|
|
1233
|
+
const conversationId = 'conversation-1';
|
|
1234
|
+
const receivingTask = createStateMachineTask({...taskDataMock, interactionId: conversationId});
|
|
1235
|
+
const otherTask = createStateMachineTask({...taskDataMock, interactionId: 'other-conversation'});
|
|
1236
|
+
receivingTask.state = {value: TaskState.CONNECTED};
|
|
1237
|
+
otherTask.state = {value: TaskState.CONNECTED};
|
|
1238
|
+
|
|
1239
|
+
taskManager.taskCollection =
|
|
1240
|
+
registryOrder === 'target-first'
|
|
1241
|
+
? {[conversationId]: receivingTask, [otherTask.data.interactionId]: otherTask}
|
|
1242
|
+
: {[otherTask.data.interactionId]: otherTask, [conversationId]: receivingTask};
|
|
1243
|
+
|
|
1244
|
+
const receivingTaskEmitSpy = jest.spyOn(receivingTask, 'emit');
|
|
1245
|
+
const otherTaskEmitSpy = jest.spyOn(otherTask, 'emit');
|
|
1246
|
+
const receivingPayload = {
|
|
1247
|
+
conversationId,
|
|
1248
|
+
summaryText: 'receiver summary',
|
|
1249
|
+
};
|
|
1250
|
+
|
|
1251
|
+
taskManager.handleRealtimeWebsocketEvent(
|
|
1252
|
+
JSON.stringify({
|
|
1253
|
+
type: 'MID_CALL_SUMMARY_RESPONSE_SUBSEQUENT_AGENT',
|
|
1254
|
+
data: {data: receivingPayload},
|
|
1255
|
+
})
|
|
1256
|
+
);
|
|
1257
|
+
|
|
1258
|
+
expect(receivingTaskEmitSpy).toHaveBeenCalledWith(
|
|
1259
|
+
TASK_EVENTS.TASK_MID_CALL_SUMMARY_RECEIVED,
|
|
1260
|
+
receivingPayload
|
|
1261
|
+
);
|
|
1262
|
+
expect(otherTaskEmitSpy).not.toHaveBeenCalledWith(
|
|
1263
|
+
TASK_EVENTS.TASK_MID_CALL_SUMMARY_RECEIVED,
|
|
1264
|
+
expect.anything()
|
|
1265
|
+
);
|
|
1266
|
+
}
|
|
1267
|
+
);
|
|
1268
|
+
|
|
1269
|
+
it('should emit on the indexed receiving task without logging private summary content', () => {
|
|
1270
|
+
const conversationId = 'conversation-1';
|
|
1271
|
+
const invalidTaskId = 'invalid-correlation-task-id';
|
|
1272
|
+
const invalidTaskPayloadSentinel = 'private-invalid-task-payload';
|
|
1273
|
+
const rawEnvelopeSentinel = 'private-raw-envelope';
|
|
1274
|
+
const summarySentinel = 'private-summary';
|
|
1275
|
+
const sectionKeySentinel = 'private-section-key';
|
|
1276
|
+
const sectionValueSentinel = 'private-section-value';
|
|
1277
|
+
const arbitraryErrorSentinel = 'private-arbitrary-error';
|
|
1278
|
+
const invalidTask = createStateMachineTask({
|
|
1279
|
+
...taskDataMock,
|
|
1280
|
+
taskId: invalidTaskId,
|
|
1281
|
+
interactionId: '',
|
|
1282
|
+
privatePayload: invalidTaskPayloadSentinel,
|
|
1283
|
+
interaction: {
|
|
1284
|
+
mainInteractionId: conversationId,
|
|
1285
|
+
callProcessingDetails: {},
|
|
1286
|
+
},
|
|
1287
|
+
});
|
|
1288
|
+
const validPeerTask = createStateMachineTask({
|
|
1289
|
+
...taskDataMock,
|
|
1290
|
+
interactionId: conversationId,
|
|
1291
|
+
interaction: {
|
|
1292
|
+
mainInteractionId: conversationId,
|
|
1293
|
+
callProcessingDetails: {},
|
|
1294
|
+
},
|
|
1295
|
+
});
|
|
1296
|
+
const receivingPayload = {
|
|
1297
|
+
conversationId,
|
|
1298
|
+
summaryText: summarySentinel,
|
|
1299
|
+
sections: {[sectionKeySentinel]: sectionValueSentinel},
|
|
1300
|
+
error: {message: arbitraryErrorSentinel},
|
|
1301
|
+
};
|
|
1302
|
+
validPeerTask.state = {value: TaskState.CONNECTED};
|
|
1303
|
+
const invalidEmitSpy = jest.spyOn(invalidTask, 'emit');
|
|
1304
|
+
const validPeerEmitSpy = jest.spyOn(validPeerTask, 'emit');
|
|
1305
|
+
|
|
1306
|
+
taskManager.taskCollection = {[invalidTaskId]: invalidTask, [conversationId]: validPeerTask};
|
|
1307
|
+
|
|
1308
|
+
taskManager.handleRealtimeWebsocketEvent(
|
|
1309
|
+
JSON.stringify({
|
|
1310
|
+
type: CC_TASK_EVENTS.MID_CALL_SUMMARY_RESPONSE_SUBSEQUENT_AGENT,
|
|
1311
|
+
data: {data: receivingPayload},
|
|
1312
|
+
rawEnvelope: rawEnvelopeSentinel,
|
|
1313
|
+
})
|
|
1314
|
+
);
|
|
1315
|
+
|
|
1316
|
+
expect(invalidEmitSpy).not.toHaveBeenCalledWith(
|
|
1317
|
+
TASK_EVENTS.TASK_MID_CALL_SUMMARY_RECEIVED,
|
|
1318
|
+
expect.anything()
|
|
1319
|
+
);
|
|
1320
|
+
expect(validPeerEmitSpy).toHaveBeenCalledWith(
|
|
1321
|
+
TASK_EVENTS.TASK_MID_CALL_SUMMARY_RECEIVED,
|
|
1322
|
+
receivingPayload
|
|
1323
|
+
);
|
|
1324
|
+
|
|
1325
|
+
const loggerDiagnostics = JSON.stringify({
|
|
1326
|
+
warnings: getLoggerProxy().warn.mock.calls,
|
|
1327
|
+
errors: getLoggerProxy().error.mock.calls,
|
|
1328
|
+
info: getLoggerProxy().info.mock.calls,
|
|
1329
|
+
logs: getLoggerProxy().log.mock.calls,
|
|
1330
|
+
});
|
|
1331
|
+
|
|
1332
|
+
[
|
|
1333
|
+
invalidTaskPayloadSentinel,
|
|
1334
|
+
rawEnvelopeSentinel,
|
|
1335
|
+
summarySentinel,
|
|
1336
|
+
sectionKeySentinel,
|
|
1337
|
+
sectionValueSentinel,
|
|
1338
|
+
arbitraryErrorSentinel,
|
|
1339
|
+
].forEach((sensitiveValue) => {
|
|
1340
|
+
expect(loggerDiagnostics).not.toContain(sensitiveValue);
|
|
1341
|
+
});
|
|
1342
|
+
});
|
|
1343
|
+
|
|
1344
|
+
it.each([
|
|
1345
|
+
['transferred call', [], CC_EVENTS.AGENT_CONTACT_ASSIGNED, TASK_EVENTS.TASK_ASSIGNED],
|
|
1346
|
+
[
|
|
1347
|
+
'consult',
|
|
1348
|
+
[CC_EVENTS.AGENT_OFFER_CONSULT],
|
|
1349
|
+
CC_EVENTS.AGENT_CONSULTING,
|
|
1350
|
+
TASK_EVENTS.TASK_CONSULT_ACCEPTED,
|
|
1351
|
+
],
|
|
1352
|
+
] as const)(
|
|
1353
|
+
'should keep a receiving summary while the %s is ringing and emit it right after it is answered',
|
|
1354
|
+
(_name, ringingUpdates, answerEvent, answeredTaskEvent) => {
|
|
1355
|
+
taskManager.taskCollection = {};
|
|
1356
|
+
const receivingPayload = {
|
|
1357
|
+
conversationId: 'conversation-1',
|
|
1358
|
+
summaryText: 'buffered receiver summary',
|
|
1359
|
+
};
|
|
1360
|
+
|
|
1361
|
+
taskManager.handleRealtimeWebsocketEvent(
|
|
1362
|
+
JSON.stringify({
|
|
1363
|
+
type: CC_TASK_EVENTS.MID_CALL_SUMMARY_RESPONSE_SUBSEQUENT_AGENT,
|
|
1364
|
+
data: {data: receivingPayload},
|
|
1365
|
+
})
|
|
1366
|
+
);
|
|
1367
|
+
|
|
1368
|
+
const task = createStateMachineTask({
|
|
1369
|
+
...taskDataMock,
|
|
1370
|
+
interactionId: receivingPayload.conversationId,
|
|
1371
|
+
interaction: {
|
|
1372
|
+
mediaType: 'telephony',
|
|
1373
|
+
callProcessingDetails: {},
|
|
1374
|
+
},
|
|
1375
|
+
});
|
|
1376
|
+
const observedOrder: string[] = [];
|
|
1377
|
+
task.on(answeredTaskEvent, () => observedOrder.push('answered'));
|
|
1378
|
+
task.on(TASK_EVENTS.TASK_MID_CALL_SUMMARY_RECEIVED, (payload) =>
|
|
1379
|
+
observedOrder.push(`receiver-summary:${payload.summaryText}`)
|
|
1380
|
+
);
|
|
1381
|
+
(TaskFactory.createTask as jest.Mock).mockReturnValueOnce(task);
|
|
1382
|
+
|
|
1383
|
+
[CC_EVENTS.AGENT_CONTACT_RESERVED, ...ringingUpdates].forEach((ringingEvent) => {
|
|
1384
|
+
webSocketManagerMock.emit('message', JSON.stringify({data: {...task.data, type: ringingEvent}}));
|
|
1385
|
+
});
|
|
1386
|
+
|
|
1387
|
+
expect(task.state).toEqual({value: TaskState.OFFERED});
|
|
1388
|
+
expect(observedOrder).toEqual([]);
|
|
1389
|
+
expect((taskManager as any).receivingSummaryBuffer.get(receivingPayload.conversationId)).toEqual(
|
|
1390
|
+
receivingPayload
|
|
1391
|
+
);
|
|
1392
|
+
|
|
1393
|
+
webSocketManagerMock.emit('message', JSON.stringify({data: {...task.data, type: answerEvent}}));
|
|
1394
|
+
|
|
1395
|
+
expect(observedOrder).toEqual(['answered', 'receiver-summary:buffered receiver summary']);
|
|
1396
|
+
expect((taskManager as any).receivingSummaryBuffer.has(receivingPayload.conversationId)).toBe(
|
|
1397
|
+
false
|
|
1398
|
+
);
|
|
1399
|
+
}
|
|
1400
|
+
);
|
|
1401
|
+
|
|
1402
|
+
it('should keep a receiving summary that arrives while its task is ringing until the task is answered', () => {
|
|
1403
|
+
const conversationId = 'conversation-1';
|
|
1404
|
+
const task = createStateMachineTask({...taskDataMock, interactionId: conversationId});
|
|
1405
|
+
task.state = {value: TaskState.OFFERED};
|
|
1406
|
+
taskManager.taskCollection = {[conversationId]: task};
|
|
1407
|
+
const receiverHandler = jest.fn();
|
|
1408
|
+
task.on(TASK_EVENTS.TASK_MID_CALL_SUMMARY_RECEIVED, receiverHandler);
|
|
1409
|
+
const receivingPayload = {conversationId, summaryText: 'ringing receiver summary'};
|
|
1410
|
+
|
|
1411
|
+
taskManager.handleRealtimeWebsocketEvent(
|
|
1412
|
+
JSON.stringify({
|
|
1413
|
+
type: CC_TASK_EVENTS.MID_CALL_SUMMARY_RESPONSE_SUBSEQUENT_AGENT,
|
|
1414
|
+
data: {data: receivingPayload},
|
|
1415
|
+
})
|
|
1416
|
+
);
|
|
1417
|
+
|
|
1418
|
+
expect(receiverHandler).not.toHaveBeenCalled();
|
|
1419
|
+
expect((taskManager as any).receivingSummaryBuffer.get(conversationId)).toEqual(receivingPayload);
|
|
1420
|
+
|
|
1421
|
+
webSocketManagerMock.emit(
|
|
1422
|
+
'message',
|
|
1423
|
+
JSON.stringify({data: {...task.data, type: CC_EVENTS.AGENT_CONTACT_ASSIGNED}})
|
|
1424
|
+
);
|
|
1425
|
+
|
|
1426
|
+
expect(receiverHandler).toHaveBeenCalledTimes(1);
|
|
1427
|
+
expect(receiverHandler).toHaveBeenCalledWith(receivingPayload);
|
|
1428
|
+
});
|
|
1429
|
+
|
|
1430
|
+
it.each([
|
|
1431
|
+
['the offer is not answered in time (RONA)', [], CC_EVENTS.AGENT_CONTACT_OFFER_RONA],
|
|
1432
|
+
['the offer is declined', [], CC_EVENTS.AGENT_CONTACT_ASSIGN_FAILED],
|
|
1433
|
+
[
|
|
1434
|
+
'the consult is cancelled before it is accepted',
|
|
1435
|
+
[CC_EVENTS.AGENT_OFFER_CONSULT],
|
|
1436
|
+
CC_EVENTS.AGENT_CONSULT_ENDED,
|
|
1437
|
+
],
|
|
1438
|
+
] as const)(
|
|
1439
|
+
'should drop a receiving summary held for a ringing task when %s',
|
|
1440
|
+
(_name, ringingUpdates, endEvent) => {
|
|
1441
|
+
taskManager.taskCollection = {};
|
|
1442
|
+
const receivingPayload = {
|
|
1443
|
+
conversationId: 'conversation-1',
|
|
1444
|
+
summaryText: 'unanswered receiver summary',
|
|
1445
|
+
};
|
|
1446
|
+
|
|
1447
|
+
taskManager.handleRealtimeWebsocketEvent(
|
|
1448
|
+
JSON.stringify({
|
|
1449
|
+
type: CC_TASK_EVENTS.MID_CALL_SUMMARY_RESPONSE_SUBSEQUENT_AGENT,
|
|
1450
|
+
data: {data: receivingPayload},
|
|
1451
|
+
})
|
|
1452
|
+
);
|
|
1453
|
+
|
|
1454
|
+
const task = createStateMachineTask({
|
|
1455
|
+
...taskDataMock,
|
|
1456
|
+
interactionId: receivingPayload.conversationId,
|
|
1457
|
+
isConsulted: ringingUpdates.length > 0,
|
|
1458
|
+
interaction: {
|
|
1459
|
+
mediaType: 'telephony',
|
|
1460
|
+
callProcessingDetails: {},
|
|
1461
|
+
},
|
|
1462
|
+
});
|
|
1463
|
+
const receiverHandler = jest.fn();
|
|
1464
|
+
task.on(TASK_EVENTS.TASK_MID_CALL_SUMMARY_RECEIVED, receiverHandler);
|
|
1465
|
+
(TaskFactory.createTask as jest.Mock).mockReturnValueOnce(task);
|
|
1466
|
+
|
|
1467
|
+
[CC_EVENTS.AGENT_CONTACT_RESERVED, ...ringingUpdates, endEvent].forEach((event) => {
|
|
1468
|
+
webSocketManagerMock.emit('message', JSON.stringify({data: {...task.data, type: event}}));
|
|
1469
|
+
});
|
|
1470
|
+
|
|
1471
|
+
expect(receiverHandler).not.toHaveBeenCalled();
|
|
1472
|
+
expect((taskManager as any).receivingSummaryBuffer.has(receivingPayload.conversationId)).toBe(
|
|
1473
|
+
false
|
|
1474
|
+
);
|
|
1475
|
+
}
|
|
1476
|
+
);
|
|
1477
|
+
|
|
1478
|
+
it('should keep a stored receiving summary while an unrelated task is updated', () => {
|
|
1479
|
+
jest.useFakeTimers();
|
|
1480
|
+
const conversationId = 'pending-summary-conversation';
|
|
1481
|
+
const receivingPayload = {
|
|
1482
|
+
conversationId,
|
|
1483
|
+
summaryText: 'pending receiver summary',
|
|
1484
|
+
};
|
|
1485
|
+
|
|
1486
|
+
taskManager.taskCollection = {};
|
|
1487
|
+
taskManager.handleRealtimeWebsocketEvent(
|
|
1488
|
+
JSON.stringify({
|
|
1489
|
+
type: CC_TASK_EVENTS.MID_CALL_SUMMARY_RESPONSE_SUBSEQUENT_AGENT,
|
|
1490
|
+
data: {data: receivingPayload},
|
|
1491
|
+
})
|
|
1492
|
+
);
|
|
1493
|
+
|
|
1494
|
+
const unrelatedTask = createStateMachineTask({...taskDataMock, interactionId: 'unrelated-task'});
|
|
1495
|
+
const unrelatedTaskEmitSpy = jest.spyOn(unrelatedTask, 'emit');
|
|
1496
|
+
taskManager.taskCollection = {
|
|
1497
|
+
[unrelatedTask.data.interactionId]: unrelatedTask,
|
|
1498
|
+
};
|
|
1499
|
+
|
|
1500
|
+
webSocketManagerMock.emit(
|
|
1501
|
+
'message',
|
|
1502
|
+
JSON.stringify({data: {...unrelatedTask.data, type: CC_EVENTS.AGENT_CONTACT_UNASSIGNED}})
|
|
1503
|
+
);
|
|
1504
|
+
|
|
1505
|
+
expect(unrelatedTaskEmitSpy).not.toHaveBeenCalledWith(
|
|
1506
|
+
TASK_EVENTS.TASK_MID_CALL_SUMMARY_RECEIVED,
|
|
1507
|
+
expect.anything()
|
|
1508
|
+
);
|
|
1509
|
+
expect((taskManager as any).receivingSummaryBuffer.get(conversationId)).toEqual(receivingPayload);
|
|
1510
|
+
expect(jest.getTimerCount()).toBe(0);
|
|
1511
|
+
});
|
|
1512
|
+
|
|
1513
|
+
it('should replay a stored receiving summary when an unmapped update rekeys the task to its conversation id', () => {
|
|
1514
|
+
jest.useFakeTimers();
|
|
1515
|
+
const conversationId = 'unmapped-update-conversation';
|
|
1516
|
+
const task = taskManager.getTask(taskId);
|
|
1517
|
+
const receiverHandler = jest.fn();
|
|
1518
|
+
const sendStateMachineEventSpy = task.sendStateMachineEvent as jest.Mock;
|
|
1519
|
+
const receivingPayload = {
|
|
1520
|
+
conversationId,
|
|
1521
|
+
summaryText: 'buffered unmapped update summary',
|
|
1522
|
+
};
|
|
1523
|
+
|
|
1524
|
+
task.on(TASK_EVENTS.TASK_MID_CALL_SUMMARY_RECEIVED, receiverHandler);
|
|
1525
|
+
sendStateMachineEventSpy.mockClear();
|
|
1526
|
+
|
|
1527
|
+
taskManager.handleRealtimeWebsocketEvent(
|
|
1528
|
+
JSON.stringify({
|
|
1529
|
+
type: CC_TASK_EVENTS.MID_CALL_SUMMARY_RESPONSE_SUBSEQUENT_AGENT,
|
|
1530
|
+
data: {data: receivingPayload},
|
|
1531
|
+
})
|
|
1532
|
+
);
|
|
1533
|
+
|
|
1534
|
+
expect(jest.getTimerCount()).toBe(0);
|
|
1535
|
+
expect((taskManager as any).receivingSummaryBuffer.get(conversationId)).toEqual(receivingPayload);
|
|
1536
|
+
|
|
1537
|
+
webSocketManagerMock.emit(
|
|
1538
|
+
'message',
|
|
1539
|
+
JSON.stringify({
|
|
1540
|
+
data: {
|
|
1541
|
+
...taskDataMock,
|
|
1542
|
+
type: CC_EVENTS.AGENT_CONTACT_UNASSIGNED,
|
|
1543
|
+
interactionId: conversationId,
|
|
1544
|
+
reservationInteractionId: taskId,
|
|
1545
|
+
mediaResourceId: conversationId,
|
|
1546
|
+
interaction: {
|
|
1547
|
+
mediaType: 'telephony',
|
|
1548
|
+
callProcessingDetails: {},
|
|
1549
|
+
},
|
|
1550
|
+
},
|
|
1551
|
+
})
|
|
1552
|
+
);
|
|
1553
|
+
|
|
1554
|
+
expect(sendStateMachineEventSpy).not.toHaveBeenCalled();
|
|
1555
|
+
expect(receiverHandler).toHaveBeenCalledTimes(1);
|
|
1556
|
+
expect(receiverHandler).toHaveBeenCalledWith(receivingPayload);
|
|
1557
|
+
expect(jest.getTimerCount()).toBe(0);
|
|
1558
|
+
});
|
|
1559
|
+
|
|
1560
|
+
it('should clear reservation feature state and flush buffered receiver summary after reservation re-key', () => {
|
|
1561
|
+
jest.useFakeTimers();
|
|
1562
|
+
taskManager.taskCollection = {};
|
|
1563
|
+
const coordinator = (taskManager as any).apiAIAssistant;
|
|
1564
|
+
const reservationInteractionId = 'reservation-rekey-id';
|
|
1565
|
+
const assignedInteractionId = 'assigned-rekey-id';
|
|
1566
|
+
const conversationId = assignedInteractionId;
|
|
1567
|
+
const featurePayload = {
|
|
1568
|
+
interactionId: reservationInteractionId,
|
|
1569
|
+
postCallEnabled: true,
|
|
1570
|
+
midCallEnabled: true,
|
|
1571
|
+
};
|
|
1572
|
+
const assignedFeaturePayload = {
|
|
1573
|
+
interactionId: assignedInteractionId,
|
|
1574
|
+
postCallEnabled: false,
|
|
1575
|
+
midCallEnabled: true,
|
|
1576
|
+
};
|
|
1577
|
+
const receivingPayload = {
|
|
1578
|
+
conversationId,
|
|
1579
|
+
summaryText: 'buffered reservation rekey summary',
|
|
1580
|
+
};
|
|
1581
|
+
const receiverHandler = jest.fn();
|
|
1582
|
+
|
|
1583
|
+
webSocketManagerMock.emit(
|
|
1584
|
+
'message',
|
|
1585
|
+
JSON.stringify({
|
|
1586
|
+
data: {
|
|
1587
|
+
...taskDataMock,
|
|
1588
|
+
type: CC_EVENTS.AGENT_OFFER_CAMPAIGN_RESERVATION,
|
|
1589
|
+
interactionId: reservationInteractionId,
|
|
1590
|
+
mediaResourceId: reservationInteractionId,
|
|
1591
|
+
interaction: {
|
|
1592
|
+
mediaType: 'telephony',
|
|
1593
|
+
callProcessingDetails: {},
|
|
1594
|
+
},
|
|
1595
|
+
},
|
|
1596
|
+
})
|
|
1597
|
+
);
|
|
1598
|
+
|
|
1599
|
+
const reservationTask = taskManager.getTask(reservationInteractionId);
|
|
1600
|
+
reservationTask.on(TASK_EVENTS.TASK_MID_CALL_SUMMARY_RECEIVED, receiverHandler);
|
|
1601
|
+
const featureHandler = jest.fn();
|
|
1602
|
+
reservationTask.on(TASK_EVENTS.TASK_FEATURE_ENABLEMENT, featureHandler);
|
|
1603
|
+
|
|
1604
|
+
taskManager.handleRealtimeWebsocketEvent(
|
|
1605
|
+
JSON.stringify({
|
|
1606
|
+
type: CC_TASK_EVENTS.FEATURE_ENABLEMENT,
|
|
1607
|
+
data: {data: featurePayload},
|
|
1608
|
+
})
|
|
1609
|
+
);
|
|
1610
|
+
|
|
1611
|
+
expect(reservationTask.setFeatureEnablement).toHaveBeenCalledWith(featurePayload);
|
|
1612
|
+
expect(jest.getTimerCount()).toBe(0);
|
|
1613
|
+
|
|
1614
|
+
taskManager.handleRealtimeWebsocketEvent(
|
|
1615
|
+
JSON.stringify({
|
|
1616
|
+
type: CC_TASK_EVENTS.FEATURE_ENABLEMENT,
|
|
1617
|
+
data: {data: assignedFeaturePayload},
|
|
1618
|
+
})
|
|
1619
|
+
);
|
|
1620
|
+
|
|
1621
|
+
expect((taskManager as any).pendingFeatureEnablement.get(assignedInteractionId)).toEqual(
|
|
1622
|
+
assignedFeaturePayload
|
|
1623
|
+
);
|
|
1624
|
+
expect(jest.getTimerCount()).toBe(0);
|
|
1625
|
+
|
|
1626
|
+
taskManager.handleRealtimeWebsocketEvent(
|
|
1627
|
+
JSON.stringify({
|
|
1628
|
+
type: CC_TASK_EVENTS.MID_CALL_SUMMARY_RESPONSE_SUBSEQUENT_AGENT,
|
|
1629
|
+
data: {data: receivingPayload},
|
|
1630
|
+
})
|
|
1631
|
+
);
|
|
1632
|
+
|
|
1633
|
+
expect(jest.getTimerCount()).toBe(0);
|
|
1634
|
+
expect((taskManager as any).receivingSummaryBuffer.get(conversationId)).toEqual(receivingPayload);
|
|
1635
|
+
|
|
1636
|
+
webSocketManagerMock.emit(
|
|
1637
|
+
'message',
|
|
1638
|
+
JSON.stringify({
|
|
1639
|
+
data: {
|
|
1640
|
+
...taskDataMock,
|
|
1641
|
+
type: CC_EVENTS.AGENT_CONTACT_ASSIGNED,
|
|
1642
|
+
interactionId: assignedInteractionId,
|
|
1643
|
+
reservationInteractionId,
|
|
1644
|
+
mediaResourceId: assignedInteractionId,
|
|
1645
|
+
interaction: {
|
|
1646
|
+
mediaType: 'telephony',
|
|
1647
|
+
callProcessingDetails: {},
|
|
1648
|
+
},
|
|
1649
|
+
},
|
|
1650
|
+
})
|
|
1651
|
+
);
|
|
1652
|
+
|
|
1653
|
+
expect(taskManager.getTask(assignedInteractionId)).toBe(reservationTask);
|
|
1654
|
+
expect(taskManager.getTask(reservationInteractionId)).toBeUndefined();
|
|
1655
|
+
expect((taskManager as any).pendingFeatureEnablement.get(reservationInteractionId)).toBeUndefined();
|
|
1656
|
+
expect(featureHandler).toHaveBeenCalledWith(assignedFeaturePayload);
|
|
1657
|
+
expect(receiverHandler).toHaveBeenCalledTimes(1);
|
|
1658
|
+
expect(receiverHandler).toHaveBeenCalledWith(receivingPayload);
|
|
1659
|
+
expect(jest.getTimerCount()).toBe(0);
|
|
1660
|
+
});
|
|
1661
|
+
|
|
1662
|
+
it('should retain the latest unmatched receiving summary until its task is answered without an expiry timer', () => {
|
|
1663
|
+
jest.useFakeTimers();
|
|
1664
|
+
const unmatchedTask = createStateMachineTask({...taskDataMock, interactionId: 'other-conversation'});
|
|
1665
|
+
const unmatchedTaskEmitSpy = jest.spyOn(unmatchedTask, 'emit');
|
|
1666
|
+
const taskManagerEmitSpy = jest.spyOn(taskManager, 'emit');
|
|
1667
|
+
const receivingPayload = {
|
|
1668
|
+
conversationId: 'conversation-1',
|
|
1669
|
+
summaryText: 'private retained receiver summary',
|
|
1670
|
+
};
|
|
1671
|
+
const latestPayload = {...receivingPayload, summaryText: 'private latest receiver summary'};
|
|
1672
|
+
|
|
1673
|
+
taskManager.taskCollection = {[unmatchedTask.data.interactionId]: unmatchedTask};
|
|
1674
|
+
|
|
1675
|
+
taskManager.handleRealtimeWebsocketEvent(
|
|
1676
|
+
JSON.stringify({
|
|
1677
|
+
type: CC_TASK_EVENTS.MID_CALL_SUMMARY_RESPONSE_SUBSEQUENT_AGENT,
|
|
1678
|
+
data: {data: receivingPayload},
|
|
1679
|
+
})
|
|
1680
|
+
);
|
|
1681
|
+
taskManager.handleRealtimeWebsocketEvent(
|
|
1682
|
+
JSON.stringify({
|
|
1683
|
+
type: CC_TASK_EVENTS.MID_CALL_SUMMARY_RESPONSE_SUBSEQUENT_AGENT,
|
|
1684
|
+
data: {data: latestPayload},
|
|
1685
|
+
})
|
|
1686
|
+
);
|
|
1687
|
+
|
|
1688
|
+
expect(unmatchedTaskEmitSpy).not.toHaveBeenCalledWith(
|
|
1689
|
+
TASK_EVENTS.TASK_MID_CALL_SUMMARY_RECEIVED,
|
|
1690
|
+
expect.anything()
|
|
1691
|
+
);
|
|
1692
|
+
expect(taskManagerEmitSpy).not.toHaveBeenCalledWith(
|
|
1693
|
+
TASK_EVENTS.TASK_MID_CALL_SUMMARY_RECEIVED,
|
|
1694
|
+
expect.anything()
|
|
1695
|
+
);
|
|
1696
|
+
expect(jest.getTimerCount()).toBe(0);
|
|
1697
|
+
|
|
1698
|
+
jest.advanceTimersByTime(AI_SUMMARY_DURATION_MS * 2);
|
|
1699
|
+
expect((taskManager as any).receivingSummaryBuffer.get(receivingPayload.conversationId)).toEqual(
|
|
1700
|
+
latestPayload
|
|
1701
|
+
);
|
|
1702
|
+
|
|
1703
|
+
const receiverTask = createStateMachineTask({
|
|
1704
|
+
...taskDataMock,
|
|
1705
|
+
interactionId: receivingPayload.conversationId,
|
|
1706
|
+
interaction: {mediaType: 'telephony'},
|
|
1707
|
+
});
|
|
1708
|
+
const receiverHandler = jest.fn();
|
|
1709
|
+
(TaskFactory.createTask as jest.Mock).mockReturnValueOnce(receiverTask);
|
|
1710
|
+
taskManager.once(TASK_EVENTS.TASK_INCOMING, (publishedTask) => {
|
|
1711
|
+
publishedTask.on(TASK_EVENTS.TASK_MID_CALL_SUMMARY_RECEIVED, receiverHandler);
|
|
1712
|
+
});
|
|
1713
|
+
|
|
1714
|
+
webSocketManagerMock.emit(
|
|
1715
|
+
'message',
|
|
1716
|
+
JSON.stringify({data: {...receiverTask.data, type: CC_EVENTS.AGENT_CONTACT_RESERVED}})
|
|
1717
|
+
);
|
|
1718
|
+
|
|
1719
|
+
expect(receiverHandler).not.toHaveBeenCalled();
|
|
1720
|
+
expect((taskManager as any).receivingSummaryBuffer.get(receivingPayload.conversationId)).toEqual(
|
|
1721
|
+
latestPayload
|
|
1722
|
+
);
|
|
1723
|
+
|
|
1724
|
+
webSocketManagerMock.emit(
|
|
1725
|
+
'message',
|
|
1726
|
+
JSON.stringify({data: {...receiverTask.data, type: CC_EVENTS.AGENT_CONTACT_ASSIGNED}})
|
|
1727
|
+
);
|
|
1728
|
+
|
|
1729
|
+
expect(receiverHandler).toHaveBeenCalledTimes(1);
|
|
1730
|
+
expect(receiverHandler).toHaveBeenCalledWith(latestPayload);
|
|
1731
|
+
expect(
|
|
1732
|
+
(taskManager as any).receivingSummaryBuffer.has(receivingPayload.conversationId)
|
|
1733
|
+
).toBe(false);
|
|
1734
|
+
|
|
1735
|
+
expect(unmatchedTaskEmitSpy).not.toHaveBeenCalledWith(
|
|
1736
|
+
TASK_EVENTS.TASK_MID_CALL_SUMMARY_RECEIVED,
|
|
1737
|
+
expect.anything()
|
|
1738
|
+
);
|
|
1739
|
+
expect(taskManagerEmitSpy).not.toHaveBeenCalledWith(
|
|
1740
|
+
TASK_EVENTS.TASK_MID_CALL_SUMMARY_RECEIVED,
|
|
1741
|
+
expect.anything()
|
|
1742
|
+
);
|
|
1743
|
+
expectNoSensitiveDiagnostics(receivingPayload.summaryText, latestPayload.summaryText);
|
|
1744
|
+
expect(jest.getTimerCount()).toBe(0);
|
|
1745
|
+
});
|
|
1746
|
+
|
|
1747
|
+
it.each([
|
|
1748
|
+
[
|
|
1749
|
+
'reserved contact',
|
|
1750
|
+
CC_EVENTS.AGENT_CONTACT_RESERVED,
|
|
1751
|
+
TASK_EVENTS.TASK_INCOMING,
|
|
1752
|
+
'receiver-reserved-task',
|
|
1753
|
+
],
|
|
1754
|
+
[
|
|
1755
|
+
'campaign preview reservation',
|
|
1756
|
+
CC_EVENTS.AGENT_OFFER_CAMPAIGN_RESERVATION,
|
|
1757
|
+
TASK_EVENTS.TASK_CAMPAIGN_PREVIEW_RESERVATION,
|
|
1758
|
+
'receiver-campaign-task',
|
|
1759
|
+
],
|
|
1760
|
+
] as const)(
|
|
1761
|
+
'should keep buffered receiving summaries after %s publication until the task is answered',
|
|
1762
|
+
(_name, eventType, publicationEvent, interactionId) => {
|
|
1763
|
+
taskManager.taskCollection = {};
|
|
1764
|
+
const receivingPayload = {
|
|
1765
|
+
conversationId: interactionId,
|
|
1766
|
+
summaryText: `buffered summary for ${interactionId}`,
|
|
1767
|
+
};
|
|
1768
|
+
const deliveryHandler = jest.fn();
|
|
1769
|
+
|
|
1770
|
+
taskManager.handleRealtimeWebsocketEvent(
|
|
1771
|
+
JSON.stringify({
|
|
1772
|
+
type: CC_TASK_EVENTS.MID_CALL_SUMMARY_RESPONSE_SUBSEQUENT_AGENT,
|
|
1773
|
+
data: {data: receivingPayload},
|
|
1774
|
+
})
|
|
1775
|
+
);
|
|
1776
|
+
|
|
1777
|
+
taskManager.on(publicationEvent, (publishedTask) => {
|
|
1778
|
+
publishedTask.on(TASK_EVENTS.TASK_MID_CALL_SUMMARY_RECEIVED, deliveryHandler);
|
|
1779
|
+
});
|
|
1780
|
+
|
|
1781
|
+
webSocketManagerMock.emit(
|
|
1782
|
+
'message',
|
|
1783
|
+
JSON.stringify({
|
|
1784
|
+
data: {
|
|
1785
|
+
...taskDataMock,
|
|
1786
|
+
type: eventType,
|
|
1787
|
+
interactionId,
|
|
1788
|
+
mediaResourceId: interactionId,
|
|
1789
|
+
interaction: {
|
|
1790
|
+
mediaType: 'telephony',
|
|
1791
|
+
callProcessingDetails: {},
|
|
1792
|
+
},
|
|
1793
|
+
},
|
|
1794
|
+
})
|
|
1795
|
+
);
|
|
1796
|
+
|
|
1797
|
+
expect(deliveryHandler).not.toHaveBeenCalled();
|
|
1798
|
+
expect((taskManager as any).receivingSummaryBuffer.get(interactionId)).toEqual(receivingPayload);
|
|
1799
|
+
}
|
|
1800
|
+
);
|
|
1801
|
+
|
|
1802
|
+
it.each([
|
|
1803
|
+
[
|
|
1804
|
+
'agent contact hydrate',
|
|
1805
|
+
CC_EVENTS.AGENT_CONTACT,
|
|
1806
|
+
TASK_EVENTS.TASK_HYDRATE,
|
|
1807
|
+
'receiver-agent-contact-task',
|
|
1808
|
+
],
|
|
1809
|
+
] as const)(
|
|
1810
|
+
'should flush buffered receiving summaries once after %s publication of an answered task',
|
|
1811
|
+
(_name, eventType, publicationEvent, interactionId) => {
|
|
1812
|
+
jest.useFakeTimers();
|
|
1813
|
+
taskManager.taskCollection = {};
|
|
1814
|
+
const receivingPayload = {
|
|
1815
|
+
conversationId: interactionId,
|
|
1816
|
+
summaryText: `buffered summary for ${interactionId}`,
|
|
1817
|
+
};
|
|
1818
|
+
const deliveryHandler = jest.fn();
|
|
1819
|
+
const observedOrder: string[] = [];
|
|
1820
|
+
|
|
1821
|
+
taskManager.handleRealtimeWebsocketEvent(
|
|
1822
|
+
JSON.stringify({
|
|
1823
|
+
type: CC_TASK_EVENTS.MID_CALL_SUMMARY_RESPONSE_SUBSEQUENT_AGENT,
|
|
1824
|
+
data: {data: receivingPayload},
|
|
1825
|
+
})
|
|
1826
|
+
);
|
|
1827
|
+
|
|
1828
|
+
taskManager.on(publicationEvent, (publishedTask) => {
|
|
1829
|
+
observedOrder.push('published');
|
|
1830
|
+
expect(publishedTask).toBe(taskManager.getTask(interactionId));
|
|
1831
|
+
publishedTask.on(TASK_EVENTS.TASK_MID_CALL_SUMMARY_RECEIVED, (payload) => {
|
|
1832
|
+
observedOrder.push('receiver-summary');
|
|
1833
|
+
deliveryHandler(payload);
|
|
1834
|
+
});
|
|
1835
|
+
});
|
|
1836
|
+
|
|
1837
|
+
webSocketManagerMock.emit(
|
|
1838
|
+
'message',
|
|
1839
|
+
JSON.stringify({
|
|
1840
|
+
data: {
|
|
1841
|
+
...taskDataMock,
|
|
1842
|
+
type: eventType,
|
|
1843
|
+
interactionId,
|
|
1844
|
+
mediaResourceId: interactionId,
|
|
1845
|
+
interaction: {
|
|
1846
|
+
mediaType: 'telephony',
|
|
1847
|
+
callProcessingDetails: {},
|
|
1848
|
+
},
|
|
1849
|
+
},
|
|
1850
|
+
})
|
|
1851
|
+
);
|
|
1852
|
+
|
|
1853
|
+
expect(observedOrder).toEqual(['published', 'receiver-summary']);
|
|
1854
|
+
expect(deliveryHandler).toHaveBeenCalledTimes(1);
|
|
1855
|
+
expect(deliveryHandler).toHaveBeenCalledWith(receivingPayload);
|
|
1856
|
+
expect(jest.getTimerCount()).toBe(0);
|
|
1857
|
+
}
|
|
1858
|
+
);
|
|
1859
|
+
|
|
1860
|
+
it('should flush buffered receiving summaries once after TASK_MERGED publication', () => {
|
|
1861
|
+
jest.useFakeTimers();
|
|
1862
|
+
taskManager.taskCollection = {};
|
|
1863
|
+
const interactionId = 'receiver-merged-task';
|
|
1864
|
+
const receivingPayload = {
|
|
1865
|
+
conversationId: interactionId,
|
|
1866
|
+
summaryText: 'buffered summary for merged task',
|
|
1867
|
+
};
|
|
1868
|
+
const deliveryHandler = jest.fn();
|
|
1869
|
+
const observedOrder: string[] = [];
|
|
1870
|
+
|
|
1871
|
+
taskManager.handleRealtimeWebsocketEvent(
|
|
1872
|
+
JSON.stringify({
|
|
1873
|
+
type: CC_TASK_EVENTS.MID_CALL_SUMMARY_RESPONSE_SUBSEQUENT_AGENT,
|
|
1874
|
+
data: {data: receivingPayload},
|
|
1875
|
+
})
|
|
1876
|
+
);
|
|
1877
|
+
|
|
1878
|
+
taskManager.on(TASK_EVENTS.TASK_MERGED, (publishedTask) => {
|
|
1879
|
+
observedOrder.push('published');
|
|
1880
|
+
expect(publishedTask).toBe(taskManager.getTask(interactionId));
|
|
1881
|
+
publishedTask.on(TASK_EVENTS.TASK_MID_CALL_SUMMARY_RECEIVED, (payload) => {
|
|
1882
|
+
observedOrder.push('receiver-summary');
|
|
1883
|
+
deliveryHandler(payload);
|
|
1884
|
+
});
|
|
1885
|
+
});
|
|
1886
|
+
|
|
1887
|
+
webSocketManagerMock.emit(
|
|
1888
|
+
'message',
|
|
1889
|
+
JSON.stringify({
|
|
1890
|
+
data: {
|
|
1891
|
+
...taskDataMock,
|
|
1892
|
+
type: CC_EVENTS.CONTACT_MERGED,
|
|
1893
|
+
interactionId,
|
|
1894
|
+
mediaResourceId: interactionId,
|
|
1895
|
+
interaction: {
|
|
1896
|
+
mediaType: 'telephony',
|
|
1897
|
+
callProcessingDetails: {},
|
|
1898
|
+
},
|
|
1899
|
+
},
|
|
1900
|
+
})
|
|
1901
|
+
);
|
|
1902
|
+
|
|
1903
|
+
expect(observedOrder).toEqual(['published', 'receiver-summary']);
|
|
1904
|
+
expect(deliveryHandler).toHaveBeenCalledTimes(1);
|
|
1905
|
+
expect(deliveryHandler).toHaveBeenCalledWith(receivingPayload);
|
|
1906
|
+
expect(jest.getTimerCount()).toBe(0);
|
|
1907
|
+
});
|
|
1908
|
+
|
|
1909
|
+
it('should retain a summary for a missing task without logging its private content', () => {
|
|
1910
|
+
const receivingPayload = {
|
|
1911
|
+
conversationId: 'missing-conversation',
|
|
1912
|
+
summaryText: 'private-summary',
|
|
1913
|
+
sections: {privateKey: 'private-value'},
|
|
1914
|
+
adaptiveCard: {body: ['private-card']},
|
|
1915
|
+
agentName: 'private-agent',
|
|
1916
|
+
};
|
|
1917
|
+
taskManager.taskCollection = {};
|
|
1918
|
+
|
|
1919
|
+
taskManager.handleRealtimeWebsocketEvent(
|
|
1920
|
+
JSON.stringify({
|
|
1921
|
+
type: CC_TASK_EVENTS.MID_CALL_SUMMARY_RESPONSE_SUBSEQUENT_AGENT,
|
|
1922
|
+
data: {data: receivingPayload},
|
|
1923
|
+
})
|
|
1924
|
+
);
|
|
1925
|
+
|
|
1926
|
+
expect((taskManager as any).receivingSummaryBuffer.get(receivingPayload.conversationId)).toEqual(
|
|
1927
|
+
receivingPayload
|
|
1928
|
+
);
|
|
1929
|
+
expectNoSensitiveDiagnostics('private-summary', 'private-value', 'private-card', 'private-agent');
|
|
1930
|
+
});
|
|
1931
|
+
|
|
1932
|
+
it('should clear AI summary state, cancel pending requests, and clear buffered frames', async () => {
|
|
1933
|
+
jest.useFakeTimers();
|
|
1934
|
+
const coordinator = (taskManager as any).apiAIAssistant;
|
|
1935
|
+
const postRegistration = createPendingRtdRequest(
|
|
1936
|
+
coordinator,
|
|
1937
|
+
taskId,
|
|
1938
|
+
'POST_CALL_SUMMARY',
|
|
1939
|
+
'POST_CALL_SUMMARY_TIMEOUT'
|
|
1940
|
+
);
|
|
1941
|
+
const midRegistration = createPendingRtdRequest(
|
|
1942
|
+
coordinator,
|
|
1943
|
+
taskId,
|
|
1944
|
+
'MID_CALL_SUMMARY',
|
|
1945
|
+
'MID_CALL_SUMMARY_TIMEOUT'
|
|
1946
|
+
);
|
|
1947
|
+
|
|
1948
|
+
const pendingFeatureInteractionId = 'pending-feature-interaction';
|
|
1949
|
+
taskManager.handleRealtimeWebsocketEvent(
|
|
1950
|
+
JSON.stringify({
|
|
1951
|
+
type: CC_TASK_EVENTS.FEATURE_ENABLEMENT,
|
|
1952
|
+
data: {data: {interactionId: pendingFeatureInteractionId, postCallEnabled: true}},
|
|
1953
|
+
})
|
|
1954
|
+
);
|
|
1955
|
+
taskManager.handleRealtimeWebsocketEvent(
|
|
1956
|
+
JSON.stringify({
|
|
1957
|
+
type: CC_TASK_EVENTS.MID_CALL_SUMMARY_RESPONSE_SUBSEQUENT_AGENT,
|
|
1958
|
+
data: {data: {conversationId: 'conversation-1', summaryText: 'summary'}},
|
|
1959
|
+
})
|
|
1960
|
+
);
|
|
1961
|
+
expect(jest.getTimerCount()).toBe(2);
|
|
1962
|
+
|
|
1963
|
+
taskManager.clearAISummaryState();
|
|
1964
|
+
|
|
1965
|
+
await expect(postRegistration.result).rejects.toEqual(
|
|
1966
|
+
expect.objectContaining({
|
|
1967
|
+
message: AI_SUMMARY_REQUEST_CANCELLED,
|
|
1968
|
+
data: {errorCode: AI_SUMMARY_REQUEST_CANCELLED},
|
|
1969
|
+
})
|
|
1970
|
+
);
|
|
1971
|
+
await expect(midRegistration.result).rejects.toEqual(
|
|
1972
|
+
expect.objectContaining({
|
|
1973
|
+
message: AI_SUMMARY_REQUEST_CANCELLED,
|
|
1974
|
+
data: {errorCode: AI_SUMMARY_REQUEST_CANCELLED},
|
|
1975
|
+
})
|
|
1976
|
+
);
|
|
1977
|
+
expect((coordinator as any).pendingRequests.size).toBe(0);
|
|
1978
|
+
expect((taskManager as any).receivingSummaryBuffer.size).toBe(0);
|
|
1979
|
+
expect((taskManager as any).pendingFeatureEnablement.size).toBe(0);
|
|
1980
|
+
expect(jest.getTimerCount()).toBe(0);
|
|
1981
|
+
jest.useRealTimers();
|
|
1982
|
+
});
|
|
1983
|
+
|
|
1984
|
+
it.each([
|
|
1985
|
+
[
|
|
1986
|
+
'without a conversationId',
|
|
1987
|
+
{
|
|
1988
|
+
type: 'UNKNOWN_SUMMARY_EVENT',
|
|
1989
|
+
data: {
|
|
1990
|
+
data: {
|
|
1991
|
+
summaryText: 'private-summary',
|
|
1992
|
+
sections: {privateKey: 'private-value'},
|
|
1993
|
+
},
|
|
1994
|
+
},
|
|
1995
|
+
},
|
|
1996
|
+
],
|
|
1997
|
+
[
|
|
1998
|
+
'without a registered task',
|
|
1999
|
+
{
|
|
2000
|
+
type: 'UNKNOWN_SUMMARY_EVENT',
|
|
2001
|
+
data: {
|
|
2002
|
+
data: {
|
|
2003
|
+
conversationId: 'missing-task-id',
|
|
2004
|
+
summaryText: 'private-summary',
|
|
2005
|
+
},
|
|
2006
|
+
},
|
|
2007
|
+
},
|
|
2008
|
+
],
|
|
2009
|
+
])('should ignore unknown summary-like RTD frames %s', (_label, payload) => {
|
|
2010
|
+
taskManager.taskCollection = {};
|
|
2011
|
+
|
|
2012
|
+
taskManager.handleRealtimeWebsocketEvent(JSON.stringify(payload));
|
|
2013
|
+
|
|
2014
|
+
expect(getLoggerProxy().warn).not.toHaveBeenCalled();
|
|
2015
|
+
expectNoSensitiveDiagnostics('private-summary', 'privateKey', 'private-value');
|
|
2016
|
+
});
|
|
2017
|
+
|
|
2018
|
+
it('should ignore malformed summary frames and log parser errors', () => {
|
|
2019
|
+
taskManager.handleRealtimeWebsocketEvent('{bad-json "private-summary"');
|
|
2020
|
+
taskManager.handleRealtimeWebsocketEvent(
|
|
2021
|
+
JSON.stringify({type: CC_TASK_EVENTS.POST_CALL_SUMMARY, data: {}})
|
|
2022
|
+
);
|
|
2023
|
+
taskManager.handleRealtimeWebsocketEvent(
|
|
2024
|
+
JSON.stringify({
|
|
2025
|
+
type: CC_TASK_EVENTS.POST_CALL_SUMMARY,
|
|
2026
|
+
data: {data: {conversationId: ''}},
|
|
2027
|
+
})
|
|
2028
|
+
);
|
|
2029
|
+
taskManager.handleRealtimeWebsocketEvent(
|
|
2030
|
+
JSON.stringify({
|
|
2031
|
+
type: CC_TASK_EVENTS.MID_CALL_SUMMARY_RESPONSE_SUBSEQUENT_AGENT,
|
|
2032
|
+
data: {data: {conversationId: ''}},
|
|
2033
|
+
})
|
|
2034
|
+
);
|
|
2035
|
+
taskManager.handleRealtimeWebsocketEvent(
|
|
2036
|
+
JSON.stringify({type: CC_EVENTS.REAL_TIME_TRANSCRIPTION, data: {data: {}}})
|
|
2037
|
+
);
|
|
2038
|
+
|
|
2039
|
+
expect(getLoggerProxy().error).toHaveBeenCalledWith('Failed to parse RTD WebSocket message', {
|
|
2040
|
+
module: TASK_MANAGER_FILE,
|
|
2041
|
+
method: METHODS.HANDLE_REAL_TIME_WEBSOCKET_EVENT,
|
|
2042
|
+
error: expect.any(SyntaxError),
|
|
2043
|
+
});
|
|
2044
|
+
expectNoSensitiveDiagnostics('private-summary');
|
|
2045
|
+
});
|
|
2046
|
+
|
|
2047
|
+
it('should keep a pending request operational after unparseable, malformed, and unknown frames', async () => {
|
|
2048
|
+
const coordinator = (taskManager as any).apiAIAssistant;
|
|
2049
|
+
const conversationId = 'recovery-conversation';
|
|
2050
|
+
const registration = createPendingRtdRequest(
|
|
2051
|
+
coordinator,
|
|
2052
|
+
conversationId,
|
|
2053
|
+
'POST_CALL_SUMMARY',
|
|
2054
|
+
'POST_CALL_SUMMARY_TIMEOUT'
|
|
2055
|
+
);
|
|
2056
|
+
const validPayload = {
|
|
2057
|
+
conversationId,
|
|
2058
|
+
summaryText: 'valid recovery summary',
|
|
2059
|
+
};
|
|
2060
|
+
|
|
2061
|
+
taskManager.handleRealtimeWebsocketEvent('{not-json');
|
|
2062
|
+
taskManager.handleRealtimeWebsocketEvent(
|
|
2063
|
+
JSON.stringify({type: CC_TASK_EVENTS.POST_CALL_SUMMARY, data: {}})
|
|
2064
|
+
);
|
|
2065
|
+
taskManager.handleRealtimeWebsocketEvent(
|
|
2066
|
+
JSON.stringify({type: 'UNKNOWN_SUMMARY_RECOVERY_EVENT', data: {data: {conversationId}}})
|
|
2067
|
+
);
|
|
2068
|
+
|
|
2069
|
+
taskManager.handleRealtimeWebsocketEvent(
|
|
2070
|
+
JSON.stringify({
|
|
2071
|
+
type: CC_TASK_EVENTS.POST_CALL_SUMMARY,
|
|
2072
|
+
data: {data: validPayload},
|
|
2073
|
+
})
|
|
2074
|
+
);
|
|
2075
|
+
|
|
2076
|
+
await expect(registration.result).resolves.toStrictEqual(validPayload);
|
|
2077
|
+
});
|
|
2078
|
+
|
|
2079
|
+
it('should contain throwing transcript listeners', () => {
|
|
2080
|
+
const task = taskManager.getTask(taskId);
|
|
2081
|
+
const transcriptHandler = jest.fn(() => {
|
|
2082
|
+
throw new Error('private transcript listener failure');
|
|
2083
|
+
});
|
|
2084
|
+
const payload = {
|
|
2085
|
+
data: {
|
|
2086
|
+
data: {
|
|
2087
|
+
content: 'private transcript content',
|
|
2088
|
+
conversationId: taskId,
|
|
2089
|
+
messageId: 'throwing-transcript',
|
|
2090
|
+
},
|
|
2091
|
+
},
|
|
2092
|
+
type: CC_EVENTS.REAL_TIME_TRANSCRIPTION,
|
|
2093
|
+
};
|
|
2094
|
+
|
|
2095
|
+
task.on(CC_EVENTS.REAL_TIME_TRANSCRIPTION, transcriptHandler);
|
|
2096
|
+
|
|
2097
|
+
taskManager.handleRealtimeWebsocketEvent(JSON.stringify(payload));
|
|
2098
|
+
|
|
2099
|
+
expect(transcriptHandler).toHaveBeenCalledWith(payload.data);
|
|
2100
|
+
expect(getLoggerProxy().warn).not.toHaveBeenCalled();
|
|
2101
|
+
expect(getLoggerProxy().error).toHaveBeenCalledWith('Failed to parse RTD WebSocket message', {
|
|
2102
|
+
module: TASK_MANAGER_FILE,
|
|
2103
|
+
method: METHODS.HANDLE_REAL_TIME_WEBSOCKET_EVENT,
|
|
2104
|
+
error: expect.any(Error),
|
|
2105
|
+
});
|
|
2106
|
+
expectNoSensitiveDiagnostics(
|
|
2107
|
+
'private transcript content',
|
|
2108
|
+
'private transcript listener failure'
|
|
2109
|
+
);
|
|
2110
|
+
});
|
|
2111
|
+
|
|
2112
|
+
const expectTaskConstructedForAISummary = () => {
|
|
2113
|
+
expect((TaskFactory.createTask as jest.Mock).mock.calls.at(-1)?.[8]).toBe(mockApiAIAssistant);
|
|
2114
|
+
};
|
|
2115
|
+
|
|
2116
|
+
it.each([
|
|
2117
|
+
[
|
|
2118
|
+
'AGENT_CONTACT_RESERVED',
|
|
2119
|
+
TASK_EVENTS.TASK_INCOMING,
|
|
2120
|
+
{
|
|
2121
|
+
...taskDataMock,
|
|
2122
|
+
type: CC_EVENTS.AGENT_CONTACT_RESERVED,
|
|
2123
|
+
interactionId: 'ai-summary-reserved-task',
|
|
2124
|
+
mediaResourceId: 'ai-summary-reserved-task',
|
|
2125
|
+
interaction: {mediaType: 'telephony'},
|
|
2126
|
+
},
|
|
2127
|
+
],
|
|
2128
|
+
[
|
|
2129
|
+
'missing-task AGENT_CONTACT',
|
|
2130
|
+
TASK_EVENTS.TASK_HYDRATE,
|
|
2131
|
+
{
|
|
2132
|
+
...taskDataMock,
|
|
2133
|
+
type: CC_EVENTS.AGENT_CONTACT,
|
|
2134
|
+
interactionId: 'ai-summary-agent-contact-task',
|
|
2135
|
+
mediaResourceId: 'ai-summary-agent-contact-task',
|
|
2136
|
+
interaction: {mediaType: 'telephony'},
|
|
2137
|
+
},
|
|
2138
|
+
],
|
|
2139
|
+
[
|
|
2140
|
+
'campaign-preview reservation',
|
|
2141
|
+
TASK_EVENTS.TASK_CAMPAIGN_PREVIEW_RESERVATION,
|
|
2142
|
+
{
|
|
2143
|
+
...taskDataMock,
|
|
2144
|
+
type: CC_EVENTS.AGENT_OFFER_CAMPAIGN_RESERVATION,
|
|
2145
|
+
interactionId: 'ai-summary-campaign-task',
|
|
2146
|
+
mediaResourceId: 'ai-summary-campaign-task',
|
|
2147
|
+
interaction: {mediaType: 'telephony'},
|
|
2148
|
+
},
|
|
2149
|
+
],
|
|
2150
|
+
[
|
|
2151
|
+
'missing-task CONTACT_MERGED',
|
|
2152
|
+
TASK_EVENTS.TASK_MERGED,
|
|
2153
|
+
{
|
|
2154
|
+
...taskDataMock,
|
|
2155
|
+
type: CC_EVENTS.CONTACT_MERGED,
|
|
2156
|
+
interactionId: 'ai-summary-merged-task',
|
|
2157
|
+
mediaResourceId: 'ai-summary-merged-task',
|
|
2158
|
+
interaction: {mediaType: 'telephony', callProcessingDetails: {}},
|
|
2159
|
+
},
|
|
2160
|
+
],
|
|
2161
|
+
] as const)(
|
|
2162
|
+
'should pass AI summary dependencies into task construction before publishing %s',
|
|
2163
|
+
(_name, publicationEvent, payload) => {
|
|
2164
|
+
taskManager.taskCollection = {};
|
|
2165
|
+
(TaskFactory.createTask as jest.Mock).mockClear();
|
|
2166
|
+
const publishedTasks: any[] = [];
|
|
2167
|
+
let registryEntryDuringConstruction: unknown = 'not-observed';
|
|
2168
|
+
let injectedApiAIAssistant;
|
|
2169
|
+
const taskManagerEmitSpy = jest.spyOn(taskManager, 'emit');
|
|
2170
|
+
|
|
2171
|
+
(TaskFactory.createTask as jest.Mock).mockImplementationOnce(
|
|
2172
|
+
(
|
|
2173
|
+
_contact,
|
|
2174
|
+
_webCallingService,
|
|
2175
|
+
data,
|
|
2176
|
+
_configFlags,
|
|
2177
|
+
_wrapupData,
|
|
2178
|
+
_agentId,
|
|
2179
|
+
_agentName,
|
|
2180
|
+
_answerCallService,
|
|
2181
|
+
apiAIAssistant
|
|
2182
|
+
) => {
|
|
2183
|
+
const createdTask = createStateMachineTask(data);
|
|
2184
|
+
|
|
2185
|
+
registryEntryDuringConstruction = taskManager.getTask(payload.interactionId);
|
|
2186
|
+
injectedApiAIAssistant = apiAIAssistant;
|
|
2187
|
+
|
|
2188
|
+
return createdTask;
|
|
2189
|
+
}
|
|
2190
|
+
);
|
|
2191
|
+
|
|
2192
|
+
taskManager.on(publicationEvent, (publishedTask) => {
|
|
2193
|
+
publishedTasks.push(publishedTask);
|
|
2194
|
+
expect(publishedTask).toBe((TaskFactory.createTask as jest.Mock).mock.results[0].value);
|
|
2195
|
+
expectTaskConstructedForAISummary();
|
|
2196
|
+
});
|
|
2197
|
+
|
|
2198
|
+
webSocketManagerMock.emit('message', JSON.stringify({data: payload}));
|
|
2199
|
+
|
|
2200
|
+
const createdTask = (TaskFactory.createTask as jest.Mock).mock.results[0].value;
|
|
2201
|
+
|
|
2202
|
+
expect(TaskFactory.createTask).toHaveBeenCalledTimes(1);
|
|
2203
|
+
expect(taskManager.getTask(payload.interactionId)).toBe(createdTask);
|
|
2204
|
+
expect(publishedTasks).toEqual([createdTask]);
|
|
2205
|
+
expectTaskConstructedForAISummary();
|
|
2206
|
+
expect(registryEntryDuringConstruction).toBeUndefined();
|
|
2207
|
+
expect(injectedApiAIAssistant).toBe(mockApiAIAssistant);
|
|
2208
|
+
const publicationCallIndex = taskManagerEmitSpy.mock.calls.findIndex(
|
|
2209
|
+
([eventName]) => eventName === publicationEvent
|
|
2210
|
+
);
|
|
2211
|
+
|
|
2212
|
+
expect(publicationCallIndex).toBeGreaterThanOrEqual(0);
|
|
2213
|
+
}
|
|
2214
|
+
);
|
|
255
2215
|
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
);
|
|
260
|
-
});
|
|
2216
|
+
it('should cover task websocket parser exits and reservation re-key', () => {
|
|
2217
|
+
webSocketManagerMock.emit('message', '{bad-json');
|
|
2218
|
+
webSocketManagerMock.emit('message', JSON.stringify({keepalive: true}));
|
|
2219
|
+
webSocketManagerMock.emit('message', JSON.stringify({data: {type: 'unknown'}}));
|
|
261
2220
|
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
isEndConsultEnabled: true,
|
|
266
|
-
webRtcEnabled: true,
|
|
267
|
-
autoWrapup: false,
|
|
268
|
-
aiFeature: {
|
|
269
|
-
id: 'ai-feature-1',
|
|
270
|
-
realtimeTranscripts: {
|
|
271
|
-
enable: true,
|
|
272
|
-
},
|
|
273
|
-
},
|
|
2221
|
+
const reservationTask = createStateMachineTask({
|
|
2222
|
+
...taskDataMock,
|
|
2223
|
+
interactionId: 'reservation-id',
|
|
274
2224
|
});
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
2225
|
+
taskManager.taskCollection = {'reservation-id': reservationTask};
|
|
2226
|
+
webSocketManagerMock.emit(
|
|
2227
|
+
'message',
|
|
278
2228
|
JSON.stringify({
|
|
279
2229
|
data: {
|
|
280
2230
|
...taskDataMock,
|
|
281
|
-
|
|
282
|
-
|
|
2231
|
+
type: CC_EVENTS.AGENT_CONTACT_ASSIGNED,
|
|
2232
|
+
interactionId: 'assigned-id',
|
|
2233
|
+
reservationInteractionId: 'reservation-id',
|
|
2234
|
+
interaction: {mediaType: 'telephony'},
|
|
283
2235
|
},
|
|
284
|
-
})
|
|
2236
|
+
})
|
|
2237
|
+
);
|
|
285
2238
|
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
webSocketManagerMock.emit('message', message(CC_EVENTS.AGENT_CONSULT_CONFERENCED));
|
|
289
|
-
webSocketManagerMock.emit('message', message(CC_EVENTS.AGENT_CONSULT_ENDED));
|
|
290
|
-
webSocketManagerMock.emit('message', message(CC_EVENTS.PARTICIPANT_LEFT_CONFERENCE));
|
|
291
|
-
webSocketManagerMock.emit('message', message(CC_EVENTS.AGENT_WRAPUP));
|
|
2239
|
+
expect(taskManager.getTask('assigned-id')).toBe(reservationTask);
|
|
2240
|
+
expect(taskManager.getTask('reservation-id')).toBeUndefined();
|
|
292
2241
|
|
|
293
|
-
expect(mockApiAIAssistant.sendEvent).toHaveBeenCalledTimes(6);
|
|
294
|
-
expect(mockApiAIAssistant.sendEvent).toHaveBeenCalledWith(
|
|
295
|
-
'test-agent-id',
|
|
296
|
-
interactionId,
|
|
297
|
-
'CUSTOM_EVENT',
|
|
298
|
-
'GET_TRANSCRIPTS',
|
|
299
|
-
{action: 'START'}
|
|
300
|
-
);
|
|
301
|
-
expect(mockApiAIAssistant.sendEvent).toHaveBeenCalledWith(
|
|
302
|
-
'test-agent-id',
|
|
303
|
-
interactionId,
|
|
304
|
-
'CUSTOM_EVENT',
|
|
305
|
-
'GET_TRANSCRIPTS',
|
|
306
|
-
{action: 'STOP'}
|
|
307
|
-
);
|
|
308
2242
|
});
|
|
309
2243
|
|
|
310
|
-
it('should
|
|
311
|
-
taskManager.
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
2244
|
+
it('should handle campaign update and preview reservation lifecycle hooks', () => {
|
|
2245
|
+
const task = taskManager.getTask(taskId);
|
|
2246
|
+
const campaignUpdatePayload = {
|
|
2247
|
+
data: {
|
|
2248
|
+
...taskDataMock,
|
|
2249
|
+
type: CC_EVENTS.CAMPAIGN_CONTACT_UPDATED,
|
|
2250
|
+
interaction: {
|
|
2251
|
+
callProcessingDetails: {
|
|
2252
|
+
campaignPreviewAutoAction: 'ACCEPT',
|
|
2253
|
+
},
|
|
320
2254
|
},
|
|
321
2255
|
},
|
|
322
|
-
}
|
|
2256
|
+
};
|
|
2257
|
+
const campaignUpdateEmitSpy = jest.spyOn(task, 'emit');
|
|
323
2258
|
|
|
324
|
-
|
|
2259
|
+
webSocketManagerMock.emit('message', JSON.stringify(campaignUpdatePayload));
|
|
2260
|
+
|
|
2261
|
+
expect(campaignUpdateEmitSpy).toHaveBeenCalledWith(
|
|
2262
|
+
TASK_EVENTS.TASK_CAMPAIGN_CONTACT_UPDATED,
|
|
2263
|
+
task
|
|
2264
|
+
);
|
|
2265
|
+
expect(task.data.interaction.callProcessingDetails.campaignPreviewAutoAction).toBe('ACCEPT');
|
|
2266
|
+
|
|
2267
|
+
taskManager.taskCollection = {};
|
|
2268
|
+
webSocketManagerMock.emit(
|
|
2269
|
+
'message',
|
|
325
2270
|
JSON.stringify({
|
|
326
2271
|
data: {
|
|
327
2272
|
...taskDataMock,
|
|
328
|
-
|
|
329
|
-
|
|
2273
|
+
type: CC_EVENTS.AGENT_OFFER_CAMPAIGN_RESERVATION,
|
|
2274
|
+
interaction: {mediaType: 'telephony'},
|
|
330
2275
|
},
|
|
331
|
-
})
|
|
332
|
-
|
|
333
|
-
webSocketManagerMock.emit('message', message(CC_EVENTS.AGENT_CONTACT_ASSIGNED));
|
|
334
|
-
webSocketManagerMock.emit('message', message(CC_EVENTS.AGENT_CONSULTING));
|
|
2276
|
+
})
|
|
2277
|
+
);
|
|
335
2278
|
|
|
336
|
-
expect(
|
|
2279
|
+
expect(taskManager.getTask(taskId)).toBeDefined();
|
|
2280
|
+
expect(TaskFactory.createTask).toHaveBeenLastCalledWith(
|
|
2281
|
+
contactMock,
|
|
2282
|
+
webCallingService,
|
|
2283
|
+
expect.objectContaining({
|
|
2284
|
+
wrapUpRequired: false,
|
|
2285
|
+
isConferenceInProgress: false,
|
|
2286
|
+
isAutoAnswering: false,
|
|
2287
|
+
}),
|
|
2288
|
+
undefined,
|
|
2289
|
+
undefined,
|
|
2290
|
+
'test-agent-id',
|
|
2291
|
+
undefined,
|
|
2292
|
+
undefined,
|
|
2293
|
+
mockApiAIAssistant
|
|
2294
|
+
);
|
|
337
2295
|
});
|
|
338
2296
|
|
|
339
|
-
it('should
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
suggestedResponses: {
|
|
348
|
-
enable: true,
|
|
349
|
-
},
|
|
2297
|
+
it('should handle contact merge creation and child cleanup paths', () => {
|
|
2298
|
+
const mergedHandler = jest.fn();
|
|
2299
|
+
const childTask = createStateMachineTask({
|
|
2300
|
+
...taskDataMock,
|
|
2301
|
+
interactionId: 'child-interaction',
|
|
2302
|
+
interaction: {
|
|
2303
|
+
mediaType: 'telephony',
|
|
2304
|
+
callProcessingDetails: {},
|
|
350
2305
|
},
|
|
351
2306
|
});
|
|
2307
|
+
taskManager.taskCollection = {child: childTask};
|
|
2308
|
+
taskManager.on(TASK_EVENTS.TASK_MERGED, mergedHandler);
|
|
352
2309
|
|
|
353
|
-
|
|
2310
|
+
webSocketManagerMock.emit(
|
|
2311
|
+
'message',
|
|
354
2312
|
JSON.stringify({
|
|
355
2313
|
data: {
|
|
356
2314
|
...taskDataMock,
|
|
357
|
-
|
|
358
|
-
|
|
2315
|
+
type: CC_EVENTS.CONTACT_MERGED,
|
|
2316
|
+
childInteractionId: 'child-interaction',
|
|
2317
|
+
interaction: {
|
|
2318
|
+
mediaType: 'telephony',
|
|
2319
|
+
callProcessingDetails: {},
|
|
2320
|
+
},
|
|
359
2321
|
},
|
|
360
|
-
})
|
|
361
|
-
|
|
362
|
-
webSocketManagerMock.emit('message', message(CC_EVENTS.AGENT_CONTACT_ASSIGNED));
|
|
363
|
-
webSocketManagerMock.emit('message', message(CC_EVENTS.AGENT_CONSULTING));
|
|
2322
|
+
})
|
|
2323
|
+
);
|
|
364
2324
|
|
|
365
|
-
expect(
|
|
2325
|
+
expect(taskManager.getTask('child-interaction')).toBeUndefined();
|
|
2326
|
+
expect(mergedHandler).toHaveBeenCalledWith(taskManager.getTask(taskId));
|
|
366
2327
|
});
|
|
367
2328
|
|
|
368
|
-
it('should
|
|
369
|
-
|
|
370
|
-
const taskEmitSpy = jest.spyOn(task, 'emit');
|
|
371
|
-
const realtimePayload = {
|
|
372
|
-
data: {
|
|
373
|
-
agentId: 'test-agent-id',
|
|
374
|
-
data: {
|
|
375
|
-
content: 'Thank you. Okay.',
|
|
376
|
-
conversationId: taskId,
|
|
377
|
-
isFinal: true,
|
|
378
|
-
languageCode: 'en-US',
|
|
379
|
-
messageId: '1',
|
|
380
|
-
orgId: 'org-id',
|
|
381
|
-
publishTimestamp: 1773807297475,
|
|
382
|
-
role: 'AGENT',
|
|
383
|
-
trackingId: 'tracking-id',
|
|
384
|
-
utteranceId: 'utterance-id',
|
|
385
|
-
},
|
|
386
|
-
notifDetails: {
|
|
387
|
-
actionEvent: 'REAL_TIME_TRANSCRIPTION',
|
|
388
|
-
},
|
|
389
|
-
notifType: 'REAL_TIME_TRANSCRIPTION',
|
|
390
|
-
orgId: 'org-id',
|
|
391
|
-
},
|
|
392
|
-
orgId: 'org-id',
|
|
393
|
-
trackingId: 'notifs_tracking-id',
|
|
394
|
-
type: 'REAL_TIME_TRANSCRIPTION',
|
|
395
|
-
};
|
|
396
|
-
|
|
397
|
-
taskManager.handleRealtimeWebsocketEvent(JSON.stringify(realtimePayload));
|
|
2329
|
+
it('should return the singleton from getTaskManager', () => {
|
|
2330
|
+
(TaskManager as any).taskManager = undefined;
|
|
398
2331
|
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
2332
|
+
const singleton = TaskManager.getTaskManager(
|
|
2333
|
+
mockApiAIAssistant as any,
|
|
2334
|
+
contactMock,
|
|
2335
|
+
webCallingService,
|
|
2336
|
+
webSocketManagerMock as any,
|
|
2337
|
+
rtdWebSocketManagerMock as any
|
|
402
2338
|
);
|
|
2339
|
+
|
|
2340
|
+
expect(
|
|
2341
|
+
TaskManager.getTaskManager(
|
|
2342
|
+
mockApiAIAssistant as any,
|
|
2343
|
+
contactMock,
|
|
2344
|
+
webCallingService,
|
|
2345
|
+
webSocketManagerMock as any,
|
|
2346
|
+
rtdWebSocketManagerMock as any
|
|
2347
|
+
)
|
|
2348
|
+
).toBe(singleton);
|
|
403
2349
|
});
|
|
404
2350
|
|
|
405
|
-
it('should
|
|
406
|
-
const
|
|
2351
|
+
it('should cover TaskManager-imported TaskUtils helper branches', () => {
|
|
2352
|
+
const interaction = {
|
|
2353
|
+
interactionId: 'main',
|
|
2354
|
+
owner: 'agent-1',
|
|
2355
|
+
mediaType: 'telephony',
|
|
2356
|
+
participants: {
|
|
2357
|
+
'agent-1': {pType: 'Agent', hasLeft: false},
|
|
2358
|
+
'agent-2': {pType: 'Agent', hasLeft: false, consultState: 'consulting'},
|
|
2359
|
+
customer: {pType: 'Customer', hasLeft: false},
|
|
2360
|
+
},
|
|
2361
|
+
media: {
|
|
2362
|
+
main: {mType: 'mainCall', participants: ['agent-1', 'customer'], isHold: true},
|
|
2363
|
+
consult: {mType: 'consult', participants: ['agent-1', 'agent-2'], isHold: false},
|
|
2364
|
+
},
|
|
2365
|
+
callProcessingDetails: {
|
|
2366
|
+
relationshipType: 'consult',
|
|
2367
|
+
parentInteractionId: 'parent',
|
|
2368
|
+
},
|
|
2369
|
+
} as any;
|
|
2370
|
+
const task = {
|
|
407
2371
|
data: {
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
2372
|
+
agentId: 'agent-1',
|
|
2373
|
+
interactionId: 'main',
|
|
2374
|
+
mediaResourceId: 'consult',
|
|
2375
|
+
interaction,
|
|
2376
|
+
},
|
|
2377
|
+
} as any;
|
|
2378
|
+
|
|
2379
|
+
expect(getIsCustomerInCall(interaction, 'main')).toBe(true);
|
|
2380
|
+
expect(getConferenceParticipantsCount(interaction, 'main')).toBe(1);
|
|
2381
|
+
expect(getIsConsultInProgressForConferenceControls(interaction, 'main', 'agent-1')).toBe(true);
|
|
2382
|
+
expect(getIsConsultedAgentForControls({isConsulted: true} as any, {} as any, false)).toBe(true);
|
|
2383
|
+
expect(getServerHoldStateForControls({taskData: task.data} as any, 'main')).toBe(true);
|
|
2384
|
+
expect(isPrimary(task, 'agent-1')).toBe(true);
|
|
2385
|
+
expect(isParticipantInMainInteraction(task, 'agent-1')).toBe(true);
|
|
2386
|
+
expect(checkParticipantNotInInteraction(task, 'missing-agent')).toBe(true);
|
|
2387
|
+
expect(getConsultMediaResourceId(interaction, undefined, 'agent-1')).toBe('consult');
|
|
2388
|
+
});
|
|
2389
|
+
|
|
2390
|
+
it('should cover TaskManager-imported TaskUtils negative and auto-answer branches', () => {
|
|
2391
|
+
const baseInteraction = {
|
|
2392
|
+
interactionId: 'interaction-1',
|
|
2393
|
+
owner: 'agent-1',
|
|
2394
|
+
mediaType: 'telephony',
|
|
2395
|
+
mediaChannel: 'telephony',
|
|
2396
|
+
contactDirection: {type: 'OUTBOUND'},
|
|
2397
|
+
outboundType: 'OUTDIAL',
|
|
2398
|
+
previousVTeams: [],
|
|
2399
|
+
participants: {
|
|
2400
|
+
'agent-1': {pType: 'Agent', hasLeft: false, autoAnswerEnabled: true},
|
|
2401
|
+
'agent-left': {pType: 'Agent', hasLeft: true},
|
|
2402
|
+
supervisor: {pType: 'Supervisor', hasLeft: false},
|
|
2403
|
+
vva: {pType: 'VVA', hasLeft: false},
|
|
2404
|
+
customer: {pType: 'Customer', hasLeft: true},
|
|
2405
|
+
},
|
|
2406
|
+
media: {
|
|
2407
|
+
main: {
|
|
2408
|
+
mType: 'mainCall',
|
|
2409
|
+
participants: ['agent-1', 'agent-left', 'supervisor', 'vva', 'customer'],
|
|
419
2410
|
},
|
|
420
|
-
|
|
421
|
-
|
|
2411
|
+
consultReserved: {
|
|
2412
|
+
mType: 'consult',
|
|
2413
|
+
participants: ['agent-1', 'reserved-agent'],
|
|
422
2414
|
},
|
|
423
|
-
notifType: 'REAL_TIME_TRANSCRIPTION',
|
|
424
|
-
orgId: 'org-id',
|
|
425
2415
|
},
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
2416
|
+
callProcessingDetails: {
|
|
2417
|
+
relationshipType: 'consult',
|
|
2418
|
+
parentInteractionId: 'parent',
|
|
2419
|
+
outdialAgentId: 'agent-1',
|
|
2420
|
+
},
|
|
2421
|
+
} as any;
|
|
2422
|
+
|
|
2423
|
+
expect(getIsCustomerInCall(baseInteraction, 'main')).toBe(false);
|
|
2424
|
+
expect(getIsCustomerInCall({media: {}, participants: {}} as any, 'missing')).toBe(false);
|
|
2425
|
+
expect(getConferenceParticipantsCount(baseInteraction, 'main')).toBe(1);
|
|
2426
|
+
expect(getConferenceParticipantsCount({media: {}, participants: {}} as any, 'missing')).toBe(0);
|
|
2427
|
+
expect(getIsConsultInProgressForConferenceControls(undefined, 'main', 'agent-1')).toBe(false);
|
|
2428
|
+
expect(getIsConsultInProgressForConferenceControls(baseInteraction, undefined, 'agent-1')).toBe(
|
|
2429
|
+
false
|
|
2430
|
+
);
|
|
2431
|
+
expect(
|
|
2432
|
+
getIsConsultInProgressForConferenceControls(
|
|
2433
|
+
{
|
|
2434
|
+
...baseInteraction,
|
|
2435
|
+
participants: {
|
|
2436
|
+
...baseInteraction.participants,
|
|
2437
|
+
'reserved-agent': {
|
|
2438
|
+
pType: 'Agent',
|
|
2439
|
+
hasLeft: false,
|
|
2440
|
+
consultState: 'consultReserved',
|
|
2441
|
+
hasJoined: false,
|
|
2442
|
+
},
|
|
2443
|
+
},
|
|
2444
|
+
},
|
|
2445
|
+
'main',
|
|
2446
|
+
'agent-1'
|
|
2447
|
+
)
|
|
2448
|
+
).toBe(false);
|
|
2449
|
+
expect(getIsConsultedAgentForControls({} as any, {consultInitiator: true} as any, true)).toBe(
|
|
2450
|
+
false
|
|
2451
|
+
);
|
|
2452
|
+
expect(getServerHoldStateForControls({} as any, 'missing')).toBeUndefined();
|
|
2453
|
+
expect(
|
|
2454
|
+
getServerHoldStateForControls(
|
|
2455
|
+
{taskData: {interaction: {media: {main: {}}}, mediaResourceId: 'main'}} as any,
|
|
2456
|
+
undefined
|
|
2457
|
+
)
|
|
2458
|
+
).toBeUndefined();
|
|
434
2459
|
|
|
435
|
-
expect(
|
|
2460
|
+
expect(isPrimary({data: {agentId: 'agent-1', interaction: {}}} as any, 'agent-1')).toBe(true);
|
|
2461
|
+
expect(
|
|
2462
|
+
isPrimary({data: {agentId: 'agent-2', interaction: {owner: 'agent-1'}}} as any, 'agent-2')
|
|
2463
|
+
).toBe(false);
|
|
2464
|
+
expect(isParticipantInMainInteraction({data: {interaction: {}}} as any, 'agent-1')).toBe(false);
|
|
2465
|
+
expect(checkParticipantNotInInteraction({data: {interaction: {}}} as any, 'agent-1')).toBe(
|
|
2466
|
+
true
|
|
2467
|
+
);
|
|
2468
|
+
expect(
|
|
2469
|
+
checkParticipantNotInInteraction(
|
|
2470
|
+
{
|
|
2471
|
+
data: {
|
|
2472
|
+
interaction: {
|
|
2473
|
+
participants: {'agent-1': {hasLeft: true}},
|
|
2474
|
+
},
|
|
2475
|
+
},
|
|
2476
|
+
} as any,
|
|
2477
|
+
'agent-1'
|
|
2478
|
+
)
|
|
2479
|
+
).toBe(true);
|
|
2480
|
+
expect(isSecondaryAgent({callProcessingDetails: undefined} as any)).toBe(false);
|
|
2481
|
+
expect(isSecondaryEpDnAgent({...baseInteraction, mediaType: 'email'})).toBe(false);
|
|
2482
|
+
expect(
|
|
2483
|
+
isCampaignPreviewTask({
|
|
2484
|
+
interaction: {
|
|
2485
|
+
outboundType: 'STANDARD_PREVIEW_CAMPAIGN',
|
|
2486
|
+
callProcessingDetails: {},
|
|
2487
|
+
},
|
|
2488
|
+
} as any)
|
|
2489
|
+
).toBe(true);
|
|
2490
|
+
expect(
|
|
2491
|
+
isCampaignPreviewReservation({
|
|
2492
|
+
data: {type: CC_EVENTS.AGENT_OFFER_CAMPAIGN_RESERVATION},
|
|
2493
|
+
} as any)
|
|
2494
|
+
).toBe(true);
|
|
2495
|
+
expect(isAutoAnswerEnabled(baseInteraction, 'missing-agent')).toBe(false);
|
|
2496
|
+
expect(isWebRTCCall(baseInteraction, LoginOption.BROWSER, true)).toBe(true);
|
|
2497
|
+
expect(isWebRTCCall(baseInteraction, LoginOption.AGENT_DN, true)).toBe(false);
|
|
2498
|
+
expect(
|
|
2499
|
+
isDigitalOutbound({
|
|
2500
|
+
...baseInteraction,
|
|
2501
|
+
mediaChannel: 'email',
|
|
2502
|
+
})
|
|
2503
|
+
).toBe(true);
|
|
2504
|
+
expect(isDigitalOutbound({...baseInteraction, contactDirection: {type: 'INBOUND'}})).toBe(
|
|
2505
|
+
false
|
|
2506
|
+
);
|
|
2507
|
+
expect(hasAgentInitiatedOutdial(baseInteraction, 'agent-1')).toBe(true);
|
|
2508
|
+
expect(
|
|
2509
|
+
hasAgentInitiatedOutdial(
|
|
2510
|
+
{
|
|
2511
|
+
...baseInteraction,
|
|
2512
|
+
callProcessingDetails: {
|
|
2513
|
+
...baseInteraction.callProcessingDetails,
|
|
2514
|
+
BLIND_TRANSFER_IN_PROGRESS: true,
|
|
2515
|
+
},
|
|
2516
|
+
},
|
|
2517
|
+
'agent-1'
|
|
2518
|
+
)
|
|
2519
|
+
).toBe(false);
|
|
2520
|
+
expect(
|
|
2521
|
+
shouldAutoAnswerTask(
|
|
2522
|
+
{interaction: baseInteraction} as any,
|
|
2523
|
+
'agent-1',
|
|
2524
|
+
LoginOption.BROWSER,
|
|
2525
|
+
true
|
|
2526
|
+
)
|
|
2527
|
+
).toBe(true);
|
|
2528
|
+
expect(
|
|
2529
|
+
shouldAutoAnswerTask(
|
|
2530
|
+
{
|
|
2531
|
+
interaction: {
|
|
2532
|
+
...baseInteraction,
|
|
2533
|
+
mediaType: 'email',
|
|
2534
|
+
mediaChannel: 'email',
|
|
2535
|
+
previousVTeams: ['queue-1'],
|
|
2536
|
+
},
|
|
2537
|
+
} as any,
|
|
2538
|
+
'agent-1',
|
|
2539
|
+
LoginOption.AGENT_DN,
|
|
2540
|
+
false
|
|
2541
|
+
)
|
|
2542
|
+
).toBe(false);
|
|
2543
|
+
expect(getConsultMediaResourceId(undefined, undefined, 'agent-1')).toBeUndefined();
|
|
436
2544
|
});
|
|
437
2545
|
|
|
438
2546
|
it('should not re-emit agent related events', () => {
|
|
@@ -2258,7 +4366,7 @@ describe('TaskManager', () => {
|
|
|
2258
4366
|
});
|
|
2259
4367
|
|
|
2260
4368
|
it('preserves consult fields from state context during consulting payload refresh', () => {
|
|
2261
|
-
const task =
|
|
4369
|
+
const task = createStateMachineTask({
|
|
2262
4370
|
...taskDataMock,
|
|
2263
4371
|
interaction: {state: 'consulting'} as any,
|
|
2264
4372
|
interactionId: taskId,
|
|
@@ -2292,7 +4400,7 @@ describe('TaskManager', () => {
|
|
|
2292
4400
|
});
|
|
2293
4401
|
|
|
2294
4402
|
it('does not preserve stale consult fields once consult is no longer active', () => {
|
|
2295
|
-
const task =
|
|
4403
|
+
const task = createStateMachineTask({
|
|
2296
4404
|
...taskDataMock,
|
|
2297
4405
|
interaction: {state: 'connected'} as any,
|
|
2298
4406
|
interactionId: taskId,
|
|
@@ -2868,17 +4976,11 @@ describe('TaskManager', () => {
|
|
|
2868
4976
|
it('correlates child-keyed ContactOwnerChanged and keeps the main task identity', () => {
|
|
2869
4977
|
const childTaskId = 'surviving-child-id';
|
|
2870
4978
|
const promotedChildId = 'promoted-child-id';
|
|
2871
|
-
const task = installTask(
|
|
2872
|
-
createConferenceTaskData(childTaskId, previousOwnerId, taskId)
|
|
2873
|
-
);
|
|
4979
|
+
const task = installTask(createConferenceTaskData(childTaskId, previousOwnerId, taskId));
|
|
2874
4980
|
const hydrateHandler = jest.fn();
|
|
2875
4981
|
taskManager.on(TASK_EVENTS.TASK_HYDRATE, hydrateHandler);
|
|
2876
4982
|
|
|
2877
|
-
const ownerChangeData = createConferenceTaskData(
|
|
2878
|
-
promotedChildId,
|
|
2879
|
-
promotedOwnerId,
|
|
2880
|
-
taskId
|
|
2881
|
-
);
|
|
4983
|
+
const ownerChangeData = createConferenceTaskData(promotedChildId, promotedOwnerId, taskId);
|
|
2882
4984
|
ownerChangeData.interaction.interactionId = taskId;
|
|
2883
4985
|
webSocketManagerMock.emit(
|
|
2884
4986
|
'message',
|
|
@@ -2893,9 +4995,9 @@ describe('TaskManager', () => {
|
|
|
2893
4995
|
expect(taskManager.taskCollection[childTaskId]).toBeUndefined();
|
|
2894
4996
|
expect(taskManager.taskCollection[promotedChildId]).toBeUndefined();
|
|
2895
4997
|
expect(taskManager.taskCollection[taskId]).toBe(task);
|
|
2896
|
-
expect(
|
|
2897
|
-
|
|
2898
|
-
);
|
|
4998
|
+
expect(
|
|
4999
|
+
Object.values(taskManager.taskCollection).filter((entry) => entry === task)
|
|
5000
|
+
).toHaveLength(1);
|
|
2899
5001
|
expect(hydrateHandler).toHaveBeenCalledTimes(1);
|
|
2900
5002
|
});
|
|
2901
5003
|
|
|
@@ -2931,11 +5033,7 @@ describe('TaskManager', () => {
|
|
|
2931
5033
|
const mainMediaInteractionId = 'main-media-interaction-id';
|
|
2932
5034
|
const promotedChildId = 'promoted-child-without-main-id';
|
|
2933
5035
|
const task = installTask(
|
|
2934
|
-
createConferenceTaskData(
|
|
2935
|
-
mainMediaInteractionId,
|
|
2936
|
-
previousOwnerId,
|
|
2937
|
-
mainMediaInteractionId
|
|
2938
|
-
)
|
|
5036
|
+
createConferenceTaskData(mainMediaInteractionId, previousOwnerId, mainMediaInteractionId)
|
|
2939
5037
|
);
|
|
2940
5038
|
const hydrateHandler = jest.fn();
|
|
2941
5039
|
taskManager.on(TASK_EVENTS.TASK_HYDRATE, hydrateHandler);
|
|
@@ -3335,10 +5433,13 @@ describe('TaskManager', () => {
|
|
|
3335
5433
|
undefined,
|
|
3336
5434
|
undefined,
|
|
3337
5435
|
currentAgentId,
|
|
3338
|
-
undefined
|
|
5436
|
+
undefined,
|
|
5437
|
+
undefined,
|
|
5438
|
+
mockApiAIAssistant
|
|
3339
5439
|
);
|
|
3340
5440
|
|
|
3341
5441
|
const recoveredTask = (TaskFactory.createTask as jest.Mock).mock.results[0].value;
|
|
5442
|
+
expectTaskConstructedForAISummary();
|
|
3342
5443
|
expect(recoveredTask.sendStateMachineEvent).toHaveBeenCalledTimes(2);
|
|
3343
5444
|
expect(recoveredTask.sendStateMachineEvent).toHaveBeenNthCalledWith(
|
|
3344
5445
|
1,
|
|
@@ -3621,7 +5722,7 @@ describe('TaskManager', () => {
|
|
|
3621
5722
|
|
|
3622
5723
|
it('routes participant-left from a main interaction to a child-keyed task', () => {
|
|
3623
5724
|
const childTaskId = 'child-interaction-id';
|
|
3624
|
-
const childTask =
|
|
5725
|
+
const childTask = createStateMachineTask({
|
|
3625
5726
|
...taskDataMock,
|
|
3626
5727
|
interactionId: childTaskId,
|
|
3627
5728
|
isConsulted: true,
|
|
@@ -3665,7 +5766,7 @@ describe('TaskManager', () => {
|
|
|
3665
5766
|
|
|
3666
5767
|
it('routes consult-end from a main interaction to a child-keyed consulted task', () => {
|
|
3667
5768
|
const childTaskId = 'consult-child-interaction-id';
|
|
3668
|
-
const childTask =
|
|
5769
|
+
const childTask = createStateMachineTask({
|
|
3669
5770
|
...taskDataMock,
|
|
3670
5771
|
interactionId: childTaskId,
|
|
3671
5772
|
isConsulted: true,
|
|
@@ -3700,7 +5801,7 @@ describe('TaskManager', () => {
|
|
|
3700
5801
|
|
|
3701
5802
|
it('deduplicates aliases of the same child task during related-interaction lookup', () => {
|
|
3702
5803
|
const childTaskId = 'aliased-child-interaction-id';
|
|
3703
|
-
const childTask =
|
|
5804
|
+
const childTask = createStateMachineTask({
|
|
3704
5805
|
...taskDataMock,
|
|
3705
5806
|
interactionId: childTaskId,
|
|
3706
5807
|
interaction: {
|
|
@@ -3732,7 +5833,7 @@ describe('TaskManager', () => {
|
|
|
3732
5833
|
|
|
3733
5834
|
it('prefers an exact task match over a child task with the same main interaction', () => {
|
|
3734
5835
|
const childTaskId = 'child-interaction-id';
|
|
3735
|
-
const childTask =
|
|
5836
|
+
const childTask = createStateMachineTask({
|
|
3736
5837
|
...taskDataMock,
|
|
3737
5838
|
interactionId: childTaskId,
|
|
3738
5839
|
interaction: {
|
|
@@ -3761,7 +5862,7 @@ describe('TaskManager', () => {
|
|
|
3761
5862
|
it('does not route a main-interaction event when multiple unique child tasks match', () => {
|
|
3762
5863
|
delete taskManager.taskCollection[taskId];
|
|
3763
5864
|
const createChildTask = (interactionId: string) =>
|
|
3764
|
-
|
|
5865
|
+
createStateMachineTask({
|
|
3765
5866
|
...taskDataMock,
|
|
3766
5867
|
interactionId,
|
|
3767
5868
|
interaction: {
|
|
@@ -3823,7 +5924,7 @@ describe('TaskManager', () => {
|
|
|
3823
5924
|
|
|
3824
5925
|
it('replaces an EP-DN child task with a hydrated main task on CONTACT_MERGED', () => {
|
|
3825
5926
|
const childTaskId = 'ep-dn-child-interaction-id';
|
|
3826
|
-
const childTask =
|
|
5927
|
+
const childTask = createStateMachineTask({
|
|
3827
5928
|
...taskDataMock,
|
|
3828
5929
|
interactionId: childTaskId,
|
|
3829
5930
|
interaction: {
|
|
@@ -3915,6 +6016,52 @@ describe('TaskManager', () => {
|
|
|
3915
6016
|
});
|
|
3916
6017
|
|
|
3917
6018
|
describe('state machine integration', () => {
|
|
6019
|
+
it('maps the complete contact-center lifecycle event surface', () => {
|
|
6020
|
+
const mapEvent = (TaskManager as any).mapEventToTaskStateMachineEvent.bind(TaskManager);
|
|
6021
|
+
const payload = {
|
|
6022
|
+
...taskDataMock,
|
|
6023
|
+
participantId: 'participant-1',
|
|
6024
|
+
reason: 'test-reason',
|
|
6025
|
+
agentsPendingWrapUp: ['agent-1'],
|
|
6026
|
+
} as any;
|
|
6027
|
+
|
|
6028
|
+
Object.values(CC_EVENTS).forEach((eventType) => {
|
|
6029
|
+
mapEvent(eventType, payload, 'agent-1');
|
|
6030
|
+
});
|
|
6031
|
+
|
|
6032
|
+
expect(
|
|
6033
|
+
mapEvent(
|
|
6034
|
+
CC_EVENTS.AGENT_CONTACT_RESERVED,
|
|
6035
|
+
{
|
|
6036
|
+
...payload,
|
|
6037
|
+
interaction: {...payload.interaction, outboundType: 'STANDARD_PREVIEW_CAMPAIGN'},
|
|
6038
|
+
},
|
|
6039
|
+
'agent-1'
|
|
6040
|
+
)
|
|
6041
|
+
).toMatchObject({isCampaignReservationAccept: true});
|
|
6042
|
+
expect(
|
|
6043
|
+
mapEvent(
|
|
6044
|
+
CC_EVENTS.AGENT_CONTACT_HELD,
|
|
6045
|
+
{
|
|
6046
|
+
...payload,
|
|
6047
|
+
mediaResourceId: undefined,
|
|
6048
|
+
interaction: {
|
|
6049
|
+
...payload.interaction,
|
|
6050
|
+
media: {
|
|
6051
|
+
[payload.interactionId]: {mediaResourceId: 'fallback-media-resource'},
|
|
6052
|
+
},
|
|
6053
|
+
},
|
|
6054
|
+
},
|
|
6055
|
+
'agent-1'
|
|
6056
|
+
)
|
|
6057
|
+
).toMatchObject({mediaResourceId: 'fallback-media-resource'});
|
|
6058
|
+
expect(
|
|
6059
|
+
mapEvent(CC_EVENTS.CONTACT_ENDED, {...payload, agentsPendingWrapUp: undefined}, 'agent-1')
|
|
6060
|
+
.taskData.wrapUpRequired
|
|
6061
|
+
).toBe(false);
|
|
6062
|
+
expect(mapEvent('UNMAPPED_EVENT', payload, 'agent-1')).toBeNull();
|
|
6063
|
+
});
|
|
6064
|
+
|
|
3918
6065
|
it('maps CC events to task state machine events using normalized payload', () => {
|
|
3919
6066
|
const mapped = (TaskManager as any).mapEventToTaskStateMachineEvent(
|
|
3920
6067
|
CC_EVENTS.AGENT_CONTACT_ASSIGNED,
|