@webex/contact-center 3.12.0-next.122 → 3.12.0-next.124

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.
@@ -227,6 +227,15 @@ export declare function getTaskStateMachineConfig(uiControlConfig: UIControlConf
227
227
  RESUME_RECORDING: {
228
228
  actions: string[];
229
229
  };
230
+ EXIT_CONFERENCE_SUCCESS: ({
231
+ guard: ({ context, event }: import("./guards").GuardParams) => boolean;
232
+ target: TaskState;
233
+ actions: string[];
234
+ } | {
235
+ target: TaskState;
236
+ actions: string[];
237
+ guard?: undefined;
238
+ })[];
230
239
  };
231
240
  };
232
241
  HOLD_INITIATING: {
@@ -248,6 +257,10 @@ export declare function getTaskStateMachineConfig(uiControlConfig: UIControlConf
248
257
  actions: string[];
249
258
  guard?: undefined;
250
259
  })[];
260
+ TASK_WRAPUP: {
261
+ target: TaskState;
262
+ actions: string[];
263
+ };
251
264
  };
252
265
  };
253
266
  HELD: {
@@ -325,6 +338,15 @@ export declare function getTaskStateMachineConfig(uiControlConfig: UIControlConf
325
338
  actions: string[];
326
339
  guard?: undefined;
327
340
  })[];
341
+ EXIT_CONFERENCE_SUCCESS: ({
342
+ guard: ({ context, event }: import("./guards").GuardParams) => boolean;
343
+ target: TaskState;
344
+ actions: string[];
345
+ } | {
346
+ target: TaskState;
347
+ actions: string[];
348
+ guard?: undefined;
349
+ })[];
328
350
  };
329
351
  };
330
352
  RESUME_INITIATING: {
@@ -352,6 +374,10 @@ export declare function getTaskStateMachineConfig(uiControlConfig: UIControlConf
352
374
  actions: string[];
353
375
  guard?: undefined;
354
376
  })[];
377
+ TASK_WRAPUP: {
378
+ target: TaskState;
379
+ actions: string[];
380
+ };
355
381
  };
356
382
  };
357
383
  CONSULT_INITIATING: {
@@ -415,6 +441,10 @@ export declare function getTaskStateMachineConfig(uiControlConfig: UIControlConf
415
441
  actions: string[];
416
442
  guard?: undefined;
417
443
  })[];
444
+ TASK_WRAPUP: {
445
+ target: TaskState;
446
+ actions: string[];
447
+ };
418
448
  };
419
449
  };
420
450
  CONSULTING: {
@@ -544,6 +574,10 @@ export declare function getTaskStateMachineConfig(uiControlConfig: UIControlConf
544
574
  actions: string[];
545
575
  guard?: undefined;
546
576
  })[];
577
+ TASK_WRAPUP: {
578
+ target: TaskState;
579
+ actions: string[];
580
+ };
547
581
  };
548
582
  };
549
583
  CONFERENCING: {
@@ -660,6 +694,15 @@ export declare function getTaskStateMachineConfig(uiControlConfig: UIControlConf
660
694
  WRAPPING_UP: {
661
695
  entry: string[];
662
696
  on: {
697
+ TASK_WRAPUP: ({
698
+ guard: ({ context }: {
699
+ context: any;
700
+ }) => boolean;
701
+ actions: string[];
702
+ } | {
703
+ actions: string[];
704
+ guard?: undefined;
705
+ })[];
663
706
  WRAPUP_COMPLETE: {
664
707
  target: TaskState;
665
708
  actions: string[];
@@ -904,6 +947,15 @@ export declare function createTaskStateMachine(uiControlConfig: UIControlConfig,
904
947
  RESUME_RECORDING: {
905
948
  actions: string[];
906
949
  };
950
+ EXIT_CONFERENCE_SUCCESS: ({
951
+ guard: ({ context, event }: import("./guards").GuardParams) => boolean;
952
+ target: TaskState;
953
+ actions: string[];
954
+ } | {
955
+ target: TaskState;
956
+ actions: string[];
957
+ guard?: undefined;
958
+ })[];
907
959
  };
908
960
  };
909
961
  HOLD_INITIATING: {
@@ -925,6 +977,10 @@ export declare function createTaskStateMachine(uiControlConfig: UIControlConfig,
925
977
  actions: string[];
926
978
  guard?: undefined;
927
979
  })[];
980
+ TASK_WRAPUP: {
981
+ target: TaskState;
982
+ actions: string[];
983
+ };
928
984
  };
929
985
  };
930
986
  HELD: {
@@ -1002,6 +1058,15 @@ export declare function createTaskStateMachine(uiControlConfig: UIControlConfig,
1002
1058
  actions: string[];
1003
1059
  guard?: undefined;
1004
1060
  })[];
1061
+ EXIT_CONFERENCE_SUCCESS: ({
1062
+ guard: ({ context, event }: import("./guards").GuardParams) => boolean;
1063
+ target: TaskState;
1064
+ actions: string[];
1065
+ } | {
1066
+ target: TaskState;
1067
+ actions: string[];
1068
+ guard?: undefined;
1069
+ })[];
1005
1070
  };
1006
1071
  };
1007
1072
  RESUME_INITIATING: {
@@ -1029,6 +1094,10 @@ export declare function createTaskStateMachine(uiControlConfig: UIControlConfig,
1029
1094
  actions: string[];
1030
1095
  guard?: undefined;
1031
1096
  })[];
1097
+ TASK_WRAPUP: {
1098
+ target: TaskState;
1099
+ actions: string[];
1100
+ };
1032
1101
  };
1033
1102
  };
1034
1103
  CONSULT_INITIATING: {
@@ -1092,6 +1161,10 @@ export declare function createTaskStateMachine(uiControlConfig: UIControlConfig,
1092
1161
  actions: string[];
1093
1162
  guard?: undefined;
1094
1163
  })[];
1164
+ TASK_WRAPUP: {
1165
+ target: TaskState;
1166
+ actions: string[];
1167
+ };
1095
1168
  };
1096
1169
  };
1097
1170
  CONSULTING: {
@@ -1221,6 +1294,10 @@ export declare function createTaskStateMachine(uiControlConfig: UIControlConfig,
1221
1294
  actions: string[];
1222
1295
  guard?: undefined;
1223
1296
  })[];
1297
+ TASK_WRAPUP: {
1298
+ target: TaskState;
1299
+ actions: string[];
1300
+ };
1224
1301
  };
1225
1302
  };
1226
1303
  CONFERENCING: {
@@ -1337,6 +1414,15 @@ export declare function createTaskStateMachine(uiControlConfig: UIControlConfig,
1337
1414
  WRAPPING_UP: {
1338
1415
  entry: string[];
1339
1416
  on: {
1417
+ TASK_WRAPUP: ({
1418
+ guard: ({ context }: {
1419
+ context: any;
1420
+ }) => boolean;
1421
+ actions: string[];
1422
+ } | {
1423
+ actions: string[];
1424
+ guard?: undefined;
1425
+ })[];
1340
1426
  WRAPUP_COMPLETE: {
1341
1427
  target: TaskState;
1342
1428
  actions: string[];
package/dist/webex.js CHANGED
@@ -41,7 +41,7 @@ if (!global.Buffer) {
41
41
  */
42
42
  const Webex = _webexCore.default.extend({
43
43
  webex: true,
44
- version: `3.12.0-next.122`
44
+ version: `3.12.0-next.124`
45
45
  });
46
46
 
47
47
  /**
package/package.json CHANGED
@@ -46,7 +46,7 @@
46
46
  },
47
47
  "dependencies": {
48
48
  "@types/platform": "1.3.4",
49
- "@webex/calling": "3.12.0-next.98",
49
+ "@webex/calling": "3.12.0-next.99",
50
50
  "@webex/internal-plugin-mercury": "3.12.0-next.51",
51
51
  "@webex/internal-plugin-metrics": "3.12.0-next.47",
52
52
  "@webex/internal-plugin-support": "3.12.0-next.54",
@@ -84,5 +84,5 @@
84
84
  "typedoc": "^0.25.0",
85
85
  "typescript": "5.4.5"
86
86
  },
87
- "version": "3.12.0-next.122"
87
+ "version": "3.12.0-next.124"
88
88
  }
@@ -30,7 +30,7 @@ import TaskFactory from './TaskFactory';
30
30
  import AnswerCallOnWebexService from '../AnswerCallOnWebexService';
31
31
  import {getWebexCallingDeviceDetailsForAgent} from './WebexCallingUtils';
32
32
  import WebRTC from './voice/WebRTC';
33
- import {TaskEvent, type TaskEventPayload} from './state-machine';
33
+ import {TaskEvent, TaskState, type TaskEventPayload} from './state-machine';
34
34
  import {MEDIA_TYPE_MAIN_CALL} from './state-machine/constants';
35
35
  import {normalizeTaskData} from './taskDataNormalizer';
36
36
  import {ApiAIAssistant} from '../ApiAiAssistant';
@@ -44,6 +44,21 @@ const MAIN_INTERACTION_CORRELATED_EVENTS = new Set<CC_EVENTS>([
44
44
 
45
45
  const isCcEvent = (value: string): value is CC_EVENTS => CC_EVENT_SET.has(value as CC_EVENTS);
46
46
 
47
+ const WRAP_UP_STAMP_EVENTS = new Set<CC_EVENTS>([
48
+ CC_EVENTS.AGENT_WRAPUP,
49
+ CC_EVENTS.AGENT_CONSULT_CONFERENCE_ENDED,
50
+ CC_EVENTS.PARTICIPANT_LEFT_CONFERENCE,
51
+ CC_EVENTS.AGENT_CONFERENCE_TRANSFERRED,
52
+ ]);
53
+
54
+ const PARTICIPANT_LEAVE_WRAP_STATES = new Set<TaskState>([
55
+ TaskState.HELD,
56
+ TaskState.RESUME_INITIATING,
57
+ TaskState.CONSULTING,
58
+ TaskState.CONSULT_INITIATING,
59
+ TaskState.CONFERENCING,
60
+ ]);
61
+
47
62
  /** @internal */
48
63
  export default class TaskManager extends EventEmitter {
49
64
  private call: ICall;
@@ -195,6 +210,175 @@ export default class TaskManager extends EventEmitter {
195
210
  this.webCallingService.off(LINE_EVENTS.INCOMING_CALL, this.handleIncomingWebCall);
196
211
  }
197
212
 
213
+ /**
214
+ * True when this agent left on the current payload.
215
+ * A named other participantId is never self-leave (even if the participants
216
+ * map is partial). Missing-map fallback is EP-DN only: participantId absent
217
+ * and self omitted now, with previous-task evidence when a prior map exists.
218
+ */
219
+ private static didSelfLeaveConference(
220
+ payload: WebSocketPayload,
221
+ agentId?: string,
222
+ task?: ITask
223
+ ): boolean {
224
+ if (!agentId) {
225
+ return false;
226
+ }
227
+ if (payload.participantId === agentId) {
228
+ return true;
229
+ }
230
+ if (payload.participantId && payload.participantId !== agentId) {
231
+ return false;
232
+ }
233
+
234
+ const participants = payload.interaction?.participants;
235
+ if (!participants || agentId in participants) {
236
+ return false;
237
+ }
238
+
239
+ const previousParticipants = task?.data?.interaction?.participants;
240
+ if (previousParticipants && !(agentId in previousParticipants)) {
241
+ return false;
242
+ }
243
+
244
+ return true;
245
+ }
246
+
247
+ /**
248
+ * Consulted non-owner: Voice exitConference / shouldWrapUpForThisAgent stays
249
+ * false unless an explicit wrap-up signal includes this agent.
250
+ */
251
+ private static isConsultedNonOwner(
252
+ payload: WebSocketPayload,
253
+ agentId?: string,
254
+ task?: ITask
255
+ ): boolean {
256
+ const isConsulted = payload.isConsulted ?? task?.data?.isConsulted;
257
+ if (isConsulted !== true) {
258
+ return false;
259
+ }
260
+ const owner = payload.interaction?.owner ?? task?.data?.interaction?.owner;
261
+
262
+ return Boolean(agentId && owner && owner !== agentId);
263
+ }
264
+
265
+ private static hasNonemptyPendingWrapUp(payload: WebSocketPayload): boolean {
266
+ return Array.isArray(payload.agentsPendingWrapUp) && payload.agentsPendingWrapUp.length > 0;
267
+ }
268
+
269
+ /**
270
+ * Transfer initiator fallback: widgets set transferConferenceRequested on the
271
+ * actor; desktop success payloads may include consultingAgentId. Only used
272
+ * when agentsPendingWrapUp is empty or absent.
273
+ */
274
+ private static isSelfConferenceTransferInitiator(
275
+ payload: WebSocketPayload,
276
+ agentId?: string,
277
+ task?: ITask
278
+ ): boolean {
279
+ const snapshotContext = task?.stateMachineService?.getSnapshot?.()?.context as
280
+ | {transferConferenceRequested?: boolean}
281
+ | undefined;
282
+ if (snapshotContext?.transferConferenceRequested === true) {
283
+ return true;
284
+ }
285
+
286
+ return Boolean(agentId && payload.consultingAgentId === agentId);
287
+ }
288
+
289
+ /**
290
+ * Explicit wrap-up signals only (no owner / isConsulted fallback).
291
+ * Used for events that remaining conference agents also receive.
292
+ * nonempty agentsPendingWrapUp is authoritative, then wrapUpRequired / isWrapUp.
293
+ */
294
+ private static wrapUpRequiredFromExplicitSignals(
295
+ payload: WebSocketPayload,
296
+ agentId?: string
297
+ ): boolean {
298
+ const pending = payload.agentsPendingWrapUp;
299
+ if (Array.isArray(pending) && pending.length > 0) {
300
+ return Boolean(agentId && pending.includes(agentId));
301
+ }
302
+
303
+ if (payload.wrapUpRequired === true) {
304
+ return true;
305
+ }
306
+ if (!agentId) {
307
+ return false;
308
+ }
309
+
310
+ return payload.interaction?.participants?.[agentId]?.isWrapUp === true;
311
+ }
312
+
313
+ /**
314
+ * Stamp wrapUpRequired once at ingress (prepareEventContext) so updateTaskData
315
+ * and the mapper see the same flag. Remaining agents stay false.
316
+ */
317
+ private static stampWrapUpRequiredForEvent(
318
+ eventType: CC_EVENTS,
319
+ payload: WebSocketPayload,
320
+ agentId?: string,
321
+ task?: ITask
322
+ ): WebSocketPayload {
323
+ if (!WRAP_UP_STAMP_EVENTS.has(eventType)) {
324
+ return payload;
325
+ }
326
+
327
+ if (eventType === CC_EVENTS.AGENT_WRAPUP) {
328
+ return {...payload, wrapUpRequired: true};
329
+ }
330
+
331
+ if (eventType === CC_EVENTS.AGENT_CONSULT_CONFERENCE_ENDED) {
332
+ return {
333
+ ...payload,
334
+ wrapUpRequired: TaskManager.wrapUpRequiredFromExplicitSignals(payload, agentId),
335
+ };
336
+ }
337
+
338
+ if (eventType === CC_EVENTS.PARTICIPANT_LEFT_CONFERENCE) {
339
+ const actorState = task?.stateMachineService?.getSnapshot?.()?.value;
340
+ if (actorState && !PARTICIPANT_LEAVE_WRAP_STATES.has(actorState as TaskState)) {
341
+ return payload;
342
+ }
343
+
344
+ const left = TaskManager.didSelfLeaveConference(payload, agentId, task);
345
+ if (!left) {
346
+ return {
347
+ ...payload,
348
+ wrapUpRequired: TaskManager.wrapUpRequiredFromExplicitSignals(payload, agentId),
349
+ };
350
+ }
351
+ if (TaskManager.hasNonemptyPendingWrapUp(payload)) {
352
+ return {
353
+ ...payload,
354
+ wrapUpRequired: Boolean(agentId && payload.agentsPendingWrapUp?.includes(agentId)),
355
+ };
356
+ }
357
+
358
+ return {
359
+ ...payload,
360
+ wrapUpRequired:
361
+ TaskManager.wrapUpRequiredFromExplicitSignals(payload, agentId) ||
362
+ !TaskManager.isConsultedNonOwner(payload, agentId, task),
363
+ };
364
+ }
365
+
366
+ // AGENT_CONFERENCE_TRANSFERRED — pending list wins; initiator only if empty/absent
367
+ if (TaskManager.hasNonemptyPendingWrapUp(payload)) {
368
+ return {
369
+ ...payload,
370
+ wrapUpRequired: TaskManager.wrapUpRequiredFromExplicitSignals(payload, agentId),
371
+ };
372
+ }
373
+
374
+ return {
375
+ ...payload,
376
+ wrapUpRequired:
377
+ TaskManager.wrapUpRequiredFromExplicitSignals(payload, agentId) ||
378
+ TaskManager.isSelfConferenceTransferInitiator(payload, agentId, task),
379
+ };
380
+ }
381
+
198
382
  /**
199
383
  * Map WebSocket CC_EVENTS to state machine TaskEvent
200
384
  * @param ccEvent - The CC_EVENT type from WebSocket
@@ -645,6 +829,15 @@ export default class TaskManager extends EventEmitter {
645
829
  adjustedPayload = TaskManager.preserveConfirmedOwner(task, adjustedPayload);
646
830
  }
647
831
 
832
+ if (WRAP_UP_STAMP_EVENTS.has(eventType)) {
833
+ adjustedPayload = TaskManager.stampWrapUpRequiredForEvent(
834
+ eventType,
835
+ adjustedPayload,
836
+ this.agentId,
837
+ task
838
+ );
839
+ }
840
+
648
841
  const stateMachineEvent = TaskManager.mapEventToTaskStateMachineEvent(
649
842
  eventType,
650
843
  adjustedPayload,
@@ -438,6 +438,7 @@ The public `TASK_EVENTS` enum contains 49 members; every member is listed below
438
438
  | TASK-R-008 | Voice `dropConferenceParticipant` must validate the public target, resolve the latest main interaction, POST an empty body to the encoded participant-drop route, and settle only from `ParticipantLeftConference`, `ParticipantDropConferenceFailed`, HTTP failure, or the existing AQM timeout. | Participant removal is backend-authoritative and must preserve event-driven roster/state synchronization without exposing participant identifiers through telemetry or logs. | `src/services/task/voice/Voice.ts`, `src/services/task/contact.ts`, `src/services/task/types.ts` | `test/unit/spec/services/task/voice/Voice.ts`, `test/unit/spec/services/task/contact.ts`, `test/unit/spec/services/core/aqm-reqs.ts` | Backend authorization and media removal are remote-service responsibilities. | PRESENT |
439
439
  | TASK-R-009 | ContactCenter must inject Desktop Profile collaboration policy into TaskFactory, and every created voice/digital Task must expose ordered Consult and Transfer destination categories through `TaskUIControls.consultTransferDestinations`. | Task consumers need one already-computed policy surface and must not fetch profile flags or duplicate destination decisions. | `src/cc.ts`, `src/services/task/TaskFactory.ts`, `src/services/task/types.ts` | `test/unit/spec/cc.ts`, `test/unit/spec/services/task/TaskFactory.ts` | Host-specific UI options may only further hide an SDK-allowed category. | PRESENT |
440
440
  | TASK-R-010 | TaskManager must propagate backend owner promotion without electing an owner locally. `ContactOwnerChanged` resolves an exact task first, then one unique related task whose existing snapshot proves that the current Agent is active on `mainCall`, or whose incoming snapshot both names that Agent as `interaction.owner` and proves the same active-main-leg membership. Active membership requires a participant entry with `hasLeft !== true`. If no task exists, recovery is limited to that incoming owner=current-Agent proof: create the normal Task under the stable main interaction ID, silently HYDRATE its actor before listener installation, then process the original owner-change event so consumers receive one `task:hydrate` and no `task:incoming`. An owner-changing `ContactUpdated` uses the existing owner-change hydrate path only for an existing task, and a late `ParticipantLeftConference` cannot replace a confirmed active owner with the departed participant. | Every surviving agent must observe the backend-selected primary Agent immediately, including stale child-keyed and narrowly recoverable desynchronization cases, without duplicating tasks or exposing an uninitialized actor. | `src/services/task/TaskManager.ts` | `test/unit/spec/services/task/TaskManager.ts`, `test/unit/spec/services/task/Task.ts` | Backend owner selection and delivery of a complete owner-bearing `ContactOwnerChanged` payload remain remote responsibilities; missing-task `ContactUpdated` remains update-only. | PRESENT |
441
+ | TASK-R-011 | TaskManager must stamp `wrapUpRequired` once in `prepareEventContext` on `AGENT_WRAPUP`, `AGENT_CONSULT_CONFERENCE_ENDED`, `PARTICIPANT_LEFT_CONFERENCE`, and `AGENT_CONFERENCE_TRANSFERRED` before `updateTaskData` / mapper (mapper passes the already-stamped payload through). Voice `exitConference` stamps the same flag on `EXIT_CONFERENCE_SUCCESS` (that HTTP path skips TaskManager) using `shouldWrapUpForThisAgent`, then the same empty-pending self-exit fallback as leave (true unless this agent is a consulted non-owner). Voice pending-list precedence uses the correlated HTTP response only (a cached `agentsPendingWrapUp` is ignored when the response omits the list). `AGENT_WRAPUP` is always `true`. Conference-end uses explicit signals only (`agentsPendingWrapUp`, then `wrapUpRequired` / `isWrapUp`). A nonempty `agentsPendingWrapUp` is authoritative and wins over initiator fallback: `consultingAgentId === self` and actor `transferConferenceRequested` are honored only when the list is empty or absent. On participant leave: do not stamp when the actor ignores `PARTICIPANT_LEAVE` (`CONNECTED`, `HOLD_INITIATING`, `CONF_INITIATING`); stamp only in `HELD`, `RESUME_INITIATING`, `CONSULTING`, `CONSULT_INITIATING`, or `CONFERENCING` (or when no actor snapshot exists). A named other `participantId` is never self-leave (do not infer leave from a partial `participants` map); missing-map fallback applies only when `participantId` is absent (EP-DN), and when a previous task map exists it must have contained self. Remaining agents use explicit signals only; this agent left + nonempty pending uses `includes(self)`; this agent left + empty/absent pending stamps `true` unless this agent is a consulted non-owner. Remaining conference agents stay `false`. `setupAutoWrapupTimer()` still requires profile `autoWrapup !== false`, a valid interval, and a default reason. `PARTICIPANT_LEAVE` and Voice `EXIT_CONFERENCE_SUCCESS` into `WRAPPING_UP` do not transition-emit; the `WRAPPING_UP` entry still emits `task:wrapup`. `EXIT_CONFERENCE_SUCCESS` is handled in `CONNECTED`, `HELD`, and `CONFERENCING`. `TASK_WRAPUP` is handled in `HOLD_INITIATING`, `RESUME_INITIATING`, `CONSULT_INITIATING`, and `CONF_INITIATING` (transition `updateTaskData` + `markEnded` only). Late `TASK_WRAPUP` in `WRAPPING_UP` always runs `updateTaskData` and runs `emitTaskWrapup` only when `context.taskData.wrapUpRequired` was not already `true`. | Consumers rely on `task:wrapup` and `task.autoWrapup` when wrap-up starts; those surfaces only activate when ingress task data already has `wrapUpRequired === true`. | `src/services/task/TaskManager.ts`, `src/services/task/voice/Voice.ts`, `src/services/task/Task.ts`, `src/services/task/state-machine/TaskStateMachine.ts` | `test/unit/spec/services/task/TaskManager.ts`, `test/unit/spec/services/task/voice/Voice.ts`, `test/unit/spec/services/task/Task.ts`, `test/unit/spec/services/task/state-machine/TaskStateMachine.ts` | Profile auto wrap-up, interval, and default reason remain agent-config responsibilities. | PRESENT |
441
442
 
442
443
  ## Design Overview
443
444
  Task separates its stable consumption boundary from collaborators so ownership and failure behavior stay explicit. A shared Task base preserves a stable API while media-specific subclasses and a separate state engine enforce capability differences.
@@ -452,7 +453,7 @@ Task separates its stable consumption boundary from collaborators so ownership a
452
453
  | `facebook` | Unsupported by TaskFactory; throws `Unknown media type` |
453
454
  | `whatsapp` | Unsupported by TaskFactory; throws `Unknown media type` |
454
455
 
455
- If enabled in agent profile, wrapup completes automatically after timeout:
456
+ If enabled in agent profile, wrapup completes automatically after timeout. The timer starts only when ingress task data has `wrapUpRequired === true` (TASK-R-011).
456
457
 
457
458
  > **Host-application example:** `updateWrapupStatus` is a consumer-owned UI callback, not SDK package logging.
458
459
 
@@ -1432,6 +1433,7 @@ Use `test/unit/spec/services/task/Task.ts`, `TaskFactory.ts`, `TaskManager.ts`,
1432
1433
  | `TASK-R-008` | `test/unit/spec/services/task/Task.ts`, `test/unit/spec/services/task/voice/Voice.ts`, `test/unit/spec/services/task/contact.ts`, `test/unit/spec/services/core/aqm-reqs.ts`, `test/unit/spec/services/task/TaskManager.ts` | None. |
1433
1434
  | `TASK-R-009` | `test/unit/spec/cc.ts`, `test/unit/spec/services/task/TaskFactory.ts`, `test/unit/spec/services/task/state-machine/uiControlsComputer.ts` | None. |
1434
1435
  | `TASK-R-010` | `test/unit/spec/services/task/TaskManager.ts`, `test/unit/spec/services/task/Task.ts` | None. |
1436
+ | `TASK-R-011` | `test/unit/spec/services/task/TaskManager.ts`, `test/unit/spec/services/task/voice/Voice.ts`, `test/unit/spec/services/task/Task.ts`, `test/unit/spec/services/task/state-machine/TaskStateMachine.ts` | None. |
1435
1437
 
1436
1438
  ## Traceability
1437
1439
  - Repo architecture: `../../../../ai-docs/ARCHITECTURE.md` · Registry: `../../../../ai-docs/SPEC_INDEX.md`
@@ -73,7 +73,6 @@ export function getTaskStateMachineConfig(uiControlConfig: UIControlConfig) {
73
73
  'markEnded',
74
74
  'clearConsultState',
75
75
  'emitTaskParticipantLeft',
76
- 'emitTaskWrapup',
77
76
  ],
78
77
  },
79
78
  {
@@ -90,6 +89,18 @@ export function getTaskStateMachineConfig(uiControlConfig: UIControlConfig) {
90
89
  },
91
90
  ];
92
91
 
92
+ const exitConferenceSuccessTransitions = () => [
93
+ {
94
+ guard: guards.shouldWrapUp,
95
+ target: TaskState.WRAPPING_UP,
96
+ actions: ['updateTaskData', 'markEnded', 'clearConsultState'],
97
+ },
98
+ {
99
+ target: TaskState.TERMINATED,
100
+ actions: ['updateTaskData', 'markEnded', 'clearConsultState', 'emitTaskEnd'],
101
+ },
102
+ ];
103
+
93
104
  const currentAgentConsultEndTransitions = () => [
94
105
  {
95
106
  guard: (params) =>
@@ -252,7 +263,7 @@ export function getTaskStateMachineConfig(uiControlConfig: UIControlConfig) {
252
263
  {
253
264
  guard: guards.shouldWrapUp,
254
265
  target: TaskState.WRAPPING_UP,
255
- actions: ['updateTaskData', 'markEnded', 'emitTaskWrapup'],
266
+ actions: ['updateTaskData', 'markEnded'],
256
267
  },
257
268
  {
258
269
  target: TaskState.TERMINATED,
@@ -444,7 +455,7 @@ export function getTaskStateMachineConfig(uiControlConfig: UIControlConfig) {
444
455
  ],
445
456
  [TaskEvent.TASK_WRAPUP]: {
446
457
  target: TaskState.WRAPPING_UP,
447
- actions: ['updateTaskData', 'markEnded', 'emitTaskWrapup'],
458
+ actions: ['updateTaskData', 'markEnded'],
448
459
  },
449
460
  [TaskEvent.OUTBOUND_FAILED]: [
450
461
  {
@@ -463,6 +474,7 @@ export function getTaskStateMachineConfig(uiControlConfig: UIControlConfig) {
463
474
  [TaskEvent.RESUME_RECORDING]: {
464
475
  actions: ['updateTaskData', 'setRecordingState', 'emitTaskRecordingResumed'],
465
476
  },
477
+ [TaskEvent.EXIT_CONFERENCE_SUCCESS]: exitConferenceSuccessTransitions(),
466
478
  },
467
479
  },
468
480
 
@@ -489,6 +501,10 @@ export function getTaskStateMachineConfig(uiControlConfig: UIControlConfig) {
489
501
  actions: ['updateTaskData', 'markEnded', 'emitTaskOutdialFailed', 'emitTaskEnd'],
490
502
  },
491
503
  ],
504
+ [TaskEvent.TASK_WRAPUP]: {
505
+ target: TaskState.WRAPPING_UP,
506
+ actions: ['updateTaskData', 'markEnded'],
507
+ },
492
508
  },
493
509
  },
494
510
 
@@ -586,7 +602,7 @@ export function getTaskStateMachineConfig(uiControlConfig: UIControlConfig) {
586
602
  // TODO: This may not be a valid transition, this needs to be checked as well
587
603
  [TaskEvent.TASK_WRAPUP]: {
588
604
  target: TaskState.WRAPPING_UP,
589
- actions: ['updateTaskData', 'markEnded', 'emitTaskWrapup'],
605
+ actions: ['updateTaskData', 'markEnded'],
590
606
  },
591
607
  [TaskEvent.OUTBOUND_FAILED]: [
592
608
  {
@@ -599,6 +615,7 @@ export function getTaskStateMachineConfig(uiControlConfig: UIControlConfig) {
599
615
  actions: ['updateTaskData', 'markEnded', 'emitTaskOutdialFailed', 'emitTaskEnd'],
600
616
  },
601
617
  ],
618
+ [TaskEvent.EXIT_CONFERENCE_SUCCESS]: exitConferenceSuccessTransitions(),
602
619
  },
603
620
  },
604
621
 
@@ -626,6 +643,10 @@ export function getTaskStateMachineConfig(uiControlConfig: UIControlConfig) {
626
643
  actions: ['updateTaskData', 'markEnded', 'emitTaskOutdialFailed', 'emitTaskEnd'],
627
644
  },
628
645
  ],
646
+ [TaskEvent.TASK_WRAPUP]: {
647
+ target: TaskState.WRAPPING_UP,
648
+ actions: ['updateTaskData', 'markEnded'],
649
+ },
629
650
  },
630
651
  },
631
652
 
@@ -714,6 +735,10 @@ export function getTaskStateMachineConfig(uiControlConfig: UIControlConfig) {
714
735
  actions: ['updateTaskData', 'markEnded', 'emitTaskOutdialFailed', 'emitTaskEnd'],
715
736
  },
716
737
  ],
738
+ [TaskEvent.TASK_WRAPUP]: {
739
+ target: TaskState.WRAPPING_UP,
740
+ actions: ['updateTaskData', 'markEnded'],
741
+ },
717
742
  },
718
743
  },
719
744
 
@@ -893,7 +918,6 @@ export function getTaskStateMachineConfig(uiControlConfig: UIControlConfig) {
893
918
  'clearConsultState',
894
919
  'handleTransferConferenceSuccess',
895
920
  'clearTransferConferenceRequested',
896
- 'emitTaskWrapup',
897
921
  ],
898
922
  },
899
923
  {
@@ -941,7 +965,7 @@ export function getTaskStateMachineConfig(uiControlConfig: UIControlConfig) {
941
965
  },
942
966
  [TaskEvent.TASK_WRAPUP]: {
943
967
  target: TaskState.WRAPPING_UP,
944
- actions: ['updateTaskData', 'markEnded', 'clearConsultState', 'emitTaskWrapup'],
968
+ actions: ['updateTaskData', 'markEnded', 'clearConsultState'],
945
969
  },
946
970
  [TaskEvent.OUTBOUND_FAILED]: [
947
971
  {
@@ -1015,6 +1039,10 @@ export function getTaskStateMachineConfig(uiControlConfig: UIControlConfig) {
1015
1039
  actions: ['updateTaskData', 'markEnded', 'emitTaskOutdialFailed', 'emitTaskEnd'],
1016
1040
  },
1017
1041
  ],
1042
+ [TaskEvent.TASK_WRAPUP]: {
1043
+ target: TaskState.WRAPPING_UP,
1044
+ actions: ['updateTaskData', 'markEnded'],
1045
+ },
1018
1046
  },
1019
1047
  },
1020
1048
 
@@ -1028,17 +1056,7 @@ export function getTaskStateMachineConfig(uiControlConfig: UIControlConfig) {
1028
1056
  'emitTaskConferenceStarted',
1029
1057
  ],
1030
1058
  },
1031
- [TaskEvent.EXIT_CONFERENCE_SUCCESS]: [
1032
- {
1033
- guard: guards.shouldWrapUp,
1034
- target: TaskState.WRAPPING_UP,
1035
- actions: ['updateTaskData', 'markEnded', 'clearConsultState', 'emitTaskWrapup'],
1036
- },
1037
- {
1038
- target: TaskState.TERMINATED,
1039
- actions: ['updateTaskData', 'markEnded', 'clearConsultState', 'emitTaskEnd'],
1040
- },
1041
- ],
1059
+ [TaskEvent.EXIT_CONFERENCE_SUCCESS]: exitConferenceSuccessTransitions(),
1042
1060
 
1043
1061
  // Needed as all agents in conference get this event, hence we need to clear the consult state
1044
1062
  [TaskEvent.CONSULT_END]: [
@@ -1156,7 +1174,6 @@ export function getTaskStateMachineConfig(uiControlConfig: UIControlConfig) {
1156
1174
  'clearConsultState',
1157
1175
  'handleTransferConferenceSuccess',
1158
1176
  'clearTransferConferenceRequested',
1159
- 'emitTaskWrapup',
1160
1177
  ],
1161
1178
  },
1162
1179
  {
@@ -1191,7 +1208,7 @@ export function getTaskStateMachineConfig(uiControlConfig: UIControlConfig) {
1191
1208
  // Agent who should wrap up → WRAPPING_UP
1192
1209
  guard: guards.shouldWrapUp,
1193
1210
  target: TaskState.WRAPPING_UP,
1194
- actions: ['updateTaskData', 'markEnded', 'clearConsultState', 'emitTaskWrapup'],
1211
+ actions: ['updateTaskData', 'markEnded', 'clearConsultState'],
1195
1212
  },
1196
1213
  {
1197
1214
  // Customer still in call → CONNECTED
@@ -1229,7 +1246,7 @@ export function getTaskStateMachineConfig(uiControlConfig: UIControlConfig) {
1229
1246
  [TaskEvent.TASK_WRAPUP]: {
1230
1247
  guard: guards.shouldWrapUp,
1231
1248
  target: TaskState.WRAPPING_UP,
1232
- actions: ['updateTaskData', 'markEnded', 'clearConsultState', 'emitTaskWrapup'],
1249
+ actions: ['updateTaskData', 'markEnded', 'clearConsultState'],
1233
1250
  },
1234
1251
  [TaskEvent.OUTBOUND_FAILED]: [
1235
1252
  {
@@ -1249,6 +1266,16 @@ export function getTaskStateMachineConfig(uiControlConfig: UIControlConfig) {
1249
1266
  // Only emit wrapup event on entry - task:end should only be emitted when COMPLETED
1250
1267
  entry: ['emitTaskWrapup'],
1251
1268
  on: {
1269
+ // Late AgentWrapup after conference exit already entered WRAPPING_UP
1270
+ [TaskEvent.TASK_WRAPUP]: [
1271
+ {
1272
+ guard: ({context}) => context.taskData?.wrapUpRequired === true,
1273
+ actions: ['updateTaskData'],
1274
+ },
1275
+ {
1276
+ actions: ['updateTaskData', 'emitTaskWrapup'],
1277
+ },
1278
+ ],
1252
1279
  // AgentWrappedup Event
1253
1280
  [TaskEvent.WRAPUP_COMPLETE]: {
1254
1281
  target: TaskState.COMPLETED,