@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
@@ -1006,69 +1006,28 @@ describe('Voice Task', () => {
1006
1006
  });
1007
1007
  });
1008
1008
 
1009
- describe('wxApp offer decision logging', () => {
1010
- const wxAppParticipant = {
1011
- deviceType: 'wxApp',
1012
- deviceId: 'device-id-1',
1013
- deviceCallId: 'call-id-1',
1014
- };
1015
-
1016
- const makeWxAppOfferTaskData = () =>
1017
- createBaseData({
1018
- agentId: 'agent-1',
1019
- interaction: {
1020
- participants: {
1021
- 'agent-1': {id: 'agent-1', ...wxAppParticipant},
1022
- },
1023
- } as any,
1024
- });
1025
-
1026
- it('logs evolving accept reasons through the normal wxApp accept flow', () => {
1027
- const logSpy = jest.spyOn(wxAppDiagnosticLogging, 'logWxAppOfferDecision');
1028
- const taskData = makeWxAppOfferTaskData();
1029
- const voice = new Voice(dummyContact, taskData, {enableWxBetterTogether: true});
1030
-
1031
- voice.stateMachineService?.send({type: TaskEvent.TASK_INCOMING, taskData});
1032
-
1033
- expect(logSpy).toHaveBeenCalledWith(
1034
- expect.objectContaining({acceptReason: 'wxApp_offer_ready'})
1035
- );
1036
-
1037
- logSpy.mockClear();
1038
- voice['setWxAppAcceptInFlight'](true);
1039
-
1040
- expect(logSpy).toHaveBeenCalledWith(
1041
- expect.objectContaining({acceptReason: 'wxApp_accept_in_flight'})
1042
- );
1043
-
1044
- logSpy.mockClear();
1045
- voice['setWxAppAcceptInFlight'](false);
1046
- voice['setWxAppAnswerPending'](true);
1047
-
1048
- expect(logSpy).toHaveBeenCalledWith(
1049
- expect.objectContaining({acceptReason: 'wxApp_answer_pending'})
1050
- );
1051
- });
1052
-
1053
- it('logs browser_webrtc_offer when voice variant is WebRTC on a non-wxApp inbound offer', () => {
1009
+ describe('wxApp offer observability integration', () => {
1010
+ it('delegates OFFERED ui control updates to wxApp offer observability', () => {
1054
1011
  const logSpy = jest.spyOn(wxAppDiagnosticLogging, 'logWxAppOfferDecision');
1055
1012
  const taskData = createBaseData({
1056
1013
  agentId: 'agent-1',
1057
1014
  interaction: {
1058
1015
  participants: {
1059
- 'agent-1': {id: 'agent-1', deviceType: 'phone', deviceId: 'device-id-1'},
1016
+ 'agent-1': {
1017
+ id: 'agent-1',
1018
+ deviceType: 'wxApp',
1019
+ deviceId: 'device-id-1',
1020
+ deviceCallId: 'call-id-1',
1021
+ },
1060
1022
  },
1061
1023
  } as any,
1062
1024
  });
1063
- const voice = new Voice(dummyContact, taskData, {
1064
- enableWxBetterTogether: true,
1065
- voiceVariant: VOICE_VARIANT.WEBRTC,
1066
- });
1025
+ const voice = new Voice(dummyContact, taskData, {enableWxBetterTogether: true});
1067
1026
 
1068
1027
  voice.stateMachineService?.send({type: TaskEvent.TASK_INCOMING, taskData});
1069
1028
 
1070
1029
  expect(logSpy).toHaveBeenCalledWith(
1071
- expect.objectContaining({acceptReason: 'browser_webrtc_offer'})
1030
+ expect.objectContaining({acceptReason: 'wxApp_offer_ready'})
1072
1031
  );
1073
1032
  });
1074
1033
  });
@@ -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
  });