@webex/contact-center 3.12.0-llmrefactor.8 → 3.12.0-llmrefactor.9

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 (56) hide show
  1. package/.sdd/manifest.json +3 -2
  2. package/ai-docs/CONTRACTS.md +2 -1
  3. package/dist/cc.js +108 -6
  4. package/dist/cc.js.map +1 -1
  5. package/dist/metrics/MetricsManager.js +16 -0
  6. package/dist/metrics/MetricsManager.js.map +1 -1
  7. package/dist/metrics/behavioral-events.js +91 -0
  8. package/dist/metrics/behavioral-events.js.map +1 -1
  9. package/dist/metrics/constants.js +16 -0
  10. package/dist/metrics/constants.js.map +1 -1
  11. package/dist/services/AnswerCallOnWebexService.js +34 -6
  12. package/dist/services/AnswerCallOnWebexService.js.map +1 -1
  13. package/dist/services/WebexCrossClientService.js +54 -3
  14. package/dist/services/WebexCrossClientService.js.map +1 -1
  15. package/dist/services/WxAppTelephonyMercurySync.js +21 -6
  16. package/dist/services/WxAppTelephonyMercurySync.js.map +1 -1
  17. package/dist/services/task/voice/Voice.js +81 -14
  18. package/dist/services/task/voice/Voice.js.map +1 -1
  19. package/dist/services/task/voice/wxAppVoiceMethods.js +154 -1
  20. package/dist/services/task/voice/wxAppVoiceMethods.js.map +1 -1
  21. package/dist/services/wxAppDiagnosticLogging.js +122 -0
  22. package/dist/services/wxAppDiagnosticLogging.js.map +1 -0
  23. package/dist/types/cc.d.ts +1 -0
  24. package/dist/types/metrics/MetricsManager.d.ts +6 -0
  25. package/dist/types/metrics/constants.d.ts +15 -0
  26. package/dist/types/services/AnswerCallOnWebexService.d.ts +1 -0
  27. package/dist/types/services/WebexCrossClientService.d.ts +4 -0
  28. package/dist/types/services/task/voice/Voice.d.ts +5 -1
  29. package/dist/types/services/task/voice/wxAppVoiceMethods.d.ts +3 -0
  30. package/dist/types/services/wxAppDiagnosticLogging.d.ts +51 -0
  31. package/dist/webex.js +1 -1
  32. package/package.json +8 -8
  33. package/src/cc.ts +155 -7
  34. package/src/metrics/MetricsManager.ts +16 -0
  35. package/src/metrics/ai-docs/metrics-spec.md +71 -9
  36. package/src/metrics/behavioral-events.ts +92 -0
  37. package/src/metrics/constants.ts +18 -0
  38. package/src/services/AnswerCallOnWebexService.ts +41 -8
  39. package/src/services/WebexCrossClientService.ts +85 -4
  40. package/src/services/WxAppTelephonyMercurySync.ts +31 -4
  41. package/src/services/agent/ai-docs/agent-spec.md +1 -0
  42. package/src/services/task/ai-docs/task-spec.md +2 -0
  43. package/src/services/task/voice/Voice.ts +115 -26
  44. package/src/services/task/voice/wxAppVoiceMethods.ts +211 -2
  45. package/src/services/wxAppDiagnosticLogging.ts +174 -0
  46. package/test/unit/spec/cc.ts +251 -22
  47. package/test/unit/spec/metrics/MetricsManager.ts +25 -0
  48. package/test/unit/spec/metrics/behavioral-events.ts +42 -0
  49. package/test/unit/spec/services/AnswerCallOnWebexService.ts +41 -0
  50. package/test/unit/spec/services/WebexCrossClientService.ts +207 -1
  51. package/test/unit/spec/services/WxAppTelephonyMercurySync.ts +23 -0
  52. package/test/unit/spec/services/task/voice/Voice.ts +380 -0
  53. package/test/unit/spec/services/task/voice/wxAppVoiceMethods.ts +145 -1
  54. package/test/unit/spec/services/wxAppDiagnosticLogging.ts +143 -0
  55. package/umd/contact-center.min.js +2 -2
  56. package/umd/contact-center.min.js.map +1 -1
@@ -4,6 +4,7 @@ import {
4
4
  TaskData,
5
5
  CONSULT_TRANSFER_DESTINATION_TYPE,
6
6
  TASK_EVENTS,
7
+ VOICE_VARIANT,
7
8
  } from '../../../../../../src/services/task/types';
8
9
  import {CC_EVENTS} from '../../../../../../src/services/config/types';
9
10
  import {TaskEvent, TaskState} from '../../../../../../src/services/task/state-machine';
@@ -12,6 +13,7 @@ import * as Utils from '../../../../../../src/services/core/Utils';
12
13
  import {createTaskData} from '../taskTestUtils';
13
14
  import MetricsManager from '../../../../../../src/metrics/MetricsManager';
14
15
  import {METRIC_EVENT_NAMES} from '../../../../../../src/metrics/constants';
16
+ import * as wxAppDiagnosticLogging from '../../../../../../src/services/wxAppDiagnosticLogging';
15
17
 
16
18
  jest.mock('../../../../../../src/services/core/WebexRequest', () => ({
17
19
  __esModule: true,
@@ -846,6 +848,73 @@ describe('Voice Task', () => {
846
848
  });
847
849
  });
848
850
 
851
+ describe('wxApp offer decision logging', () => {
852
+ const wxAppParticipant = {
853
+ deviceType: 'wxApp',
854
+ deviceId: 'device-id-1',
855
+ deviceCallId: 'call-id-1',
856
+ };
857
+
858
+ const makeWxAppOfferTaskData = () =>
859
+ createBaseData({
860
+ agentId: 'agent-1',
861
+ interaction: {
862
+ participants: {
863
+ 'agent-1': {id: 'agent-1', ...wxAppParticipant},
864
+ },
865
+ } as any,
866
+ });
867
+
868
+ it('logs evolving accept reasons through the normal wxApp accept flow', () => {
869
+ const logSpy = jest.spyOn(wxAppDiagnosticLogging, 'logWxAppOfferDecision');
870
+ const taskData = makeWxAppOfferTaskData();
871
+ const voice = new Voice(dummyContact, taskData, {enableWxBetterTogether: true});
872
+
873
+ voice.stateMachineService?.send({type: TaskEvent.TASK_INCOMING, taskData});
874
+
875
+ expect(logSpy).toHaveBeenCalledWith(
876
+ expect.objectContaining({acceptReason: 'wxApp_offer_ready'})
877
+ );
878
+
879
+ logSpy.mockClear();
880
+ voice['setWxAppAcceptInFlight'](true);
881
+
882
+ expect(logSpy).toHaveBeenCalledWith(
883
+ expect.objectContaining({acceptReason: 'wxApp_accept_in_flight'})
884
+ );
885
+
886
+ logSpy.mockClear();
887
+ voice['setWxAppAcceptInFlight'](false);
888
+ voice['setWxAppAnswerPending'](true);
889
+
890
+ expect(logSpy).toHaveBeenCalledWith(
891
+ expect.objectContaining({acceptReason: 'wxApp_answer_pending'})
892
+ );
893
+ });
894
+
895
+ it('logs browser_webrtc_offer when voice variant is WebRTC on a non-wxApp inbound offer', () => {
896
+ const logSpy = jest.spyOn(wxAppDiagnosticLogging, 'logWxAppOfferDecision');
897
+ const taskData = createBaseData({
898
+ agentId: 'agent-1',
899
+ interaction: {
900
+ participants: {
901
+ 'agent-1': {id: 'agent-1', deviceType: 'phone', deviceId: 'device-id-1'},
902
+ },
903
+ } as any,
904
+ });
905
+ const voice = new Voice(dummyContact, taskData, {
906
+ enableWxBetterTogether: true,
907
+ voiceVariant: VOICE_VARIANT.WEBRTC,
908
+ });
909
+
910
+ voice.stateMachineService?.send({type: TaskEvent.TASK_INCOMING, taskData});
911
+
912
+ expect(logSpy).toHaveBeenCalledWith(
913
+ expect.objectContaining({acceptReason: 'browser_webrtc_offer'})
914
+ );
915
+ });
916
+ });
917
+
849
918
  describe('applyWxAppMuteStateFromSync', () => {
850
919
  const wxAppParticipant = {
851
920
  deviceType: 'wxApp',
@@ -1305,6 +1374,66 @@ describe('Voice Task', () => {
1305
1374
  expect(dummyContact.cancelTask).toHaveBeenCalledWith({interactionId: 'int1'});
1306
1375
  });
1307
1376
 
1377
+ it('emits TASK_DECLINE and WXAPP_TASK_DECLINE success metrics for wxApp outdial decline', async () => {
1378
+ const taskData = makeWxAppOutdialTaskData();
1379
+ const voice = new Voice(dummyContact, taskData, {enableWxBetterTogether: true});
1380
+ voice.stateMachineService?.send({type: TaskEvent.TASK_INCOMING, taskData});
1381
+ const metricsManager = MetricsManager.getInstance();
1382
+ const trackEventSpy = jest.spyOn(metricsManager, 'trackEvent');
1383
+
1384
+ await voice.decline();
1385
+
1386
+ expect(trackEventSpy).toHaveBeenCalledWith(
1387
+ METRIC_EVENT_NAMES.WXAPP_TASK_DECLINE_SUCCESS,
1388
+ expect.objectContaining({taskId: 'int1'}),
1389
+ ['operational', 'behavioral']
1390
+ );
1391
+ expect(trackEventSpy).toHaveBeenCalledWith(
1392
+ METRIC_EVENT_NAMES.TASK_DECLINE_SUCCESS,
1393
+ expect.objectContaining({taskId: 'int1'}),
1394
+ ['operational', 'behavioral']
1395
+ );
1396
+ });
1397
+
1398
+ it('emits TASK_DECLINE and WXAPP_TASK_DECLINE failed metrics when outdial cancel fails', async () => {
1399
+ const cancelError = Object.assign(new Error('AQM failed'), {
1400
+ details: {
1401
+ trackingId: 'aqm-track-1',
1402
+ orgId: 'org-1',
1403
+ type: 'Service.aqm.task.cancel',
1404
+ data: {reason: 'TASK_NOT_FOUND', reasonCode: 404, agentId: 'agent-1'},
1405
+ },
1406
+ });
1407
+ dummyContact.cancelTask = jest.fn().mockRejectedValue(cancelError);
1408
+ const taskData = makeWxAppOutdialTaskData();
1409
+ const voice = new Voice(dummyContact, taskData, {enableWxBetterTogether: true});
1410
+ voice.stateMachineService?.send({type: TaskEvent.TASK_INCOMING, taskData});
1411
+ const metricsManager = MetricsManager.getInstance();
1412
+ const trackEventSpy = jest.spyOn(metricsManager, 'trackEvent');
1413
+
1414
+ await expect(voice.decline()).rejects.toThrow('AQM failed');
1415
+
1416
+ expect(trackEventSpy).toHaveBeenCalledWith(
1417
+ METRIC_EVENT_NAMES.WXAPP_TASK_DECLINE_FAILED,
1418
+ expect.objectContaining({
1419
+ taskId: 'int1',
1420
+ trackingId: 'aqm-track-1',
1421
+ failureReason: 'TASK_NOT_FOUND',
1422
+ }),
1423
+ ['operational', 'behavioral']
1424
+ );
1425
+ expect(trackEventSpy).toHaveBeenCalledWith(
1426
+ METRIC_EVENT_NAMES.TASK_DECLINE_FAILED,
1427
+ expect.objectContaining({
1428
+ taskId: 'int1',
1429
+ trackingId: 'aqm-track-1',
1430
+ failureReason: 'TASK_NOT_FOUND',
1431
+ }),
1432
+ ['operational', 'behavioral']
1433
+ );
1434
+ dummyContact.cancelTask = jest.fn().mockResolvedValue(undefined);
1435
+ });
1436
+
1308
1437
  it('clears wxAppAnswerPending when declining outdial', async () => {
1309
1438
  const mockSvc = {
1310
1439
  answerCall: jest.fn().mockResolvedValue(undefined),
@@ -1456,6 +1585,13 @@ describe('Voice Task', () => {
1456
1585
  const firstToggle = voice.toggleMute();
1457
1586
  const secondToggle = voice.toggleMute();
1458
1587
 
1588
+ await new Promise<void>((resolve) => {
1589
+ setImmediate(resolve);
1590
+ });
1591
+
1592
+ expect(mockSvc.muteCall).toHaveBeenCalledTimes(1);
1593
+ expect(mockSvc.unmuteCall).not.toHaveBeenCalled();
1594
+
1459
1595
  resolveMute!();
1460
1596
  await Promise.all([firstToggle, secondToggle]);
1461
1597
 
@@ -1464,6 +1600,96 @@ describe('Voice Task', () => {
1464
1600
  expect(voice.getWxAppMuted()).toBe(false);
1465
1601
  });
1466
1602
 
1603
+ it('queues three concurrent no-arg toggleMute() calls in order', async () => {
1604
+ let resolveFirstMute: () => void;
1605
+ const firstMuteGate = new Promise<void>((resolve) => {
1606
+ resolveFirstMute = resolve;
1607
+ });
1608
+ const mockSvc = {
1609
+ muteCall: jest
1610
+ .fn()
1611
+ .mockImplementationOnce(() => firstMuteGate)
1612
+ .mockResolvedValue(undefined),
1613
+ unmuteCall: jest.fn().mockResolvedValue(undefined),
1614
+ };
1615
+ const taskData = createBaseData({
1616
+ agentId: 'agent-1',
1617
+ interaction: {
1618
+ participants: {
1619
+ 'agent-1': {id: 'agent-1', ...wxAppParticipant},
1620
+ },
1621
+ } as any,
1622
+ });
1623
+ const voice = new Voice(dummyContact, taskData, {
1624
+ enableWxBetterTogether: true,
1625
+ answerCallOnWebexService: mockSvc as any,
1626
+ });
1627
+ primeConnectedState(voice, taskData);
1628
+ expect(voice.getWxAppMuted()).toBe(false);
1629
+
1630
+ const firstToggle = voice.toggleMute();
1631
+ const secondToggle = voice.toggleMute();
1632
+ const thirdToggle = voice.toggleMute();
1633
+
1634
+ await new Promise<void>((resolve) => {
1635
+ setImmediate(resolve);
1636
+ });
1637
+
1638
+ expect(mockSvc.muteCall).toHaveBeenCalledTimes(1);
1639
+ expect(mockSvc.unmuteCall).not.toHaveBeenCalled();
1640
+
1641
+ resolveFirstMute!();
1642
+ await Promise.all([firstToggle, secondToggle, thirdToggle]);
1643
+
1644
+ expect(mockSvc.muteCall).toHaveBeenCalledTimes(2);
1645
+ expect(mockSvc.unmuteCall).toHaveBeenCalledTimes(1);
1646
+ expect(voice.getWxAppMuted()).toBe(true);
1647
+ });
1648
+
1649
+ it('records duration_ms on each serialized toggleMute success metric', async () => {
1650
+ let resolveFirstMute: () => void;
1651
+ const firstMuteGate = new Promise<void>((resolve) => {
1652
+ resolveFirstMute = resolve;
1653
+ });
1654
+ const mockSvc = {
1655
+ muteCall: jest.fn().mockImplementationOnce(() => firstMuteGate),
1656
+ unmuteCall: jest.fn().mockResolvedValue(undefined),
1657
+ };
1658
+ const taskData = createBaseData({
1659
+ agentId: 'agent-1',
1660
+ interaction: {
1661
+ participants: {
1662
+ 'agent-1': {id: 'agent-1', ...wxAppParticipant},
1663
+ },
1664
+ } as any,
1665
+ });
1666
+ const voice = new Voice(dummyContact, taskData, {
1667
+ enableWxBetterTogether: true,
1668
+ answerCallOnWebexService: mockSvc as any,
1669
+ });
1670
+ primeConnectedState(voice, taskData);
1671
+ const metricsManager = MetricsManager.getInstance();
1672
+ const trackEventSpy = jest.spyOn(metricsManager, 'trackEvent');
1673
+
1674
+ const firstToggle = voice.toggleMute();
1675
+ const secondToggle = voice.toggleMute();
1676
+
1677
+ resolveFirstMute!();
1678
+ await Promise.all([firstToggle, secondToggle]);
1679
+
1680
+ const successCalls = trackEventSpy.mock.calls.filter(
1681
+ ([eventName]) => eventName === METRIC_EVENT_NAMES.WXAPP_TASK_MUTE_SUCCESS
1682
+ );
1683
+ expect(successCalls).toHaveLength(2);
1684
+ successCalls.forEach(([, payload]) => {
1685
+ expect(payload).toEqual(
1686
+ expect.objectContaining({
1687
+ duration_ms: expect.any(Number),
1688
+ })
1689
+ );
1690
+ });
1691
+ });
1692
+
1467
1693
  it('continues queued toggleMute after predecessor failure', async () => {
1468
1694
  const mockSvc = {
1469
1695
  muteCall: jest.fn().mockRejectedValue(new Error('mute failed')),
@@ -1519,5 +1745,159 @@ describe('Voice Task', () => {
1519
1745
  lineOwnerId: undefined,
1520
1746
  });
1521
1747
  });
1748
+
1749
+ it('serializes concurrent transmitDtmf() so each digit completes before the next', async () => {
1750
+ let resolveFirst: () => void;
1751
+ const firstGate = new Promise<void>((resolve) => {
1752
+ resolveFirst = resolve;
1753
+ });
1754
+ const mockSvc = {
1755
+ transmitDtmf: jest
1756
+ .fn()
1757
+ .mockImplementationOnce(() => firstGate)
1758
+ .mockResolvedValueOnce(undefined),
1759
+ };
1760
+ const taskData = createBaseData({
1761
+ agentId: 'agent-1',
1762
+ interaction: {
1763
+ participants: {
1764
+ 'agent-1': {id: 'agent-1', ...wxAppParticipant},
1765
+ },
1766
+ } as any,
1767
+ });
1768
+ const voice = new Voice(dummyContact, taskData, {
1769
+ enableWxBetterTogether: true,
1770
+ answerCallOnWebexService: mockSvc as any,
1771
+ });
1772
+ primeConnectedState(voice, taskData);
1773
+
1774
+ const firstDtmf = voice.transmitDtmf({dtmf: '1'});
1775
+ const secondDtmf = voice.transmitDtmf({dtmf: '2'});
1776
+
1777
+ await new Promise<void>((resolve) => {
1778
+ setImmediate(resolve);
1779
+ });
1780
+
1781
+ expect(mockSvc.transmitDtmf).toHaveBeenCalledTimes(1);
1782
+ expect(mockSvc.transmitDtmf).toHaveBeenCalledWith({
1783
+ callId: 'call-id-1',
1784
+ dtmf: '1',
1785
+ lineOwnerId: undefined,
1786
+ });
1787
+
1788
+ resolveFirst!();
1789
+ await Promise.all([firstDtmf, secondDtmf]);
1790
+
1791
+ expect(mockSvc.transmitDtmf).toHaveBeenCalledTimes(2);
1792
+ expect(mockSvc.transmitDtmf).toHaveBeenNthCalledWith(2, {
1793
+ callId: 'call-id-1',
1794
+ dtmf: '2',
1795
+ lineOwnerId: undefined,
1796
+ });
1797
+ });
1798
+
1799
+ it('queues three concurrent transmitDtmf() calls in order', async () => {
1800
+ let resolveFirst: () => void;
1801
+ const firstGate = new Promise<void>((resolve) => {
1802
+ resolveFirst = resolve;
1803
+ });
1804
+ const mockSvc = {
1805
+ transmitDtmf: jest
1806
+ .fn()
1807
+ .mockImplementationOnce(() => firstGate)
1808
+ .mockResolvedValue(undefined),
1809
+ };
1810
+ const taskData = createBaseData({
1811
+ agentId: 'agent-1',
1812
+ interaction: {
1813
+ participants: {
1814
+ 'agent-1': {id: 'agent-1', ...wxAppParticipant},
1815
+ },
1816
+ } as any,
1817
+ });
1818
+ const voice = new Voice(dummyContact, taskData, {
1819
+ enableWxBetterTogether: true,
1820
+ answerCallOnWebexService: mockSvc as any,
1821
+ });
1822
+ primeConnectedState(voice, taskData);
1823
+
1824
+ const firstDtmf = voice.transmitDtmf({dtmf: '1'});
1825
+ const secondDtmf = voice.transmitDtmf({dtmf: '2'});
1826
+ const thirdDtmf = voice.transmitDtmf({dtmf: '3'});
1827
+
1828
+ await new Promise<void>((resolve) => {
1829
+ setImmediate(resolve);
1830
+ });
1831
+
1832
+ expect(mockSvc.transmitDtmf).toHaveBeenCalledTimes(1);
1833
+ expect(mockSvc.transmitDtmf).toHaveBeenCalledWith({
1834
+ callId: 'call-id-1',
1835
+ dtmf: '1',
1836
+ lineOwnerId: undefined,
1837
+ });
1838
+
1839
+ resolveFirst!();
1840
+ await Promise.all([firstDtmf, secondDtmf, thirdDtmf]);
1841
+
1842
+ expect(mockSvc.transmitDtmf).toHaveBeenCalledTimes(3);
1843
+ expect(mockSvc.transmitDtmf).toHaveBeenNthCalledWith(2, {
1844
+ callId: 'call-id-1',
1845
+ dtmf: '2',
1846
+ lineOwnerId: undefined,
1847
+ });
1848
+ expect(mockSvc.transmitDtmf).toHaveBeenNthCalledWith(3, {
1849
+ callId: 'call-id-1',
1850
+ dtmf: '3',
1851
+ lineOwnerId: undefined,
1852
+ });
1853
+ });
1854
+
1855
+ it('records duration_ms on each serialized transmitDtmf success metric', async () => {
1856
+ let resolveFirst: () => void;
1857
+ const firstGate = new Promise<void>((resolve) => {
1858
+ resolveFirst = resolve;
1859
+ });
1860
+ const mockSvc = {
1861
+ transmitDtmf: jest
1862
+ .fn()
1863
+ .mockImplementationOnce(() => firstGate)
1864
+ .mockResolvedValueOnce(undefined),
1865
+ };
1866
+ const taskData = createBaseData({
1867
+ agentId: 'agent-1',
1868
+ interaction: {
1869
+ participants: {
1870
+ 'agent-1': {id: 'agent-1', ...wxAppParticipant},
1871
+ },
1872
+ } as any,
1873
+ });
1874
+ const voice = new Voice(dummyContact, taskData, {
1875
+ enableWxBetterTogether: true,
1876
+ answerCallOnWebexService: mockSvc as any,
1877
+ });
1878
+ primeConnectedState(voice, taskData);
1879
+ const metricsManager = MetricsManager.getInstance();
1880
+ const trackEventSpy = jest.spyOn(metricsManager, 'trackEvent');
1881
+
1882
+ const firstDtmf = voice.transmitDtmf({dtmf: '1'});
1883
+ const secondDtmf = voice.transmitDtmf({dtmf: '2'});
1884
+
1885
+ resolveFirst!();
1886
+ await Promise.all([firstDtmf, secondDtmf]);
1887
+
1888
+ const successCalls = trackEventSpy.mock.calls.filter(
1889
+ ([eventName]) => eventName === METRIC_EVENT_NAMES.WXAPP_TASK_DTMF_SUCCESS
1890
+ );
1891
+ expect(successCalls).toHaveLength(2);
1892
+ successCalls.forEach(([, payload]) => {
1893
+ expect(payload).toEqual(
1894
+ expect.objectContaining({
1895
+ duration_ms: expect.any(Number),
1896
+ })
1897
+ );
1898
+ });
1899
+
1900
+ trackEventSpy.mockRestore();
1901
+ });
1522
1902
  });
1523
1903
  });
@@ -8,7 +8,10 @@ import {
8
8
  toggleMuteOnWebex,
9
9
  transmitDtmfOnWebex,
10
10
  runWxAppAccept,
11
+ runWxAppOutdialDecline,
11
12
  runWxAppReject,
13
+ runWxAppToggleMute,
14
+ runWxAppTransmitDtmf,
12
15
  mapWxAppVoiceError,
13
16
  WxAppVoiceDeps,
14
17
  WxAppVoiceLifecycle,
@@ -17,6 +20,8 @@ import {METHODS} from '../../../../../../src/constants';
17
20
  import {TaskState} from '../../../../../../src/services/task/state-machine';
18
21
  import {TaskData} from '../../../../../../src/services/task/types';
19
22
  import AnswerCallOnWebexService from '../../../../../../src/services/AnswerCallOnWebexService';
23
+ import MetricsManager from '../../../../../../src/metrics/MetricsManager';
24
+ import {METRIC_EVENT_NAMES} from '../../../../../../src/metrics/constants';
20
25
 
21
26
  const WX_APP_DEVICE = {
22
27
  deviceType: 'wxApp',
@@ -53,6 +58,13 @@ function makeMockService(): jest.Mocked<AnswerCallOnWebexService> {
53
58
  } as unknown as jest.Mocked<AnswerCallOnWebexService>;
54
59
  }
55
60
 
61
+ function makeMockMetricsManager(): jest.Mocked<Pick<MetricsManager, 'timeEvent' | 'trackEvent'>> {
62
+ return {
63
+ timeEvent: jest.fn(),
64
+ trackEvent: jest.fn(),
65
+ };
66
+ }
67
+
56
68
  function makeDeps(overrides: Partial<WxAppVoiceDeps> = {}): WxAppVoiceDeps {
57
69
  let muted = false;
58
70
 
@@ -60,6 +72,7 @@ function makeDeps(overrides: Partial<WxAppVoiceDeps> = {}): WxAppVoiceDeps {
60
72
  enableWxBetterTogether: true,
61
73
  answerCallOnWebexService: makeMockService(),
62
74
  agentId: 'agent-1',
75
+ metricsManager: makeMockMetricsManager() as unknown as MetricsManager,
63
76
  getTaskData: () => makeTaskData(),
64
77
  getTaskState: () => TaskState.OFFERED,
65
78
  getWxAppMuted: () => muted,
@@ -412,6 +425,15 @@ describe('runWxAppAccept', () => {
412
425
 
413
426
  await runWxAppAccept(deps, lifecycle);
414
427
 
428
+ expect(deps.metricsManager.timeEvent).toHaveBeenCalledWith([
429
+ METRIC_EVENT_NAMES.WXAPP_TASK_ACCEPT_SUCCESS,
430
+ METRIC_EVENT_NAMES.WXAPP_TASK_ACCEPT_FAILED,
431
+ ]);
432
+ expect(deps.metricsManager.trackEvent).toHaveBeenCalledWith(
433
+ METRIC_EVENT_NAMES.WXAPP_TASK_ACCEPT_SUCCESS,
434
+ {taskId: 'interaction-1'},
435
+ ['operational', 'behavioral']
436
+ );
415
437
  expect(lifecycle.setWxAppAcceptInFlight).toHaveBeenNthCalledWith(1, true);
416
438
  expect(lifecycle.setWxAppAnswerPending).toHaveBeenCalledWith(true);
417
439
  expect(deps.answerCallOnWebexService!.answerCall).toHaveBeenCalled();
@@ -422,12 +444,20 @@ describe('runWxAppAccept', () => {
422
444
 
423
445
  it('clears pending and maps error when answer fails', async () => {
424
446
  const deps = makeDeps();
425
- const error = new Error('answer failed');
447
+ const error = Object.assign(new Error('answer failed'), {trackingId: 'track-1'});
426
448
  deps.answerCallOnWebexService!.answerCall = jest.fn().mockRejectedValue(error);
427
449
  const lifecycle = makeLifecycle();
428
450
 
429
451
  await expect(runWxAppAccept(deps, lifecycle)).rejects.toThrow('answer failed');
430
452
 
453
+ expect(deps.metricsManager.trackEvent).toHaveBeenCalledWith(
454
+ METRIC_EVENT_NAMES.WXAPP_TASK_ACCEPT_FAILED,
455
+ expect.objectContaining({
456
+ taskId: 'interaction-1',
457
+ trackingId: 'track-1',
458
+ }),
459
+ ['operational', 'behavioral']
460
+ );
431
461
  expect(lifecycle.setWxAppAnswerPending).toHaveBeenCalledWith(false);
432
462
  expect(lifecycle.mapWxAppVoiceError).toHaveBeenCalledWith(error, METHODS.ACCEPT);
433
463
  expect(lifecycle.setWxAppAcceptInFlight).toHaveBeenLastCalledWith(false);
@@ -435,6 +465,19 @@ describe('runWxAppAccept', () => {
435
465
  });
436
466
 
437
467
  describe('runWxAppReject', () => {
468
+ it('tracks wxApp decline success metric', async () => {
469
+ const deps = makeDeps();
470
+ const lifecycle = makeLifecycle();
471
+
472
+ await runWxAppReject(deps, lifecycle);
473
+
474
+ expect(deps.metricsManager.trackEvent).toHaveBeenCalledWith(
475
+ METRIC_EVENT_NAMES.WXAPP_TASK_DECLINE_SUCCESS,
476
+ {taskId: 'interaction-1'},
477
+ ['operational', 'behavioral']
478
+ );
479
+ });
480
+
438
481
  it('maps error when reject fails', async () => {
439
482
  const deps = makeDeps();
440
483
  const error = new Error('reject failed');
@@ -443,10 +486,81 @@ describe('runWxAppReject', () => {
443
486
 
444
487
  await expect(runWxAppReject(deps, lifecycle)).rejects.toThrow('reject failed');
445
488
 
489
+ expect(deps.metricsManager.trackEvent).toHaveBeenCalledWith(
490
+ METRIC_EVENT_NAMES.WXAPP_TASK_DECLINE_FAILED,
491
+ expect.objectContaining({taskId: 'interaction-1'}),
492
+ ['operational', 'behavioral']
493
+ );
446
494
  expect(lifecycle.mapWxAppVoiceError).toHaveBeenCalledWith(error, METHODS.REJECT);
447
495
  });
448
496
  });
449
497
 
498
+ describe('runWxAppOutdialDecline', () => {
499
+ it('tracks wxApp decline success metric for outdial cancel', async () => {
500
+ const deps = makeDeps();
501
+ const cancelResult = {type: 'RoutingMessage', trackingId: 'track-1', data: {}};
502
+
503
+ const result = await runWxAppOutdialDecline(deps, async () => cancelResult);
504
+
505
+ expect(result).toBe(cancelResult);
506
+ expect(deps.metricsManager.timeEvent).toHaveBeenCalledWith([
507
+ METRIC_EVENT_NAMES.WXAPP_TASK_DECLINE_SUCCESS,
508
+ METRIC_EVENT_NAMES.WXAPP_TASK_DECLINE_FAILED,
509
+ ]);
510
+ expect(deps.metricsManager.trackEvent).toHaveBeenCalledWith(
511
+ METRIC_EVENT_NAMES.WXAPP_TASK_DECLINE_SUCCESS,
512
+ {taskId: 'interaction-1'},
513
+ ['operational', 'behavioral']
514
+ );
515
+ });
516
+
517
+ it('tracks wxApp decline failed metric and rethrows when cancel fails', async () => {
518
+ const deps = makeDeps();
519
+ const error = new Error('cancel failed');
520
+
521
+ await expect(
522
+ runWxAppOutdialDecline(deps, async () => {
523
+ throw error;
524
+ })
525
+ ).rejects.toThrow('cancel failed');
526
+
527
+ expect(deps.metricsManager.trackEvent).toHaveBeenCalledWith(
528
+ METRIC_EVENT_NAMES.WXAPP_TASK_DECLINE_FAILED,
529
+ expect.objectContaining({taskId: 'interaction-1'}),
530
+ ['operational', 'behavioral']
531
+ );
532
+ });
533
+
534
+ it('preserves AQM correlation fields when cancel fails with error.details', async () => {
535
+ const deps = makeDeps();
536
+ const error = Object.assign(new Error('AQM failed'), {
537
+ details: {
538
+ trackingId: 'aqm-track-1',
539
+ orgId: 'org-1',
540
+ type: 'Service.aqm.task.cancel',
541
+ data: {reason: 'TASK_NOT_FOUND', reasonCode: 404, agentId: 'agent-1'},
542
+ },
543
+ });
544
+
545
+ await expect(
546
+ runWxAppOutdialDecline(deps, async () => {
547
+ throw error;
548
+ })
549
+ ).rejects.toThrow('AQM failed');
550
+
551
+ expect(deps.metricsManager.trackEvent).toHaveBeenCalledWith(
552
+ METRIC_EVENT_NAMES.WXAPP_TASK_DECLINE_FAILED,
553
+ expect.objectContaining({
554
+ taskId: 'interaction-1',
555
+ trackingId: 'aqm-track-1',
556
+ failureReason: 'TASK_NOT_FOUND',
557
+ reasonCode: 404,
558
+ }),
559
+ ['operational', 'behavioral']
560
+ );
561
+ });
562
+ });
563
+
450
564
  describe('mapWxAppVoiceError', () => {
451
565
  it('rethrows normalized wxApp telephony errors without remapping', () => {
452
566
  const normalized = Object.assign(new Error('TELEPHONY_ERROR'), {
@@ -457,3 +571,33 @@ describe('mapWxAppVoiceError', () => {
457
571
  expect(() => mapWxAppVoiceError(normalized, 'accept', 'cc')).toThrow(normalized);
458
572
  });
459
573
  });
574
+
575
+ describe('runWxAppToggleMute metrics', () => {
576
+ it('tracks mute success metric', async () => {
577
+ const deps = makeDeps({getTaskState: () => TaskState.CONNECTED});
578
+ const lifecycle = makeLifecycle();
579
+
580
+ await runWxAppToggleMute(deps, lifecycle);
581
+
582
+ expect(deps.metricsManager.trackEvent).toHaveBeenCalledWith(
583
+ METRIC_EVENT_NAMES.WXAPP_TASK_MUTE_SUCCESS,
584
+ expect.objectContaining({taskId: 'interaction-1', targetMuted: true}),
585
+ ['operational', 'behavioral']
586
+ );
587
+ });
588
+ });
589
+
590
+ describe('runWxAppTransmitDtmf metrics', () => {
591
+ it('tracks dtmf success metric with digit length only', async () => {
592
+ const deps = makeDeps({getTaskState: () => TaskState.CONNECTED});
593
+ const lifecycle = makeLifecycle();
594
+
595
+ await runWxAppTransmitDtmf(deps, lifecycle, {dtmf: '123'});
596
+
597
+ expect(deps.metricsManager.trackEvent).toHaveBeenCalledWith(
598
+ METRIC_EVENT_NAMES.WXAPP_TASK_DTMF_SUCCESS,
599
+ {taskId: 'interaction-1', dtmfLength: 3},
600
+ ['operational', 'behavioral']
601
+ );
602
+ });
603
+ });