@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.
Files changed (82) hide show
  1. package/.sdd/manifest.json +4 -2
  2. package/ai-docs/CONTRACTS.md +2 -0
  3. package/dist/cc.js +3 -1
  4. package/dist/cc.js.map +1 -1
  5. package/dist/constants.js +10 -1
  6. package/dist/constants.js.map +1 -1
  7. package/dist/index.js +9 -2
  8. package/dist/index.js.map +1 -1
  9. package/dist/metrics/constants.js +9 -0
  10. package/dist/metrics/constants.js.map +1 -1
  11. package/dist/services/ApiAiAssistant.js +87 -10
  12. package/dist/services/ApiAiAssistant.js.map +1 -1
  13. package/dist/services/config/types.js +10 -4
  14. package/dist/services/config/types.js.map +1 -1
  15. package/dist/services/core/types.js.map +1 -1
  16. package/dist/services/task/Task.js +202 -1
  17. package/dist/services/task/Task.js.map +1 -1
  18. package/dist/services/task/TaskFactory.js +4 -4
  19. package/dist/services/task/TaskFactory.js.map +1 -1
  20. package/dist/services/task/TaskManager.js +113 -26
  21. package/dist/services/task/TaskManager.js.map +1 -1
  22. package/dist/services/task/TaskUtils.js +11 -1
  23. package/dist/services/task/TaskUtils.js.map +1 -1
  24. package/dist/services/task/constants.js +11 -2
  25. package/dist/services/task/constants.js.map +1 -1
  26. package/dist/services/task/digital/Digital.js +2 -2
  27. package/dist/services/task/digital/Digital.js.map +1 -1
  28. package/dist/services/task/types.js +31 -0
  29. package/dist/services/task/types.js.map +1 -1
  30. package/dist/services/task/voice/Voice.js +2 -2
  31. package/dist/services/task/voice/Voice.js.map +1 -1
  32. package/dist/services/task/voice/WebRTC.js +2 -2
  33. package/dist/services/task/voice/WebRTC.js.map +1 -1
  34. package/dist/types/constants.d.ts +9 -0
  35. package/dist/types/index.d.ts +4 -5
  36. package/dist/types/metrics/constants.d.ts +8 -0
  37. package/dist/types/services/ApiAiAssistant.d.ts +4 -2
  38. package/dist/types/services/config/types.d.ts +17 -7
  39. package/dist/types/services/task/Task.d.ts +15 -4
  40. package/dist/types/services/task/TaskFactory.d.ts +3 -2
  41. package/dist/types/services/task/TaskUtils.d.ts +6 -0
  42. package/dist/types/services/task/constants.d.ts +9 -0
  43. package/dist/types/services/task/digital/Digital.d.ts +3 -2
  44. package/dist/types/services/task/types.d.ts +109 -1
  45. package/dist/types/services/task/voice/Voice.d.ts +3 -2
  46. package/dist/types/services/task/voice/WebRTC.d.ts +3 -2
  47. package/dist/types/types.d.ts +10 -4
  48. package/dist/types.js +8 -4
  49. package/dist/types.js.map +1 -1
  50. package/dist/webex.js +1 -1
  51. package/package.json +3 -3
  52. package/src/cc.ts +4 -0
  53. package/src/constants.ts +12 -0
  54. package/src/index.ts +10 -2
  55. package/src/metrics/ai-docs/metrics-spec.md +12 -4
  56. package/src/metrics/constants.ts +10 -0
  57. package/src/services/ApiAiAssistant.ts +120 -12
  58. package/src/services/config/types.ts +10 -4
  59. package/src/services/core/types.ts +31 -1
  60. package/src/services/task/Task.ts +308 -5
  61. package/src/services/task/TaskFactory.ts +26 -5
  62. package/src/services/task/TaskManager.ts +148 -29
  63. package/src/services/task/TaskUtils.ts +13 -0
  64. package/src/services/task/ai-docs/task-spec.md +30 -5
  65. package/src/services/task/constants.ts +10 -0
  66. package/src/services/task/digital/Digital.ts +8 -3
  67. package/src/services/task/types.ts +139 -0
  68. package/src/services/task/voice/Voice.ts +8 -3
  69. package/src/services/task/voice/WebRTC.ts +6 -3
  70. package/src/types.ts +10 -4
  71. package/test/unit/fixtures/aiSummaryTestUtils.ts +40 -0
  72. package/test/unit/spec/cc.ts +1314 -32
  73. package/test/unit/spec/index.ts +881 -0
  74. package/test/unit/spec/services/ApiAiAssistant.ts +122 -6
  75. package/test/unit/spec/services/core/WebexRequest.ts +5 -6
  76. package/test/unit/spec/services/task/Task.ts +1988 -10
  77. package/test/unit/spec/services/task/TaskFactory.ts +17 -0
  78. package/test/unit/spec/services/task/TaskManager.ts +2327 -180
  79. package/test/unit/spec/services/task/TaskUtils.ts +72 -0
  80. package/test/unit/spec/services/task/voice/Voice.ts +353 -18
  81. package/umd/contact-center.min.js +2 -2
  82. 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 {CC_AGENT_EVENTS, CC_EVENTS} from '../../../../../src/services/config/types';
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 createMockTask = (data = taskDataMock) => {
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
- const mappedEvent = taskEventMap[event.type as TaskEvent];
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] = createMockTask(taskDataMock);
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) => createMockTask(data));
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
- expect(taskEmitSpy).toHaveBeenCalledWith(
257
- TASK_EVENTS.TASK_CONSULT_ACCEPTED,
258
- taskManager.getTask(taskId)
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
- it('should invoke sendEvent for configured start/stop backend events', () => {
263
- taskManager.setConfigFlags({
264
- isEndTaskEnabled: true,
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
- const interactionId = taskId;
277
- const message = (type: CC_EVENTS) =>
2225
+ taskManager.taskCollection = {'reservation-id': reservationTask};
2226
+ webSocketManagerMock.emit(
2227
+ 'message',
278
2228
  JSON.stringify({
279
2229
  data: {
280
2230
  ...taskDataMock,
281
- interactionId,
282
- type,
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
- webSocketManagerMock.emit('message', message(CC_EVENTS.AGENT_CONTACT_ASSIGNED));
287
- webSocketManagerMock.emit('message', message(CC_EVENTS.AGENT_CONSULTING));
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 not invoke sendEvent when realtime transcripts are disabled in aiFeature', () => {
311
- taskManager.setConfigFlags({
312
- isEndTaskEnabled: true,
313
- isEndConsultEnabled: true,
314
- webRtcEnabled: true,
315
- autoWrapup: false,
316
- aiFeature: {
317
- id: 'ai-feature-1',
318
- realtimeTranscripts: {
319
- enable: false,
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
- const message = (type: CC_EVENTS) =>
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
- interactionId: taskId,
329
- type,
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(mockApiAIAssistant.sendEvent).not.toHaveBeenCalled();
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 not invoke sendEvent when realtime transcripts config is missing', () => {
340
- taskManager.setConfigFlags({
341
- isEndTaskEnabled: true,
342
- isEndConsultEnabled: true,
343
- webRtcEnabled: true,
344
- autoWrapup: false,
345
- aiFeature: {
346
- id: 'ai-feature-1',
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
- const message = (type: CC_EVENTS) =>
2310
+ webSocketManagerMock.emit(
2311
+ 'message',
354
2312
  JSON.stringify({
355
2313
  data: {
356
2314
  ...taskDataMock,
357
- interactionId: taskId,
358
- type,
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(mockApiAIAssistant.sendEvent).not.toHaveBeenCalled();
2325
+ expect(taskManager.getTask('child-interaction')).toBeUndefined();
2326
+ expect(mergedHandler).toHaveBeenCalledWith(taskManager.getTask(taskId));
366
2327
  });
367
2328
 
368
- it('should emit REAL_TIME_TRANSCRIPTION from RTD websocket payload', () => {
369
- const task = taskManager.getTask(taskId);
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
- expect(taskEmitSpy).toHaveBeenCalledWith(
400
- CC_EVENTS.REAL_TIME_TRANSCRIPTION,
401
- realtimePayload.data
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 ignore RTD transcript events when task is not found', () => {
406
- const realtimePayload = {
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
- data: {
409
- content: 'Thank you. Okay.',
410
- conversationId: 'missing-task-id',
411
- isFinal: true,
412
- languageCode: 'en-US',
413
- messageId: '1',
414
- orgId: 'org-id',
415
- publishTimestamp: 1773807297475,
416
- role: 'AGENT',
417
- trackingId: 'tracking-id',
418
- utteranceId: 'utterance-id',
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
- notifDetails: {
421
- actionEvent: 'REAL_TIME_TRANSCRIPTION',
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
- orgId: 'org-id',
427
- trackingId: 'notifs_tracking-id',
428
- type: 'REAL_TIME_TRANSCRIPTION',
429
- };
430
-
431
- const existingTaskEmitSpy = jest.spyOn(taskManager.getTask(taskId), 'emit');
432
-
433
- taskManager.handleRealtimeWebsocketEvent(JSON.stringify(realtimePayload));
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(existingTaskEmitSpy).not.toHaveBeenCalled();
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 = createMockTask({
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 = createMockTask({
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(Object.values(taskManager.taskCollection).filter((entry) => entry === task)).toHaveLength(
2897
- 1
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 = createMockTask({
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 = createMockTask({
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 = createMockTask({
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 = createMockTask({
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
- createMockTask({
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 = createMockTask({
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,