Skip to content

fix(task-refactor): consult flow bug bash fixes#4962

Open
akulakum wants to merge 10 commits into
webex:task-refactorfrom
akulakum:CONSULT_BUG_BASH_FIXES
Open

fix(task-refactor): consult flow bug bash fixes#4962
akulakum wants to merge 10 commits into
webex:task-refactorfrom
akulakum:CONSULT_BUG_BASH_FIXES

Conversation

@akulakum
Copy link
Copy Markdown
Contributor

@akulakum akulakum commented May 7, 2026

COMPLETES #https://jira-eng-sjc12.cisco.com/jira/browse/CAI-7897

This pull request addresses

Issues in the contact center SDK task-refactor state machine related to the consult flow lifecycle and 3-party conference controls. The uiControlsComputer, state machine guards, actions, task manager, and voice service did not correctly handle: (1) post-call lifecycle when the customer leaves a 3-way consult interaction, (2) conference UI controls during and after merge, (3) EP_DN conference API payload resolution, and (4) pending vs real conference state distinction.

by making the following changes

1. Fix extra controls visible after customer ends call (uiControlsComputer.ts)

  • When customerPresent is false during an active consult, transfer, conference, mergeToConference, and switch controls now return VISIBLE_DISABLED on the consult leg and DISABLED (hidden) on the main leg.
  • The recording control on the main leg returns DISABLED when hasParallelConsultLeg && !customerPresent.

2. Emit post-call activity event to Widgets (TaskManager.ts)

  • When CC_EVENTS.PARTICIPANT_POST_CALL_ACTIVITY is received, the task manager now explicitly emits TASK_EVENTS.TASK_POST_CALL_ACTIVITY on the task object so Widgets can render the "Post Call" timer label.
  • Added null check before removeTaskFromCollection in handleContactMergedEvent to prevent cancelAutoWrapupTimer TypeError.

3. Fix hydration failure during post-call state (guards.ts, actions.ts)

  • Enhanced isInteractionConsulting guard to return true when interaction.state === 'post_call' if self-agent's consultState === 'consulting' and consult media exists.
  • Introduced isActiveConsultState helper to correctly gate hydration of consult context fields during post-call refresh.

4. Fix incorrect wrap-up transition and conference state actions (TaskStateMachine.ts)

  • Added guarded transition: when consultInitiator === true, interaction.isTerminated === true, and shouldWrapUpForThisAgent is true, transitions directly from CONSULTING → WRAPPING_UP.
  • Added updateTaskData/syncTaskDataFromEvent actions to CONFERENCE_START and UNHOLD_SUCCESS transitions to ensure task data is current when entering conference.
  • Added clearConsultState action to CONF_INITIATING → CONFERENCING transition to reset stale consult context.

5. Fix conference Exit Conference button for pending state (uiControlsComputer.ts)

  • exitConference returns DISABLED when participantCount <= 1 (pending conference where only initiating agent is present).

6. Fix EP_DN conference 400 Bad Request (Voice.ts)

  • Reordered resolution priority in consultConference() to use derivedDestAgentId/derivedDestType (computed from live interaction data via calculateDestAgentId/calculateDestType) as highest priority, ensuring EP_ID UUID is used instead of phone number.

7. Fix conference controls for pending vs real conference (uiControlsComputer.ts)

  • Uses participantCount to distinguish pending conference (only self agent in mainCall) vs real multi-agent conference:
    • Pending (participantCount <= 1): Transfer/Recording enabled, Consult disabled, Exit Conference hidden
    • Real (participantCount > 1): Transfer/Recording hidden, Consult enabled, Exit Conference visible

8. Fix missing wrapup screen after end call from pending conference (TaskManager.ts, TaskStateMachine.ts)

  • Added AgentConsultConferencing to the CONFERENCE_START event mapping in TaskManager so the state machine correctly transitions from CONF_INITIATING to CONFERENCING when Agent 1 merges before Agent 2 accepts.
  • Added CONSULT_END, CONTACT_ENDED, and TASK_WRAPUP handlers to the CONF_INITIATING state for defense-in-depth, ensuring wrapup transitions are never dropped.
  • Enhanced the CONFERENCING state's CONSULT_END handler to transition to WRAPPING_UP when isTerminated === true.

Change Type

  • Bug fix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to change)
  • Documentation update
  • Tooling change
  • Internal code refactor

The following scenarios were tested

< ENUMERATE TESTS PERFORMED, WHETHER MANUAL OR AUTOMATED >

The GAI Coding Policy And Copyright Annotation Best Practices

  • GAI was not used (or, no additional notation is required)
  • Code was generated entirely by GAI
  • GAI was used to create a draft that was subsequently customized or modified
  • Coder created a draft manually that was non-substantively modified by GAI (e.g., refactoring was performed by GAI on manually written code)
  • Tool used for AI assistance (GitHub Copilot / Other - specify)
    • Github Copilot
    • Other - Please Specify
  • This PR is related to
    • Feature
    • Defect fix
    • Tech Debt
    • Automation

I certified that

  • I have read and followed contributing guidelines
  • I discussed changes with code owners prior to submitting this pull request
  • I have not skipped any automated checks
  • All existing and new tests passed
  • I have updated the documentation accordingly

Make sure to have followed the contributing guidelines before submitting.

@akulakum akulakum requested a review from a team as a code owner May 7, 2026 05:14
@akulakum akulakum added the validated If the pull request is validated for automation. label May 7, 2026
@chatgpt-codex-connector
Copy link
Copy Markdown

Codex Review: Something went wrong. Try again later by commenting “@codex review”.

Provided git ref refs/pull/4962/head does not exist
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

};

const isActiveConsultState = (taskData: TaskData | undefined, selfAgentId?: string): boolean => {
if (taskData?.interaction?.state === 'consulting') return true;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

are we not using constants for 'consulting' and 'post_call' etc. if not consider doing it

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch! Added INTERACTION_STATE and CONSULT_STATE constants in constants.ts and updated all usages in actions.ts and guards.ts to use them instead of raw strings.


return (
taskData?.interaction?.isTerminated === true &&
shouldWrapUpForThisAgent(context, taskData)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Here using shouldWrapUpForThisAgent should suffice right why do we need extra conditions here ? We already have matches present for cases where initiator has to go back to HELD after end consult and consulted agent goes to terminated. We do not need this elaborated guard

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

shouldWrapUpForThisAgent alone would match even when the consult ends normally (where the initiator should return to HELD or CONFERENCING). The isTerminated check distinguishes between: (1) normal consult end — Agent 2 hangs up, main call still active → initiator goes to HELD, and (2) interaction terminated — customer left during consult → initiator should wrap up. Without isTerminated, this guard fires prematurely during a normal consult end and swallows transitions meant for the subsequent guards below it.

[TaskEvent.CONSULT_END]: {
actions: ['updateTaskData', 'clearConsultState'],
},
[TaskEvent.CONSULT_END]: [
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Which case this new transitions would be handling ?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This handles: Agent 1 merges EP_DN consult → enters CONFERENCING → clicks End Call → backend sends AgentConsultEnded with isTerminated: true. Previously, CONSULT_END in CONFERENCING only cleared consult state without transitioning, so the agent stayed stuck in CONFERENCING instead of moving to WRAPPING_UP. The guard checks isTerminated && shouldWrapUpForThisAgent to only transition when the call has actually ended.

actions: ['handleConferenceFailed', 'emitTaskConferenceFailed'],
},
// AgentConsultEnded while conference is initiating (end call before conference completes)
[TaskEvent.CONSULT_END]: [
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would like to understand these 2 cases as well, Could you please elaborate these for me

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These are defense-in-depth handlers for race conditions. After Agent 1 clicks merge, the state machine enters CONF_INITIATING while awaiting CONFERENCE_START. If the call ends or consult ends before the conference confirmation arrives (e.g., network delay, Agent 1 immediately clicks End Call), these events would be dropped since CONF_INITIATING previously only handled CONFERENCE_START and CONFERENCE_FAILED.

Two cases:

  1. isTerminated === true → interaction has ended, transition to WRAPPING_UP
  2. Not terminated → consult ended normally, transition back to CONNECTED

const isActiveConsultState = (taskData: TaskData | undefined, selfAgentId?: string): boolean => {
if (taskData?.interaction?.state === INTERACTION_STATE.CONSULTING) return true;
if (taskData?.interaction?.state === INTERACTION_STATE.POST_CALL && selfAgentId) {
const selfParticipant = taskData.interaction?.participants?.[selfAgentId] as any;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Using any should be avoided as much as possible

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed. Removed as any cast on selfParticipant (direct property access from participants map) and used explicit type assertion (media as {mType?: string})?.mType for media objects instead of as any.

};

const selfAgentId = context.uiControlConfig.agentId ?? taskData?.agentId;
const consultingActive = isActiveConsultState(taskData, selfAgentId);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Isn't there a simpler way to figure out if there is an active consult ? State machine state itself would be consulting for active consult. We are using isActiveConsult only in one place and it looks like unncessary complicated logic to just figure out if there is an active consult.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This function is used specifically during hydration (page refresh) in deriveTaskDataUpdates. At that point, the state machine state hasn't been restored yet — we're inspecting raw TaskData from the backend to determine which context fields to populate. We can't rely on the state machine's current state because it's being rebuilt from scratch. The post_call case is needed because when the customer leaves during a consult, interaction.state becomes 'post_call' but the consult between agents is still active. Without this check, consult context fields wouldn't be populated on refresh, and the UI would lose the consulting section.

const consultDestinationType =
'destinationType' in event ? event.destinationType ?? null : null;
const consultDestinationAgentId = 'destAgentId' in event ? event.destAgentId ?? null : null;
const consultDestinationAgentId =
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why was this change required ?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For EP_DN consults, event.destAgentId may not be present in the event payload, but event.destination contains the Entry Point UUID needed for conference API calls. This change ensures we store the EP_DN UUID in consultDestinationAgentId so that Voice.consultConference() can resolve the correct destAgentId when initiating a conference, preventing the 400 Bad Request error.


if (taskData.destAgentId) {
updates.consultDestinationAgentId = taskData.destAgentId;
const isEpDnWithStoredId =
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please explain this change

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

During hydration, taskData.destAgentId can contain the EP_DN phone number (e.g., +13159998059) instead of the Entry Point UUID. If the context already has the correct UUID stored from the live consult flow (consultDestinationType === 'entryPoint' and consultDestinationAgentId exists), we skip overwriting it with the phone number. Without this, after a page refresh, the conference API would send the phone number instead of the UUID, causing a 400 Bad Request.

}

// Customer left during consult: interaction state is "post_call" but consult
// between agents is still active. Detect via agent's consultState + consult media.
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How was this guard impacting the flow and this change was needed here ? and this looks very similar to what we are doing in isActiveConsult as well

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The isInteractionConsulting guard determines which state the machine hydrates into on page refresh. Without this post_call check, when the customer leaves during an active consult, the guard returns false (since interaction.state is 'post_call', not 'consulting'), and the state machine hydrates to CONNECTED instead of CONSULTING — causing the agent to lose the entire consulting section and controls. The similarity with isActiveConsultState is by design: the guard decides the target state (CONSULTING), while isActiveConsultState in actions decides whether to populate consult context fields. Both need the same detection logic at different layers. We can extract a shared utility if preferred.

isConsulted: true,
},
'customer-1': {id: 'customer-1', pType: 'CUSTOMER', hasLeft: false},
'customer-1': {id: 'customer-1', pType: 'Customer', hasLeft: false},
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Did we not need any other unit test changes ?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added 11 new unit tests covering: (1) conference controls — pending vs real conference (transfer/recording/consult/exitConference behavior based on participant count), (2) hold visibility in conference, (3) post-call consult controls when customer left, (4) CONF_INITIATING state transitions (CONSULT_END, CONTACT_ENDED, TASK_WRAPUP), (5) CONFERENCING → WRAPPING_UP on CONSULT_END with isTerminated, and (6) isInteractionConsulting guard for EP_DN and post_call hydration scenarios.

// derivedDestAgentId is most reliable as it resolves epId for EP_DN
// and agent ID for regular agents from live interaction data
destAgentId:
derivedDestAgentId ||
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is it always present ?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No, it's not always present — it's derived from calculateDestAgentId() which inspects live interaction participants. It returns empty string if no consulted agent or EP_DN participant is found. That's why we have a fallback chain: derivedDestAgentId || context.consultDestinationAgentId || this.data.destAgentId. The derivedDestAgentId is the most reliable source because it resolves the correct EP_ID UUID for EP_DN from the interaction's participant epId field, but we fall back to context/taskData if the interaction data isn't available yet.

@akulakum akulakum requested a review from Kesari3008 May 12, 2026 04:48
@akulakum
Copy link
Copy Markdown
Contributor Author

  1. Removed isActiveConsultState function from actions.ts — inlined its logic into deriveTaskDataUpdates using the shared hasActiveConsultInPostCall utility
  2. Extracted hasActiveConsultInPostCall shared utility in guards.ts — eliminates duplicate post_call + consult detection logic between actions.ts and guards.ts
  3. Simplified isInteractionConsulting guard to use hasActiveConsultInPostCall instead of repeating the same checks
  4. Moved hasCustomerLeft check inside shouldWrapUpForThisAgent via requireCustomerLeft option — simplifies Guard 3 in TaskStateMachine.ts (removes inline callProcessingDetails access from the guard)
  5. Replaced magic strings 'consulting', 'post_call', 'consult' with INTERACTION_STATE, CONSULT_STATE, MEDIA_TYPE_CONSULT constants

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

validated If the pull request is validated for automation.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants