diff --git a/packages/contact-center/store/ai-docs/store-spec.md b/packages/contact-center/store/ai-docs/store-spec.md index e0a3dbd84..285abf528 100644 --- a/packages/contact-center/store/ai-docs/store-spec.md +++ b/packages/contact-center/store/ai-docs/store-spec.md @@ -4,21 +4,23 @@ > Context-efficiency: link to canonical docs — don't duplicate them. Load specs on demand per `SPEC_INDEX.md`. ## Metadata -| Field | Value | -|---|---| -| Module id | `store` | -| Source path(s) | `packages/contact-center/store/src/` | -| Doc kind | Module spec | -| Coverage score | Pending coverage assessment | -| Generated from | `module-spec` @ SDLC template library `0.1.0-draft` | + +| Field | Value | +| --------------------------------------- | ----------------------------------------------------------------------------- | +| Module id | `store` | +| Source path(s) | `packages/contact-center/store/src/` | +| Doc kind | Module spec | +| Coverage score | Pending coverage assessment | +| Generated from | `module-spec` @ SDLC template library `0.1.0-draft` | | generated_by / approved_by / updated_at | generated_by: migration agent / approved_by: pending / updated_at: 2026-06-29 | -| Validation status | not-run | +| Validation status | not-run | Coverage score: `Pending coverage assessment` before the first report; after assessment, replace with `<0-100%>` plus the report path/evidence. Keep manifest coverage state outside the rendered module doc metadata. ## Evidence Rules + Every generated requirement below must cite concrete source evidence using `file path`. Separate source evidence, test evidence, examples, assumptions, and gaps so validators and future agents can distinguish truth from context. Test evidence is preferred for WHY. Commit evidence is allowed only when the @@ -27,13 +29,15 @@ conflicting, ask a focused discovery question before finalizing the requirement; as approved unknowns only when the human explicitly defers or does not know. ## Source Material Register -| Source doc | Scope | Decision | Detail location or disposition | -|---|---|---|---| -| `ai-docs/_archive/pre-sdlc-migration/packages/contact-center/store/ai-docs/AGENTS.md` | overview / API / usage | migrated | Overview, Purpose, Public Surface, Use Cases; usage snippets condensed to behavior. | -| `ai-docs/_archive/pre-sdlc-migration/packages/contact-center/store/ai-docs/ARCHITECTURE.md` | architecture / sequence diagrams | reconciled | Design Overview, Data Flow, Sequence Diagram(s), Pitfalls. Diagrams re-derived from current `store.ts` / `storeEventsWrapper.ts`; see Conflicts note below for drift corrected. | -| `@webex/contact-center` package types (`node_modules/@webex/contact-center/dist/types/index.d.ts`) | SDK API reference (installed `.d.ts`) | reference-only | Linked as the authoritative source for SDK-shaped types/methods consumed via `store.cc.*`. | + +| Source doc | Scope | Decision | Detail location or disposition | +| -------------------------------------------------------------------------------------------------- | ------------------------------------- | -------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `ai-docs/_archive/pre-sdlc-migration/packages/contact-center/store/ai-docs/AGENTS.md` | overview / API / usage | migrated | Overview, Purpose, Public Surface, Use Cases; usage snippets condensed to behavior. | +| `ai-docs/_archive/pre-sdlc-migration/packages/contact-center/store/ai-docs/ARCHITECTURE.md` | architecture / sequence diagrams | reconciled | Design Overview, Data Flow, Sequence Diagram(s), Pitfalls. Diagrams re-derived from current `store.ts` / `storeEventsWrapper.ts`; see Conflicts note below for drift corrected. | +| `@webex/contact-center` package types (`node_modules/@webex/contact-center/dist/types/index.d.ts`) | SDK API reference (installed `.d.ts`) | reference-only | Linked as the authoritative source for SDK-shaped types/methods consumed via `store.cc.*`. | ## Overview + `@webex/cc-store` is the single shared MobX store for every Webex Contact Center widget. It is the sole boundary between widgets and the `@webex/contact-center` SDK: widgets never import the SDK directly — they read observables and call methods on the store, which proxies to `store.cc.*`. The package is structured in two layers. `Store` (`src/store.ts`) is a `makeAutoObservable` singleton (`Store.getInstance()`) that holds raw observable state and owns initialization/registration with the SDK. `StoreWrapper` (`src/storeEventsWrapper.ts`) is the default export — it wraps the singleton, getter-proxies every observable, owns all SDK event wiring (CC + task events), exposes mutators (all writes funnel through `runInAction`), list-fetch helpers, callback registration, and task-lifecycle handling. `src/index.ts` re-exports the `StoreWrapper` instance as the default export plus everything from `store.types.ts` (types, the `CC_EVENTS` / `TASK_EVENTS` enums, login/consult/campaign constants) and `task-utils.ts` (pure selectors over SDK `ITask` objects). `util.ts` extracts a fixed allow-list of feature flags from the agent `Profile` at registration time. @@ -41,12 +45,14 @@ as approved unknowns only when the human explicitly defers or does not know. A maintainer should start at `src/store.ts` to understand the observable shape and init/register flow, then `src/storeEventsWrapper.ts` for how SDK events drive observable updates, then `src/task-utils.ts` for the read-only task/consult/conference selectors widgets consume. ## Purpose / Responsibility + Owns Contact Center client-side state and the SDK boundary: initialize/register with `@webex/contact-center`, subscribe to CC and task events, expose reactive observables and mutators, fetch domain lists (buddy agents, queues, entry points, address book), and centralize the error callback. It does NOT own UI rendering, business validation, or any direct network protocol beyond delegating to the SDK. ## Stack TypeScript 5.6.3, MobX 6.13.5 (`makeAutoObservable`, `observable.ref`, `runInAction`). Consumed in React 18 via `mobx-react-lite` `observer()` in downstream packages (not a dependency of this package itself). SDK peer `@webex/contact-center` 3.12.0-next.82. Tests: Jest 29 + ts compile (`tsc --project tsconfig.test.json && jest --coverage`). Build target: `dist/index.js` (Webpack). Evidence: `packages/contact-center/store/package.json`. ## Folder / Package Structure + ``` packages/contact-center/store/src/ ├── index.ts # Barrel: default StoreWrapper instance + re-export of types & task-utils @@ -57,28 +63,32 @@ packages/contact-center/store/src/ ├── util.ts # getFeatureFlags(): allow-list extraction from agent Profile └── constants.ts # Task/interaction/consult state + participant-type string constants ``` + Tests mirror src under `packages/contact-center/store/tests/` (`store.ts`, `storeEventsWrapper.ts`, `task-utils.ts`, `util.ts`). ## Key Files (source of truth) -| File | Holds | -|---|---| -| `packages/contact-center/store/src/store.ts` | The observable state shape, the 6000ms init timeout, and the `registerCC` profile→observable mapping. Never re-declare these defaults elsewhere. | + +| File | Holds | +| -------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `packages/contact-center/store/src/store.ts` | The observable state shape, the 6000ms init timeout, and the `registerCC` profile→observable mapping. Never re-declare these defaults elsewhere. | | `packages/contact-center/store/src/store.types.ts` | `CC_EVENTS` / `TASK_EVENTS` event-name enums, `ConsultStatus`, `LoginOptions` order, `ERROR_TRIGGERING_IDLE_CODES`, `CAMPAIGN_PREVIEW_*` type lists, and the public export barrel. | -| `packages/contact-center/store/src/util.ts` | The exact feature-flag allow-list parsed from the agent profile. | -| `packages/contact-center/store/src/constants.ts` | Canonical task/interaction/consult state strings and `EXCLUDED_PARTICIPANT_TYPES`. | -| `packages/contact-center/store/src/index.ts` | The public export surface (default store + types + task-utils). | +| `packages/contact-center/store/src/util.ts` | The exact feature-flag allow-list parsed from the agent profile. | +| `packages/contact-center/store/src/constants.ts` | Canonical task/interaction/consult state strings and `EXCLUDED_PARTICIPANT_TYPES`. | +| `packages/contact-center/store/src/index.ts` | The public export surface (default store + types + task-utils). | ## Public Surface + This module is consumed as an imported SDK/code API (the `@webex/cc-store` package), not a network surface. Root index: [`CONTRACTS.md`](../../../../ai-docs/CONTRACTS.md). -| Contract ID | Type | Surface | Purpose | Compatibility / deprecation | Schema / detail link | Root index | -|---|---|---|---|---|---|---| -| `store.instance` | SDK | default export `store` (StoreWrapper singleton); `init(options, setupEventListeners)`, `registerCC(webex?)`, observable getters, mutators, `getBuddyAgents/getQueues/getEntryPoints/getAddressBookEntries`, `setOnError`, `setCCCallback/removeCCCallback`, `setTaskCallback/removeTaskCallback` | Sole SDK access point and shared reactive state for all CC widgets | stable semver; observable getter set is additive | `packages/contact-center/store/src/storeEventsWrapper.ts`, `src/store.ts` | [`CONTRACTS.md`](../../../../ai-docs/CONTRACTS.md) | -| `store.types` | SDK | type re-exports (`IContactCenter`, `ITask`, `Profile`, `Team`, `IStore`, `IStoreWrapper`, `InitParams`, `RealTimeTranscriptionData`, ~20 more) | Typed domain surface for widget code | stable semver; SDK-shaped types track the SDK | `packages/contact-center/store/src/store.types.ts:334-366`; SDK: `@webex/contact-center` types (`node_modules/@webex/contact-center/dist/types/index.d.ts`) | [`CONTRACTS.md`](../../../../ai-docs/CONTRACTS.md) | -| `store.constants` | SDK | value/enum exports (`CC_EVENTS`, `TASK_EVENTS`, `ConsultStatus`, `LoginOptions`, `CAMPAIGN_PREVIEW_*`, `DESKTOP`/`EXTENSION`/`DIAL_NUMBER`) | Event names + domain enums for widgets | stable semver | `packages/contact-center/store/src/store.types.ts:368-403` | [`CONTRACTS.md`](../../../../ai-docs/CONTRACTS.md) | -| `store.task-utils` | SDK | pure selectors (`isIncomingTask`, `getTaskStatus`, `getConsultStatus`, `getConferenceParticipants`, `getConferenceParticipantsCount`, `isInteractionOnHold`, `findHoldStatus`, `findHoldTimestamp`, etc.) | Read-only derivations over `ITask` | stable semver | `packages/contact-center/store/src/task-utils.ts` | [`CONTRACTS.md`](../../../../ai-docs/CONTRACTS.md) | +| Contract ID | Type | Surface | Purpose | Compatibility / deprecation | Schema / detail link | Root index | +| ------------------ | ---- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------ | ------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------- | +| `store.instance` | SDK | default export `store` (StoreWrapper singleton); `init(options, setupEventListeners)`, `registerCC(webex?)`, observable getters, mutators, `getBuddyAgents/getQueues/getEntryPoints/getAddressBookEntries`, `setOnError`, `setCCCallback/removeCCCallback`, `setTaskCallback/removeTaskCallback` | Sole SDK access point and shared reactive state for all CC widgets | stable semver; observable getter set is additive | `packages/contact-center/store/src/storeEventsWrapper.ts`, `src/store.ts` | [`CONTRACTS.md`](../../../../ai-docs/CONTRACTS.md) | +| `store.types` | SDK | type re-exports (`IContactCenter`, `ITask`, `Profile`, `Team`, `IStore`, `IStoreWrapper`, `InitParams`, `RealTimeTranscriptionData`, ~20 more) | Typed domain surface for widget code | stable semver; SDK-shaped types track the SDK | `packages/contact-center/store/src/store.types.ts:334-366`; SDK: `@webex/contact-center` types (`node_modules/@webex/contact-center/dist/types/index.d.ts`) | [`CONTRACTS.md`](../../../../ai-docs/CONTRACTS.md) | +| `store.constants` | SDK | value/enum exports (`CC_EVENTS`, `TASK_EVENTS`, `ConsultStatus`, `LoginOptions`, `CAMPAIGN_PREVIEW_*`, `DESKTOP`/`EXTENSION`/`DIAL_NUMBER`) | Event names + domain enums for widgets | stable semver | `packages/contact-center/store/src/store.types.ts:368-403` | [`CONTRACTS.md`](../../../../ai-docs/CONTRACTS.md) | +| `store.task-utils` | SDK | pure selectors (`isIncomingTask`, `getTaskStatus`, `getConsultStatus`, `getConferenceParticipants`, `getConferenceParticipantsCount`, `isInteractionOnHold`, `findHoldStatus`, `findHoldTimestamp`, etc.) | Read-only derivations over `ITask` | stable semver | `packages/contact-center/store/src/task-utils.ts` | [`CONTRACTS.md`](../../../../ai-docs/CONTRACTS.md) | Compatibility notes: + - Adding a new observable getter or mutator is additive (minor). Removing/renaming an observable, mutator, or changing the `CC_EVENTS`/`TASK_EVENTS` enum values is breaking (major) — widgets and the SDK event stream depend on the exact string values. - The `CC_EVENTS` / `TASK_EVENTS` enums are locally declared until the SDK exports them (see `// TODO: remove this once cc sdk exports this enum`, `store.types.ts:247`). They must stay byte-identical to the SDK's emitted event strings. @@ -88,31 +98,34 @@ Compatibility notes: - Internal: none upstream. The store is the lowest widget-layer dependency (`cc-components → widget packages → store → SDK`); it imports no widget package. ## Requirements -| ID | WHAT | WHY | Source Evidence | Test / Example Evidence | Assumptions / Gaps | Confidence | -|---|---|---|---|---|---|---| -| `STORE-R-001` | `Store.getInstance()` returns one shared singleton instance; the default export is a single `StoreWrapper` over it | All widgets must share one source of truth for agent/session/task state | `packages/contact-center/store/src/store.ts:64-72`, `src/storeEventsWrapper.ts:51-53,1112-1114` | `tests/store.ts` ("should initialize with default values") | none | PRESENT | -| `STORE-R-002` | `init({webex})` registers immediately; `init({webexConfig, access_token})` calls `Webex.init()`, waits for the `ready` event, then registers | Supports both host-provided Webex and store-bootstrapped Webex | `src/store.ts:132-188` | `tests/store.ts` (init: "should call registerCC if webex is in options", "should initialize webex and call registerCC on ready event") | none | PRESENT | -| `STORE-R-003` | When bootstrapping Webex, init rejects with `Webex SDK failed to initialize` if the `ready` event has not fired within 6000ms | Prevents widgets hanging forever on an unreachable SDK | `src/store.ts:139-142` | `tests/store.ts` ("should reject the promise if Webex SDK fails to initialize") | none | PRESENT | -| `STORE-R-004` | `registerCC()` throws `Webex SDK not initialized` when neither a `webex` arg nor a prior `this.cc` exists | Fail fast on misuse instead of a later null deref | `src/store.ts:74-81` | `tests/store.ts` ("should throw error if webex and cc object are not present") | none | PRESENT | -| `STORE-R-005` | On successful `register()`, the profile is mapped into observables (teams, idleCodes, agentId, wrapupCodes, deviceType, dialNumber, teamId, timestamps, feature flags); registration failures reject and are logged | Populates initial state so widgets render correctly; surfaces failures | `src/store.ts:89-129` | `tests/store.ts` ("should initialise store values on successful register", "should log an error on failed register") | none | PRESENT | -| `STORE-R-006` | `loginOptions` excludes `BROWSER` unless `webRtcEnabled`, and is sorted by the `LoginOptions` key order | WebRTC/browser calling is gated by org capability; UI ordering must be stable | `src/store.ts:100-103`, `src/store.types.ts:319-323` | `tests/store.ts` ("should initialise store values on successful register") | none | PRESENT | -| `STORE-R-007` | `featureFlags` is restricted to a fixed allow-list of profile keys, omitting `undefined` values | Avoid leaking arbitrary profile fields and keep a known flag surface | `src/util.ts:3-36` | `tests/util.ts` ("should return an object with feature flags from agent profile...") | none | PRESENT | -| `STORE-R-008` | All observable mutations go through `runInAction` (directly or via mutators) | MobX strict-mode correctness; batched, atomic reactive updates | `src/storeEventsWrapper.ts` (e.g. 189-237, 269-282, 303-323, 906-921, 1008-1023) | `tests/storeEventsWrapper.ts` ("storeEventsWrapper Proxies", "setState") | none | PRESENT | -| `STORE-R-009` | `setCurrentTask` ignores incoming tasks and pending (state `new`, not yet accepted) campaign-preview tasks (clears `currentTask`); deep-clones the task; fires `onTaskSelected` only when the task actually changes | CallControl must not render for previews still showing Accept/Skip; avoid stale callbacks | `src/storeEventsWrapper.ts:243-283` | `tests/storeEventsWrapper.ts` ("setCurrentTask", "campaign preview task lifecycle") | none | PRESENT | -| `STORE-R-010` | `refreshTaskList()` re-reads `cc.taskManager.getAllTasks()` and reconciles `currentTask`: clears + resets state when empty, keeps current if still present, else promotes the first task | Keep the store's task view consistent with the SDK after any task event | `src/storeEventsWrapper.ts:303-323` | `tests/storeEventsWrapper.ts` ("refreshTaskList") | none | PRESENT | -| `STORE-R-011` | Incoming tasks register the full task-event listener set once; the `onIncomingTask` callback fires only for genuinely new tasks (not already in `taskList`) | Avoid duplicate listeners and duplicate incoming-task UI for consult/re-entry | `src/storeEventsWrapper.ts:690-762` | `tests/storeEventsWrapper.ts` ("storeEventsWrapper events reactions") | none | PRESENT | -| `STORE-R-012` | `handleTaskRemove` detaches every task listener, clears `realtimeTranscriptionData` for the removed current task, drops accepted-campaign tracking, resets custom state, and refreshes the list | Prevent listener/audio/state leaks across task lifecycles | `src/storeEventsWrapper.ts:458-521` | `tests/storeEventsWrapper.ts` ("handleTaskRemove — campaign ID cleanup") | Per-listener detach is asserted only partially; full leak audit is a gap | PRESENT | -| `STORE-R-013` | `agent:logoutSuccess` triggers `cleanUpStore()` which resets session observables and removes CC SDK listeners; `agent:multiLogin` sets `showMultipleLoginAlert` | Clean session teardown and multi-login warning | `src/storeEventsWrapper.ts:811-819,1003-1024,1029-1066` | `tests/storeEventsWrapper.ts` ("storeEventsWrapper events reactions") | none | PRESENT | -| `STORE-R-014` | `agent:stateChange` (type `AgentStateChangeSuccess`) updates `currentState` (defaulting `auxCodeId` `''`→`'0'`) and both state-change timestamps | Drives the agent-state widget and timers | `src/storeEventsWrapper.ts:797-809` | `tests/storeEventsWrapper.ts` ("storeEventsWrapper events reactions") | none | PRESENT | -| `STORE-R-015` | List fetchers proxy the SDK and propagate errors after logging; `getQueues` filters by upper-cased channel type; `getAddressBookEntries` returns empty when `isAddressBookEnabled` is false | Centralize SDK fetch + transform so widgets stay SDK-agnostic | `src/storeEventsWrapper.ts:924-1001` | `tests/storeEventsWrapper.ts` ("storeEventsWrapper", "getAccessToken") | `getBuddyAgents`/`getQueues` happy-path filtering covered; address-book disabled branch coverage is a gap | PRESENT | -| `STORE-R-016` | `setOnError` wraps the caller callback to also submit a behavioral metrics event before invoking it | Consistent telemetry on widget errors | `src/storeEventsWrapper.ts:285-301` | None found | Negative/telemetry-path test missing | WEAK | -| `STORE-R-017` | `isIncomingTask` returns true only when the task is not wrap-up-required, the agent has not joined, and the interaction state is `new`/`consult`/`connected`/`conference` | Gates whether a task is treated as an unanswered incoming offer | `src/task-utils.ts:26-37` | `tests/task-utils.ts` ("isIncomingTask" — incoming / not incoming / edge cases) | none | PRESENT | -| `STORE-R-018` | `getConsultStatus`/`getTaskStatus` map participant `consultState` + interaction state to a `ConsultStatus`, with special handling for secondary EP-DN agents | Consult/conference UI relies on a single derived status | `src/task-utils.ts:39-146` | None found (direct `getConsultStatus` test) | Only `isIncomingTask`, conference, and hold helpers are directly tested; consult-status helper is a gap | WEAK | -| `STORE-R-019` | Conference helpers (`getIsConferenceInProgress`, `getConferenceParticipants`, `getConferenceParticipantsCount`) count only active agent participants, excluding `Customer`/`Supervisor`/`VVA` and those who left | Accurate conference participant display | `src/task-utils.ts:148-247`, `src/constants.ts:33` | `tests/task-utils.ts` ("getIsConferenceInProgress", "getConferenceParticipants", "getConferenceParticipantsCount") | none | PRESENT | -| `STORE-R-020` | `findHoldTimestamp`/`findHoldStatus` resolve hold state per media type, remapping to `mainCall` for secondary EP-DN agents | Hold timers align with Agent Desktop across consult/conference | `src/task-utils.ts:285-362` | `tests/task-utils.ts` ("findHoldTimestamp") | `findHoldStatus` direct coverage is a gap | PRESENT | -| `STORE-R-021` | `handleRealtimeTranscription` upserts transcript lines keyed by `messageId`, normalizing role/timestamp and dropping empty content | Live transcription panel needs deduped, ordered lines | `src/storeEventsWrapper.ts:891-922` | None found | No dedicated transcription test located | WEAK | + +| ID | WHAT | WHY | Source Evidence | Test / Example Evidence | Assumptions / Gaps | Confidence | +| ------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------- | ---------- | +| `STORE-R-001` | `Store.getInstance()` returns one shared singleton instance; the default export is a single `StoreWrapper` over it | All widgets must share one source of truth for agent/session/task state | `packages/contact-center/store/src/store.ts:64-72`, `src/storeEventsWrapper.ts:51-53,1112-1114` | `tests/store.ts` ("should initialize with default values") | none | PRESENT | +| `STORE-R-002` | `init({webex})` registers immediately; `init({webexConfig, access_token})` calls `Webex.init()`, waits for the `ready` event, then registers | Supports both host-provided Webex and store-bootstrapped Webex | `src/store.ts:132-188` | `tests/store.ts` (init: "should call registerCC if webex is in options", "should initialize webex and call registerCC on ready event") | none | PRESENT | +| `STORE-R-003` | When bootstrapping Webex, init rejects with `Webex SDK failed to initialize` if the `ready` event has not fired within 6000ms | Prevents widgets hanging forever on an unreachable SDK | `src/store.ts:139-142` | `tests/store.ts` ("should reject the promise if Webex SDK fails to initialize") | none | PRESENT | +| `STORE-R-004` | `registerCC()` throws `Webex SDK not initialized` when neither a `webex` arg nor a prior `this.cc` exists | Fail fast on misuse instead of a later null deref | `src/store.ts:74-81` | `tests/store.ts` ("should throw error if webex and cc object are not present") | none | PRESENT | +| `STORE-R-005` | On successful `register()`, the profile is mapped into observables (teams, idleCodes, agentId, wrapupCodes, deviceType, dialNumber, teamId, timestamps, feature flags); registration failures reject and are logged | Populates initial state so widgets render correctly; surfaces failures | `src/store.ts:89-129` | `tests/store.ts` ("should initialise store values on successful register", "should log an error on failed register") | none | PRESENT | +| `STORE-R-006` | `loginOptions` excludes `BROWSER` unless `webRtcEnabled`, and is sorted by the `LoginOptions` key order | WebRTC/browser calling is gated by org capability; UI ordering must be stable | `src/store.ts:100-103`, `src/store.types.ts:319-323` | `tests/store.ts` ("should initialise store values on successful register") | none | PRESENT | +| `STORE-R-007` | `featureFlags` is restricted to a fixed allow-list of profile keys, omitting `undefined` values | Avoid leaking arbitrary profile fields and keep a known flag surface | `src/util.ts:3-36` | `tests/util.ts` ("should return an object with feature flags from agent profile...") | none | PRESENT | +| `STORE-R-008` | All observable mutations go through `runInAction` (directly or via mutators) | MobX strict-mode correctness; batched, atomic reactive updates | `src/storeEventsWrapper.ts` (e.g. 189-237, 269-282, 303-323, 906-921, 1008-1023) | `tests/storeEventsWrapper.ts` ("storeEventsWrapper Proxies", "setState") | none | PRESENT | +| `STORE-R-009` | `setCurrentTask` ignores incoming tasks and pending (state `new`, not yet accepted) campaign-preview tasks (clears `currentTask`); deep-clones the task; fires `onTaskSelected` only when the task actually changes | CallControl must not render for previews still showing Accept/Skip; avoid stale callbacks | `src/storeEventsWrapper.ts:243-283` | `tests/storeEventsWrapper.ts` ("setCurrentTask", "campaign preview task lifecycle") | none | PRESENT | +| `STORE-R-010` | `refreshTaskList()` re-reads `cc.taskManager.getAllTasks()` and reconciles `currentTask`: clears + resets state when empty, keeps current if still present, else promotes the first task | Keep the store's task view consistent with the SDK after any task event | `src/storeEventsWrapper.ts:303-323` | `tests/storeEventsWrapper.ts` ("refreshTaskList") | none | PRESENT | +| `STORE-R-011` | Incoming tasks register the full task-event listener set once; the `onIncomingTask` callback fires only for genuinely new tasks (not already in `taskList`) | Avoid duplicate listeners and duplicate incoming-task UI for consult/re-entry | `src/storeEventsWrapper.ts:690-762` | `tests/storeEventsWrapper.ts` ("storeEventsWrapper events reactions") | none | PRESENT | +| `STORE-R-012` | `handleTaskRemove` detaches every task listener, clears `realtimeTranscriptionData` for the removed current task, drops accepted-campaign tracking, resets custom state, and refreshes the list | Prevent listener/audio/state leaks across task lifecycles | `src/storeEventsWrapper.ts:458-521` | `tests/storeEventsWrapper.ts` ("handleTaskRemove — campaign ID cleanup") | Per-listener detach is asserted only partially; full leak audit is a gap | PRESENT | +| `STORE-R-013` | `agent:logoutSuccess` triggers `cleanUpStore()` which resets session observables and removes CC SDK listeners; `agent:multiLogin` sets `showMultipleLoginAlert` | Clean session teardown and multi-login warning | `src/storeEventsWrapper.ts:811-819,1003-1024,1029-1066` | `tests/storeEventsWrapper.ts` ("storeEventsWrapper events reactions") | none | PRESENT | +| `STORE-R-014` | `agent:stateChange` (type `AgentStateChangeSuccess`) updates `currentState` (defaulting `auxCodeId` `''`→`'0'`) and both state-change timestamps | Drives the agent-state widget and timers | `src/storeEventsWrapper.ts:797-809` | `tests/storeEventsWrapper.ts` ("storeEventsWrapper events reactions") | none | PRESENT | +| `STORE-R-015` | List fetchers proxy the SDK and propagate errors after logging; `getQueues` filters by upper-cased channel type; `getAddressBookEntries` returns empty when `isAddressBookEnabled` is false | Centralize SDK fetch + transform so widgets stay SDK-agnostic | `src/storeEventsWrapper.ts:924-1001` | `tests/storeEventsWrapper.ts` ("storeEventsWrapper", "getAccessToken") | `getBuddyAgents`/`getQueues` happy-path filtering covered; address-book disabled branch coverage is a gap | PRESENT | +| `STORE-R-016` | `setOnError` wraps the caller callback to also submit a behavioral metrics event before invoking it | Consistent telemetry on widget errors | `src/storeEventsWrapper.ts:285-301` | None found | Negative/telemetry-path test missing | WEAK | +| `STORE-R-017` | `isIncomingTask` returns true only when the task is not wrap-up-required, the agent has not joined, and the interaction state is `new`/`consult`/`connected`/`conference` | Gates whether a task is treated as an unanswered incoming offer | `src/task-utils.ts:26-37` | `tests/task-utils.ts` ("isIncomingTask" — incoming / not incoming / edge cases) | none | PRESENT | +| `STORE-R-018` | `getConsultStatus`/`getTaskStatus` map participant `consultState` + interaction state to a `ConsultStatus`, with special handling for secondary EP-DN agents | Consult/conference UI relies on a single derived status | `src/task-utils.ts:39-146` | None found (direct `getConsultStatus` test) | Only `isIncomingTask`, conference, and hold helpers are directly tested; consult-status helper is a gap | WEAK | +| `STORE-R-019` | Conference helpers (`getIsConferenceInProgress`, `getConferenceParticipants`, `getConferenceParticipantsCount`) count only active agent participants, excluding `Customer`/`Supervisor`/`VVA` and those who left | Accurate conference participant display | `src/task-utils.ts:148-247`, `src/constants.ts:33` | `tests/task-utils.ts` ("getIsConferenceInProgress", "getConferenceParticipants", "getConferenceParticipantsCount") | none | PRESENT | +| `STORE-R-020` | `findHoldTimestamp`/`findHoldStatus` resolve hold state per media type, remapping to `mainCall` for secondary EP-DN agents | Hold timers align with Agent Desktop across consult/conference | `src/task-utils.ts:285-362` | `tests/task-utils.ts` ("findHoldTimestamp") | `findHoldStatus` direct coverage is a gap | PRESENT | +| `STORE-R-021` | `handleRealtimeTranscription` upserts transcript lines keyed by `messageId`, normalizing role/timestamp and dropping empty content | Live transcription panel needs deduped, ordered lines | `src/storeEventsWrapper.ts:891-922` | None found | No dedicated transcription test located | WEAK | +| `STORE-R-022` | `setTaskCallback(event, callback, task: ITask)` and `removeTaskCallback(event, callback, task: ITask)` accept the task object directly (not a string ID), call `task.on()`/`task.off()` on that reference, and guard on `!callback \|\| !task`; diagnostic logging uses optional chaining on `this.store.logger` | Eliminates the `store.taskList[taskId]` lookup race: if the task is removed from the list before the React effect cleanup fires, the old implementation silently skipped `task.off()`, orphaning listeners and causing duplicate SDK callbacks on the next task | `src/storeEventsWrapper.ts:417-427,453-463` | `tests/storeEventsWrapper.ts` ("should set task callback", "should remove task callback", "should remove task callback even when task is absent from store.taskList") | none | PRESENT | ## Design Overview + The store is deliberately split into a thin observable core and a thick wrapper. `Store` (`store.ts`) holds only field declarations + `makeAutoObservable` (with `cc` as `observable.ref` so the SDK object itself is not deeply observed) and the two lifecycle methods `init`/`registerCC`. Everything reactive and event-driven lives in `StoreWrapper` (`storeEventsWrapper.ts`), which composes the singleton via `Store.getInstance()` and re-exposes each field through a getter. This keeps the observable schema in one place while concentrating SDK coupling, event wiring, and mutation discipline in the wrapper. Initialization has two entry shapes (`InitParams = WithWebex | WithWebexConfig`). With a host-supplied `webex`, the wrapper wires event listeners and registers synchronously. Without one, the store calls `Webex.init()`, arms a 6000ms timeout, and waits for the `ready` event before wiring listeners and registering; the timeout guards against an SDK that never becomes ready. Registration maps the agent `Profile` into observables once. @@ -122,7 +135,9 @@ Event handling is the heart of the wrapper. `setupIncomingTaskHandler` is passed Mutations are funneled through small mutator methods that wrap `runInAction`, satisfying MobX strict mode and keeping reactive updates atomic. `task-utils.ts` is pure (no store state) — selectors that downstream widgets call to derive consult/conference/hold status from an `ITask`. ## Data Flow + In-process MobX reactivity; the only external transport is the SDK event stream and method calls (`@webex/contact-center`), which is itself WebSocket/HTTP under the hood but opaque to this module. + ```mermaid graph TB subgraph Host @@ -149,14 +164,15 @@ graph TB ``` ## Sequence Diagram(s) + Sequence coverage: -| Operation group | Diagram | Failure / recovery coverage | -|---|---|---| -| Init + register | "Store init / register" | 6000ms init timeout reject; register reject; wrapper error callback | -| SDK event → observable update | "Agent state change & multi-login" | non-`AgentStateChangeSuccess` payloads ignored | -| Incoming task lifecycle | "Incoming task → assigned → end/remove" | duplicate-task guard; campaign-preview RESERVED branch; listener detach on remove | -| Representative `store.cc.*` call | "getQueues list fetch" | SDK error logged + rethrown | +| Operation group | Diagram | Failure / recovery coverage | +| -------------------------------- | --------------------------------------- | --------------------------------------------------------------------------------- | +| Init + register | "Store init / register" | 6000ms init timeout reject; register reject; wrapper error callback | +| SDK event → observable update | "Agent state change & multi-login" | non-`AgentStateChangeSuccess` payloads ignored | +| Incoming task lifecycle | "Incoming task → assigned → end/remove" | duplicate-task guard; campaign-preview RESERVED branch; listener detach on remove | +| Representative `store.cc.*` call | "getQueues list fetch" | SDK error logged + rethrown | ```mermaid sequenceDiagram @@ -258,6 +274,7 @@ sequenceDiagram ``` ## Class / Component Relationships + ```mermaid classDiagram class IStore { <> } @@ -273,9 +290,11 @@ classDiagram Store ..> SDK : Webex.init / cc.register class task_utils { <> isIncomingTask getConsultStatus getConferenceParticipants findHoldStatus } ``` + `StoreWrapper` extends the `IStore` contract (via `IStoreWrapper`) and composes a single `Store` singleton, proxying every observable through getters. `Store` implements `IStore` and is the only class that touches `Webex.init()`/`cc.register()`. `task-utils` is a stateless module of selectors that the wrapper and downstream widgets call against `ITask`. ## Use Cases + - **UC-1 Bootstrap with host Webex:** Host calls `store.init({webex})` after the SDK `ready` event → wrapper wires listeners and `registerCC` maps the profile into observables → widgets render. Evidence: `src/store.ts:132-138`, `tests/store.ts` (init). - **UC-2 Bootstrap Webex from store:** Host calls `store.init({webexConfig, access_token})` → store runs `Webex.init()`, waits for `ready` (or rejects at 6s), then registers. Evidence: `src/store.ts:139-188`, `tests/store.ts` (init). - **UC-3 Observe agent/session state in React:** Widget wraps in `observer()` and reads `store.agentId`, `store.isAgentLoggedIn`, `store.deviceType`, `store.currentState` → re-renders on mutation. Evidence: `src/storeEventsWrapper.ts:56-187`, `_archive/.../AGENTS.md` usage. @@ -284,7 +303,9 @@ classDiagram - **UC-6 Fetch a domain list for a widget dropdown:** Transfer/Consult widget calls `getBuddyAgents()`/`getQueues()`; Outdial calls `getEntryPoints()`/`getAddressBookEntries()` → store proxies the SDK, transforms/filters, returns. Evidence: `src/storeEventsWrapper.ts:924-1001`, `tests/storeEventsWrapper.ts`. ## State Model + The store is a single MobX `makeAutoObservable` instance. Observable slices (all in `src/store.ts:23-56`): + - **Session / profile:** `agentId`, `agentProfile`, `isAgentLoggedIn`, `deviceType`, `dialNumber`, `teamId`, `teams`, `loginOptions`, `idleCodes`, `wrapupCodes`, `featureFlags`, `dataCenter`. - **Agent state:** `currentState`, `customState`, `lastStateChangeTimestamp`, `lastIdleCodeChangeTimestamp`, `showMultipleLoginAlert`. - **Tasks:** `taskList` (`Record`), `currentTask`, `acceptedCampaignIds` (`Set`), `realtimeTranscriptionData`. @@ -294,6 +315,7 @@ The store is a single MobX `makeAutoObservable` instance. Observable slices (all Transition triggers: SDK CC/task events drive the session/agent/task slices via the wrapper's handlers (`handleStateChange`, `handleTaskAssigned`, `refreshTaskList`, `cleanUpStore`, campaign-preview handlers). Widget-initiated mutators (`setDeviceType`, `setDialNumber`, `setTeamId`, `setState`, `setCurrentTheme`, etc.) drive UI-local slices. All writes pass through `runInAction`. ## Concurrency & Reactive Flow + - Single-threaded JS, but inherently asynchronous and event-driven: SDK events arrive at arbitrary times and mutate shared observable state. There is no ordering guarantee between unrelated SDK events. - All state writes are wrapped in `runInAction` (MobX strict mode) so each handler's mutations are applied atomically and observers see a consistent snapshot. - Idempotency: per-task listeners are registered once (guarded by `!this.taskList[id]` for the incoming callback and by the `realtimeTranscriptionListeners[taskId]` map for transcription) and detached symmetrically in `handleTaskRemove`. `acceptedCampaignIds` is replaced as a new `Set` on each change to keep MobX reactions firing. @@ -301,14 +323,17 @@ Transition triggers: SDK CC/task events drive the session/agent/task slices via - Do NOT block inside event handlers; list fetchers are async and return promises rather than blocking the reactive update path. ## Pitfalls + - **6-second init timeout (`src/store.ts:140`):** only applies to the `webexConfig` bootstrap path. With `init({webex})` there is no timeout — a never-ready host Webex hangs init silently. Ensure the host awaits the SDK `ready` event before calling `init({webex})`. - **Event enums are local copies (`store.types.ts:204-259`):** `CC_EVENTS`/`TASK_EVENTS` string values must match the SDK exactly; an SDK rename will silently stop a handler from firing. - **Pending campaign previews must not become `currentTask`:** `setCurrentTask` clears `currentTask` for a preview in state `new` that is not in `acceptedCampaignIds` (`storeEventsWrapper.ts:255-267`). Bypassing this (e.g. calling SDK methods directly) re-introduces the bug where CallControl renders for an unaccepted preview. - **Listener leaks:** every `task.on(...)` in `registerTaskEventListeners` has a matching `task.off(...)` in `handleTaskRemove`. Adding a listener in one without the other leaks handlers and can double-fire `refreshTaskList`. +- **`setTaskCallback`/`removeTaskCallback` accept the `ITask` object directly** (not a `taskId` string) to avoid stale `store.taskList` lookup races during React 18 StrictMode double-mount/unmount. Callers must capture and pass the task reference; passing a stale or different object orphans listeners. - **`getBuddyAgents`/`getQueues` default args dereference `this.currentTask.data.interaction.mediaType` (`storeEventsWrapper.ts:925,941`):** calling them with no `currentTask` set throws. Callers should pass an explicit `mediaType` when no task is active. - **`@ts-expect-error` markers tie to SDK gaps:** several casts (e.g. `response.teams`, credentials API) are pinned to `CAI-6762`; removing the workaround before the SDK fix breaks the build. ## Module Do's / Don'ts + - DO: route every SDK access through `store.cc.*`; widgets must never import `@webex/contact-center` directly. - DO: wrap every observable mutation in `runInAction` (use the existing mutators). - DO: add a matching `task.off(...)` in `handleTaskRemove` for any new `task.on(...)` in `registerTaskEventListeners`. @@ -316,35 +341,39 @@ Transition triggers: SDK CC/task events drive the session/agent/task slices via - DON'T: change a `CC_EVENTS`/`TASK_EVENTS` enum value without confirming the SDK emits that exact string. ## Export Stability + `@webex/cc-store` is published and consumed by every widget package plus `@webex/cc-widgets`, which re-exports the `store` singleton. Adding an observable getter, mutator, type, or constant is a minor (additive) change. Removing/renaming any export, changing an event-enum value, or changing the `init`/`registerCC` signatures is a major (breaking) change. The TypeScript declaration surface is the `export type`/`export` lists in `store.types.ts:334-403` plus `index.ts`. Evidence: `packages/contact-center/store/src/index.ts`, `ai-docs/CONTRACTS.md`. ## Test-Case Strategy (module) -Unit tests are split by source file. `tests/store.ts` covers the singleton defaults, `registerCC` profile mapping (positive) and register failure logging (negative), and all `init` branches including the 6s timeout reject and synchronous `Webex.init` throw. `tests/storeEventsWrapper.ts` is the largest suite: observable proxies, `setState`, callback register/remove, list fetchers + `getAccessToken`, event reactions, hydration custom-states, `refreshTaskList`, `setCurrentTask`, and the full campaign-preview lifecycle (accepted/unaccepted, ID cleanup, type branching). `tests/task-utils.ts` covers `isIncomingTask` (incoming / not-incoming / edge), the conference helpers, and `findHoldTimestamp`. `tests/util.ts` covers `getFeatureFlags`. - -| Behavior / Requirement | Existing test evidence | Gap | -|---|---|---| -| `STORE-R-001` | `tests/store.ts` | none | -| `STORE-R-002` | `tests/store.ts` (init) | none | -| `STORE-R-003` | `tests/store.ts` ("...fails to initialize") | none | -| `STORE-R-004` | `tests/store.ts` ("...not present") | none | -| `STORE-R-005` | `tests/store.ts` (register positive + negative) | none | -| `STORE-R-006` | `tests/store.ts` | explicit BROWSER-filter assertion could be strengthened | -| `STORE-R-007` | `tests/util.ts` | no negative (unknown-key omission) case | -| `STORE-R-008` | `tests/storeEventsWrapper.ts` (proxies, setState) | none | -| `STORE-R-009` | `tests/storeEventsWrapper.ts` (setCurrentTask, campaign preview) | none | -| `STORE-R-010` | `tests/storeEventsWrapper.ts` (refreshTaskList) | none | -| `STORE-R-011` | `tests/storeEventsWrapper.ts` (events reactions) | none | -| `STORE-R-012` | `tests/storeEventsWrapper.ts` (handleTaskRemove cleanup) | full per-listener detach not exhaustively asserted | -| `STORE-R-013` | `tests/storeEventsWrapper.ts` (events reactions) | none | -| `STORE-R-014` | `tests/storeEventsWrapper.ts` (events reactions) | none | -| `STORE-R-015` | `tests/storeEventsWrapper.ts` (list fetchers, getAccessToken) | address-book-disabled branch not directly asserted | -| `STORE-R-016` | None found | missing telemetry-path test | -| `STORE-R-017` | `tests/task-utils.ts` (isIncomingTask) | none | -| `STORE-R-018` | None found | `getConsultStatus`/`getTaskStatus` untested | -| `STORE-R-019` | `tests/task-utils.ts` (conference helpers) | none | -| `STORE-R-020` | `tests/task-utils.ts` (findHoldTimestamp) | `findHoldStatus` untested | -| `STORE-R-021` | None found | `handleRealtimeTranscription` untested | + +Unit tests are split by source file. `tests/store.ts` covers the singleton defaults, `registerCC` profile mapping (positive) and register failure logging (negative), and all `init` branches including the 6s timeout reject and synchronous `Webex.init` throw. `tests/storeEventsWrapper.ts` is the largest suite: observable proxies, `setState`, callback register/remove (with `ITask` objects, not string IDs), list fetchers + `getAccessToken`, event reactions, hydration custom-states, `refreshTaskList`, `setCurrentTask`, and the full campaign-preview lifecycle (accepted/unaccepted, ID cleanup, type branching). A regression test verifies `removeTaskCallback` calls `task.off()` even when the task is absent from `store.taskList`, guarding against the orphaned-listener race. `tests/task-utils.ts` covers `isIncomingTask` (incoming / not-incoming / edge), the conference helpers, and `findHoldTimestamp`. `tests/util.ts` covers `getFeatureFlags`. + +| Behavior / Requirement | Existing test evidence | Gap | +| ---------------------- | ---------------------------------------------------------------------------------------------------------- | ------------------------------------------------------- | +| `STORE-R-001` | `tests/store.ts` | none | +| `STORE-R-002` | `tests/store.ts` (init) | none | +| `STORE-R-003` | `tests/store.ts` ("...fails to initialize") | none | +| `STORE-R-004` | `tests/store.ts` ("...not present") | none | +| `STORE-R-005` | `tests/store.ts` (register positive + negative) | none | +| `STORE-R-006` | `tests/store.ts` | explicit BROWSER-filter assertion could be strengthened | +| `STORE-R-007` | `tests/util.ts` | no negative (unknown-key omission) case | +| `STORE-R-008` | `tests/storeEventsWrapper.ts` (proxies, setState) | none | +| `STORE-R-009` | `tests/storeEventsWrapper.ts` (setCurrentTask, campaign preview) | none | +| `STORE-R-010` | `tests/storeEventsWrapper.ts` (refreshTaskList) | none | +| `STORE-R-011` | `tests/storeEventsWrapper.ts` (events reactions) | none | +| `STORE-R-012` | `tests/storeEventsWrapper.ts` (handleTaskRemove cleanup) | full per-listener detach not exhaustively asserted | +| `STORE-R-022` | `tests/storeEventsWrapper.ts` ("should remove task callback even when task is absent from store.taskList") | none | +| `STORE-R-013` | `tests/storeEventsWrapper.ts` (events reactions) | none | +| `STORE-R-014` | `tests/storeEventsWrapper.ts` (events reactions) | none | +| `STORE-R-015` | `tests/storeEventsWrapper.ts` (list fetchers, getAccessToken) | address-book-disabled branch not directly asserted | +| `STORE-R-016` | None found | missing telemetry-path test | +| `STORE-R-017` | `tests/task-utils.ts` (isIncomingTask) | none | +| `STORE-R-018` | None found | `getConsultStatus`/`getTaskStatus` untested | +| `STORE-R-019` | `tests/task-utils.ts` (conference helpers) | none | +| `STORE-R-020` | `tests/task-utils.ts` (findHoldTimestamp) | `findHoldStatus` untested | +| `STORE-R-021` | None found | `handleRealtimeTranscription` untested | ## Traceability + - Repo architecture: [`ARCHITECTURE.md`](../../../../ai-docs/ARCHITECTURE.md) · Registry: [`SPEC_INDEX.md`](../../../../ai-docs/SPEC_INDEX.md) · Contracts: [`CONTRACTS.md`](../../../../ai-docs/CONTRACTS.md) - Coverage state & contracts baseline: `.sdd/manifest.json` diff --git a/packages/contact-center/store/src/storeEventsWrapper.ts b/packages/contact-center/store/src/storeEventsWrapper.ts index fc12ff465..dfac9b89b 100644 --- a/packages/contact-center/store/src/storeEventsWrapper.ts +++ b/packages/contact-center/store/src/storeEventsWrapper.ts @@ -414,10 +414,15 @@ class StoreWrapper implements IStoreWrapper { this.store.cc.on(event, callback); }; - setTaskCallback = (event: TASK_EVENTS, callback, taskId: string) => { - if (!callback) return; - const task = this.store.taskList[taskId]; - if (!task) return; + setTaskCallback = (event: TASK_EVENTS, callback, task: ITask) => { + if (!callback || !task) return; + this.store.logger?.info( + `CC-Widgets: setTaskCallback(): registering task event '${event}' for ${task.data?.interactionId}`, + { + module: 'storeEventsWrapper.ts', + method: 'setTaskCallback', + } + ); task.on(event, callback); }; @@ -445,10 +450,15 @@ class StoreWrapper implements IStoreWrapper { this.store.cc.off(event); }; - removeTaskCallback = (event: TASK_EVENTS, callback, taskId: string) => { - if (!callback) return; - const task = this.store.taskList[taskId]; - if (!task) return; + removeTaskCallback = (event: TASK_EVENTS, callback, task: ITask) => { + if (!callback || !task) return; + this.store.logger?.info( + `CC-Widgets: removeTaskCallback(): removing task event '${event}' for ${task.data?.interactionId}`, + { + module: 'storeEventsWrapper.ts', + method: 'removeTaskCallback', + } + ); task.off(event, callback); }; diff --git a/packages/contact-center/store/tests/storeEventsWrapper.ts b/packages/contact-center/store/tests/storeEventsWrapper.ts index 5f84dbb3b..6b4e465c0 100644 --- a/packages/contact-center/store/tests/storeEventsWrapper.ts +++ b/packages/contact-center/store/tests/storeEventsWrapper.ts @@ -474,46 +474,49 @@ describe('storeEventsWrapper', () => { it('should set task callback', () => { const mockCb = jest.fn(); expect(storeWrapper.setTaskCallback).toBeInstanceOf(Function); - storeWrapper['store'].taskList = { - mockTaskId: mockTask, - }; - storeWrapper.setTaskCallback(TASK_EVENTS.TASK_ASSIGNED, mockCb, 'mockTaskId'); + storeWrapper.setTaskCallback(TASK_EVENTS.TASK_ASSIGNED, mockCb, mockTask); expect(mockTask.on).toHaveBeenCalledWith(TASK_EVENTS.TASK_ASSIGNED, mockCb); }); - it('should return if callback is not present or task is not found', () => { + it('should return if callback is not present or task is not provided', () => { const mockCb = jest.fn(); expect(storeWrapper.setTaskCallback).toBeInstanceOf(Function); - storeWrapper.setTaskCallback(TASK_EVENTS.TASK_ASSIGNED, undefined, 'mockTaskId'); + storeWrapper.setTaskCallback(TASK_EVENTS.TASK_ASSIGNED, undefined, mockTask); expect(mockTask.on).not.toHaveBeenCalledWith(TASK_EVENTS.TASK_ASSIGNED, mockCb); - storeWrapper.setTaskCallback(TASK_EVENTS.TASK_ASSIGNED, mockCb, 'mockTaskI2'); + storeWrapper.setTaskCallback(TASK_EVENTS.TASK_ASSIGNED, mockCb, null); expect(mockTask.on).not.toHaveBeenCalledWith(TASK_EVENTS.TASK_ASSIGNED, mockCb); }); it('should remove task callback', () => { const mockCb = jest.fn(); - storeWrapper['store'].taskList = { - mockTaskId: mockTask, - }; expect(storeWrapper.removeTaskCallback).toBeInstanceOf(Function); - storeWrapper.removeTaskCallback(TASK_EVENTS.TASK_WRAPPEDUP, mockCb, 'mockTaskId'); + storeWrapper.removeTaskCallback(TASK_EVENTS.TASK_WRAPPEDUP, mockCb, mockTask); expect(mockTask.off).toHaveBeenCalledWith(TASK_EVENTS.TASK_WRAPPEDUP, mockCb); }); - it('should return and not remove callback if callback is not present or task is not found', () => { + it('should return and not remove callback if callback is not present or task is not provided', () => { const mockCb = jest.fn(); expect(storeWrapper.removeTaskCallback).toBeInstanceOf(Function); - storeWrapper.removeTaskCallback(TASK_EVENTS.TASK_ASSIGNED, undefined, 'mockTaskId'); + storeWrapper.removeTaskCallback(TASK_EVENTS.TASK_ASSIGNED, undefined, mockTask); expect(mockTask.on).not.toHaveBeenCalledWith(TASK_EVENTS.TASK_ASSIGNED, mockCb); - storeWrapper.removeTaskCallback(TASK_EVENTS.TASK_ASSIGNED, mockCb, 'mockTaskI2'); + storeWrapper.removeTaskCallback(TASK_EVENTS.TASK_ASSIGNED, mockCb, null); expect(mockTask.on).not.toHaveBeenCalledWith(TASK_EVENTS.TASK_ASSIGNED, mockCb); }); + + it('should remove task callback even when task is absent from store.taskList', () => { + const mockCb = jest.fn(); + // Clear taskList so the task is not found by ID lookup + storeWrapper['store'].taskList = {}; + + storeWrapper.removeTaskCallback(TASK_EVENTS.TASK_ASSIGNED, mockCb, mockTask); + expect(mockTask.off).toHaveBeenCalledWith(TASK_EVENTS.TASK_ASSIGNED, mockCb); + }); }); }); diff --git a/packages/contact-center/task/ai-docs/task-spec.md b/packages/contact-center/task/ai-docs/task-spec.md index 060f6e128..31bd031b3 100644 --- a/packages/contact-center/task/ai-docs/task-spec.md +++ b/packages/contact-center/task/ai-docs/task-spec.md @@ -4,47 +4,54 @@ > Context-efficiency: link to canonical docs — don't duplicate them. Load specs on demand per `SPEC_INDEX.md`. ## Metadata -| Field | Value | -|---|---| -| Module id | `task` | -| Source path(s) | `packages/contact-center/task/src/` | -| Doc kind | Module spec | -| Coverage score | Pending coverage assessment | -| Generated from | `module-spec` @ SDLC template library `0.1.0-draft` | + +| Field | Value | +| --------------------------------------- | ----------------------------------------------------------------------------- | +| Module id | `task` | +| Source path(s) | `packages/contact-center/task/src/` | +| Doc kind | Module spec | +| Coverage score | Pending coverage assessment | +| Generated from | `module-spec` @ SDLC template library `0.1.0-draft` | | generated_by / approved_by / updated_at | generated_by: migration agent / approved_by: pending / updated_at: 2026-06-29 | -| Validation status | not-run | +| Validation status | not-run | Coverage score: `Pending coverage assessment` before the first report; after assessment, replace with `<0-100%>` plus the report path/evidence. Keep manifest coverage state outside the rendered module doc metadata. ## Evidence Rules + Every generated requirement below must cite concrete source evidence using `file path`. Separate source evidence, test evidence, examples, assumptions, and gaps so validators and future agents can distinguish truth from context. Test evidence is preferred for WHY. Commit evidence is allowed only when the repository policy says history is reliable, and must include the commit hash. If evidence is missing or conflicting, ask a focused discovery question before finalizing the requirement; record unresolved answers as approved unknowns only when the human explicitly defers or does not know. ## Source Material Register -| Source doc | Scope | Decision | Detail location or disposition | -|---|---|---|---| -| `ai-docs/_archive/.../task/ai-docs/widgets/CallControl/AGENTS.md` + `ARCHITECTURE.md` | architecture / overview / API | reconciled | Flows landed in Sequence Diagram(s); props in Public Surface. Migration-future claims (`task.uiControls`, renamed events) NOT applied — current code still uses `getControlsVisibility`; see Pitfalls + conflict notes. | -| `ai-docs/_archive/.../task/ai-docs/widgets/IncomingTask/AGENTS.md` + `ARCHITECTURE.md` | architecture / overview / API | reconciled | Accept/decline + RONA flow → Sequence Diagram(s); callbacks → Public Surface. | -| `ai-docs/_archive/.../task/ai-docs/widgets/OutdialCall/AGENTS.md` + `ARCHITECTURE.md` | architecture / overview / API | reconciled | Outdial + ANI flow → Sequence Diagram(s); login-mode behavior → Use Cases / Pitfalls. | -| `ai-docs/_archive/.../task/ai-docs/widgets/TaskList/AGENTS.md` + `ARCHITECTURE.md` | architecture / overview / API | reconciled | Task selection / accept / decline flow → Sequence Diagram(s). | -| `packages/contact-center/ai-docs/migration/*.md` (7 files) | architecture (planned refactor) | reference-only | Describes a planned SDK `task.uiControls` migration that is NOT in current code. Used only to mark conflicts; current behavior documented as-is. | -| `packages/contact-center/task/src/` | source of truth | migrated | All requirements, flows, state, and error tables derive from real code here. | + +| Source doc | Scope | Decision | Detail location or disposition | +| -------------------------------------------------------------------------------------- | ------------------------------- | -------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `ai-docs/_archive/.../task/ai-docs/widgets/CallControl/AGENTS.md` + `ARCHITECTURE.md` | architecture / overview / API | reconciled | Flows landed in Sequence Diagram(s); props in Public Surface. Migration-future claims (`task.uiControls`, renamed events) NOT applied — current code still uses `getControlsVisibility`; see Pitfalls + conflict notes. | +| `ai-docs/_archive/.../task/ai-docs/widgets/IncomingTask/AGENTS.md` + `ARCHITECTURE.md` | architecture / overview / API | reconciled | Accept/decline + RONA flow → Sequence Diagram(s); callbacks → Public Surface. | +| `ai-docs/_archive/.../task/ai-docs/widgets/OutdialCall/AGENTS.md` + `ARCHITECTURE.md` | architecture / overview / API | reconciled | Outdial + ANI flow → Sequence Diagram(s); login-mode behavior → Use Cases / Pitfalls. | +| `ai-docs/_archive/.../task/ai-docs/widgets/TaskList/AGENTS.md` + `ARCHITECTURE.md` | architecture / overview / API | reconciled | Task selection / accept / decline flow → Sequence Diagram(s). | +| `packages/contact-center/ai-docs/migration/*.md` (7 files) | architecture (planned refactor) | reference-only | Describes a planned SDK `task.uiControls` migration that is NOT in current code. Used only to mark conflicts; current behavior documented as-is. | +| `packages/contact-center/task/src/` | source of truth | migrated | All requirements, flows, state, and error tables derive from real code here. | ## Overview + `task` is the largest CC widget bundle: it exports six React/Web-Component widgets that together cover the full agent interaction lifecycle — being offered a task, accepting/declining it, controlling an active call (hold, mute, record, consult, transfer, conference, wrap-up), placing outbound calls, listing concurrent tasks, and rendering a live transcript. Each widget follows the repo-standard layering: a thin `observer()` widget wraps an `ErrorBoundary`, reads MobX state from `@webex/cc-store`, delegates business logic to a custom hook in `helper.ts`, and renders a presentational component from `@webex/cc-components`. The hook is the only place that touches the SDK (`task.*` / `store.cc.*`) and registers/unregisters store task-event callbacks. A maintainer should start at `src/index.ts` (the export barrel), then `src/helper.ts` (all five hooks: `useIncomingTask`, `useTaskList`, `useCallControl`, `useOutdialCall`, `useRealTimeTranscript`), then `src/Utils/task-util.ts` (the `getControlsVisibility` aggregator that decides which call-control buttons are visible/enabled). The widget shells (`src/CallControl/index.tsx` etc.) are intentionally tiny — they only select store fields and forward props. -State is not owned here: the live task objects (`currentTask`, `incomingTask`, `taskList`), wrap-up codes, device type, feature flags, agent id, and accepted-campaign ids all live in `@webex/cc-store`. The hooks read those, call SDK methods on the `ITask` object, and register callbacks via `store.setTaskCallback(EVENT, fn, interactionId)` so SDK-emitted events flow back into widget-local `useState` and into the consumer's `on*` callbacks. +State is not owned here: the live task objects (`currentTask`, `incomingTask`, `taskList`), wrap-up codes, device type, feature flags, agent id, and accepted-campaign ids all live in `@webex/cc-store`. The hooks read those, call SDK methods on the `ITask` object, and register callbacks via `store.setTaskCallback(EVENT, fn, task)` (passing the `ITask` object directly) so SDK-emitted events flow back into widget-local `useState` and into the consumer's `on*` callbacks. -Note on migration docs: the archived per-widget docs and `ai-docs/migration/*.md` describe a *planned* refactor to an SDK-computed `task.uiControls` surface and renamed events (e.g. `AGENT_WRAPPEDUP` → `TASK_WRAPPEDUP`). That refactor is **not** present in the current code — control visibility is still computed locally by `getControlsVisibility`, and the store still emits `AGENT_WRAPPEDUP` / `CONTACT_RECORDING_*`. This spec documents the code as it exists today and flags the divergence in Pitfalls. +Note on migration docs: the archived per-widget docs and `ai-docs/migration/*.md` describe a _planned_ refactor to an SDK-computed `task.uiControls` surface and renamed events (e.g. `AGENT_WRAPPEDUP` → `TASK_WRAPPEDUP`). That refactor is **not** present in the current code — control visibility is still computed locally by `getControlsVisibility`, and the store still emits `AGENT_WRAPPEDUP` / `CONTACT_RECORDING_*`. This spec documents the code as it exists today and flags the divergence in Pitfalls. ## Purpose / Responsibility + Owns the agent-facing UI and SDK orchestration for the contact lifecycle of a single task and the agent's task list: offer→accept/decline, active-call controls (hold/resume/mute/record/consult/transfer/conference/wrap-up), outbound dialing, multi-task listing/selection, and live transcript rendering. It does NOT own task state, SDK connection, agent state/presence, or wrap-up-code configuration — those belong to `store`/SDK. ## Stack + TypeScript 5, React 18 (function components + hooks), MobX via `mobx-react-lite` `observer()`, `react-error-boundary` for fault isolation. Presentational components are imported from `@webex/cc-components`; all task/agent state and SDK access come from `@webex/cc-store` (`@webex/contact-center` SDK underneath). A `Web Worker` (created from an inline blob) drives the hold timer (`src/Utils/useHoldTimer.ts`). Tests: Jest + React Testing Library under `tests/`. Build target: distributed as part of `@webex/cc-widgets` (r2wc Web Components). ## Folder / Package Structure + ``` packages/contact-center/task/src/ ├── index.ts # Export barrel: IncomingTask, TaskList, CallControl, OutdialCall, CallControlCAD, RealTimeTranscript @@ -65,29 +72,33 @@ packages/contact-center/task/src/ ``` ## Key Files (source of truth) -| File | Holds | -|---|---| -| `src/index.ts` | Authoritative list of exported widgets — do not assume exports from elsewhere. | -| `src/task.types.ts` | Public prop/callback shapes per widget; `TARGET_TYPE`/`TargetType`; `DeviceTypeFlags`; re-exports `CAMPAIGN_PREVIEW_*` from store. | -| `src/helper.ts` | All hook logic and the exact SDK methods + store callbacks each operation uses. | + +| File | Holds | +| ------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------- | +| `src/index.ts` | Authoritative list of exported widgets — do not assume exports from elsewhere. | +| `src/task.types.ts` | Public prop/callback shapes per widget; `TARGET_TYPE`/`TargetType`; `DeviceTypeFlags`; re-exports `CAMPAIGN_PREVIEW_*` from store. | +| `src/helper.ts` | All hook logic and the exact SDK methods + store callbacks each operation uses. | | `src/Utils/task-util.ts` | `getControlsVisibility` — the single source of truth for which call-control buttons are visible/enabled per device/feature-flag/task-state. | -| `src/Utils/constants.ts` | Media types, `MAX_PARTICIPANTS_IN_MULTIPARTY_CONFERENCE = 7`, timer labels, `DestinationAgentType` enum. | +| `src/Utils/constants.ts` | Media types, `MAX_PARTICIPANTS_IN_MULTIPARTY_CONFERENCE = 7`, timer labels, `DestinationAgentType` enum. | ## Public Surface -| Contract ID | Type | Surface | Purpose | Compatibility / deprecation | Schema / detail link | Root index | -|---|---|---|---|---|---|---| -| `cc-widgets.IncomingTask` | SDK (React component / Web Component) | `IncomingTask` — props: `incomingTask`; callbacks: `onAccepted({task})`, `onRejected({task})` | Render an offered task with accept/decline; notify consumer on accept/reject/RONA | Stable; adding optional props/callbacks = minor | `src/task.types.ts` (`IncomingTaskProps`) | [`CONTRACTS.md`](../../../../ai-docs/CONTRACTS.md) | -| `cc-widgets.TaskList` | SDK (React component / Web Component) | `TaskList` — props: `hasCampaignPreviewEnabled?`; callbacks: `onTaskAccepted(task)`, `onTaskDeclined(task, reason)`, `onTaskSelected({task, isClicked})` | List concurrent tasks; accept/decline/select | Stable; `hasCampaignPreviewEnabled` defaults true | `src/task.types.ts` (`TaskListProps`) | [`CONTRACTS.md`](../../../../ai-docs/CONTRACTS.md) | -| `cc-widgets.CallControl` | SDK (React component / Web Component) | `CallControl` — callbacks: `onHoldResume({isHeld,task})`, `onEnd({task})`, `onWrapUp({task,wrapUpReason})`, `onRecordingToggle({isRecording,task})`, `onToggleMute({isMuted,task})`; props: `conferenceEnabled?`, `consultTransferOptions?`, `callControlClassName?`, `callControlConsultClassName?` | Active-call controls for `store.currentTask` | Stable; `conferenceEnabled` defaults `true` | `src/task.types.ts` (`CallControlProps`) | [`CONTRACTS.md`](../../../../ai-docs/CONTRACTS.md) | -| `cc-widgets.CallControlCAD` | SDK (React component / Web Component) | `CallControlCAD` — same callbacks/props as `CallControl`; emphasizes `callControlClassName` / `callControlConsultClassName` | CallControl variant styled for a customer-data layout | Stable; same surface as CallControl | `src/task.types.ts` (`CallControlProps`) | [`CONTRACTS.md`](../../../../ai-docs/CONTRACTS.md) | -| `cc-widgets.OutdialCall` | SDK (React component / Web Component) | `OutdialCall` — props: `isAddressBookEnabled?` (default `true`); no consumer callbacks | Outbound dialpad + ANI selection; disabled when a telephony task is active | Stable | `src/task.types.ts` (`OutdialProps`) | [`CONTRACTS.md`](../../../../ai-docs/CONTRACTS.md) | -| `cc-widgets.RealTimeTranscript` | SDK (React component / Web Component) | `RealTimeTranscript` — props: `liveTranscriptEntries?`, `className?` | Render live transcript for `store.currentTask` | Stable | `src/task.types.ts` (`RealTimeTranscriptProps`) | [`CONTRACTS.md`](../../../../ai-docs/CONTRACTS.md) | + +| Contract ID | Type | Surface | Purpose | Compatibility / deprecation | Schema / detail link | Root index | +| ------------------------------- | ------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------- | ------------------------------------------------- | ----------------------------------------------- | -------------------------------------------------- | +| `cc-widgets.IncomingTask` | SDK (React component / Web Component) | `IncomingTask` — props: `incomingTask`; callbacks: `onAccepted({task})`, `onRejected({task})` | Render an offered task with accept/decline; notify consumer on accept/reject/RONA | Stable; adding optional props/callbacks = minor | `src/task.types.ts` (`IncomingTaskProps`) | [`CONTRACTS.md`](../../../../ai-docs/CONTRACTS.md) | +| `cc-widgets.TaskList` | SDK (React component / Web Component) | `TaskList` — props: `hasCampaignPreviewEnabled?`; callbacks: `onTaskAccepted(task)`, `onTaskDeclined(task, reason)`, `onTaskSelected({task, isClicked})` | List concurrent tasks; accept/decline/select | Stable; `hasCampaignPreviewEnabled` defaults true | `src/task.types.ts` (`TaskListProps`) | [`CONTRACTS.md`](../../../../ai-docs/CONTRACTS.md) | +| `cc-widgets.CallControl` | SDK (React component / Web Component) | `CallControl` — callbacks: `onHoldResume({isHeld,task})`, `onEnd({task})`, `onWrapUp({task,wrapUpReason})`, `onRecordingToggle({isRecording,task})`, `onToggleMute({isMuted,task})`; props: `conferenceEnabled?`, `consultTransferOptions?`, `callControlClassName?`, `callControlConsultClassName?` | Active-call controls for `store.currentTask` | Stable; `conferenceEnabled` defaults `true` | `src/task.types.ts` (`CallControlProps`) | [`CONTRACTS.md`](../../../../ai-docs/CONTRACTS.md) | +| `cc-widgets.CallControlCAD` | SDK (React component / Web Component) | `CallControlCAD` — same callbacks/props as `CallControl`; emphasizes `callControlClassName` / `callControlConsultClassName` | CallControl variant styled for a customer-data layout | Stable; same surface as CallControl | `src/task.types.ts` (`CallControlProps`) | [`CONTRACTS.md`](../../../../ai-docs/CONTRACTS.md) | +| `cc-widgets.OutdialCall` | SDK (React component / Web Component) | `OutdialCall` — props: `isAddressBookEnabled?` (default `true`); no consumer callbacks | Outbound dialpad + ANI selection; disabled when a telephony task is active | Stable | `src/task.types.ts` (`OutdialProps`) | [`CONTRACTS.md`](../../../../ai-docs/CONTRACTS.md) | +| `cc-widgets.RealTimeTranscript` | SDK (React component / Web Component) | `RealTimeTranscript` — props: `liveTranscriptEntries?`, `className?` | Render live transcript for `store.currentTask` | Stable | `src/task.types.ts` (`RealTimeTranscriptProps`) | [`CONTRACTS.md`](../../../../ai-docs/CONTRACTS.md) | Compatibility notes: + - Adding an optional prop/callback is additive (minor); removing or renaming one, or changing a callback payload shape, is breaking (major) — these widgets are consumed via r2wc Web Components in `@webex/cc-widgets`. - `conferenceEnabled` is normalized to `true` when undefined inside the `CallControl`/`CallControlCAD` wrappers; consumers relying on `undefined` getting `false` would break. ## Requires (dependencies) + - `@webex/cc-store` (peer, internal): MobX singleton supplying `currentTask`, `incomingTask`, `taskList`, `wrapupCodes`, `deviceType`, `featureFlags`, `agentId`, `isMuted`, `acceptedCampaignIds`, `realtimeTranscriptionData`, `logger`, `cc` (SDK), plus `setTaskCallback`/`removeTaskCallback`, `setTaskAssigned`/`setTaskRejected`/`setTaskSelected`, `setCurrentTask`, `setIsMuted`, `getBuddyAgents`, `getAddressBookEntries`, `getEntryPoints`, `getQueues`, and helpers `getConferenceParticipants`, `findMediaResourceId`, `findHoldStatus`, `getConsultStatus`, `getIsConsultInProgress`, `getIsCustomerInCall`, `getConferenceParticipantsCount`, `ConsultStatus`, `TASK_EVENTS`. Source of truth for event names: `packages/contact-center/store/src/store.types.ts`. - `@webex/cc-components` (internal): presentational components (`IncomingTaskComponent`, `TaskListComponent`, `CallControlComponent`, `CallControlCADComponent`, `OutdialCallComponent`, `RealTimeTranscriptComponent`) and types (`ControlProps`, `TaskProps`, `OutdialCallProps`, `Visibility`, `ControlVisibility`, `RealTimeTranscriptComponentProps`, `CampaignCallProcessingDetails`). - `@webex/contact-center` (SDK, transitive via store): the `ITask` interface and methods invoked here (`accept`, `decline`, `hold`, `resume`, `end`, `wrapup`, `cancelAutoWrapupTimer`, `pauseRecording`, `resumeRecording`, `toggleMute`, `transfer`, `consult`, `endConsult`, `consultTransfer`, `consultConference`, `transferConference`, `exitConference`), `cc.startOutdial`, `cc.getOutdialAniEntries`, `cc.addressBook.getEntries`, `cc.agentConfig`. @@ -95,33 +106,35 @@ Compatibility notes: - Browser `Web Worker` + `Blob`/`URL.createObjectURL` for the hold timer (graceful fallback to `holdTime = 0` when no hold timestamp). ## Requirements -| ID | WHAT | WHY | Source Evidence | Test / Example Evidence | Assumptions / Gaps | Confidence | -|---|---|---|---|---|---|---| -| `TASK-R-001` | `IncomingTask.accept()` calls `incomingTask.accept()` only when `incomingTask.data.interactionId` exists; SDK rejection is caught and logged, never thrown to the consumer. | Prevents calling SDK with no task and avoids crashing the widget on backend failure. | `src/helper.ts` (`useIncomingTask.accept`) | `tests/helper.ts` ("should return if there is no taskId for incoming task", "should handle errors when accepting a task", "should handle errors in accept method") | none | PRESENT | -| `TASK-R-002` | `IncomingTask.reject()` calls `incomingTask.decline()` (guarded by interactionId); RONA timeout reaches the same decline path via the timer in the presentational component. | Decline and RONA must converge on `decline()` so the backend reassigns the task. | `src/helper.ts` (`useIncomingTask.reject`) | `tests/helper.ts` ("should handle errors when declining a task", "should call onRejected if it is provided") | RONA countdown UI lives in `@webex/cc-components`, not this module | PRESENT | -| `TASK-R-003` | `useIncomingTask` registers callbacks for `TASK_ASSIGNED`/`TASK_CONSULT_ACCEPTED` (→ `onAccepted`) and `TASK_END`/`TASK_REJECT`/`TASK_CONSULT_END` (→ `onRejected`), keyed by interactionId, and removes them on unmount/task change. | Consumer notifications must fire on real SDK events and listeners must not leak across tasks. | `src/helper.ts` (`useIncomingTask` `useEffect`) | `tests/helper.ts` ("should setup event listeners for the incoming call", "shouldnt setup event listeners is not incoming call", "should call onAccepted if it is provided") | Cleanup uses different fn references than registration for some events (see Pitfalls) | PRESENT | -| `TASK-R-004` | `TaskList.acceptTask`/`declineTask` call `task.accept()`/`task.decline()` per task; `onTaskSelect` calls `store.setCurrentTask(task, true)`. | List actions operate per-task and selection switches the active `currentTask` for CallControl. | `src/helper.ts` (`useTaskList`) | `tests/helper.ts` ("should call onTaskAccepted callback when provided", "should call onTaskDeclined callback when provided", "should call onTaskSelected callback when provided", "should handle errors in onTaskSelect") | none | PRESENT | -| `TASK-R-005` | `useTaskList` wires `store.setTaskAssigned`/`setTaskRejected`/`setTaskSelected` only when the matching consumer callback (`onTaskAccepted`/`onTaskDeclined`/`onTaskSelected`) is provided; each wrapped callback is try/caught. | Avoid registering no-op store callbacks and isolate consumer-thrown errors. | `src/helper.ts` (`useTaskList` `useEffect`) | `tests/helper.ts` ("should not call onTaskAccepted if it is not provided", "should handle errors in taskAssigned callback", "should handle errors in taskSelected callback") | none | PRESENT | -| `TASK-R-006` | `CallControl.toggleHold(true/false)` calls `currentTask.hold()`/`currentTask.resume()`; `TASK_HOLD`/`TASK_RESUME` events fire `onHoldResume({isHeld, task})`. | Hold/resume must reflect real SDK state to the consumer. | `src/helper.ts` (`useCallControl.toggleHold`, `holdCallback`, `resumeCallback`) | `tests/helper.ts` ("should call onHoldResume with hold=true and handle success", "...hold=false...", "should log an error if hold fails", "should log an error if resume fails") | none | PRESENT | -| `TASK-R-007` | `toggleRecording` calls `pauseRecording()` when `isRecording` else `resumeRecording({autoResumed:false})`; `TASK_RECORDING_PAUSED`/`TASK_RECORDING_RESUMED` callbacks set `isRecording` and fire `onRecordingToggle`. | Recording UI state must track SDK events, not just the click. | `src/helper.ts` (`useCallControl.toggleRecording`, `pauseRecordingCallback`, `resumeRecordingCallback`) | `tests/helper.ts` ("should pause the recording when pauseResume is called with true", "should fail and log error if pause failed", "should resume the recording when pauseResume is called with false") | Subscription uses `TASK_RECORDING_PAUSED/RESUMED`; cleanup removes `CONTACT_RECORDING_PAUSED/RESUMED` (mismatch — see Pitfalls) | PRESENT | -| `TASK-R-008` | `toggleMute` no-ops with a warning when `controlVisibility.muteUnmute` is false; otherwise `await currentTask.toggleMute()`, then `store.setIsMuted(intended)` and `onToggleMute` only after success; on failure it reports the prior `isMuted`. | Mute state must reflect SDK truth even under rapid toggles or failure. | `src/helper.ts` (`useCallControl.toggleMute`) | `tests/helper.ts` ("should successfully toggle mute from unmuted to muted", "should handle multiple rapid toggleMute calls correctly", "should not call onToggleMute callback on error if not provided") | none | PRESENT | -| `TASK-R-009` | `wrapupCall(reason, auxCodeId)` calls `currentTask.wrapup(...)`; on resolve it promotes the first remaining task in `store.taskList` to `currentTask` and sets agent state to ENGAGED. | After wrap-up the agent should auto-focus the next task and return to an engaged state. | `src/helper.ts` (`useCallControl.wrapupCall`) | `tests/helper.ts` ("should call wrapupCall", "should log an error if wrapup fails") | ENGAGED label/username are local constants (`ENGAGED_LABEL`, `ENGAGED_USERNAME`) | PRESENT | -| `TASK-R-010` | Auto-wrap-up: when `currentTask.autoWrapup` and `controlVisibility.wrapup` are present, a 1s interval counts `secondsUntilAutoWrapup` down from `getTimeLeftSeconds()`; `cancelAutoWrapup` calls `currentTask.cancelAutoWrapupTimer()`. | Show and allow cancellation of the auto-wrap-up countdown. | `src/helper.ts` (`useCallControl` auto-wrapup `useEffect`, `cancelAutoWrapup`) | `tests/helper.ts` ("should initialize secondsUntilAutoWrapup to null when auto wrap-up is not active", "should call cancelAutoWrapup successfully", "should handle cancelAutoWrapup when currentTask is missing") | none | PRESENT | -| `TASK-R-011` | `consultCall(dest, type, allowParticipantsToInteract)` sends `holdParticipants: !allowParticipantsToInteract`; for `type==='queue'` it sets/clears `store.isQueueConsultInProgress` + `currentConsultQueueId` around the call, including on error. | Queue consult requires tracking the in-flight queue id so `endConsult` can pass it. | `src/helper.ts` (`useCallControl.consultCall`, `endConsultCall`) | `tests/helper.ts` ("should call consultCall successfully", "should call consultCall with allowParticipantsToInteract set to true", "should call endConsultCall with queue parameters when queue consult is in progress") | none | PRESENT | -| `TASK-R-012` | `consultTransfer` calls `currentTask.transferConference()` when `currentTask.data.isConferenceInProgress`, else `currentTask.consultTransfer()`; missing `currentTask.data` early-returns. | Conference and 1:1 consult complete via different SDK calls. | `src/helper.ts` (`useCallControl.consultTransfer`) | `tests/helper.ts` ("should call consultTransfer successfully", "should handle consultTransfer when currentTask data is missing") | none | PRESENT | -| `TASK-R-013` | `transferCall(to, type)` awaits `currentTask.transfer({to, destinationType})` and re-throws on error (unlike most handlers which swallow). | Blind transfer failures must surface to the calling modal so the UI can react. | `src/helper.ts` (`useCallControl.transferCall`) | `tests/helper.ts` ("should call transferCall successfully", "should handle rejection when loading buddy agents") | Re-throw is intentional and differs from hold/end/wrapup which only log | PRESENT | -| `TASK-R-014` | `switchToConsult`/`switchToMainCall` hold/resume the correct media leg via `findMediaResourceId(currentTask, 'mainCall'|'consult')`; `exitConference`/`consultConference` proxy the SDK directly. | Switching between consult and main legs targets the right media resource. | `src/helper.ts` (`useCallControl.switchToConsult/switchToMainCall/exitConference/consultConference`) | `tests/helper.ts` (useCallControl consult/conference cases) | none | WEAK | -| `TASK-R-015` | `getControlsVisibility(deviceType, featureFlags, task, agentId, conferenceEnabled, logger)` returns `{isVisible,isEnabled}` for every control plus consult/conference state flags, and returns safe all-hidden defaults inside a try/catch on any error. | Button visibility must degrade safely and never throw into render. | `src/Utils/task-util.ts` (`getControlsVisibility` + `get*ButtonVisibility`) | `tests/utils/task-util.ts` ("should handle errors when accessing featureFlags and return safe defaults", BROWSER/AGENT_DN/EXTENSION + telephony/chat/email cases) | none | PRESENT | -| `TASK-R-016` | End button is enabled during an EP-DN consult only when on the main call (`consultCallHeld`) or during conference when main is not held & consult not completed; disabled for regular agent-to-agent consult. | Matches Agent Desktop end-call rules for EP-DN vs agent consults. | `src/Utils/task-util.ts` (`getEndButtonVisibility`, `isConsultingWithEpDnAgent`) | `tests/utils/task-util.ts` ("should enable end button during EP_DN consult when switched back to main call...", "should disable end button for regular agent-to-agent consult (non-EP_DN)", EP/EPDN/EntryPoint variant detection) | none | PRESENT | -| `TASK-R-017` | `useHoldTimer` prioritizes the `consult` hold timestamp over `mainCall`, converts second-precision timestamps to ms (`< 1e10`), drives elapsed seconds via a Web Worker, and resets to 0 when no hold timestamp / on resume. | Hold timer must show the leg currently on hold and clean up its worker. | `src/Utils/useHoldTimer.ts` | `tests/utils/useHoldTimer.test.ts` ("should prioritize consult hold over main call hold", "should handle timestamp in seconds and convert to milliseconds", "should reset to 0 when call is resumed", "should return 0 when currentTask is null") | none | PRESENT | -| `TASK-R-018` | State timer prioritizes Wrap Up over Post Call; consult timer returns `Consult Requested` (initiated), `Consult on Hold` (held), else `Consulting`, falling back to participant `lastUpdated` when no consult timestamp. | Drives the correct timer label/timestamp in CallControl. | `src/Utils/timer-utils.ts` (`calculateStateTimerData`, `calculateConsultTimerData`) | `tests/utils/timer-utils.test.ts` ("should prioritize Wrap Up over Post Call", "should return Consult on Hold when consult is held", "should return Consult Requested label when consult is initiated") | none | PRESENT | -| `TASK-R-019` | `OutdialCall.startOutdial(destination, origin?)` alerts and aborts on empty/whitespace destination; passes `origin` (ANI) only when provided; SDK rejection is logged, not thrown. | Prevent empty outdials and honor optional caller-ID selection. | `src/helper.ts` (`useOutdialCall.startOutdial`) | `tests/OutdialCall/index.tsx` (render + `isAddressBookEnabled` cases) | No direct unit test asserts the empty-destination alert (gap) | WEAK | -| `TASK-R-020` | `getOutdialANIEntries` throws if `cc.agentConfig.outdialANIId` is missing, else returns `cc.getOutdialAniEntries({outdialANI})`; `isTelephonyTaskActive` is true iff any task in `store.taskList` has `mediaType === telephony`. | ANI selection requires a configured ANI id; outdial is gated on no active telephony task. | `src/helper.ts` (`useOutdialCall.getOutdialANIEntries`, `isTelephonyTaskActive`) | `tests/OutdialCall/index.tsx` (component render); helper outdial paths in `tests/helper.ts` | No explicit unit test for the "no outdialANIId throws" branch (gap) | WEAK | -| `TASK-R-021` | `useRealTimeTranscript` maps `realtimeTranscriptionData` to `RealTimeTranscriptEntry[]` only when `currentTaskId` is set and data is non-empty; otherwise returns `liveTranscriptEntries` unchanged. Speaker is normalized (AGENT→"You", CUSTOMER/CALLER→"Customer"). | Live transcript must key off the active task and normalize speaker labels. | `src/helper.ts` (`useRealTimeTranscript`, `mapTranscriptLineToEntry`, `getTranscriptSpeaker`) | `tests/RealtimeTranscript/index.tsx` ("passes props to useRealtimeTranscript hook", "renders fallback when an error is thrown") | none | PRESENT | -| `TASK-R-022` | Each widget shell renders inside an `ErrorBoundary` whose `fallbackRender` returns empty and `onError` calls `store.onErrorCallback(widgetName, error)` when set; absence of the callback must not throw. | A crashing widget must isolate and report, never break the host. | `src/{CallControl,CallControlCAD,IncomingTask,TaskList,OutdialCall,RealTimeTranscript}/index.tsx` | `tests/CallControl/index.tsx`, `tests/CallControlCAD/index.tsx`, `tests/IncomingTask/index.tsx`, `tests/TaskList/index.tsx`, `tests/OutdialCall/index.tsx`, `tests/RealtimeTranscript/index.tsx` (each has an ErrorBoundary + "onErrorCallback not set" case) | none | PRESENT | -| `TASK-R-023` | `CallControl`/`CallControlCAD` render nothing when there is no `currentTask` or when the task is an unaccepted campaign preview (`isUnacceptedCampaignPreview(task, acceptedCampaignIds)`). | Controls must only appear for an accepted, active task — matches Agent Desktop campaign-preview behavior. | `src/CallControl/index.tsx`, `src/CallControlCAD/index.tsx`, `src/Utils/task-util.ts` (`isCampaignPreviewTask`, `isUnacceptedCampaignPreview`) | None found for the unaccepted-campaign-preview early return (gap) | Campaign-preview gating relies on `store.acceptedCampaignIds`, not `participants.hasJoined` | WEAK | + +| ID | WHAT | WHY | Source Evidence | Test / Example Evidence | Assumptions / Gaps | Confidence | +| ------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------- | ---------- | ---- | +| `TASK-R-001` | `IncomingTask.accept()` calls `incomingTask.accept()` only when `incomingTask.data.interactionId` exists; SDK rejection is caught and logged, never thrown to the consumer. | Prevents calling SDK with no task and avoids crashing the widget on backend failure. | `src/helper.ts` (`useIncomingTask.accept`) | `tests/helper.ts` ("should return if there is no taskId for incoming task", "should handle errors when accepting a task", "should handle errors in accept method") | none | PRESENT | +| `TASK-R-002` | `IncomingTask.reject()` calls `incomingTask.decline()` (guarded by interactionId); RONA timeout reaches the same decline path via the timer in the presentational component. | Decline and RONA must converge on `decline()` so the backend reassigns the task. | `src/helper.ts` (`useIncomingTask.reject`) | `tests/helper.ts` ("should handle errors when declining a task", "should call onRejected if it is provided") | RONA countdown UI lives in `@webex/cc-components`, not this module | PRESENT | +| `TASK-R-003` | `useIncomingTask` registers callbacks for `TASK_ASSIGNED`/`TASK_CONSULT_ACCEPTED` (→ `onAccepted`) and `TASK_END`/`TASK_REJECT`/`TASK_CONSULT_END` (→ `onRejected`), keyed by interactionId, and removes them on unmount/task change. | Consumer notifications must fire on real SDK events and listeners must not leak across tasks. | `src/helper.ts` (`useIncomingTask` `useEffect`) | `tests/helper.ts` ("should setup event listeners for the incoming call", "shouldnt setup event listeners is not incoming call", "should call onAccepted if it is provided") | Cleanup uses different fn references than registration for some events (see Pitfalls) | PRESENT | +| `TASK-R-004` | `TaskList.acceptTask`/`declineTask` call `task.accept()`/`task.decline()` per task; `onTaskSelect` calls `store.setCurrentTask(task, true)`. | List actions operate per-task and selection switches the active `currentTask` for CallControl. | `src/helper.ts` (`useTaskList`) | `tests/helper.ts` ("should call onTaskAccepted callback when provided", "should call onTaskDeclined callback when provided", "should call onTaskSelected callback when provided", "should handle errors in onTaskSelect") | none | PRESENT | +| `TASK-R-005` | `useTaskList` wires `store.setTaskAssigned`/`setTaskRejected`/`setTaskSelected` only when the matching consumer callback (`onTaskAccepted`/`onTaskDeclined`/`onTaskSelected`) is provided; each wrapped callback is try/caught. | Avoid registering no-op store callbacks and isolate consumer-thrown errors. | `src/helper.ts` (`useTaskList` `useEffect`) | `tests/helper.ts` ("should not call onTaskAccepted if it is not provided", "should handle errors in taskAssigned callback", "should handle errors in taskSelected callback") | none | PRESENT | +| `TASK-R-006` | `CallControl.toggleHold(true/false)` calls `currentTask.hold()`/`currentTask.resume()`; `TASK_HOLD`/`TASK_RESUME` events fire `onHoldResume({isHeld, task})`. | Hold/resume must reflect real SDK state to the consumer. | `src/helper.ts` (`useCallControl.toggleHold`, `holdCallback`, `resumeCallback`) | `tests/helper.ts` ("should call onHoldResume with hold=true and handle success", "...hold=false...", "should log an error if hold fails", "should log an error if resume fails") | none | PRESENT | +| `TASK-R-007` | `toggleRecording` calls `pauseRecording()` when `isRecording` else `resumeRecording({autoResumed:false})`; `TASK_RECORDING_PAUSED`/`TASK_RECORDING_RESUMED` callbacks set `isRecording` and fire `onRecordingToggle`. | Recording UI state must track SDK events, not just the click. | `src/helper.ts` (`useCallControl.toggleRecording`, `pauseRecordingCallback`, `resumeRecordingCallback`) | `tests/helper.ts` ("should pause the recording when pauseResume is called with true", "should fail and log error if pause failed", "should resume the recording when pauseResume is called with false") | Subscription uses `TASK_RECORDING_PAUSED/RESUMED`; cleanup removes `CONTACT_RECORDING_PAUSED/RESUMED` (mismatch — see Pitfalls) | PRESENT | +| `TASK-R-008` | `toggleMute` no-ops with a warning when `controlVisibility.muteUnmute` is false; otherwise `await currentTask.toggleMute()`, then `store.setIsMuted(intended)` and `onToggleMute` only after success; on failure it reports the prior `isMuted`. | Mute state must reflect SDK truth even under rapid toggles or failure. | `src/helper.ts` (`useCallControl.toggleMute`) | `tests/helper.ts` ("should successfully toggle mute from unmuted to muted", "should handle multiple rapid toggleMute calls correctly", "should not call onToggleMute callback on error if not provided") | none | PRESENT | +| `TASK-R-009` | `wrapupCall(reason, auxCodeId)` calls `currentTask.wrapup(...)`; on resolve it promotes the first remaining task in `store.taskList` to `currentTask` and sets agent state to ENGAGED. | After wrap-up the agent should auto-focus the next task and return to an engaged state. | `src/helper.ts` (`useCallControl.wrapupCall`) | `tests/helper.ts` ("should call wrapupCall", "should log an error if wrapup fails") | ENGAGED label/username are local constants (`ENGAGED_LABEL`, `ENGAGED_USERNAME`) | PRESENT | +| `TASK-R-010` | Auto-wrap-up: when `currentTask.autoWrapup` and `controlVisibility.wrapup` are present, a 1s interval counts `secondsUntilAutoWrapup` down from `getTimeLeftSeconds()`; `cancelAutoWrapup` calls `currentTask.cancelAutoWrapupTimer()`. | Show and allow cancellation of the auto-wrap-up countdown. | `src/helper.ts` (`useCallControl` auto-wrapup `useEffect`, `cancelAutoWrapup`) | `tests/helper.ts` ("should initialize secondsUntilAutoWrapup to null when auto wrap-up is not active", "should call cancelAutoWrapup successfully", "should handle cancelAutoWrapup when currentTask is missing") | none | PRESENT | +| `TASK-R-011` | `consultCall(dest, type, allowParticipantsToInteract)` sends `holdParticipants: !allowParticipantsToInteract`; for `type==='queue'` it sets/clears `store.isQueueConsultInProgress` + `currentConsultQueueId` around the call, including on error. | Queue consult requires tracking the in-flight queue id so `endConsult` can pass it. | `src/helper.ts` (`useCallControl.consultCall`, `endConsultCall`) | `tests/helper.ts` ("should call consultCall successfully", "should call consultCall with allowParticipantsToInteract set to true", "should call endConsultCall with queue parameters when queue consult is in progress") | none | PRESENT | +| `TASK-R-012` | `consultTransfer` calls `currentTask.transferConference()` when `currentTask.data.isConferenceInProgress`, else `currentTask.consultTransfer()`; missing `currentTask.data` early-returns. | Conference and 1:1 consult complete via different SDK calls. | `src/helper.ts` (`useCallControl.consultTransfer`) | `tests/helper.ts` ("should call consultTransfer successfully", "should handle consultTransfer when currentTask data is missing") | none | PRESENT | +| `TASK-R-013` | `transferCall(to, type)` awaits `currentTask.transfer({to, destinationType})` and re-throws on error (unlike most handlers which swallow). | Blind transfer failures must surface to the calling modal so the UI can react. | `src/helper.ts` (`useCallControl.transferCall`) | `tests/helper.ts` ("should call transferCall successfully", "should handle rejection when loading buddy agents") | Re-throw is intentional and differs from hold/end/wrapup which only log | PRESENT | +| `TASK-R-014` | `switchToConsult`/`switchToMainCall` hold/resume the correct media leg via `findMediaResourceId(currentTask, 'mainCall' | 'consult')`; `exitConference`/`consultConference` proxy the SDK directly. | Switching between consult and main legs targets the right media resource. | `src/helper.ts` (`useCallControl.switchToConsult/switchToMainCall/exitConference/consultConference`) | `tests/helper.ts` (useCallControl consult/conference cases) | none | WEAK | +| `TASK-R-015` | `getControlsVisibility(deviceType, featureFlags, task, agentId, conferenceEnabled, logger)` returns `{isVisible,isEnabled}` for every control plus consult/conference state flags, and returns safe all-hidden defaults inside a try/catch on any error. | Button visibility must degrade safely and never throw into render. | `src/Utils/task-util.ts` (`getControlsVisibility` + `get*ButtonVisibility`) | `tests/utils/task-util.ts` ("should handle errors when accessing featureFlags and return safe defaults", BROWSER/AGENT_DN/EXTENSION + telephony/chat/email cases) | none | PRESENT | +| `TASK-R-016` | End button is enabled during an EP-DN consult only when on the main call (`consultCallHeld`) or during conference when main is not held & consult not completed; disabled for regular agent-to-agent consult. | Matches Agent Desktop end-call rules for EP-DN vs agent consults. | `src/Utils/task-util.ts` (`getEndButtonVisibility`, `isConsultingWithEpDnAgent`) | `tests/utils/task-util.ts` ("should enable end button during EP_DN consult when switched back to main call...", "should disable end button for regular agent-to-agent consult (non-EP_DN)", EP/EPDN/EntryPoint variant detection) | none | PRESENT | +| `TASK-R-017` | `useHoldTimer` prioritizes the `consult` hold timestamp over `mainCall`, converts second-precision timestamps to ms (`< 1e10`), drives elapsed seconds via a Web Worker, and resets to 0 when no hold timestamp / on resume. | Hold timer must show the leg currently on hold and clean up its worker. | `src/Utils/useHoldTimer.ts` | `tests/utils/useHoldTimer.test.ts` ("should prioritize consult hold over main call hold", "should handle timestamp in seconds and convert to milliseconds", "should reset to 0 when call is resumed", "should return 0 when currentTask is null") | none | PRESENT | +| `TASK-R-018` | State timer prioritizes Wrap Up over Post Call; consult timer returns `Consult Requested` (initiated), `Consult on Hold` (held), else `Consulting`, falling back to participant `lastUpdated` when no consult timestamp. | Drives the correct timer label/timestamp in CallControl. | `src/Utils/timer-utils.ts` (`calculateStateTimerData`, `calculateConsultTimerData`) | `tests/utils/timer-utils.test.ts` ("should prioritize Wrap Up over Post Call", "should return Consult on Hold when consult is held", "should return Consult Requested label when consult is initiated") | none | PRESENT | +| `TASK-R-019` | `OutdialCall.startOutdial(destination, origin?)` alerts and aborts on empty/whitespace destination; passes `origin` (ANI) only when provided; SDK rejection is logged, not thrown. | Prevent empty outdials and honor optional caller-ID selection. | `src/helper.ts` (`useOutdialCall.startOutdial`) | `tests/OutdialCall/index.tsx` (render + `isAddressBookEnabled` cases) | No direct unit test asserts the empty-destination alert (gap) | WEAK | +| `TASK-R-020` | `getOutdialANIEntries` throws if `cc.agentConfig.outdialANIId` is missing, else returns `cc.getOutdialAniEntries({outdialANI})`; `isTelephonyTaskActive` is true iff any task in `store.taskList` has `mediaType === telephony`. | ANI selection requires a configured ANI id; outdial is gated on no active telephony task. | `src/helper.ts` (`useOutdialCall.getOutdialANIEntries`, `isTelephonyTaskActive`) | `tests/OutdialCall/index.tsx` (component render); helper outdial paths in `tests/helper.ts` | No explicit unit test for the "no outdialANIId throws" branch (gap) | WEAK | +| `TASK-R-021` | `useRealTimeTranscript` maps `realtimeTranscriptionData` to `RealTimeTranscriptEntry[]` only when `currentTaskId` is set and data is non-empty; otherwise returns `liveTranscriptEntries` unchanged. Speaker is normalized (AGENT→"You", CUSTOMER/CALLER→"Customer"). | Live transcript must key off the active task and normalize speaker labels. | `src/helper.ts` (`useRealTimeTranscript`, `mapTranscriptLineToEntry`, `getTranscriptSpeaker`) | `tests/RealtimeTranscript/index.tsx` ("passes props to useRealtimeTranscript hook", "renders fallback when an error is thrown") | none | PRESENT | +| `TASK-R-022` | Each widget shell renders inside an `ErrorBoundary` whose `fallbackRender` returns empty and `onError` calls `store.onErrorCallback(widgetName, error)` when set; absence of the callback must not throw. | A crashing widget must isolate and report, never break the host. | `src/{CallControl,CallControlCAD,IncomingTask,TaskList,OutdialCall,RealTimeTranscript}/index.tsx` | `tests/CallControl/index.tsx`, `tests/CallControlCAD/index.tsx`, `tests/IncomingTask/index.tsx`, `tests/TaskList/index.tsx`, `tests/OutdialCall/index.tsx`, `tests/RealtimeTranscript/index.tsx` (each has an ErrorBoundary + "onErrorCallback not set" case) | none | PRESENT | +| `TASK-R-023` | `CallControl`/`CallControlCAD` render nothing when there is no `currentTask` or when the task is an unaccepted campaign preview (`isUnacceptedCampaignPreview(task, acceptedCampaignIds)`). | Controls must only appear for an accepted, active task — matches Agent Desktop campaign-preview behavior. | `src/CallControl/index.tsx`, `src/CallControlCAD/index.tsx`, `src/Utils/task-util.ts` (`isCampaignPreviewTask`, `isUnacceptedCampaignPreview`) | None found for the unaccepted-campaign-preview early return (gap) | Campaign-preview gating relies on `store.acceptedCampaignIds`, not `participants.hasJoined` | WEAK | ## Design Overview + Every widget is the same four-layer pipeline. The shell (`*/index.tsx`) is an `observer()` that destructures the store fields it needs, builds a hook-input object, calls the hook, merges hook output with extra store fields, and renders the matching `cc-components` component — all wrapped in an `ErrorBoundary` that funnels crashes to `store.onErrorCallback`. The shells contain almost no logic; the only branching there is CallControl's "no task / unaccepted campaign preview → render empty" guard and the `conferenceEnabled ?? true` default. `helper.ts` holds all behavior. Each hook (a) registers SDK-event callbacks through `store.setTaskCallback(EVENT, fn, interactionId)` in a `useEffect` and removes them in cleanup, (b) exposes imperative actions (`accept`, `toggleHold`, `consultCall`, `startOutdial`, …) that call `ITask`/`cc` SDK methods, and (c) derives view state. The most complex hook, `useCallControl`, additionally maintains a dozen `useState` values (recording, buddy agents, consult agent name, target type, timers, conference participants) and recomputes `controlVisibility` via `useMemo(getControlsVisibility, …)`. @@ -131,6 +144,7 @@ Every widget is the same four-layer pipeline. The shell (`*/index.tsx`) is an `o Why this shape: the one-directional layering (`widget → hook → component → store → SDK`) keeps the SDK surface in exactly one file per package and lets MobX `observer()` re-render widgets reactively when the store's task observables change, while consumer callbacks (`on*`) are the only outward coupling. ## Data Flow + Transport is in-process MobX reactivity inward and SDK promise calls + SDK event callbacks outward. SDK events arrive over the SDK's transport (WebSocket/HTTP underneath, owned by the SDK, not this module) and are surfaced as `store.setTaskCallback` invocations. ```mermaid @@ -141,23 +155,24 @@ flowchart LR Hook -->|view state + actions| Component[cc-components presentational] Component -->|user action| Hook Hook -->|task.* / cc.* SDK calls| SDK - Hook -->|setTaskCallback EVENT, fn, interactionId| Store + Hook -->|setTaskCallback EVENT, fn, task| Store Store -->|invokes registered callback| Hook Hook -->|on* callbacks| Consumer[Host app] Hook -->|getControlsVisibility / timer utils| Utils[Utils/*] ``` ## Sequence Diagram(s) + Sequence coverage: -| Operation group | Diagram | Failure / recovery coverage | -|---|---|---| -| Offer → accept / decline (IncomingTask) + RONA | Incoming task accept/decline | RONA timeout path; SDK reject caught+logged; missing interactionId early-return | -| Hold / resume / record / mute / end (CallControl) | Active-call controls | Hold/resume/record/mute SDK rejection logged; mute reverts on failure; recording event subscription/cleanup mismatch noted | -| Consult / transfer / conference (CallControl) | Consult & transfer | Queue-consult flag rollback on error; `transferCall` re-throws; conference vs 1:1 branch | -| Wrap-up (manual + auto) | Wrap-up | Auto-wrap-up countdown + cancel; wrapup SDK rejection logged; next-task promotion | -| Outbound dial (OutdialCall) | Outdial | Empty-destination alert+abort; missing ANI id throws; SDK reject logged | -| Task list select / accept / decline (TaskList) | Task list actions | Per-task accept/decline reject logged; selection updates currentTask | +| Operation group | Diagram | Failure / recovery coverage | +| ------------------------------------------------- | ---------------------------- | -------------------------------------------------------------------------------------------------------------------------- | +| Offer → accept / decline (IncomingTask) + RONA | Incoming task accept/decline | RONA timeout path; SDK reject caught+logged; missing interactionId early-return | +| Hold / resume / record / mute / end (CallControl) | Active-call controls | Hold/resume/record/mute SDK rejection logged; mute reverts on failure; recording event subscription/cleanup mismatch noted | +| Consult / transfer / conference (CallControl) | Consult & transfer | Queue-consult flag rollback on error; `transferCall` re-throws; conference vs 1:1 branch | +| Wrap-up (manual + auto) | Wrap-up | Auto-wrap-up countdown + cancel; wrapup SDK rejection logged; next-task promotion | +| Outbound dial (OutdialCall) | Outdial | Empty-destination alert+abort; missing ANI id throws; SDK reject logged | +| Task list select / accept / decline (TaskList) | Task list actions | Per-task accept/decline reject logged; selection updates currentTask | ```mermaid sequenceDiagram @@ -335,6 +350,7 @@ sequenceDiagram ``` ## Class / Component Relationships + ```mermaid classDiagram class WidgetShell { @@ -384,9 +400,11 @@ classDiagram useOutdialCall --> Store useRealTimeTranscript --> Store ``` + The six widget shells are siblings that each bind to exactly one hook and one presentational component. Only `useCallControl` composes the `Utils/*` helpers (`getControlsVisibility`, the timer utils, and `useHoldTimer`). All hooks depend on the shared `store` singleton for state and event wiring; none import the SDK directly. ## Use Cases + - **UC-1 Accept an offered task (IncomingTask):** Agent → store sets `incomingTask` → widget renders card → Agent clicks Accept → `accept()` → `incomingTask.accept()` → `TASK_ASSIGNED` → `onAccepted`. Evidence: `src/helper.ts` (`useIncomingTask`), `tests/helper.ts` ("should call onAccepted if it is provided"). - **UC-2 Decline / RONA timeout (IncomingTask):** Agent clicks Decline or RONA timer expires → `reject()` → `incomingTask.decline()` → `TASK_REJECT`/`TASK_END` → `onRejected`. Evidence: `src/helper.ts` (`useIncomingTask.reject`), `tests/helper.ts` ("should call onRejected if it is provided"). UI flow: countdown badge on the card; on timeout the card auto-dismisses. - **UC-3 Hold / resume active call (CallControl):** Agent clicks Hold → `toggleHold(true)` → `currentTask.hold()` → `TASK_HOLD` → hold timer starts via `useHoldTimer`, `onHoldResume({isHeld:true})`. Evidence: `src/helper.ts`, `src/Utils/useHoldTimer.ts`, `tests/helper.ts` (hold/resume cases). UI flow: Hold button toggles to Resume; "Hold" elapsed timer shown. @@ -400,9 +418,11 @@ The six widget shells are siblings that each bind to exactly one hook and one pr - **UC-11 View live transcript (RealTimeTranscript):** As `store.realtimeTranscriptionData` updates for `currentTask`, lines are mapped to entries with normalized speaker/time. Evidence: `src/helper.ts` (`useRealTimeTranscript`), `tests/RealtimeTranscript/index.tsx`. ## State Model + Widget-local state (held in `useCallControl` via `useState`, server/task data is NOT owned here): `isRecording`, `buddyAgents`, `loadingBuddyAgents`, `consultAgentName`, `startTimestamp`, `secondsUntilAutoWrapup`, `stateTimerLabel`/`stateTimerTimestamp`, `consultTimerLabel`/`consultTimerTimestamp`, `lastTargetType` (`TARGET_TYPE` agent/queue/entryPoint/dialNumber), `conferenceParticipants`. `useHoldTimer` holds `holdTime` and a `Worker` ref. The authoritative task lifecycle state lives on the `ITask` object in `store` (`currentTask`, `incomingTask`, `taskList`); widgets derive booleans from it via `getControlsVisibility` and the timer utils. Transitions are triggered by SDK events delivered through `store.setTaskCallback`. ## Business Rules & Invariants + - A task with no `data.interactionId` must not have SDK accept/decline called on it — enforced in `useIncomingTask.accept/reject` (`src/helper.ts`). - CallControl renders nothing unless there is a `currentTask` that is not an unaccepted campaign preview — enforced in `src/CallControl/index.tsx` and `src/CallControlCAD/index.tsx` via `isUnacceptedCampaignPreview` (`src/Utils/task-util.ts`). Acceptance is tracked by `store.acceptedCampaignIds`, not `participants.hasJoined`. - Queue-consult bookkeeping (`isQueueConsultInProgress`, `currentConsultQueueId`) must be cleared on both success and error of `consultCall` so `endConsultCall` never sends a stale `queueId` — enforced in `useCallControl.consultCall/endConsultCall`. @@ -411,7 +431,9 @@ Widget-local state (held in `useCallControl` via `useState`, server/task data is - `getControlsVisibility` must always return a complete control set (safe all-hidden defaults on error) and never throw into render — enforced by its try/catch (`src/Utils/task-util.ts`). ## State Machine + States are derived from the live `ITask` (`data.interaction.state`, participant flags, consult/conference/hold status); this module observes and acts on transitions rather than owning them. + ```mermaid stateDiagram-v2 [*] --> Offered: store sets incomingTask @@ -434,6 +456,7 @@ stateDiagram-v2 ``` ## UI Flow + - **IncomingTask:** task card with caller/queue/media info, RONA countdown badge, Accept/Decline buttons. Empty state = no card when `incomingTask` is null. Error state = empty fragment via ErrorBoundary. - **TaskList:** list of task cards; selected task highlighted (mirrors `currentTask`); per-task Accept/Decline; empty list renders nothing. Campaign-preview tasks render a `CampaignTask` when `hasCampaignPreviewEnabled` (default true). - **CallControl / CallControlCAD:** rows of controls (hold/resume, mute, record, transfer, consult, conference, end, wrap-up), consult sub-controls (switch/merge/end consult), wrap-up dropdown, auto-wrap-up countdown, hold/consult/state timers. Hidden entirely when no `currentTask` or unaccepted campaign preview. CAD variant adds `callControlClassName` / `callControlConsultClassName` styling hooks. Disabled/enabled state of every button comes from `getControlsVisibility`. @@ -441,24 +464,26 @@ stateDiagram-v2 - **RealTimeTranscript:** scrolling transcript with normalized speaker ("You"/"Customer") and `HH:MM` display time; renders supplied `liveTranscriptEntries` when no live data for the current task. ## Error Handling & Failure Modes -| Condition | Signal (error/code/result) | Caller recovery | -|---|---|---| -| `accept()`/`reject()` with no `interactionId` | Silent early return (no SDK call) | None needed; no-op | -| SDK rejection on accept/decline/hold/resume/end/wrapup/recording | `logger.error(...)`; promise rejection swallowed | None surfaced; consumer relies on subsequent SDK state events | -| `toggleMute` SDK failure | `onToggleMute` fires with the *previous* `isMuted`; store not updated | UI stays consistent with actual mute state | -| `toggleMute` when control hidden | `logger.warn` + no-op | None | -| `consultCall`/`endConsultCall`/`consultTransfer`/`transferCall`/`consultConference`/`switch*`/`exitConference` failure | `logError` then **re-throws** | Calling modal/component must catch and surface to the agent | -| Queue `consultCall` failure | Queue-consult flags rolled back, then re-throw | Caller handles; no stale `queueId` | -| `startOutdial` empty destination | `alert(...)` + abort (no SDK call) | Agent re-enters a valid number | -| `startOutdial` SDK failure | `logger.error` (swallowed) | Agent retries | -| `getOutdialANIEntries` missing `outdialANIId` | `throw Error('No OutdialANI Id received.')` | Caller catches; ANI dropdown empty | -| `getAddressBookEntries`/`getEntryPoints`/`getQueuesFetcher` failure (useCallControl) | `logger.error` + returns `{data:[], meta:{page:0,totalPages:0}}` | Empty paginated result rendered | -| `getControlsVisibility` internal error | try/catch returns all-hidden safe defaults | All controls hidden, no crash | -| Any widget render crash | ErrorBoundary renders empty fragment + `store.onErrorCallback(name, error)` if set | Host notified; widget removed from view | + +| Condition | Signal (error/code/result) | Caller recovery | +| ---------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------- | ------------------------------------------------------------- | +| `accept()`/`reject()` with no `interactionId` | Silent early return (no SDK call) | None needed; no-op | +| SDK rejection on accept/decline/hold/resume/end/wrapup/recording | `logger.error(...)`; promise rejection swallowed | None surfaced; consumer relies on subsequent SDK state events | +| `toggleMute` SDK failure | `onToggleMute` fires with the _previous_ `isMuted`; store not updated | UI stays consistent with actual mute state | +| `toggleMute` when control hidden | `logger.warn` + no-op | None | +| `consultCall`/`endConsultCall`/`consultTransfer`/`transferCall`/`consultConference`/`switch*`/`exitConference` failure | `logError` then **re-throws** | Calling modal/component must catch and surface to the agent | +| Queue `consultCall` failure | Queue-consult flags rolled back, then re-throw | Caller handles; no stale `queueId` | +| `startOutdial` empty destination | `alert(...)` + abort (no SDK call) | Agent re-enters a valid number | +| `startOutdial` SDK failure | `logger.error` (swallowed) | Agent retries | +| `getOutdialANIEntries` missing `outdialANIId` | `throw Error('No OutdialANI Id received.')` | Caller catches; ANI dropdown empty | +| `getAddressBookEntries`/`getEntryPoints`/`getQueuesFetcher` failure (useCallControl) | `logger.error` + returns `{data:[], meta:{page:0,totalPages:0}}` | Empty paginated result rendered | +| `getControlsVisibility` internal error | try/catch returns all-hidden safe defaults | All controls hidden, no crash | +| Any widget render crash | ErrorBoundary renders empty fragment + `store.onErrorCallback(name, error)` if set | Host notified; widget removed from view | ## Pitfalls + - **Recording event subscription/cleanup mismatch:** `useCallControl` subscribes to `TASK_RECORDING_PAUSED`/`TASK_RECORDING_RESUMED` but the cleanup removes `CONTACT_RECORDING_PAUSED`/`CONTACT_RECORDING_RESUMED` (`src/helper.ts` recording `useEffect`). Both names exist in `store.types.ts`, so the subscribed callbacks are not removed by name on teardown — a latent listener-leak/duplicate-callback edge. Verify against `packages/contact-center/store/src/store.types.ts` before changing. -- **Callback identity in cleanup (IncomingTask):** registration uses inline closures for `TASK_ASSIGNED` but `removeTaskCallback` is called with `taskAssignCallback`; the references differ, so removal may not match registration. Confirm `store.removeTaskCallback` matching semantics before relying on cleanup. +- **Callback identity in cleanup (IncomingTask):** `setTaskCallback` and `removeTaskCallback` now accept the `ITask` object directly (not a string ID) and call `task.on()`/`task.off()` on the same reference. This eliminates the stale `store.taskList` lookup race that previously orphaned listeners during React 18 StrictMode double-mount/unmount. Callers must pass the same task object and the same callback reference for removal to succeed. - **Migration docs are aspirational, not current:** archived docs / `ai-docs/migration/*.md` describe `task.uiControls`, renamed events (`TASK_WRAPPEDUP`, `TASK_CONSULT_CREATED`), and deletion of `getControlsVisibility`. None of this is in the code today — current code computes visibility locally and the store still emits `AGENT_WRAPPEDUP`/`CONTACT_RECORDING_*`. Do not implement against the migration docs as if they were live. - **Second-vs-millisecond timestamps:** `useHoldTimer` treats values `< 1e10` as seconds and multiplies by 1000; passing an already-ms small value would mis-scale. `findHoldTimestamp` returns `0` as a valid hold timestamp (not null) — guard with explicit null checks. - **`transferCall`/consult ops re-throw while hold/end/wrapup swallow:** inconsistent error contract within the same hook. Callers of consult/transfer must wrap in try/catch; callers of hold/end/wrapup must not expect a throw. @@ -466,45 +491,49 @@ stateDiagram-v2 - **`conferenceEnabled` defaulting happens in the shell**, not the hook (`?? true`). Reading the prop directly in the hook without the default would see `undefined`. ## Module Do's / Don'ts + - DO put every SDK call and `store.setTaskCallback` registration in `helper.ts`; keep widget shells to store-selection + render only. - DO read button visibility/enablement from `getControlsVisibility` output (`controlVisibility`), not from ad-hoc device/feature checks in components. - DO clear queue-consult flags on both success and failure paths of `consultCall`. - DON'T import the SDK (`@webex/contact-center`) directly in a widget shell — go through `store`. - DON'T derive hold/consult state from button `isEnabled` flags; use the task object + `getConsultStatus`/`findHoldStatus`. -- DON'T add new task-event subscriptions without matching the exact event name in both `setTaskCallback` and the cleanup `removeTaskCallback`. +- DON'T add new task-event subscriptions without matching the exact event name in both `setTaskCallback` and the cleanup `removeTaskCallback`. Always pass the task object (not an ID string) and the same callback reference to both. ## Host Integration & Theming + These widgets are published through `@webex/cc-widgets` as r2wc custom elements (e.g. ``); peer `react ^18`. They require an initialized `@webex/cc-store` singleton (SDK connected, agent logged in) before mount — `currentTask`/`incomingTask`/`taskList`/`cc`/`logger` must be populated by the store. Presentational styling comes from `@webex/cc-components`; `CallControlCAD` exposes `callControlClassName`/`callControlConsultClassName` for host CSS overrides. The host supplies `store.onErrorCallback` to receive widget-crash notifications. ## Test-Case Strategy (module) + Tests are split between widget-shell render tests (each `tests//index.tsx` asserts the hook is called with the right props, the presentational component receives merged output, and the ErrorBoundary renders empty + invokes/handles-missing `onErrorCallback`) and exhaustive hook/util logic tests. `tests/helper.ts` is the large behavioral suite covering accept/decline, hold/resume, end, recording pause/resume (positive + SDK-failure negative cases), mute (including rapid toggles and failure revert), wrap-up + auto-wrap-up cancel, consult/transfer/conference, queue-consult flags, buddy-agent loading, and consulting-agent extraction. `tests/utils/task-util.ts` matrices `getControlsVisibility` across device types (BROWSER/AGENT_DN/EXTENSION) and media types (telephony/chat/email) plus EP-DN end-button rules and the error→safe-defaults path. `tests/utils/timer-utils.test.ts` and `tests/utils/useHoldTimer.test.ts` cover label priority, consult-on-hold, null-task defaults, and consult-vs-main hold prioritization. Edge cases asserted: missing interaction/participants, missing currentTask, error logging in every callback. Gaps: no unit test for the OutdialCall empty-destination alert, the `getOutdialANIEntries` missing-ANI-id throw, or the CallControl unaccepted-campaign-preview early return. -| Behavior / Requirement | Existing test evidence | Gap | -|---|---|---| -| `TASK-R-001` accept guarded + error-safe | `tests/helper.ts` ("should return if there is no taskId for incoming task", "should handle errors when accepting a task") | none | -| `TASK-R-002` reject / RONA | `tests/helper.ts` ("should call onRejected if it is provided", "should handle errors when declining a task") | RONA timer UI tested in cc-components, not here | -| `TASK-R-003` incoming event wiring | `tests/helper.ts` ("should setup event listeners for the incoming call") | none | -| `TASK-R-004` task-list accept/decline/select | `tests/helper.ts` (task-list accept/decline/select cases) | none | -| `TASK-R-005` conditional store-callback wiring | `tests/helper.ts` ("should not call onTaskAccepted if it is not provided") | none | -| `TASK-R-006` hold/resume | `tests/helper.ts` ("should call onHoldResume with hold=true/false…", "should log an error if hold/resume fails") | none | -| `TASK-R-007` recording toggle | `tests/helper.ts` (pause/resume + failure cases) | No test asserts the PAUSED/RESUMED vs CONTACT_* cleanup mismatch | -| `TASK-R-008` mute | `tests/helper.ts` ("toggle mute…", "rapid toggleMute", "onToggleMute on error") | none | -| `TASK-R-009` wrap-up + next-task promotion | `tests/helper.ts` ("should call wrapupCall", "…if wrapup fails") | none | -| `TASK-R-010` auto-wrap-up + cancel | `tests/helper.ts` ("initialize secondsUntilAutoWrapup…", "cancelAutoWrapup…") | none | -| `TASK-R-011` consult + queue flags | `tests/helper.ts` ("consultCall…", "endConsultCall with queue parameters…") | none | -| `TASK-R-012` consult vs conference transfer | `tests/helper.ts` ("consultTransfer successfully", "…when currentTask data is missing") | none | -| `TASK-R-013` blind transfer re-throw | `tests/helper.ts` ("transferCall successfully") | No explicit re-throw assertion | -| `TASK-R-014` switch/exit conference legs | `tests/helper.ts` (consult/conference cases) | Thin coverage of switch-to-main/consult media targeting | -| `TASK-R-015` control visibility matrix | `tests/utils/task-util.ts` (device/media + safe-defaults cases) | none | -| `TASK-R-016` EP-DN end-button rules | `tests/utils/task-util.ts` (EP-DN + variant detection cases) | none | -| `TASK-R-017` hold timer | `tests/utils/useHoldTimer.test.ts` (consult priority, sec→ms, reset) | none | -| `TASK-R-018` timer labels | `tests/utils/timer-utils.test.ts` (wrap-up priority, consult-on-hold/requested) | none | -| `TASK-R-019` outdial validation | `tests/OutdialCall/index.tsx` (render/address-book) | No empty-destination alert test | -| `TASK-R-020` ANI / telephony gating | `tests/OutdialCall/index.tsx` | No missing-ANI-id throw test | -| `TASK-R-021` transcript mapping | `tests/RealtimeTranscript/index.tsx` | none | -| `TASK-R-022` ErrorBoundary isolation | each `tests//index.tsx` (ErrorBoundary + onErrorCallback-undefined) | none | -| `TASK-R-023` campaign-preview gating | None found | No test for unaccepted-campaign-preview early return | +| Behavior / Requirement | Existing test evidence | Gap | +| ---------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------ | +| `TASK-R-001` accept guarded + error-safe | `tests/helper.ts` ("should return if there is no taskId for incoming task", "should handle errors when accepting a task") | none | +| `TASK-R-002` reject / RONA | `tests/helper.ts` ("should call onRejected if it is provided", "should handle errors when declining a task") | RONA timer UI tested in cc-components, not here | +| `TASK-R-003` incoming event wiring | `tests/helper.ts` ("should setup event listeners for the incoming call") | none | +| `TASK-R-004` task-list accept/decline/select | `tests/helper.ts` (task-list accept/decline/select cases) | none | +| `TASK-R-005` conditional store-callback wiring | `tests/helper.ts` ("should not call onTaskAccepted if it is not provided") | none | +| `TASK-R-006` hold/resume | `tests/helper.ts` ("should call onHoldResume with hold=true/false…", "should log an error if hold/resume fails") | none | +| `TASK-R-007` recording toggle | `tests/helper.ts` (pause/resume + failure cases) | No test asserts the PAUSED/RESUMED vs CONTACT\_\* cleanup mismatch | +| `TASK-R-008` mute | `tests/helper.ts` ("toggle mute…", "rapid toggleMute", "onToggleMute on error") | none | +| `TASK-R-009` wrap-up + next-task promotion | `tests/helper.ts` ("should call wrapupCall", "…if wrapup fails") | none | +| `TASK-R-010` auto-wrap-up + cancel | `tests/helper.ts` ("initialize secondsUntilAutoWrapup…", "cancelAutoWrapup…") | none | +| `TASK-R-011` consult + queue flags | `tests/helper.ts` ("consultCall…", "endConsultCall with queue parameters…") | none | +| `TASK-R-012` consult vs conference transfer | `tests/helper.ts` ("consultTransfer successfully", "…when currentTask data is missing") | none | +| `TASK-R-013` blind transfer re-throw | `tests/helper.ts` ("transferCall successfully") | No explicit re-throw assertion | +| `TASK-R-014` switch/exit conference legs | `tests/helper.ts` (consult/conference cases) | Thin coverage of switch-to-main/consult media targeting | +| `TASK-R-015` control visibility matrix | `tests/utils/task-util.ts` (device/media + safe-defaults cases) | none | +| `TASK-R-016` EP-DN end-button rules | `tests/utils/task-util.ts` (EP-DN + variant detection cases) | none | +| `TASK-R-017` hold timer | `tests/utils/useHoldTimer.test.ts` (consult priority, sec→ms, reset) | none | +| `TASK-R-018` timer labels | `tests/utils/timer-utils.test.ts` (wrap-up priority, consult-on-hold/requested) | none | +| `TASK-R-019` outdial validation | `tests/OutdialCall/index.tsx` (render/address-book) | No empty-destination alert test | +| `TASK-R-020` ANI / telephony gating | `tests/OutdialCall/index.tsx` | No missing-ANI-id throw test | +| `TASK-R-021` transcript mapping | `tests/RealtimeTranscript/index.tsx` | none | +| `TASK-R-022` ErrorBoundary isolation | each `tests//index.tsx` (ErrorBoundary + onErrorCallback-undefined) | none | +| `TASK-R-023` campaign-preview gating | None found | No test for unaccepted-campaign-preview early return | ## Traceability + - Repo architecture: [`ARCHITECTURE.md`](../../../../ai-docs/ARCHITECTURE.md) · Registry: [`SPEC_INDEX.md`](../../../../ai-docs/SPEC_INDEX.md) · Contracts: [`CONTRACTS.md`](../../../../ai-docs/CONTRACTS.md) - Coverage state & contracts baseline: `.sdd/manifest.json` diff --git a/packages/contact-center/task/src/helper.ts b/packages/contact-center/task/src/helper.ts index 2405aa814..b41c72aab 100644 --- a/packages/contact-center/task/src/helper.ts +++ b/packages/contact-center/task/src/helper.ts @@ -241,29 +241,21 @@ export const useIncomingTask = (props: UseTaskProps) => { useEffect(() => { try { if (!incomingTask) return; - store.setTaskCallback(TASK_EVENTS.TASK_ASSIGNED, taskAssignCallback, incomingTask.data.interactionId); - store.setTaskCallback(TASK_EVENTS.TASK_CONSULT_ACCEPTED, taskAssignCallback, incomingTask?.data.interactionId); - store.setTaskCallback(TASK_EVENTS.TASK_END, taskRejectCallback, incomingTask?.data.interactionId); - store.setTaskCallback(TASK_EVENTS.TASK_REJECT, taskRejectCallback, incomingTask?.data.interactionId); - store.setTaskCallback(TASK_EVENTS.TASK_CONSULT_END, taskRejectCallback, incomingTask?.data.interactionId); - store.setTaskCallback(TASK_EVENTS.TASK_OUTDIAL_FAILED, taskRejectCallback, incomingTask?.data.interactionId); + store.setTaskCallback(TASK_EVENTS.TASK_ASSIGNED, taskAssignCallback, incomingTask); + store.setTaskCallback(TASK_EVENTS.TASK_CONSULT_ACCEPTED, taskAssignCallback, incomingTask); + store.setTaskCallback(TASK_EVENTS.TASK_END, taskRejectCallback, incomingTask); + store.setTaskCallback(TASK_EVENTS.TASK_REJECT, taskRejectCallback, incomingTask); + store.setTaskCallback(TASK_EVENTS.TASK_CONSULT_END, taskRejectCallback, incomingTask); + store.setTaskCallback(TASK_EVENTS.TASK_OUTDIAL_FAILED, taskRejectCallback, incomingTask); return () => { try { - store.removeTaskCallback(TASK_EVENTS.TASK_ASSIGNED, taskAssignCallback, incomingTask?.data.interactionId); - store.removeTaskCallback( - TASK_EVENTS.TASK_CONSULT_ACCEPTED, - taskAssignCallback, - incomingTask?.data.interactionId - ); - store.removeTaskCallback(TASK_EVENTS.TASK_END, taskRejectCallback, incomingTask?.data.interactionId); - store.removeTaskCallback(TASK_EVENTS.TASK_REJECT, taskRejectCallback, incomingTask?.data.interactionId); - store.removeTaskCallback(TASK_EVENTS.TASK_CONSULT_END, taskRejectCallback, incomingTask?.data.interactionId); - store.removeTaskCallback( - TASK_EVENTS.TASK_OUTDIAL_FAILED, - taskRejectCallback, - incomingTask?.data.interactionId - ); + store.removeTaskCallback(TASK_EVENTS.TASK_ASSIGNED, taskAssignCallback, incomingTask); + store.removeTaskCallback(TASK_EVENTS.TASK_CONSULT_ACCEPTED, taskAssignCallback, incomingTask); + store.removeTaskCallback(TASK_EVENTS.TASK_END, taskRejectCallback, incomingTask); + store.removeTaskCallback(TASK_EVENTS.TASK_REJECT, taskRejectCallback, incomingTask); + store.removeTaskCallback(TASK_EVENTS.TASK_CONSULT_END, taskRejectCallback, incomingTask); + store.removeTaskCallback(TASK_EVENTS.TASK_OUTDIAL_FAILED, taskRejectCallback, incomingTask); } catch (error) { logger?.error(`CC-Widgets: Task: Error in useIncomingTask cleanup - ${error.message}`, { module: 'useIncomingTask', @@ -741,29 +733,22 @@ export const useCallControl = (props: useCallControlProps) => { method: 'useEffect-init', }); - const interactionId = currentTask.data.interactionId; - - store.setTaskCallback( - // Should use holdCallback - TASK_EVENTS.TASK_HOLD, - holdCallback, - interactionId - ); - store.setTaskCallback(TASK_EVENTS.TASK_RESUME, resumeCallback, interactionId); - store.setTaskCallback(TASK_EVENTS.TASK_END, endCallCallback, interactionId); - store.setTaskCallback(TASK_EVENTS.TASK_WRAPUP, endCallCallback, interactionId); // Also call onEnd when entering wrapup - store.setTaskCallback(TASK_EVENTS.TASK_WRAPPEDUP, wrapupCallCallback, interactionId); - store.setTaskCallback(TASK_EVENTS.TASK_RECORDING_PAUSED, pauseRecordingCallback, interactionId); - store.setTaskCallback(TASK_EVENTS.TASK_RECORDING_RESUMED, resumeRecordingCallback, interactionId); + store.setTaskCallback(TASK_EVENTS.TASK_HOLD, holdCallback, currentTask); + store.setTaskCallback(TASK_EVENTS.TASK_RESUME, resumeCallback, currentTask); + store.setTaskCallback(TASK_EVENTS.TASK_END, endCallCallback, currentTask); + store.setTaskCallback(TASK_EVENTS.TASK_WRAPUP, endCallCallback, currentTask); // Also call onEnd when entering wrapup + store.setTaskCallback(TASK_EVENTS.TASK_WRAPPEDUP, wrapupCallCallback, currentTask); + store.setTaskCallback(TASK_EVENTS.TASK_RECORDING_PAUSED, pauseRecordingCallback, currentTask); + store.setTaskCallback(TASK_EVENTS.TASK_RECORDING_RESUMED, resumeRecordingCallback, currentTask); return () => { - store.removeTaskCallback(TASK_EVENTS.TASK_HOLD, holdCallback, interactionId); - store.removeTaskCallback(TASK_EVENTS.TASK_RESUME, resumeCallback, interactionId); - store.removeTaskCallback(TASK_EVENTS.TASK_END, endCallCallback, interactionId); - store.removeTaskCallback(TASK_EVENTS.TASK_WRAPUP, endCallCallback, interactionId); - store.removeTaskCallback(TASK_EVENTS.TASK_WRAPPEDUP, wrapupCallCallback, interactionId); - store.removeTaskCallback(TASK_EVENTS.TASK_RECORDING_PAUSED, pauseRecordingCallback, interactionId); - store.removeTaskCallback(TASK_EVENTS.TASK_RECORDING_RESUMED, resumeRecordingCallback, interactionId); + store.removeTaskCallback(TASK_EVENTS.TASK_HOLD, holdCallback, currentTask); + store.removeTaskCallback(TASK_EVENTS.TASK_RESUME, resumeCallback, currentTask); + store.removeTaskCallback(TASK_EVENTS.TASK_END, endCallCallback, currentTask); + store.removeTaskCallback(TASK_EVENTS.TASK_WRAPUP, endCallCallback, currentTask); + store.removeTaskCallback(TASK_EVENTS.TASK_WRAPPEDUP, wrapupCallCallback, currentTask); + store.removeTaskCallback(TASK_EVENTS.TASK_RECORDING_PAUSED, pauseRecordingCallback, currentTask); + store.removeTaskCallback(TASK_EVENTS.TASK_RECORDING_RESUMED, resumeRecordingCallback, currentTask); }; }, [currentTask]); diff --git a/packages/contact-center/task/tests/helper.ts b/packages/contact-center/task/tests/helper.ts index 85c82eefc..7889dec4f 100644 --- a/packages/contact-center/task/tests/helper.ts +++ b/packages/contact-center/task/tests/helper.ts @@ -107,20 +107,12 @@ describe('useIncomingTask Hook', () => { }) ); - expect(setTaskCallbackSpy).toHaveBeenCalledWith(TASK_EVENTS.TASK_ASSIGNED, expect.any(Function), 'interaction1'); - expect(setTaskCallbackSpy).toHaveBeenCalledWith(TASK_EVENTS.TASK_REJECT, expect.any(Function), 'interaction1'); - expect(setTaskCallbackSpy).toHaveBeenCalledWith(TASK_EVENTS.TASK_END, expect.any(Function), 'interaction1'); - expect(setTaskCallbackSpy).toHaveBeenCalledWith( - TASK_EVENTS.TASK_CONSULT_ACCEPTED, - expect.any(Function), - 'interaction1' - ); - expect(setTaskCallbackSpy).toHaveBeenCalledWith(TASK_EVENTS.TASK_CONSULT_END, expect.any(Function), 'interaction1'); - expect(setTaskCallbackSpy).toHaveBeenCalledWith( - TASK_EVENTS.TASK_OUTDIAL_FAILED, - expect.any(Function), - 'interaction1' - ); + expect(setTaskCallbackSpy).toHaveBeenCalledWith(TASK_EVENTS.TASK_ASSIGNED, expect.any(Function), taskMock); + expect(setTaskCallbackSpy).toHaveBeenCalledWith(TASK_EVENTS.TASK_REJECT, expect.any(Function), taskMock); + expect(setTaskCallbackSpy).toHaveBeenCalledWith(TASK_EVENTS.TASK_END, expect.any(Function), taskMock); + expect(setTaskCallbackSpy).toHaveBeenCalledWith(TASK_EVENTS.TASK_CONSULT_ACCEPTED, expect.any(Function), taskMock); + expect(setTaskCallbackSpy).toHaveBeenCalledWith(TASK_EVENTS.TASK_CONSULT_END, expect.any(Function), taskMock); + expect(setTaskCallbackSpy).toHaveBeenCalledWith(TASK_EVENTS.TASK_OUTDIAL_FAILED, expect.any(Function), taskMock); expect(setTaskCallbackSpy).toHaveBeenCalledTimes(6); // Clean up @@ -128,24 +120,16 @@ describe('useIncomingTask Hook', () => { unmount(); }); - expect(removeTaskCallbackSpy).toHaveBeenCalledWith(TASK_EVENTS.TASK_ASSIGNED, expect.any(Function), 'interaction1'); - expect(removeTaskCallbackSpy).toHaveBeenCalledWith(TASK_EVENTS.TASK_REJECT, expect.any(Function), 'interaction1'); - expect(removeTaskCallbackSpy).toHaveBeenCalledWith(TASK_EVENTS.TASK_END, expect.any(Function), 'interaction1'); + expect(removeTaskCallbackSpy).toHaveBeenCalledWith(TASK_EVENTS.TASK_ASSIGNED, expect.any(Function), taskMock); + expect(removeTaskCallbackSpy).toHaveBeenCalledWith(TASK_EVENTS.TASK_REJECT, expect.any(Function), taskMock); + expect(removeTaskCallbackSpy).toHaveBeenCalledWith(TASK_EVENTS.TASK_END, expect.any(Function), taskMock); expect(removeTaskCallbackSpy).toHaveBeenCalledWith( TASK_EVENTS.TASK_CONSULT_ACCEPTED, expect.any(Function), - 'interaction1' - ); - expect(removeTaskCallbackSpy).toHaveBeenCalledWith( - TASK_EVENTS.TASK_CONSULT_END, - expect.any(Function), - 'interaction1' - ); - expect(removeTaskCallbackSpy).toHaveBeenCalledWith( - TASK_EVENTS.TASK_OUTDIAL_FAILED, - expect.any(Function), - 'interaction1' + taskMock ); + expect(removeTaskCallbackSpy).toHaveBeenCalledWith(TASK_EVENTS.TASK_CONSULT_END, expect.any(Function), taskMock); + expect(removeTaskCallbackSpy).toHaveBeenCalledWith(TASK_EVENTS.TASK_OUTDIAL_FAILED, expect.any(Function), taskMock); expect(removeTaskCallbackSpy).toHaveBeenCalledTimes(6); setTaskCallbackSpy.mockRestore(); @@ -155,7 +139,7 @@ describe('useIncomingTask Hook', () => { it('should call onAccepted if it is provided', async () => { // Mock store.setTaskCallback to capture the callback let assignedCallback; - jest.spyOn(store, 'setTaskCallback').mockImplementation((event, callback) => { + const setTaskCallbackSpy = jest.spyOn(store, 'setTaskCallback').mockImplementation((event, callback) => { if (event === TASK_EVENTS.TASK_ASSIGNED) { assignedCallback = callback; } @@ -182,6 +166,7 @@ describe('useIncomingTask Hook', () => { // Ensure no errors are logged expect(logger.error).not.toHaveBeenCalled(); + setTaskCallbackSpy.mockRestore(); }); it('should call onRejected if it is provided', async () => { @@ -755,6 +740,8 @@ describe('useCallControl', () => { const mockOnWrapUp = jest.fn(); beforeEach(() => { + // Restore any spied implementations leaked from prior describe blocks + jest.restoreAllMocks(); store.refreshTaskList(); // Mock the MediaStreamTrack and MediaStream classes for the test environment global.MediaStreamTrack = jest.fn().mockImplementation(() => ({