@webex/contact-center 3.12.0-task-refactor.4 → 3.12.0-task-refactor.6

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 (35) hide show
  1. package/dist/services/task/TaskManager.js +1 -0
  2. package/dist/services/task/TaskManager.js.map +1 -1
  3. package/dist/services/task/TaskUtils.js +8 -6
  4. package/dist/services/task/TaskUtils.js.map +1 -1
  5. package/dist/services/task/state-machine/TaskStateMachine.js +77 -14
  6. package/dist/services/task/state-machine/TaskStateMachine.js.map +1 -1
  7. package/dist/services/task/state-machine/actions.js +85 -13
  8. package/dist/services/task/state-machine/actions.js.map +1 -1
  9. package/dist/services/task/state-machine/guards.js +35 -0
  10. package/dist/services/task/state-machine/guards.js.map +1 -1
  11. package/dist/services/task/state-machine/uiControlsComputer.js +76 -10
  12. package/dist/services/task/state-machine/uiControlsComputer.js.map +1 -1
  13. package/dist/services/task/voice/Voice.js +10 -4
  14. package/dist/services/task/voice/Voice.js.map +1 -1
  15. package/dist/types/services/task/state-machine/TaskStateMachine.d.ts +68 -8
  16. package/dist/types/services/task/state-machine/guards.d.ts +5 -0
  17. package/dist/types/services/task/voice/Voice.d.ts +18 -17
  18. package/dist/webex.js +1 -1
  19. package/package.json +1 -1
  20. package/src/services/task/TaskManager.ts +1 -1
  21. package/src/services/task/TaskUtils.ts +8 -6
  22. package/src/services/task/state-machine/TaskStateMachine.ts +101 -16
  23. package/src/services/task/state-machine/actions.ts +148 -24
  24. package/src/services/task/state-machine/guards.ts +46 -0
  25. package/src/services/task/state-machine/uiControlsComputer.ts +158 -15
  26. package/src/services/task/voice/Voice.ts +12 -5
  27. package/test/unit/spec/services/WebCallingService.ts +7 -1
  28. package/test/unit/spec/services/task/TaskManager.ts +26 -0
  29. package/test/unit/spec/services/task/TaskUtils.ts +16 -0
  30. package/test/unit/spec/services/task/state-machine/TaskStateMachine.ts +573 -0
  31. package/test/unit/spec/services/task/state-machine/guards.ts +88 -0
  32. package/test/unit/spec/services/task/state-machine/uiControlsComputer.ts +1023 -46
  33. package/test/unit/spec/services/task/voice/Voice.ts +44 -0
  34. package/umd/contact-center.min.js +2 -2
  35. package/umd/contact-center.min.js.map +1 -1
@@ -18,6 +18,7 @@ describe('Task state machine', () => {
18
18
  const startMachine = () => {
19
19
  const actor = createActor(createTaskStateMachine(createConfig()));
20
20
  actor.start();
21
+
21
22
  return actor;
22
23
  };
23
24
 
@@ -124,6 +125,28 @@ describe('Task state machine', () => {
124
125
  service.send({type: TaskEvent.RESUME_RECORDING});
125
126
  expect(service.getSnapshot().context.recordingInProgress).toBe(true);
126
127
  });
128
+
129
+ it('toggles recording state while task is held', () => {
130
+ const service = startMachine();
131
+ const taskData = createTaskData({
132
+ interaction: {
133
+ callProcessingDetails: {recordInProgress: true},
134
+ } as any,
135
+ });
136
+
137
+ service.send({type: TaskEvent.TASK_INCOMING, taskData});
138
+ service.send({type: TaskEvent.ASSIGN, taskData});
139
+ service.send({type: TaskEvent.HOLD_INITIATED, mediaResourceId: taskData.mediaResourceId});
140
+ service.send({type: TaskEvent.HOLD_SUCCESS, mediaResourceId: taskData.mediaResourceId});
141
+ expect(service.getSnapshot().value).toBe(TaskState.HELD);
142
+ expect(service.getSnapshot().context.recordingInProgress).toBe(true);
143
+
144
+ service.send({type: TaskEvent.PAUSE_RECORDING});
145
+ expect(service.getSnapshot().context.recordingInProgress).toBe(false);
146
+
147
+ service.send({type: TaskEvent.RESUME_RECORDING});
148
+ expect(service.getSnapshot().context.recordingInProgress).toBe(true);
149
+ });
127
150
  });
128
151
 
129
152
  describe('wrap-up and completion flow', () => {
@@ -160,6 +183,95 @@ describe('Task state machine', () => {
160
183
  });
161
184
 
162
185
  describe('consult and conference flows', () => {
186
+ const createSingleAgentConferenceTaskData = (interactionState: string, isHold = false) =>
187
+ createTaskData({
188
+ interactionId: 'interaction-1',
189
+ mediaResourceId: 'interaction-1',
190
+ interaction: {
191
+ state: interactionState,
192
+ mainInteractionId: 'interaction-1',
193
+ interactionId: 'interaction-1',
194
+ participants: {
195
+ 'agent-1': {id: 'agent-1', pType: 'Agent', hasLeft: false},
196
+ 'customer-1': {id: 'customer-1', pType: 'Customer', hasLeft: false},
197
+ },
198
+ media: {
199
+ 'interaction-1': {
200
+ mediaResourceId: 'interaction-1',
201
+ mType: 'mainCall',
202
+ participants: ['agent-1', 'customer-1'],
203
+ isHold,
204
+ },
205
+ },
206
+ } as any,
207
+ });
208
+
209
+ const createConferenceConsultTaskData = ({
210
+ interactionState,
211
+ includeSecondAgent,
212
+ conferenceHoldParticipant,
213
+ isMainHeld = false,
214
+ }: {
215
+ interactionState: string;
216
+ includeSecondAgent: boolean;
217
+ conferenceHoldParticipant: boolean | string;
218
+ isMainHeld?: boolean;
219
+ }) =>
220
+ createTaskData({
221
+ interactionId: 'interaction-1',
222
+ mediaResourceId: 'interaction-1',
223
+ consultMediaResourceId: 'consult-media-1',
224
+ interaction: {
225
+ state: interactionState,
226
+ mainInteractionId: 'interaction-1',
227
+ interactionId: 'interaction-1',
228
+ callProcessingDetails: {
229
+ conferenceHoldParticipant,
230
+ },
231
+ participants: {
232
+ 'agent-1': {
233
+ id: 'agent-1',
234
+ pType: 'Agent',
235
+ hasLeft: false,
236
+ consultState: 'consulting',
237
+ },
238
+ ...(includeSecondAgent
239
+ ? {
240
+ 'agent-2': {
241
+ id: 'agent-2',
242
+ pType: 'Agent',
243
+ hasLeft: false,
244
+ },
245
+ }
246
+ : {}),
247
+ 'agent-3': {
248
+ id: 'agent-3',
249
+ pType: 'Agent',
250
+ hasLeft: false,
251
+ isConsulted: true,
252
+ consultState: 'consulting',
253
+ },
254
+ 'customer-1': {id: 'customer-1', pType: 'Customer', hasLeft: false},
255
+ },
256
+ media: {
257
+ 'interaction-1': {
258
+ mediaResourceId: 'interaction-1',
259
+ mType: 'mainCall',
260
+ participants: includeSecondAgent
261
+ ? ['agent-1', 'agent-2', 'customer-1']
262
+ : ['agent-1', 'customer-1'],
263
+ isHold: isMainHeld,
264
+ },
265
+ 'consult-media-1': {
266
+ mediaResourceId: 'consult-media-1',
267
+ mType: 'consult',
268
+ participants: ['agent-1', 'agent-3'],
269
+ isHold: false,
270
+ },
271
+ },
272
+ } as any,
273
+ });
274
+
163
275
  it('boots from IDLE to CONSULTING on CONSULTING_ACTIVE for split-leg ordering', () => {
164
276
  const service = startMachine();
165
277
  const taskData = createTaskData({
@@ -181,6 +293,154 @@ describe('Task state machine', () => {
181
293
  expect(service.getSnapshot().context.consultDestinationAgentJoined).toBe(true);
182
294
  });
183
295
 
296
+ it('hydrates to CONSULTING when top-level state is conference but self consultState is consulting', () => {
297
+ const service = startMachine();
298
+ const taskData = createTaskData({
299
+ isConsulted: false,
300
+ interaction: {
301
+ state: 'conference',
302
+ mainInteractionId: 'interaction-1',
303
+ interactionId: 'interaction-1',
304
+ participants: {
305
+ 'agent-1': {
306
+ id: 'agent-1',
307
+ pType: 'Agent',
308
+ hasLeft: false,
309
+ consultState: 'consulting',
310
+ isConsulted: false,
311
+ },
312
+ 'agent-2': {
313
+ id: 'agent-2',
314
+ pType: 'Agent',
315
+ hasLeft: false,
316
+ consultState: 'consulting',
317
+ isConsulted: true,
318
+ },
319
+ 'customer-1': {id: 'customer-1', pType: 'Customer', hasLeft: false},
320
+ },
321
+ media: {
322
+ 'interaction-1': {
323
+ mediaResourceId: 'interaction-1',
324
+ mType: 'mainCall',
325
+ participants: ['agent-1', 'customer-1'],
326
+ isHold: true,
327
+ },
328
+ 'consult-media-1': {
329
+ mediaResourceId: 'consult-media-1',
330
+ mType: 'consult',
331
+ participants: ['agent-1', 'agent-2'],
332
+ isHold: false,
333
+ },
334
+ },
335
+ } as any,
336
+ });
337
+
338
+ service.send({type: TaskEvent.HYDRATE, taskData});
339
+
340
+ const snapshot = service.getSnapshot();
341
+ expect(snapshot.value).toBe(TaskState.CONSULTING);
342
+ expect(snapshot.context.consultInitiator).toBe(true);
343
+ expect(snapshot.context.consultFromConference).toBe(true);
344
+ });
345
+
346
+ it('hydrates consulted agent to CONSULTING when self consultState is consulting and main leg is held', () => {
347
+ const service = startMachine();
348
+ const taskData = createTaskData({
349
+ agentId: 'agent-2',
350
+ isConsulted: true,
351
+ interaction: {
352
+ state: 'conference',
353
+ mainInteractionId: 'interaction-1',
354
+ interactionId: 'interaction-1',
355
+ participants: {
356
+ 'agent-1': {
357
+ id: 'agent-1',
358
+ pType: 'Agent',
359
+ hasLeft: false,
360
+ consultState: 'consulting',
361
+ isConsulted: false,
362
+ },
363
+ 'agent-2': {
364
+ id: 'agent-2',
365
+ pType: 'Agent',
366
+ hasLeft: false,
367
+ consultState: 'consulting',
368
+ isConsulted: true,
369
+ },
370
+ 'customer-1': {id: 'customer-1', pType: 'Customer', hasLeft: false},
371
+ },
372
+ media: {
373
+ 'interaction-1': {
374
+ mediaResourceId: 'interaction-1',
375
+ mType: 'mainCall',
376
+ participants: ['agent-1', 'customer-1'],
377
+ isHold: true,
378
+ },
379
+ 'consult-media-1': {
380
+ mediaResourceId: 'consult-media-1',
381
+ mType: 'consult',
382
+ participants: ['agent-1', 'agent-2'],
383
+ isHold: false,
384
+ },
385
+ },
386
+ } as any,
387
+ });
388
+
389
+ service.send({type: TaskEvent.HYDRATE, taskData});
390
+
391
+ expect(service.getSnapshot().value).toBe(TaskState.CONSULTING);
392
+ });
393
+
394
+ it('hydrates to CONSULTING when consult is pending (self consultState is consultInitiated)', () => {
395
+ const service = startMachine();
396
+ const taskData = createTaskData({
397
+ isConsulted: false,
398
+ interaction: {
399
+ state: 'conference',
400
+ mainInteractionId: 'interaction-1',
401
+ interactionId: 'interaction-1',
402
+ participants: {
403
+ 'agent-1': {
404
+ id: 'agent-1',
405
+ pType: 'Agent',
406
+ hasLeft: false,
407
+ consultState: 'consultInitiated',
408
+ isConsulted: false,
409
+ },
410
+ 'agent-2': {
411
+ id: 'agent-2',
412
+ pType: 'Agent',
413
+ hasLeft: false,
414
+ consultState: 'consultReserved',
415
+ isConsulted: true,
416
+ },
417
+ 'customer-1': {id: 'customer-1', pType: 'Customer', hasLeft: false},
418
+ },
419
+ media: {
420
+ 'interaction-1': {
421
+ mediaResourceId: 'interaction-1',
422
+ mType: 'mainCall',
423
+ participants: ['agent-1', 'customer-1'],
424
+ isHold: true,
425
+ },
426
+ 'consult-media-1': {
427
+ mediaResourceId: 'consult-media-1',
428
+ mType: 'consult',
429
+ participants: ['agent-1', 'agent-2'],
430
+ isHold: false,
431
+ },
432
+ },
433
+ } as any,
434
+ });
435
+
436
+ service.send({type: TaskEvent.HYDRATE, taskData});
437
+
438
+ const snapshot = service.getSnapshot();
439
+ expect(snapshot.value).toBe(TaskState.CONSULTING);
440
+ expect(snapshot.context.consultInitiator).toBe(true);
441
+ expect(snapshot.context.consultFromConference).toBe(true);
442
+ });
443
+
184
444
  it('tracks consult destination, agent join, and clears on consult end', () => {
185
445
  const service = startMachine();
186
446
  const taskData = createTaskData();
@@ -211,6 +471,137 @@ describe('Task state machine', () => {
211
471
  expect(snapshotAfterEnd.context.consultDestinationAgentJoined).toBe(false);
212
472
  });
213
473
 
474
+ it('keeps consultDestinationAgentJoined false while consultee is only reserved, then sets true on actual join', () => {
475
+ const service = startMachine();
476
+ const pendingTaskData = createTaskData({
477
+ isConsulted: false,
478
+ interaction: {
479
+ state: 'conference',
480
+ mainInteractionId: 'interaction-1',
481
+ interactionId: 'interaction-1',
482
+ participants: {
483
+ 'agent-1': {
484
+ id: 'agent-1',
485
+ pType: 'Agent',
486
+ hasLeft: false,
487
+ consultState: 'consultInitiated',
488
+ isConsulted: false,
489
+ },
490
+ 'agent-2': {
491
+ id: 'agent-2',
492
+ pType: 'Agent',
493
+ hasLeft: false,
494
+ hasJoined: false,
495
+ consultState: 'consultReserved',
496
+ isConsulted: true,
497
+ },
498
+ 'customer-1': {id: 'customer-1', pType: 'Customer', hasLeft: false},
499
+ },
500
+ media: {
501
+ 'interaction-1': {
502
+ mediaResourceId: 'interaction-1',
503
+ mType: 'mainCall',
504
+ participants: ['agent-1', 'customer-1'],
505
+ isHold: true,
506
+ },
507
+ 'consult-media-1': {
508
+ mediaResourceId: 'consult-media-1',
509
+ mType: 'consult',
510
+ participants: ['agent-1', 'agent-2'],
511
+ isHold: false,
512
+ },
513
+ },
514
+ } as any,
515
+ });
516
+
517
+ service.send({type: TaskEvent.HYDRATE, taskData: pendingTaskData});
518
+
519
+ expect(service.getSnapshot().value).toBe(TaskState.CONSULTING);
520
+ expect(service.getSnapshot().context.consultDestinationAgentJoined).toBe(false);
521
+
522
+ const joinedTaskData = {
523
+ ...pendingTaskData,
524
+ interaction: {
525
+ ...pendingTaskData.interaction,
526
+ participants: {
527
+ ...pendingTaskData.interaction?.participants,
528
+ 'agent-2': {
529
+ ...pendingTaskData.interaction?.participants?.['agent-2'],
530
+ hasJoined: true,
531
+ consultState: 'consulting',
532
+ },
533
+ },
534
+ },
535
+ } as any;
536
+
537
+ service.send({type: TaskEvent.CONTACT_UPDATED, taskData: joinedTaskData});
538
+
539
+ expect(service.getSnapshot().context.consultDestinationAgentJoined).toBe(true);
540
+ });
541
+
542
+ it('hydrates conference consult context flags for active consult leg controls', () => {
543
+ const service = startMachine();
544
+ const taskData = createTaskData({
545
+ type: 'AgentContactUnheld' as any,
546
+ isConsulted: false,
547
+ consultingAgentId: 'agent-1',
548
+ consultMediaResourceId: 'consult-media-1',
549
+ interaction: {
550
+ state: 'conference',
551
+ mainInteractionId: 'interaction-1',
552
+ interactionId: 'interaction-1',
553
+ participants: {
554
+ 'agent-1': {
555
+ id: 'agent-1',
556
+ pType: 'Agent',
557
+ hasLeft: false,
558
+ consultState: 'consulting',
559
+ isConsulted: false,
560
+ },
561
+ 'agent-2': {
562
+ id: 'agent-2',
563
+ pType: 'Agent',
564
+ hasLeft: false,
565
+ hasJoined: true,
566
+ consultState: 'consulting',
567
+ isConsulted: true,
568
+ },
569
+ 'agent-3': {
570
+ id: 'agent-3',
571
+ pType: 'Agent',
572
+ hasLeft: false,
573
+ consultState: 'conferencing',
574
+ isConsulted: false,
575
+ },
576
+ 'customer-1': {id: 'customer-1', pType: 'Customer', hasLeft: false},
577
+ },
578
+ media: {
579
+ 'interaction-1': {
580
+ mediaResourceId: 'interaction-1',
581
+ mType: 'mainCall',
582
+ participants: ['agent-1', 'agent-3', 'customer-1'],
583
+ isHold: false,
584
+ },
585
+ 'consult-media-1': {
586
+ mediaResourceId: 'consult-media-1',
587
+ mType: 'consult',
588
+ participants: ['agent-1', 'agent-2'],
589
+ isHold: false,
590
+ },
591
+ },
592
+ } as any,
593
+ });
594
+
595
+ service.send({type: TaskEvent.HYDRATE, taskData});
596
+
597
+ const snapshot = service.getSnapshot();
598
+ expect(snapshot.value).toBe(TaskState.CONSULTING);
599
+ expect(snapshot.context.consultInitiator).toBe(true);
600
+ expect(snapshot.context.consultFromConference).toBe(true);
601
+ expect(snapshot.context.consultDestinationAgentJoined).toBe(true);
602
+ expect(snapshot.context.consultCallHeld).toBe(false);
603
+ });
604
+
214
605
  it('returns to connected when consult ends after switching back to the main leg', () => {
215
606
  const service = startMachine();
216
607
  const taskData = createTaskData();
@@ -234,6 +625,89 @@ describe('Task state machine', () => {
234
625
  expect(snapshotAfterEnd.context.consultCallHeld).toBe(false);
235
626
  });
236
627
 
628
+ it('downgrades to HELD on CONSULT_END when conference has downgraded and conferenceHoldParticipant is true', () => {
629
+ const service = startMachine();
630
+ const conferenceTaskData = createConferenceConsultTaskData({
631
+ interactionState: 'conference',
632
+ includeSecondAgent: true,
633
+ conferenceHoldParticipant: false,
634
+ });
635
+ const downgradedHeldTaskData = createConferenceConsultTaskData({
636
+ interactionState: 'hold',
637
+ includeSecondAgent: false,
638
+ conferenceHoldParticipant: true,
639
+ isMainHeld: true,
640
+ });
641
+
642
+ service.send({type: TaskEvent.TASK_INCOMING, taskData: conferenceTaskData});
643
+ service.send({type: TaskEvent.ASSIGN, taskData: conferenceTaskData});
644
+ service.send({type: TaskEvent.CONFERENCE_START, taskData: conferenceTaskData});
645
+ expect(service.getSnapshot().value).toBe(TaskState.CONFERENCING);
646
+
647
+ service.send({type: TaskEvent.CONSULT, destination: 'agent-3', destinationType: 'agent'});
648
+ expect(service.getSnapshot().value).toBe(TaskState.CONSULT_INITIATING);
649
+ service.send({type: TaskEvent.CONSULT_SUCCESS, taskData: conferenceTaskData});
650
+ expect(service.getSnapshot().value).toBe(TaskState.CONSULTING);
651
+
652
+ service.send({type: TaskEvent.CONSULT_END, taskData: downgradedHeldTaskData});
653
+ expect(service.getSnapshot().value).toBe(TaskState.HELD);
654
+ });
655
+
656
+ it('downgrades to CONNECTED on CONSULT_END when conference has downgraded and conferenceHoldParticipant is false', () => {
657
+ const service = startMachine();
658
+ const conferenceTaskData = createConferenceConsultTaskData({
659
+ interactionState: 'conference',
660
+ includeSecondAgent: true,
661
+ conferenceHoldParticipant: false,
662
+ });
663
+ const downgradedConnectedTaskData = createConferenceConsultTaskData({
664
+ interactionState: 'connected',
665
+ includeSecondAgent: false,
666
+ conferenceHoldParticipant: false,
667
+ isMainHeld: false,
668
+ });
669
+
670
+ service.send({type: TaskEvent.TASK_INCOMING, taskData: conferenceTaskData});
671
+ service.send({type: TaskEvent.ASSIGN, taskData: conferenceTaskData});
672
+ service.send({type: TaskEvent.CONFERENCE_START, taskData: conferenceTaskData});
673
+ expect(service.getSnapshot().value).toBe(TaskState.CONFERENCING);
674
+
675
+ service.send({type: TaskEvent.CONSULT, destination: 'agent-3', destinationType: 'agent'});
676
+ expect(service.getSnapshot().value).toBe(TaskState.CONSULT_INITIATING);
677
+ service.send({type: TaskEvent.CONSULT_SUCCESS, taskData: conferenceTaskData});
678
+ expect(service.getSnapshot().value).toBe(TaskState.CONSULTING);
679
+
680
+ service.send({type: TaskEvent.CONSULT_END, taskData: downgradedConnectedTaskData});
681
+ expect(service.getSnapshot().value).toBe(TaskState.CONNECTED);
682
+ });
683
+
684
+ it('returns to CONFERENCING on CONSULT_END when conference is still active', () => {
685
+ const service = startMachine();
686
+ const conferenceTaskData = createConferenceConsultTaskData({
687
+ interactionState: 'conference',
688
+ includeSecondAgent: true,
689
+ conferenceHoldParticipant: false,
690
+ });
691
+ const stillConferenceTaskData = createConferenceConsultTaskData({
692
+ interactionState: 'conference',
693
+ includeSecondAgent: true,
694
+ conferenceHoldParticipant: false,
695
+ });
696
+
697
+ service.send({type: TaskEvent.TASK_INCOMING, taskData: conferenceTaskData});
698
+ service.send({type: TaskEvent.ASSIGN, taskData: conferenceTaskData});
699
+ service.send({type: TaskEvent.CONFERENCE_START, taskData: conferenceTaskData});
700
+ expect(service.getSnapshot().value).toBe(TaskState.CONFERENCING);
701
+
702
+ service.send({type: TaskEvent.CONSULT, destination: 'agent-3', destinationType: 'agent'});
703
+ expect(service.getSnapshot().value).toBe(TaskState.CONSULT_INITIATING);
704
+ service.send({type: TaskEvent.CONSULT_SUCCESS, taskData: conferenceTaskData});
705
+ expect(service.getSnapshot().value).toBe(TaskState.CONSULTING);
706
+
707
+ service.send({type: TaskEvent.CONSULT_END, taskData: stillConferenceTaskData});
708
+ expect(service.getSnapshot().value).toBe(TaskState.CONFERENCING);
709
+ });
710
+
237
711
  it('transitions to conferencing when merge event is received', () => {
238
712
  const service = startMachine();
239
713
  const taskData = createTaskData({consultingAgentId: 'agent-1'});
@@ -372,6 +846,44 @@ describe('Task state machine', () => {
372
846
  expect(service.getSnapshot().value).toBe(TaskState.TERMINATED);
373
847
  });
374
848
 
849
+ it('downgrades to HELD on HOLD_SUCCESS when no other agents remain on task', () => {
850
+ const service = startMachine();
851
+ const conferenceTaskData = createSingleAgentConferenceTaskData('conference');
852
+ const heldTaskData = createSingleAgentConferenceTaskData('hold', true);
853
+
854
+ service.send({type: TaskEvent.TASK_INCOMING, taskData: conferenceTaskData});
855
+ service.send({type: TaskEvent.ASSIGN, taskData: conferenceTaskData});
856
+ service.send({type: TaskEvent.CONFERENCE_START, taskData: conferenceTaskData});
857
+ expect(service.getSnapshot().value).toBe(TaskState.CONFERENCING);
858
+
859
+ service.send({
860
+ type: TaskEvent.HOLD_SUCCESS,
861
+ mediaResourceId: heldTaskData.mediaResourceId,
862
+ taskData: heldTaskData,
863
+ });
864
+
865
+ expect(service.getSnapshot().value).toBe(TaskState.HELD);
866
+ });
867
+
868
+ it('downgrades to CONNECTED on UNHOLD_SUCCESS when no other agents remain on task', () => {
869
+ const service = startMachine();
870
+ const conferenceTaskData = createSingleAgentConferenceTaskData('conference');
871
+ const connectedTaskData = createSingleAgentConferenceTaskData('connected', false);
872
+
873
+ service.send({type: TaskEvent.TASK_INCOMING, taskData: conferenceTaskData});
874
+ service.send({type: TaskEvent.ASSIGN, taskData: conferenceTaskData});
875
+ service.send({type: TaskEvent.CONFERENCE_START, taskData: conferenceTaskData});
876
+ expect(service.getSnapshot().value).toBe(TaskState.CONFERENCING);
877
+
878
+ service.send({
879
+ type: TaskEvent.UNHOLD_SUCCESS,
880
+ mediaResourceId: connectedTaskData.mediaResourceId,
881
+ taskData: connectedTaskData,
882
+ });
883
+
884
+ expect(service.getSnapshot().value).toBe(TaskState.CONNECTED);
885
+ });
886
+
375
887
  it('returns to CONNECTED when CTQ cancel arrives before queue connects', () => {
376
888
  const service = startMachine();
377
889
  const taskData = createTaskData();
@@ -573,4 +1085,65 @@ describe('Task state machine', () => {
573
1085
  expect(service.getSnapshot().value).toBe(TaskState.TERMINATED);
574
1086
  });
575
1087
  });
1088
+
1089
+ describe('OUTBOUND_FAILED handling', () => {
1090
+ it('transitions from IDLE to TERMINATED on OUTBOUND_FAILED (race condition)', () => {
1091
+ const service = startMachine();
1092
+ expect(service.getSnapshot().value).toBe(TaskState.IDLE);
1093
+
1094
+ const taskData = createTaskData({
1095
+ interaction: {
1096
+ outboundType: 'OUTDIAL',
1097
+ isTerminated: true,
1098
+ } as any,
1099
+ });
1100
+
1101
+ service.send({type: TaskEvent.OUTBOUND_FAILED, taskData, reason: 'CUSTOMER_BUSY'});
1102
+ expect(service.getSnapshot().value).toBe(TaskState.TERMINATED);
1103
+ });
1104
+
1105
+ it('transitions from OFFERED to TERMINATED on OUTBOUND_FAILED without wrapup', () => {
1106
+ const service = startMachine();
1107
+ const offerTaskData = createTaskData({
1108
+ interaction: {
1109
+ outboundType: 'OUTDIAL',
1110
+ } as any,
1111
+ });
1112
+
1113
+ service.send({type: TaskEvent.TASK_INCOMING, taskData: offerTaskData});
1114
+ expect(service.getSnapshot().value).toBe(TaskState.OFFERED);
1115
+
1116
+ const failedTaskData = createTaskData({
1117
+ interaction: {
1118
+ outboundType: 'OUTDIAL',
1119
+ isTerminated: true,
1120
+ } as any,
1121
+ });
1122
+ service.send({type: TaskEvent.OUTBOUND_FAILED, taskData: failedTaskData, reason: 'CUSTOMER_BUSY'});
1123
+ expect(service.getSnapshot().value).toBe(TaskState.TERMINATED);
1124
+ });
1125
+
1126
+ it('transitions from OFFERED to WRAPPING_UP on OUTBOUND_FAILED when wrapup is required', () => {
1127
+ const service = startMachine();
1128
+ const offerTaskData = createTaskData({
1129
+ interaction: {
1130
+ outboundType: 'OUTDIAL',
1131
+ } as any,
1132
+ });
1133
+
1134
+ service.send({type: TaskEvent.TASK_INCOMING, taskData: offerTaskData});
1135
+ expect(service.getSnapshot().value).toBe(TaskState.OFFERED);
1136
+
1137
+ const failedTaskData = createTaskData({
1138
+ agentId: 'agent-1',
1139
+ agentsPendingWrapUp: ['agent-1'],
1140
+ interaction: {
1141
+ outboundType: 'OUTDIAL',
1142
+ isTerminated: true,
1143
+ } as any,
1144
+ });
1145
+ service.send({type: TaskEvent.OUTBOUND_FAILED, taskData: failedTaskData, reason: 'CUSTOMER_BUSY'});
1146
+ expect(service.getSnapshot().value).toBe(TaskState.WRAPPING_UP);
1147
+ });
1148
+ });
576
1149
  });