From 340bf5380945a682c5a5de6c22b6aa9289b2134f Mon Sep 17 00:00:00 2001 From: Matthew Olker Date: Tue, 21 Jul 2026 16:10:12 -0400 Subject: [PATCH 1/5] fix: pass task object directly to setTaskCallback/removeTaskCallback to prevent duplicate event listeners - Changed setTaskCallback and removeTaskCallback signatures to accept ITask instead of taskId string, eliminating volatile store.taskList lookup that caused orphaned listeners during React 18 StrictMode mount/unmount cycles - Added diagnostic logging with optional chaining on logger - Updated useIncomingTask and useCallControl hooks to pass task objects - Fixed leaking spy in useIncomingTask test (mockRestore) - Added jest.restoreAllMocks() in useCallControl beforeEach - Added regression test for removal when task absent from store.taskList CAI-8283 --- .../store/src/storeEventsWrapper.ts | 26 ++++--- .../store/tests/storeEventsWrapper.ts | 31 +++++---- packages/contact-center/task/src/helper.ts | 67 +++++++------------ packages/contact-center/task/tests/helper.ts | 45 +++++-------- 4 files changed, 77 insertions(+), 92 deletions(-) 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/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(() => ({ From ceea7163649f5e61d5da2ae76980f78c772fac5a Mon Sep 17 00:00:00 2001 From: Matthew Olker Date: Tue, 21 Jul 2026 16:21:55 -0400 Subject: [PATCH 2/5] docs: update store and task specs for setTaskCallback/removeTaskCallback signature change - Added STORE-R-022 requirement for ITask-based signatures - Updated test strategy with regression test coverage - Updated task spec overview, data flow, pitfalls, and do's/don'ts - Reflects CAI-8283 fix for duplicate event listeners CAI-8283 --- .../store/ai-docs/store-spec.md | 189 ++++++++------ .../contact-center/task/ai-docs/task-spec.md | 243 ++++++++++-------- 2 files changed, 246 insertions(+), 186 deletions(-) diff --git a/packages/contact-center/store/ai-docs/store-spec.md b/packages/contact-center/store/ai-docs/store-spec.md index 8e9e76621..fbf0d83b5 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,15 @@ 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.42. 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,62 +64,70 @@ 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. ## Requires (dependencies) + - `@webex/contact-center` SDK (peer, floor pinned in `package.json` at `3.12.0-next.42`) — the entire CC runtime: `Webex.init()`, `webex.cc.*` methods, the CC/task event stream, agent `Profile`, `webex.credentials.getUserToken()`. Consumed ONLY through the store. Fallback on unavailability: `Store.init()` rejects after a 6000ms timeout (`src/store.ts:140-142`); the wrapper wraps the rejection and invokes `onErrorCallback('Store', err)` (`src/storeEventsWrapper.ts:442-452`). - `mobx` ^6.13.5 — observable state and `runInAction` for all mutations. - 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 +137,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 +166,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 +276,7 @@ sequenceDiagram ``` ## Class / Component Relationships + ```mermaid classDiagram class IStore { <> } @@ -273,9 +292,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 +305,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 +317,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 +325,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 +343,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/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` From 21774a116aa3c32a78cc07e205c79e0ef43b8d54 Mon Sep 17 00:00:00 2001 From: Matthew Olker Date: Thu, 13 Aug 2026 12:10:49 -0400 Subject: [PATCH 3/5] fix(store,task): address PR #723 review feedback - Fix removeTaskCallback guard test to assert on mockTask.off instead of mockTask.on so the assertion actually catches a regression - Verify setTaskCallback receives the correct task object (not just any function) in the useCallControl event-listener test - Update react-patterns.md example to pass the ITask object instead of the stale interactionId string to setTaskCallback/removeTaskCallback --- ai-docs/patterns/react-patterns.md | 65 +++++++++++++++---- .../store/tests/storeEventsWrapper.ts | 4 +- packages/contact-center/task/tests/helper.ts | 7 +- 3 files changed, 58 insertions(+), 18 deletions(-) diff --git a/ai-docs/patterns/react-patterns.md b/ai-docs/patterns/react-patterns.md index a8bb9b5e9..917309f85 100644 --- a/ai-docs/patterns/react-patterns.md +++ b/ai-docs/patterns/react-patterns.md @@ -24,6 +24,7 @@ an `ErrorBoundary`; a `helper.ts` hook holds business logic and SDK calls; the p (in `cc-components`) is pure UI driven by props. **Correct** + ```typescript // from packages/contact-center/user-state/src/user-state/index.tsx const UserStateInternal: React.FunctionComponent = observer(({onStateChange}) => { @@ -36,12 +37,15 @@ const UserStateInternal: React.FunctionComponent = observer(({o return ; }); ``` + The three real layers for this feature: + - Widget: `packages/contact-center/user-state/src/user-state/index.tsx` - Hook: `packages/contact-center/user-state/src/helper.ts` (`useUserState`) - Component: `packages/contact-center/cc-components/src/components/UserState/user-state.tsx` (`UserStateComponent`) **Incorrect** + ```typescript // a presentational component in cc-components reaching into the store import store from '@webex/cc-store'; @@ -49,15 +53,18 @@ export const UserStateComponent = () => { const {idleCodes} = store; // component must not read the store or call the SDK }; ``` + **Why wrong:** It reverses the dependency arrow (`cc-components` must not import the store/SDK) and makes the component untestable in isolation — it can no longer be driven purely by props. See ADR-0001. **Where it appears** + - `user-state`: `.../user-state/src/user-state/index.tsx` → `.../user-state/src/helper.ts` → `.../cc-components/src/components/UserState/user-state.tsx` - `station-login`: `.../station-login/src/station-login/index.tsx` → `.../station-login/src/helper.ts` → `.../cc-components/src/components/StationLogin/station-login.tsx` - `task` (CallControl): `.../task/src/CallControl/index.tsx` → `.../task/src/helper.ts` → `.../cc-components/src/components/task/CallControl/call-control.tsx` **Edge cases / exceptions** + - The `task` package has several widgets sharing one `helper.ts` (see the hooks pattern below). - Small presentational sub-components may compose without their own hook, but data still arrives via props. @@ -69,6 +76,7 @@ the component untestable in isolation — it can no longer be driven purely by p catches render errors and reports them through `store.onErrorCallback`. **Correct** + ```typescript // from packages/contact-center/task/src/CallControl/index.tsx const CallControl: React.FunctionComponent = (props) => { @@ -86,17 +94,21 @@ const CallControl: React.FunctionComponent = (props) => { ``` **Incorrect** + ```typescript // exporting the observer component directly, with no boundary export {CallControlInternal as CallControl}; ``` + **Why wrong:** A render error in one widget would otherwise bubble up and blank out the whole host page. The boundary contains the failure to that widget and forwards it to the host via `onErrorCallback`. **Where it appears** + - `packages/contact-center/user-state/src/user-state/index.tsx` , `packages/contact-center/station-login/src/station-login/index.tsx` , `packages/contact-center/task/src/CallControl/index.tsx` (also `IncomingTask`, `OutdialCall`, `CallControlCAD`) **Edge cases / exceptions** + - `fallbackRender={() => <>}` renders nothing on failure by design (widgets are embedded in a host app that owns the surrounding UI). - The first `onError` argument is the widget name string — keep it matching the widget so host telemetry attributes errors correctly. @@ -108,6 +120,7 @@ The boundary contains the failure to that widget and forwards it to the host via `use*` hook exported from the feature's `helper.ts`, not inline in the widget. **Correct** + ```typescript // from packages/contact-center/task/src/helper.ts const loadBuddyAgents = useCallback(async () => { @@ -126,24 +139,29 @@ const loadBuddyAgents = useCallback(async () => { } }, [logger]); ``` + Real hooks: `useUserState` (`user-state/src/helper.ts`), `useStationLogin` (`station-login/src/helper.ts`), and `useTaskList` / `useIncomingTask` / `useCallControl` / `useOutdialCall` / `useRealTimeTranscript` (all in `task/src/helper.ts`). **Incorrect** + ```typescript // SDK call inline in the widget instead of a hook const CallControlInternal = observer((props) => { const onHold = () => store.cc.hold(); // logic leaks into the widget }); ``` + **Why wrong:** Inline logic can't be unit-tested with `renderHook`, gets duplicated across widgets, and mixes rendering with side effects. Hooks keep the widget thin and the logic reusable/testable. **Where it appears** + - `packages/contact-center/user-state/src/helper.ts` , `packages/contact-center/station-login/src/helper.ts` , `packages/contact-center/task/src/helper.ts` (also `packages/contact-center/cc-digital-channels/src/helper.ts`) **Edge cases / exceptions** + - One `helper.ts` may export several hooks when a package hosts several widgets (the `task` package does). - A few narrowly-reusable hooks live outside `helper.ts` — e.g. `task/src/Utils/useHoldTimer.ts`, `cc-components/src/hooks/useIntersectionObserver.ts` — when they're shared UI utilities rather than a widget's business logic. @@ -155,6 +173,7 @@ mixes rendering with side effects. Hooks keep the widget thin and the logic reus store or SDK. **Correct** + ```typescript // from packages/contact-center/cc-components/src/components/UserState/user-state.tsx const UserStateComponent: React.FunctionComponent = (props) => { @@ -169,16 +188,20 @@ const UserStateComponent: React.FunctionComponent = (p ``` **Incorrect** + ```typescript import store from '@webex/cc-store'; // component pulling state itself ``` + **Why wrong:** Same as the layering rule — importing the store into `cc-components` reverses the dependency arrow and destroys prop-driven testability. **Where it appears** + - `packages/contact-center/cc-components/src/components/UserState/user-state.tsx` , `packages/contact-center/cc-components/src/components/StationLogin/station-login.tsx` , `packages/contact-center/cc-components/src/components/task/CallControl/call-control.tsx` (also `task/IncomingTask`, `task/TaskList`) **Edge cases / exceptions** + - Components may hold local view-only state (open/closed, hover) and use UI utility hooks; they just never own domain state or call the SDK. --- @@ -189,40 +212,47 @@ dependency arrow and destroys prop-driven testability. cleanup that unregisters the exact same handler. **Correct** + ```typescript // from packages/contact-center/task/src/helper.ts useEffect(() => { - if (!currentTask?.data?.interactionId) return; - const interactionId = currentTask.data.interactionId; + if (!currentTask) return; - store.setTaskCallback(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_HOLD, holdCallback, currentTask); + store.setTaskCallback(TASK_EVENTS.TASK_RESUME, resumeCallback, currentTask); + store.setTaskCallback(TASK_EVENTS.TASK_END, endCallCallback, 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_HOLD, holdCallback, currentTask); + store.removeTaskCallback(TASK_EVENTS.TASK_RESUME, resumeCallback, currentTask); + store.removeTaskCallback(TASK_EVENTS.TASK_END, endCallCallback, currentTask); }; }, [currentTask]); ``` + Note the repo registers task-scoped listeners through the store's `setTaskCallback` / -`removeTaskCallback` helpers (not raw `cc.on` / `cc.off` in the widget). +`removeTaskCallback` helpers (not raw `cc.on` / `cc.off` in the widget). Pass the `ITask` object +itself (not its `interactionId`) as the third argument — passing an ID requires a `store.taskList` +lookup that can be stale during React 18 StrictMode mount/unmount cycles. **Incorrect** + ```typescript useEffect(() => { - store.setTaskCallback(TASK_EVENTS.TASK_HOLD, holdCallback, interactionId); - // no return — handler never removed + store.setTaskCallback(TASK_EVENTS.TASK_HOLD, holdCallback, currentTask.data.interactionId); + // no return — handler never removed, and passing an ID risks a stale taskList lookup }, [currentTask]); ``` + **Why wrong:** Without cleanup, handlers accumulate across re-renders/task changes, firing multiple times and holding references to stale task state (a memory + double-fire leak). **Where it appears** + - `packages/contact-center/task/src/helper.ts` (task callbacks) , `packages/contact-center/user-state/src/helper.ts` (worker lifecycle) , `packages/contact-center/cc-digital-channels/src/helper.ts` **Edge cases / exceptions** + - The cleanup must reference the **same function identity** passed on registration (define handlers in the hook body or memoize them), or removal is a no-op. --- @@ -233,6 +263,7 @@ and holding references to stale task state (a memory + double-fire leak). passed to a memoized child. Keeps identity stable across renders. **Correct** + ```typescript // from packages/contact-center/task/src/helper.ts const getEntryPoints = useCallback(async () => { @@ -241,17 +272,25 @@ const getEntryPoints = useCallback(async () => { ``` **Incorrect** + ```typescript -const getEntryPoints = async () => { /* ... */ }; // new identity every render -useEffect(() => { getEntryPoints(); }, [getEntryPoints]); // effect re-runs every render +const getEntryPoints = async () => { + /* ... */ +}; // new identity every render +useEffect(() => { + getEntryPoints(); +}, [getEntryPoints]); // effect re-runs every render ``` + **Why wrong:** A fresh function each render changes the effect's dependency identity, re-running the effect on every render — an infinite-ish fetch loop. **Where it appears** + - `packages/contact-center/task/src/helper.ts` (`loadBuddyAgents`, `getAddressBookEntries`, `getEntryPoints`, `getQueuesFetcher`, `extractConsultingAgent`). **Edge cases / exceptions** + - Skip `useCallback` for handlers used only inline in JSX with no memoized child and no effect dependency — the memo overhead buys nothing there. --- diff --git a/packages/contact-center/store/tests/storeEventsWrapper.ts b/packages/contact-center/store/tests/storeEventsWrapper.ts index a63d80d48..8ae0bd8af 100644 --- a/packages/contact-center/store/tests/storeEventsWrapper.ts +++ b/packages/contact-center/store/tests/storeEventsWrapper.ts @@ -512,10 +512,10 @@ describe('storeEventsWrapper', () => { expect(storeWrapper.removeTaskCallback).toBeInstanceOf(Function); storeWrapper.removeTaskCallback(TASK_EVENTS.TASK_ASSIGNED, undefined, mockTask); - expect(mockTask.on).not.toHaveBeenCalledWith(TASK_EVENTS.TASK_ASSIGNED, mockCb); + expect(mockTask.off).not.toHaveBeenCalledWith(TASK_EVENTS.TASK_ASSIGNED, mockCb); storeWrapper.removeTaskCallback(TASK_EVENTS.TASK_ASSIGNED, mockCb, null); - expect(mockTask.on).not.toHaveBeenCalledWith(TASK_EVENTS.TASK_ASSIGNED, mockCb); + expect(mockTask.off).not.toHaveBeenCalledWith(TASK_EVENTS.TASK_ASSIGNED, mockCb); }); it('should remove task callback even when task is absent from store.taskList', () => { diff --git a/packages/contact-center/task/tests/helper.ts b/packages/contact-center/task/tests/helper.ts index 7889dec4f..08f882c73 100644 --- a/packages/contact-center/task/tests/helper.ts +++ b/packages/contact-center/task/tests/helper.ts @@ -794,10 +794,10 @@ describe('useCallControl', () => { const onSpy = jest.spyOn(mockCurrentTask, 'on'); // Mock the implementation of setTaskCallback to also call the onSpy for testing - setTaskCallbackSpy.mockImplementation((event, callback) => { + setTaskCallbackSpy.mockImplementation((event, callback, task) => { // Skip calling original implementation to avoid recursion - // Just register directly on task for test visibility - mockCurrentTask.on(event, callback); + // Just register directly on the passed-in task for test visibility + task.on(event, callback); }); const {unmount} = renderHook(() => @@ -815,6 +815,7 @@ describe('useCallControl', () => { // 7 store callbacks + TASK_UI_CONTROLS_UPDATED + TASK_SWITCH_CALL + TASK_HOLD + TASK_RESUME on task expect(onSpy).toHaveBeenCalledTimes(11); + expect(setTaskCallbackSpy).toHaveBeenCalledWith(expect.any(String), expect.any(Function), mockCurrentTask); // Unmount the component act(() => { From a36d733b6608d61ee26e438420107d803a1a548e Mon Sep 17 00:00:00 2001 From: Matthew Olker Date: Fri, 28 Aug 2026 13:19:53 -0400 Subject: [PATCH 4/5] fix(store,task): restore taskId-compatible setTaskCallback/removeTaskCallback Addresses outstanding Codex review comment on PR #723: external/already- published @webex/cc-store consumers may still call setTaskCallback/removeTaskCallback with a string interactionId. Restore that as the required third argument, resolving to task ?? store.taskList[taskId], and make the ITask reference an optional fourth argument so in-repo callers (useIncomingTask, useCallControl) keep passing the task object directly to avoid the stale taskList lookup race this PR originally fixed. Update store/task tests and specs to cover both the task-object and legacy taskId-only call paths. --- ai-docs/patterns/react-patterns.md | 27 +-- .../store/ai-docs/store-spec.md | 159 +++++++++-------- .../store/src/storeEventsWrapper.ts | 20 ++- .../store/tests/storeEventsWrapper.ts | 68 +++++-- .../contact-center/task/ai-docs/task-spec.md | 166 +++++++++--------- packages/contact-center/task/src/helper.ts | 64 ++++--- packages/contact-center/task/tests/helper.ts | 89 ++++++++-- 7 files changed, 365 insertions(+), 228 deletions(-) diff --git a/ai-docs/patterns/react-patterns.md b/ai-docs/patterns/react-patterns.md index 917309f85..631ef2005 100644 --- a/ai-docs/patterns/react-patterns.md +++ b/ai-docs/patterns/react-patterns.md @@ -216,31 +216,36 @@ cleanup that unregisters the exact same handler. ```typescript // from packages/contact-center/task/src/helper.ts useEffect(() => { - if (!currentTask) return; + const registeredTask = currentTask; + if (!registeredTask?.data?.interactionId) return; + const interactionId = registeredTask.data.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_HOLD, holdCallback, interactionId, registeredTask); + store.setTaskCallback(TASK_EVENTS.TASK_RESUME, resumeCallback, interactionId, registeredTask); + store.setTaskCallback(TASK_EVENTS.TASK_END, endCallCallback, interactionId, registeredTask); return () => { - 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_HOLD, holdCallback, interactionId, registeredTask); + store.removeTaskCallback(TASK_EVENTS.TASK_RESUME, resumeCallback, interactionId, registeredTask); + store.removeTaskCallback(TASK_EVENTS.TASK_END, endCallCallback, interactionId, registeredTask); }; }, [currentTask]); ``` Note the repo registers task-scoped listeners through the store's `setTaskCallback` / -`removeTaskCallback` helpers (not raw `cc.on` / `cc.off` in the widget). Pass the `ITask` object -itself (not its `interactionId`) as the third argument — passing an ID requires a `store.taskList` -lookup that can be stale during React 18 StrictMode mount/unmount cycles. +`removeTaskCallback` helpers (not raw `cc.on` / `cc.off` in the widget). Both take +`(event, callback, taskId, task?)`: the `taskId` keeps the published API compatible with any +external/already-shipped consumer still passing only a string ID (it falls back to a +`store.taskList[taskId]` lookup), but in-repo callers should always also pass the optional `task` +object — that lookup can be stale during React 18 StrictMode mount/unmount cycles, whereas passing +the captured task reference registers/removes directly on it. **Incorrect** ```typescript useEffect(() => { store.setTaskCallback(TASK_EVENTS.TASK_HOLD, holdCallback, currentTask.data.interactionId); - // no return — handler never removed, and passing an ID risks a stale taskList lookup + // no return — handler never removed, and omitting `task` risks a stale taskList lookup }, [currentTask]); ``` diff --git a/packages/contact-center/store/ai-docs/store-spec.md b/packages/contact-center/store/ai-docs/store-spec.md index 9c472b3a6..717e40f89 100644 --- a/packages/contact-center/store/ai-docs/store-spec.md +++ b/packages/contact-center/store/ai-docs/store-spec.md @@ -29,11 +29,13 @@ 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.*`. | + `@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. @@ -45,6 +47,7 @@ A maintainer should start at `src/store.ts` to understand the observable shape a 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 dependency `@webex/contact-center` 3.12.0-next.109. 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 @@ -76,12 +79,12 @@ Tests mirror src under `packages/contact-center/store/tests/` (`store.ts`, `stor 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 including the existing `ContactServiceQueue`, `ContactServiceQueuesResponse`, `ContactServiceQueueSearchParams`, `EntryPointRecord`, `EntryPointListResponse`, and `EntryPointSearchParams` contracts plus Task destination controls | Typed SDK-backed domain surface using the SDK's established entity and list types directly | stable semver; SDK-shaped types track the SDK | `packages/contact-center/store/src/store.types.ts`; 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`, `getConferenceParticipantDropRoster`, `getConferenceParticipantsCount`, `isInteractionOnHold`, `findHoldStatus`, `findHoldTimestamp`, etc.) | Read-only derivations over `ITask`; the Drop roster is main-leg, owner-aware, and may add the current Entry Point/EP-DN consult destination by number while ringing or answering Agent name before merge | stable semver | `packages/contact-center/store/src/task-utils.ts`; [`participant-drop-intake.md`](../../../../ai-docs/features/participant-drop-intake.md) | [`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 including the existing `ContactServiceQueue`, `ContactServiceQueuesResponse`, `ContactServiceQueueSearchParams`, `EntryPointRecord`, `EntryPointListResponse`, and `EntryPointSearchParams` contracts plus Task destination controls | Typed SDK-backed domain surface using the SDK's established entity and list types directly | stable semver; SDK-shaped types track the SDK | `packages/contact-center/store/src/store.types.ts`; 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`, `getConferenceParticipantDropRoster`, `getConferenceParticipantsCount`, `isInteractionOnHold`, `findHoldStatus`, `findHoldTimestamp`, etc.) | Read-only derivations over `ITask`; the Drop roster is main-leg, owner-aware, and may add the current Entry Point/EP-DN consult destination by number while ringing or answering Agent name before merge | stable semver | `packages/contact-center/store/src/task-utils.ts`; [`participant-drop-intake.md`](../../../../ai-docs/features/participant-drop-intake.md) | [`CONTRACTS.md`](../../../../ai-docs/CONTRACTS.md) | Compatibility notes: @@ -89,44 +92,46 @@ Compatibility notes: - 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. ## Requires (dependencies) + - `@webex/contact-center` SDK (pinned in `package.json` at `3.12.0-next.109`) — the entire CC runtime: `Webex.init()`, `webex.cc.*` methods, the CC/task event stream, agent `Profile`, `webex.credentials.getUserToken()`. Consumed ONLY through the store. Fallback on unavailability: `Store.init()` rejects after a 6000ms timeout (`src/store.ts:140-142`); the wrapper wraps the rejection and invokes `onErrorCallback('Store', err)` (`src/storeEventsWrapper.ts:442-452`). - `mobx` ^6.13.5 — observable state and `runInAction` for all mutations. - 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` | Consult/transfer fetchers use the existing SDK `getBuddyAgents`, `getQueues`, and `getEntryPoints` methods and return their established entity/list responses without local result filtering, sorting, projection, or metadata reconstruction. Telephony calls rely on SDK defaults; for a non-telephony active task the store supplies only a complete channel eligibility expression through the existing `filter` parameter. Errors are logged and rethrown; `getAddressBookEntries` returns empty when `isAddressBookEnabled` is false. | Keep ordinary list policy in the SDK and preserve backend order while carrying the one piece of per-task context an SDK-global list method cannot infer without a new signature. | `src/storeEventsWrapper.ts`, `src/store.types.ts` | `tests/storeEventsWrapper.ts` | 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` | `getConferenceParticipantDropRoster(task, agentId)` derives Customer, Agent, EP-DN, and read-only Supervisor rows from the telephony main leg; it excludes self/departed/not-joined/VVA/unsupported/ordinary consult-only Agent participants, gates Drop on ownership, uses inbound ANI or outbound DNIS for Customer, and exposes active non-held consult disabling without changing `getConferenceParticipants`. Consult activity recognizes visible consult controls, `uiControls.main.endConsult`, and consult interaction states. The viewing agent must remain active, and any supported non-customer row keeps the roster visible after Customer departure; Customer-only and terminal calls return `null`. For only the current active Entry Point/EP-DN consult leg, participant classification reads both SDK `pType` and `type`: it selects across observable task data and the state-machine snapshot using current media IDs and leg timestamps, shows the dialed `dn` (ID fallback) while ringing, replaces it with the answering Agent name before merge, and keeps that per-target action disabled until main-leg merge. Stale legs are excluded and merged rows deduplicated. | `CallControlCAD` needs an authoritative, reactive Drop policy and immediate Entry Point visibility while preserving other consumers' established conference-count contract. | `src/task-utils.ts`, `src/store.types.ts` | `tests/task-utils.ts` (`getConferenceParticipantDropRoster`) | Backend authorization and event delivery remain authoritative; selector visibility is not authorization. | PRESENT | -| `STORE-R-023` | `handleConsultEnd` clears consult UI state synchronously, while `handleConsultEnd` and `handleTaskEnd` schedule one coalesced microtask refresh of the SDK task collection. Participant-left refresh remains synchronous for a surviving call. `handleTaskEnd` also resets `isMuted` for wxApp thick-client sync. | The SDK emits terminal task events before its final-state collection cleanup; deferring only terminal refresh prevents a stale ended call window without deleting SDK-owned tasks locally. | `src/storeEventsWrapper.ts` | `tests/storeEventsWrapper.ts` (deferred/coalesced terminal refresh and ended-current-task cleanup) | If the backend emits no terminal lifecycle event, the store does not fabricate one. | PRESENT | -| `STORE-R-024` | Retain the existing `allowConsultToQueue` observable, wrapper getter, feature-flag entry, and call-control prop solely for public compatibility, but do not use them for destination visibility or order. Do not mirror `accessQueue`, `accessEntryPoint`, or `accessBuddyTeam`; the UI consumes destination policy only from each SDK Task's `uiControls.consultTransferDestinations`. | Preserving the established store surface avoids a patch-release break while keeping one authoritative policy source for current UI behavior. | `src/store.ts`, `src/store.types.ts`, `src/storeEventsWrapper.ts`, `src/util.ts` | `tests/storeEventsWrapper.ts`, `tests/util.ts` | The SDK continues to ingest collaboration profile values internally when computing Task controls. | PRESENT | -| `STORE-R-025` | `handleTaskHydrate` replaces the Store's cloned `currentTask` with a clone of the authoritative SDK task even when the interaction ID is unchanged. Participant Drop ownership and Primary-row state are always derived from the hydrated `interaction.owner`; the Store never elects a successor locally. | Agent Desktop receives the promoted agent through `ContactOwnerChanged` and remaining agents through an owner-changing `ContactUpdated`; the SDK normalizes those paths into hydration so every widget view updates without waiting for another participant event. | `src/storeEventsWrapper.ts`, `src/task-utils.ts` | `tests/storeEventsWrapper.ts` (owner-change hydration replaces the clone and updates promoted/secondary roster permissions) | Backend ownership and SDK hydration remain authoritative. | PRESENT | -| `STORE-R-026` | Per-task listener on **`TASK_WXAPP_MUTE_STATE_UPDATED`** (guarded by `wxAppMuteStateListeners` map) calls **`handleWxAppMuteStateUpdated`** → `setIsMuted(payload.muted)` only when the task matches `currentTask`; detached in **`handleTaskRemove`** | Webex App mute/unmute must sync embed UI without widgets calling Mercury; prevent duplicate listeners | `src/storeEventsWrapper.ts:507-509,942-946,999-1003` | `tests/storeEventsWrapper.ts` (`handleWxAppMuteStateUpdated`) | SDK must emit event; store does not call telephony REST | PRESENT | -| `STORE-R-027` | **`setCurrentTask`** resets **`isMuted`** to `false` synchronously when the promoted task **changes** (`!isSameTask`) without writing that reset into the telephony mute cache, then calls **`seedWxAppMuteFromTask`** only when **`enableWxBetterTogether`** is true **and** the canonical task has an engaged wxApp call id via **`getWebexCallingCallId()`** (same gate as wxApp telephony controls): resolves canonical SDK task from `taskManager.getAllTasks()`, awaits **`syncWxAppMuteFromCallDetails()`**, and applies **`getWxAppMuted()`** to `store.isMuted` only while that task remains current (refresh/hydrate backfill; skips re-seed on uiControls-driven list refresh). When wxApp sync is unavailable (WebRTC/extension Voice without engaged wxApp call id), **`restoreCachedMuteForTelephonyTask`** applies the per-**`interactionId`** cache after the synchronous reset. **`setIsMuted`** updates the cache for the current telephony task; **`handleTaskRemove`** clears the cache entry for that task. The synchronous reset prevents the prior task's mute indicator from leaking onto the newly selected task before async seed or cache restore completes. | Page refresh must restore mute icon from telephony GET even if Mercury event was missed; WebRTC multi-task switches must preserve mute per voice interaction without SDK sync overwriting cache; task switches must not show stale mute state; avoid duplicate GETs on answer | `src/storeEventsWrapper.ts` (`isWxAppEngagedTelephonyTask`, `seedWxAppMuteFromTask`, `setCurrentTask`, `muteStateByInteractionId`) | `tests/storeEventsWrapper.ts` (`seed isMuted on setCurrentTask`, Voice sync-without-wxApp-id cache restore, WebRTC cache restore, task-switch stale mute reset, dedupe tests) | SDK Voice may expose sync APIs without engaged wxApp call id — store must not treat that as wxApp | PRESENT | -| `STORE-R-028` | **`offerActionErrors`** is a `Record` on the Store; **`setOfferActionError`**, **`clearOfferActionError`**, and **`pruneOfferActionErrors(activeIds)`** replace the map with a new object inside `runInAction` (no in-place mutation) so shallow React.memo comparators in `withMetrics` detect prop changes; **`pruneOfferActionErrors`** assigns a new map only when stale interaction IDs are removed (no-op when all entries remain active). TaskList and IncomingTask read/write the same map so inline wxApp accept/decline errors stay synchronized across widget instances. | Per-intake UX requires inline errors on both TaskList rows and the IncomingTask toast; isolated React state per widget cannot share failures; immutable map refs required for TaskListComponent memo | `src/store.ts`, `src/store.types.ts`, `src/storeEventsWrapper.ts` | `tests/storeEventsWrapper.ts` (`offerActionErrors`) | Store type is minimal (message + wxApp metadata); widgets prune on task leave | PRESENT | -| `STORE-R-029` | **`handleTaskMuteState`** resets **`isMuted`** to `false` for incoming telephony offers only when there is **no** `currentTask` or the incoming task **is** the current task; background offers while another call is engaged must **not** clobber mute for the active interaction. When the agent later accepts/selects the new offer, **`setCurrentTask`** synchronously clears mute and **`seedWxAppMuteFromTask`** backfills the authoritative SDK value. | Multi-offer wxApp sessions must keep the engaged call's mute indicator accurate; unconditional reset on every incoming callback desyncs CallControl for the active leg | `src/storeEventsWrapper.ts` (`handleTaskMuteState`, `handleIncomingTask`, `handleIncomingCampaignPreview`, `setCurrentTask`) | `tests/storeEventsWrapper.ts` (`handleTaskMuteState`, task-switch mute reset) | Non-telephony offers skip mute reset | PRESENT | -| `STORE-R-030` | **`init(InitParams)`** reads host init flag **`enableWxBetterTogether`** strictly as `=== true` from **`webexConfig.cc.enableWxBetterTogether`** on the `{ webexConfig, access_token }` path or **`webex.config.cc.enableWxBetterTogether`** on the `{ webex }` path; defaults to **`false`** when absent. The value is stored on the MobX observable **`store.enableWxBetterTogether`** (read-only after init — no runtime setter; Phase 1: re-init to change). Widgets and store gates use this for wxApp Mute/Keypad visibility and wxApp mute seed; SDK owns mute/DTMF API routing. | Host embeds must opt in explicitly at init; ambiguous truthy values must not enable wxApp surfaces | `src/store.ts` (`init`), `src/store.types.ts` (`WebexCcInitConfig`, `InitParams`) | `tests/store.ts` (`sets enableWxBetterTogether from webexConfig.cc at init`, `defaults enableWxBetterTogether to false when webexConfig.cc flag is absent`, preinitialized webex path) | Typed init config is minimal — only documents keys widgets read today | PRESENT | -| `STORE-R-031` | `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 | + +| 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` | Consult/transfer fetchers use the existing SDK `getBuddyAgents`, `getQueues`, and `getEntryPoints` methods and return their established entity/list responses without local result filtering, sorting, projection, or metadata reconstruction. Telephony calls rely on SDK defaults; for a non-telephony active task the store supplies only a complete channel eligibility expression through the existing `filter` parameter. Errors are logged and rethrown; `getAddressBookEntries` returns empty when `isAddressBookEnabled` is false. | Keep ordinary list policy in the SDK and preserve backend order while carrying the one piece of per-task context an SDK-global list method cannot infer without a new signature. | `src/storeEventsWrapper.ts`, `src/store.types.ts` | `tests/storeEventsWrapper.ts` | 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` | `getConferenceParticipantDropRoster(task, agentId)` derives Customer, Agent, EP-DN, and read-only Supervisor rows from the telephony main leg; it excludes self/departed/not-joined/VVA/unsupported/ordinary consult-only Agent participants, gates Drop on ownership, uses inbound ANI or outbound DNIS for Customer, and exposes active non-held consult disabling without changing `getConferenceParticipants`. Consult activity recognizes visible consult controls, `uiControls.main.endConsult`, and consult interaction states. The viewing agent must remain active, and any supported non-customer row keeps the roster visible after Customer departure; Customer-only and terminal calls return `null`. For only the current active Entry Point/EP-DN consult leg, participant classification reads both SDK `pType` and `type`: it selects across observable task data and the state-machine snapshot using current media IDs and leg timestamps, shows the dialed `dn` (ID fallback) while ringing, replaces it with the answering Agent name before merge, and keeps that per-target action disabled until main-leg merge. Stale legs are excluded and merged rows deduplicated. | `CallControlCAD` needs an authoritative, reactive Drop policy and immediate Entry Point visibility while preserving other consumers' established conference-count contract. | `src/task-utils.ts`, `src/store.types.ts` | `tests/task-utils.ts` (`getConferenceParticipantDropRoster`) | Backend authorization and event delivery remain authoritative; selector visibility is not authorization. | PRESENT | +| `STORE-R-023` | `handleConsultEnd` clears consult UI state synchronously, while `handleConsultEnd` and `handleTaskEnd` schedule one coalesced microtask refresh of the SDK task collection. Participant-left refresh remains synchronous for a surviving call. `handleTaskEnd` also resets `isMuted` for wxApp thick-client sync. | The SDK emits terminal task events before its final-state collection cleanup; deferring only terminal refresh prevents a stale ended call window without deleting SDK-owned tasks locally. | `src/storeEventsWrapper.ts` | `tests/storeEventsWrapper.ts` (deferred/coalesced terminal refresh and ended-current-task cleanup) | If the backend emits no terminal lifecycle event, the store does not fabricate one. | PRESENT | +| `STORE-R-024` | Retain the existing `allowConsultToQueue` observable, wrapper getter, feature-flag entry, and call-control prop solely for public compatibility, but do not use them for destination visibility or order. Do not mirror `accessQueue`, `accessEntryPoint`, or `accessBuddyTeam`; the UI consumes destination policy only from each SDK Task's `uiControls.consultTransferDestinations`. | Preserving the established store surface avoids a patch-release break while keeping one authoritative policy source for current UI behavior. | `src/store.ts`, `src/store.types.ts`, `src/storeEventsWrapper.ts`, `src/util.ts` | `tests/storeEventsWrapper.ts`, `tests/util.ts` | The SDK continues to ingest collaboration profile values internally when computing Task controls. | PRESENT | +| `STORE-R-025` | `handleTaskHydrate` replaces the Store's cloned `currentTask` with a clone of the authoritative SDK task even when the interaction ID is unchanged. Participant Drop ownership and Primary-row state are always derived from the hydrated `interaction.owner`; the Store never elects a successor locally. | Agent Desktop receives the promoted agent through `ContactOwnerChanged` and remaining agents through an owner-changing `ContactUpdated`; the SDK normalizes those paths into hydration so every widget view updates without waiting for another participant event. | `src/storeEventsWrapper.ts`, `src/task-utils.ts` | `tests/storeEventsWrapper.ts` (owner-change hydration replaces the clone and updates promoted/secondary roster permissions) | Backend ownership and SDK hydration remain authoritative. | PRESENT | +| `STORE-R-026` | Per-task listener on **`TASK_WXAPP_MUTE_STATE_UPDATED`** (guarded by `wxAppMuteStateListeners` map) calls **`handleWxAppMuteStateUpdated`** → `setIsMuted(payload.muted)` only when the task matches `currentTask`; detached in **`handleTaskRemove`** | Webex App mute/unmute must sync embed UI without widgets calling Mercury; prevent duplicate listeners | `src/storeEventsWrapper.ts:507-509,942-946,999-1003` | `tests/storeEventsWrapper.ts` (`handleWxAppMuteStateUpdated`) | SDK must emit event; store does not call telephony REST | PRESENT | +| `STORE-R-027` | **`setCurrentTask`** resets **`isMuted`** to `false` synchronously when the promoted task **changes** (`!isSameTask`) without writing that reset into the telephony mute cache, then calls **`seedWxAppMuteFromTask`** only when **`enableWxBetterTogether`** is true **and** the canonical task has an engaged wxApp call id via **`getWebexCallingCallId()`** (same gate as wxApp telephony controls): resolves canonical SDK task from `taskManager.getAllTasks()`, awaits **`syncWxAppMuteFromCallDetails()`**, and applies **`getWxAppMuted()`** to `store.isMuted` only while that task remains current (refresh/hydrate backfill; skips re-seed on uiControls-driven list refresh). When wxApp sync is unavailable (WebRTC/extension Voice without engaged wxApp call id), **`restoreCachedMuteForTelephonyTask`** applies the per-**`interactionId`** cache after the synchronous reset. **`setIsMuted`** updates the cache for the current telephony task; **`handleTaskRemove`** clears the cache entry for that task. The synchronous reset prevents the prior task's mute indicator from leaking onto the newly selected task before async seed or cache restore completes. | Page refresh must restore mute icon from telephony GET even if Mercury event was missed; WebRTC multi-task switches must preserve mute per voice interaction without SDK sync overwriting cache; task switches must not show stale mute state; avoid duplicate GETs on answer | `src/storeEventsWrapper.ts` (`isWxAppEngagedTelephonyTask`, `seedWxAppMuteFromTask`, `setCurrentTask`, `muteStateByInteractionId`) | `tests/storeEventsWrapper.ts` (`seed isMuted on setCurrentTask`, Voice sync-without-wxApp-id cache restore, WebRTC cache restore, task-switch stale mute reset, dedupe tests) | SDK Voice may expose sync APIs without engaged wxApp call id — store must not treat that as wxApp | PRESENT | +| `STORE-R-028` | **`offerActionErrors`** is a `Record` on the Store; **`setOfferActionError`**, **`clearOfferActionError`**, and **`pruneOfferActionErrors(activeIds)`** replace the map with a new object inside `runInAction` (no in-place mutation) so shallow React.memo comparators in `withMetrics` detect prop changes; **`pruneOfferActionErrors`** assigns a new map only when stale interaction IDs are removed (no-op when all entries remain active). TaskList and IncomingTask read/write the same map so inline wxApp accept/decline errors stay synchronized across widget instances. | Per-intake UX requires inline errors on both TaskList rows and the IncomingTask toast; isolated React state per widget cannot share failures; immutable map refs required for TaskListComponent memo | `src/store.ts`, `src/store.types.ts`, `src/storeEventsWrapper.ts` | `tests/storeEventsWrapper.ts` (`offerActionErrors`) | Store type is minimal (message + wxApp metadata); widgets prune on task leave | PRESENT | +| `STORE-R-029` | **`handleTaskMuteState`** resets **`isMuted`** to `false` for incoming telephony offers only when there is **no** `currentTask` or the incoming task **is** the current task; background offers while another call is engaged must **not** clobber mute for the active interaction. When the agent later accepts/selects the new offer, **`setCurrentTask`** synchronously clears mute and **`seedWxAppMuteFromTask`** backfills the authoritative SDK value. | Multi-offer wxApp sessions must keep the engaged call's mute indicator accurate; unconditional reset on every incoming callback desyncs CallControl for the active leg | `src/storeEventsWrapper.ts` (`handleTaskMuteState`, `handleIncomingTask`, `handleIncomingCampaignPreview`, `setCurrentTask`) | `tests/storeEventsWrapper.ts` (`handleTaskMuteState`, task-switch mute reset) | Non-telephony offers skip mute reset | PRESENT | +| `STORE-R-030` | **`init(InitParams)`** reads host init flag **`enableWxBetterTogether`** strictly as `=== true` from **`webexConfig.cc.enableWxBetterTogether`** on the `{ webexConfig, access_token }` path or **`webex.config.cc.enableWxBetterTogether`** on the `{ webex }` path; defaults to **`false`** when absent. The value is stored on the MobX observable **`store.enableWxBetterTogether`** (read-only after init — no runtime setter; Phase 1: re-init to change). Widgets and store gates use this for wxApp Mute/Keypad visibility and wxApp mute seed; SDK owns mute/DTMF API routing. | Host embeds must opt in explicitly at init; ambiguous truthy values must not enable wxApp surfaces | `src/store.ts` (`init`), `src/store.types.ts` (`WebexCcInitConfig`, `InitParams`) | `tests/store.ts` (`sets enableWxBetterTogether from webexConfig.cc at init`, `defaults enableWxBetterTogether to false when webexConfig.cc flag is absent`, preinitialized webex path) | Typed init config is minimal — only documents keys widgets read today | PRESENT | +| `STORE-R-031` | `setTaskCallback(event, callback, taskId: string, task?: ITask)` and `removeTaskCallback(event, callback, taskId: string, task?: ITask)` resolve the task to `task ?? this.store.taskList[taskId]`, call `task.on()`/`task.off()` on that reference, and guard on `!callback` plus the resolved task being falsy; diagnostic logging uses optional chaining on `this.store.logger` | The optional `task` param eliminates the `store.taskList[taskId]` lookup race for in-repo callers (if the task is removed from the list before the React effect cleanup fires, an ID-only lookup silently skips `task.off()`, orphaning listeners); the required `taskId` preserves the published API for external/already-shipped consumers that only ever passed a string ID | `src/storeEventsWrapper.ts:417-427,453-463` | `tests/storeEventsWrapper.ts` ("should set/remove task callback using the supplied task object", "...by resolving the task from store.taskList when no task object is given (legacy string-id callers)", "removeTaskCallback detaches from the supplied task object even when taskList has been replaced") | none | PRESENT | ## Design Overview @@ -337,7 +342,7 @@ Transition triggers: SDK CC/task events drive the session/agent/task slices via - **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. +- **`setTaskCallback`/`removeTaskCallback` accept an optional `task: ITask` after the required `taskId`** so in-repo callers can pass the task reference directly and avoid a stale `store.taskList` lookup race during React 18 StrictMode double-mount/unmount; omitting `task` falls back to `store.taskList[taskId]` for published/external consumers that only ever passed the string ID. Callers that do capture a task reference must pass the exact same reference to both register and cleanup — 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. - **Task media is SDK-originated but broadly typed on `ITask`:** the store validates it against the supported media keys before passing the existing `BuddyAgents` option; absent or unknown media uses the SDK telephony default. - **`@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. @@ -358,38 +363,38 @@ Transition triggers: SDK CC/task events drive the session/agent/task slices via 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-013` | `tests/storeEventsWrapper.ts` (events reactions) | none | -| `STORE-R-014` | `tests/storeEventsWrapper.ts` (events reactions) | none | -| `STORE-R-015` | `tests/storeEventsWrapper.ts` (existing-method delegation, non-telephony existing-filter use, unchanged order/metadata, errors) | 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 | -| `STORE-R-022` | `tests/task-utils.ts` (participant Drop roster, owner transfer, ANI/DNIS, filtering, consult gating, conference eligibility) | none | -| `STORE-R-023` | `tests/storeEventsWrapper.ts` (deferred/coalesced terminal refresh and ended-current-task cleanup) | none | -| `STORE-R-024` | `tests/storeEventsWrapper.ts`, `tests/util.ts` | Add a widget integration assertion for direct Task destination controls if the store ever begins adapting task UI controls. | -| `STORE-R-025` | `tests/storeEventsWrapper.ts` (owner-change hydration and immediate Drop-roster recomputation) | none | -| `STORE-R-026` | `tests/storeEventsWrapper.ts` (`handleWxAppMuteStateUpdated`) | none | -| `STORE-R-027` | `tests/storeEventsWrapper.ts` (`seed isMuted on setCurrentTask`, WebRTC cache restore, task-switch stale mute reset, dedupe tests) | none | -| `STORE-R-029` | `tests/storeEventsWrapper.ts` (`handleTaskMuteState`, task-switch mute reset) | none | -| `STORE-R-030` | `tests/store.ts` (`enableWxBetterTogether` init paths and default) | none | -| `STORE-R-031` | `tests/storeEventsWrapper.ts` ("should remove task callback even when task is absent from store.taskList") | none | +| 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` (existing-method delegation, non-telephony existing-filter use, unchanged order/metadata, errors) | 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 | +| `STORE-R-022` | `tests/task-utils.ts` (participant Drop roster, owner transfer, ANI/DNIS, filtering, consult gating, conference eligibility) | none | +| `STORE-R-023` | `tests/storeEventsWrapper.ts` (deferred/coalesced terminal refresh and ended-current-task cleanup) | none | +| `STORE-R-024` | `tests/storeEventsWrapper.ts`, `tests/util.ts` | Add a widget integration assertion for direct Task destination controls if the store ever begins adapting task UI controls. | +| `STORE-R-025` | `tests/storeEventsWrapper.ts` (owner-change hydration and immediate Drop-roster recomputation) | none | +| `STORE-R-026` | `tests/storeEventsWrapper.ts` (`handleWxAppMuteStateUpdated`) | none | +| `STORE-R-027` | `tests/storeEventsWrapper.ts` (`seed isMuted on setCurrentTask`, WebRTC cache restore, task-switch stale mute reset, dedupe tests) | none | +| `STORE-R-029` | `tests/storeEventsWrapper.ts` (`handleTaskMuteState`, task-switch mute reset) | none | +| `STORE-R-030` | `tests/store.ts` (`enableWxBetterTogether` init paths and default) | none | +| `STORE-R-031` | `tests/storeEventsWrapper.ts` ("should remove task callback even when task is absent from store.taskList", legacy string-id fallback cases) | none | ## Traceability diff --git a/packages/contact-center/store/src/storeEventsWrapper.ts b/packages/contact-center/store/src/storeEventsWrapper.ts index 5fd453a2f..56eb0728e 100644 --- a/packages/contact-center/store/src/storeEventsWrapper.ts +++ b/packages/contact-center/store/src/storeEventsWrapper.ts @@ -622,16 +622,18 @@ class StoreWrapper implements IStoreWrapper { this.store.cc.on(event, callback); }; - setTaskCallback = (event: TASK_EVENTS, callback, task: ITask) => { - if (!callback || !task) return; + setTaskCallback = (event: TASK_EVENTS, callback, taskId: string, task?: ITask) => { + if (!callback) return; + const taskToRegister = task ?? this.store.taskList[taskId]; + if (!taskToRegister) return; this.store.logger?.info( - `CC-Widgets: setTaskCallback(): registering task event '${event}' for ${task.data?.interactionId}`, + `CC-Widgets: setTaskCallback(): registering task event '${event}' for ${taskToRegister.data?.interactionId}`, { module: 'storeEventsWrapper.ts', method: 'setTaskCallback', } ); - task.on(event, callback); + taskToRegister.on(event, callback); }; setAgentProfile = (profile: AgentLoginProfile) => { @@ -658,16 +660,18 @@ class StoreWrapper implements IStoreWrapper { this.store.cc.off(event); }; - removeTaskCallback = (event: TASK_EVENTS, callback, task: ITask) => { - if (!callback || !task) return; + removeTaskCallback = (event: TASK_EVENTS, callback, taskId: string, task?: ITask) => { + if (!callback) return; + const taskToDetach = task ?? this.store.taskList[taskId]; + if (!taskToDetach) return; this.store.logger?.info( - `CC-Widgets: removeTaskCallback(): removing task event '${event}' for ${task.data?.interactionId}`, + `CC-Widgets: removeTaskCallback(): removing task event '${event}' for ${taskToDetach.data?.interactionId}`, { module: 'storeEventsWrapper.ts', method: 'removeTaskCallback', } ); - task.off(event, callback); + taskToDetach.off(event, callback); }; init(options: InitParams): Promise { diff --git a/packages/contact-center/store/tests/storeEventsWrapper.ts b/packages/contact-center/store/tests/storeEventsWrapper.ts index 50d15b880..6a463e5b8 100644 --- a/packages/contact-center/store/tests/storeEventsWrapper.ts +++ b/packages/contact-center/store/tests/storeEventsWrapper.ts @@ -500,50 +500,64 @@ describe('storeEventsWrapper', () => { storeWrapper.refreshTaskList(); }); - it('should set task callback', () => { + it('should set task callback using the supplied task object', () => { const mockCb = jest.fn(); expect(storeWrapper.setTaskCallback).toBeInstanceOf(Function); - storeWrapper.setTaskCallback(TASK_EVENTS.TASK_ASSIGNED, mockCb, mockTask); + storeWrapper.setTaskCallback(TASK_EVENTS.TASK_ASSIGNED, mockCb, mockTask.data.interactionId, mockTask); expect(mockTask.on).toHaveBeenCalledWith(TASK_EVENTS.TASK_ASSIGNED, mockCb); }); - it('should return if callback is not present or task is not provided', () => { + it('should set task callback by resolving the task from store.taskList when no task object is given (legacy string-id callers)', () => { + const mockCb = jest.fn(); + + storeWrapper.setTaskCallback(TASK_EVENTS.TASK_ASSIGNED, mockCb, mockTask.data.interactionId); + expect(mockTask.on).toHaveBeenCalledWith(TASK_EVENTS.TASK_ASSIGNED, mockCb); + }); + + it('should return and not set callback if callback is missing, or the task cannot be resolved', () => { const mockCb = jest.fn(); expect(storeWrapper.setTaskCallback).toBeInstanceOf(Function); - storeWrapper.setTaskCallback(TASK_EVENTS.TASK_ASSIGNED, undefined, mockTask); + storeWrapper.setTaskCallback(TASK_EVENTS.TASK_ASSIGNED, undefined, mockTask.data.interactionId, mockTask); expect(mockTask.on).not.toHaveBeenCalledWith(TASK_EVENTS.TASK_ASSIGNED, mockCb); - storeWrapper.setTaskCallback(TASK_EVENTS.TASK_ASSIGNED, mockCb, null); + storeWrapper.setTaskCallback(TASK_EVENTS.TASK_ASSIGNED, mockCb, 'unknown-interaction-id'); expect(mockTask.on).not.toHaveBeenCalledWith(TASK_EVENTS.TASK_ASSIGNED, mockCb); }); - it('should remove task callback', () => { + it('should remove task callback using the supplied task object', () => { const mockCb = jest.fn(); expect(storeWrapper.removeTaskCallback).toBeInstanceOf(Function); - storeWrapper.removeTaskCallback(TASK_EVENTS.TASK_WRAPPEDUP, mockCb, mockTask); + storeWrapper.removeTaskCallback(TASK_EVENTS.TASK_WRAPPEDUP, mockCb, mockTask.data.interactionId, 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 provided', () => { + it('should remove task callback by resolving the task from store.taskList when no task object is given (legacy string-id callers)', () => { + const mockCb = jest.fn(); + + storeWrapper.removeTaskCallback(TASK_EVENTS.TASK_WRAPPEDUP, mockCb, mockTask.data.interactionId); + expect(mockTask.off).toHaveBeenCalledWith(TASK_EVENTS.TASK_WRAPPEDUP, mockCb); + }); + + it('should return and not remove callback if callback is missing, or the task cannot be resolved', () => { const mockCb = jest.fn(); expect(storeWrapper.removeTaskCallback).toBeInstanceOf(Function); - storeWrapper.removeTaskCallback(TASK_EVENTS.TASK_ASSIGNED, undefined, mockTask); + storeWrapper.removeTaskCallback(TASK_EVENTS.TASK_ASSIGNED, undefined, mockTask.data.interactionId, mockTask); expect(mockTask.off).not.toHaveBeenCalledWith(TASK_EVENTS.TASK_ASSIGNED, mockCb); - storeWrapper.removeTaskCallback(TASK_EVENTS.TASK_ASSIGNED, mockCb, null); + storeWrapper.removeTaskCallback(TASK_EVENTS.TASK_ASSIGNED, mockCb, 'unknown-interaction-id'); expect(mockTask.off).not.toHaveBeenCalledWith(TASK_EVENTS.TASK_ASSIGNED, mockCb); }); - it('should remove task callback even when task is absent from store.taskList', () => { + it('should remove task callback via the supplied task object even when the 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); + storeWrapper.removeTaskCallback(TASK_EVENTS.TASK_ASSIGNED, mockCb, mockTask.data.interactionId, mockTask); expect(mockTask.off).toHaveBeenCalledWith(TASK_EVENTS.TASK_ASSIGNED, mockCb); }); }); @@ -1127,6 +1141,36 @@ describe('storeEventsWrapper', () => { expect(oldTask.off).toHaveBeenCalledWith(TASK_EVENTS.TASK_WXAPP_MUTE_STATE_UPDATED, oldListenerCalls[0][1]); }); + it('removeTaskCallback detaches from the supplied task object even when taskList has been replaced', () => { + const interactionId = 'interaction-callback-detach'; + const registeredTask = makeMockTask({ + data: {interactionId, interaction: {state: 'connected'}}, + }); + const replacementTask = makeMockTask({ + data: {interactionId, interaction: {state: 'connected'}}, + }); + const callback = jest.fn(); + + storeWrapper['store'].taskList = {[interactionId]: replacementTask}; + storeWrapper.removeTaskCallback(TASK_EVENTS.TASK_ASSIGNED, callback, interactionId, registeredTask); + + expect(registeredTask.off).toHaveBeenCalledWith(TASK_EVENTS.TASK_ASSIGNED, callback); + expect(replacementTask.off).not.toHaveBeenCalled(); + }); + + it('removeTaskCallback falls back to the store.taskList lookup for legacy string-id-only callers', () => { + const interactionId = 'interaction-callback-legacy-lookup'; + const listedTask = makeMockTask({ + data: {interactionId, interaction: {state: 'connected'}}, + }); + const callback = jest.fn(); + + storeWrapper['store'].taskList = {[interactionId]: listedTask}; + storeWrapper.removeTaskCallback(TASK_EVENTS.TASK_ASSIGNED, callback, interactionId); + + expect(listedTask.off).toHaveBeenCalledWith(TASK_EVENTS.TASK_ASSIGNED, callback); + }); + it('should seed isMuted from syncWxAppMuteFromCallDetails on setCurrentTask', async () => { storeWrapper['store'].agentId = 'mockAgentId'; enableWxBetterTogetherInStore(); diff --git a/packages/contact-center/task/ai-docs/task-spec.md b/packages/contact-center/task/ai-docs/task-spec.md index d5dfdbf65..dab8910a5 100644 --- a/packages/contact-center/task/ai-docs/task-spec.md +++ b/packages/contact-center/task/ai-docs/task-spec.md @@ -22,20 +22,22 @@ Coverage score: `Pending coverage assessment` before the first report; after ass 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. | + +| 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. | + ## 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, task)` (passing the `ITask` object directly) 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, interactionId, task)` (passing both the `interactionId` and the `ITask` object) 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. @@ -79,14 +81,15 @@ packages/contact-center/task/src/ | `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`; participant Drop is store-driven | CallControl variant styled for a customer-data layout with owner-aware conference participant removal | Stable; no new React prop or Web Component property/attribute | `src/task.types.ts` (`CallControlProps`); [`participant-drop-intake.md`](../../../../ai-docs/features/participant-drop-intake.md) | [`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`; participant Drop is store-driven | CallControl variant styled for a customer-data layout with owner-aware conference participant removal | Stable; no new React prop or Web Component property/attribute | `src/task.types.ts` (`CallControlProps`); [`participant-drop-intake.md`](../../../../ai-docs/features/participant-drop-intake.md) | [`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: @@ -95,14 +98,14 @@ Compatibility notes: ### Feature: Accept on Webex thick client (implemented — WXCC-6026) -| Surface | Change | -|---|---| -| **Host init** | `webexConfig.cc.enableWxBetterTogether: boolean` (default `false`) — set **before** `store.init()`; persisted on store as read-only `enableWxBetterTogether` for **UI visibility gating**, wxApp mute seed gate; SDK owns mute/DTMF API routing | -| **IncomingTask** | Calls SDK `task.accept()` / `task.decline()` — wxApp routing is internal to SDK `Voice` | -| **CallControl** | Engaged wxApp → `task.toggleMute({ muted })` / `task.transmitDtmf({ dtmf })`; widget force-visible only when wxApp engaged **and** SDK `isEnabled`; hide SDK visible+disabled ghosts; Desktop WebRTC SDK passthrough; CAD consult sub-bar mute hidden only when wxApp engaged; `toggleMute` guard includes `consult.mute.isVisible` | -| **TaskList** | Inline Accept / Decline — same unified `task.accept()` / `task.decline()` as IncomingTask; offer-action errors stored on `@webex/cc-store` (`offerActionErrors`) keyed by `interactionId` so TaskList and IncomingTask stay in sync; store assigns a new map reference on each update so `withMetrics` memo does not block TaskList re-renders | -| **@webex/cc-store** | `offerActionErrors` map + `setOfferActionError` / `clearOfferActionError` / `pruneOfferActionErrors` — shared wxApp accept/decline inline error state across TaskList and IncomingTask widget instances (immutable map replacement on mutate) | -| **@webex/cc-components** | Shared wxApp visibility helpers: `isWxAppEngagedCall`, `shouldShowWxAppTelephonyControls` (imported by `helper.ts` for mute/DTMF gates) | +| Surface | Change | +| ------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **Host init** | `webexConfig.cc.enableWxBetterTogether: boolean` (default `false`) — set **before** `store.init()`; persisted on store as read-only `enableWxBetterTogether` for **UI visibility gating**, wxApp mute seed gate; SDK owns mute/DTMF API routing | +| **IncomingTask** | Calls SDK `task.accept()` / `task.decline()` — wxApp routing is internal to SDK `Voice` | +| **CallControl** | Engaged wxApp → `task.toggleMute({ muted })` / `task.transmitDtmf({ dtmf })`; widget force-visible only when wxApp engaged **and** SDK `isEnabled`; hide SDK visible+disabled ghosts; Desktop WebRTC SDK passthrough; CAD consult sub-bar mute hidden only when wxApp engaged; `toggleMute` guard includes `consult.mute.isVisible` | +| **TaskList** | Inline Accept / Decline — same unified `task.accept()` / `task.decline()` as IncomingTask; offer-action errors stored on `@webex/cc-store` (`offerActionErrors`) keyed by `interactionId` so TaskList and IncomingTask stay in sync; store assigns a new map reference on each update so `withMetrics` memo does not block TaskList re-renders | +| **@webex/cc-store** | `offerActionErrors` map + `setOfferActionError` / `clearOfferActionError` / `pruneOfferActionErrors` — shared wxApp accept/decline inline error state across TaskList and IncomingTask widget instances (immutable map replacement on mutate) | +| **@webex/cc-components** | Shared wxApp visibility helpers: `isWxAppEngagedCall`, `shouldShowWxAppTelephonyControls` (imported by `helper.ts` for mute/DTMF gates) | **SDK follow-up (uiControls):** SDK must enable `main.mute/keypad` through consult/hold/conference when wxApp engaged; BROWSER login ignores init flag for uiControls. @@ -111,6 +114,7 @@ Compatibility notes: **Store scope:** `storeEventsWrapper` listens for **`TASK_WXAPP_MUTE_STATE_UPDATED`** per task → `handleWxAppMuteStateUpdated` → `setIsMuted()` when task is `currentTask`. Widgets never call Mercury directly. ## Requires (dependencies) + - `@webex/cc-store` (peer, internal): MobX singleton supplying `currentTask` (including SDK `TaskUIControls`), `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 and destination-control types: `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`. @@ -118,33 +122,34 @@ 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 mute controls are unavailable; wxApp engaged calls use **`currentTask.toggleMute({ muted: intendedMuteState })`**; WebRTC uses parameterless toggle; then `store.setIsMuted(intended)` and `onToggleMute` only after success; on failure reports prior `isMuted`. | Mute state must reflect SDK/store truth; wxApp must pass UI intent to avoid Mercury desync. | `src/helper.ts` (`useCallControl.toggleMute`), `@webex/cc-components` (`shouldShowWxAppTelephonyControls`) | `tests/helper.ts` (mute + wxApp hooks), `@webex/cc-components` `tests/utils/wxapp-telephony.utils.test.ts` | 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 | -| `TASK-R-024` | `useCallControl` owns Customer confirmation plus `requestParticipantDrop`/confirm/cancel orchestration. It revalidates the latest task, owner-aware roster, global consult gate, and per-target disabled state; serializes requests with a synchronous token that survives same-interaction task clones; calls `task.dropConferenceParticipant({participantId: target.dropTargetId})`; waits for SDK hydration rather than removing rows; suppresses stale completions after owner/agent/interaction/terminal changes; and emits only generic success/failure feedback. One supported non-customer participant keeps the roster visible after Customer departure. An active Entry Point/EP-DN consult appears by dialed number while ringing and changes to the answering Agent name before merge; its action cannot invoke Drop until it joins the main leg. Failure logs no participant data and invokes `store.onErrorCallback('CallControlCAD', sanitizedError)`. Agent/consult termination remains SDK-event-authoritative; incoming consultees consume the existing consult-end signal once, while the store defers terminal list refresh until SDK cleanup completes. | Concurrent, stale, or premature participant removal must not target the wrong task, leak PII, hide surviving participants, duplicate rejection callbacks, or desynchronize from the event-driven SDK task model. | `src/helper.ts`, `src/task.types.ts` | `tests/helper.ts` (`conference participant Drop`, incoming consult-end rejection) | The published SDK version with `ITask.dropConferenceParticipant` is a release gate; widgets do not synthesize consult termination. | PRESENT | -| `TASK-R-025` | `CallControl` and `CallControlCAD` must pass the current Task's `uiControls` to presentational components without building a collaboration-policy context or forwarding raw Desktop Profile access flags. | The SDK Task is the single source for destination availability/order; widget wrappers should contain no duplicated destination policy. | `src/CallControl/index.tsx`, `src/CallControlCAD/index.tsx`, `src/helper.ts` | `tests/CallControl/index.tsx`, `tests/CallControlCAD/index.tsx` | Presentational host options may only hide SDK-allowed categories. | PRESENT | + +| 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 mute controls are unavailable; wxApp engaged calls use **`currentTask.toggleMute({ muted: intendedMuteState })`**; WebRTC uses parameterless toggle; then `store.setIsMuted(intended)` and `onToggleMute` only after success; on failure reports prior `isMuted`. | Mute state must reflect SDK/store truth; wxApp must pass UI intent to avoid Mercury desync. | `src/helper.ts` (`useCallControl.toggleMute`), `@webex/cc-components` (`shouldShowWxAppTelephonyControls`) | `tests/helper.ts` (mute + wxApp hooks), `@webex/cc-components` `tests/utils/wxapp-telephony.utils.test.ts` | 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 | +| `TASK-R-024` | `useCallControl` owns Customer confirmation plus `requestParticipantDrop`/confirm/cancel orchestration. It revalidates the latest task, owner-aware roster, global consult gate, and per-target disabled state; serializes requests with a synchronous token that survives same-interaction task clones; calls `task.dropConferenceParticipant({participantId: target.dropTargetId})`; waits for SDK hydration rather than removing rows; suppresses stale completions after owner/agent/interaction/terminal changes; and emits only generic success/failure feedback. One supported non-customer participant keeps the roster visible after Customer departure. An active Entry Point/EP-DN consult appears by dialed number while ringing and changes to the answering Agent name before merge; its action cannot invoke Drop until it joins the main leg. Failure logs no participant data and invokes `store.onErrorCallback('CallControlCAD', sanitizedError)`. Agent/consult termination remains SDK-event-authoritative; incoming consultees consume the existing consult-end signal once, while the store defers terminal list refresh until SDK cleanup completes. | Concurrent, stale, or premature participant removal must not target the wrong task, leak PII, hide surviving participants, duplicate rejection callbacks, or desynchronize from the event-driven SDK task model. | `src/helper.ts`, `src/task.types.ts` | `tests/helper.ts` (`conference participant Drop`, incoming consult-end rejection) | The published SDK version with `ITask.dropConferenceParticipant` is a release gate; widgets do not synthesize consult termination. | PRESENT | +| `TASK-R-025` | `CallControl` and `CallControlCAD` must pass the current Task's `uiControls` to presentational components without building a collaboration-policy context or forwarding raw Desktop Profile access flags. | The SDK Task is the single source for destination availability/order; widget wrappers should contain no duplicated destination policy. | `src/CallControl/index.tsx`, `src/CallControlCAD/index.tsx`, `src/helper.ts` | `tests/CallControl/index.tsx`, `tests/CallControlCAD/index.tsx` | Presentational host options may only hide SDK-allowed categories. | PRESENT | ## Design Overview @@ -496,7 +501,7 @@ stateDiagram-v2 ## 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):** `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. +- **Callback identity in cleanup (IncomingTask, CallControl):** `setTaskCallback`/`removeTaskCallback` take `(event, callback, taskId, task?)`; this module always passes the optional `task` object alongside `taskId` so registration/cleanup resolve `task.on()`/`task.off()` directly on the captured reference instead of a live `store.taskList` lookup. This eliminates the stale-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; the `taskId`-only fallback exists solely for already-published external consumers still on the old string-ID call signature. - **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. @@ -510,42 +515,43 @@ stateDiagram-v2 - 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`. Always pass the task object (not an ID string) and the same callback reference to both. +- DON'T add new task-event subscriptions without matching the exact event name in both `setTaskCallback` and the cleanup `removeTaskCallback`. Always pass the `interactionId` plus the task object (not the ID alone) 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 and inherits participant Drop in both React and Web Component modes without new public inputs. The host supplies `store.onErrorCallback` to receive widget-crash and sanitized participant-Drop failure 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 | -| `TASK-R-024` participant Drop orchestration | `tests/helper.ts` (exact payload, duplicate prevention, success/failure cleanup, sanitized callback/logging, stale completion, roster re-derivation) | Live routing-event behavior is covered by SDK/manual integration tests | -| `TASK-R-025` SDK destination-control pass-through | `tests/CallControl/index.tsx`, `tests/CallControlCAD/index.tsx`; cc-components focused destination tests | None | +| 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 | +| `TASK-R-024` participant Drop orchestration | `tests/helper.ts` (exact payload, duplicate prevention, success/failure cleanup, sanitized callback/logging, stale completion, roster re-derivation) | Live routing-event behavior is covered by SDK/manual integration tests | +| `TASK-R-025` SDK destination-control pass-through | `tests/CallControl/index.tsx`, `tests/CallControlCAD/index.tsx`; cc-components focused destination tests | None | ## Traceability diff --git a/packages/contact-center/task/src/helper.ts b/packages/contact-center/task/src/helper.ts index 4b13090c4..3fb28e279 100644 --- a/packages/contact-center/task/src/helper.ts +++ b/packages/contact-center/task/src/helper.ts @@ -363,21 +363,21 @@ export const useIncomingTask = (props: UseTaskProps) => { useEffect(() => { try { if (!incomingTask) return; - 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); + store.setTaskCallback(TASK_EVENTS.TASK_ASSIGNED, taskAssignCallback, interactionId, incomingTask); + store.setTaskCallback(TASK_EVENTS.TASK_CONSULT_ACCEPTED, taskAssignCallback, interactionId, incomingTask); + store.setTaskCallback(TASK_EVENTS.TASK_END, taskRejectCallback, interactionId, incomingTask); + store.setTaskCallback(TASK_EVENTS.TASK_REJECT, taskRejectCallback, interactionId, incomingTask); + store.setTaskCallback(TASK_EVENTS.TASK_CONSULT_END, taskRejectCallback, interactionId, incomingTask); + store.setTaskCallback(TASK_EVENTS.TASK_OUTDIAL_FAILED, taskRejectCallback, interactionId, incomingTask); return () => { try { - 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); + store.removeTaskCallback(TASK_EVENTS.TASK_ASSIGNED, taskAssignCallback, interactionId, incomingTask); + store.removeTaskCallback(TASK_EVENTS.TASK_CONSULT_ACCEPTED, taskAssignCallback, interactionId, incomingTask); + store.removeTaskCallback(TASK_EVENTS.TASK_END, taskRejectCallback, interactionId, incomingTask); + store.removeTaskCallback(TASK_EVENTS.TASK_REJECT, taskRejectCallback, interactionId, incomingTask); + store.removeTaskCallback(TASK_EVENTS.TASK_CONSULT_END, taskRejectCallback, interactionId, incomingTask); + store.removeTaskCallback(TASK_EVENTS.TASK_OUTDIAL_FAILED, taskRejectCallback, interactionId, incomingTask); } catch (error) { logger?.error(`CC-Widgets: Task: Error in useIncomingTask cleanup - ${error.message}`, { module: 'useIncomingTask', @@ -975,22 +975,34 @@ export const useCallControl = (props: useCallControlProps) => { method: 'useEffect-init', }); - 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); + const interactionId = registeredTask.data.interactionId; + + store.setTaskCallback(TASK_EVENTS.TASK_HOLD, holdCallback, interactionId, registeredTask); + store.setTaskCallback(TASK_EVENTS.TASK_RESUME, resumeCallback, interactionId, registeredTask); + store.setTaskCallback(TASK_EVENTS.TASK_END, endCallCallback, interactionId, registeredTask); + store.setTaskCallback(TASK_EVENTS.TASK_WRAPUP, endCallCallback, interactionId, registeredTask); // Also call onEnd when entering wrapup + store.setTaskCallback(TASK_EVENTS.TASK_WRAPPEDUP, wrapupCallCallback, interactionId, registeredTask); + store.setTaskCallback(TASK_EVENTS.TASK_RECORDING_PAUSED, pauseRecordingCallback, interactionId, registeredTask); + store.setTaskCallback(TASK_EVENTS.TASK_RECORDING_RESUMED, resumeRecordingCallback, interactionId, registeredTask); return () => { - 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); + store.removeTaskCallback(TASK_EVENTS.TASK_HOLD, holdCallback, interactionId, registeredTask); + store.removeTaskCallback(TASK_EVENTS.TASK_RESUME, resumeCallback, interactionId, registeredTask); + store.removeTaskCallback(TASK_EVENTS.TASK_END, endCallCallback, interactionId, registeredTask); + store.removeTaskCallback(TASK_EVENTS.TASK_WRAPUP, endCallCallback, interactionId, registeredTask); + store.removeTaskCallback(TASK_EVENTS.TASK_WRAPPEDUP, wrapupCallCallback, interactionId, registeredTask); + store.removeTaskCallback( + TASK_EVENTS.TASK_RECORDING_PAUSED, + pauseRecordingCallback, + interactionId, + registeredTask + ); + store.removeTaskCallback( + TASK_EVENTS.TASK_RECORDING_RESUMED, + resumeRecordingCallback, + interactionId, + registeredTask + ); }; }, [currentTask]); diff --git a/packages/contact-center/task/tests/helper.ts b/packages/contact-center/task/tests/helper.ts index 64d70ab00..a4a2bec0a 100644 --- a/packages/contact-center/task/tests/helper.ts +++ b/packages/contact-center/task/tests/helper.ts @@ -108,7 +108,7 @@ describe('useIncomingTask Hook', () => { }); // Mock the implementation of removeTaskCallback to also call the offSpy for testing - removeTaskCallbackSpy.mockImplementation((event, callback, task) => { + removeTaskCallbackSpy.mockImplementation((event, callback, taskId, task) => { // Make sure off is called on the task mock (task ?? taskMock).off(event, callback); }); @@ -122,12 +122,42 @@ describe('useIncomingTask Hook', () => { }) ); - 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).toHaveBeenCalledWith( + TASK_EVENTS.TASK_ASSIGNED, + expect.any(Function), + taskMock.data.interactionId, + taskMock + ); + expect(setTaskCallbackSpy).toHaveBeenCalledWith( + TASK_EVENTS.TASK_REJECT, + expect.any(Function), + taskMock.data.interactionId, + taskMock + ); + expect(setTaskCallbackSpy).toHaveBeenCalledWith( + TASK_EVENTS.TASK_END, + expect.any(Function), + taskMock.data.interactionId, + taskMock + ); + expect(setTaskCallbackSpy).toHaveBeenCalledWith( + TASK_EVENTS.TASK_CONSULT_ACCEPTED, + expect.any(Function), + taskMock.data.interactionId, + taskMock + ); + expect(setTaskCallbackSpy).toHaveBeenCalledWith( + TASK_EVENTS.TASK_CONSULT_END, + expect.any(Function), + taskMock.data.interactionId, + taskMock + ); + expect(setTaskCallbackSpy).toHaveBeenCalledWith( + TASK_EVENTS.TASK_OUTDIAL_FAILED, + expect.any(Function), + taskMock.data.interactionId, + taskMock + ); expect(setTaskCallbackSpy).toHaveBeenCalledTimes(6); // Clean up @@ -135,16 +165,42 @@ describe('useIncomingTask Hook', () => { unmount(); }); - 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_ASSIGNED, + expect.any(Function), + taskMock.data.interactionId, + taskMock + ); + expect(removeTaskCallbackSpy).toHaveBeenCalledWith( + TASK_EVENTS.TASK_REJECT, + expect.any(Function), + taskMock.data.interactionId, + taskMock + ); + expect(removeTaskCallbackSpy).toHaveBeenCalledWith( + TASK_EVENTS.TASK_END, + expect.any(Function), + taskMock.data.interactionId, + taskMock + ); expect(removeTaskCallbackSpy).toHaveBeenCalledWith( TASK_EVENTS.TASK_CONSULT_ACCEPTED, expect.any(Function), + taskMock.data.interactionId, + taskMock + ); + expect(removeTaskCallbackSpy).toHaveBeenCalledWith( + TASK_EVENTS.TASK_CONSULT_END, + expect.any(Function), + taskMock.data.interactionId, + taskMock + ); + expect(removeTaskCallbackSpy).toHaveBeenCalledWith( + TASK_EVENTS.TASK_OUTDIAL_FAILED, + expect.any(Function), + taskMock.data.interactionId, 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(); @@ -896,7 +952,7 @@ describe('useCallControl', () => { const onSpy = jest.spyOn(mockCurrentTask, 'on'); // Mock the implementation of setTaskCallback to also call the onSpy for testing - setTaskCallbackSpy.mockImplementation((event, callback, task) => { + setTaskCallbackSpy.mockImplementation((event, callback, taskId, task) => { // Skip calling original implementation to avoid recursion // Just register directly on the passed-in task for test visibility task.on(event, callback); @@ -917,7 +973,12 @@ describe('useCallControl', () => { // 7 store callbacks + TASK_UI_CONTROLS_UPDATED + TASK_SWITCH_CALL + TASK_HOLD + TASK_RESUME on task expect(onSpy).toHaveBeenCalledTimes(11); - expect(setTaskCallbackSpy).toHaveBeenCalledWith(expect.any(String), expect.any(Function), mockCurrentTask); + expect(setTaskCallbackSpy).toHaveBeenCalledWith( + expect.any(String), + expect.any(Function), + mockCurrentTask.data.interactionId, + mockCurrentTask + ); // Unmount the component act(() => { From 8c077e750a8b724cdc07d68ef5b2f432668da288 Mon Sep 17 00:00:00 2001 From: Matthew Olker Date: Fri, 28 Aug 2026 13:31:12 -0400 Subject: [PATCH 5/5] fix(store): drop interactionId from setTaskCallback/removeTaskCallback logs Task/interaction data is classified as agent/customer PII in ai-docs/SECURITY.md and AGENTS.md rule 8 prohibits logging PII. Match the rest of storeEventsWrapper.ts (e.g. removeCCCallback), which keeps log messages free of payload values and carries only {module, method} context. --- .../store/src/storeEventsWrapper.ts | 22 +++++++------------ 1 file changed, 8 insertions(+), 14 deletions(-) diff --git a/packages/contact-center/store/src/storeEventsWrapper.ts b/packages/contact-center/store/src/storeEventsWrapper.ts index 56eb0728e..efaabaf5d 100644 --- a/packages/contact-center/store/src/storeEventsWrapper.ts +++ b/packages/contact-center/store/src/storeEventsWrapper.ts @@ -626,13 +626,10 @@ class StoreWrapper implements IStoreWrapper { if (!callback) return; const taskToRegister = task ?? this.store.taskList[taskId]; if (!taskToRegister) return; - this.store.logger?.info( - `CC-Widgets: setTaskCallback(): registering task event '${event}' for ${taskToRegister.data?.interactionId}`, - { - module: 'storeEventsWrapper.ts', - method: 'setTaskCallback', - } - ); + this.store.logger?.info(`CC-Widgets: setTaskCallback(): registering task event '${event}'`, { + module: 'storeEventsWrapper.ts', + method: 'setTaskCallback', + }); taskToRegister.on(event, callback); }; @@ -664,13 +661,10 @@ class StoreWrapper implements IStoreWrapper { if (!callback) return; const taskToDetach = task ?? this.store.taskList[taskId]; if (!taskToDetach) return; - this.store.logger?.info( - `CC-Widgets: removeTaskCallback(): removing task event '${event}' for ${taskToDetach.data?.interactionId}`, - { - module: 'storeEventsWrapper.ts', - method: 'removeTaskCallback', - } - ); + this.store.logger?.info(`CC-Widgets: removeTaskCallback(): removing task event '${event}'`, { + module: 'storeEventsWrapper.ts', + method: 'removeTaskCallback', + }); taskToDetach.off(event, callback); };