@webex/contact-center 3.12.0-llmrefactor.12 → 3.12.0-llmrefactor.14

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 (107) hide show
  1. package/.sdd/manifest.json +6 -5
  2. package/ai-docs/CONTRACTS.md +8 -0
  3. package/ai-docs/SERVICE_STATE.md +4 -0
  4. package/ai-docs/contact-center-spec.md +26 -3
  5. package/ai-docs/features/contact-owner-change-refactor.md +78 -0
  6. package/dist/cc.js +202 -9
  7. package/dist/cc.js.map +1 -1
  8. package/dist/constants.js +5 -1
  9. package/dist/constants.js.map +1 -1
  10. package/dist/index.js +25 -10
  11. package/dist/index.js.map +1 -1
  12. package/dist/metrics/behavioral-events.js +6 -0
  13. package/dist/metrics/behavioral-events.js.map +1 -1
  14. package/dist/metrics/constants.js +6 -0
  15. package/dist/metrics/constants.js.map +1 -1
  16. package/dist/services/AnswerCallOnWebexService.js +4 -4
  17. package/dist/services/AnswerCallOnWebexService.js.map +1 -1
  18. package/dist/services/ApiAiAssistant.js +116 -1
  19. package/dist/services/ApiAiAssistant.js.map +1 -1
  20. package/dist/services/WebexCrossClientService.js +5 -2
  21. package/dist/services/WebexCrossClientService.js.map +1 -1
  22. package/dist/services/WxAppTelephonyMercurySync.js +6 -3
  23. package/dist/services/WxAppTelephonyMercurySync.js.map +1 -1
  24. package/dist/services/config/Util.js +5 -1
  25. package/dist/services/config/Util.js.map +1 -1
  26. package/dist/services/config/constants.js +10 -1
  27. package/dist/services/config/constants.js.map +1 -1
  28. package/dist/services/config/index.js +51 -0
  29. package/dist/services/config/index.js.map +1 -1
  30. package/dist/services/config/types.js +4 -3
  31. package/dist/services/config/types.js.map +1 -1
  32. package/dist/services/core/Utils.js +5 -1
  33. package/dist/services/core/Utils.js.map +1 -1
  34. package/dist/services/task/TaskFactory.js +1 -0
  35. package/dist/services/task/TaskFactory.js.map +1 -1
  36. package/dist/services/task/TaskManager.js +50 -58
  37. package/dist/services/task/TaskManager.js.map +1 -1
  38. package/dist/services/task/types.js.map +1 -1
  39. package/dist/services/task/voice/Voice.js +63 -44
  40. package/dist/services/task/voice/Voice.js.map +1 -1
  41. package/dist/services/task/voice/wxAppOfferObservability.js +145 -0
  42. package/dist/services/task/voice/wxAppOfferObservability.js.map +1 -0
  43. package/dist/services/task/voice/wxAppVoiceMethods.js +52 -11
  44. package/dist/services/task/voice/wxAppVoiceMethods.js.map +1 -1
  45. package/dist/services/wxAppDiagnosticLogging.js +50 -13
  46. package/dist/services/wxAppDiagnosticLogging.js.map +1 -1
  47. package/dist/types/cc.d.ts +21 -1
  48. package/dist/types/constants.d.ts +4 -0
  49. package/dist/types/index.d.ts +11 -1
  50. package/dist/types/metrics/constants.d.ts +6 -0
  51. package/dist/types/services/ApiAiAssistant.d.ts +35 -2
  52. package/dist/types/services/config/constants.d.ts +5 -0
  53. package/dist/types/services/config/types.d.ts +15 -2
  54. package/dist/types/services/core/Utils.d.ts +2 -0
  55. package/dist/types/services/task/types.d.ts +2 -0
  56. package/dist/types/services/task/voice/Voice.d.ts +15 -2
  57. package/dist/types/services/task/voice/wxAppOfferObservability.d.ts +39 -0
  58. package/dist/types/services/task/voice/wxAppVoiceMethods.d.ts +19 -6
  59. package/dist/types/services/wxAppDiagnosticLogging.d.ts +12 -0
  60. package/dist/types/types.d.ts +56 -1
  61. package/dist/types.js +39 -2
  62. package/dist/types.js.map +1 -1
  63. package/dist/webex.js +9 -1
  64. package/dist/webex.js.map +1 -1
  65. package/package.json +9 -9
  66. package/src/cc.ts +294 -25
  67. package/src/constants.ts +4 -0
  68. package/src/index.ts +11 -0
  69. package/src/metrics/ai-docs/metrics-spec.md +61 -20
  70. package/src/metrics/behavioral-events.ts +6 -0
  71. package/src/metrics/constants.ts +6 -0
  72. package/src/services/AnswerCallOnWebexService.ts +4 -4
  73. package/src/services/ApiAiAssistant.ts +153 -1
  74. package/src/services/WebexCrossClientService.ts +7 -2
  75. package/src/services/WxAppTelephonyMercurySync.ts +3 -3
  76. package/src/services/ai-docs/services-spec.md +11 -3
  77. package/src/services/config/Util.ts +6 -0
  78. package/src/services/config/ai-docs/config-spec.md +14 -3
  79. package/src/services/config/constants.ts +12 -0
  80. package/src/services/config/index.ts +65 -0
  81. package/src/services/config/types.ts +13 -2
  82. package/src/services/core/Utils.ts +4 -0
  83. package/src/services/task/TaskFactory.ts +1 -0
  84. package/src/services/task/TaskManager.ts +85 -87
  85. package/src/services/task/ai-docs/task-spec.md +7 -3
  86. package/src/services/task/types.ts +16 -0
  87. package/src/services/task/voice/Voice.ts +74 -52
  88. package/src/services/task/voice/wxAppOfferObservability.ts +204 -0
  89. package/src/services/task/voice/wxAppVoiceMethods.ts +83 -24
  90. package/src/services/wxAppDiagnosticLogging.ts +135 -23
  91. package/src/types.ts +65 -0
  92. package/src/webex.js +8 -0
  93. package/test/unit/spec/cc.ts +469 -19
  94. package/test/unit/spec/services/ApiAiAssistant.ts +134 -2
  95. package/test/unit/spec/services/WebexCrossClientService.ts +45 -3
  96. package/test/unit/spec/services/WxAppTelephonyMercurySync.ts +1 -1
  97. package/test/unit/spec/services/config/Util.ts +44 -0
  98. package/test/unit/spec/services/config/index.ts +68 -0
  99. package/test/unit/spec/services/task/TaskManager.ts +214 -3
  100. package/test/unit/spec/services/task/state-machine/TaskStateMachine.ts +33 -0
  101. package/test/unit/spec/services/task/voice/Voice.ts +10 -51
  102. package/test/unit/spec/services/task/voice/wxAppOfferObservability.ts +361 -0
  103. package/test/unit/spec/services/task/voice/wxAppVoiceMethods.ts +121 -5
  104. package/test/unit/spec/services/wxAppDiagnosticLogging.ts +44 -8
  105. package/test/unit/spec/wellnessSampleUtils.ts +288 -0
  106. package/umd/contact-center.min.js +2 -2
  107. package/umd/contact-center.min.js.map +1 -1
@@ -2,7 +2,12 @@ import ApiAIAssistant from '../../../../src/services/ApiAiAssistant';
2
2
  import MetricsManager from '../../../../src/metrics/MetricsManager';
3
3
  import LoggerProxy from '../../../../src/logger-proxy';
4
4
  import WebexRequest from '../../../../src/services/core/WebexRequest';
5
- import {HTTP_METHODS, RealTimeAssistanceUserActionId, WebexSDK} from '../../../../src/types';
5
+ import {
6
+ HTTP_METHODS,
7
+ RealTimeAssistanceUserActionId,
8
+ WELLNESS_BREAK_USER_ACTIONS,
9
+ WebexSDK,
10
+ } from '../../../../src/types';
6
11
 
7
12
  jest.mock('../../../../src/metrics/MetricsManager');
8
13
  jest.mock('../../../../src/logger-proxy');
@@ -11,6 +16,11 @@ describe('ApiAIAssistant', () => {
11
16
  let apiAIAssistant: ApiAIAssistant;
12
17
  let mockWebex: WebexSDK;
13
18
  let mockMetricsManager: jest.Mocked<MetricsManager>;
19
+ let wellnessContext: {
20
+ isWellnessBreakEnabled: boolean;
21
+ agentId?: string;
22
+ agentSessionId?: string;
23
+ };
14
24
 
15
25
  beforeEach(() => {
16
26
  jest.clearAllMocks();
@@ -43,7 +53,8 @@ describe('ApiAIAssistant', () => {
43
53
  } as unknown as jest.Mocked<MetricsManager>;
44
54
  (MetricsManager.getInstance as jest.Mock).mockReturnValue(mockMetricsManager);
45
55
 
46
- apiAIAssistant = new ApiAIAssistant(mockWebex);
56
+ wellnessContext = {isWellnessBreakEnabled: false};
57
+ apiAIAssistant = new ApiAIAssistant(mockWebex, () => wellnessContext);
47
58
  });
48
59
 
49
60
  it('should send transcript start event successfully', async () => {
@@ -270,4 +281,125 @@ describe('ApiAIAssistant', () => {
270
281
 
271
282
  expect(errorMessage).toBe('Error while performing sendRealTimeAssistanceUserAction');
272
283
  });
284
+
285
+ describe('Agent Wellness Break actions', () => {
286
+ beforeEach(() => {
287
+ wellnessContext = {
288
+ isWellnessBreakEnabled: true,
289
+ agentId: 'test-agent-id',
290
+ agentSessionId: 'session-1',
291
+ };
292
+ });
293
+
294
+ it('does not expose wellness context mutation on the public assistant instance', () => {
295
+ expect(apiAIAssistant).not.toHaveProperty('setWellnessContext');
296
+ });
297
+
298
+ it('sends the exact REQUESTED body and resolves only for HTTP 202', async () => {
299
+ jest.spyOn(Date, 'now').mockReturnValue(1787640000000);
300
+ (mockWebex.request as jest.Mock).mockResolvedValue({statusCode: 202});
301
+
302
+ await expect(apiAIAssistant.requestWellnessBreak()).resolves.toBeUndefined();
303
+
304
+ expect(mockWebex.request).toHaveBeenCalledWith({
305
+ uri: 'https://api-ai-assistant.produs1.ciscoccservice.com/event',
306
+ method: HTTP_METHODS.POST,
307
+ addAuthHeader: true,
308
+ body: {
309
+ agentId: 'test-agent-id',
310
+ orgId: 'test-org-id',
311
+ eventType: 'CUSTOM_EVENT',
312
+ eventName: 'WellnessBreakAction',
313
+ eventDetails: {
314
+ data: {
315
+ action: 'REQUESTED',
316
+ agentSessionId: 'session-1',
317
+ actionTimeStamp: 1787640000000,
318
+ },
319
+ },
320
+ },
321
+ });
322
+ });
323
+
324
+ it.each([
325
+ WELLNESS_BREAK_USER_ACTIONS.ACCEPTED,
326
+ WELLNESS_BREAK_USER_ACTIONS.REJECTED,
327
+ WELLNESS_BREAK_USER_ACTIONS.NO_RESPONSE,
328
+ ] as const)(
329
+ 'sends the %s offer action',
330
+ async (action) => {
331
+ (mockWebex.request as jest.Mock).mockResolvedValue({statusCode: 202});
332
+
333
+ await apiAIAssistant.respondToWellnessBreak({action});
334
+
335
+ expect(
336
+ (mockWebex.request as jest.Mock).mock.calls[0][0].body.eventDetails.data.action
337
+ ).toBe(action);
338
+ }
339
+ );
340
+
341
+ it('rejects disabled or incomplete internal session context before HTTP', async () => {
342
+ wellnessContext = {
343
+ isWellnessBreakEnabled: false,
344
+ agentId: 'test-agent-id',
345
+ agentSessionId: 'session-1',
346
+ };
347
+ await expect(apiAIAssistant.requestWellnessBreak()).rejects.toThrow(
348
+ 'WELLNESS_BREAK_NOT_ENABLED'
349
+ );
350
+
351
+ wellnessContext = {
352
+ isWellnessBreakEnabled: true,
353
+ };
354
+ await expect(apiAIAssistant.requestWellnessBreak()).rejects.toThrow(
355
+ 'WELLNESS_BREAK_AGENT_ID_REQUIRED'
356
+ );
357
+
358
+ wellnessContext = {
359
+ isWellnessBreakEnabled: true,
360
+ agentId: 'test-agent-id',
361
+ };
362
+ await expect(apiAIAssistant.requestWellnessBreak()).rejects.toThrow(
363
+ 'WELLNESS_BREAK_AGENT_SESSION_REQUIRED'
364
+ );
365
+ expect(mockWebex.request).not.toHaveBeenCalled();
366
+ });
367
+
368
+ it('rejects a missing organization before HTTP', async () => {
369
+ (mockWebex.credentials.getOrgId as jest.Mock).mockReturnValue(' ');
370
+
371
+ await expect(apiAIAssistant.requestWellnessBreak()).rejects.toThrow(
372
+ 'WELLNESS_BREAK_ORG_ID_REQUIRED'
373
+ );
374
+ expect(mockWebex.request).not.toHaveBeenCalled();
375
+ });
376
+
377
+ it.each([200, 204, 400, 401, 403, 404, 409, 429, 500])(
378
+ 'rejects the non-202 HTTP status %i',
379
+ async (statusCode) => {
380
+ (mockWebex.request as jest.Mock).mockResolvedValue({statusCode});
381
+
382
+ await expect(apiAIAssistant.requestWellnessBreak()).rejects.toThrow(
383
+ `WELLNESS_BREAK_ACTION_UNEXPECTED_STATUS_${statusCode}`
384
+ );
385
+ }
386
+ );
387
+
388
+ it('rejects network failures through the structured error path', async () => {
389
+ (mockWebex.request as jest.Mock).mockRejectedValue(new Error('Network unavailable'));
390
+
391
+ await expect(apiAIAssistant.requestWellnessBreak()).rejects.toThrow(
392
+ 'Error while performing requestWellnessBreak'
393
+ );
394
+ });
395
+
396
+ it('rejects unsupported offer actions before HTTP', async () => {
397
+ await expect(
398
+ apiAIAssistant.respondToWellnessBreak({
399
+ action: 'REQUESTED',
400
+ } as unknown as Parameters<ApiAIAssistant['respondToWellnessBreak']>[0])
401
+ ).rejects.toThrow('WELLNESS_BREAK_ACTION_INVALID');
402
+ expect(mockWebex.request).not.toHaveBeenCalled();
403
+ });
404
+ });
273
405
  });
@@ -160,11 +160,53 @@ describe('WebexCrossClientService', () => {
160
160
 
161
161
  expect(trackEvent).toHaveBeenCalledWith(
162
162
  METRIC_EVENT_NAMES.WXAPP_USERSUB_PUBLISH_FAILED,
163
- expect.objectContaining({enableWxBetterTogether: true}),
163
+ expect.objectContaining({enableWxBetterTogether: true, usersubPublished: false}),
164
164
  ['operational', 'behavioral']
165
165
  );
166
166
  });
167
167
 
168
+ it('reports retained usersub state when disable publish fails after successful enable', async () => {
169
+ await service.setManageWebexCallingInWxcc(true, {trackPublishMetrics: true});
170
+ trackEvent.mockClear();
171
+
172
+ webex.request = jest.fn().mockRejectedValue(new Error('disable failed'));
173
+
174
+ await expect(
175
+ service.setManageWebexCallingInWxcc(false, {trackPublishMetrics: true})
176
+ ).rejects.toThrow('disable failed');
177
+
178
+ expect(trackEvent).toHaveBeenCalledWith(
179
+ METRIC_EVENT_NAMES.WXAPP_USERSUB_PUBLISH_FAILED,
180
+ expect.objectContaining({
181
+ enableWxBetterTogether: false,
182
+ usersubPublished: true,
183
+ }),
184
+ ['operational', 'behavioral']
185
+ );
186
+ expect(service.isAnswerCallsStateActive()).toBe(true);
187
+ });
188
+
189
+ it('reports retained usersub state when re-enable publish fails after successful enable', async () => {
190
+ await service.setManageWebexCallingInWxcc(true);
191
+ trackEvent.mockClear();
192
+
193
+ webex.request = jest.fn().mockRejectedValue(new Error('refresh failed'));
194
+
195
+ await expect(
196
+ service.setManageWebexCallingInWxcc(true, {trackPublishMetrics: true})
197
+ ).rejects.toThrow('refresh failed');
198
+
199
+ expect(trackEvent).toHaveBeenCalledWith(
200
+ METRIC_EVENT_NAMES.WXAPP_USERSUB_PUBLISH_FAILED,
201
+ expect.objectContaining({
202
+ enableWxBetterTogether: true,
203
+ usersubPublished: true,
204
+ }),
205
+ ['operational', 'behavioral']
206
+ );
207
+ expect(service.isAnswerCallsStateActive()).toBe(true);
208
+ });
209
+
168
210
  it('tracks usersub publish success when trackPublishMetrics is enabled', async () => {
169
211
  await service.setManageWebexCallingInWxcc(true, {trackPublishMetrics: true});
170
212
 
@@ -174,7 +216,7 @@ describe('WebexCrossClientService', () => {
174
216
  ]);
175
217
  expect(trackEvent).toHaveBeenCalledWith(
176
218
  METRIC_EVENT_NAMES.WXAPP_USERSUB_PUBLISH_SUCCESS,
177
- {enableWxBetterTogether: true},
219
+ {enableWxBetterTogether: true, usersubPublished: true},
178
220
  ['operational', 'behavioral']
179
221
  );
180
222
  });
@@ -243,7 +285,7 @@ describe('WebexCrossClientService', () => {
243
285
  expect(cancelTimedEvent).not.toHaveBeenCalled();
244
286
  expect(trackEvent).toHaveBeenCalledWith(
245
287
  METRIC_EVENT_NAMES.WXAPP_USERSUB_PUBLISH_SUCCESS,
246
- {enableWxBetterTogether: false},
288
+ {enableWxBetterTogether: false, usersubPublished: false},
247
289
  ['operational', 'behavioral']
248
290
  );
249
291
  });
@@ -42,7 +42,7 @@ describe('WxAppTelephonyMercurySync', () => {
42
42
  expect(sync.isSubscribed()).toBe(true);
43
43
  expect(trackEvent).toHaveBeenCalledWith(
44
44
  METRIC_EVENT_NAMES.WXAPP_MERCURY_SUBSCRIBE_SUCCESS,
45
- {},
45
+ {mercurySubscribed: true},
46
46
  ['operational', 'behavioral']
47
47
  );
48
48
  });
@@ -82,4 +82,48 @@ describe('parseAgentConfigs collaboration access fields', () => {
82
82
  expect(profile.accessEntryPoint).toBe('NONE');
83
83
  expect(profile.accessBuddyTeam).toBe('ALL');
84
84
  });
85
+
86
+ it('enables wellness only when organization config, reminders, and license are present', () => {
87
+ const profile = parseAgentConfigs({
88
+ ...baseProfileData,
89
+ orgSettingsData: {...baseProfileData.orgSettingsData, aiAssistantQuantity: 1},
90
+ aiFeatureFlags: {
91
+ data: [
92
+ {
93
+ id: 'ai-feature-1',
94
+ agentWellbeing: {
95
+ enable: true,
96
+ wellnessBreakReminders: 'ENABLED',
97
+ },
98
+ },
99
+ ],
100
+ },
101
+ });
102
+
103
+ expect(profile.isWellnessBreakEnabled).toBe(true);
104
+ });
105
+
106
+ it.each([
107
+ {quantity: 0, enable: true, reminders: 'ENABLED'},
108
+ {quantity: 1, enable: false, reminders: 'ENABLED'},
109
+ {quantity: 1, enable: true, reminders: 'DISABLED'},
110
+ ] as const)(
111
+ 'disables wellness when an effective gate is missing',
112
+ ({quantity, enable, reminders}) => {
113
+ const profile = parseAgentConfigs({
114
+ ...baseProfileData,
115
+ orgSettingsData: {...baseProfileData.orgSettingsData, aiAssistantQuantity: quantity},
116
+ aiFeatureFlags: {
117
+ data: [
118
+ {
119
+ id: 'ai-feature-1',
120
+ agentWellbeing: {enable, wellnessBreakReminders: reminders},
121
+ },
122
+ ],
123
+ },
124
+ });
125
+
126
+ expect(profile.isWellnessBreakEnabled).toBe(false);
127
+ }
128
+ );
85
129
  });
@@ -705,6 +705,74 @@ describe('AgentConfigService', () => {
705
705
  });
706
706
  });
707
707
 
708
+ describe('getWellbeingBreakIdleCode', () => {
709
+ it('uses the system-code query and finds the exact code across pages', async () => {
710
+ (mockWebexRequest.request as jest.Mock)
711
+ .mockResolvedValueOnce({
712
+ statusCode: 200,
713
+ body: {
714
+ data: [
715
+ {id: 'other', name: 'Other', active: true, isSystemCode: true, defaultCode: false},
716
+ ],
717
+ meta: {totalPages: 2},
718
+ },
719
+ })
720
+ .mockResolvedValueOnce({
721
+ statusCode: 200,
722
+ body: {
723
+ data: [
724
+ {
725
+ id: 'wellbeing-code',
726
+ name: 'WellbeingBreak',
727
+ active: true,
728
+ isSystemCode: true,
729
+ defaultCode: false,
730
+ },
731
+ ],
732
+ meta: {totalPages: 2},
733
+ },
734
+ });
735
+
736
+ await expect(agentConfigService.getWellbeingBreakIdleCode(mockOrgId)).resolves.toEqual({
737
+ id: 'wellbeing-code',
738
+ name: 'WellbeingBreak',
739
+ isSystem: true,
740
+ isDefault: false,
741
+ });
742
+ expect(mockWebexRequest.request).toHaveBeenNthCalledWith(1, {
743
+ service: mockWccAPIURL,
744
+ resource:
745
+ `organization/${mockOrgId}/v2/auxiliary-code?page=0&pageSize=100` +
746
+ '&workType=IDLE_CODE&customFilter=isSystemCode==true&desktopProfileFilter=false',
747
+ method: 'GET',
748
+ });
749
+ expect(mockWebexRequest.request).toHaveBeenNthCalledWith(2, {
750
+ service: mockWccAPIURL,
751
+ resource:
752
+ `organization/${mockOrgId}/v2/auxiliary-code?page=1&pageSize=100` +
753
+ '&workType=IDLE_CODE&customFilter=isSystemCode==true&desktopProfileFilter=false',
754
+ method: 'GET',
755
+ });
756
+ });
757
+
758
+ it('rejects when the exact system code is unavailable', async () => {
759
+ (mockWebexRequest.request as jest.Mock).mockResolvedValue({
760
+ statusCode: 200,
761
+ body: {
762
+ data: [
763
+ {id: 'inactive', name: 'WellbeingBreak', active: false, isSystemCode: true},
764
+ {id: 'lookalike', name: 'wellbeingbreak', active: true, isSystemCode: true},
765
+ ],
766
+ meta: {totalPages: 1},
767
+ },
768
+ });
769
+
770
+ await expect(agentConfigService.getWellbeingBreakIdleCode(mockOrgId)).rejects.toThrow(
771
+ 'WELLBEING_BREAK_IDLE_CODE_NOT_FOUND'
772
+ );
773
+ });
774
+ });
775
+
708
776
  describe('getAgentConfig', () => {
709
777
  const mockTeamData = [
710
778
  {id: 'team1', name: 'Support Team'},
@@ -2927,7 +2927,7 @@ describe('TaskManager', () => {
2927
2927
  expect(hydrateHandler).toHaveBeenCalledTimes(1);
2928
2928
  });
2929
2929
 
2930
- it('reuses a task stored under the incoming main-call media key', () => {
2930
+ it('reuses a task identified by the incoming main-call media key', () => {
2931
2931
  const mainMediaInteractionId = 'main-media-interaction-id';
2932
2932
  const promotedChildId = 'promoted-child-without-main-id';
2933
2933
  const task = installTask(
@@ -2958,6 +2958,41 @@ describe('TaskManager', () => {
2958
2958
  expect(hydrateHandler).toHaveBeenCalledWith(task);
2959
2959
  });
2960
2960
 
2961
+ it('reuses a stale task through its collection key and the incoming main-call media key', () => {
2962
+ const mainMediaInteractionId = 'collection-main-interaction-id';
2963
+ const staleChildId = 'stale-collection-child-id';
2964
+ const promotedChildId = 'promoted-collection-child-id';
2965
+ const staleTaskData = createConferenceTaskData(
2966
+ staleChildId,
2967
+ previousOwnerId,
2968
+ staleChildId
2969
+ );
2970
+ staleTaskData.interaction.media[staleChildId].mType = 'consult';
2971
+ const task = createMockTask(staleTaskData);
2972
+ taskManager.taskCollection = {[mainMediaInteractionId]: task};
2973
+ (taskManager as any).setupTaskListeners(task);
2974
+ const hydrateHandler = jest.fn();
2975
+ taskManager.on(TASK_EVENTS.TASK_HYDRATE, hydrateHandler);
2976
+ const ownerChangeData = createPromotedAgentOwnerChangeData(
2977
+ promotedChildId,
2978
+ mainMediaInteractionId
2979
+ );
2980
+ delete ownerChangeData.interaction.mainInteractionId;
2981
+
2982
+ webSocketManagerMock.emit('message', JSON.stringify({data: ownerChangeData}));
2983
+
2984
+ expectLastStateMachineEvent(task.sendStateMachineEvent, TaskEvent.CONTACT_OWNER_CHANGED);
2985
+ expect(task.data.interactionId).toBe(mainMediaInteractionId);
2986
+ expect(task.data.interaction.owner).toBe(currentAgentId);
2987
+ expect(TaskFactory.createTask).not.toHaveBeenCalled();
2988
+ expect(taskManager.taskCollection[mainMediaInteractionId]).toBe(task);
2989
+ expect(taskManager.taskCollection[staleChildId]).toBeUndefined();
2990
+ expect(taskManager.taskCollection[promotedChildId]).toBeUndefined();
2991
+ expect(Object.values(taskManager.taskCollection)).toEqual([task]);
2992
+ expect(task.listenerCount(TASK_EVENTS.TASK_HYDRATE)).toBe(1);
2993
+ expect(hydrateHandler).toHaveBeenCalledTimes(1);
2994
+ });
2995
+
2961
2996
  it('prefers the exact task for ContactOwnerChanged over related child tasks', () => {
2962
2997
  const exactTask = installTask(createConferenceTaskData(taskId, previousOwnerId));
2963
2998
  const childTaskId = 'related-child-id';
@@ -2980,6 +3015,102 @@ describe('TaskManager', () => {
2980
3015
  expect(childTask.sendStateMachineEvent).not.toHaveBeenCalled();
2981
3016
  });
2982
3017
 
3018
+ it('does not replace a distinct main task when an exact child task also exists', () => {
3019
+ const childTaskId = 'exact-child-id';
3020
+ const mainTask = installTask(createConferenceTaskData(taskId, previousOwnerId));
3021
+ const childTask = createMockTask(
3022
+ createConferenceTaskData(childTaskId, previousOwnerId, taskId)
3023
+ );
3024
+ taskManager.taskCollection[childTaskId] = childTask;
3025
+
3026
+ webSocketManagerMock.emit(
3027
+ 'message',
3028
+ JSON.stringify({
3029
+ data: {
3030
+ ...createConferenceTaskData(childTaskId, promotedOwnerId, taskId),
3031
+ type: CC_EVENTS.CONTACT_OWNER_CHANGED,
3032
+ },
3033
+ })
3034
+ );
3035
+
3036
+ expect(childTask.sendStateMachineEvent).not.toHaveBeenCalled();
3037
+ expect(mainTask.sendStateMachineEvent).not.toHaveBeenCalled();
3038
+ expect(taskManager.taskCollection[taskId]).toBe(mainTask);
3039
+ expect(taskManager.taskCollection[childTaskId]).toBe(childTask);
3040
+ });
3041
+
3042
+ it('ignores a related owner change with conflicting explicit main interaction IDs', () => {
3043
+ const childTaskId = 'conflicting-main-child-id';
3044
+ const existingMainId = 'existing-main-id';
3045
+ const incomingMainId = 'incoming-main-id';
3046
+ const sharedParentId = 'shared-parent-id';
3047
+ const taskData = createConferenceTaskData(
3048
+ childTaskId,
3049
+ previousOwnerId,
3050
+ existingMainId
3051
+ );
3052
+ taskData.interaction.parentInteractionId = sharedParentId;
3053
+ const task = installTask(taskData);
3054
+ const ownerChangeData = createConferenceTaskData(
3055
+ 'different-child-id',
3056
+ promotedOwnerId,
3057
+ incomingMainId
3058
+ );
3059
+ ownerChangeData.interaction.parentInteractionId = sharedParentId;
3060
+
3061
+ webSocketManagerMock.emit(
3062
+ 'message',
3063
+ JSON.stringify({
3064
+ data: {...ownerChangeData, type: CC_EVENTS.CONTACT_OWNER_CHANGED},
3065
+ })
3066
+ );
3067
+
3068
+ expect(task.sendStateMachineEvent).not.toHaveBeenCalled();
3069
+ expect(taskManager.taskCollection[childTaskId]).toBe(task);
3070
+ expect(taskManager.taskCollection[existingMainId]).toBeUndefined();
3071
+ expect(taskManager.taskCollection[incomingMainId]).toBeUndefined();
3072
+ expect(TaskFactory.createTask).not.toHaveBeenCalled();
3073
+ });
3074
+
3075
+ it('does not replace a distinct task already stored at the canonical main key', () => {
3076
+ const childTaskId = 'related-active-child-id';
3077
+ const canonicalMainId = 'occupied-canonical-main-id';
3078
+ const candidate = createMockTask(
3079
+ createConferenceTaskData(childTaskId, previousOwnerId, canonicalMainId)
3080
+ );
3081
+ const occupiedData = createConferenceTaskData(
3082
+ 'occupied-task-child-id',
3083
+ previousOwnerId,
3084
+ canonicalMainId
3085
+ );
3086
+ occupiedData.interaction.media[canonicalMainId].mType = 'consult';
3087
+ const occupiedTask = createMockTask(occupiedData);
3088
+ taskManager.taskCollection = {
3089
+ [childTaskId]: candidate,
3090
+ [canonicalMainId]: occupiedTask,
3091
+ };
3092
+
3093
+ webSocketManagerMock.emit(
3094
+ 'message',
3095
+ JSON.stringify({
3096
+ data: {
3097
+ ...createConferenceTaskData(
3098
+ 'incoming-related-child-id',
3099
+ promotedOwnerId,
3100
+ canonicalMainId
3101
+ ),
3102
+ type: CC_EVENTS.CONTACT_OWNER_CHANGED,
3103
+ },
3104
+ })
3105
+ );
3106
+
3107
+ expect(candidate.sendStateMachineEvent).not.toHaveBeenCalled();
3108
+ expect(occupiedTask.sendStateMachineEvent).not.toHaveBeenCalled();
3109
+ expect(taskManager.taskCollection[childTaskId]).toBe(candidate);
3110
+ expect(taskManager.taskCollection[canonicalMainId]).toBe(occupiedTask);
3111
+ expect(TaskFactory.createTask).not.toHaveBeenCalled();
3112
+ });
3113
+
2983
3114
  it('ignores a related ContactOwnerChanged task whose current agent is consult-only', () => {
2984
3115
  const childTaskId = 'consult-only-child-id';
2985
3116
  const data = createConferenceTaskData(childTaskId, previousOwnerId, taskId);
@@ -3066,7 +3197,43 @@ describe('TaskManager', () => {
3066
3197
  expect(secondTask.sendStateMachineEvent).not.toHaveBeenCalled();
3067
3198
  });
3068
3199
 
3069
- it('does not recover a new task when multiple stale related tasks become eligible from the payload', () => {
3200
+ it('ignores ambiguous main-call media-key matches without creating a task', () => {
3201
+ const mainMediaInteractionId = 'ambiguous-main-media-id';
3202
+ const createMediaRelatedTask = (interactionId: string) => {
3203
+ const data = createConferenceTaskData(interactionId, previousOwnerId, interactionId);
3204
+ data.interaction.media = {
3205
+ [mainMediaInteractionId]: {
3206
+ mediaResourceId: mainMediaInteractionId,
3207
+ mediaType: 'telephony',
3208
+ mType: 'mainCall',
3209
+ participants: [currentAgentId, previousOwnerId, promotedOwnerId],
3210
+ },
3211
+ };
3212
+
3213
+ return createMockTask(data);
3214
+ };
3215
+ const firstTask = createMediaRelatedTask('first-media-child-id');
3216
+ const secondTask = createMediaRelatedTask('second-media-child-id');
3217
+ taskManager.taskCollection = {
3218
+ 'first-media-child-id': firstTask,
3219
+ 'second-media-child-id': secondTask,
3220
+ };
3221
+ (TaskFactory.createTask as jest.Mock).mockClear();
3222
+ const ownerChangeData = createPromotedAgentOwnerChangeData(
3223
+ 'promoted-media-child-id',
3224
+ mainMediaInteractionId
3225
+ );
3226
+ delete ownerChangeData.interaction.mainInteractionId;
3227
+
3228
+ webSocketManagerMock.emit('message', JSON.stringify({data: ownerChangeData}));
3229
+
3230
+ expect(firstTask.sendStateMachineEvent).not.toHaveBeenCalled();
3231
+ expect(secondTask.sendStateMachineEvent).not.toHaveBeenCalled();
3232
+ expect(TaskFactory.createTask).not.toHaveBeenCalled();
3233
+ expect(Object.values(taskManager.taskCollection)).toEqual([firstTask, secondTask]);
3234
+ });
3235
+
3236
+ it('does not recover a new task when multiple stale related tasks exist', () => {
3070
3237
  const createStaleRelatedTask = (interactionId: string) => {
3071
3238
  const data = createConferenceTaskData(interactionId, previousOwnerId, taskId);
3072
3239
  data.interaction.media[taskId].participants = [previousOwnerId, promotedOwnerId];
@@ -3178,7 +3345,10 @@ describe('TaskManager', () => {
3178
3345
  expect.objectContaining({
3179
3346
  type: TaskEvent.HYDRATE,
3180
3347
  agentId: currentAgentId,
3181
- taskData: expect.objectContaining({interactionId: taskId}),
3348
+ taskData: expect.objectContaining({
3349
+ interactionId: taskId,
3350
+ interaction: expect.objectContaining({state: 'conference'}),
3351
+ }),
3182
3352
  })
3183
3353
  );
3184
3354
  expect(recoveredTask.sendStateMachineEvent).toHaveBeenNthCalledWith(
@@ -3192,6 +3362,7 @@ describe('TaskManager', () => {
3192
3362
  expect(taskManager.taskCollection['promoted-child-interaction-id']).toBeUndefined();
3193
3363
  expect(Object.values(taskManager.taskCollection)).toEqual([recoveredTask]);
3194
3364
  expect(recoveredTask.data.interaction.owner).toBe(currentAgentId);
3365
+ expect(recoveredTask.listenerCount(TASK_EVENTS.TASK_HYDRATE)).toBe(1);
3195
3366
  expect(hydrateHandler).toHaveBeenCalledTimes(1);
3196
3367
  expect(hydrateHandler).toHaveBeenCalledWith(recoveredTask);
3197
3368
  expect(incomingHandler).not.toHaveBeenCalled();
@@ -3217,6 +3388,21 @@ describe('TaskManager', () => {
3217
3388
  data.interaction.isTerminated = true;
3218
3389
  },
3219
3390
  ],
3391
+ [
3392
+ 'the interaction is not telephony',
3393
+ (data) => {
3394
+ data.interaction.mediaType = 'chat';
3395
+ },
3396
+ ],
3397
+ [
3398
+ 'the payload has no stable interaction identifier',
3399
+ (data) => {
3400
+ data.interactionId = undefined;
3401
+ data.interaction.interactionId = undefined;
3402
+ delete data.interaction.mainInteractionId;
3403
+ data.interaction.media = {};
3404
+ },
3405
+ ],
3220
3406
  ])('does not create a missing task when %s', (_description, mutatePayload) => {
3221
3407
  clearTasksAndFactoryHistory();
3222
3408
  const ownerChangeData = createPromotedAgentOwnerChangeData();
@@ -3626,6 +3812,15 @@ describe('TaskManager', () => {
3626
3812
  expect(taskManager.taskCollection['task-alias']).toBeUndefined();
3627
3813
  });
3628
3814
 
3815
+ it('disposes wxApp offer observability when removing a task from the collection', () => {
3816
+ const disposeWxAppOfferObservability = jest.fn();
3817
+ task.disposeWxAppOfferObservability = disposeWxAppOfferObservability;
3818
+
3819
+ (taskManager as any).removeTaskFromCollection(task);
3820
+
3821
+ expect(disposeWxAppOfferObservability).toHaveBeenCalledTimes(1);
3822
+ });
3823
+
3629
3824
  it('replaces an EP-DN child task with a hydrated main task on CONTACT_MERGED', () => {
3630
3825
  const childTaskId = 'ep-dn-child-interaction-id';
3631
3826
  const childTask = createMockTask({
@@ -4186,4 +4381,20 @@ describe('TaskManager', () => {
4186
4381
  expect(taskTwo.applyWxAppMuteStateFromSync).toHaveBeenCalledWith('call-1', true);
4187
4382
  });
4188
4383
  });
4384
+
4385
+ describe('refreshWxAppOfferObservabilityForAllTasks', () => {
4386
+ it('invokes refresh on collected tasks that implement it', () => {
4387
+ const taskOne = {refreshWxAppOfferObservability: jest.fn()};
4388
+ const taskTwo = {};
4389
+
4390
+ taskManager['taskCollection'] = {
4391
+ [taskId]: taskOne,
4392
+ 'task-2': taskTwo,
4393
+ };
4394
+
4395
+ taskManager.refreshWxAppOfferObservabilityForAllTasks();
4396
+
4397
+ expect(taskOne.refreshWxAppOfferObservability).toHaveBeenCalledTimes(1);
4398
+ });
4399
+ });
4189
4400
  });
@@ -539,6 +539,39 @@ describe('Task state machine', () => {
539
539
  } as any,
540
540
  });
541
541
 
542
+ it('hydrates a recovered active conference from IDLE to CONFERENCING', () => {
543
+ const service = startMachine();
544
+ const taskData = createTaskData({
545
+ agentId: 'agent-1',
546
+ interactionId: 'interaction-1',
547
+ mediaResourceId: 'interaction-1',
548
+ interaction: {
549
+ state: 'conference',
550
+ interactionId: 'interaction-1',
551
+ mainInteractionId: 'interaction-1',
552
+ owner: 'agent-1',
553
+ participants: {
554
+ 'agent-1': {id: 'agent-1', pType: 'Agent', hasLeft: false},
555
+ 'agent-2': {id: 'agent-2', pType: 'Agent', hasLeft: false},
556
+ 'customer-1': {id: 'customer-1', pType: 'Customer', hasLeft: false},
557
+ },
558
+ media: {
559
+ 'interaction-1': {
560
+ mediaResourceId: 'interaction-1',
561
+ mType: 'mainCall',
562
+ participants: ['agent-1', 'agent-2', 'customer-1'],
563
+ isHold: false,
564
+ },
565
+ },
566
+ } as any,
567
+ });
568
+
569
+ service.send({type: TaskEvent.HYDRATE, taskData});
570
+
571
+ expect(service.getSnapshot().value).toBe(TaskState.CONFERENCING);
572
+ expect(service.getSnapshot().context.taskData).toEqual(taskData);
573
+ });
574
+
542
575
  it('boots from IDLE to CONSULTING on CONSULTING_ACTIVE for split-leg ordering', () => {
543
576
  const service = startMachine();
544
577
  const taskData = createTaskData({