@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
@@ -6,14 +6,44 @@ import {
6
6
  TASK_CHANNEL_TYPE,
7
7
  TransferPayLoad,
8
8
  VOICE_VARIANT,
9
+ AISummaryAction,
10
+ AISummaryResponse,
9
11
  } from '../../../../../src/services/task/types';
10
12
  import {TaskEvent} from '../../../../../src/services/task/state-machine';
11
13
  import {ENTRY_POINT_TRANSFER_DESTINATION_TYPE} from '../../../../../src/services/task/constants';
12
14
  import LoggerProxy from '../../../../../src/logger-proxy';
13
15
  import {createTaskData} from './taskTestUtils';
16
+ import {AIAssistantEventName} from '../../../../../src/types';
17
+ import {AI_SUMMARY_ERROR_CODES} from '../../../../../src/constants';
18
+ import {METRIC_EVENT_NAMES} from '../../../../../src/metrics/constants';
19
+ import {
20
+ AI_SUMMARY_DURATION_MS,
21
+ AI_SUMMARY_REQUEST_CANCELLED,
22
+ METHODS,
23
+ } from '../../../../../src/services/task/constants';
24
+ import {
25
+ createAISummaryError,
26
+ createAISummaryErrorExpectation,
27
+ createDeferred,
28
+ flushEventLoopTurn,
29
+ } from '../../../fixtures/aiSummaryTestUtils';
30
+ import type ApiAIAssistant from '../../../../../src/services/ApiAiAssistant';
31
+
32
+ const AI_SUMMARY_TRANSPORT_ERROR_CODES = {
33
+ VALIDATION_FAILED: 'AI_SUMMARY_TRANSPORT_VALIDATION_FAILED',
34
+ HTTP_REQUEST_FAILED: 'AI_SUMMARY_HTTP_REQUEST_FAILED',
35
+ TIMEOUT: 'AI_SUMMARY_TRANSPORT_TIMEOUT',
36
+ } as const;
14
37
 
15
38
  class DummyTask extends Task {
16
- constructor(contact: any, data: TaskData, wrapupData?: any, agentId?: string) {
39
+ constructor(
40
+ contact: any,
41
+ data: TaskData,
42
+ wrapupData?: any,
43
+ agentId = 'agent-1',
44
+ agentName = 'Receiving Agent',
45
+ apiAIAssistant?: ApiAIAssistant
46
+ ) {
17
47
  super(
18
48
  contact,
19
49
  data,
@@ -23,7 +53,9 @@ class DummyTask extends Task {
23
53
  isEndConsultEnabled: true,
24
54
  },
25
55
  wrapupData,
26
- agentId
56
+ agentId,
57
+ agentName,
58
+ apiAIAssistant
27
59
  );
28
60
  }
29
61
 
@@ -64,7 +96,170 @@ class SpyAcceptTask extends Task {
64
96
  }
65
97
  }
66
98
 
67
- const flushPromises = () => new Promise((resolve) => setImmediate(resolve));
99
+ jest.mock('xstate', () => ({
100
+ createActor: jest.fn((machine) => {
101
+ let snapshot = {value: 'IDLE', context: {}, event: undefined as any};
102
+ const subscribers = new Set<any>();
103
+ const actions = machine?.options?.actions ?? {};
104
+ const notify = () => {
105
+ subscribers.forEach((subscriber) => subscriber(snapshot));
106
+ };
107
+ const transition = (value: string, event: any) => {
108
+ snapshot = {value, context: {}, event};
109
+ notify();
110
+ };
111
+
112
+ return {
113
+ subscribe: jest.fn((subscriber) => {
114
+ subscribers.add(subscriber);
115
+
116
+ return {unsubscribe: () => subscribers.delete(subscriber)};
117
+ }),
118
+ start: jest.fn(() => notify()),
119
+ stop: jest.fn(),
120
+ getSnapshot: jest.fn(() => snapshot),
121
+ send: jest.fn((event) => {
122
+ switch (event?.type) {
123
+ case 'TASK_INCOMING':
124
+ actions.emitTaskIncoming?.({event});
125
+ transition('OFFERED', event);
126
+ break;
127
+ case 'TASK_OFFERED':
128
+ actions.requestAutoAnswer?.({event});
129
+ transition('OFFERED', event);
130
+ break;
131
+ case 'ASSIGN':
132
+ actions.emitTaskAssigned?.({event});
133
+ transition('CONNECTED', event);
134
+ break;
135
+ case 'CONTACT_UPDATED':
136
+ actions.syncTaskDataFromEvent?.({event});
137
+ transition(snapshot.value, event);
138
+ break;
139
+ case 'CONTACT_OWNER_CHANGED':
140
+ actions.emitTaskHydrate?.({event});
141
+ transition(snapshot.value, event);
142
+ break;
143
+ case 'CONFERENCE_START':
144
+ transition('CONFERENCING', event);
145
+ break;
146
+ case 'PARTICIPANT_LEAVE':
147
+ actions.emitTaskParticipantLeft?.({event});
148
+ transition(snapshot.value, event);
149
+ break;
150
+ case 'CONTACT_ENDED':
151
+ actions.requestCleanup?.({event});
152
+ transition('WRAPUP', event);
153
+ break;
154
+ case 'RONA':
155
+ actions.cleanupResources?.({event});
156
+ transition('ENDED', event);
157
+ break;
158
+ default:
159
+ transition(snapshot.value, event);
160
+ }
161
+ }),
162
+ };
163
+ }),
164
+ }));
165
+
166
+ jest.mock('../../../../../src/services/task/state-machine', () => ({
167
+ __esModule: true,
168
+ TaskEvent: {
169
+ TASK_INCOMING: 'TASK_INCOMING',
170
+ TASK_OFFERED: 'TASK_OFFERED',
171
+ ASSIGN: 'ASSIGN',
172
+ CONTACT_UPDATED: 'CONTACT_UPDATED',
173
+ CONTACT_OWNER_CHANGED: 'CONTACT_OWNER_CHANGED',
174
+ CONFERENCE_START: 'CONFERENCE_START',
175
+ PARTICIPANT_LEAVE: 'PARTICIPANT_LEAVE',
176
+ CONTACT_ENDED: 'CONTACT_ENDED',
177
+ RONA: 'RONA',
178
+ TASK_WRAPUP: 'TASK_WRAPUP',
179
+ },
180
+ createTaskStateMachine: jest.fn((_config, options) => ({options})),
181
+ }));
182
+
183
+ jest.mock('../../../../../src/services/task/state-machine/uiControlsComputer', () => {
184
+ const createControl = () => ({isVisible: false, isEnabled: false});
185
+ const createLegControls = () => ({
186
+ accept: createControl(),
187
+ decline: createControl(),
188
+ hold: createControl(),
189
+ transfer: createControl(),
190
+ consult: createControl(),
191
+ end: createControl(),
192
+ recording: createControl(),
193
+ mute: createControl(),
194
+ consultTransfer: createControl(),
195
+ endConsult: createControl(),
196
+ conference: createControl(),
197
+ exitConference: createControl(),
198
+ transferConference: createControl(),
199
+ mergeToConference: createControl(),
200
+ wrapup: createControl(),
201
+ switch: createControl(),
202
+ });
203
+ const createDefaultControls = () => ({
204
+ main: createLegControls(),
205
+ consult: createLegControls(),
206
+ activeLeg: 'main',
207
+ });
208
+
209
+ return {
210
+ __esModule: true,
211
+ computeUIControls: jest.fn((state) => {
212
+ const controls = createDefaultControls();
213
+ if (state === 'CONFERENCING') {
214
+ controls.main.exitConference = {isVisible: true, isEnabled: true};
215
+ }
216
+
217
+ return controls;
218
+ }),
219
+ getDefaultUIControls: jest.fn(createDefaultControls),
220
+ haveUIControlsChanged: jest.fn(() => false),
221
+ };
222
+ });
223
+
224
+ jest.mock('../../../../../src/services/task/AutoWrapup', () => ({
225
+ __esModule: true,
226
+ default: jest.fn().mockImplementation(() => ({
227
+ start: jest.fn(),
228
+ clear: jest.fn(),
229
+ })),
230
+ }));
231
+
232
+ jest.mock('../../../../../src/services/task/TaskUtils', () => ({
233
+ createSummaryError: jest.fn((errorCode) => {
234
+ const error = new Error(errorCode) as Error & {data?: Record<string, unknown>};
235
+ error.data = {errorCode};
236
+
237
+ return error;
238
+ }),
239
+ }));
240
+
241
+ jest.mock('../../../../../src/services/core/Utils', () => ({
242
+ getErrorDetails: jest.fn((_error, method) => ({
243
+ error: new Error(`Error while performing ${method}`),
244
+ })),
245
+ }));
246
+
247
+ jest.mock('../../../../../src/metrics/MetricsManager', () => {
248
+ const metricsInstance = {
249
+ trackEvent: jest.fn(),
250
+ timeEvent: jest.fn(),
251
+ };
252
+ const MetricsManager = jest.fn();
253
+
254
+ (MetricsManager as any).getInstance = jest.fn(() => metricsInstance);
255
+ (MetricsManager as any).getCommonTrackingFieldForAQMResponse = jest.fn(() => ({}));
256
+ (MetricsManager as any).getCommonTrackingFieldForAQMResponseFailed = jest.fn(() => ({}));
257
+
258
+ return {
259
+ __esModule: true,
260
+ default: MetricsManager,
261
+ };
262
+ });
68
263
 
69
264
  jest.mock('../../../../../src/logger-proxy', () => ({
70
265
  __esModule: true,
@@ -83,6 +278,10 @@ jest.mock('../../../../../src/services/core/WebexRequest', () => ({
83
278
  },
84
279
  }));
85
280
 
281
+ beforeEach(() => {
282
+ jest.clearAllMocks();
283
+ });
284
+
86
285
  describe('Task (base class)', () => {
87
286
  const dummyContact = {} as any;
88
287
  const initialData = {
@@ -256,6 +455,33 @@ describe('Task (base class)', () => {
256
455
  expect(emitSpy).not.toHaveBeenCalledWith(TASK_EVENTS.TASK_WRAPUP, task);
257
456
  });
258
457
 
458
+ it('adapts incoming, consulting, and rejection state-machine actions to task events', () => {
459
+ const actions = (task as any).getStateMachineActionOverrides();
460
+ const incomingData = createTaskData({interactionId: 'action-incoming'});
461
+ const emitSpy = jest.spyOn(task, 'emit');
462
+
463
+ actions.emitTaskIncoming({event: {type: 'TASK_INCOMING', taskData: incomingData}});
464
+ actions.emitTaskConsulting({
465
+ event: {
466
+ type: 'CONSULTING_ACTIVE',
467
+ taskData: {...incomingData, isConsulted: false},
468
+ },
469
+ });
470
+ actions.emitTaskConsulting({
471
+ event: {
472
+ type: 'CONSULTING_ACTIVE',
473
+ taskData: {...incomingData, isConsulted: true},
474
+ },
475
+ });
476
+ actions.emitTaskReject({event: {type: 'ASSIGN_FAILED', reason: 'busy'}});
477
+ actions.emitTaskReject({event: undefined});
478
+
479
+ expect(task.data.interactionId).toBe('action-incoming');
480
+ expect(emitSpy).toHaveBeenCalledWith(TASK_EVENTS.TASK_CONSULTING, task);
481
+ expect(emitSpy).toHaveBeenCalledWith(TASK_EVENTS.TASK_CONSULT_ACCEPTED, task);
482
+ expect(emitSpy).toHaveBeenCalledWith(TASK_EVENTS.TASK_REJECT, 'busy');
483
+ });
484
+
259
485
  it('starts auto wrap-up only when wrapUpRequired is true and profile autoWrapup is enabled', () => {
260
486
  const wrapUpProps = {
261
487
  autoWrapup: true,
@@ -301,12 +527,12 @@ describe('Task (base class)', () => {
301
527
  'agent-1'
302
528
  );
303
529
 
304
- jest.useFakeTimers();
305
530
  try {
306
531
  wrappingTask.updateTaskData(createTaskData({wrapUpRequired: true}) as TaskData);
307
532
  expect(wrappingTask.autoWrapup).toBeDefined();
308
533
 
309
- jest.advanceTimersByTime(1000);
534
+ const onComplete = (wrappingTask.autoWrapup as any).start.mock.calls[0][0];
535
+ await onComplete();
310
536
 
311
537
  expect(contact.wrapup).toHaveBeenCalledWith({
312
538
  interactionId: 'interaction-1',
@@ -314,7 +540,6 @@ describe('Task (base class)', () => {
314
540
  });
315
541
  } finally {
316
542
  wrappingTask.cancelAutoWrapupTimer();
317
- jest.useRealTimers();
318
543
  }
319
544
  });
320
545
 
@@ -544,7 +769,7 @@ describe('Task (base class)', () => {
544
769
  taskData: {...data, isAutoAnswering: true} as any,
545
770
  });
546
771
 
547
- await flushPromises();
772
+ await flushEventLoopTurn();
548
773
  expect(webrtcTask.acceptMock).toHaveBeenCalled();
549
774
  expect(autoAnsweredSpy).toHaveBeenCalledWith(webrtcTask);
550
775
  });
@@ -556,7 +781,7 @@ describe('Task (base class)', () => {
556
781
  webrtcTask.sendStateMachineEvent({type: TaskEvent.TASK_INCOMING, taskData: data});
557
782
  webrtcTask.sendStateMachineEvent({type: TaskEvent.TASK_OFFERED, taskData: data});
558
783
 
559
- await flushPromises();
784
+ await flushEventLoopTurn();
560
785
  expect(webrtcTask.acceptMock).not.toHaveBeenCalled();
561
786
  });
562
787
 
@@ -567,7 +792,7 @@ describe('Task (base class)', () => {
567
792
  pstnTask.sendStateMachineEvent({type: TaskEvent.TASK_INCOMING, taskData: data});
568
793
  pstnTask.sendStateMachineEvent({type: TaskEvent.TASK_OFFERED, taskData: data});
569
794
 
570
- await flushPromises();
795
+ await flushEventLoopTurn();
571
796
  expect(pstnTask.acceptMock).not.toHaveBeenCalled();
572
797
  });
573
798
 
@@ -579,7 +804,7 @@ describe('Task (base class)', () => {
579
804
  webrtcTask.sendStateMachineEvent({type: TaskEvent.TASK_INCOMING, taskData: data});
580
805
  webrtcTask.sendStateMachineEvent({type: TaskEvent.TASK_OFFERED, taskData: data});
581
806
 
582
- await flushPromises();
807
+ await flushEventLoopTurn();
583
808
  expect(webrtcTask.data.isAutoAnswering).toBe(false);
584
809
  });
585
810
 
@@ -743,3 +968,1756 @@ describe('Task failure scenarios', () => {
743
968
  await expect(task.wrapup(payload)).rejects.toThrow('Error while performing wrapup');
744
969
  });
745
970
  });
971
+
972
+ const createAISummaryTaskData = (overrides: Partial<TaskData> = {}): TaskData => {
973
+ const {interaction: interactionOverrides, ...taskOverrides} = overrides;
974
+ const interactionId = taskOverrides.interactionId ?? 'interaction-1';
975
+
976
+ return createTaskData({
977
+ taskId: 'task-owner-1',
978
+ ...taskOverrides,
979
+ interactionId,
980
+ interaction: {
981
+ interactionId,
982
+ mainInteractionId: 'conversation-1',
983
+ ...(interactionOverrides as Record<string, unknown> | undefined),
984
+ },
985
+ });
986
+ };
987
+
988
+ const createPostCallSummaryPayload = () => ({
989
+ conversationId: 'conversation-1',
990
+ summaryText: 'generated post-call summary',
991
+ sections: {initialContactReason: 'billing'},
992
+ });
993
+
994
+ const createMidCallSummaryPayload = () => ({
995
+ conversationId: 'conversation-1',
996
+ summaryText: 'generated mid-call summary',
997
+ sections: {reasonForTransferOrConsult: 'specialist'},
998
+ });
999
+
1000
+ const createPostCallResponsePayload = (
1001
+ overrides: Partial<AISummaryResponse> = {}
1002
+ ): AISummaryResponse =>
1003
+ ({
1004
+ summary: {summarySectionKeySentinel: 'summary-section-value-sentinel'} as any,
1005
+ feedback: 'thumbs_up',
1006
+ state: 'DEFAULT',
1007
+ wrapUpCode: 'resolved',
1008
+ numberOfTimesViewed: 1,
1009
+ numberOfTimesEdited: 0,
1010
+ numberOfTimesCopied: 0,
1011
+ ...overrides,
1012
+ } as AISummaryResponse);
1013
+
1014
+ const createMidCallResponsePayload = (
1015
+ overrides: Partial<AISummaryResponse> = {}
1016
+ ): AISummaryResponse =>
1017
+ ({
1018
+ summaryReceived: true,
1019
+ summary: {midCallSectionKeySentinel: 'mid-call-section-value-sentinel'} as any,
1020
+ feedback: 'none',
1021
+ state: 'DEFAULT',
1022
+ numberOfTimesViewed: 1,
1023
+ numberOfTimesEdited: 0,
1024
+ numberOfTimesCopied: 0,
1025
+ ...overrides,
1026
+ } as AISummaryResponse);
1027
+
1028
+ const summaryRequestCases: Array<{
1029
+ label: string;
1030
+ invoke: (task: DummyTask) => Promise<any>;
1031
+ eventName: AIAssistantEventName;
1032
+ timeoutCode: string;
1033
+ createPayload: () => any;
1034
+ }> = [
1035
+ {
1036
+ label: 'post-call',
1037
+ invoke: (task) => task.requestPostCallSummary(),
1038
+ eventName: AIAssistantEventName.GET_POST_CALL_SUMMARY,
1039
+ timeoutCode: AI_SUMMARY_ERROR_CODES.POST_CALL_SUMMARY_TIMEOUT,
1040
+ createPayload: createPostCallSummaryPayload,
1041
+ },
1042
+ {
1043
+ label: 'consult mid-call',
1044
+ invoke: (task) => task.requestMidCallSummary('CONSULT'),
1045
+ eventName: AIAssistantEventName.GET_MID_CALL_CONSULT_SUMMARY,
1046
+ timeoutCode: AI_SUMMARY_ERROR_CODES.MID_CALL_SUMMARY_TIMEOUT,
1047
+ createPayload: createMidCallSummaryPayload,
1048
+ },
1049
+ {
1050
+ label: 'transfer mid-call',
1051
+ invoke: (task) => task.requestMidCallSummary('TRANSFER'),
1052
+ eventName: AIAssistantEventName.GET_MID_CALL_TRANSFER_SUMMARY,
1053
+ timeoutCode: AI_SUMMARY_ERROR_CODES.MID_CALL_SUMMARY_TIMEOUT,
1054
+ createPayload: createMidCallSummaryPayload,
1055
+ },
1056
+ ];
1057
+
1058
+ describe('Task AI summary APIs', () => {
1059
+ const dummyContact = {} as any;
1060
+ const injectSummaryDependencies = (
1061
+ task: DummyTask,
1062
+ apiAIAssistant: any,
1063
+ flags: {wrapUpSummariesEnabled?: boolean; consultTransferSummariesEnabled?: boolean}
1064
+ ) => {
1065
+ apiAIAssistant.aiFeature = {id: 'summary-flags', generatedSummaries: flags};
1066
+ (task as any).apiAIAssistant = apiAIAssistant;
1067
+ };
1068
+
1069
+ const createSummaryMocks = (
1070
+ task: DummyTask,
1071
+ options: {
1072
+ postCallEnabled?: boolean;
1073
+ midCallEnabled?: boolean;
1074
+ wrapUpSummariesEnabled?: boolean;
1075
+ consultTransferSummariesEnabled?: boolean;
1076
+ registrationResult?: Promise<any>;
1077
+ } = {}
1078
+ ) => {
1079
+ const pendingRequests = new Map<string, any>();
1080
+ const adapter: any = {
1081
+ sendEvent: jest.fn().mockResolvedValue(undefined),
1082
+ requestAndWaitForRtd: jest.fn(async (requestOptions: any) => {
1083
+ const key = JSON.stringify([requestOptions.rtdEventType, requestOptions.correlationId]);
1084
+ let resolveResult: (value: any) => void = () => undefined;
1085
+ let rejectResult: (error: Error) => void = () => undefined;
1086
+ const result = new Promise((resolve, reject) => {
1087
+ resolveResult = resolve;
1088
+ rejectResult = reject;
1089
+ });
1090
+ const pending = {
1091
+ result,
1092
+ timeoutId: undefined as any,
1093
+ resolve: resolveResult,
1094
+ reject: rejectResult,
1095
+ };
1096
+ pendingRequests.set(key, pending);
1097
+ const publishTimestamp = Date.now();
1098
+ const acknowledgement = adapter.sendEvent(
1099
+ requestOptions.agentId,
1100
+ requestOptions.interactionId,
1101
+ requestOptions.eventType,
1102
+ requestOptions.eventName,
1103
+ requestOptions.eventMetaData,
1104
+ undefined,
1105
+ undefined,
1106
+ publishTimestamp,
1107
+ requestOptions.timeout
1108
+ );
1109
+ const value = options.registrationResult ?? Promise.resolve(createPostCallSummaryPayload());
1110
+ const timeout = new Promise((_resolve, reject) => {
1111
+ pending.timeoutId = setTimeout(
1112
+ () => reject(requestOptions.createTimeoutError()),
1113
+ requestOptions.timeoutMs
1114
+ );
1115
+ });
1116
+ try {
1117
+ const [resolvedValue] = await Promise.all([
1118
+ Promise.race([value, timeout]),
1119
+ acknowledgement,
1120
+ ]);
1121
+ clearTimeout(pending.timeoutId);
1122
+ pendingRequests.delete(key);
1123
+ return resolvedValue;
1124
+ } catch (error) {
1125
+ clearTimeout(pending.timeoutId);
1126
+ pendingRequests.delete(key);
1127
+ throw error;
1128
+ }
1129
+ }),
1130
+ resolveFromRtdEvent: jest.fn((rtdEventType: string, correlationId: string, payload: any) => {
1131
+ const key = JSON.stringify([rtdEventType, correlationId]);
1132
+ const pending = pendingRequests.get(key);
1133
+ if (!pending) return 'not-found';
1134
+ clearTimeout(pending.timeoutId);
1135
+ pendingRequests.delete(key);
1136
+ pending.resolve(payload);
1137
+ return 'resolved';
1138
+ }),
1139
+ clearAllRtdRequests: jest.fn(() => {
1140
+ pendingRequests.forEach((pending) => {
1141
+ clearTimeout(pending.timeoutId);
1142
+ pending.reject(new Error(AI_SUMMARY_REQUEST_CANCELLED));
1143
+ });
1144
+ pendingRequests.clear();
1145
+ }),
1146
+ clear: jest.fn(),
1147
+ pendingRequests,
1148
+ };
1149
+ const coordinator = adapter;
1150
+ const postCallEnabled = Object.prototype.hasOwnProperty.call(options, 'postCallEnabled')
1151
+ ? options.postCallEnabled
1152
+ : true;
1153
+ const midCallEnabled = Object.prototype.hasOwnProperty.call(options, 'midCallEnabled')
1154
+ ? options.midCallEnabled
1155
+ : true;
1156
+ const wrapUpSummariesEnabled = Object.prototype.hasOwnProperty.call(
1157
+ options,
1158
+ 'wrapUpSummariesEnabled'
1159
+ )
1160
+ ? options.wrapUpSummariesEnabled
1161
+ : true;
1162
+ const consultTransferSummariesEnabled = Object.prototype.hasOwnProperty.call(
1163
+ options,
1164
+ 'consultTransferSummariesEnabled'
1165
+ )
1166
+ ? options.consultTransferSummariesEnabled
1167
+ : true;
1168
+
1169
+ const generatedSummaryFlags = {
1170
+ wrapUpSummariesEnabled,
1171
+ consultTransferSummariesEnabled,
1172
+ };
1173
+
1174
+ task.setFeatureEnablement({
1175
+ interactionId: task.data.interactionId,
1176
+ postCallEnabled,
1177
+ midCallEnabled,
1178
+ });
1179
+ injectSummaryDependencies(task, adapter, generatedSummaryFlags);
1180
+
1181
+ return {adapter, coordinator};
1182
+ };
1183
+
1184
+ const createRealSummaryMocks = (task: DummyTask) => {
1185
+ const result = createDeferred<any>();
1186
+ const adapter = {
1187
+ sendEvent: jest.fn().mockResolvedValue(undefined),
1188
+ requestAndWaitForRtd: jest.fn(async (requestOptions: any) => {
1189
+ const publishTimestamp = Date.now();
1190
+ let timeoutId: ReturnType<typeof setTimeout>;
1191
+ const timeout = new Promise((_resolve, reject) => {
1192
+ timeoutId = setTimeout(
1193
+ () => reject(requestOptions.createTimeoutError()),
1194
+ requestOptions.timeoutMs
1195
+ );
1196
+ });
1197
+ await adapter.sendEvent(
1198
+ requestOptions.agentId,
1199
+ requestOptions.interactionId,
1200
+ requestOptions.eventType,
1201
+ requestOptions.eventName,
1202
+ requestOptions.eventMetaData,
1203
+ undefined,
1204
+ undefined,
1205
+ publishTimestamp,
1206
+ requestOptions.timeout
1207
+ );
1208
+ try {
1209
+ return await Promise.race([result.promise, timeout]);
1210
+ } finally {
1211
+ clearTimeout(timeoutId);
1212
+ }
1213
+ }),
1214
+ resolveFromRtdEvent: jest.fn((_eventType: string, _correlationId: string, payload: any) => {
1215
+ result.resolve(payload);
1216
+ return 'resolved';
1217
+ }),
1218
+ clearAll: jest.fn(() => result.reject(new Error(AI_SUMMARY_REQUEST_CANCELLED))),
1219
+ clear: jest.fn(),
1220
+ } as any;
1221
+ const generatedSummaryFlags = {
1222
+ wrapUpSummariesEnabled: true,
1223
+ consultTransferSummariesEnabled: true,
1224
+ };
1225
+
1226
+ task.setFeatureEnablement({
1227
+ interactionId: task.data.interactionId,
1228
+ postCallEnabled: true,
1229
+ midCallEnabled: true,
1230
+ });
1231
+ injectSummaryDependencies(task, adapter, generatedSummaryFlags);
1232
+
1233
+ return {adapter, coordinator: adapter, result};
1234
+ };
1235
+
1236
+ const expectSummaryGetEvent = (
1237
+ adapter: {sendEvent: jest.Mock},
1238
+ eventName: string,
1239
+ callIndex?: number
1240
+ ) => {
1241
+ const expectedArgs = [
1242
+ 'agent-1',
1243
+ 'interaction-1',
1244
+ 'CTI_EVENT',
1245
+ eventName,
1246
+ expect.objectContaining({
1247
+ conversationId: 'conversation-1',
1248
+ clientType: 'WxCC',
1249
+ }),
1250
+ undefined,
1251
+ undefined,
1252
+ expect.any(Number),
1253
+ AI_SUMMARY_DURATION_MS,
1254
+ ];
1255
+
1256
+ if (callIndex === undefined) {
1257
+ expect(adapter.sendEvent).toHaveBeenCalledWith(...expectedArgs);
1258
+
1259
+ return;
1260
+ }
1261
+
1262
+ expect(adapter.sendEvent).toHaveBeenNthCalledWith(callIndex, ...expectedArgs);
1263
+ };
1264
+
1265
+ const getSummaryEventPayload = (adapter: {sendEvent: jest.Mock}, callIndex = 0) => {
1266
+ const [agentId, interactionId, , eventName, eventData, , , publishTimestamp] =
1267
+ adapter.sendEvent.mock.calls[callIndex];
1268
+ const {clientType: _clientType, action: _action, ...summaryData} = eventData;
1269
+
1270
+ return {
1271
+ agentId,
1272
+ interactionId,
1273
+ eventName,
1274
+ ...summaryData,
1275
+ ...(publishTimestamp !== undefined ? {publishTimestamp} : {}),
1276
+ };
1277
+ };
1278
+
1279
+ const spyOnAISummaryMetrics = (task: DummyTask) => {
1280
+ const metricsManager = (task as any).metricsManager;
1281
+
1282
+ return {
1283
+ trackEvent: jest.spyOn(metricsManager, 'trackEvent').mockImplementation(() => undefined),
1284
+ timeEvent: jest.spyOn(metricsManager, 'timeEvent').mockImplementation(() => undefined),
1285
+ };
1286
+ };
1287
+
1288
+ const getPendingRequest = (
1289
+ coordinator: any,
1290
+ eventType: 'POST_CALL_SUMMARY' | 'MID_CALL_SUMMARY',
1291
+ conversationId: string
1292
+ ) =>
1293
+ (coordinator as any).pendingRequests.get(JSON.stringify([eventType, conversationId]));
1294
+
1295
+ afterEach(() => {
1296
+ jest.useRealTimers();
1297
+ jest.restoreAllMocks();
1298
+ });
1299
+
1300
+ it('defines the exact AI summary operation metric names', () => {
1301
+ expect(METRIC_EVENT_NAMES).toMatchObject({
1302
+ AI_SUMMARY_GET_POST_CALL_SUCCESS: 'Post Call Summary Get Success',
1303
+ AI_SUMMARY_GET_POST_CALL_FAILED: 'Post Call Summary Get Failed',
1304
+ AI_SUMMARY_GET_MID_CALL_SUCCESS: 'Mid Call Summary Get Success',
1305
+ AI_SUMMARY_GET_MID_CALL_FAILED: 'Mid Call Summary Get Failed',
1306
+ AI_SUMMARY_POST_CALL_RESPONSE_SUCCESS: 'Post Call Summary Response Success',
1307
+ AI_SUMMARY_POST_CALL_RESPONSE_FAILED: 'Post Call Summary Response Failed',
1308
+ AI_SUMMARY_MID_CALL_RESPONSE_SUCCESS: 'Mid Call Summary Response Success',
1309
+ AI_SUMMARY_MID_CALL_RESPONSE_FAILED: 'Mid Call Summary Response Failed',
1310
+ });
1311
+ });
1312
+
1313
+ it('requests a post-call summary through accepted registration, HTTP acknowledgement, and matching RTD result', async () => {
1314
+ const task = new DummyTask(dummyContact, createAISummaryTaskData());
1315
+ const metrics = spyOnAISummaryMetrics(task);
1316
+ const postCallResult = createPostCallSummaryPayload();
1317
+ const {adapter, coordinator} = createSummaryMocks(task, {
1318
+ registrationResult: Promise.resolve(postCallResult),
1319
+ });
1320
+
1321
+ await expect(task.requestPostCallSummary()).resolves.toBe(postCallResult);
1322
+
1323
+ expect(coordinator.requestAndWaitForRtd).toHaveBeenCalledWith(
1324
+ expect.objectContaining({
1325
+ correlationId: 'conversation-1',
1326
+ rtdEventType: 'POST_CALL_SUMMARY',
1327
+ timeoutMs: AI_SUMMARY_DURATION_MS,
1328
+ })
1329
+ );
1330
+ expectSummaryGetEvent(adapter, AIAssistantEventName.GET_POST_CALL_SUMMARY);
1331
+ expect(metrics.trackEvent).toHaveBeenCalledWith(
1332
+ METRIC_EVENT_NAMES.AI_SUMMARY_GET_POST_CALL_SUCCESS,
1333
+ expect.objectContaining({
1334
+ conversationId: 'conversation-1',
1335
+ interactionId: 'interaction-1',
1336
+ }),
1337
+ ['operational']
1338
+ );
1339
+ expect(metrics.timeEvent).toHaveBeenCalledWith([
1340
+ METRIC_EVENT_NAMES.AI_SUMMARY_GET_POST_CALL_SUCCESS,
1341
+ METRIC_EVENT_NAMES.AI_SUMMARY_GET_POST_CALL_FAILED,
1342
+ ]);
1343
+ });
1344
+
1345
+ it('maps consult and transfer mid-call summary requests to their exact outbound event names', async () => {
1346
+ const consultTask = new DummyTask(dummyContact, createAISummaryTaskData());
1347
+ const consultMetrics = spyOnAISummaryMetrics(consultTask);
1348
+ const consultResult = createMidCallSummaryPayload();
1349
+ const consultMocks = createSummaryMocks(consultTask, {
1350
+ registrationResult: Promise.resolve(consultResult),
1351
+ });
1352
+
1353
+ await expect(consultTask.requestMidCallSummary('CONSULT')).resolves.toBe(consultResult);
1354
+
1355
+ expect(consultMocks.coordinator.requestAndWaitForRtd).toHaveBeenCalledWith(
1356
+ expect.objectContaining({
1357
+ correlationId: 'conversation-1',
1358
+ rtdEventType: 'MID_CALL_SUMMARY',
1359
+ timeoutMs: AI_SUMMARY_DURATION_MS,
1360
+ })
1361
+ );
1362
+ expectSummaryGetEvent(
1363
+ consultMocks.adapter,
1364
+ AIAssistantEventName.GET_MID_CALL_CONSULT_SUMMARY
1365
+ );
1366
+ expect(consultMetrics.trackEvent).toHaveBeenCalledWith(
1367
+ METRIC_EVENT_NAMES.AI_SUMMARY_GET_MID_CALL_SUCCESS,
1368
+ expect.objectContaining({
1369
+ operation: METHODS.REQUEST_MID_CALL_SUMMARY,
1370
+ actionType: 'CONSULT',
1371
+ conversationId: 'conversation-1',
1372
+ interactionId: 'interaction-1',
1373
+ }),
1374
+ ['operational']
1375
+ );
1376
+
1377
+ const transferTask = new DummyTask(dummyContact, createAISummaryTaskData());
1378
+ const transferResult = createMidCallSummaryPayload();
1379
+ const transferMocks = createSummaryMocks(transferTask, {
1380
+ registrationResult: Promise.resolve(transferResult),
1381
+ });
1382
+
1383
+ await expect(transferTask.requestMidCallSummary('TRANSFER')).resolves.toBe(transferResult);
1384
+ expectSummaryGetEvent(
1385
+ transferMocks.adapter,
1386
+ AIAssistantEventName.GET_MID_CALL_TRANSFER_SUMMARY
1387
+ );
1388
+ });
1389
+
1390
+ it.each([
1391
+ {
1392
+ label: 'post-call registration first',
1393
+ invoke: (task: DummyTask) => task.requestPostCallSummary(),
1394
+ inboundType: 'POST_CALL_SUMMARY' as const,
1395
+ timeoutCode: AI_SUMMARY_ERROR_CODES.POST_CALL_SUMMARY_TIMEOUT,
1396
+ eventName: AIAssistantEventName.GET_POST_CALL_SUMMARY,
1397
+ payload: createPostCallSummaryPayload(),
1398
+ successMetric: METRIC_EVENT_NAMES.AI_SUMMARY_GET_POST_CALL_SUCCESS,
1399
+ failureMetric: METRIC_EVENT_NAMES.AI_SUMMARY_GET_POST_CALL_FAILED,
1400
+ operation: METHODS.REQUEST_POST_CALL_SUMMARY,
1401
+ settleFirst: 'result' as const,
1402
+ },
1403
+ {
1404
+ label: 'mid-call acknowledgement first',
1405
+ invoke: (task: DummyTask) => task.requestMidCallSummary('CONSULT'),
1406
+ inboundType: 'MID_CALL_SUMMARY' as const,
1407
+ timeoutCode: AI_SUMMARY_ERROR_CODES.MID_CALL_SUMMARY_TIMEOUT,
1408
+ eventName: AIAssistantEventName.GET_MID_CALL_CONSULT_SUMMARY,
1409
+ payload: createMidCallSummaryPayload(),
1410
+ successMetric: METRIC_EVENT_NAMES.AI_SUMMARY_GET_MID_CALL_SUCCESS,
1411
+ failureMetric: METRIC_EVENT_NAMES.AI_SUMMARY_GET_MID_CALL_FAILED,
1412
+ operation: METHODS.REQUEST_MID_CALL_SUMMARY,
1413
+ actionType: 'CONSULT',
1414
+ settleFirst: 'acknowledgement' as const,
1415
+ },
1416
+ ])(
1417
+ 'sends the HTTP request and waits for both request branches on $label',
1418
+ async ({
1419
+ invoke,
1420
+ inboundType,
1421
+ timeoutCode,
1422
+ eventName,
1423
+ payload,
1424
+ successMetric,
1425
+ failureMetric,
1426
+ operation,
1427
+ actionType,
1428
+ settleFirst,
1429
+ }) => {
1430
+ const task = new DummyTask(dummyContact, createAISummaryTaskData());
1431
+ const metrics = spyOnAISummaryMetrics(task);
1432
+ const acknowledgement = createDeferred<void>();
1433
+ const result = createDeferred<any>();
1434
+ const {adapter, coordinator} = createSummaryMocks(task, {
1435
+ registrationResult: result.promise,
1436
+ });
1437
+ let publicSettled = false;
1438
+
1439
+ adapter.sendEvent.mockReturnValueOnce(acknowledgement.promise);
1440
+
1441
+ const publicRequest = invoke(task);
1442
+
1443
+ publicRequest.then(
1444
+ () => {
1445
+ publicSettled = true;
1446
+ },
1447
+ () => {
1448
+ publicSettled = true;
1449
+ }
1450
+ );
1451
+ await Promise.resolve();
1452
+
1453
+ expect(coordinator.requestAndWaitForRtd).toHaveBeenCalledWith(
1454
+ expect.objectContaining({
1455
+ correlationId: 'conversation-1',
1456
+ rtdEventType: inboundType,
1457
+ timeoutMs: AI_SUMMARY_DURATION_MS,
1458
+ })
1459
+ );
1460
+ expectSummaryGetEvent(adapter, eventName);
1461
+ expect(publicSettled).toBe(false);
1462
+
1463
+ if (settleFirst === 'result') {
1464
+ result.resolve(payload);
1465
+ await Promise.resolve();
1466
+ expect(publicSettled).toBe(false);
1467
+ expect(metrics.trackEvent).not.toHaveBeenCalled();
1468
+
1469
+ acknowledgement.resolve(undefined);
1470
+ } else {
1471
+ acknowledgement.resolve(undefined);
1472
+ await Promise.resolve();
1473
+ expect(publicSettled).toBe(false);
1474
+ expect(metrics.trackEvent).not.toHaveBeenCalled();
1475
+
1476
+ result.resolve(payload);
1477
+ }
1478
+
1479
+ await expect(publicRequest).resolves.toBe(payload);
1480
+ expect(metrics.trackEvent).toHaveBeenCalledTimes(1);
1481
+ expect(metrics.trackEvent).toHaveBeenCalledWith(
1482
+ successMetric,
1483
+ expect.objectContaining({
1484
+ operation,
1485
+ ...(actionType ? {actionType} : {}),
1486
+ conversationId: 'conversation-1',
1487
+ interactionId: 'interaction-1',
1488
+ }),
1489
+ ['operational']
1490
+ );
1491
+ expect(metrics.timeEvent).toHaveBeenCalledWith([successMetric, failureMetric]);
1492
+ }
1493
+ );
1494
+
1495
+ it.each([
1496
+ {
1497
+ label: 'post-call organization false',
1498
+ invoke: (task: DummyTask) => task.requestPostCallSummary(),
1499
+ options: {wrapUpSummariesEnabled: false, postCallEnabled: true},
1500
+ disabledCode: AI_SUMMARY_ERROR_CODES.POST_CALL_SUMMARY_DISABLED,
1501
+ failureMetric: METRIC_EVENT_NAMES.AI_SUMMARY_GET_POST_CALL_FAILED,
1502
+ operation: METHODS.REQUEST_POST_CALL_SUMMARY,
1503
+ },
1504
+ {
1505
+ label: 'post-call organization undefined',
1506
+ invoke: (task: DummyTask) => task.requestPostCallSummary(),
1507
+ options: {wrapUpSummariesEnabled: undefined, postCallEnabled: true},
1508
+ disabledCode: AI_SUMMARY_ERROR_CODES.POST_CALL_SUMMARY_DISABLED,
1509
+ failureMetric: METRIC_EVENT_NAMES.AI_SUMMARY_GET_POST_CALL_FAILED,
1510
+ operation: METHODS.REQUEST_POST_CALL_SUMMARY,
1511
+ },
1512
+ {
1513
+ label: 'post-call interaction false',
1514
+ invoke: (task: DummyTask) => task.requestPostCallSummary(),
1515
+ options: {wrapUpSummariesEnabled: true, postCallEnabled: false},
1516
+ disabledCode: AI_SUMMARY_ERROR_CODES.POST_CALL_SUMMARY_DISABLED,
1517
+ failureMetric: METRIC_EVENT_NAMES.AI_SUMMARY_GET_POST_CALL_FAILED,
1518
+ operation: METHODS.REQUEST_POST_CALL_SUMMARY,
1519
+ },
1520
+ {
1521
+ label: 'post-call interaction undefined',
1522
+ invoke: (task: DummyTask) => task.requestPostCallSummary(),
1523
+ options: {wrapUpSummariesEnabled: true, postCallEnabled: undefined},
1524
+ disabledCode: AI_SUMMARY_ERROR_CODES.POST_CALL_SUMMARY_DISABLED,
1525
+ failureMetric: METRIC_EVENT_NAMES.AI_SUMMARY_GET_POST_CALL_FAILED,
1526
+ operation: METHODS.REQUEST_POST_CALL_SUMMARY,
1527
+ },
1528
+ {
1529
+ label: 'mid-call organization false',
1530
+ invoke: (task: DummyTask) => task.requestMidCallSummary('CONSULT'),
1531
+ options: {consultTransferSummariesEnabled: false, midCallEnabled: true},
1532
+ disabledCode: AI_SUMMARY_ERROR_CODES.MID_CALL_SUMMARY_DISABLED,
1533
+ failureMetric: METRIC_EVENT_NAMES.AI_SUMMARY_GET_MID_CALL_FAILED,
1534
+ operation: METHODS.REQUEST_MID_CALL_SUMMARY,
1535
+ actionType: 'CONSULT',
1536
+ },
1537
+ {
1538
+ label: 'mid-call organization undefined',
1539
+ invoke: (task: DummyTask) => task.requestMidCallSummary('CONSULT'),
1540
+ options: {consultTransferSummariesEnabled: undefined, midCallEnabled: true},
1541
+ disabledCode: AI_SUMMARY_ERROR_CODES.MID_CALL_SUMMARY_DISABLED,
1542
+ failureMetric: METRIC_EVENT_NAMES.AI_SUMMARY_GET_MID_CALL_FAILED,
1543
+ operation: METHODS.REQUEST_MID_CALL_SUMMARY,
1544
+ actionType: 'CONSULT',
1545
+ },
1546
+ {
1547
+ label: 'mid-call interaction false',
1548
+ invoke: (task: DummyTask) => task.requestMidCallSummary('CONSULT'),
1549
+ options: {consultTransferSummariesEnabled: true, midCallEnabled: false},
1550
+ disabledCode: AI_SUMMARY_ERROR_CODES.MID_CALL_SUMMARY_DISABLED,
1551
+ failureMetric: METRIC_EVENT_NAMES.AI_SUMMARY_GET_MID_CALL_FAILED,
1552
+ operation: METHODS.REQUEST_MID_CALL_SUMMARY,
1553
+ actionType: 'CONSULT',
1554
+ },
1555
+ {
1556
+ label: 'mid-call interaction undefined',
1557
+ invoke: (task: DummyTask) => task.requestMidCallSummary('CONSULT'),
1558
+ options: {consultTransferSummariesEnabled: true, midCallEnabled: undefined},
1559
+ disabledCode: AI_SUMMARY_ERROR_CODES.MID_CALL_SUMMARY_DISABLED,
1560
+ failureMetric: METRIC_EVENT_NAMES.AI_SUMMARY_GET_MID_CALL_FAILED,
1561
+ operation: METHODS.REQUEST_MID_CALL_SUMMARY,
1562
+ actionType: 'CONSULT',
1563
+ },
1564
+ ])(
1565
+ 'rejects disabled $label summary requests before registration or HTTP',
1566
+ async ({invoke, options, disabledCode, failureMetric, operation, actionType}) => {
1567
+ const task = new DummyTask(dummyContact, createAISummaryTaskData());
1568
+ const metrics = spyOnAISummaryMetrics(task);
1569
+ const {adapter, coordinator} = createSummaryMocks(task, options);
1570
+
1571
+ await expect(invoke(task)).rejects.toMatchObject(
1572
+ createAISummaryErrorExpectation(disabledCode)
1573
+ );
1574
+ expect(coordinator.requestAndWaitForRtd).not.toHaveBeenCalled();
1575
+ expect(adapter.sendEvent).not.toHaveBeenCalled();
1576
+ expect(metrics.trackEvent).toHaveBeenCalledTimes(1);
1577
+ expect(metrics.trackEvent).toHaveBeenCalledWith(
1578
+ failureMetric,
1579
+ expect.objectContaining({
1580
+ operation,
1581
+ ...(actionType ? {actionType} : {}),
1582
+ failureCode: disabledCode,
1583
+ }),
1584
+ ['operational']
1585
+ );
1586
+ expect(metrics.timeEvent).toHaveBeenCalled();
1587
+ }
1588
+ );
1589
+
1590
+ it.each([
1591
+ {
1592
+ label: 'post-call',
1593
+ invoke: (task: DummyTask) => task.requestPostCallSummary(),
1594
+ disabledCode: AI_SUMMARY_ERROR_CODES.POST_CALL_SUMMARY_DISABLED,
1595
+ featureEnablement: {postCallEnabled: false, midCallEnabled: true},
1596
+ },
1597
+ {
1598
+ label: 'mid-call',
1599
+ invoke: (task: DummyTask) => task.requestMidCallSummary('CONSULT'),
1600
+ disabledCode: AI_SUMMARY_ERROR_CODES.MID_CALL_SUMMARY_DISABLED,
1601
+ featureEnablement: {postCallEnabled: true, midCallEnabled: false},
1602
+ },
1603
+ ])(
1604
+ 'uses the top-level interaction feature key only for $label requests',
1605
+ async ({invoke, disabledCode, featureEnablement}) => {
1606
+ const task = new DummyTask(
1607
+ dummyContact,
1608
+ createAISummaryTaskData({
1609
+ interactionId: 'child-interaction-1',
1610
+ interaction: {mainInteractionId: 'conversation-1'} as any,
1611
+ })
1612
+ );
1613
+ const {adapter, coordinator} = createSummaryMocks(task, featureEnablement);
1614
+
1615
+ await expect(invoke(task)).rejects.toMatchObject(
1616
+ createAISummaryErrorExpectation(disabledCode)
1617
+ );
1618
+ expect(coordinator.requestAndWaitForRtd).not.toHaveBeenCalled();
1619
+ expect(adapter.sendEvent).not.toHaveBeenCalled();
1620
+ }
1621
+ );
1622
+
1623
+ it.each([
1624
+ {
1625
+ label: 'post-call organization false',
1626
+ invoke: (task: DummyTask) => task.requestPostCallSummary(),
1627
+ inboundType: 'POST_CALL_SUMMARY' as const,
1628
+ disabledCode: AI_SUMMARY_ERROR_CODES.POST_CALL_SUMMARY_DISABLED,
1629
+ flags: {wrapUpSummariesEnabled: false, consultTransferSummariesEnabled: true},
1630
+ featureEnablement: {interactionId: 'interaction-1', postCallEnabled: true},
1631
+ },
1632
+ {
1633
+ label: 'post-call raw undefined interaction flag',
1634
+ invoke: (task: DummyTask) => task.requestPostCallSummary(),
1635
+ inboundType: 'POST_CALL_SUMMARY' as const,
1636
+ disabledCode: AI_SUMMARY_ERROR_CODES.POST_CALL_SUMMARY_DISABLED,
1637
+ flags: {wrapUpSummariesEnabled: true, consultTransferSummariesEnabled: true},
1638
+ },
1639
+ {
1640
+ label: 'post-call mainInteractionId mismatch',
1641
+ invoke: (task: DummyTask) => task.requestPostCallSummary(),
1642
+ inboundType: 'POST_CALL_SUMMARY' as const,
1643
+ disabledCode: AI_SUMMARY_ERROR_CODES.POST_CALL_SUMMARY_DISABLED,
1644
+ flags: {wrapUpSummariesEnabled: true, consultTransferSummariesEnabled: true},
1645
+ taskData: createAISummaryTaskData({
1646
+ interactionId: 'child-interaction-1',
1647
+ interaction: {mainInteractionId: 'conversation-1'} as any,
1648
+ }),
1649
+ featureEnablement: {interactionId: 'conversation-1', postCallEnabled: true},
1650
+ },
1651
+ {
1652
+ label: 'mid-call organization false',
1653
+ invoke: (task: DummyTask) => task.requestMidCallSummary('CONSULT'),
1654
+ inboundType: 'MID_CALL_SUMMARY' as const,
1655
+ disabledCode: AI_SUMMARY_ERROR_CODES.MID_CALL_SUMMARY_DISABLED,
1656
+ flags: {wrapUpSummariesEnabled: true, consultTransferSummariesEnabled: false},
1657
+ featureEnablement: {interactionId: 'interaction-1', midCallEnabled: true},
1658
+ },
1659
+ {
1660
+ label: 'mid-call raw undefined interaction flag',
1661
+ invoke: (task: DummyTask) => task.requestMidCallSummary('CONSULT'),
1662
+ inboundType: 'MID_CALL_SUMMARY' as const,
1663
+ disabledCode: AI_SUMMARY_ERROR_CODES.MID_CALL_SUMMARY_DISABLED,
1664
+ flags: {wrapUpSummariesEnabled: true, consultTransferSummariesEnabled: true},
1665
+ },
1666
+ {
1667
+ label: 'mid-call mainInteractionId mismatch',
1668
+ invoke: (task: DummyTask) => task.requestMidCallSummary('CONSULT'),
1669
+ inboundType: 'MID_CALL_SUMMARY' as const,
1670
+ disabledCode: AI_SUMMARY_ERROR_CODES.MID_CALL_SUMMARY_DISABLED,
1671
+ flags: {wrapUpSummariesEnabled: true, consultTransferSummariesEnabled: true},
1672
+ taskData: createAISummaryTaskData({
1673
+ interactionId: 'child-interaction-1',
1674
+ interaction: {mainInteractionId: 'conversation-1'} as any,
1675
+ }),
1676
+ featureEnablement: {interactionId: 'conversation-1', midCallEnabled: true},
1677
+ },
1678
+ ])(
1679
+ 'leaves the real coordinator idle when $label disables a request',
1680
+ async ({invoke, inboundType, disabledCode, flags, taskData, featureEnablement}) => {
1681
+ jest.useFakeTimers();
1682
+ const task = new DummyTask(dummyContact, taskData ?? createAISummaryTaskData());
1683
+ const adapter: any = {
1684
+ sendEvent: jest.fn().mockResolvedValue(undefined),
1685
+ requestAndWaitForRtd: jest.fn(),
1686
+ pendingRequests: new Map(),
1687
+ };
1688
+ const coordinator = adapter;
1689
+ const registerSpy = jest.spyOn(coordinator, 'requestAndWaitForRtd');
1690
+ injectSummaryDependencies(task, adapter, flags);
1691
+ task.setFeatureEnablement({
1692
+ interactionId: task.data.interactionId,
1693
+ ...(featureEnablement ?? {}),
1694
+ });
1695
+
1696
+ await expect(invoke(task)).rejects.toMatchObject(
1697
+ createAISummaryErrorExpectation(disabledCode)
1698
+ );
1699
+ expect(registerSpy).not.toHaveBeenCalled();
1700
+ expect(adapter.sendEvent).not.toHaveBeenCalled();
1701
+ expect(getPendingRequest(coordinator, inboundType, 'conversation-1')).toBeUndefined();
1702
+ expect(jest.getTimerCount()).toBe(0);
1703
+ }
1704
+ );
1705
+
1706
+ it('uses the agent configuration’s current generated-summary flags for each request', async () => {
1707
+ const firstFlags = {
1708
+ wrapUpSummariesEnabled: false,
1709
+ consultTransferSummariesEnabled: true,
1710
+ };
1711
+ const secondFlags = {
1712
+ wrapUpSummariesEnabled: true,
1713
+ consultTransferSummariesEnabled: true,
1714
+ };
1715
+ const adapter: any = {
1716
+ sendEvent: jest.fn().mockResolvedValue(undefined),
1717
+ pendingRequests: new Map(),
1718
+ };
1719
+ adapter.aiFeature = {id: 'summary-flags', generatedSummaries: firstFlags};
1720
+ const featureEnablement = {interactionId: 'interaction-1', postCallEnabled: true};
1721
+ const coordinator = adapter;
1722
+ let resolveResult: (payload: any) => void = () => undefined;
1723
+ adapter.requestAndWaitForRtd = jest.fn(async (requestOptions: any) => {
1724
+ const result = new Promise((resolve) => {
1725
+ resolveResult = resolve;
1726
+ });
1727
+ await adapter.sendEvent(
1728
+ requestOptions.agentId,
1729
+ requestOptions.interactionId,
1730
+ requestOptions.eventType,
1731
+ requestOptions.eventName,
1732
+ requestOptions.eventMetaData,
1733
+ undefined,
1734
+ undefined,
1735
+ Date.now(),
1736
+ requestOptions.timeout
1737
+ );
1738
+ return result;
1739
+ });
1740
+ coordinator.resolveFromRtdEvent = jest.fn(() => {
1741
+ resolveResult(createPostCallSummaryPayload());
1742
+ return 'resolved';
1743
+ });
1744
+ const task = new DummyTask(
1745
+ dummyContact,
1746
+ createAISummaryTaskData(),
1747
+ undefined,
1748
+ 'agent-1',
1749
+ 'Receiving Agent',
1750
+ adapter
1751
+ );
1752
+ task.setFeatureEnablement({interactionId: task.data.interactionId, ...featureEnablement});
1753
+
1754
+ await expect(task.requestPostCallSummary()).rejects.toMatchObject(
1755
+ createAISummaryErrorExpectation(AI_SUMMARY_ERROR_CODES.POST_CALL_SUMMARY_DISABLED)
1756
+ );
1757
+ expect(adapter.sendEvent).not.toHaveBeenCalled();
1758
+
1759
+ adapter.aiFeature = {id: 'summary-flags', generatedSummaries: secondFlags};
1760
+ const request = task.requestPostCallSummary();
1761
+
1762
+ await Promise.resolve();
1763
+ expect(adapter.sendEvent).toHaveBeenCalledTimes(1);
1764
+ expect(
1765
+ coordinator.resolveFromRtdEvent(
1766
+ 'POST_CALL_SUMMARY',
1767
+ 'conversation-1',
1768
+ createPostCallSummaryPayload()
1769
+ )
1770
+ ).toBe('resolved');
1771
+ await expect(request).resolves.toEqual(createPostCallSummaryPayload());
1772
+ });
1773
+
1774
+ it('cleans up only the accepted request token when the request acknowledgement rejects', async () => {
1775
+ const task = new DummyTask(dummyContact, createAISummaryTaskData());
1776
+ const neverSettlingResult = createDeferred<any>();
1777
+ const {adapter, coordinator} = createSummaryMocks(task, {
1778
+ registrationResult: neverSettlingResult.promise,
1779
+ });
1780
+ const baseUrlError = createAISummaryError(
1781
+ AI_SUMMARY_ERROR_CODES.AI_ASSISTANT_BASE_URL_NOT_AVAILABLE
1782
+ );
1783
+
1784
+ adapter.sendEvent.mockRejectedValue(baseUrlError);
1785
+
1786
+ await expect(task.requestPostCallSummary()).rejects.toBe(baseUrlError);
1787
+ expect(coordinator.requestAndWaitForRtd).toHaveBeenCalled();
1788
+ });
1789
+
1790
+ it.each(summaryRequestCases.map((testCase) => [testCase.label, testCase] as const))(
1791
+ '%s request sends one outbound adapter invocation when it later fails from inbound timeout',
1792
+ async (_label, {invoke, eventName, timeoutCode}) => {
1793
+ const task = new DummyTask(dummyContact, createAISummaryTaskData());
1794
+ const timeoutResult = createDeferred<any>();
1795
+ const {adapter} = createSummaryMocks(task, {registrationResult: timeoutResult.promise});
1796
+
1797
+ const timedOutRequest = invoke(task);
1798
+ await flushEventLoopTurn();
1799
+
1800
+ expect(adapter.sendEvent).toHaveBeenCalledTimes(1);
1801
+ expectSummaryGetEvent(adapter, eventName);
1802
+
1803
+ timeoutResult.reject(createAISummaryError(timeoutCode));
1804
+
1805
+ await expect(timedOutRequest).rejects.toMatchObject({
1806
+ message: timeoutCode,
1807
+ data: {errorCode: timeoutCode},
1808
+ });
1809
+ }
1810
+ );
1811
+
1812
+ it.each(summaryRequestCases.map((testCase) => [testCase.label, testCase] as const))(
1813
+ 'issues a fresh second adapter call when %s request is invoked again after settlement',
1814
+ async (_label, {invoke, eventName, createPayload}) => {
1815
+ const task = new DummyTask(dummyContact, createAISummaryTaskData());
1816
+ const firstPayload = createPayload();
1817
+ const secondPayload = createPayload();
1818
+ const {adapter, coordinator} = createSummaryMocks(task);
1819
+
1820
+ expect(firstPayload).not.toBe(secondPayload);
1821
+ coordinator.requestAndWaitForRtd
1822
+ .mockImplementationOnce(async (requestOptions: any) => {
1823
+ const publishTimestamp = Date.now();
1824
+ await adapter.sendEvent(
1825
+ requestOptions.agentId,
1826
+ requestOptions.interactionId,
1827
+ requestOptions.eventType,
1828
+ requestOptions.eventName,
1829
+ requestOptions.eventMetaData,
1830
+ undefined,
1831
+ undefined,
1832
+ publishTimestamp,
1833
+ requestOptions.timeout
1834
+ );
1835
+
1836
+ return firstPayload;
1837
+ })
1838
+ .mockImplementationOnce(async (requestOptions: any) => {
1839
+ const publishTimestamp = Date.now();
1840
+ await adapter.sendEvent(
1841
+ requestOptions.agentId,
1842
+ requestOptions.interactionId,
1843
+ requestOptions.eventType,
1844
+ requestOptions.eventName,
1845
+ requestOptions.eventMetaData,
1846
+ undefined,
1847
+ undefined,
1848
+ publishTimestamp,
1849
+ requestOptions.timeout
1850
+ );
1851
+
1852
+ return secondPayload;
1853
+ });
1854
+
1855
+ await expect(invoke(task)).resolves.toBe(firstPayload);
1856
+ await expect(invoke(task)).resolves.toBe(secondPayload);
1857
+
1858
+ expect(coordinator.requestAndWaitForRtd).toHaveBeenCalledTimes(2);
1859
+ expect(adapter.sendEvent).toHaveBeenCalledTimes(2);
1860
+ expectSummaryGetEvent(adapter, eventName, 1);
1861
+ expectSummaryGetEvent(adapter, eventName, 2);
1862
+ }
1863
+ );
1864
+
1865
+ it('settles public request promises when coordinator rejects owner cleanup results', async () => {
1866
+ const cleanupTask = new DummyTask(dummyContact, createAISummaryTaskData());
1867
+ const cleanupResult = createDeferred<any>();
1868
+
1869
+ createSummaryMocks(cleanupTask, {registrationResult: cleanupResult.promise});
1870
+
1871
+ const cancelledRequest = cleanupTask.requestMidCallSummary('CONSULT');
1872
+ await flushEventLoopTurn();
1873
+ cleanupResult.reject(createAISummaryError(AI_SUMMARY_REQUEST_CANCELLED));
1874
+
1875
+ await expect(cancelledRequest).rejects.toMatchObject({
1876
+ message: AI_SUMMARY_REQUEST_CANCELLED,
1877
+ data: {errorCode: AI_SUMMARY_REQUEST_CANCELLED},
1878
+ });
1879
+ });
1880
+
1881
+ it('serializes post-call responses after request cleanup', async () => {
1882
+ const task = new DummyTask(dummyContact, createAISummaryTaskData());
1883
+ const taskRegistry: Record<string, DummyTask> = {'interaction-1': task};
1884
+ const metrics = spyOnAISummaryMetrics(task);
1885
+ const postCallResult = createPostCallSummaryPayload();
1886
+ const {adapter, coordinator, result} = createRealSummaryMocks(task);
1887
+
1888
+ const postCallRequest = task.requestPostCallSummary();
1889
+ await flushEventLoopTurn();
1890
+
1891
+ expectSummaryGetEvent(adapter, AIAssistantEventName.GET_POST_CALL_SUMMARY);
1892
+ result.resolve(postCallResult);
1893
+ await expect(postCallRequest).resolves.toBe(postCallResult);
1894
+
1895
+ delete taskRegistry['interaction-1'];
1896
+ coordinator.clear('task-owner-1', 'conversation-1');
1897
+ const responsePayload = createPostCallResponsePayload({
1898
+ summary: {humanAuthoredSectionKeySentinel: 'human-authored-section-value-sentinel'} as any,
1899
+ });
1900
+
1901
+ await expect(task.sendPostCallSummaryResponse(responsePayload)).resolves.toBeUndefined();
1902
+
1903
+ expect(taskRegistry['interaction-1']).toBeUndefined();
1904
+ expect(adapter.sendEvent).toHaveBeenCalledTimes(2);
1905
+ expect(getSummaryEventPayload(adapter, 1)).toStrictEqual({
1906
+ agentId: 'agent-1',
1907
+ interactionId: 'interaction-1',
1908
+ conversationId: 'conversation-1',
1909
+ eventName: AIAssistantEventName.POST_CALL_SUMMARY_RESPONSE,
1910
+ summary: {humanAuthoredSectionKeySentinel: 'human-authored-section-value-sentinel'},
1911
+ feedback: 'thumbs_up',
1912
+ wrapUpCode: 'resolved',
1913
+ publishTimestamp: expect.any(Number),
1914
+ numberOfTimesViewed: 1,
1915
+ numberOfTimesEdited: 0,
1916
+ numberOfTimesCopied: 0,
1917
+ state: 'DEFAULT',
1918
+ });
1919
+ expect(metrics.trackEvent).toHaveBeenCalledWith(
1920
+ METRIC_EVENT_NAMES.AI_SUMMARY_POST_CALL_RESPONSE_SUCCESS,
1921
+ expect.objectContaining({
1922
+ operation: METHODS.SEND_POST_CALL_SUMMARY_RESPONSE,
1923
+ conversationId: 'conversation-1',
1924
+ interactionId: 'interaction-1',
1925
+ }),
1926
+ ['operational']
1927
+ );
1928
+ expect(JSON.stringify(metrics.trackEvent.mock.calls)).not.toContain(
1929
+ 'humanAuthoredSectionKeySentinel'
1930
+ );
1931
+ expect(JSON.stringify(metrics.trackEvent.mock.calls)).not.toContain(
1932
+ 'human-authored-section-value-sentinel'
1933
+ );
1934
+ expect(metrics.timeEvent).toHaveBeenCalledWith([
1935
+ METRIC_EVENT_NAMES.AI_SUMMARY_POST_CALL_RESPONSE_SUCCESS,
1936
+ METRIC_EVENT_NAMES.AI_SUMMARY_POST_CALL_RESPONSE_FAILED,
1937
+ ]);
1938
+ });
1939
+
1940
+ it('propagates a post-call response telemetry failure', async () => {
1941
+ const task = new DummyTask(dummyContact, createAISummaryTaskData());
1942
+ const metrics = spyOnAISummaryMetrics(task);
1943
+ const {adapter} = createSummaryMocks(task);
1944
+
1945
+ metrics.trackEvent.mockImplementationOnce(() => {
1946
+ throw new Error('metrics unavailable');
1947
+ });
1948
+
1949
+ await expect(task.sendPostCallSummaryResponse(createPostCallResponsePayload())).rejects.toThrow(
1950
+ 'metrics unavailable'
1951
+ );
1952
+ expect(adapter.sendEvent).toHaveBeenCalledTimes(1);
1953
+ });
1954
+
1955
+ it('uses task IDs for direct post-call responses', async () => {
1956
+ const task = new DummyTask(dummyContact, createAISummaryTaskData());
1957
+ const {adapter} = createSummaryMocks(task);
1958
+
1959
+ await expect(
1960
+ task.sendPostCallSummaryResponse(createPostCallResponsePayload())
1961
+ ).resolves.toBeUndefined();
1962
+
1963
+ expect(getSummaryEventPayload(adapter)).toMatchObject({
1964
+ interactionId: 'interaction-1',
1965
+ conversationId: 'conversation-1',
1966
+ eventName: AIAssistantEventName.POST_CALL_SUMMARY_RESPONSE,
1967
+ });
1968
+ });
1969
+
1970
+ it('serializes mid-call responses after a summary request', async () => {
1971
+ const task = new DummyTask(dummyContact, createAISummaryTaskData());
1972
+ const {adapter, coordinator} = createRealSummaryMocks(task);
1973
+ const midCallRequest = task.requestMidCallSummary('CONSULT');
1974
+
1975
+ await flushEventLoopTurn();
1976
+ expect(
1977
+ coordinator.resolveFromRtdEvent(
1978
+ 'MID_CALL_SUMMARY',
1979
+ 'conversation-1',
1980
+ createMidCallSummaryPayload()
1981
+ )
1982
+ ).toBe('resolved');
1983
+ await expect(midCallRequest).resolves.toEqual(createMidCallSummaryPayload());
1984
+
1985
+ await expect(
1986
+ task.sendMidCallSummaryResponse(createMidCallResponsePayload(), 'CONSULT')
1987
+ ).resolves.toBeUndefined();
1988
+
1989
+ expect(getSummaryEventPayload(adapter, 1)).toMatchObject({
1990
+ interactionId: 'interaction-1',
1991
+ conversationId: 'conversation-1',
1992
+ eventName: AIAssistantEventName.MID_CALL_CONSULT_SUMMARY_RESPONSE,
1993
+ });
1994
+ });
1995
+
1996
+ it('uses the same task IDs for mid-call and post-call responses', async () => {
1997
+ const task = new DummyTask(dummyContact, createAISummaryTaskData());
1998
+ const {adapter} = createSummaryMocks(task);
1999
+
2000
+ await expect(task.requestMidCallSummary('CONSULT')).resolves.toBeDefined();
2001
+
2002
+ await expect(task.requestPostCallSummary()).resolves.toBeDefined();
2003
+
2004
+ await expect(
2005
+ task.sendMidCallSummaryResponse(createMidCallResponsePayload(), 'CONSULT')
2006
+ ).resolves.toBeUndefined();
2007
+ await expect(
2008
+ task.sendPostCallSummaryResponse(createPostCallResponsePayload())
2009
+ ).resolves.toBeUndefined();
2010
+
2011
+ expect(getSummaryEventPayload(adapter, 2)).toMatchObject({
2012
+ interactionId: 'interaction-1',
2013
+ conversationId: 'conversation-1',
2014
+ eventName: AIAssistantEventName.MID_CALL_CONSULT_SUMMARY_RESPONSE,
2015
+ });
2016
+ expect(getSummaryEventPayload(adapter, 3)).toMatchObject({
2017
+ interactionId: 'interaction-1',
2018
+ conversationId: 'conversation-1',
2019
+ eventName: AIAssistantEventName.POST_CALL_SUMMARY_RESPONSE,
2020
+ });
2021
+ });
2022
+
2023
+ it('sends a post-call response after a summary request fails', async () => {
2024
+ const task = new DummyTask(dummyContact, createAISummaryTaskData());
2025
+ const {adapter} = createSummaryMocks(task);
2026
+ const requestError = createAISummaryError(
2027
+ AI_SUMMARY_ERROR_CODES.AI_ASSISTANT_BASE_URL_NOT_AVAILABLE
2028
+ );
2029
+
2030
+ adapter.sendEvent.mockRejectedValueOnce(requestError);
2031
+
2032
+ await expect(task.requestPostCallSummary()).rejects.toBe(requestError);
2033
+
2034
+ await expect(
2035
+ task.sendPostCallSummaryResponse(createPostCallResponsePayload())
2036
+ ).resolves.toBeUndefined();
2037
+ expect(getSummaryEventPayload(adapter, 1)).toMatchObject({
2038
+ interactionId: 'interaction-1',
2039
+ conversationId: 'conversation-1',
2040
+ });
2041
+ });
2042
+
2043
+ it.each([
2044
+ {
2045
+ label: 'post-call',
2046
+ invoke: (task: DummyTask) =>
2047
+ task.sendPostCallSummaryResponse(
2048
+ createPostCallResponsePayload({
2049
+ summary: 'Caller reported a billing discrepancy.',
2050
+ numberOfTimesViewed: 2,
2051
+ numberOfTimesEdited: 1,
2052
+ numberOfTimesCopied: 3,
2053
+ })
2054
+ ),
2055
+ expected: {
2056
+ agentId: 'agent-1',
2057
+ interactionId: 'interaction-1',
2058
+ conversationId: 'conversation-1',
2059
+ eventName: AIAssistantEventName.POST_CALL_SUMMARY_RESPONSE,
2060
+ summary: 'Caller reported a billing discrepancy.',
2061
+ feedback: 'thumbs_up',
2062
+ wrapUpCode: 'resolved',
2063
+ publishTimestamp: expect.any(Number),
2064
+ numberOfTimesViewed: 2,
2065
+ numberOfTimesEdited: 1,
2066
+ numberOfTimesCopied: 3,
2067
+ state: 'DEFAULT',
2068
+ },
2069
+ },
2070
+ {
2071
+ label: 'mid-call',
2072
+ invoke: (task: DummyTask) =>
2073
+ task.sendMidCallSummaryResponse(
2074
+ createMidCallResponsePayload({
2075
+ summary: 'Caller reported a billing discrepancy.',
2076
+ numberOfTimesViewed: 2,
2077
+ numberOfTimesEdited: 1,
2078
+ numberOfTimesCopied: 3,
2079
+ }),
2080
+ 'TRANSFER'
2081
+ ),
2082
+ expected: {
2083
+ agentId: 'agent-1',
2084
+ interactionId: 'interaction-1',
2085
+ conversationId: 'conversation-1',
2086
+ eventName: AIAssistantEventName.MID_CALL_TRANSFER_SUMMARY_RESPONSE,
2087
+ summary: 'Caller reported a billing discrepancy.',
2088
+ feedback: 'none',
2089
+ agentName: 'Receiving Agent',
2090
+ publishTimestamp: expect.any(Number),
2091
+ numberOfTimesViewed: 2,
2092
+ numberOfTimesEdited: 1,
2093
+ numberOfTimesCopied: 3,
2094
+ state: 'DEFAULT',
2095
+ },
2096
+ },
2097
+ ])('forwards $label plain-text response summaries unchanged', async ({invoke, expected}) => {
2098
+ const task = new DummyTask(dummyContact, createAISummaryTaskData());
2099
+ const {adapter} = createSummaryMocks(task);
2100
+
2101
+ await expect(invoke(task)).resolves.toBeUndefined();
2102
+
2103
+ expect(getSummaryEventPayload(adapter)).toStrictEqual(expected);
2104
+ });
2105
+
2106
+ it.each([
2107
+ {
2108
+ label: 'post-call',
2109
+ invoke: (task: DummyTask) =>
2110
+ task.sendPostCallSummaryResponse(createPostCallResponsePayload()),
2111
+ expected: {
2112
+ agentId: 'agent-1',
2113
+ interactionId: 'interaction-1',
2114
+ conversationId: 'conversation-1',
2115
+ eventName: AIAssistantEventName.POST_CALL_SUMMARY_RESPONSE,
2116
+ summary: {summarySectionKeySentinel: 'summary-section-value-sentinel'},
2117
+ feedback: 'thumbs_up',
2118
+ wrapUpCode: 'resolved',
2119
+ numberOfTimesViewed: 1,
2120
+ numberOfTimesEdited: 0,
2121
+ numberOfTimesCopied: 0,
2122
+ state: 'DEFAULT',
2123
+ },
2124
+ },
2125
+ {
2126
+ label: 'mid-call',
2127
+ invoke: (task: DummyTask) =>
2128
+ task.sendMidCallSummaryResponse(createMidCallResponsePayload(), 'CONSULT'),
2129
+ expected: {
2130
+ agentId: 'agent-1',
2131
+ interactionId: 'interaction-1',
2132
+ conversationId: 'conversation-1',
2133
+ eventName: AIAssistantEventName.MID_CALL_CONSULT_SUMMARY_RESPONSE,
2134
+ summary: {midCallSectionKeySentinel: 'mid-call-section-value-sentinel'},
2135
+ feedback: 'none',
2136
+ agentName: 'Receiving Agent',
2137
+ numberOfTimesViewed: 1,
2138
+ numberOfTimesEdited: 0,
2139
+ numberOfTimesCopied: 0,
2140
+ state: 'DEFAULT',
2141
+ },
2142
+ },
2143
+ ])('generates the $label transport timestamp inside the SDK', async ({invoke, expected}) => {
2144
+ const task = new DummyTask(dummyContact, createAISummaryTaskData());
2145
+ const {adapter} = createSummaryMocks(task);
2146
+
2147
+ await expect(invoke(task)).resolves.toBeUndefined();
2148
+
2149
+ const transportPayload = getSummaryEventPayload(adapter);
2150
+
2151
+ expect(getSummaryEventPayload(adapter)).toMatchObject({
2152
+ ...expected,
2153
+ publishTimestamp: expect.any(Number),
2154
+ });
2155
+ expect(Object.prototype.hasOwnProperty.call(transportPayload, 'actionTimeStamp')).toBe(false);
2156
+ expect(Object.prototype.hasOwnProperty.call(transportPayload, 'publishTimestamp')).toBe(true);
2157
+ });
2158
+
2159
+ it('sends the exact post-call NOT_RECEIVED response transport payload', async () => {
2160
+ const task = new DummyTask(dummyContact, createAISummaryTaskData());
2161
+ const {adapter} = createSummaryMocks(task);
2162
+
2163
+ await expect(
2164
+ task.sendPostCallSummaryResponse(
2165
+ createPostCallResponsePayload({
2166
+ summary: '',
2167
+ feedback: 'none',
2168
+ state: 'NOT_RECEIVED',
2169
+ wrapUpCode: 'resolved',
2170
+ numberOfTimesViewed: 0,
2171
+ numberOfTimesEdited: 0,
2172
+ numberOfTimesCopied: 0,
2173
+ })
2174
+ )
2175
+ ).resolves.toBeUndefined();
2176
+
2177
+ expect(getSummaryEventPayload(adapter)).toStrictEqual({
2178
+ agentId: 'agent-1',
2179
+ interactionId: 'interaction-1',
2180
+ conversationId: 'conversation-1',
2181
+ eventName: AIAssistantEventName.POST_CALL_SUMMARY_RESPONSE,
2182
+ summary: '',
2183
+ feedback: 'none',
2184
+ wrapUpCode: 'resolved',
2185
+ publishTimestamp: expect.any(Number),
2186
+ numberOfTimesViewed: 0,
2187
+ numberOfTimesEdited: 0,
2188
+ numberOfTimesCopied: 0,
2189
+ state: 'NOT_RECEIVED',
2190
+ });
2191
+ });
2192
+
2193
+ it('serializes mid-call response branches without transport-only invalid fields', async () => {
2194
+ const consultTask = new DummyTask(dummyContact, createAISummaryTaskData());
2195
+ const consultMetrics = spyOnAISummaryMetrics(consultTask);
2196
+ const consultMocks = createSummaryMocks(consultTask);
2197
+
2198
+ await expect(
2199
+ consultTask.sendMidCallSummaryResponse(
2200
+ createMidCallResponsePayload({
2201
+ state: 'MID_CALL_CANCELLED',
2202
+ numberOfTimesViewed: 0,
2203
+ numberOfTimesEdited: 0,
2204
+ numberOfTimesCopied: 0,
2205
+ }),
2206
+ 'CONSULT'
2207
+ )
2208
+ ).resolves.toBeUndefined();
2209
+
2210
+ const consultPayload = getSummaryEventPayload(consultMocks.adapter);
2211
+
2212
+ expect(consultPayload).toMatchObject({
2213
+ eventName: AIAssistantEventName.MID_CALL_CONSULT_SUMMARY_RESPONSE,
2214
+ interactionId: 'interaction-1',
2215
+ conversationId: 'conversation-1',
2216
+ agentName: 'Receiving Agent',
2217
+ numberOfTimesViewed: 0,
2218
+ publishTimestamp: expect.any(Number),
2219
+ });
2220
+ expect(consultPayload).not.toHaveProperty('summaryReceived');
2221
+ expect(consultPayload).not.toHaveProperty('wrapUpCode');
2222
+ expect(consultMetrics.trackEvent).toHaveBeenCalledWith(
2223
+ METRIC_EVENT_NAMES.AI_SUMMARY_MID_CALL_RESPONSE_SUCCESS,
2224
+ expect.objectContaining({
2225
+ operation: METHODS.SEND_MID_CALL_SUMMARY_RESPONSE,
2226
+ actionType: 'CONSULT',
2227
+ conversationId: 'conversation-1',
2228
+ interactionId: 'interaction-1',
2229
+ }),
2230
+ ['operational']
2231
+ );
2232
+
2233
+ const transferTask = new DummyTask(dummyContact, createAISummaryTaskData());
2234
+ const transferMocks = createSummaryMocks(transferTask);
2235
+
2236
+ await expect(
2237
+ transferTask.sendMidCallSummaryResponse(
2238
+ createMidCallResponsePayload({
2239
+ summaryReceived: false,
2240
+ summary: '',
2241
+ feedback: 'none',
2242
+ state: 'NOT_RECEIVED',
2243
+ numberOfTimesViewed: 0,
2244
+ numberOfTimesEdited: 0,
2245
+ numberOfTimesCopied: 0,
2246
+ }),
2247
+ 'TRANSFER'
2248
+ )
2249
+ ).resolves.toBeUndefined();
2250
+
2251
+ expect(getSummaryEventPayload(transferMocks.adapter)).toMatchObject({
2252
+ eventName: AIAssistantEventName.MID_CALL_TRANSFER_SUMMARY_RESPONSE,
2253
+ interactionId: 'interaction-1',
2254
+ conversationId: 'conversation-1',
2255
+ summary: '',
2256
+ numberOfTimesViewed: 0,
2257
+ });
2258
+ });
2259
+
2260
+ it.each([
2261
+ {
2262
+ label: 'post-call received',
2263
+ invoke: (task: DummyTask) =>
2264
+ task.sendPostCallSummaryResponse(createPostCallResponsePayload()),
2265
+ eventName: AIAssistantEventName.POST_CALL_SUMMARY_RESPONSE,
2266
+ expected: {wrapUpCode: 'resolved', state: 'DEFAULT'},
2267
+ absent: ['agentName', 'summaryReceived'] as const,
2268
+ },
2269
+ {
2270
+ label: 'post-call NOT_RECEIVED',
2271
+ invoke: (task: DummyTask) =>
2272
+ task.sendPostCallSummaryResponse(
2273
+ createPostCallResponsePayload({
2274
+ summary: '',
2275
+ state: 'NOT_RECEIVED',
2276
+ numberOfTimesViewed: 0,
2277
+ numberOfTimesEdited: 0,
2278
+ numberOfTimesCopied: 0,
2279
+ })
2280
+ ),
2281
+ eventName: AIAssistantEventName.POST_CALL_SUMMARY_RESPONSE,
2282
+ expected: {wrapUpCode: 'resolved', summary: '', state: 'NOT_RECEIVED'},
2283
+ absent: ['agentName', 'summaryReceived'] as const,
2284
+ },
2285
+ {
2286
+ label: 'mid-call received',
2287
+ invoke: (task: DummyTask) =>
2288
+ task.sendMidCallSummaryResponse(createMidCallResponsePayload(), 'CONSULT'),
2289
+ eventName: AIAssistantEventName.MID_CALL_CONSULT_SUMMARY_RESPONSE,
2290
+ expected: {agentName: 'Receiving Agent', state: 'DEFAULT'},
2291
+ absent: ['wrapUpCode', 'summaryReceived'] as const,
2292
+ },
2293
+ {
2294
+ label: 'mid-call summaryReceived:false',
2295
+ invoke: (task: DummyTask) =>
2296
+ task.sendMidCallSummaryResponse(
2297
+ createMidCallResponsePayload({
2298
+ summaryReceived: false,
2299
+ summary: '',
2300
+ state: 'NOT_RECEIVED',
2301
+ numberOfTimesViewed: 0,
2302
+ numberOfTimesEdited: 0,
2303
+ numberOfTimesCopied: 0,
2304
+ }),
2305
+ 'TRANSFER'
2306
+ ),
2307
+ eventName: AIAssistantEventName.MID_CALL_TRANSFER_SUMMARY_RESPONSE,
2308
+ expected: {agentName: 'Receiving Agent', summary: '', state: 'NOT_RECEIVED'},
2309
+ absent: ['wrapUpCode', 'summaryReceived'] as const,
2310
+ },
2311
+ ])(
2312
+ 'populates both identifiers on $label response transport',
2313
+ async ({invoke, eventName, expected, absent}) => {
2314
+ const task = new DummyTask(
2315
+ dummyContact,
2316
+ createAISummaryTaskData({
2317
+ interactionId: 'response-interaction-1',
2318
+ interaction: {mainInteractionId: 'response-conversation-1'} as any,
2319
+ })
2320
+ );
2321
+ const {adapter} = createSummaryMocks(task);
2322
+
2323
+ await expect(invoke(task)).resolves.toBeUndefined();
2324
+
2325
+ expect(adapter.sendEvent).toHaveBeenCalledTimes(1);
2326
+ const transportPayload = getSummaryEventPayload(adapter);
2327
+
2328
+ expect(transportPayload.agentId).toBe('agent-1');
2329
+ expect(transportPayload).toMatchObject({
2330
+ agentId: 'agent-1',
2331
+ interactionId: 'response-interaction-1',
2332
+ conversationId: 'response-conversation-1',
2333
+ eventName,
2334
+ ...expected,
2335
+ });
2336
+ absent.forEach((propertyName) => {
2337
+ expect(transportPayload).not.toHaveProperty(propertyName);
2338
+ });
2339
+ }
2340
+ );
2341
+
2342
+ it.each(['CONSULT', 'TRANSFER'] as const)(
2343
+ 'sends a successful summary-not-received MID_CALL_CANCELLED %s response',
2344
+ async (actionType) => {
2345
+ const task = new DummyTask(dummyContact, createAISummaryTaskData());
2346
+ const {adapter} = createSummaryMocks(task);
2347
+
2348
+ await expect(
2349
+ task.sendMidCallSummaryResponse(
2350
+ createMidCallResponsePayload({
2351
+ summaryReceived: false,
2352
+ summary: '',
2353
+ feedback: 'none',
2354
+ state: 'MID_CALL_CANCELLED',
2355
+ numberOfTimesViewed: 0,
2356
+ numberOfTimesEdited: 0,
2357
+ numberOfTimesCopied: 0,
2358
+ }),
2359
+ actionType
2360
+ )
2361
+ ).resolves.toBeUndefined();
2362
+
2363
+ expect(getSummaryEventPayload(adapter)).toMatchObject({
2364
+ eventName:
2365
+ actionType === 'CONSULT'
2366
+ ? AIAssistantEventName.MID_CALL_CONSULT_SUMMARY_RESPONSE
2367
+ : AIAssistantEventName.MID_CALL_TRANSFER_SUMMARY_RESPONSE,
2368
+ summary: '',
2369
+ state: 'MID_CALL_CANCELLED',
2370
+ numberOfTimesViewed: 0,
2371
+ numberOfTimesEdited: 0,
2372
+ numberOfTimesCopied: 0,
2373
+ });
2374
+ }
2375
+ );
2376
+
2377
+ it('preserves a received MID_CALL_CANCELLED summary and viewed counter', async () => {
2378
+ const task = new DummyTask(dummyContact, createAISummaryTaskData());
2379
+ const {adapter} = createSummaryMocks(task);
2380
+ const summary = {
2381
+ reasonForTransferOrConsult: 'Customer requested a specialist',
2382
+ issueResolution: 'Warm transfer completed',
2383
+ };
2384
+
2385
+ await expect(
2386
+ task.sendMidCallSummaryResponse(
2387
+ createMidCallResponsePayload({
2388
+ summaryReceived: true,
2389
+ summary,
2390
+ state: 'MID_CALL_CANCELLED',
2391
+ numberOfTimesViewed: 1,
2392
+ numberOfTimesEdited: 0,
2393
+ numberOfTimesCopied: 0,
2394
+ }),
2395
+ 'CONSULT'
2396
+ )
2397
+ ).resolves.toBeUndefined();
2398
+
2399
+ expect(getSummaryEventPayload(adapter)).toMatchObject({
2400
+ summary,
2401
+ state: 'MID_CALL_CANCELLED',
2402
+ numberOfTimesViewed: 1,
2403
+ });
2404
+ });
2405
+
2406
+ it.each([
2407
+ {
2408
+ actionType: 'CONSULT' as AISummaryAction,
2409
+ eventName: AIAssistantEventName.MID_CALL_CONSULT_SUMMARY_RESPONSE,
2410
+ },
2411
+ {
2412
+ actionType: 'TRANSFER' as AISummaryAction,
2413
+ eventName: AIAssistantEventName.MID_CALL_TRANSFER_SUMMARY_RESPONSE,
2414
+ },
2415
+ ])(
2416
+ 'sends MID_CALL_CANCELLED $actionType responses without invoking a handoff',
2417
+ async ({actionType, eventName}) => {
2418
+ const task = new DummyTask(dummyContact, createAISummaryTaskData());
2419
+ const {adapter} = createSummaryMocks(task);
2420
+ const consultSpy = jest.spyOn(task, 'consult').mockResolvedValue({} as any);
2421
+ const transferSpy = jest.spyOn(task, 'transfer').mockResolvedValue({} as any);
2422
+ const sendResponseThenHandoff = async (
2423
+ payload: AISummaryResponse,
2424
+ handoffActionType: AISummaryAction
2425
+ ) => {
2426
+ await task.sendMidCallSummaryResponse(payload, handoffActionType);
2427
+
2428
+ if (payload.state === 'MID_CALL_CANCELLED') {
2429
+ return;
2430
+ }
2431
+
2432
+ if (handoffActionType === 'CONSULT') {
2433
+ await task.consult({} as any);
2434
+
2435
+ return;
2436
+ }
2437
+
2438
+ await task.transfer({} as any);
2439
+ };
2440
+
2441
+ await expect(
2442
+ sendResponseThenHandoff(
2443
+ createMidCallResponsePayload({
2444
+ state: 'MID_CALL_CANCELLED',
2445
+ numberOfTimesViewed: 0,
2446
+ numberOfTimesEdited: 0,
2447
+ numberOfTimesCopied: 0,
2448
+ }),
2449
+ actionType
2450
+ )
2451
+ ).resolves.toBeUndefined();
2452
+
2453
+ expect(getSummaryEventPayload(adapter)).toMatchObject({
2454
+ eventName,
2455
+ state: 'MID_CALL_CANCELLED',
2456
+ });
2457
+ expect(consultSpy).not.toHaveBeenCalled();
2458
+ expect(transferSpy).not.toHaveBeenCalled();
2459
+ }
2460
+ );
2461
+
2462
+ it.each([
2463
+ ['post-call IGNORED', 'post-call', 'IGNORED'],
2464
+ ['mid-call EXCLUDED', 'mid-call', 'EXCLUDED'],
2465
+ ['mid-call IGNORED', 'mid-call', 'IGNORED'],
2466
+ ] as const)('accepts %s response states', async (_label, flow, state) => {
2467
+ const task = new DummyTask(dummyContact, createAISummaryTaskData());
2468
+ const {adapter} = createSummaryMocks(task);
2469
+
2470
+ if (flow === 'post-call') {
2471
+ await expect(
2472
+ task.sendPostCallSummaryResponse(createPostCallResponsePayload({state: state as any}))
2473
+ ).resolves.toBeUndefined();
2474
+ } else {
2475
+ await expect(
2476
+ task.sendMidCallSummaryResponse(
2477
+ createMidCallResponsePayload({state: state as any}),
2478
+ 'CONSULT'
2479
+ )
2480
+ ).resolves.toBeUndefined();
2481
+ }
2482
+
2483
+ expect(getSummaryEventPayload(adapter)).toMatchObject({state});
2484
+ });
2485
+
2486
+ it('accepts mid-call IGNORED on the unavailable branch (summaryReceived:false)', async () => {
2487
+ const task = new DummyTask(dummyContact, createAISummaryTaskData());
2488
+ const {adapter} = createSummaryMocks(task);
2489
+
2490
+ await expect(
2491
+ task.sendMidCallSummaryResponse(
2492
+ {
2493
+ summaryReceived: false,
2494
+ summary: '',
2495
+ numberOfTimesViewed: 0,
2496
+ numberOfTimesEdited: 0,
2497
+ numberOfTimesCopied: 0,
2498
+ feedback: 'none',
2499
+ state: 'IGNORED',
2500
+ },
2501
+ 'CONSULT'
2502
+ )
2503
+ ).resolves.toBeUndefined();
2504
+
2505
+ expect(getSummaryEventPayload(adapter)).toMatchObject({state: 'IGNORED'});
2506
+ });
2507
+
2508
+ it.each(['none', 'thumbs_up', 'thumbs_down'] as const)(
2509
+ 'forwards valid %s feedback unchanged for both response flows',
2510
+ async (feedback) => {
2511
+ const postTask = new DummyTask(dummyContact, createAISummaryTaskData());
2512
+ const postMocks = createSummaryMocks(postTask);
2513
+ const midTask = new DummyTask(dummyContact, createAISummaryTaskData());
2514
+ const midMocks = createSummaryMocks(midTask);
2515
+
2516
+ await expect(
2517
+ postTask.sendPostCallSummaryResponse(createPostCallResponsePayload({feedback}))
2518
+ ).resolves.toBeUndefined();
2519
+ await expect(
2520
+ midTask.sendMidCallSummaryResponse(createMidCallResponsePayload({feedback}), 'CONSULT')
2521
+ ).resolves.toBeUndefined();
2522
+
2523
+ expect(getSummaryEventPayload(postMocks.adapter)).toMatchObject({feedback});
2524
+ expect(getSummaryEventPayload(midMocks.adapter)).toMatchObject({feedback});
2525
+ }
2526
+ );
2527
+
2528
+ it('forwards valid non-zero counters unchanged for both response flows', async () => {
2529
+ const counters = {
2530
+ numberOfTimesViewed: 2,
2531
+ numberOfTimesEdited: 1,
2532
+ numberOfTimesCopied: 3,
2533
+ };
2534
+ const postTask = new DummyTask(dummyContact, createAISummaryTaskData());
2535
+ const metrics = spyOnAISummaryMetrics(postTask);
2536
+ const postMocks = createSummaryMocks(postTask);
2537
+ const midTask = new DummyTask(dummyContact, createAISummaryTaskData());
2538
+ const midMocks = createSummaryMocks(midTask);
2539
+
2540
+ await expect(
2541
+ postTask.sendPostCallSummaryResponse(createPostCallResponsePayload(counters))
2542
+ ).resolves.toBeUndefined();
2543
+ await expect(
2544
+ midTask.sendMidCallSummaryResponse(createMidCallResponsePayload(counters), 'TRANSFER')
2545
+ ).resolves.toBeUndefined();
2546
+
2547
+ expect(getSummaryEventPayload(postMocks.adapter)).toMatchObject(counters);
2548
+ expect(getSummaryEventPayload(midMocks.adapter)).toMatchObject(counters);
2549
+ expect(metrics.trackEvent).toHaveBeenCalledWith(
2550
+ METRIC_EVENT_NAMES.AI_SUMMARY_POST_CALL_RESPONSE_SUCCESS,
2551
+ expect.objectContaining({operation: METHODS.SEND_POST_CALL_SUMMARY_RESPONSE}),
2552
+ ['operational']
2553
+ );
2554
+ expect(metrics.trackEvent).toHaveBeenCalledWith(
2555
+ METRIC_EVENT_NAMES.AI_SUMMARY_MID_CALL_RESPONSE_SUCCESS,
2556
+ expect.objectContaining({
2557
+ operation: METHODS.SEND_MID_CALL_SUMMARY_RESPONSE,
2558
+ actionType: 'TRANSFER',
2559
+ }),
2560
+ ['operational']
2561
+ );
2562
+ });
2563
+
2564
+ it('maps plain adapter errors to a bounded failure code and preserves rejection identity', async () => {
2565
+ const task = new DummyTask(dummyContact, createAISummaryTaskData());
2566
+ const metrics = spyOnAISummaryMetrics(task);
2567
+ const {adapter} = createSummaryMocks(task);
2568
+ const adapterError = new Error('summary-section-value-sentinel');
2569
+
2570
+ adapter.sendEvent.mockRejectedValue(adapterError);
2571
+
2572
+ await expect(
2573
+ task.sendMidCallSummaryResponse(createMidCallResponsePayload(), 'CONSULT')
2574
+ ).rejects.toBe(adapterError);
2575
+
2576
+ expect(JSON.stringify(metrics.trackEvent.mock.calls)).not.toContain(
2577
+ 'summary-section-value-sentinel'
2578
+ );
2579
+ expect(metrics.trackEvent).toHaveBeenCalledWith(
2580
+ METRIC_EVENT_NAMES.AI_SUMMARY_MID_CALL_RESPONSE_FAILED,
2581
+ expect.objectContaining({
2582
+ operation: METHODS.SEND_MID_CALL_SUMMARY_RESPONSE,
2583
+ actionType: 'CONSULT',
2584
+ failureCode: 'AI_SUMMARY_INVALID_RESPONSE_PAYLOAD',
2585
+ }),
2586
+ ['operational']
2587
+ );
2588
+ });
2589
+
2590
+ it.each([
2591
+ {
2592
+ label: 'summary base URL unavailable',
2593
+ expectedCode: AI_SUMMARY_ERROR_CODES.AI_ASSISTANT_BASE_URL_NOT_AVAILABLE,
2594
+ configure: true,
2595
+ payload: createPostCallResponsePayload(),
2596
+ adapterError: createAISummaryError(
2597
+ AI_SUMMARY_ERROR_CODES.AI_ASSISTANT_BASE_URL_NOT_AVAILABLE
2598
+ ),
2599
+ expectAdapterCall: true,
2600
+ },
2601
+ ...[
2602
+ AI_SUMMARY_TRANSPORT_ERROR_CODES.VALIDATION_FAILED,
2603
+ AI_SUMMARY_TRANSPORT_ERROR_CODES.HTTP_REQUEST_FAILED,
2604
+ AI_SUMMARY_TRANSPORT_ERROR_CODES.TIMEOUT,
2605
+ ].map((expectedCode) => ({
2606
+ label: expectedCode,
2607
+ expectedCode,
2608
+ configure: true,
2609
+ payload: createPostCallResponsePayload(),
2610
+ adapterError: createAISummaryError(expectedCode),
2611
+ expectAdapterCall: true,
2612
+ })),
2613
+ ])(
2614
+ 'records bounded post-call response failure metric for $label',
2615
+ async ({expectedCode, configure, taskData, payload, adapterError, expectAdapterCall}) => {
2616
+ const responseTask = new DummyTask(dummyContact, taskData ?? createAISummaryTaskData());
2617
+ const responseMetrics = spyOnAISummaryMetrics(responseTask);
2618
+ const summaryMocks = configure ? createSummaryMocks(responseTask) : undefined;
2619
+
2620
+ if (adapterError) {
2621
+ summaryMocks?.adapter.sendEvent.mockRejectedValue(adapterError);
2622
+ }
2623
+
2624
+ const result = responseTask.sendPostCallSummaryResponse(payload);
2625
+
2626
+ if (adapterError) {
2627
+ await expect(result).rejects.toBe(adapterError);
2628
+ } else {
2629
+ await expect(result).rejects.toMatchObject(createAISummaryErrorExpectation(expectedCode));
2630
+ }
2631
+
2632
+ if (summaryMocks) {
2633
+ if (expectAdapterCall) {
2634
+ expect(summaryMocks.adapter.sendEvent).toHaveBeenCalledTimes(1);
2635
+ } else {
2636
+ expect(summaryMocks.adapter.sendEvent).not.toHaveBeenCalled();
2637
+ }
2638
+ }
2639
+ expect(responseMetrics.trackEvent).toHaveBeenCalledWith(
2640
+ METRIC_EVENT_NAMES.AI_SUMMARY_POST_CALL_RESPONSE_FAILED,
2641
+ expect.objectContaining({
2642
+ operation: METHODS.SEND_POST_CALL_SUMMARY_RESPONSE,
2643
+ failureCode: expectedCode,
2644
+ }),
2645
+ ['operational']
2646
+ );
2647
+ expect(responseMetrics.timeEvent).toHaveBeenCalled();
2648
+ }
2649
+ );
2650
+
2651
+ it.each([
2652
+ {
2653
+ label: 'summary base URL unavailable',
2654
+ expectedCode: AI_SUMMARY_ERROR_CODES.AI_ASSISTANT_BASE_URL_NOT_AVAILABLE,
2655
+ configure: true,
2656
+ adapterError: createAISummaryError(
2657
+ AI_SUMMARY_ERROR_CODES.AI_ASSISTANT_BASE_URL_NOT_AVAILABLE
2658
+ ),
2659
+ taskData: undefined,
2660
+ expectAdapterCall: true,
2661
+ includeActionType: true,
2662
+ },
2663
+ ...[
2664
+ AI_SUMMARY_TRANSPORT_ERROR_CODES.VALIDATION_FAILED,
2665
+ AI_SUMMARY_TRANSPORT_ERROR_CODES.HTTP_REQUEST_FAILED,
2666
+ AI_SUMMARY_TRANSPORT_ERROR_CODES.TIMEOUT,
2667
+ ].map((expectedCode) => ({
2668
+ label: expectedCode,
2669
+ expectedCode,
2670
+ configure: true,
2671
+ adapterError: createAISummaryError(expectedCode),
2672
+ taskData: undefined,
2673
+ expectAdapterCall: true,
2674
+ includeActionType: true,
2675
+ })),
2676
+ ])(
2677
+ 'records bounded mid-call response failure metric for $label',
2678
+ async ({
2679
+ expectedCode,
2680
+ configure,
2681
+ taskData,
2682
+ adapterError,
2683
+ expectAdapterCall,
2684
+ }) => {
2685
+ const responseTask = new DummyTask(dummyContact, taskData ?? createAISummaryTaskData());
2686
+ const responseMetrics = spyOnAISummaryMetrics(responseTask);
2687
+ const summaryMocks = configure ? createSummaryMocks(responseTask) : undefined;
2688
+
2689
+ if (adapterError) {
2690
+ summaryMocks?.adapter.sendEvent.mockRejectedValue(adapterError);
2691
+ }
2692
+
2693
+ const result = responseTask.sendMidCallSummaryResponse(
2694
+ createMidCallResponsePayload(),
2695
+ 'CONSULT'
2696
+ );
2697
+
2698
+ if (adapterError) {
2699
+ await expect(result).rejects.toBe(adapterError);
2700
+ } else {
2701
+ await expect(result).rejects.toMatchObject(createAISummaryErrorExpectation(expectedCode));
2702
+ }
2703
+
2704
+ if (summaryMocks) {
2705
+ if (expectAdapterCall) {
2706
+ expect(summaryMocks.adapter.sendEvent).toHaveBeenCalledTimes(1);
2707
+ } else {
2708
+ expect(summaryMocks.adapter.sendEvent).not.toHaveBeenCalled();
2709
+ }
2710
+ }
2711
+ expect(responseMetrics.trackEvent).toHaveBeenCalledWith(
2712
+ METRIC_EVENT_NAMES.AI_SUMMARY_MID_CALL_RESPONSE_FAILED,
2713
+ expect.objectContaining({
2714
+ operation: METHODS.SEND_MID_CALL_SUMMARY_RESPONSE,
2715
+ actionType: 'CONSULT',
2716
+ failureCode: expectedCode,
2717
+ }),
2718
+ ['operational']
2719
+ );
2720
+ expect(responseMetrics.timeEvent).toHaveBeenCalled();
2721
+ }
2722
+ );
2723
+ });