@webex/contact-center 3.12.0-llmrefactor.13 → 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 (68) hide show
  1. package/.sdd/manifest.json +3 -2
  2. package/ai-docs/CONTRACTS.md +7 -1
  3. package/ai-docs/contact-center-spec.md +7 -7
  4. package/ai-docs/features/contact-owner-change-refactor.md +78 -0
  5. package/dist/cc.js +39 -7
  6. package/dist/cc.js.map +1 -1
  7. package/dist/metrics/behavioral-events.js +6 -0
  8. package/dist/metrics/behavioral-events.js.map +1 -1
  9. package/dist/metrics/constants.js +1 -0
  10. package/dist/metrics/constants.js.map +1 -1
  11. package/dist/services/AnswerCallOnWebexService.js +4 -4
  12. package/dist/services/AnswerCallOnWebexService.js.map +1 -1
  13. package/dist/services/WebexCrossClientService.js +5 -2
  14. package/dist/services/WebexCrossClientService.js.map +1 -1
  15. package/dist/services/WxAppTelephonyMercurySync.js +6 -3
  16. package/dist/services/WxAppTelephonyMercurySync.js.map +1 -1
  17. package/dist/services/config/types.js.map +1 -1
  18. package/dist/services/task/TaskFactory.js +1 -0
  19. package/dist/services/task/TaskFactory.js.map +1 -1
  20. package/dist/services/task/TaskManager.js +50 -58
  21. package/dist/services/task/TaskManager.js.map +1 -1
  22. package/dist/services/task/types.js.map +1 -1
  23. package/dist/services/task/voice/Voice.js +63 -44
  24. package/dist/services/task/voice/Voice.js.map +1 -1
  25. package/dist/services/task/voice/wxAppOfferObservability.js +145 -0
  26. package/dist/services/task/voice/wxAppOfferObservability.js.map +1 -0
  27. package/dist/services/task/voice/wxAppVoiceMethods.js +52 -11
  28. package/dist/services/task/voice/wxAppVoiceMethods.js.map +1 -1
  29. package/dist/services/wxAppDiagnosticLogging.js +50 -13
  30. package/dist/services/wxAppDiagnosticLogging.js.map +1 -1
  31. package/dist/types/metrics/constants.d.ts +1 -0
  32. package/dist/types/services/config/types.d.ts +2 -0
  33. package/dist/types/services/task/types.d.ts +2 -0
  34. package/dist/types/services/task/voice/Voice.d.ts +15 -2
  35. package/dist/types/services/task/voice/wxAppOfferObservability.d.ts +39 -0
  36. package/dist/types/services/task/voice/wxAppVoiceMethods.d.ts +19 -6
  37. package/dist/types/services/wxAppDiagnosticLogging.d.ts +12 -0
  38. package/dist/types.js.map +1 -1
  39. package/dist/webex.js +1 -1
  40. package/package.json +2 -2
  41. package/src/cc.ts +43 -7
  42. package/src/metrics/ai-docs/metrics-spec.md +44 -2
  43. package/src/metrics/behavioral-events.ts +6 -0
  44. package/src/metrics/constants.ts +1 -0
  45. package/src/services/AnswerCallOnWebexService.ts +4 -4
  46. package/src/services/WebexCrossClientService.ts +7 -2
  47. package/src/services/WxAppTelephonyMercurySync.ts +3 -3
  48. package/src/services/config/types.ts +2 -0
  49. package/src/services/task/TaskFactory.ts +1 -0
  50. package/src/services/task/TaskManager.ts +85 -87
  51. package/src/services/task/ai-docs/task-spec.md +7 -3
  52. package/src/services/task/types.ts +16 -0
  53. package/src/services/task/voice/Voice.ts +74 -52
  54. package/src/services/task/voice/wxAppOfferObservability.ts +204 -0
  55. package/src/services/task/voice/wxAppVoiceMethods.ts +83 -24
  56. package/src/services/wxAppDiagnosticLogging.ts +135 -23
  57. package/src/types.ts +2 -0
  58. package/test/unit/spec/cc.ts +201 -19
  59. package/test/unit/spec/services/WebexCrossClientService.ts +45 -3
  60. package/test/unit/spec/services/WxAppTelephonyMercurySync.ts +1 -1
  61. package/test/unit/spec/services/task/TaskManager.ts +214 -3
  62. package/test/unit/spec/services/task/state-machine/TaskStateMachine.ts +33 -0
  63. package/test/unit/spec/services/task/voice/Voice.ts +10 -51
  64. package/test/unit/spec/services/task/voice/wxAppOfferObservability.ts +361 -0
  65. package/test/unit/spec/services/task/voice/wxAppVoiceMethods.ts +121 -5
  66. package/test/unit/spec/services/wxAppDiagnosticLogging.ts +44 -8
  67. package/umd/contact-center.min.js +2 -2
  68. package/umd/contact-center.min.js.map +1 -1
@@ -0,0 +1,361 @@
1
+ import MetricsManager from '../../../../../../src/metrics/MetricsManager';
2
+ import {METRIC_EVENT_NAMES} from '../../../../../../src/metrics/constants';
3
+ import {TaskState} from '../../../../../../src/services/task/state-machine';
4
+ import {TaskData, VOICE_VARIANT} from '../../../../../../src/services/task/types';
5
+ import {
6
+ WxAppOfferObservability,
7
+ WxAppOfferObservabilityContext,
8
+ WXAPP_PARTICIPANT_MISMATCH_GRACE_MS,
9
+ } from '../../../../../../src/services/task/voice/wxAppOfferObservability';
10
+ import {WxAppVoiceDependencies} from '../../../../../../src/services/task/voice/wxAppVoiceMethods';
11
+ import * as wxAppDiagnosticLogging from '../../../../../../src/services/wxAppDiagnosticLogging';
12
+ import {createTaskData} from '../taskTestUtils';
13
+
14
+ const wxAppParticipant = {
15
+ deviceType: 'wxApp',
16
+ deviceId: 'device-id-1',
17
+ deviceCallId: 'call-id-1',
18
+ };
19
+
20
+ function makeTaskDataWithoutWxAppFields(): TaskData {
21
+ return createTaskData({
22
+ agentId: 'agent-1',
23
+ interaction: {
24
+ participants: {
25
+ 'agent-1': {id: 'agent-1'},
26
+ },
27
+ } as TaskData['interaction'],
28
+ });
29
+ }
30
+
31
+ function makeTaskDataWithWxAppFields(): TaskData {
32
+ return createTaskData({
33
+ agentId: 'agent-1',
34
+ interaction: {
35
+ participants: {
36
+ 'agent-1': {id: 'agent-1', ...wxAppParticipant},
37
+ },
38
+ } as TaskData['interaction'],
39
+ });
40
+ }
41
+
42
+ function makeMockMetricsManager(): jest.Mocked<Pick<MetricsManager, 'trackEvent'>> {
43
+ return {trackEvent: jest.fn()};
44
+ }
45
+
46
+ function makeContext(
47
+ overrides: {
48
+ taskData?: TaskData;
49
+ taskState?: TaskState;
50
+ usersubPublished?: boolean;
51
+ wxAppAcceptInFlight?: boolean;
52
+ wxAppAnswerPending?: boolean;
53
+ voiceVariant?: typeof VOICE_VARIANT.PSTN;
54
+ acceptEnabled?: boolean;
55
+ isOutdial?: boolean;
56
+ } = {}
57
+ ): WxAppOfferObservabilityContext & {
58
+ setTaskData: (data: TaskData) => void;
59
+ setTaskState: (s: TaskState) => void;
60
+ setUsersubPublished: (value: boolean) => void;
61
+ } {
62
+ let taskData = overrides.taskData ?? makeTaskDataWithoutWxAppFields();
63
+ let taskState = overrides.taskState ?? TaskState.OFFERED;
64
+ let usersubPublished = overrides.usersubPublished ?? true;
65
+ const metricsManager = makeMockMetricsManager() as unknown as MetricsManager;
66
+
67
+ const deps = (): WxAppVoiceDependencies => ({
68
+ enableWxBetterTogether: true,
69
+ agentId: 'agent-1',
70
+ metricsManager,
71
+ getTaskData: () => taskData,
72
+ getTaskState: () => taskState,
73
+ getWxAppMuted: () => false,
74
+ setWxAppMuted: jest.fn(),
75
+ getUsersubPublished: () => usersubPublished,
76
+ });
77
+
78
+ return {
79
+ getInteractionId: () => taskData.interactionId,
80
+ getTaskState: () => taskState,
81
+ getAcceptControl: () => ({
82
+ isVisible: true,
83
+ isEnabled: overrides.acceptEnabled ?? false,
84
+ }),
85
+ getVoiceVariant: () => overrides.voiceVariant ?? VOICE_VARIANT.PSTN,
86
+ isOutdial: () => overrides.isOutdial ?? false,
87
+ isWxAppInboundOffer: () => {
88
+ const p = taskData.interaction?.participants?.['agent-1'] as
89
+ | {deviceType?: string; deviceCallId?: string; deviceId?: string}
90
+ | undefined;
91
+ return (
92
+ p?.deviceType === 'wxApp' &&
93
+ typeof p.deviceCallId === 'string' &&
94
+ p.deviceCallId.trim() !== '' &&
95
+ typeof p.deviceId === 'string' &&
96
+ p.deviceId.trim() !== ''
97
+ );
98
+ },
99
+ isWxAppCallingOffer: () => {
100
+ const p = taskData.interaction?.participants?.['agent-1'] as
101
+ | {deviceType?: string; deviceCallId?: string; deviceId?: string}
102
+ | undefined;
103
+ return (
104
+ p?.deviceType === 'wxApp' &&
105
+ typeof p.deviceCallId === 'string' &&
106
+ p.deviceCallId.trim() !== '' &&
107
+ typeof p.deviceId === 'string' &&
108
+ p.deviceId.trim() !== ''
109
+ );
110
+ },
111
+ getWxAppAcceptInFlight: () => overrides.wxAppAcceptInFlight ?? false,
112
+ getWxAppAnswerPending: () => overrides.wxAppAnswerPending ?? false,
113
+ getEnableWxBetterTogether: () => true,
114
+ getUsersubPublished: () => usersubPublished,
115
+ getWxAppVoiceDependencies: deps,
116
+ getMetricsManager: () => metricsManager,
117
+ setTaskData: (data: TaskData) => {
118
+ taskData = data;
119
+ },
120
+ setTaskState: (state: TaskState) => {
121
+ taskState = state;
122
+ },
123
+ setUsersubPublished: (value: boolean) => {
124
+ usersubPublished = value;
125
+ },
126
+ };
127
+ }
128
+
129
+ describe('WxAppOfferObservability', () => {
130
+ beforeEach(() => {
131
+ jest.useFakeTimers();
132
+ });
133
+
134
+ afterEach(() => {
135
+ jest.useRealTimers();
136
+ });
137
+
138
+ describe('offer decision logging', () => {
139
+ it('logs wxApp_offer_ready when participant fields are present', () => {
140
+ const logSpy = jest.spyOn(wxAppDiagnosticLogging, 'logWxAppOfferDecision');
141
+ const observability = new WxAppOfferObservability();
142
+ const ctx = makeContext({taskData: makeTaskDataWithWxAppFields(), acceptEnabled: true});
143
+
144
+ observability.handleUiControlsUpdate(ctx);
145
+
146
+ expect(logSpy).toHaveBeenCalledWith(
147
+ expect.objectContaining({acceptReason: 'wxApp_offer_ready'})
148
+ );
149
+ });
150
+
151
+ it('logs accept reason transitions only once per reason', () => {
152
+ const logSpy = jest.spyOn(wxAppDiagnosticLogging, 'logWxAppOfferDecision');
153
+ const observability = new WxAppOfferObservability();
154
+ const ctx = makeContext({
155
+ taskData: makeTaskDataWithWxAppFields(),
156
+ wxAppAcceptInFlight: true,
157
+ });
158
+
159
+ observability.handleUiControlsUpdate(ctx);
160
+ observability.handleUiControlsUpdate(ctx);
161
+
162
+ expect(logSpy).toHaveBeenCalledTimes(1);
163
+ expect(logSpy).toHaveBeenCalledWith(
164
+ expect.objectContaining({acceptReason: 'wxApp_accept_in_flight'})
165
+ );
166
+ });
167
+
168
+ it('logs browser_webrtc_offer for WebRTC variant without wxApp participant', () => {
169
+ const logSpy = jest.spyOn(wxAppDiagnosticLogging, 'logWxAppOfferDecision');
170
+ const observability = new WxAppOfferObservability();
171
+ const ctx = makeContext({
172
+ taskData: createTaskData({
173
+ agentId: 'agent-1',
174
+ interaction: {
175
+ participants: {
176
+ 'agent-1': {id: 'agent-1', deviceType: 'phone', deviceId: 'device-id-1'},
177
+ },
178
+ } as TaskData['interaction'],
179
+ }),
180
+ voiceVariant: VOICE_VARIANT.WEBRTC,
181
+ });
182
+
183
+ observability.handleUiControlsUpdate(ctx);
184
+
185
+ expect(logSpy).toHaveBeenCalledWith(
186
+ expect.objectContaining({acceptReason: 'browser_webrtc_offer'})
187
+ );
188
+ });
189
+ });
190
+
191
+ describe('deferred participant mismatch', () => {
192
+ it('does not warn when wxApp fields arrive before grace period elapses', () => {
193
+ const warnSpy = jest.spyOn(wxAppDiagnosticLogging, 'logWxAppOfferParticipantMismatch');
194
+ const observability = new WxAppOfferObservability();
195
+ const ctx = makeContext();
196
+
197
+ observability.handleUiControlsUpdate(ctx);
198
+ expect(warnSpy).not.toHaveBeenCalled();
199
+
200
+ ctx.setTaskData(makeTaskDataWithWxAppFields());
201
+ observability.handleUiControlsUpdate(ctx);
202
+
203
+ jest.advanceTimersByTime(WXAPP_PARTICIPANT_MISMATCH_GRACE_MS);
204
+
205
+ expect(warnSpy).not.toHaveBeenCalled();
206
+ expect(ctx.getMetricsManager().trackEvent).not.toHaveBeenCalledWith(
207
+ METRIC_EVENT_NAMES.WXAPP_OFFER_PARTICIPANT_FIELDS_MISSING,
208
+ expect.anything(),
209
+ expect.anything()
210
+ );
211
+ });
212
+
213
+ it('warns and emits metric when fields remain missing after grace period', () => {
214
+ const warnSpy = jest.spyOn(wxAppDiagnosticLogging, 'logWxAppOfferParticipantMismatch');
215
+ const observability = new WxAppOfferObservability();
216
+ const ctx = makeContext();
217
+
218
+ observability.handleUiControlsUpdate(ctx);
219
+ expect(warnSpy).not.toHaveBeenCalled();
220
+
221
+ jest.advanceTimersByTime(WXAPP_PARTICIPANT_MISMATCH_GRACE_MS);
222
+
223
+ expect(warnSpy).toHaveBeenCalledWith(
224
+ expect.objectContaining({
225
+ interactionId: 'interaction-1',
226
+ usersubPublished: true,
227
+ hasDeviceCallId: false,
228
+ hasDeviceId: false,
229
+ acceptReason: 'extension_non_wxApp_offer',
230
+ })
231
+ );
232
+ expect(ctx.getMetricsManager().trackEvent).toHaveBeenCalledWith(
233
+ METRIC_EVENT_NAMES.WXAPP_OFFER_PARTICIPANT_FIELDS_MISSING,
234
+ expect.objectContaining({
235
+ taskId: 'interaction-1',
236
+ usersubPublished: true,
237
+ hasDeviceCallId: false,
238
+ hasDeviceId: false,
239
+ acceptReason: 'extension_non_wxApp_offer',
240
+ }),
241
+ ['operational', 'behavioral']
242
+ );
243
+ });
244
+
245
+ it('cancels pending mismatch when task leaves OFFERED before grace period', () => {
246
+ const warnSpy = jest.spyOn(wxAppDiagnosticLogging, 'logWxAppOfferParticipantMismatch');
247
+ const observability = new WxAppOfferObservability();
248
+ const ctx = makeContext();
249
+
250
+ observability.handleUiControlsUpdate(ctx);
251
+ ctx.setTaskState(TaskState.TERMINATED);
252
+ observability.handleUiControlsUpdate(ctx);
253
+
254
+ jest.advanceTimersByTime(WXAPP_PARTICIPANT_MISMATCH_GRACE_MS);
255
+
256
+ expect(warnSpy).not.toHaveBeenCalled();
257
+ });
258
+
259
+ it('clears grace timer on dispose', () => {
260
+ const warnSpy = jest.spyOn(wxAppDiagnosticLogging, 'logWxAppOfferParticipantMismatch');
261
+ const observability = new WxAppOfferObservability();
262
+ const ctx = makeContext();
263
+
264
+ observability.handleUiControlsUpdate(ctx);
265
+ observability.dispose();
266
+
267
+ jest.advanceTimersByTime(WXAPP_PARTICIPANT_MISMATCH_GRACE_MS);
268
+
269
+ expect(warnSpy).not.toHaveBeenCalled();
270
+ });
271
+
272
+ it('does not reschedule diagnostics after dispose', () => {
273
+ const logSpy = jest.spyOn(wxAppDiagnosticLogging, 'logWxAppOfferDecision');
274
+ const warnSpy = jest.spyOn(wxAppDiagnosticLogging, 'logWxAppOfferParticipantMismatch');
275
+ const observability = new WxAppOfferObservability();
276
+ const ctx = makeContext();
277
+
278
+ observability.dispose();
279
+ observability.handleUiControlsUpdate(ctx);
280
+ jest.advanceTimersByTime(WXAPP_PARTICIPANT_MISMATCH_GRACE_MS);
281
+
282
+ expect(logSpy).not.toHaveBeenCalled();
283
+ expect(warnSpy).not.toHaveBeenCalled();
284
+ expect(ctx.getMetricsManager().trackEvent).not.toHaveBeenCalledWith(
285
+ METRIC_EVENT_NAMES.WXAPP_OFFER_PARTICIPANT_FIELDS_MISSING,
286
+ expect.anything(),
287
+ expect.anything()
288
+ );
289
+ });
290
+
291
+ it('does not warn or emit metric for outdial offers with missing wxApp fields', () => {
292
+ const warnSpy = jest.spyOn(wxAppDiagnosticLogging, 'logWxAppOfferParticipantMismatch');
293
+ const observability = new WxAppOfferObservability();
294
+ const ctx = makeContext({isOutdial: true});
295
+
296
+ observability.handleUiControlsUpdate(ctx);
297
+ jest.advanceTimersByTime(WXAPP_PARTICIPANT_MISMATCH_GRACE_MS);
298
+
299
+ expect(warnSpy).not.toHaveBeenCalled();
300
+ expect(ctx.getMetricsManager().trackEvent).not.toHaveBeenCalledWith(
301
+ METRIC_EVENT_NAMES.WXAPP_OFFER_PARTICIPANT_FIELDS_MISSING,
302
+ expect.anything(),
303
+ expect.anything()
304
+ );
305
+ });
306
+
307
+ it('does not schedule mismatch while usersub is unpublished, then emits after refresh when usersub becomes active', () => {
308
+ const warnSpy = jest.spyOn(wxAppDiagnosticLogging, 'logWxAppOfferParticipantMismatch');
309
+ const observability = new WxAppOfferObservability();
310
+ const ctx = makeContext({usersubPublished: false});
311
+
312
+ observability.handleUiControlsUpdate(ctx);
313
+ jest.advanceTimersByTime(WXAPP_PARTICIPANT_MISMATCH_GRACE_MS);
314
+ expect(warnSpy).not.toHaveBeenCalled();
315
+
316
+ ctx.setUsersubPublished(true);
317
+ observability.handleUiControlsUpdate(ctx);
318
+ expect(warnSpy).not.toHaveBeenCalled();
319
+
320
+ jest.advanceTimersByTime(WXAPP_PARTICIPANT_MISMATCH_GRACE_MS);
321
+
322
+ expect(warnSpy).toHaveBeenCalledWith(
323
+ expect.objectContaining({
324
+ usersubPublished: true,
325
+ hasDeviceCallId: false,
326
+ hasDeviceId: false,
327
+ })
328
+ );
329
+ expect(ctx.getMetricsManager().trackEvent).toHaveBeenCalledWith(
330
+ METRIC_EVENT_NAMES.WXAPP_OFFER_PARTICIPANT_FIELDS_MISSING,
331
+ expect.objectContaining({
332
+ usersubPublished: true,
333
+ hasDeviceCallId: false,
334
+ hasDeviceId: false,
335
+ }),
336
+ ['operational', 'behavioral']
337
+ );
338
+ });
339
+
340
+ it('does not emit mismatch after usersub refresh when participant fields are already valid', () => {
341
+ const warnSpy = jest.spyOn(wxAppDiagnosticLogging, 'logWxAppOfferParticipantMismatch');
342
+ const observability = new WxAppOfferObservability();
343
+ const ctx = makeContext({
344
+ taskData: makeTaskDataWithWxAppFields(),
345
+ usersubPublished: false,
346
+ });
347
+
348
+ observability.handleUiControlsUpdate(ctx);
349
+ ctx.setUsersubPublished(true);
350
+ observability.handleUiControlsUpdate(ctx);
351
+ jest.advanceTimersByTime(WXAPP_PARTICIPANT_MISMATCH_GRACE_MS);
352
+
353
+ expect(warnSpy).not.toHaveBeenCalled();
354
+ expect(ctx.getMetricsManager().trackEvent).not.toHaveBeenCalledWith(
355
+ METRIC_EVENT_NAMES.WXAPP_OFFER_PARTICIPANT_FIELDS_MISSING,
356
+ expect.anything(),
357
+ expect.anything()
358
+ );
359
+ });
360
+ });
361
+ });
@@ -431,7 +431,12 @@ describe('runWxAppAccept', () => {
431
431
  ]);
432
432
  expect(deps.metricsManager.trackEvent).toHaveBeenCalledWith(
433
433
  METRIC_EVENT_NAMES.WXAPP_TASK_ACCEPT_SUCCESS,
434
- {taskId: 'interaction-1'},
434
+ expect.objectContaining({
435
+ taskId: 'interaction-1',
436
+ acceptReason: 'wxApp_offer_ready',
437
+ hasDeviceCallId: true,
438
+ hasDeviceId: true,
439
+ }),
435
440
  ['operational', 'behavioral']
436
441
  );
437
442
  expect(lifecycle.setWxAppAcceptInFlight).toHaveBeenNthCalledWith(1, true);
@@ -455,6 +460,7 @@ describe('runWxAppAccept', () => {
455
460
  expect.objectContaining({
456
461
  taskId: 'interaction-1',
457
462
  trackingId: 'track-1',
463
+ acceptReason: 'wxApp_offer_ready',
458
464
  }),
459
465
  ['operational', 'behavioral']
460
466
  );
@@ -462,6 +468,42 @@ describe('runWxAppAccept', () => {
462
468
  expect(lifecycle.mapWxAppVoiceError).toHaveBeenCalledWith(error, METHODS.ACCEPT);
463
469
  expect(lifecycle.setWxAppAcceptInFlight).toHaveBeenLastCalledWith(false);
464
470
  });
471
+
472
+ it('includes captured acceptReason on accept success when pending', async () => {
473
+ const deps = makeDeps();
474
+ const lifecycle = makeLifecycle();
475
+
476
+ await runWxAppAccept(deps, lifecycle, {acceptReason: 'wxApp_answer_pending'});
477
+
478
+ expect(deps.metricsManager.trackEvent).toHaveBeenCalledWith(
479
+ METRIC_EVENT_NAMES.WXAPP_TASK_ACCEPT_SUCCESS,
480
+ expect.objectContaining({
481
+ taskId: 'interaction-1',
482
+ acceptReason: 'wxApp_answer_pending',
483
+ }),
484
+ ['operational', 'behavioral']
485
+ );
486
+ });
487
+
488
+ it('includes captured acceptReason on accept failure when pending', async () => {
489
+ const deps = makeDeps();
490
+ const error = new Error('answer failed');
491
+ deps.answerCallOnWebexService!.answerCall = jest.fn().mockRejectedValue(error);
492
+ const lifecycle = makeLifecycle();
493
+
494
+ await expect(
495
+ runWxAppAccept(deps, lifecycle, {acceptReason: 'wxApp_answer_pending'})
496
+ ).rejects.toThrow('answer failed');
497
+
498
+ expect(deps.metricsManager.trackEvent).toHaveBeenCalledWith(
499
+ METRIC_EVENT_NAMES.WXAPP_TASK_ACCEPT_FAILED,
500
+ expect.objectContaining({
501
+ taskId: 'interaction-1',
502
+ acceptReason: 'wxApp_answer_pending',
503
+ }),
504
+ ['operational', 'behavioral']
505
+ );
506
+ });
465
507
  });
466
508
 
467
509
  describe('runWxAppReject', () => {
@@ -473,7 +515,12 @@ describe('runWxAppReject', () => {
473
515
 
474
516
  expect(deps.metricsManager.trackEvent).toHaveBeenCalledWith(
475
517
  METRIC_EVENT_NAMES.WXAPP_TASK_DECLINE_SUCCESS,
476
- {taskId: 'interaction-1'},
518
+ expect.objectContaining({
519
+ taskId: 'interaction-1',
520
+ acceptReason: 'wxApp_offer_ready',
521
+ hasDeviceCallId: true,
522
+ hasDeviceId: true,
523
+ }),
477
524
  ['operational', 'behavioral']
478
525
  );
479
526
  });
@@ -488,11 +535,50 @@ describe('runWxAppReject', () => {
488
535
 
489
536
  expect(deps.metricsManager.trackEvent).toHaveBeenCalledWith(
490
537
  METRIC_EVENT_NAMES.WXAPP_TASK_DECLINE_FAILED,
491
- expect.objectContaining({taskId: 'interaction-1'}),
538
+ expect.objectContaining({
539
+ taskId: 'interaction-1',
540
+ acceptReason: 'wxApp_offer_ready',
541
+ }),
492
542
  ['operational', 'behavioral']
493
543
  );
494
544
  expect(lifecycle.mapWxAppVoiceError).toHaveBeenCalledWith(error, METHODS.REJECT);
495
545
  });
546
+
547
+ it('includes captured acceptReason on decline success when pending', async () => {
548
+ const deps = makeDeps();
549
+ const lifecycle = makeLifecycle();
550
+
551
+ await runWxAppReject(deps, lifecycle, {acceptReason: 'wxApp_answer_pending'});
552
+
553
+ expect(deps.metricsManager.trackEvent).toHaveBeenCalledWith(
554
+ METRIC_EVENT_NAMES.WXAPP_TASK_DECLINE_SUCCESS,
555
+ expect.objectContaining({
556
+ taskId: 'interaction-1',
557
+ acceptReason: 'wxApp_answer_pending',
558
+ }),
559
+ ['operational', 'behavioral']
560
+ );
561
+ });
562
+
563
+ it('includes captured acceptReason on decline failure when pending', async () => {
564
+ const deps = makeDeps();
565
+ const error = new Error('reject failed');
566
+ deps.answerCallOnWebexService!.rejectCall = jest.fn().mockRejectedValue(error);
567
+ const lifecycle = makeLifecycle();
568
+
569
+ await expect(
570
+ runWxAppReject(deps, lifecycle, {acceptReason: 'wxApp_answer_pending'})
571
+ ).rejects.toThrow('reject failed');
572
+
573
+ expect(deps.metricsManager.trackEvent).toHaveBeenCalledWith(
574
+ METRIC_EVENT_NAMES.WXAPP_TASK_DECLINE_FAILED,
575
+ expect.objectContaining({
576
+ taskId: 'interaction-1',
577
+ acceptReason: 'wxApp_answer_pending',
578
+ }),
579
+ ['operational', 'behavioral']
580
+ );
581
+ });
496
582
  });
497
583
 
498
584
  describe('runWxAppOutdialDecline', () => {
@@ -509,7 +595,30 @@ describe('runWxAppOutdialDecline', () => {
509
595
  ]);
510
596
  expect(deps.metricsManager.trackEvent).toHaveBeenCalledWith(
511
597
  METRIC_EVENT_NAMES.WXAPP_TASK_DECLINE_SUCCESS,
512
- {taskId: 'interaction-1'},
598
+ expect.objectContaining({
599
+ taskId: 'interaction-1',
600
+ acceptReason: 'wxApp_offer_ready',
601
+ hasDeviceCallId: true,
602
+ hasDeviceId: true,
603
+ }),
604
+ ['operational', 'behavioral']
605
+ );
606
+ });
607
+
608
+ it('tracks post-accept acceptReason on outdial cancel when provided', async () => {
609
+ const deps = makeDeps();
610
+ const cancelResult = {type: 'RoutingMessage', trackingId: 'track-1', data: {}};
611
+
612
+ await runWxAppOutdialDecline(deps, async () => cancelResult, {
613
+ acceptReason: 'wxApp_answer_pending',
614
+ });
615
+
616
+ expect(deps.metricsManager.trackEvent).toHaveBeenCalledWith(
617
+ METRIC_EVENT_NAMES.WXAPP_TASK_DECLINE_SUCCESS,
618
+ expect.objectContaining({
619
+ taskId: 'interaction-1',
620
+ acceptReason: 'wxApp_answer_pending',
621
+ }),
513
622
  ['operational', 'behavioral']
514
623
  );
515
624
  });
@@ -555,6 +664,8 @@ describe('runWxAppOutdialDecline', () => {
555
664
  trackingId: 'aqm-track-1',
556
665
  failureReason: 'TASK_NOT_FOUND',
557
666
  reasonCode: 404,
667
+ hasDeviceCallId: true,
668
+ hasDeviceId: true,
558
669
  }),
559
670
  ['operational', 'behavioral']
560
671
  );
@@ -596,7 +707,12 @@ describe('runWxAppTransmitDtmf metrics', () => {
596
707
 
597
708
  expect(deps.metricsManager.trackEvent).toHaveBeenCalledWith(
598
709
  METRIC_EVENT_NAMES.WXAPP_TASK_DTMF_SUCCESS,
599
- {taskId: 'interaction-1', dtmfLength: 3},
710
+ expect.objectContaining({
711
+ taskId: 'interaction-1',
712
+ dtmfLength: 3,
713
+ hasDeviceCallId: true,
714
+ hasDeviceId: true,
715
+ }),
600
716
  ['operational', 'behavioral']
601
717
  );
602
718
  });
@@ -3,13 +3,15 @@ import {
3
3
  callIdSuffix,
4
4
  deriveWxAppAcceptReason,
5
5
  logWxAppOfferDecision,
6
+ logWxAppOfferParticipantMismatch,
6
7
  logWxAppSessionReadiness,
7
8
  logWxAppTelephonyAction,
8
9
  WXAPP_LOG_PREFIX,
9
10
  } from '../../../../src/services/wxAppDiagnosticLogging';
10
11
 
11
12
  jest.mock('../../../../src/logger-proxy', () => ({
12
- info: jest.fn(),
13
+ log: jest.fn(),
14
+ warn: jest.fn(),
13
15
  error: jest.fn(),
14
16
  }));
15
17
 
@@ -76,7 +78,7 @@ describe('wxAppDiagnosticLogging', () => {
76
78
  });
77
79
 
78
80
  describe('log helpers', () => {
79
- it('logs session readiness with feature prefix', () => {
81
+ it('logs session readiness at log level with grep-friendly suffix', () => {
80
82
  logWxAppSessionReadiness({
81
83
  enableWxBetterTogether: true,
82
84
  loginOption: 'EXTENSION',
@@ -86,8 +88,8 @@ describe('wxAppDiagnosticLogging', () => {
86
88
  telephonyTaskType: 'Voice',
87
89
  });
88
90
 
89
- expect(LoggerProxy.info).toHaveBeenCalledWith(
90
- `${WXAPP_LOG_PREFIX} session readiness`,
91
+ expect(LoggerProxy.log).toHaveBeenCalledWith(
92
+ expect.stringContaining(`${WXAPP_LOG_PREFIX} session readiness`),
91
93
  expect.objectContaining({
92
94
  module: 'wxAppDiagnosticLogging',
93
95
  method: 'logWxAppSessionReadiness',
@@ -95,27 +97,61 @@ describe('wxAppDiagnosticLogging', () => {
95
97
  feature: 'wxApp',
96
98
  event: 'session_readiness',
97
99
  loginOption: 'EXTENSION',
100
+ usersubPublished: true,
101
+ mercurySubscribed: true,
98
102
  }),
99
103
  })
100
104
  );
105
+ expect(LoggerProxy.log).toHaveBeenCalledWith(
106
+ expect.stringContaining('usersubPublished=true'),
107
+ expect.anything()
108
+ );
101
109
  });
102
110
 
103
- it('logs offer decision with acceptReason', () => {
111
+ it('logs offer decision at log level with acceptReason suffix', () => {
104
112
  logWxAppOfferDecision({
105
113
  interactionId: 'task-1',
106
114
  acceptVisible: true,
107
115
  acceptEnabled: false,
108
116
  acceptReason: 'extension_non_wxApp_offer',
109
117
  hasDeviceCallId: false,
118
+ hasDeviceId: false,
119
+ usersubPublished: true,
110
120
  });
111
121
 
112
- expect(LoggerProxy.info).toHaveBeenCalledWith(
113
- `${WXAPP_LOG_PREFIX} offer decision`,
122
+ expect(LoggerProxy.log).toHaveBeenCalledWith(
123
+ expect.stringContaining('acceptReason=extension_non_wxApp_offer'),
114
124
  expect.objectContaining({
115
125
  data: expect.objectContaining({
116
126
  event: 'offer_decision',
117
127
  acceptReason: 'extension_non_wxApp_offer',
118
128
  interactionId: 'task-1',
129
+ usersubPublished: true,
130
+ }),
131
+ })
132
+ );
133
+ });
134
+
135
+ it('logs participant mismatch at warn level', () => {
136
+ logWxAppOfferParticipantMismatch({
137
+ interactionId: 'task-1',
138
+ usersubPublished: true,
139
+ hasDeviceCallId: false,
140
+ hasDeviceId: false,
141
+ participantDeviceType: 'wxApp',
142
+ acceptVisible: true,
143
+ acceptEnabled: false,
144
+ acceptReason: 'extension_non_wxApp_offer',
145
+ });
146
+
147
+ expect(LoggerProxy.warn).toHaveBeenCalledWith(
148
+ expect.stringContaining('participant fields mismatch'),
149
+ expect.objectContaining({
150
+ method: 'logWxAppOfferParticipantMismatch',
151
+ data: expect.objectContaining({
152
+ event: 'participant_fields_mismatch',
153
+ usersubPublished: true,
154
+ hasDeviceCallId: false,
119
155
  }),
120
156
  })
121
157
  );
@@ -131,7 +167,7 @@ describe('wxAppDiagnosticLogging', () => {
131
167
  });
132
168
 
133
169
  expect(LoggerProxy.error).toHaveBeenCalledWith(
134
- `${WXAPP_LOG_PREFIX} telephony accept failed`,
170
+ expect.stringContaining('telephony accept failed'),
135
171
  expect.objectContaining({
136
172
  data: expect.objectContaining({
137
173
  trackingId: 'track-abc',