@webex/contact-center 3.12.0-next.9 → 3.12.0-task-refactor.2

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 (200) hide show
  1. package/AGENTS.md +438 -0
  2. package/ai-docs/README.md +131 -0
  3. package/ai-docs/RULES.md +455 -0
  4. package/ai-docs/patterns/event-driven-patterns.md +485 -0
  5. package/ai-docs/patterns/testing-patterns.md +480 -0
  6. package/ai-docs/patterns/typescript-patterns.md +365 -0
  7. package/ai-docs/templates/README.md +102 -0
  8. package/ai-docs/templates/documentation/create-agents-md.md +240 -0
  9. package/ai-docs/templates/documentation/create-architecture-md.md +295 -0
  10. package/ai-docs/templates/existing-service/bug-fix.md +254 -0
  11. package/ai-docs/templates/existing-service/feature-enhancement.md +450 -0
  12. package/ai-docs/templates/new-method/00-master.md +80 -0
  13. package/ai-docs/templates/new-method/01-requirements.md +232 -0
  14. package/ai-docs/templates/new-method/02-implementation.md +295 -0
  15. package/ai-docs/templates/new-method/03-tests.md +201 -0
  16. package/ai-docs/templates/new-method/04-validation.md +141 -0
  17. package/ai-docs/templates/new-service/00-master.md +109 -0
  18. package/ai-docs/templates/new-service/01-pre-questions.md +159 -0
  19. package/ai-docs/templates/new-service/02-code-generation.md +346 -0
  20. package/ai-docs/templates/new-service/03-integration.md +178 -0
  21. package/ai-docs/templates/new-service/04-test-generation.md +205 -0
  22. package/ai-docs/templates/new-service/05-validation.md +145 -0
  23. package/dist/cc.js +65 -123
  24. package/dist/cc.js.map +1 -1
  25. package/dist/constants.js +13 -2
  26. package/dist/constants.js.map +1 -1
  27. package/dist/index.js +13 -5
  28. package/dist/index.js.map +1 -1
  29. package/dist/metrics/behavioral-events.js +26 -13
  30. package/dist/metrics/behavioral-events.js.map +1 -1
  31. package/dist/metrics/constants.js +7 -6
  32. package/dist/metrics/constants.js.map +1 -1
  33. package/dist/services/ApiAiAssistant.js +0 -3
  34. package/dist/services/ApiAiAssistant.js.map +1 -1
  35. package/dist/services/config/Util.js +2 -3
  36. package/dist/services/config/Util.js.map +1 -1
  37. package/dist/services/config/types.js +16 -14
  38. package/dist/services/config/types.js.map +1 -1
  39. package/dist/services/constants.js +0 -1
  40. package/dist/services/constants.js.map +1 -1
  41. package/dist/services/core/Err.js.map +1 -1
  42. package/dist/services/core/Utils.js +79 -55
  43. package/dist/services/core/Utils.js.map +1 -1
  44. package/dist/services/core/aqm-reqs.js +17 -92
  45. package/dist/services/core/aqm-reqs.js.map +1 -1
  46. package/dist/services/core/websocket/WebSocketManager.js +5 -25
  47. package/dist/services/core/websocket/WebSocketManager.js.map +1 -1
  48. package/dist/services/core/websocket/types.js.map +1 -1
  49. package/dist/services/index.js +1 -2
  50. package/dist/services/index.js.map +1 -1
  51. package/dist/services/task/Task.js +644 -0
  52. package/dist/services/task/Task.js.map +1 -0
  53. package/dist/services/task/TaskFactory.js +45 -0
  54. package/dist/services/task/TaskFactory.js.map +1 -0
  55. package/dist/services/task/TaskManager.js +570 -535
  56. package/dist/services/task/TaskManager.js.map +1 -1
  57. package/dist/services/task/TaskUtils.js +132 -28
  58. package/dist/services/task/TaskUtils.js.map +1 -1
  59. package/dist/services/task/constants.js +7 -6
  60. package/dist/services/task/constants.js.map +1 -1
  61. package/dist/services/task/dialer.js +0 -51
  62. package/dist/services/task/dialer.js.map +1 -1
  63. package/dist/services/task/digital/Digital.js +77 -0
  64. package/dist/services/task/digital/Digital.js.map +1 -0
  65. package/dist/services/task/state-machine/TaskStateMachine.js +634 -0
  66. package/dist/services/task/state-machine/TaskStateMachine.js.map +1 -0
  67. package/dist/services/task/state-machine/actions.js +372 -0
  68. package/dist/services/task/state-machine/actions.js.map +1 -0
  69. package/dist/services/task/state-machine/constants.js +139 -0
  70. package/dist/services/task/state-machine/constants.js.map +1 -0
  71. package/dist/services/task/state-machine/guards.js +263 -0
  72. package/dist/services/task/state-machine/guards.js.map +1 -0
  73. package/dist/services/task/state-machine/index.js +53 -0
  74. package/dist/services/task/state-machine/index.js.map +1 -0
  75. package/dist/services/task/state-machine/types.js +54 -0
  76. package/dist/services/task/state-machine/types.js.map +1 -0
  77. package/dist/services/task/state-machine/uiControlsComputer.js +377 -0
  78. package/dist/services/task/state-machine/uiControlsComputer.js.map +1 -0
  79. package/dist/services/task/taskDataNormalizer.js +99 -0
  80. package/dist/services/task/taskDataNormalizer.js.map +1 -0
  81. package/dist/services/task/types.js +157 -18
  82. package/dist/services/task/types.js.map +1 -1
  83. package/dist/services/task/voice/Voice.js +1031 -0
  84. package/dist/services/task/voice/Voice.js.map +1 -0
  85. package/dist/services/task/voice/WebRTC.js +149 -0
  86. package/dist/services/task/voice/WebRTC.js.map +1 -0
  87. package/dist/types/cc.d.ts +4 -33
  88. package/dist/types/constants.d.ts +13 -2
  89. package/dist/types/index.d.ts +11 -5
  90. package/dist/types/metrics/constants.d.ts +5 -3
  91. package/dist/types/services/ApiAiAssistant.d.ts +1 -1
  92. package/dist/types/services/config/types.d.ts +97 -25
  93. package/dist/types/services/core/Err.d.ts +0 -2
  94. package/dist/types/services/core/Utils.d.ts +25 -23
  95. package/dist/types/services/core/aqm-reqs.d.ts +0 -49
  96. package/dist/types/services/core/websocket/WebSocketManager.d.ts +1 -1
  97. package/dist/types/services/core/websocket/connection-service.d.ts +0 -1
  98. package/dist/types/services/core/websocket/types.d.ts +1 -1
  99. package/dist/types/services/index.d.ts +1 -1
  100. package/dist/types/services/task/Task.d.ts +146 -0
  101. package/dist/types/services/task/TaskFactory.d.ts +12 -0
  102. package/dist/types/services/task/TaskUtils.d.ts +39 -8
  103. package/dist/types/services/task/constants.d.ts +5 -4
  104. package/dist/types/services/task/dialer.d.ts +0 -15
  105. package/dist/types/services/task/digital/Digital.d.ts +22 -0
  106. package/dist/types/services/task/state-machine/TaskStateMachine.d.ts +906 -0
  107. package/dist/types/services/task/state-machine/actions.d.ts +8 -0
  108. package/dist/types/services/task/state-machine/constants.d.ts +91 -0
  109. package/dist/types/services/task/state-machine/guards.d.ts +78 -0
  110. package/dist/types/services/task/state-machine/index.d.ts +13 -0
  111. package/dist/types/services/task/state-machine/types.d.ts +256 -0
  112. package/dist/types/services/task/state-machine/uiControlsComputer.d.ts +9 -0
  113. package/dist/types/services/task/taskDataNormalizer.d.ts +10 -0
  114. package/dist/types/services/task/types.d.ts +539 -88
  115. package/dist/types/services/task/voice/Voice.d.ts +183 -0
  116. package/dist/types/services/task/voice/WebRTC.d.ts +53 -0
  117. package/dist/types/types.d.ts +68 -0
  118. package/dist/types/webex.d.ts +1 -0
  119. package/dist/types.js +70 -0
  120. package/dist/types.js.map +1 -1
  121. package/dist/webex.js +14 -2
  122. package/dist/webex.js.map +1 -1
  123. package/package.json +14 -11
  124. package/src/cc.ts +91 -177
  125. package/src/constants.ts +13 -2
  126. package/src/index.ts +14 -5
  127. package/src/metrics/ai-docs/AGENTS.md +348 -0
  128. package/src/metrics/ai-docs/ARCHITECTURE.md +336 -0
  129. package/src/metrics/behavioral-events.ts +28 -14
  130. package/src/metrics/constants.ts +7 -8
  131. package/src/services/ApiAiAssistant.ts +2 -4
  132. package/src/services/agent/ai-docs/AGENTS.md +238 -0
  133. package/src/services/agent/ai-docs/ARCHITECTURE.md +302 -0
  134. package/src/services/ai-docs/AGENTS.md +384 -0
  135. package/src/services/config/Util.ts +2 -3
  136. package/src/services/config/ai-docs/AGENTS.md +253 -0
  137. package/src/services/config/ai-docs/ARCHITECTURE.md +424 -0
  138. package/src/services/config/types.ts +108 -20
  139. package/src/services/constants.ts +0 -1
  140. package/src/services/core/Err.ts +0 -1
  141. package/src/services/core/Utils.ts +90 -67
  142. package/src/services/core/ai-docs/AGENTS.md +379 -0
  143. package/src/services/core/ai-docs/ARCHITECTURE.md +696 -0
  144. package/src/services/core/aqm-reqs.ts +22 -100
  145. package/src/services/core/websocket/WebSocketManager.ts +4 -23
  146. package/src/services/core/websocket/types.ts +1 -1
  147. package/src/services/index.ts +1 -2
  148. package/src/services/task/Task.ts +785 -0
  149. package/src/services/task/TaskFactory.ts +55 -0
  150. package/src/services/task/TaskManager.ts +579 -633
  151. package/src/services/task/TaskUtils.ts +175 -31
  152. package/src/services/task/ai-docs/AGENTS.md +448 -0
  153. package/src/services/task/ai-docs/ARCHITECTURE.md +573 -0
  154. package/src/services/task/constants.ts +5 -4
  155. package/src/services/task/dialer.ts +1 -56
  156. package/src/services/task/digital/Digital.ts +95 -0
  157. package/src/services/task/state-machine/TaskStateMachine.ts +793 -0
  158. package/src/services/task/state-machine/actions.ts +422 -0
  159. package/src/services/task/state-machine/ai-docs/AGENTS.md +495 -0
  160. package/src/services/task/state-machine/ai-docs/ARCHITECTURE.md +1135 -0
  161. package/src/services/task/state-machine/constants.ts +150 -0
  162. package/src/services/task/state-machine/guards.ts +303 -0
  163. package/src/services/task/state-machine/index.ts +28 -0
  164. package/src/services/task/state-machine/types.ts +228 -0
  165. package/src/services/task/state-machine/uiControlsComputer.ts +542 -0
  166. package/src/services/task/taskDataNormalizer.ts +137 -0
  167. package/src/services/task/types.ts +641 -95
  168. package/src/services/task/voice/Voice.ts +1255 -0
  169. package/src/services/task/voice/WebRTC.ts +187 -0
  170. package/src/types.ts +88 -5
  171. package/src/utils/AGENTS.md +276 -0
  172. package/src/webex.js +2 -0
  173. package/test/unit/spec/cc.ts +59 -142
  174. package/test/unit/spec/logger-proxy.ts +70 -0
  175. package/test/unit/spec/services/ApiAiAssistant.ts +17 -0
  176. package/test/unit/spec/services/config/index.ts +26 -55
  177. package/test/unit/spec/services/core/Utils.ts +103 -52
  178. package/test/unit/spec/services/core/websocket/WebSocketManager.ts +48 -112
  179. package/test/unit/spec/services/core/websocket/connection-service.ts +5 -4
  180. package/test/unit/spec/services/task/AutoWrapup.ts +63 -0
  181. package/test/unit/spec/services/task/Task.ts +416 -0
  182. package/test/unit/spec/services/task/TaskFactory.ts +62 -0
  183. package/test/unit/spec/services/task/TaskManager.ts +781 -1735
  184. package/test/unit/spec/services/task/TaskUtils.ts +125 -0
  185. package/test/unit/spec/services/task/dialer.ts +112 -198
  186. package/test/unit/spec/services/task/digital/Digital.ts +105 -0
  187. package/test/unit/spec/services/task/state-machine/TaskStateMachine.ts +473 -0
  188. package/test/unit/spec/services/task/state-machine/guards.ts +288 -0
  189. package/test/unit/spec/services/task/state-machine/types.ts +18 -0
  190. package/test/unit/spec/services/task/state-machine/uiControlsComputer.ts +147 -0
  191. package/test/unit/spec/services/task/taskTestUtils.ts +87 -0
  192. package/test/unit/spec/services/task/voice/Voice.ts +587 -0
  193. package/test/unit/spec/services/task/voice/WebRTC.ts +242 -0
  194. package/umd/contact-center.min.js +2 -2
  195. package/umd/contact-center.min.js.map +1 -1
  196. package/dist/services/task/index.js +0 -1525
  197. package/dist/services/task/index.js.map +0 -1
  198. package/dist/types/services/task/index.d.ts +0 -650
  199. package/src/services/task/index.ts +0 -1801
  200. package/test/unit/spec/services/task/index.ts +0 -2184
@@ -0,0 +1,542 @@
1
+ /**
2
+ * UI Controls Computer - Centralized logic for computing UI control states
3
+ */
4
+
5
+ import {
6
+ InteractionUIControls,
7
+ TASK_CHANNEL_TYPE,
8
+ TaskData,
9
+ TaskUILeg,
10
+ TaskUIControls,
11
+ VOICE_VARIANT,
12
+ } from '../types';
13
+ import {TaskContext, UIControlConfig} from './types';
14
+ import {TaskState, MAX_PARTICIPANTS_IN_MULTIPARTY_CONFERENCE} from './constants';
15
+ import {
16
+ getIsCustomerInCall,
17
+ getConferenceParticipantsCount,
18
+ getIsConferenceInProgress,
19
+ getIsConsultInProgressForConferenceControls,
20
+ getIsConsultedAgentForControls,
21
+ getServerHoldStateForControls,
22
+ } from '../TaskUtils';
23
+
24
+ const DISABLED = {isVisible: false, isEnabled: false} as const;
25
+ const VISIBLE_ENABLED = {isVisible: true, isEnabled: true} as const;
26
+ const VISIBLE_DISABLED = {isVisible: true, isEnabled: false} as const;
27
+
28
+ function getDefaultInteractionUIControls(): InteractionUIControls {
29
+ return {
30
+ accept: DISABLED,
31
+ decline: DISABLED,
32
+ hold: DISABLED,
33
+ mute: DISABLED,
34
+ end: DISABLED,
35
+ transfer: DISABLED,
36
+ consult: DISABLED,
37
+ consultTransfer: DISABLED,
38
+ endConsult: DISABLED,
39
+ recording: DISABLED,
40
+ conference: DISABLED,
41
+ wrapup: DISABLED,
42
+ exitConference: DISABLED,
43
+ transferConference: DISABLED,
44
+ mergeToConference: DISABLED,
45
+ switch: DISABLED,
46
+ };
47
+ }
48
+
49
+ function createTaskUIControls(
50
+ main: InteractionUIControls,
51
+ consult: InteractionUIControls,
52
+ activeLeg: TaskUILeg
53
+ ): TaskUIControls {
54
+ return {
55
+ main,
56
+ consult,
57
+ activeLeg,
58
+ };
59
+ }
60
+
61
+ export function getDefaultUIControls(): TaskUIControls {
62
+ return createTaskUIControls(
63
+ getDefaultInteractionUIControls(),
64
+ getDefaultInteractionUIControls(),
65
+ 'main'
66
+ );
67
+ }
68
+
69
+ function computeVoiceInteractionUIControls(
70
+ state: TaskState,
71
+ context: TaskContext,
72
+ config: UIControlConfig,
73
+ fallbackTaskData?: TaskData,
74
+ currentLeg: TaskUILeg = 'main'
75
+ ): InteractionUIControls {
76
+ // Early exit for idle
77
+ if (state === TaskState.IDLE) {
78
+ return getDefaultInteractionUIControls();
79
+ }
80
+
81
+ // Essential data
82
+ const taskData = context.taskData ?? fallbackTaskData ?? null;
83
+ const interaction = taskData?.interaction;
84
+ const mainCallId = interaction?.mainInteractionId || taskData?.interactionId;
85
+ const isWebrtc = config.voiceVariant === VOICE_VARIANT.WEBRTC;
86
+ const isOutdial = interaction?.outboundType === 'OUTDIAL';
87
+ const serverHold = getServerHoldStateForControls(context, mainCallId, fallbackTaskData);
88
+
89
+ // Backend-derived checks
90
+ const customerInCall =
91
+ interaction && mainCallId ? getIsCustomerInCall(interaction, mainCallId) : false;
92
+ // EP-DN/secondary legs can have incomplete media participant lists; fall back to participants map.
93
+ const customerPresent =
94
+ customerInCall ||
95
+ Boolean(
96
+ interaction &&
97
+ interaction.participants &&
98
+ Object.values(interaction.participants).some(
99
+ (p: any) => p?.pType === 'Customer' && !p?.hasLeft
100
+ )
101
+ );
102
+ const participantCount =
103
+ interaction && mainCallId ? getConferenceParticipantsCount(interaction, mainCallId) : 0;
104
+ const maxParticipants = participantCount >= MAX_PARTICIPANTS_IN_MULTIPARTY_CONFERENCE;
105
+ const selfAgentId = config.agentId ?? taskData?.agentId;
106
+ const consultInProgress = getIsConsultInProgressForConferenceControls(
107
+ interaction,
108
+ mainCallId,
109
+ selfAgentId
110
+ );
111
+ const conferenceFromBackend = taskData ? getIsConferenceInProgress(taskData) : false;
112
+ // Note: ownership is used by some controls; keep computations local to those controls
113
+
114
+ // Context flags (set by state machine actions)
115
+ const {
116
+ consultInitiator,
117
+ consultDestinationAgentJoined,
118
+ consultDestinationType,
119
+ consultCallHeld,
120
+ consultFromConference,
121
+ } = context;
122
+ const {recordingControlsAvailable} = context;
123
+
124
+ // EP_DN consults are "ready" as soon as the consult is created (EP accepts routing immediately).
125
+ // Backend sends destinationType as 'EP-DN'; SDK method uses 'entryPoint' — check both.
126
+ const isEpDnConsult =
127
+ consultDestinationType === 'entryPoint' || consultDestinationType === ('EP-DN' as any);
128
+ const isConsultDestinationReady = consultDestinationAgentJoined || isEpDnConsult;
129
+
130
+ const stateImpliesHeld = state === TaskState.HELD || state === TaskState.RESUME_INITIATING;
131
+ const stateImpliesConnected =
132
+ state === TaskState.CONNECTED || state === TaskState.HOLD_INITIATING;
133
+ const isHeld = stateImpliesHeld || serverHold === true;
134
+ const isConnected = stateImpliesConnected || (!stateImpliesHeld && serverHold === false);
135
+
136
+ // State categories for cleaner logic
137
+ const isConsulting =
138
+ state === TaskState.CONSULTING ||
139
+ state === TaskState.CONSULT_INITIATING ||
140
+ state === TaskState.CONF_INITIATING;
141
+ const isConferencing = state === TaskState.CONFERENCING;
142
+ const isWrappingUp = state === TaskState.WRAPPING_UP;
143
+ const selfInMainCall =
144
+ Boolean(selfAgentId) &&
145
+ Boolean(mainCallId) &&
146
+ Boolean(interaction?.media?.[mainCallId]?.participants?.includes(selfAgentId as string));
147
+ const conferenceActive = isConferencing || conferenceFromBackend || consultFromConference;
148
+ // Treat consult initiator as "in conference" even if mainCall participant list lags while consulting.
149
+ const inConference = conferenceActive && (isConferencing || selfInMainCall || consultInitiator);
150
+
151
+ // Check if this is a consulted agent (must be after isConsulting is computed).
152
+ const isSoleAgentOnCall = participantCount <= 1 && !isConsulting && !inConference;
153
+ const isConsulted =
154
+ inConference || isSoleAgentOnCall
155
+ ? false
156
+ : getIsConsultedAgentForControls(taskData, context, isConsulting);
157
+
158
+ // Active call = can perform call operations
159
+ const isActive =
160
+ state === TaskState.CONNECTED ||
161
+ state === TaskState.HELD ||
162
+ state === TaskState.HOLD_INITIATING ||
163
+ state === TaskState.RESUME_INITIATING ||
164
+ isConsulting ||
165
+ isConferencing;
166
+
167
+ // Consulted agents have limited controls until they're in conference or wrapup
168
+ // Use inConference (not isConferencing) so controls remain enabled after state downgrade
169
+ const hasFullControls = !isConsulted || consultInitiator || inConference || isWrappingUp;
170
+ const consultOwnedBySelf =
171
+ consultInitiator || (Boolean(selfAgentId) && taskData?.consultingAgentId === selfAgentId);
172
+ const hasConsultMedia = Boolean(
173
+ taskData?.consultMediaResourceId ||
174
+ Object.values(interaction?.media ?? {}).some((media: any) => media?.mType === 'consult')
175
+ );
176
+ const hasParallelConsultLeg =
177
+ consultOwnedBySelf &&
178
+ !isConsulting &&
179
+ !isConsulted &&
180
+ (consultInProgress || consultCallHeld || hasConsultMedia);
181
+ const consultLegOnHold = isConsulting && consultCallHeld;
182
+
183
+ return {
184
+ // Accept/Decline: Voice tasks in offered state
185
+ // For outdial, accept is disabled (auto-answer handles it), decline remains enabled
186
+ // For Extension mode (non-WebRTC), accept shows as disabled "Ringing" button
187
+ accept:
188
+ state === TaskState.OFFERED && !interaction?.isTerminated
189
+ ? {isVisible: true, isEnabled: isWebrtc && !isOutdial}
190
+ : DISABLED,
191
+ decline:
192
+ isWebrtc && state === TaskState.OFFERED && !interaction?.isTerminated
193
+ ? VISIBLE_ENABLED
194
+ : DISABLED,
195
+
196
+ // Hold: visible in connected/held/conference, disabled in conference/consulting
197
+ hold: (() => {
198
+ if (!hasFullControls) return DISABLED;
199
+ if (consultOwnedBySelf && (isConsulting || hasParallelConsultLeg || consultCallHeld)) {
200
+ return DISABLED;
201
+ }
202
+ if (hasParallelConsultLeg) return DISABLED;
203
+ if (state === TaskState.OFFERED) return DISABLED;
204
+ if (isWrappingUp) return DISABLED;
205
+ // Visibility: connected || held || inConference
206
+ if (!(isConnected || isHeld || inConference)) return DISABLED;
207
+ // Enabled: (connected || held) && !inConference && !isConsulting
208
+ const canHold = (isConnected || isHeld) && !inConference && !isConsulting;
209
+
210
+ return canHold ? VISIBLE_ENABLED : VISIBLE_DISABLED;
211
+ })(),
212
+
213
+ // Mute: WebRTC only, active calls; hidden entirely during wrapup
214
+ mute: (() => {
215
+ if (!isWebrtc) return DISABLED;
216
+ if (isWrappingUp) return DISABLED;
217
+ if (isConsulting) return VISIBLE_ENABLED;
218
+
219
+ if (isConnected || isHeld || isConferencing) {
220
+ if (inConference) return VISIBLE_ENABLED;
221
+
222
+ return isHeld ? VISIBLE_DISABLED : VISIBLE_ENABLED;
223
+ }
224
+
225
+ return DISABLED;
226
+ })(),
227
+
228
+ // End: varies by state; during consulting only on main leg (consult held)
229
+ end: (() => {
230
+ if (!config.isEndTaskEnabled) return DISABLED;
231
+ if (hasParallelConsultLeg) {
232
+ return isConnected && isEpDnConsult ? VISIBLE_ENABLED : VISIBLE_DISABLED;
233
+ }
234
+
235
+ if (isConsulting) {
236
+ if (currentLeg === 'consult' && consultCallHeld) return DISABLED;
237
+
238
+ return consultInitiator && consultCallHeld ? VISIBLE_ENABLED : DISABLED;
239
+ }
240
+
241
+ if (inConference) {
242
+ if (isConsulted) return DISABLED;
243
+
244
+ if (consultInProgress) return VISIBLE_DISABLED;
245
+
246
+ return isWrappingUp ? VISIBLE_DISABLED : VISIBLE_ENABLED;
247
+ }
248
+ if (!hasFullControls) return DISABLED;
249
+ if (isActive) return isHeld || isWrappingUp ? VISIBLE_DISABLED : VISIBLE_ENABLED;
250
+
251
+ return DISABLED;
252
+ })(),
253
+
254
+ // Transfer: connected/held, not in conference
255
+ transfer: (() => {
256
+ if (hasParallelConsultLeg) {
257
+ if (state === TaskState.CONNECTED) return VISIBLE_ENABLED;
258
+ if (state === TaskState.HELD) return VISIBLE_DISABLED;
259
+ }
260
+ if (isConsulting) {
261
+ if (!consultInitiator) return DISABLED;
262
+ if (consultLegOnHold) return VISIBLE_DISABLED;
263
+
264
+ return isConsultDestinationReady ? VISIBLE_ENABLED : VISIBLE_DISABLED;
265
+ }
266
+ if (!hasFullControls || inConference) return DISABLED;
267
+ if (state === TaskState.CONNECTED || state === TaskState.HELD) return VISIBLE_ENABLED;
268
+
269
+ return DISABLED;
270
+ })(),
271
+
272
+ // Consult: connected/held/conference when conditions met
273
+ consult: (() => {
274
+ const isConnectedOrHeld = state === TaskState.CONNECTED || state === TaskState.HELD;
275
+
276
+ if (hasParallelConsultLeg) return DISABLED;
277
+ if (!hasFullControls || !(isConnectedOrHeld || inConference)) {
278
+ return DISABLED;
279
+ }
280
+
281
+ // Enabled conditions differ by state
282
+ const canFromConnected =
283
+ !maxParticipants && customerPresent && !consultInProgress && !isConsulted;
284
+ const canFromConference =
285
+ !maxParticipants && customerPresent && !consultInProgress && !isConsulting;
286
+
287
+ const isEnabled = inConference ? canFromConference : canFromConnected;
288
+
289
+ return {isVisible: true, isEnabled};
290
+ })(),
291
+
292
+ // ConsultTransfer: always hidden (use transfer button)
293
+ consultTransfer: DISABLED,
294
+
295
+ // EndConsult: during consulting
296
+ endConsult: (() => {
297
+ if (!isConsulting) return DISABLED;
298
+ if (isConsulted && isConferencing) return DISABLED;
299
+ if (!isConsulted && isConferencing && !(consultInitiator && conferenceFromBackend)) {
300
+ return DISABLED;
301
+ }
302
+
303
+ return {isVisible: true, isEnabled: consultInitiator || config.isEndConsultEnabled};
304
+ })(),
305
+
306
+ // Recording: connected/held only, not in consult/conference
307
+ recording: (() => {
308
+ if (!recordingControlsAvailable || !config.isRecordingEnabled) return DISABLED;
309
+ if (!hasFullControls || isConsulting || inConference) return DISABLED;
310
+ if (state === TaskState.CONNECTED || state === TaskState.HELD) {
311
+ return VISIBLE_ENABLED;
312
+ }
313
+
314
+ return DISABLED;
315
+ })(),
316
+
317
+ // Conference: during consulting, enabled on both legs when agent joined
318
+ // Label changes based on leg: "Conference" on main leg, "Merge" on consult leg
319
+ conference: (() => {
320
+ if (hasParallelConsultLeg) {
321
+ if (state === TaskState.CONNECTED) {
322
+ return maxParticipants ? VISIBLE_DISABLED : VISIBLE_ENABLED;
323
+ }
324
+ if (state === TaskState.HELD) return VISIBLE_DISABLED;
325
+
326
+ return DISABLED;
327
+ }
328
+ if (!hasFullControls || !isConsulting) return DISABLED;
329
+ if (!consultInitiator) return DISABLED;
330
+ if (consultLegOnHold) return VISIBLE_DISABLED;
331
+
332
+ return isConsultDestinationReady && !maxParticipants ? VISIBLE_ENABLED : VISIBLE_DISABLED;
333
+ })(),
334
+
335
+ // Wrapup: wrapping up state
336
+ wrapup: isWrappingUp ? VISIBLE_ENABLED : DISABLED,
337
+
338
+ // ExitConference: in conference, not consulting from conference
339
+ exitConference: (() => {
340
+ if (isConsulted && !isConferencing) return DISABLED;
341
+ if (!inConference) return DISABLED;
342
+ const consultingFromConference = consultInitiator && isConsulting && conferenceFromBackend;
343
+
344
+ return consultingFromConference ? VISIBLE_DISABLED : VISIBLE_ENABLED;
345
+ })(),
346
+
347
+ // TransferConference: in conference with active consult, owner consulting from conference
348
+ transferConference: (() => {
349
+ if (hasParallelConsultLeg || consultLegOnHold) return DISABLED;
350
+ if (!inConference || !isConsulting) return DISABLED;
351
+ if (!consultInitiator || isConsulted) return DISABLED;
352
+
353
+ return isConsultDestinationReady ? VISIBLE_ENABLED : VISIBLE_DISABLED;
354
+ })(),
355
+
356
+ // MergeToConference: mirrors conference control, enabled on both legs
357
+ mergeToConference: (() => {
358
+ if (!isConsulting || !consultInitiator) return DISABLED;
359
+ if (consultLegOnHold) return VISIBLE_DISABLED;
360
+
361
+ return isConsultDestinationReady && !maxParticipants ? VISIBLE_ENABLED : VISIBLE_DISABLED;
362
+ })(),
363
+
364
+ // Switch: visible only on the currently active leg
365
+ switch: (() => {
366
+ if (currentLeg === 'consult') {
367
+ if (!isConsulting || !consultInitiator || consultCallHeld) return DISABLED;
368
+
369
+ return isConsultDestinationReady ? VISIBLE_ENABLED : VISIBLE_DISABLED;
370
+ }
371
+
372
+ if (hasParallelConsultLeg && state === TaskState.CONNECTED) {
373
+ return isConsultDestinationReady ? VISIBLE_ENABLED : VISIBLE_DISABLED;
374
+ }
375
+
376
+ return DISABLED;
377
+ })(),
378
+ };
379
+ }
380
+
381
+ function computeDigitalInteractionUIControls(
382
+ state: TaskState,
383
+ context: TaskContext,
384
+ fallbackTaskData?: TaskData
385
+ ): InteractionUIControls {
386
+ const taskData = context.taskData ?? fallbackTaskData ?? null;
387
+ const isTerminated = taskData?.interaction?.isTerminated ?? false;
388
+
389
+ const isConnected = state === TaskState.CONNECTED;
390
+ const isWrappingUp = state === TaskState.WRAPPING_UP;
391
+
392
+ return {
393
+ accept: state === TaskState.OFFERED ? VISIBLE_ENABLED : DISABLED,
394
+ decline: DISABLED,
395
+ hold: DISABLED,
396
+ mute: DISABLED,
397
+ end: isConnected && !isWrappingUp ? VISIBLE_ENABLED : DISABLED,
398
+ transfer: isConnected && !isWrappingUp ? VISIBLE_ENABLED : DISABLED,
399
+ consult: DISABLED,
400
+ consultTransfer: DISABLED,
401
+ endConsult: DISABLED,
402
+ recording: DISABLED,
403
+ conference: DISABLED,
404
+ wrapup: isTerminated || isWrappingUp ? VISIBLE_ENABLED : DISABLED,
405
+ exitConference: DISABLED,
406
+ transferConference: DISABLED,
407
+ mergeToConference: DISABLED,
408
+ switch: DISABLED,
409
+ };
410
+ }
411
+
412
+ function getVoiceLegState(
413
+ currentState: TaskState,
414
+ context: TaskContext,
415
+ config: UIControlConfig,
416
+ fallbackTaskData?: TaskData
417
+ ): {hasConsultLeg: boolean; activeLeg: TaskUILeg; mainState: TaskState; consultState: TaskState} {
418
+ if (currentState === TaskState.WRAPPING_UP) {
419
+ return {
420
+ hasConsultLeg: false,
421
+ activeLeg: 'main',
422
+ mainState: currentState,
423
+ consultState: TaskState.CONSULTING,
424
+ };
425
+ }
426
+
427
+ const taskData = context.taskData ?? fallbackTaskData ?? null;
428
+ const interaction = taskData?.interaction;
429
+ const mainCallId = interaction?.mainInteractionId || taskData?.interactionId;
430
+ const selfAgentId = config.agentId ?? taskData?.agentId;
431
+ const consultInProgress = getIsConsultInProgressForConferenceControls(
432
+ interaction,
433
+ mainCallId,
434
+ selfAgentId
435
+ );
436
+ const isConsultingState =
437
+ currentState === TaskState.CONSULTING ||
438
+ currentState === TaskState.CONSULT_INITIATING ||
439
+ currentState === TaskState.CONF_INITIATING;
440
+ const consultOwnedBySelf =
441
+ context.consultInitiator ||
442
+ (Boolean(selfAgentId) && taskData?.consultingAgentId === selfAgentId);
443
+ const hasConsultMedia = Boolean(
444
+ taskData?.consultMediaResourceId ||
445
+ Object.values(interaction?.media ?? {}).some((media: any) => media?.mType === 'consult')
446
+ );
447
+ const hasConsultLeg = Boolean(
448
+ consultOwnedBySelf &&
449
+ !taskData?.isConsulted &&
450
+ !interaction?.isTerminated &&
451
+ (consultInProgress || isConsultingState || context.consultCallHeld || hasConsultMedia)
452
+ );
453
+
454
+ if (!hasConsultLeg) {
455
+ return {
456
+ hasConsultLeg: false,
457
+ activeLeg: 'main',
458
+ mainState: currentState,
459
+ consultState: TaskState.CONSULTING,
460
+ };
461
+ }
462
+
463
+ return {
464
+ hasConsultLeg: true,
465
+ activeLeg: context.consultCallHeld ? 'main' : 'consult',
466
+ mainState: context.consultCallHeld ? TaskState.CONNECTED : TaskState.HELD,
467
+ consultState: isConsultingState ? currentState : TaskState.CONSULTING,
468
+ };
469
+ }
470
+
471
+ export function computeUIControls(
472
+ currentState: TaskState,
473
+ context: TaskContext,
474
+ fallbackTaskData?: TaskData
475
+ ): TaskUIControls {
476
+ if (currentState === TaskState.TERMINATED || currentState === TaskState.COMPLETED) {
477
+ return getDefaultUIControls();
478
+ }
479
+
480
+ switch (context.uiControlConfig.channelType) {
481
+ case TASK_CHANNEL_TYPE.VOICE: {
482
+ const {hasConsultLeg, activeLeg, mainState, consultState} = getVoiceLegState(
483
+ currentState,
484
+ context,
485
+ context.uiControlConfig,
486
+ fallbackTaskData
487
+ );
488
+
489
+ const mainControls = computeVoiceInteractionUIControls(
490
+ mainState,
491
+ context,
492
+ context.uiControlConfig,
493
+ fallbackTaskData,
494
+ 'main'
495
+ );
496
+ const consultControls = hasConsultLeg
497
+ ? computeVoiceInteractionUIControls(
498
+ consultState,
499
+ context,
500
+ context.uiControlConfig,
501
+ fallbackTaskData,
502
+ 'consult'
503
+ )
504
+ : getDefaultInteractionUIControls();
505
+
506
+ return createTaskUIControls(mainControls, consultControls, activeLeg);
507
+ }
508
+ case TASK_CHANNEL_TYPE.DIGITAL:
509
+ return createTaskUIControls(
510
+ computeDigitalInteractionUIControls(currentState, context, fallbackTaskData),
511
+ getDefaultInteractionUIControls(),
512
+ 'main'
513
+ );
514
+ default:
515
+ return getDefaultUIControls();
516
+ }
517
+ }
518
+
519
+ function haveInteractionUIControlsChanged(
520
+ previous: InteractionUIControls,
521
+ next: InteractionUIControls
522
+ ): boolean {
523
+ return (Object.keys(next) as (keyof InteractionUIControls)[]).some((key) => {
524
+ const prev = previous[key];
525
+ const curr = next[key];
526
+
527
+ return prev.isVisible !== curr.isVisible || prev.isEnabled !== curr.isEnabled;
528
+ });
529
+ }
530
+
531
+ export function haveUIControlsChanged(
532
+ previous: TaskUIControls | undefined,
533
+ next: TaskUIControls
534
+ ): boolean {
535
+ if (!previous) return true;
536
+
537
+ return (
538
+ previous.activeLeg !== next.activeLeg ||
539
+ haveInteractionUIControlsChanged(previous.main, next.main) ||
540
+ haveInteractionUIControlsChanged(previous.consult, next.consult)
541
+ );
542
+ }
@@ -0,0 +1,137 @@
1
+ import {
2
+ CallProcessingBooleanKey,
3
+ InteractionBooleanKey,
4
+ ParticipantBooleanKey,
5
+ TaskData,
6
+ } from './types';
7
+
8
+ const booleanKeys: CallProcessingBooleanKey[] = [
9
+ 'recordingStarted',
10
+ 'recordInProgress',
11
+ 'isPaused',
12
+ 'pauseResumeEnabled',
13
+ 'ctqInProgress',
14
+ 'outdialTransferToQueueEnabled',
15
+ 'taskToBeSelfServiced',
16
+ 'CONTINUE_RECORDING_ON_TRANSFER',
17
+ 'isParked',
18
+ 'participantInviteTimeout',
19
+ 'checkAgentAvailability',
20
+ ];
21
+
22
+ const interactionBooleanKeys: InteractionBooleanKey[] = [
23
+ 'isFcManaged',
24
+ 'isMediaForked',
25
+ 'isTerminated',
26
+ ];
27
+
28
+ const participantBooleanKeys: ParticipantBooleanKey[] = [
29
+ 'autoAnswerEnabled',
30
+ 'hasJoined',
31
+ 'hasLeft',
32
+ 'isConsulted',
33
+ 'isInPredial',
34
+ 'isOffered',
35
+ 'isWrapUp',
36
+ 'isWrappedUp',
37
+ ];
38
+
39
+ const toBoolean = (value: unknown): boolean | undefined => {
40
+ if (typeof value === 'boolean') {
41
+ return value;
42
+ }
43
+
44
+ if (typeof value === 'string') {
45
+ const normalized = value.toLowerCase();
46
+
47
+ if (normalized === 'true') {
48
+ return true;
49
+ }
50
+ if (normalized === 'false') {
51
+ return false;
52
+ }
53
+ }
54
+
55
+ return undefined;
56
+ };
57
+
58
+ const normalizeFields = <T extends Record<string, any>>(obj: T, keys: string[]): T | undefined => {
59
+ let updated: T | undefined;
60
+
61
+ keys.forEach((key) => {
62
+ const normalized = toBoolean(obj[key]);
63
+
64
+ if (typeof normalized !== 'undefined') {
65
+ if (!updated) {
66
+ updated = {...obj};
67
+ }
68
+ (updated as any)[key] = normalized;
69
+ }
70
+ });
71
+
72
+ return updated;
73
+ };
74
+
75
+ /**
76
+ * Normalize backend task payload quirks so downstream code can rely on actual booleans.
77
+ *
78
+ * Applies to every Agent Contact websocket event before it reaches the state machine:
79
+ * - Converts string booleans in callProcessingDetails to actual booleans.
80
+ * - Also normalizes known boolean fields on interaction and participants.
81
+ * - Keeps payload shape intact; only coerces known boolean fields.
82
+ */
83
+ export function normalizeTaskData(data: TaskData): TaskData {
84
+ const interaction = data?.interaction;
85
+
86
+ if (!interaction) {
87
+ return data;
88
+ }
89
+
90
+ const details = interaction.callProcessingDetails;
91
+ const updatedDetails = details ? normalizeFields(details, booleanKeys) : undefined;
92
+ const updatedInteractionBooleans = normalizeFields(
93
+ interaction,
94
+ interactionBooleanKeys as string[]
95
+ );
96
+
97
+ let updatedParticipants: typeof interaction.participants | undefined;
98
+ const participants = interaction.participants || {};
99
+ Object.keys(participants).forEach((id) => {
100
+ const participant = participants[id];
101
+ const normalized = normalizeFields(participant, participantBooleanKeys);
102
+ if (normalized) {
103
+ if (!updatedParticipants) {
104
+ updatedParticipants = {...participants};
105
+ }
106
+ updatedParticipants[id] = normalized;
107
+ }
108
+ });
109
+
110
+ let updatedMedia: typeof interaction.media | undefined;
111
+ const mediaEntries = interaction.media || {};
112
+ Object.keys(mediaEntries).forEach((id) => {
113
+ const media = mediaEntries[id];
114
+ const normalized = normalizeFields(media, ['isHold']);
115
+ if (normalized) {
116
+ if (!updatedMedia) {
117
+ updatedMedia = {...mediaEntries};
118
+ }
119
+ updatedMedia[id] = normalized;
120
+ }
121
+ });
122
+
123
+ if (!updatedDetails && !updatedInteractionBooleans && !updatedParticipants && !updatedMedia) {
124
+ return data;
125
+ }
126
+
127
+ return {
128
+ ...data,
129
+ interaction: {
130
+ ...interaction,
131
+ ...(updatedInteractionBooleans || {}),
132
+ callProcessingDetails: updatedDetails || details,
133
+ participants: updatedParticipants || interaction.participants,
134
+ media: updatedMedia || interaction.media,
135
+ },
136
+ };
137
+ }