Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 47 additions & 0 deletions packages/contact-center/ai-docs/migration/migration-overview.md
Original file line number Diff line number Diff line change
Expand Up @@ -269,5 +269,52 @@ const controls = currentTask?.uiControls ?? getDefaultUIControls();

---

### 2026-06-04 — Consult button disabled after consult ends/fails before consultee answers (multi-login)

**Issue:** On the initiating agent (Agent 1), after a consult to Agent 2 ends or fails before Agent 2 answers (RONA `AgentConsultFailed`, or `AgentConsultEnded` from either Stable Prod or Task Refactor), the held main leg showed `main.consult` visible but disabled, blocking a new consult.

**Root cause:**
- `CONSULT_END`/`CONSULT_FAILED` were not wired on all states the initiator can be in when the consult ends externally (`HELD`, `CONNECTED`, `CONSULT_INITIATING`), so `clearConsultState`/`handleConsultFailed` did not run and consult context flags stayed stale.
- `getIsConsultInProgressForConferenceControls` treated a RONA consultee (`isConsulted: true`, `consultState: consultReserved`, `hasJoined: false`) as an active consult, keeping `consultInProgress` true.

**Fix (SDK, kesari-aligned at the wiring/action layers):**
- `TaskStateMachine.ts`: wire `CONSULT_END` on `HELD` / `CONNECTED` / `CONSULT_INITIATING` and `CONSULT_FAILED` at root, reusing existing `clearConsultState` / `handleConsultFailed` actions and `isPrimaryMediaOnHold` guard.
- `actions.ts`: `deriveTaskDataUpdates` clears consult flags on terminal events; `clearConsultState` recomputes `uiControls`.
- `TaskUtils.ts`: `getIsConsultInProgressForConferenceControls` ignores RONA-pending consultees (`consultReserved` + `!hasJoined`).
- Tests: `uiControlsComputer.ts`, `TaskStateMachine.ts`, `TaskUtils.ts` (149 passing). Widgets consume `task.uiControls.main.consult` — no widget code change.

**Known deviation / planned follow-up (kesari principle 3 & 4):** `uiControlsComputer.ts` currently re-derives consult-ended state from `taskData` (`isConsultEndedForSelf` + `effectiveConsult*` shadow flags, the `isConsultUnansweredFailure` early-return, the consult retry path, and `getVoiceLegState` main-state inference). This duplicates flag-clearing already done in `actions.ts` and is UI-layer inference the principles discourage. Follow-up: remove these blocks and let `uiControlsComputer` read only the cleared context flags + `state`, relying on the existing `canFromConnected` path; keep all current tests green to confirm behavior is unchanged. The 4th touched SDK file (`TaskUtils.ts`) exceeds the 3-file guidance but is a shared selector, so the location is justified.

#### 2026-06-04 (follow-up) — Same button still disabled live, but enabled after refresh: stale consult media in merged `task.data`

**Symptom refinement:** After the wiring/action fixes above, the live consult button was still disabled when Agent 1 ended the consult before Agent 2 answered, yet a page refresh enabled it. A runtime diagnostic marker proved the fresh SDK was loaded and that `Task.computeUIControls()` (not just the state-machine action path) produces the final `uiControls` the widget consumes — computed with `this.data`, not the raw event payload.

**Root cause (data layer):** `Task.reconcileData` (`Task.ts`) is a recursive deep-merge that **never deletes keys**. `AgentConsultCreated` adds the consult-media entry (and consultee participant) into `task.data.interaction.media`/`participants`. The subsequent `AgentConsultEnded` payload contains only the main media, but the merge **retains** the stale consult-media key. `computeUIControls` then sees `hasConsultMedia === true` and the consult-enable branches (`isConsultUnansweredFailure`, the held-main consult retry path) — both gated on `!hasConsultMedia` — fail, leaving consult disabled. Refresh works because hydration overwrites `this.data` cleanly (no stale consult media).

**Fix (SDK, minimal, scoped to consult-ended):** In `uiControlsComputer.ts`, added `effectiveHasConsultMedia = isConsultEndedForSelf ? false : hasConsultMedia` (mirrors the existing `effectiveConsult*` shadow-flag pattern) and used it in the two consult-enable branches. A consult that has ended for self no longer counts its lingering media as active. No change to `reconcileData` (kesari principle 3 — avoid new/changed task-data merge layers and broad blast radius).

**Tests:** `uiControlsComputer.ts` now has a regression test that feeds the **reconciled** `this.data` (stale consult media + stale consultee participant retained) and asserts `main.consult` enabled (43 passing). The earlier end-before-answer test used the clean payload and therefore did not catch this.

**Note on kesari adherence:** This fix extends the same `uiControlsComputer` `taskData`-inference deviation already flagged above (it remains UI-layer compensation for a data-layer merge quirk). The architecturally clean fix is to make `reconcileData` treat the backend `interaction.media`/`participants` snapshot as authoritative (drop removed keys), which would let the `effectiveHasConsultMedia`/`isConsultUnansweredFailure` compensations be deleted — folded into the existing planned follow-up.

#### 2026-06-04 (root-cause fix) — Stale consult media/participant persists across subsequent events (consult disabled after resume)

**Symptom:** With the UI-layer gate above, consult was correctly enabled on the held main leg right after the consult ended. But on the **next** event — Agent 1 resumes the call (`AgentContactUnheld`, a clean snapshot: main media only, `consultState: null`) — the consult button went disabled again. The UI gate (`effectiveHasConsultMedia`) only applies on the consult-end/fail tick (`isConsultEndedForSelf`), so it no longer fired, while the stale consult media still lingered in `this.data`.

**Root cause (the real one, data layer):** `Task.reconcileData` deep-merges and never deletes keys. `interaction.media` and `interaction.participants` are **complete snapshots** from the backend, but the merge keeps consult-leg entries the backend already dropped. So every event after the consult ends still sees a stale consult-media key → `hasConsultMedia === true` → consult blocked. Refresh worked only because hydration overwrites `this.data` cleanly.

**Fix (SDK, `Task.ts`, scoped):** Added `pruneStaleInteractionMaps(incoming)`, called from `updateTaskData` only on the merge path (`shouldOverwrite === false`). It makes **only** `interaction.media` and `interaction.participants` authoritative to the incoming payload (removes keys absent from the incoming snapshot **when** that snapshot provides the map). Every other field stays on the existing generic deep-merge (CAD and other partial updates merge exactly as before — covered by a test). This removes the whack-a-mole: the stale consult leg is gone for resume, transfer, next-consult, and all later events.

**Why both fixes stay:** `pruneStaleInteractionMaps` handles events where the backend already dropped the consult media; `effectiveHasConsultMedia` still handles the consult-end/fail tick where the payload itself can still carry consult media (e.g. `AgentConsultFailed` RONA). They are complementary, not redundant.

**Tests:** `Task.ts` gains two tests — (1) stale consult media + consultee participant are pruned on a clean resume snapshot while CAD merge is preserved; (2) a partial update that omits the interaction maps does **not** prune them. Full task suite green: `Task`, `uiControlsComputer`, `TaskStateMachine`, `TaskUtils`, `TaskManager`, `Voice` — **285 passing**.

**Kesari note:** Fixing the existing `reconcileData` snapshot semantics (rather than adding UI heuristics) is the correct, root-cause layer. It is scoped to the two snapshot maps to avoid disturbing the intended partial-merge behavior, and it is the change that makes the earlier `uiControlsComputer` compensations eventually removable (still tracked as the planned follow-up).

---

_Created: 2026-03-09_
_Updated: 2026-05-20 (migration complete reference; per-leg TaskUIControls; SDK→store→widgets flow; outdial fix log; popup model)_
_Updated: 2026-06-04 (consult-button-disabled-after-end-before-answer fix log + kesari deviation follow-up note)_
_Updated: 2026-06-04 (stale-consult-media-in-reconciled-task-data root cause + effectiveHasConsultMedia fix + regression test)_
_Updated: 2026-06-04 (root-cause data-layer fix: Task.pruneStaleInteractionMaps makes interaction.media/participants authoritative; fixes consult disabled after resume; 285 task tests green)_
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import {
isTelephonyMediaType,
buildCallControlButtons,
filterButtonsForConsultation,
getConsultFilterPhase,
updateCallStateFromTask,
} from './call-control.utils';
import {withMetrics} from '@webex/cc-ui-logging';
Expand Down Expand Up @@ -144,8 +145,8 @@ function CallControlComponent(props: CallControlComponentProps) {
conferenceEnabled
);

const isConsulting = (controls?.consult?.endConsult?.isVisible || controls?.main?.endConsult?.isVisible) ?? false;
const filteredButtons = filterButtonsForConsultation(buttons, isConsulting, isTelephony, logger);
const consultFilterPhase = getConsultFilterPhase(currentTask, controls);
const filteredButtons = filterButtonsForConsultation(buttons, consultFilterPhase, isTelephony, logger);

if (!currentTask) return null;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -210,6 +210,12 @@ export const buildCallControlButtons = (
const isTransferVisible = mainCtrl?.transfer?.isVisible ?? false;
const isTransferEnabled = mainCtrl?.transfer?.isEnabled ?? false;
const isConsulting = (controls?.consult?.endConsult?.isVisible || controls?.main?.endConsult?.isVisible) ?? false;
// Consult requested: main leg shows transfer menu only (no hold/resume, no transferConsult duplicate).
const isConsultPendingOnMain =
isConsulting &&
isTransferVisible &&
(controls?.consult?.switch?.isVisible ?? false) &&
!(controls?.consult?.switch?.isEnabled ?? false);
const shouldPrioritizeTransferConference = isTransferConferenceVisible;
return [
{
Expand Down Expand Up @@ -240,7 +246,7 @@ export const buildCallControlButtons = (
tooltip: isHeld ? RESUME_CALL : HOLD_CALL,
className: 'call-control-button',
disabled: !(mainCtrl?.hold?.isEnabled ?? false),
isVisible: mainCtrl?.hold?.isVisible ?? false,
isVisible: (mainCtrl?.hold?.isVisible ?? false) && !isConsulting,
dataTestId: 'call-control:hold-toggle',
},
{
Expand All @@ -260,7 +266,11 @@ export const buildCallControlButtons = (
onClick: onTransferConsult || (() => {}),
className: 'call-control-button',
disabled: shouldPrioritizeTransferConference ? !isTransferConferenceEnabled : !isTransferEnabled,
isVisible: (isTransferVisible || shouldPrioritizeTransferConference) && isConsulting && !!onTransferConsult,
isVisible:
(isTransferVisible || shouldPrioritizeTransferConference) &&
isConsulting &&
!isConsultPendingOnMain &&
!!onTransferConsult,
},
{
id: 'conference',
Expand Down Expand Up @@ -324,33 +334,70 @@ export const buildCallControlButtons = (
}
};

export type ConsultFilterPhase = 'none' | 'pending' | 'active';

/**
* Distinguishes consult-requested (destination not joined) from active consult.
* Do not use endConsult visibility alone — it spans both phases.
*/
export const getConsultFilterPhase = (
currentTask: ITask | null | undefined,
controls: TaskUIControls | undefined
): ConsultFilterPhase => {
const endConsultVisible = controls?.consult?.endConsult?.isVisible || controls?.main?.endConsult?.isVisible;
if (!endConsultVisible) {
return 'none';
}

const agentId = currentTask?.data?.agentId;
const selfConsultState = agentId && currentTask?.data?.interaction?.participants?.[agentId]?.consultState;

if (currentTask?.data?.consultStatus === 'consultInitiated') {
return 'pending';
}
if (selfConsultState === 'consultInitiated') {
return 'pending';
}

// Pending: main transfer stays visible (disabled) while consult leg controls are not ready.
if (
controls?.main?.transfer?.isVisible &&
controls?.consult?.switch?.isVisible &&
!controls?.consult?.switch?.isEnabled
) {
return 'pending';
}

return 'active';
};

/**
* Filters buttons based on consultation state
* During consulting:
* - Hide: hold, consult, and blind transfer buttons
* - Respect SDK enabled/disabled state for consulting buttons (transferConsult, conference)
* They will be enabled when on main call, disabled when on consult call
* - Show as-is: mute, switchToConsult, recording, exitConference, end
* Filters buttons based on consultation phase.
* Pending (consult requested): hide hold/consult/record; keep transfer on main (Stable Prod parity).
* Active (destination joined): also hide blind transfer on main.
*/
export const filterButtonsForConsultation = (
buttons: CallControlButton[],
consultInitiated: boolean,
consultPhase: ConsultFilterPhase,
isTelephony: boolean,
logger?
logger?: ILogger
): CallControlButton[] => {
try {
if (!consultInitiated || !isTelephony) {
if (!isTelephony || consultPhase === 'none') {
return buttons;
}

if (consultPhase === 'pending') {
return buttons.filter((button) => !['hold', 'consult', 'record'].includes(button.id));
}

return buttons.filter((button) => !['hold', 'consult', 'transfer', 'record'].includes(button.id));
} catch (error) {
logger?.error('CC-Widgets: CallControl: Error in filterButtonsForConsultation', {
module: 'cc-components#call-control.utils.ts',
method: 'filterButtonsForConsultation',
error: error.message,
});
// Return original buttons as safe fallback
return buttons || [];
}
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {
CUSTOMER_NAME,
} from '../constants';
import {withMetrics} from '@webex/cc-ui-logging';
import {isSecondaryAgent} from '@webex/cc-store';

const CallControlCADComponent: React.FC<CallControlComponentProps> = (props) => {
const {
Expand Down Expand Up @@ -63,7 +64,14 @@ const CallControlCADComponent: React.FC<CallControlComponentProps> = (props) =>
const isTelephony = mediaChannel === MediaChannelType.TELEPHONY;
const participantsCount = conferenceParticipants?.length || 1;
const participantsLabel = participantsCount === 1 ? 'Participant' : 'Participants';
const shouldShowParticipantsList = (conferenceParticipants?.length || 0) > 1;
const interactionState = currentTask?.data?.interaction?.state;
const isConferenceActive =
controls?.main?.exitConference?.isVisible ||
currentTask?.data?.isConferenceInProgress === true ||
interactionState === 'conference';
const isConsultOnlyAgent = isSecondaryAgent(currentTask);
const shouldShowParticipantsList =
isConferenceActive && !isConsultOnlyAgent && (conferenceParticipants?.length ?? 0) > 0;

const customerName = currentTask?.data?.interaction?.callAssociatedDetails?.customerName;

Expand Down
Loading
Loading