@webex/contact-center 3.12.0-llmrefactor.7 → 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.
- package/.sdd/manifest.json +3 -2
- package/ai-docs/CONTRACTS.md +3 -2
- package/dist/cc.js +108 -6
- package/dist/cc.js.map +1 -1
- package/dist/metrics/MetricsManager.js +16 -0
- package/dist/metrics/MetricsManager.js.map +1 -1
- package/dist/metrics/behavioral-events.js +91 -0
- package/dist/metrics/behavioral-events.js.map +1 -1
- package/dist/metrics/constants.js +16 -0
- package/dist/metrics/constants.js.map +1 -1
- package/dist/services/AnswerCallOnWebexService.js +34 -6
- package/dist/services/AnswerCallOnWebexService.js.map +1 -1
- package/dist/services/WebexCrossClientService.js +54 -3
- package/dist/services/WebexCrossClientService.js.map +1 -1
- package/dist/services/WxAppTelephonyMercurySync.js +21 -6
- package/dist/services/WxAppTelephonyMercurySync.js.map +1 -1
- package/dist/services/task/state-machine/TaskStateMachine.js +0 -17
- package/dist/services/task/state-machine/TaskStateMachine.js.map +1 -1
- package/dist/services/task/state-machine/guards.js +7 -37
- package/dist/services/task/state-machine/guards.js.map +1 -1
- package/dist/services/task/voice/Voice.js +81 -14
- package/dist/services/task/voice/Voice.js.map +1 -1
- package/dist/services/task/voice/wxAppVoiceMethods.js +154 -1
- package/dist/services/task/voice/wxAppVoiceMethods.js.map +1 -1
- package/dist/services/wxAppDiagnosticLogging.js +122 -0
- package/dist/services/wxAppDiagnosticLogging.js.map +1 -0
- package/dist/types/cc.d.ts +1 -0
- package/dist/types/metrics/MetricsManager.d.ts +6 -0
- package/dist/types/metrics/constants.d.ts +15 -0
- package/dist/types/services/AnswerCallOnWebexService.d.ts +1 -0
- package/dist/types/services/WebexCrossClientService.d.ts +4 -0
- package/dist/types/services/task/state-machine/TaskStateMachine.d.ts +0 -42
- package/dist/types/services/task/state-machine/guards.d.ts +4 -4
- package/dist/types/services/task/voice/Voice.d.ts +5 -1
- package/dist/types/services/task/voice/wxAppVoiceMethods.d.ts +3 -0
- package/dist/types/services/wxAppDiagnosticLogging.d.ts +51 -0
- package/dist/webex.js +1 -1
- package/package.json +8 -8
- package/src/cc.ts +155 -7
- package/src/metrics/MetricsManager.ts +16 -0
- package/src/metrics/ai-docs/metrics-spec.md +71 -9
- package/src/metrics/behavioral-events.ts +92 -0
- package/src/metrics/constants.ts +18 -0
- package/src/services/AnswerCallOnWebexService.ts +41 -8
- package/src/services/WebexCrossClientService.ts +85 -4
- package/src/services/WxAppTelephonyMercurySync.ts +31 -4
- package/src/services/agent/ai-docs/agent-spec.md +1 -0
- package/src/services/task/ai-docs/task-spec.md +17 -16
- package/src/services/task/state-machine/TaskStateMachine.ts +0 -20
- package/src/services/task/state-machine/ai-docs/AGENTS.md +2 -4
- package/src/services/task/state-machine/ai-docs/ARCHITECTURE.md +8 -9
- package/src/services/task/state-machine/ai-docs/task-state-machine-spec.md +5 -7
- package/src/services/task/state-machine/guards.ts +7 -63
- package/src/services/task/voice/Voice.ts +115 -26
- package/src/services/task/voice/wxAppVoiceMethods.ts +211 -2
- package/src/services/wxAppDiagnosticLogging.ts +174 -0
- package/test/unit/spec/cc.ts +251 -22
- package/test/unit/spec/metrics/MetricsManager.ts +25 -0
- package/test/unit/spec/metrics/behavioral-events.ts +42 -0
- package/test/unit/spec/services/AnswerCallOnWebexService.ts +41 -0
- package/test/unit/spec/services/WebexCrossClientService.ts +207 -1
- package/test/unit/spec/services/WxAppTelephonyMercurySync.ts +23 -0
- package/test/unit/spec/services/task/state-machine/TaskStateMachine.ts +72 -46
- package/test/unit/spec/services/task/state-machine/guards.ts +6 -6
- package/test/unit/spec/services/task/voice/Voice.ts +380 -0
- package/test/unit/spec/services/task/voice/wxAppVoiceMethods.ts +145 -1
- package/test/unit/spec/services/wxAppDiagnosticLogging.ts +143 -0
- package/umd/contact-center.min.js +2 -2
- 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
|
+
});
|