diff --git a/packages/contact-center/cc-components/src/components/task/CallControl/call-control.utils.ts b/packages/contact-center/cc-components/src/components/task/CallControl/call-control.utils.ts
index 72a732e01..bd37c6b49 100644
--- a/packages/contact-center/cc-components/src/components/task/CallControl/call-control.utils.ts
+++ b/packages/contact-center/cc-components/src/components/task/CallControl/call-control.utils.ts
@@ -402,6 +402,20 @@ export const filterButtonsForConsultation = (
}
};
+/**
+ * The SDK normalizes callProcessingDetails booleans, but older backend payloads can
+ * still surface them as 'true'/'false' strings.
+ */
+const toOptionalBoolean = (value: unknown): boolean | undefined => {
+ if (typeof value === 'boolean') return value;
+ if (typeof value === 'string') {
+ const normalized = value.toLowerCase();
+ if (normalized === 'true') return true;
+ if (normalized === 'false') return false;
+ }
+ return undefined;
+};
+
/**
* Updates call state from current task data
*/
@@ -417,8 +431,18 @@ export const updateCallStateFromTask = (
const {callProcessingDetails} = interaction;
if (callProcessingDetails) {
- const {isPaused} = callProcessingDetails;
- setIsRecording(isPaused !== 'true');
+ // Mirrors the SDK's own precedence: isPaused wins when present, otherwise
+ // recordInProgress is the active recording signal.
+ const isPaused = toOptionalBoolean(callProcessingDetails.isPaused);
+ if (isPaused !== undefined) {
+ setIsRecording(!isPaused);
+ return;
+ }
+
+ const recordInProgress = toOptionalBoolean(callProcessingDetails.recordInProgress);
+ if (recordInProgress !== undefined) {
+ setIsRecording(recordInProgress);
+ }
}
} catch (error) {
logger?.error('CC-Widgets: CallControl: Error in updateCallStateFromTask', {
diff --git a/packages/contact-center/cc-components/tests/components/task/CallControl/call-control.utils.tsx b/packages/contact-center/cc-components/tests/components/task/CallControl/call-control.utils.tsx
index b275b120d..789ac36f6 100644
--- a/packages/contact-center/cc-components/tests/components/task/CallControl/call-control.utils.tsx
+++ b/packages/contact-center/cc-components/tests/components/task/CallControl/call-control.utils.tsx
@@ -956,6 +956,76 @@ describe('CallControl Utils', () => {
expect(mockSetIsRecording).toHaveBeenCalledWith(true);
});
+ // The SDK's taskDataNormalizer coerces callProcessingDetails flags to real
+ // booleans before widgets ever see them.
+ it('should handle isPaused as boolean true from the SDK', () => {
+ const taskWithBooleanPaused = {
+ ...mockCurrentTask,
+ data: {
+ ...mockCurrentTask.data,
+ interaction: {
+ ...mockCurrentTask.data.interaction,
+ callProcessingDetails: {isPaused: true},
+ },
+ },
+ };
+
+ updateCallStateFromTask(taskWithBooleanPaused as unknown as ITask, mockSetIsRecording);
+
+ expect(mockSetIsRecording).toHaveBeenCalledWith(false);
+ });
+
+ it('should handle isPaused as boolean false from the SDK', () => {
+ const taskWithBooleanNotPaused = {
+ ...mockCurrentTask,
+ data: {
+ ...mockCurrentTask.data,
+ interaction: {
+ ...mockCurrentTask.data.interaction,
+ callProcessingDetails: {isPaused: false},
+ },
+ },
+ };
+
+ updateCallStateFromTask(taskWithBooleanNotPaused as unknown as ITask, mockSetIsRecording);
+
+ expect(mockSetIsRecording).toHaveBeenCalledWith(true);
+ });
+
+ it('should fall back to recordInProgress when isPaused is absent', () => {
+ const taskWithoutIsPaused = {
+ ...mockCurrentTask,
+ data: {
+ ...mockCurrentTask.data,
+ interaction: {
+ ...mockCurrentTask.data.interaction,
+ callProcessingDetails: {recordingStarted: true, recordInProgress: false},
+ },
+ },
+ };
+
+ updateCallStateFromTask(taskWithoutIsPaused as unknown as ITask, mockSetIsRecording);
+
+ expect(mockSetIsRecording).toHaveBeenCalledWith(false);
+ });
+
+ it('should not change recording state when no recording flags are present', () => {
+ const taskWithUnrelatedDetails = {
+ ...mockCurrentTask,
+ data: {
+ ...mockCurrentTask.data,
+ interaction: {
+ ...mockCurrentTask.data.interaction,
+ callProcessingDetails: {pauseResumeEnabled: true},
+ },
+ },
+ };
+
+ updateCallStateFromTask(taskWithUnrelatedDetails as unknown as ITask, mockSetIsRecording);
+
+ expect(mockSetIsRecording).not.toHaveBeenCalled();
+ });
+
it('should return early when currentTask is null', () => {
updateCallStateFromTask(null as unknown as ITask, mockSetIsRecording);
diff --git a/packages/contact-center/task/tests/call-control-recording.tsx b/packages/contact-center/task/tests/call-control-recording.tsx
new file mode 100644
index 000000000..9b5ea11ee
--- /dev/null
+++ b/packages/contact-center/task/tests/call-control-recording.tsx
@@ -0,0 +1,174 @@
+import React from 'react';
+import {render, screen, act} from '@testing-library/react';
+import '@testing-library/jest-dom';
+import {EventEmitter} from 'events';
+import store, {TASK_EVENTS, IContactCenter} from '@webex/cc-store';
+import {mockTask, mockCC, createEnabledMainTaskUIControls} from '@webex/test-fixtures';
+import {CallControl} from '../src/CallControl';
+
+const INTERACTION_ID = 'interaction-recording-1';
+const AGENT_ID = 'agent-recording-1';
+
+/**
+ * Task double as a class instance, like the SDK's Task: MobX leaves non-plain
+ * objects by reference, so a plain object would not behave like production here.
+ * Extends EventEmitter so SDK-style emits reach the widget's listeners.
+ */
+class FakeTask extends EventEmitter {
+ public data;
+ public uiControls;
+ public pauseRecording = jest.fn().mockResolvedValue({});
+ public resumeRecording = jest.fn().mockResolvedValue({});
+
+ constructor(isPaused: boolean) {
+ super();
+ this.uiControls = createEnabledMainTaskUIControls();
+ this.data = {
+ ...mockTask.data,
+ interactionId: INTERACTION_ID,
+ agentId: AGENT_ID,
+ interaction: {
+ ...mockTask.data.interaction,
+ interactionId: INTERACTION_ID,
+ mediaType: 'telephony',
+ mediaChannel: 'telephony',
+ state: 'connected',
+ callProcessingDetails: {
+ ...mockTask.data.interaction.callProcessingDetails,
+ recordingStarted: true,
+ // taskDataNormalizer coerces these to real booleans before widgets see them.
+ isPaused,
+ recordInProgress: !isPaused,
+ pauseResumeEnabled: true,
+ },
+ participants: {
+ [AGENT_ID]: {id: AGENT_ID, pType: 'Agent', hasJoined: true, joinTimestamp: Date.now()},
+ },
+ media: {},
+ },
+ };
+ }
+
+ /** Mirrors the SDK replacing task.data from the websocket payload. */
+ setPaused(isPaused: boolean) {
+ this.data = {
+ ...this.data,
+ interaction: {
+ ...this.data.interaction,
+ callProcessingDetails: {
+ ...this.data.interaction.callProcessingDetails,
+ isPaused,
+ recordInProgress: !isPaused,
+ },
+ },
+ };
+ }
+}
+
+const createTask = (isPaused: boolean) => new FakeTask(isPaused);
+
+const promoteTask = (task: FakeTask) => {
+ store.store.cc = {
+ ...mockCC,
+ agentConfig: {agentId: AGENT_ID},
+ taskManager: {getAllTasks: jest.fn().mockReturnValue({[INTERACTION_ID]: task})},
+ } as unknown as IContactCenter;
+ store.store.agentId = AGENT_ID;
+ // Registers the store's own task listeners (refreshTaskList on recording
+ // pause/resume, etc.) exactly as production does.
+ store.handleIncomingTask(task);
+ store.setCurrentTask(task);
+};
+
+/** What the SDK does when the ContactRecordingPaused websocket event arrives. */
+const emitRecordingPaused = (task: FakeTask) =>
+ act(() => {
+ task.setPaused(true);
+ task.emit(TASK_EVENTS.TASK_RECORDING_PAUSED, task);
+ });
+
+const emitRecordingResumed = (task: FakeTask) =>
+ act(() => {
+ task.setPaused(false);
+ task.emit(TASK_EVENTS.TASK_RECORDING_RESUMED, task);
+ });
+
+const recordButtonLabel = () => screen.getByTestId('call-control:recording-toggle').getAttribute('aria-label');
+
+describe('CallControl recording pause/resume state', () => {
+ beforeAll(() => {
+ store.setDeviceType('BROWSER');
+ store.store.featureFlags = {isEndCallEnabled: true, isEndConsultEnabled: true, webRtcEnabled: true};
+ store.store.logger = {
+ log: jest.fn(),
+ info: jest.fn(),
+ warn: jest.fn(),
+ error: jest.fn(),
+ debug: jest.fn(),
+ trace: jest.fn(),
+ };
+ });
+
+ it('should show pause action while recording is active', () => {
+ promoteTask(createTask(false));
+ render();
+
+ expect(recordButtonLabel()).toBe('Pause Recording');
+ });
+
+ it('should show resume action when the SDK reports recording paused', () => {
+ const task = createTask(false);
+ promoteTask(task);
+ render();
+
+ emitRecordingPaused(task);
+
+ expect(recordButtonLabel()).toBe('Resume Recording');
+ });
+
+ it('should show pause action again when the SDK reports recording resumed', () => {
+ const task = createTask(false);
+ promoteTask(task);
+ render();
+
+ emitRecordingPaused(task);
+ emitRecordingResumed(task);
+
+ expect(recordButtonLabel()).toBe('Pause Recording');
+ });
+
+ it('should show resume action for a task that is already paused on mount', () => {
+ promoteTask(createTask(true));
+ render();
+
+ expect(recordButtonLabel()).toBe('Resume Recording');
+ });
+
+ it('should keep the paused state when a later task event refreshes the store', () => {
+ const task = createTask(false);
+ promoteTask(task);
+ render();
+
+ emitRecordingPaused(task);
+
+ // Later websocket events re-promote the task, handing call control a freshly
+ // cloned currentTask, which must not reset the paused state.
+ act(() => {
+ task.emit(TASK_EVENTS.TASK_UI_CONTROLS_UPDATED, task.uiControls);
+ });
+
+ expect(recordButtonLabel()).toBe('Resume Recording');
+ });
+
+ it('should keep the paused state when call control remounts while paused', () => {
+ const task = createTask(false);
+ promoteTask(task);
+ const firstRender = render();
+
+ emitRecordingPaused(task);
+ firstRender.unmount();
+ render();
+
+ expect(recordButtonLabel()).toBe('Resume Recording');
+ });
+});