diff --git a/ai-docs/CONTRACTS.md b/ai-docs/CONTRACTS.md index 6a76ae085..96a6131e7 100644 --- a/ai-docs/CONTRACTS.md +++ b/ai-docs/CONTRACTS.md @@ -25,8 +25,10 @@ The React entry of `@webex/cc-widgets` re-exports the widgets, including `AIAssi | ai-assistant.AIAssistant | `@webex/cc-widgets` and `@webex/cc-ai-assistant` (React); `@webex/cc-widgets/wc` (custom element) | `AIAssistant`; `widget-cc-ai-assistant` | React component and existing custom element; wellness callbacks `onWellnessBreakOffered`, `onWellnessBreakAccepted`, `onWellnessBreakStarted`, `onWellnessBreakEnded`, `onWellnessBreakError`; optional `wellnessAudioUrl` and `wellnessBreakOverlayTarget` | Both React import paths remain available; WC entry retains the tag | `packages/contact-center/ai-assistant/ai-docs/ai-assistant-spec.md` | `packages/contact-center/ai-assistant/src/ai-assistant.types.ts`, `packages/contact-center/cc-widgets/src/index.ts`, `packages/contact-center/cc-widgets/src/wc.ts` | | ai-assistant.wellness-types | `@webex/cc-ai-assistant` | `WellnessBreakPhase`, `WellnessBreakErrorCode`, `WellnessBreakError`, `WellnessBreakOverlayTarget` | Exported TypeScript lifecycle, error, and layout contracts | additive minor surface; literals are stable | `packages/contact-center/ai-assistant/ai-docs/ai-assistant-spec.md` | `packages/contact-center/ai-assistant/src/ai-assistant.types.ts` | | cc-components.WellnessBreak | `@webex/cc-components` | `WellnessBreakRequestCard`, `WellnessBreakOfferCard`, `WellnessBreakModal`, `WellnessBreakError` | Props-only Agent Wellness Break presentation; modal accepts an optional animation loader supplied by AI Assistant and owns no wellness media | monorepo component surface; no SDK/store or Lottie dependency | `packages/contact-center/cc-components/ai-docs/cc-components-spec.md` | `packages/contact-center/cc-components/src/components/AIAssistant/WellnessBreak/` | +| cc-components.AIAssistantReceiverSummary | `@webex/cc-components` | `AIAssistantComponentProps.receiverSummary`, `AIAssistantReceiverSummary` | Props-only receiver view with a stable interaction `branchKey`, SDK `content`, feedback selection/pending state, and copy/feedback callbacks bound by the widget to that displayed summary. Callbacks carry no numeric revision. The component renders a display-only card and copies visible content while excluding source metadata and action labels; feedback supports clearing to `none`; SDK requests and submission remain in the store. | additive presentation surface; consumed by the widget integration | `packages/contact-center/cc-components/ai-docs/cc-components-spec.md` (AI summary presentation) | `packages/contact-center/cc-components/src/components/AIAssistant/ai-assistant.types.ts` | +| cc-components.CallControlAISummary | `@webex/cc-components` | `CallControlComponentProps.aiSummary`, `CallControlAISummaryProps`, `ConsultTransferSummaryView`, `WrapUpSummaryView` | Optional props-only consult/transfer and wrap-up summary views; raw SDK content and local `editedSummary` are separate. SDK view/edit cards use the shared renderer; the Momentum fallback requires explicit Edit. Summary feedback accepts `none`; mid-call retry repeats the current picker action. Optional `excluded`/`onExcludeChange` keep Exclude summary controlled; `cancelMidCallSummary` submits cancellation only for footer Cancel; header close/Escape dismiss without submission, and `resetMidCallSummaryExclusion` restores the next picker to unchecked. Callbacks carry no numeric revision. Requests, edits and SDK submission are supplied by the widget/store. | additive optional props; the internal AISummary component remains private | `packages/contact-center/cc-components/ai-docs/cc-components-spec.md` (AI summaries in CallControl) | `packages/contact-center/cc-components/src/components/task/task.types.ts` | | cc-widgets.store | `@webex/cc-store` | `store` | MobX singleton (`Store.getInstance()`); `init(options: InitParams, setupEventListeners): Promise`; sole SDK access point via `store.cc.*` | stable semver | `packages/contact-center/store/ai-docs/store-spec.md` | `packages/contact-center/store/src/index.ts:1,4` | -| store.ai-summary | `@webex/cc-store` | `cc.aiSummaries`, summary request/receiver-feedback/send methods, `AISummaryEntry`, `AISummaryRole`, `AISummaryResponseSubmissionResult` | Entries keyed by interaction and role retain the unchanged SDK `AISummary` in `content` and `AISummaryResponse` in `response`. Requests return `AISummary` or `undefined`; senders return `AISummaryResponseSubmissionResult`. Widget helpers own local editing, copy tracking and deferred feedback through response fields. `setReceiverSummaryFeedback(feedback, expectedSummary, task)` uses the displayed SDK payload to guard its asynchronous acknowledgement. Consumers read enablement from `task.aiSummaryCapabilities`; `task:featureEnablement` refreshes the task view. Wrap-up sends `sendPostCallSummaryResponse(wrapUpCode, originalTask)` before `task.wrapup()`. | additive; uses published `@webex/contact-center` `3.12.0-next.148` | `packages/contact-center/store/ai-docs/store-spec.md` (AI summaries) | `packages/contact-center/store/src/store.types.ts`, `packages/contact-center/store/src/storeEventsWrapper.ts` | +| store.ai-summary | `@webex/cc-store` | `store.aiSummaries`, summary request/response-edit/receiver-feedback/send methods, `AISummaryEntry`, `AISummaryRole`, `AISummaryResponseSubmissionResult` | The store-owned map is observable by reference; registration preserves it for the same SDK instance and clears it when the SDK instance changes. Entries keyed by interaction and role retain the unchanged SDK `AISummary` in `content` and `AISummaryResponse` in `response`. Requests return `AISummary` or `undefined`; senders return `AISummaryResponseSubmissionResult`. `editAISummary(role, key, value, expectedSummary, task)`, `recordAISummaryCopied(role, expectedSummary, task)` and `setAISummaryFeedback(role, feedback, expectedSummary, task)` update responses inside store actions and return whether the displayed Task and SDK payload are still current and editable. Editing and deferred feedback apply to initiator/post-call; copy bookkeeping supports all three roles. `setAISummaryExcluded(excluded, expectedSummary, task)` locally updates the initiator choice, preserves content identity and rejects stale task/content controls. Exclusion persists through pending requests and Retry; empty failed requests retain `NOT_RECEIVED` precedence. `sendMidCallSummaryResponse(action, task, state?)` can snapshot `MID_CALL_CANCELLED` without changing the stored response before close resets exclusion. `setReceiverSummaryFeedback(feedback, expectedSummary, task)` uses the displayed SDK payload to guard its asynchronous acknowledgement. Consumers read enablement from `task.aiSummaryCapabilities`; `task:featureEnablement` refreshes the task view. Wrap-up dispatches `sendPostCallSummaryResponse(wrapUpCode, originalTask)` and immediately starts `task.wrapup()` without awaiting summary acknowledgement; the response is captured synchronously and send failures are logged. | additive; uses published `@webex/contact-center` `3.12.0-next.158` | `packages/contact-center/store/ai-docs/store-spec.md` (AI summaries) | `packages/contact-center/store/src/store.types.ts`, `packages/contact-center/store/src/storeEventsWrapper.ts` | | store.host-init.enableWxBetterTogether | `@webex/cc-store` | `webexConfig.cc.enableWxBetterTogether` → `store.enableWxBetterTogether` | Host init boolean (default `false`), set **before** `store.init()`: read from `webexConfig.cc` on `{ webexConfig, access_token }` init or `webex.config.cc` on `{ webex }` init; read-only observable after init (Phase 1: re-init to change). Gates wxApp Mute/Keypad visibility and wxApp mute seed; SDK owns mute/DTMF API routing. | stable semver; renaming or removing the init key or store getter is breaking | `packages/contact-center/store/ai-docs/store-spec.md` (`STORE-R-030`); `packages/contact-center/task/ai-docs/task-spec.md` (WXCC-6026) | `packages/contact-center/store/src/store.ts`, `packages/contact-center/store/src/store.types.ts` (`WebexCcInitConfig`) | | store.types | `@webex/cc-store` | Existing queue/entry-point request, response, and entity type re-exports plus `TaskUIControls` | TypeScript exports describing the SDK-backed domain surface; established entity rows and Task destination controls pass through without a widget destination abstraction | stable semver; SDK-shaped types track SDK | `@webex/contact-center` types (`node_modules/@webex/contact-center/dist/types/index.d.ts`) (SDK source); `packages/contact-center/store/ai-docs/store-spec.md` | `packages/contact-center/store/src/store.types.ts` | | cc-components.consult-transfer-lists | `@webex/cc-components` | `CallControl` consult/transfer fetch props plus `action`, `availableDestinations`, and optional list-item `presence` | `FetchPaginatedList` / `FetchPaginatedList` and the SDK-ordered destination array from `TaskUIControls`; UI preserves list/category order, passes buddy availability to Momentum Avatar as semantic presence, shows `AddressBookEntry.number` and `EntryPointRecord.number` subtitles, and may only apply host hide overrides | stable semver; entity and control types track store/SDK contracts; optional presentation prop is additive | `packages/contact-center/cc-components/ai-docs/cc-components-spec.md`; `ai-docs/features/consult-transfer-list-policy/spec/feature-spec.md` | `packages/contact-center/cc-components/src/components/task/task.types.ts` | @@ -38,6 +40,12 @@ The React entry of `@webex/cc-widgets` re-exports the widgets, including `AIAssi | ui-logging.withMetrics | `@webex/cc-ui-logging` | `withMetrics` | `withMetrics

(Component, widgetName: string): React.MemoExoticComponent` HOC that auto-emits mount/unmount/error metrics; every widget export is wrapped with it | stable semver; signature change is breaking | `packages/contact-center/ui-logging/ai-docs/ui-logging-spec.md` | `packages/contact-center/ui-logging/src/index.ts` | | ui-logging.WidgetMetrics | `@webex/cc-ui-logging` | `WidgetMetrics` (type) | `{ widgetName; event: 'WIDGET_MOUNTED' \| 'ERROR' \| 'WIDGET_UNMOUNTED' \| 'PROPS_UPDATED'; props?; timestamp; additionalContext? }` | stable semver; narrowing the `event` union or removing a field is breaking | `packages/contact-center/ui-logging/ai-docs/ui-logging-spec.md` | `packages/contact-center/ui-logging/src/index.ts` | +## AI summary surfaces + +`AI_SUMMARY_EDITABLE_SECTION_KEYS` exports the canonical editable structured keys, checked +against SDK `AISummarySections`. Summary editors may fill omitted optional fields from the +SDK edit card using an empty-string baseline; resolution and unknown keys remain read-only. + ## Requires — what this repo depends on | Dependency (service / package / datastore) | What is consumed | Schema / detail link | Availability assumption | Fallback on failure | Version floor | |---|---|---|---|---|---| diff --git a/ai-docs/GETTING_STARTED.md b/ai-docs/GETTING_STARTED.md index bfdae5abb..bc6d8014a 100644 --- a/ai-docs/GETTING_STARTED.md +++ b/ai-docs/GETTING_STARTED.md @@ -18,6 +18,9 @@ yarn build:dev # build all packages — required before cross-package ts ``` A fresh clone (or a new git worktree) has no `node_modules`; `yarn install` followed by `yarn build:dev` must complete before any per-package test or cross-package import will resolve. +The store's Webpack build emits JavaScript and TypeScript declarations together. +`yarn workspace @webex/cc-store build:watch` refreshes both when store source changes. + ## Build / Run / Test | Task | Command | |---|---| diff --git a/ai-docs/patterns/mobx-patterns.md b/ai-docs/patterns/mobx-patterns.md index fe2ac0ed3..450f819da 100644 --- a/ai-docs/patterns/mobx-patterns.md +++ b/ai-docs/patterns/mobx-patterns.md @@ -95,6 +95,7 @@ not at all. **Where it appears** - `packages/contact-center/store/src/storeEventsWrapper.ts` (dozens of setters and event handlers wrap writes in `runInAction`) , `packages/contact-center/task/src/helper.ts` , `packages/contact-center/user-state/src/helper.ts` +- `packages/contact-center/store/src/store.ts` (`aiSummaries` is a store-owned `observable.ref` field; widgets call store methods for response bookkeeping) **Edge cases / exceptions** - Reads never need `runInAction`; only writes to observables do. diff --git a/packages/contact-center/ai-assistant/ai-docs/ai-assistant-spec.md b/packages/contact-center/ai-assistant/ai-docs/ai-assistant-spec.md index 582666515..30fd485a0 100644 --- a/packages/contact-center/ai-assistant/ai-docs/ai-assistant-spec.md +++ b/packages/contact-center/ai-assistant/ai-docs/ai-assistant-spec.md @@ -291,6 +291,35 @@ views and the ErrorBoundary path. `tests/ai-assistant/feedback.tsx` covers like/ and the missing-`adaptiveCardId` warning. UI-level rendering (spinner, error text, snapshots) is covered in `cc-components/tests/components/AIAssistant/`. +## Receiving-agent AI summaries + +`getReceiverSummary` in `src/helper.ts` reads the current interaction's receiver entry +from `store.aiSummaries`, maps its request status and SDK `areTranscriptsAvailable` +flag to a display surface, and passes the raw SDK content, confirmed feedback from +`summaryData.response.feedback` and pending state to the presentation component. Receiver feedback accepts `none` to withdraw a confirmed selection, using the same displayed-payload and pending-request guards. Received +content remains visible during a failed or pending refresh. Copy callbacks delegate to +`store.recordAISummaryCopied` for the receiver role; the store owns the response update +and MobX action. The widget helper does not import MobX or mutate summary entries. +Copy and feedback callbacks retain the displayed SDK content and Task identities; +replacement content or a different active Task invalidates those callbacks. Feedback +passes that SDK payload and Task to `store.setReceiverSummaryFeedback`, which owns +submission and acknowledgement. The widget makes no summary SDK calls. + +The receiver branch is independent of Real-time Assist enablement. A missing receiver +entry supplies no branch. SDK content with `areTranscriptsAvailable: false` supplies +the unavailable surface. Without content, an error status supplies the generic error +surface; other statuses supply no branch. +Raw content without an adaptive card still supplies a branch; card rendering and +unsupported-card fallback belong to the presentation component. +Summary changes are observed by `AIAssistantInternal`. +Widget tests use plain store mocks to verify summary projections. Real MobX +notification coverage lives in `store/tests/ai-summary-observability.ts`; this +package retains its existing `mobx-react-lite` observer without adding direct MobX +development or peer dependencies. + +Evidence: `src/helper.ts`, `src/ai-assistant/index.tsx`; +tests: receiver summary cases in `tests/ai-assistant/index.tsx`. + ## Traceability - Repo architecture: `../../../../ai-docs/ARCHITECTURE.md` · Registry: `../../../../ai-docs/SPEC_INDEX.md` diff --git a/packages/contact-center/ai-assistant/src/ai-assistant/index.tsx b/packages/contact-center/ai-assistant/src/ai-assistant/index.tsx index 0f979e7ae..7fae47fa9 100644 --- a/packages/contact-center/ai-assistant/src/ai-assistant/index.tsx +++ b/packages/contact-center/ai-assistant/src/ai-assistant/index.tsx @@ -4,7 +4,7 @@ import {observer} from 'mobx-react-lite'; import {ErrorBoundary} from 'react-error-boundary'; import {AIAssistantComponent} from '@webex/cc-components'; -import {useAiAssistant, REAL_TIME_ASSIST_FLAG} from '../helper'; +import {getReceiverSummary, useAiAssistant, REAL_TIME_ASSIST_FLAG} from '../helper'; import {useWellnessBreak} from '../wellness/useWellnessBreak'; import {loadWellnessAnimation} from '../wellness/animation'; import {IAIAssistantProps} from '../ai-assistant.types'; @@ -71,6 +71,7 @@ const AIAssistantInternal: React.FunctionComponent = observer return ( { return useMemo(() => ({...chrome, ...realTimeAssist}), [chrome, realTimeAssist]); }; +/** The receiving agent's consult/transfer summary, pushed by the backend for the task's interaction. */ +export const getReceiverSummary = (task?: ITask): AIAssistantReceiverSummary | undefined => { + const interactionId = task?.data.interactionId; + const summaryData = interactionId ? store.aiSummaries[interactionId]?.receiver : undefined; + if (!task || !interactionId || !summaryData) { + return undefined; + } + const branchKey = `mid-call:receiver:${interactionId}`; + const content = summaryData.content; + if (!content) { + return summaryData.status === 'error' ? {surface: 'generic-error', branchKey} : undefined; + } + // The SDK flag is optional; an omitted value must not hide supplied content. + if (content.areTranscriptsAvailable === false) { + return {surface: 'unavailable', branchKey}; + } + return { + surface: 'content', + branchKey, + content, + selectedFeedback: summaryData.response.feedback, + feedbackPending: Boolean(summaryData.feedbackPending), + recordReceiverSummaryCopied: () => store.recordAISummaryCopied('receiver', content, task), + setReceiverSummaryFeedback: (feedback) => { + void store.setReceiverSummaryFeedback(feedback, content, task); + }, + }; +}; + export {REAL_TIME_ASSIST_FLAG}; diff --git a/packages/contact-center/ai-assistant/tests/ai-assistant/feedback.tsx b/packages/contact-center/ai-assistant/tests/ai-assistant/feedback.tsx index e318d59eb..48ff6a79b 100644 --- a/packages/contact-center/ai-assistant/tests/ai-assistant/feedback.tsx +++ b/packages/contact-center/ai-assistant/tests/ai-assistant/feedback.tsx @@ -60,6 +60,7 @@ jest.mock('@webex/cc-components', () => { jest.mock('@webex/cc-store', () => ({ __esModule: true, default: { + aiSummaries: {}, cc: { apiAIAssistant: { getRealTimeAssistance: jest.fn(), diff --git a/packages/contact-center/ai-assistant/tests/ai-assistant/index.tsx b/packages/contact-center/ai-assistant/tests/ai-assistant/index.tsx index 2a405ab35..afc96548c 100644 --- a/packages/contact-center/ai-assistant/tests/ai-assistant/index.tsx +++ b/packages/contact-center/ai-assistant/tests/ai-assistant/index.tsx @@ -1,31 +1,37 @@ import React from 'react'; -import {fireEvent, render, screen, within} from '@testing-library/react'; +import {fireEvent, render, screen, waitFor, within} from '@testing-library/react'; import {AIAssistant} from '../../src'; import * as helper from '../../src/helper'; import store from '@webex/cc-store'; +import type {AISummaryEntry} from '@webex/cc-store'; import '@testing-library/jest-dom'; -jest.mock('@webex/cc-store', () => { - return { - __esModule: true, - default: { - cc: { - apiAIAssistant: { - getRealTimeAssistance: jest.fn(), - sendRealTimeAssistanceUserAction: jest.fn(), - }, +jest.mock('mobx-react-lite', () => ({observer: (component: React.ComponentType) => component})); + +jest.mock('@webex/cc-store', () => ({ + __esModule: true, + default: { + aiSummaries: {}, + cc: { + apiAIAssistant: { + getRealTimeAssistance: jest.fn(), + sendRealTimeAssistanceUserAction: jest.fn(), }, - currentTask: {data: {interactionId: 'interaction-1'}}, - agentId: 'agent-1', - featureFlags: {isSuggestedResponsesEnabled: true}, - realTimeAssist: {}, - onErrorCallback: undefined, - clearRealTimeAssist: jest.fn(), }, - }; -}); + currentTask: {data: {interactionId: 'interaction-1'}}, + agentId: 'agent-1', + agentProfile: {}, + featureFlags: {isSuggestedResponsesEnabled: true}, + realTimeAssist: {}, + onErrorCallback: undefined, + clearRealTimeAssist: jest.fn(), + recordAISummaryCopied: jest.fn(), + setReceiverSummaryFeedback: jest.fn(), + }, +})); type StoreMock = { + aiSummaries: Record; cc: { apiAIAssistant: { getRealTimeAssistance: jest.Mock; @@ -34,23 +40,94 @@ type StoreMock = { }; currentTask?: {data: {interactionId: string}}; agentId: string; + agentProfile: Record; featureFlags: {isSuggestedResponsesEnabled: boolean}; realTimeAssist: Record; onErrorCallback?: jest.Mock; clearRealTimeAssist: jest.Mock; + recordAISummaryCopied: jest.Mock; + setReceiverSummaryFeedback: jest.Mock; }; const storeMock = store as unknown as StoreMock; +const receiverEntry = (overrides: Partial = {}): AISummaryEntry => ({ + status: 'ready', + response: { + summary: 'Receiver summary', + feedback: 'none', + state: 'DEFAULT', + numberOfTimesViewed: 1, + numberOfTimesEdited: 0, + numberOfTimesCopied: 0, + }, + action: 'TRANSFER', + content: { + conversationId: 'interaction-1', + adaptiveCard: { + type: 'AdaptiveCard', + version: '1.5', + body: [{type: 'TextBlock', text: 'Receiver summary'}], + }, + }, + ...overrides, +}); + +const setReceiverEntry = (receiver?: AISummaryEntry): void => { + storeMock.aiSummaries = receiver ? {'interaction-1': {receiver}} : {}; +}; + +const deferred = (): { + promise: Promise; + resolve: (value: T) => void; +} => { + let resolve: (value: T) => void = () => undefined; + const promise = new Promise((promiseResolve) => { + resolve = promiseResolve; + }); + return {promise, resolve}; +}; + describe('AIAssistant widget', () => { + const innerText = Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'innerText'); + + beforeAll(() => { + // Adaptive Cards writes plain text through innerText, which jsdom does not implement. + Object.defineProperty(HTMLElement.prototype, 'innerText', { + configurable: true, + get() { + return this.textContent; + }, + set(value: string) { + this.textContent = value; + }, + }); + }); + + afterAll(() => { + if (innerText) { + Object.defineProperty(HTMLElement.prototype, 'innerText', innerText); + } else { + delete (HTMLElement.prototype as Partial).innerText; + } + }); + beforeEach(() => { jest.clearAllMocks(); jest.restoreAllMocks(); jest.spyOn(console, 'error').mockImplementation(() => {}); + Object.defineProperty(navigator, 'clipboard', { + configurable: true, + value: {writeText: jest.fn().mockResolvedValue(undefined)}, + }); storeMock.currentTask = {data: {interactionId: 'interaction-1'}}; + storeMock.agentProfile = {}; storeMock.featureFlags = {isSuggestedResponsesEnabled: true}; storeMock.realTimeAssist = {}; + storeMock.aiSummaries = {}; storeMock.onErrorCallback = undefined; storeMock.cc.apiAIAssistant.sendRealTimeAssistanceUserAction.mockResolvedValue(undefined); + storeMock.recordAISummaryCopied.mockReturnValue(true); + storeMock.setReceiverSummaryFeedback.mockResolvedValue(undefined); }); it('renders launcher when chrome is closed', () => { @@ -106,6 +183,183 @@ describe('AIAssistant widget', () => { expect(container.querySelector('.my-host-class')).toBeInTheDocument(); }); + it('should omit the View summary trigger until a receiver summary arrives for the current interaction', () => { + storeMock.aiSummaries = {'other-interaction': {receiver: receiverEntry()}}; + render(); + expect(screen.queryByTestId('ai-assistant:view-summary')).not.toBeInTheDocument(); + }); + + it('updates the receiver summary branch from the latest store projection', async () => { + setReceiverEntry(receiverEntry({content: {conversationId: 'interaction-1', areTranscriptsAvailable: false}})); + const {rerender} = render(); + + fireEvent.click(screen.getByTestId('ai-assistant:view-summary')); + expect(screen.getByTestId('ai-summary:unavailable')).toBeInTheDocument(); + + setReceiverEntry(receiverEntry({status: 'error', content: undefined})); + rerender(); + + await waitFor(() => expect(screen.getByTestId('ai-summary:error')).toBeInTheDocument()); + expect(screen.queryByTestId('ai-summary:unavailable')).not.toBeInTheDocument(); + }); + + it('should open the receiver summary from the View summary trigger', async () => { + setReceiverEntry(receiverEntry()); + render(); + + fireEvent.click(screen.getByTestId('ai-assistant:view-summary')); + + expect(await screen.findByTestId('ai-assistant:receiver-summary')).toBeInTheDocument(); + }); + + it('opens a receiver summary even when Real-time Assist is disabled', async () => { + storeMock.featureFlags = {isSuggestedResponsesEnabled: false}; + setReceiverEntry(receiverEntry()); + render(); + + fireEvent.click(screen.getByTestId('ai-assistant:view-summary')); + + expect(await screen.findByTestId('ai-assistant:receiver-summary')).toBeInTheDocument(); + expect(screen.queryByTestId('ai-assistant:landing')).not.toBeInTheDocument(); + }); + + it('delegates receiver copies and feedback with the displayed SDK content', async () => { + const entry = receiverEntry(); + setReceiverEntry(entry); + render(); + + fireEvent.click(screen.getByTestId('ai-assistant:view-summary')); + fireEvent.click(await screen.findByRole('button', {name: 'Copy Summary'})); + + await waitFor(() => expect(navigator.clipboard.writeText).toHaveBeenCalledWith('Receiver summary')); + await waitFor(() => + expect(storeMock.recordAISummaryCopied).toHaveBeenCalledWith('receiver', entry.content, storeMock.currentTask) + ); + expect(storeMock.aiSummaries['interaction-1'].receiver?.content).toBe(entry.content); + + fireEvent.click(screen.getByRole('button', {name: 'This is helpful'})); + + await waitFor(() => + expect(storeMock.setReceiverSummaryFeedback).toHaveBeenCalledWith( + 'thumbs_up', + entry.content, + storeMock.currentTask + ) + ); + }); + + it('keeps received content visible while a refresh loads or fails', () => { + const entry = receiverEntry(); + for (const status of ['loading', 'error'] as const) { + setReceiverEntry({...entry, status}); + expect(helper.getReceiverSummary(store.currentTask)).toMatchObject({surface: 'content', content: entry.content}); + } + }); + + it('delegates receiver feedback deselection with the displayed SDK content and task', () => { + const entry = receiverEntry(); + entry.response.feedback = 'thumbs_up'; + setReceiverEntry(entry); + const summary = helper.getReceiverSummary(store.currentTask); + expect(summary?.surface).toBe('content'); + if (summary?.surface !== 'content') return; + + summary.setReceiverSummaryFeedback('none'); + + expect(storeMock.setReceiverSummaryFeedback).toHaveBeenCalledWith('none', entry.content, storeMock.currentTask); + expect(entry.response.feedback).toBe('thumbs_up'); + }); + + it('omits a receiver summary while a request has no content', () => { + setReceiverEntry(receiverEntry({status: 'loading', content: undefined})); + + expect(helper.getReceiverSummary(store.currentTask)).toBeUndefined(); + }); + + it('shows the unavailable fallback for receiver content without a card', async () => { + setReceiverEntry(receiverEntry({content: {conversationId: 'interaction-1', summaryText: 'Text-only summary'}})); + render(); + + fireEvent.click(screen.getByTestId('ai-assistant:view-summary')); + + expect(await screen.findByTestId('ai-summary:unavailable')).toBeInTheDocument(); + expect(screen.queryByRole('button', {name: 'Copy Summary'})).not.toBeInTheDocument(); + }); + + it('retains the displayed SDK content for the store to reject stale callbacks', () => { + setReceiverEntry(receiverEntry()); + const displayedTask = storeMock.currentTask; + const summary = helper.getReceiverSummary(store.currentTask); + expect(summary?.surface).toBe('content'); + if (summary?.surface !== 'content') return; + + const replacement = receiverEntry(); + setReceiverEntry(replacement); + + storeMock.recordAISummaryCopied.mockReturnValueOnce(false); + expect(summary.recordReceiverSummaryCopied()).toBe(false); + expect(storeMock.recordAISummaryCopied).toHaveBeenCalledWith('receiver', summary.content, displayedTask); + summary.setReceiverSummaryFeedback('thumbs_up'); + expect(storeMock.aiSummaries['interaction-1'].receiver?.response.numberOfTimesCopied).toBe(0); + expect(storeMock.setReceiverSummaryFeedback).toHaveBeenCalledWith('thumbs_up', summary.content, displayedTask); + }); + + it('retains the displayed task for the store to reject stale callbacks', () => { + setReceiverEntry(receiverEntry()); + const displayedTask = storeMock.currentTask; + const summary = helper.getReceiverSummary(store.currentTask); + expect(summary?.surface).toBe('content'); + if (summary?.surface !== 'content') return; + + storeMock.currentTask = {data: {interactionId: 'interaction-1'}}; + + storeMock.recordAISummaryCopied.mockReturnValueOnce(false); + expect(summary.recordReceiverSummaryCopied()).toBe(false); + expect(storeMock.recordAISummaryCopied).toHaveBeenCalledWith('receiver', summary.content, displayedTask); + summary.setReceiverSummaryFeedback('thumbs_up'); + expect(storeMock.aiSummaries['interaction-1'].receiver?.response.numberOfTimesCopied).toBe(0); + expect(storeMock.setReceiverSummaryFeedback).toHaveBeenCalledWith('thumbs_up', summary.content, displayedTask); + }); + + it('projects receiver feedback pending state as disabled controls without painting a selection', async () => { + setReceiverEntry(receiverEntry({feedbackPending: true})); + render(); + + fireEvent.click(screen.getByTestId('ai-assistant:view-summary')); + + const like = await screen.findByRole('button', {name: 'This is helpful'}); + expect(like).toBeDisabled(); + expect(like).toHaveAttribute('aria-pressed', 'false'); + }); + + it('replaces receiver feedback projections only after the store confirms the selection', async () => { + const feedbackSend = deferred(); + setReceiverEntry(receiverEntry()); + storeMock.setReceiverSummaryFeedback.mockImplementationOnce(() => { + setReceiverEntry(receiverEntry({feedbackPending: true})); + return feedbackSend.promise; + }); + const {rerender} = render(); + + fireEvent.click(screen.getByTestId('ai-assistant:view-summary')); + const like = await screen.findByRole('button', {name: 'This is helpful'}); + fireEvent.click(like); + rerender(); + + await waitFor(() => expect(like).toBeDisabled()); + expect(like).toHaveAttribute('aria-pressed', 'false'); + + feedbackSend.resolve(); + const confirmed = receiverEntry(); + confirmed.response.feedback = 'thumbs_up'; + setReceiverEntry(confirmed); + rerender(); + + await waitFor(() => + expect(screen.getByRole('button', {name: 'This is helpful'})).toHaveAttribute('aria-pressed', 'true') + ); + }); + it('routes errors thrown in the hook to store.onErrorCallback', () => { const onErrorCallback = jest.fn(); storeMock.onErrorCallback = onErrorCallback; diff --git a/packages/contact-center/cc-components/ai-docs/cc-components-spec.md b/packages/contact-center/cc-components/ai-docs/cc-components-spec.md index 7551a10d5..edca5e55f 100644 --- a/packages/contact-center/cc-components/ai-docs/cc-components-spec.md +++ b/packages/contact-center/cc-components/ai-docs/cc-components-spec.md @@ -355,6 +355,257 @@ Each component is tested in isolation with React Testing Library: render from a | `CC-COMPONENTS-R-017` | `tests/components/task/CallControlCAD/call-control-cad.tsx` (Momentum structure/configuration, sections, immediate/confirmed Drop, pending/consult disabled state, read-only rows, focus fallback, live regions) | Momentum overlay keyboard behavior and visual placement receive final manual coverage | | `CC-COMPONENTS-R-018` | `tests/components/task/CallControl/CallControlCustom/consult-transfer-popover.tsx`, `tests/components/task/CallControl/CallControlCustom/consult-transfer-popover.snapshot.tsx` | None | +## AI Assistant summary presentation + +The internal `components/AIAssistant/AISummary` surface renders generating, unavailable, error, +and content states from props. Initiator and post-call summaries support keyed text +edits, copy and feedback. Initiator and post-call errors support Retry when supplied; post-call shows a read-only Outcome from SDK +`resolution`; the display labels never become SDK section keys. Receiver summaries +use copy and feedback actions around a display-only Adaptive Card. Receiver content +without an Adaptive Card renders as unavailable and exposes no summary actions. + +Summary content props retain the store's SDK `AISummary` shape, including the required +`conversationId`; feedback and lifecycle values are supplied by the widget from the +store entry. Generating and error states accept absent content so widgets can pass +the store value directly without constructing an empty SDK payload. An editable +content state without a payload falls back to unavailable and exposes no actions. +Edit callbacks notify the owner without requiring a return value. Component tests +use the same SDK shape. `AISummary` owns receiver card rendering and actions; +the assistant selects the summary branch alongside its chrome. +A common props interface declares the mode and shared optional callbacks once. Mode +values live in summary constants. Missing edit callbacks produce read-only content; +missing action or Retry callbacks hide those controls. The shared +action surface uses that SDK payload only as identity. Callbacks are bound by the widget to the displayed +SDK summary and accept no numeric revision. Section ordering, labels, plain-text +clipboard formatting and the Momentum fallback presentation belong to +this component. Widget helpers dispatch local edits, copy counts and deferred feedback +to store actions for the applicable role. SDK submission stays in the store. +The presentation-state union is declared locally rather than derived from a store +selector. Pending clipboard completions cannot update confirmation after the +displayed SDK payload changes. + +Summary content starts in a read-only view. The explicit Edit summary action switches +from the SDK's `adaptiveCard` to `editAdaptiveCard`, reusing `AdaptiveCardRenderer`. +Existing response drafts reopen the edit surface so unchanged SDK view cards cannot +hide local edits. Without a supplied card, Momentum `Text`, `Button` and `Textarea` +provide the same explicit view/edit transition. All present editable fields, including +empty strings, stay available after blur so cleared text can be restored. Outcome +remains read-only. No per-section text buttons, hidden measurement element, font +listeners or resize observer are used. Fallback textareas retain Momentum's normal +field styling and public row sizing. +Fallback text uses explicit Momentum `Text.type` values and icons use public size +props; no descendant-wide font override or fixed feedback-button wrapper dimensions +replace native typography and sizing. +Plain and structured fallbacks share one field iteration and one Momentum textarea +path. The content grid spaces fields directly without a single-child section wrapper. +Status states share one container, heading and description; content rendering follows +one content-state check. Editable controls derive from the hook's edit state, which +already requires an edit callback. Receiver mode keeps its externally rendered card +and exposes only the shared action controls. + +The renderer shares `copyToClipboard`, feedback selection and control painting with +suggested responses. One action snapshot supplies action visibility, control kinds +and original labels; the same update pass applies contextual labels and action state. +Summary copy events occur only after a successful clipboard write; +replaced cards, content identities and unmounts invalidate pending completions. +Controlled summary feedback uses the store's confirmed selection and permits clearing +a repeated selection to `none`. Suggested responses keep their existing SDK action +IDs, `{type, actionId}` event payload, acknowledgement-driven feedback toggle and +immediate copy-action notification. Only supplied controlled feedback is repainted +on props updates. The renderer accepts contextual action labels without referring to summary +or store implementations. It overlays keyed drafts on matching `Input.Text` IDs +without reparsing the card or replacing the focused input; a plain draft string is +applied only to a card with one text input. An empty initial response does not erase +a card-only plain editor's SDK value when `summaryText` is absent. It reports the original input ID to its +owner. One `controlsDisabled` prop disables card actions and inputs; callers combine +request loading and exclusion before passing it. Missing callbacks +hide their corresponding actions; inputs stay read-only until Edit and whenever +editing is unavailable. Summary Edit/sparkle icons resolve to bundled Momentum assets. +Desktop's supplied summary templates use `Action.Submit`; native disabled controls +and the action callback guard handle summary exclusion/loading without capture-phase +click or keyboard listeners for arbitrary selectable-container actions. +The raw SDK card and summary content remain unchanged. Shared card inputs use Momentum theme tokens for their border, radius, text and focus styling. The renderer's `summary` presentation uses Desktop's 12/14px typography, 600-weight labels and compact actions, without the suggestion surface or an extra host border. Summary template body containers scroll at 182px while their actions stay outside that body; structured inputs use CSS content sizing instead of DOM measurement. These overrides are scoped to summary cards and their template IDs so suggestion cards retain their existing presentation. +Host configuration shares one per-call transparent container style across semantic +container variants; summary typography, spacing and actions are selected in that +same configuration without changing suggestion defaults. + +Fallback controls also share the copy and feedback utility functions. +Clipboard failures are handled once by `copyToClipboard`; the summary hook checks +its result before recording or confirming a copy. Card input changes resolve the +editable key once and dispatch through the same edit callback as fallback fields. +Momentum Tooltip owns hover, focus and Escape behavior through its `triggerID` binding; +the summary adds no document keyboard listener or manual visibility state. +Momentum Button's `active` property owns feedback icon fill and `aria-pressed`. +Selected feedback adds no separate pending-submission message, matching Desktop. +Its native variants, sizes and states own hover, selected, disabled and focus +styling; summary CSS adds layout without replacing those interaction styles. +Clipboard confirmation retains payload identity checks. +Empty read-only sections and clipboard blocks are omitted; empty fields remain +available throughout editing. + +`AIAssistantComponent.receiverSummary` adds View summary to closed/minimized chrome +and opens the receiver branch independently of Real-time Assist enablement. Each +chrome branch checks eligibility before rendering the shared trigger button. Copy uses +visible rendered card text through the shared Adaptive Card text extractor's +`summaryOnly` option. Summary presentation enables that same filter for card-provided +copy actions. Hidden text blocks, descendants of hidden card containers, source +timestamp/subtitle/link metadata and action-row labels are excluded from summary +copying; ordinary Real-time Assist copying keeps its existing behavior. Visible +nested blocks keep their text and line breaks. +The renderer uses Adaptive Cards' `supportsInteractivity: false` for receiver content, keeping +card-provided actions, select actions and inputs inert without rewriting the SDK +card. Within `AISummary`, its render callback controls whether the copy/feedback controls are +shown; parsing or rendering failure hides them. Valid replacement cards preserve +those controls and their focus. Replacing a display-only receiver card resets its +error boundary; ordinary Real-time Assist cards keep their existing recovery +behavior. One reset effect clears receiver selection on interaction replacement or +closed chrome. Closing either chrome clears the receiver selection before invoking the +host callback, so external reopening returns to the ordinary assistant view. +An explicitly selected receiver summary takes precedence over retained wellness +history; an active wellness offer, transition, error or overlay retains priority. +Wellness history remains available when returning to the ordinary assistant view. +Opening the receiver branch uses Momentum Button's `autoFocusOnMount` on its newly +mounted header control. The assistant remains a non-modal panel; it does not scan +controls for focus successors, force Momentum tab indexes, or repair focus after +arbitrary content removal. Receiver copying uses `AISummary`'s existing root DOM +reference to extract the card's visible text; no parent DOM getter is passed through props. +View summary uses the native secondary button variant without a browser title +tooltip or custom hover/focus styling. The existing launcher keeps its prior markup +and presentation. + +Summary presentation constants use the `AI_ASSISTANT_SUMMARY_` prefix. These components +receive data and callbacks through props and make no SDK calls. +`ai-summary.tsx` owns the rendered UI; its local `helper.ts` owns summary interaction, +clipboard and edit behavior. +Fallback status text, action controls and editors use Momentum `Text`, `Button` and +`Textarea`. Momentum's `autoFocusOnMount` enters the first fallback editor. Native +AdaptiveCards editors receive one initial input focus when Edit mounts the card, +matching Desktop's AdaptiveCards integration. Shared card +rendering remains imperative at its DOM boundary and uses one committed props +reference to keep callbacks and controlled values current without reparsing; +widget/store adapters own all SDK operations and response changes. +`AISummary` remains internal; it is not exported by the package's root React barrel. + +The Desktop source comparison uses `InteractionSummary/InteractionSummary.ts`, +`InteractionSummaryCard.ts` and `@uuip/adaptive-cards/src/components/AdaptiveCard.ts` +in the separate `wxcc-desktop` checkout. Both implementations delegate supplied +cards to AdaptiveCards and retain imperative card/input/clipboard handling. The +widget's typed/plain fallbacks additionally handle SDK payloads without a card. +Desktop supplies a Markdown parser and recolors external SVG assets for its theme; +this shared renderer instead normalizes hyphen bullets to text and retains the +bundled SVG image colors. Native Momentum controls inherit their theme, but semantic +Markdown lists and raw-card SVG recoloring are not equivalent to Desktop. + +Evidence: `components/AIAssistant/AISummary/` and `components/AIAssistant/`; tests: +`tests/components/AIAssistant/AISummary/ai-summary.tsx`, `tests/components/AIAssistant/AISummary/ai-summary-parity.tsx`, +`tests/components/AIAssistant/ai-assistant.tsx`, and +`tests/components/AIAssistant/adaptive-card-renderer.test.tsx`. + +## AI summaries in CallControl + +Consult/transfer and wrap-up panels forward their summary view props to the shared +`AIAssistant/AISummary` component. The widget binds callbacks to the displayed SDK +content and supplies local response edits separately through `editedSummary`, so +raw content retains its identity. These components perform no SDK operations. + +The compact wrap-up panel is titled Wrap Up Reasons and uses the existing Momentum +`Combobox`, `Selectlistbox` and `Option` components for searchable reason selection, +with the label Choose a reason to wrap up and placeholder Search topic. Momentum owns +filtering, dropdown placement and keyboard interaction. Typing clears the eligible +reason and disables submission until a configured option is committed; the picker +retains only a temporary snapshot of the native committed value while searching so +clearing eligibility does not erase the first typed character. Outside search, its +value derives directly from the selected reason. Selection events use the configured ID and name, +ignoring event-provided labels. SDK `suggestedWrapUpCodes` appear as native Momentum +suggestion chips only when an explicit ID or an unambiguous name maps to a configured +reason; duplicate suggestions and unknown codes cannot become actions. Selecting a +suggested reason fills the picker and hides the suggestion chips without submitting. +An SDK suggestion message is shown when there are no valid suggested chips. Summary +content is separated from the picker and uses its shared 182px scrolling body. The +27.5rem panel uses natural height bounded by 80vh, retaining its footer in short views. +Submit Wrap Up stays enabled with a committed reason while summary generation is +pending. It uses CallControl's existing wrap-up handler, which dispatches the action +and clears the selected reason immediately. Summary acknowledgement does not block +wrap-up, and the presentation adds no completion state or summary freeze. An absent +summary prop preserves the existing CallControl form. + +Summary-enabled voice destinations use Momentum selection controls, then an explicit +Consult or Transfer footer action. Selecting a row does not start the call operation. +Only footer Cancel invokes the supplied cancellation callback before clearing +exclusion and closing the picker. The dialog close button, Escape, and final dispatch +clear exclusion and close without emitting cancellation. Menu state owns open/close; +native hide callbacks only clear it and never send cancellation. Final dispatch +disables its button until reopening, using one React state flag as Desktop does; +a synchronous dispatch failure +leaves it retryable. Selection is kept per category, invalidated on search or reload, +and the final action revalidates the destination against the current list. +Refresh remains available alongside search. Mid-call Retry repeats the currently +open Consult/Transfer action through the widget callback, rather than an older +content entry's action identity. Non-summary destination behavior is preserved. + +Destination keys map to internal categories through the popover's existing local +`DESTINATION_CATEGORY` map; +`VOICE_DESTINATION_CATEGORY_LABELS` supplies Agent, Queue, Dial Number, and Entry Point +labels, and `VOICE_DESTINATION_SEARCH_PLACEHOLDERS` supplies category-specific search +copy matching Desktop. These presentation labels live in the shared task `constants.ts`. Voice +agent rows show names and presence without extension subtitles. SDK view/edit cards +supply their own summary title; the fallback title is rendered only without either +card. Empty destination categories use the existing shared +`ConsultTransferEmptyState`; existing category loading, empty-state and pagination +paths retain their established markup. +Voice destination rows supply Momentum's item index for native roving focus and +arrow-key/Enter selection. Their scroll wrapper adds no extra tab stop or mouse +event suppression. Row action buttons use native size, ghost and outline props. +Voice destination search uses Momentum `Searchfield`, including its leading icon, +clear button, typography and input focus. Typing or clearing invalidates a staged +destination through the existing query handler. Non-summary search retains its +existing input. The voice layout adds no overlaid search-icon wrapper or input +padding override. + +Summary-enabled voice Consult/Transfer opens in Momentum Design `Dialog`, centered +in the viewport independently of the embedded widget container. Summary wrap-up +retains the right-aligned anchored `PopoverNext`, with bounded content and native +viewport adjustment. Legacy wrap-up keeps its bottom-start placement. The dialog +owns its backdrop, focus trap, Escape handling, close button, and trigger focus +restoration. Consult/Transfer uses the native Consult Request or Transfer Request +header and suppresses duplicate content headings and close controls. The panel keeps +its selected overlay until it closes when summary capability changes, and changing +the task closes it. Non-summary destinations and the keypad retain `PopoverNext`; +keypad close does not reset summary exclusion. +The dialog constrains its host to 80vh and its width to the viewport; inner flex +regions keep destination lists and summary content scrollable while final actions +stay visible. SDK cards reserve their natural height so summary actions stay above +the modal footer. At short viewport heights the picker content scrolls above a sticky +action footer. The wrap-up popover always permits its content region to scroll while +retaining its directly aligned native completion button outside that region, +including longer suggestion rows and enlarged text. +The Exclude summary control aligns at the start of the action +footer. Excluded summary content receives an inactive glass overlay using the +Momentum theme token, with summary controls disabled and destination actions +remaining available. Each CallControl panel stylesheet owns its constrained flex and scroll +layout; the shared summary stylesheet owns the editor, status, and action presentation. + +Consult/transfer retains its summary layout until the panel closes after capability +revocation, preserving destination controls and the close action. Native Dialog +and Popover own overlay focus and keyboard +behavior. Shared summaries do not receive containing-panel DOM refs or implement +their own focus recovery. Wrap-up renders directly in the native Popover's content +region without an extra layout wrapper, focus capture handlers, fallback tab stop +or DOM recovery effect when summary capability changes. Requests, local +response changes, retry and submission remain in the widget/store layers. +The consult/transfer panel receives its accessible label directly from its heading. +Momentum's wrap-up Combobox owns its input and label IDs. Summary tooltips use +per-instance trigger IDs allocated once with the existing counter and lazy `useState` +pattern; no React type bridge or shared ID hook is required. +Layout effects synchronously invalidate task/content callbacks and retain current +clipboard props. One active-menu record holds the selected menu, overlay choice and +latched summary appearance. A passive effect retains the summary layout if summary +content first becomes available while that menu is open. + +Evidence: `components/task/CallControl/call-control.tsx` and `CallControlCustom/`; +tests: `tests/components/task/CallControl/call-control.tsx`, +`CallControlCustom/consult-transfer-popover.tsx`, and `CallControlCustom/wrap-up-summary.tsx`. + ## 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) diff --git a/packages/contact-center/cc-components/src/components/AIAssistant/AISummary/ai-summary.constants.ts b/packages/contact-center/cc-components/src/components/AIAssistant/AISummary/ai-summary.constants.ts new file mode 100644 index 000000000..2b4d83e8f --- /dev/null +++ b/packages/contact-center/cc-components/src/components/AIAssistant/AISummary/ai-summary.constants.ts @@ -0,0 +1,80 @@ +import type {AISummaryFeedbackButton, AISummarySectionKey} from './ai-summary.types'; + +export const AI_ASSISTANT_SUMMARY_MODES = { + MID_CALL_INITIATOR: 'mid-call-initiator', + MID_CALL_RECEIVER: 'mid-call-receiver', + POST_CALL: 'post-call', +} as const; + +export const AI_ASSISTANT_SUMMARY_MESSAGES = { + viewSummary: 'View summary', + editSummary: 'Edit summary', + editing: 'Editing', + generatingTitle: 'Generating summary...', + generatingDescription: 'Just a sec—the details are coming together.', + unavailable: 'The summary is not available', + generationError: 'Having trouble generating summary', + generationErrorDescription: + 'It could be a lost connection or something else. Could you check your connection or try again later?', + copySummary: 'Copy Summary', + copiedSummary: 'Copied', + attribution: 'AI-generated', + plainSummary: 'Summary', + like: 'This is helpful', + dislike: "This isn't helpful", + retry: 'Retry', + sectionLabels: { + initialContactReason: 'Initial contact reason', + additionalContactReasons: 'Additional contact reason(s)', + additionalContext: 'Additional context', + keyActionsTaken: 'Key Actions Taken', + nextSteps: 'Next Steps', + reasonForTransferOrConsult: 'Reason for transfer or consult', + resolution: 'Outcome', + }, + midCall: { + consultHeading: 'Here’s a consult summary—they’ll get a copy', + transferHeading: 'Here’s a transfer summary—they’ll get a copy.', + destinationCategory: 'Destination category', + searchDestinations: 'Search destinations', + }, + postCall: { + heading: 'Wrap Up Reasons', + summaryHeading: 'Summary of your conversation', + searchPlaceholder: 'Search topic', + completeAction: 'Submit Wrap Up', + suggested: 'AI-suggested:', + instructions: 'Choose a code, and select Edit summary to make changes if needed.', + chooseReason: 'Choose a reason to wrap up', + noReasonMatches: 'No wrap-up reasons match your search.', + }, +} as const; + +/** Presentation order for SDK sections and the read-only resolution. */ +export const AI_ASSISTANT_SUMMARY_SECTION_ORDER = [ + 'reasonForTransferOrConsult', + 'initialContactReason', + 'additionalContactReasons', + 'additionalContext', + 'keyActionsTaken', + 'resolution', + 'nextSteps', +] as const satisfies readonly (keyof typeof AI_ASSISTANT_SUMMARY_MESSAGES.sectionLabels)[]; + +export const AI_ASSISTANT_SUMMARY_FEEDBACK_BUTTONS = [ + {feedback: 'thumbs_up', icon: 'like', label: AI_ASSISTANT_SUMMARY_MESSAGES.like}, + {feedback: 'thumbs_down', icon: 'dislike', label: AI_ASSISTANT_SUMMARY_MESSAGES.dislike}, +] as const satisfies readonly AISummaryFeedbackButton[]; + +export const AI_ASSISTANT_SUMMARY_BULLETED_SECTION_KEYS: ReadonlySet = new Set([ + 'additionalContactReasons', + 'keyActionsTaken', + 'nextSteps', +]); + +/** Context labels for the shared suggestion/summary card action renderer. */ +export const AI_ASSISTANT_SUMMARY_ACTION_LABELS = { + copy: AI_ASSISTANT_SUMMARY_MESSAGES.copySummary, + like: AI_ASSISTANT_SUMMARY_MESSAGES.like, + dislike: AI_ASSISTANT_SUMMARY_MESSAGES.dislike, +} as const; diff --git a/packages/contact-center/cc-components/src/components/AIAssistant/AISummary/ai-summary.styles.scss b/packages/contact-center/cc-components/src/components/AIAssistant/AISummary/ai-summary.styles.scss new file mode 100644 index 000000000..6ccef9c15 --- /dev/null +++ b/packages/contact-center/cc-components/src/components/AIAssistant/AISummary/ai-summary.styles.scss @@ -0,0 +1,164 @@ +.ai-summary { + color: var(--mds-color-theme-text-primary-normal, #1b1c1d); + min-inline-size: 0; + font-size: 0.875rem; + line-height: 1.4286; +} + +.ai-summary--receiver { + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.ai-summary mdc-text::part(text) { + margin: 0; +} + +.ai-summary mdc-text[tagname='span'] { + display: inline; +} + +.ai-summary__status, +.ai-summary__content, +.ai-summary__actions { + min-inline-size: 0; +} + +.ai-summary__status { + display: grid; + gap: 0.5rem; + min-block-size: 13rem; + place-content: center; + text-align: center; + flex: 1 1 auto; +} + +.ai-summary__spinner { + margin-block-start: 1.25rem; + justify-self: center; +} + +.ai-summary__retry-button { + justify-self: center; +} + +.ai-summary__error-icon { + justify-self: center; + color: var(--mds-color-theme-text-error-normal, #b4232a); +} + +.ai-summary__description { + color: var(--mds-color-theme-text-secondary-normal, #54585c); + overflow-wrap: anywhere; +} + +.ai-summary__content { + display: grid; + gap: 0.5rem; +} + +.ai-summary__summary-heading { + display: flex; + align-items: center; + gap: 0.375rem; + margin-block: 0 0.5rem; +} + +.ai-summary__summary-sparkle { + color: var(--mds-color-theme-text-accent-normal, #0b5cad); +} + +.ai-summary__summary-info { + color: var(--mds-color-theme-text-secondary-normal, #54585c); +} + +.ai-summary__editor-surface { + display: flex; + flex-direction: column; + border: 0.0625rem solid var(--mds-color-theme-outline-primary-normal); + border-radius: 0.5rem; + padding: 0.625rem 0.75rem; +} + +.ai-summary__native-card { + min-inline-size: 0; + min-block-size: 0; +} + +.ai-summary__sr-only { + position: absolute; + inline-size: 0.0625rem; + block-size: 0.0625rem; + overflow: hidden; + clip-path: inset(50%); + white-space: nowrap; +} + +.ai-summary__textarea { + inline-size: 100%; + min-inline-size: 0; +} + +.ai-summary__readonly { + white-space: pre-wrap; + overflow-wrap: anywhere; +} + +.ai-summary__label { + margin-inline-end: 0.25rem; + + &::after { + content: ':'; + } +} + +.ai-summary__readonly-value { + white-space: pre-wrap; +} + +.ai-summary__readonly-list { + margin: 0; + padding-inline-start: 1rem; + white-space: normal; +} + +.ai-summary__actions { + display: flex; + flex-wrap: wrap; + gap: 0.375rem; + align-items: center; + margin-block-start: auto; + padding-block-start: 0.5rem; +} + +.ai-summary__action { + display: inline-flex; +} + +.ai-summary__feedback-group { + display: inline-flex; + align-items: center; + gap: 0.25rem; + flex: 0 0 auto; +} + +.ai-summary__actions-spacer { + flex: 1 1 auto; + min-inline-size: 0; +} + +.ai-summary__attribution { + color: var(--mds-color-theme-text-secondary-normal, #54585c); + white-space: nowrap; +} + +.ai-summary__action-separator { + display: inline-block; + inline-size: 0.25rem; + block-size: 0.25rem; + flex: 0 0 0.25rem; + border-radius: 50%; + background: var(--mds-color-theme-text-secondary-normal, #54585c); + opacity: 0.46; +} diff --git a/packages/contact-center/cc-components/src/components/AIAssistant/AISummary/ai-summary.tsx b/packages/contact-center/cc-components/src/components/AIAssistant/AISummary/ai-summary.tsx new file mode 100644 index 000000000..a6a5207fb --- /dev/null +++ b/packages/contact-center/cc-components/src/components/AIAssistant/AISummary/ai-summary.tsx @@ -0,0 +1,303 @@ +import React, {useState} from 'react'; +import {Button, Icon, Spinner, Text, Textarea, Tooltip} from '@momentum-design/components/dist/react'; +import { + AI_ASSISTANT_SUMMARY_ACTION_LABELS, + AI_ASSISTANT_SUMMARY_BULLETED_SECTION_KEYS, + AI_ASSISTANT_SUMMARY_FEEDBACK_BUTTONS, + AI_ASSISTANT_SUMMARY_MESSAGES, + AI_ASSISTANT_SUMMARY_MODES, +} from './ai-summary.constants'; +import {getDisplaySections, useAISummaryInteractions} from './helper'; +import AdaptiveCardRenderer from '../AdaptiveCardRenderer/adaptive-card-renderer'; +import type {AISummaryProps} from './ai-summary.types'; +import './ai-summary.styles.scss'; + +const renderReadonlyValue = (value: string, bulleted: boolean) => { + const lines = value.split('\n').filter((line) => line.trim().length > 0); + if (bulleted && lines.length > 1) { + return ( +

    + {lines.map((line, index) => ( +
  • + {line.trim().replace(/^(?:\u2022|[-*])\s*/, '')} +
  • + ))} +
+ ); + } + + return ( + + {value} + + ); +}; + +const AISummary = (props: AISummaryProps): React.ReactElement => { + const { + rootRef, + displayContent, + copied, + setCopied, + summaryId, + disabled, + selectedFeedback, + isEditing, + startEditing, + copyText, + hiddenActionIds, + handleRetry, + handleCardAction, + handleInput, + handleCardUserAction, + handleCardRendered, + handleCopy, + handleFeedback, + } = useAISummaryInteractions(props); + const isReceiver = props.mode === AI_ASSISTANT_SUMMARY_MODES.MID_CALL_RECEIVER; + const [receiverCardReady, setReceiverCardReady] = useState(false); + const state = + props.state === 'content' && (!displayContent || (isReceiver && !displayContent.adaptiveCard)) + ? 'unavailable' + : props.state; + if (state !== 'content') { + return ( +
+
+ {state === 'generic-error' && ( +
+
+ ); + } + + const card = isReceiver ? undefined : isEditing ? props.content?.editAdaptiveCard : props.content?.adaptiveCard; + if (card) { + return ( +
+
+ +
+
+ ); + } + const fields = displayContent?.sections + ? getDisplaySections(displayContent) + : [{key: 'summaryText' as const, value: displayContent?.summaryText ?? ''}]; + const firstEditableKey = fields.find((field) => field.key !== 'resolution')?.key; + const showCopy = Boolean(props.onCopy); + const showFeedback = Boolean(props.onFeedback); + const showEdit = Boolean(props.onEdit && !isEditing && !isReceiver); + const showActions = showCopy || showFeedback || showEdit; + const showSummaryHeading = props.mode === AI_ASSISTANT_SUMMARY_MODES.POST_CALL; + const copyLabel = copied ? AI_ASSISTANT_SUMMARY_MESSAGES.copiedSummary : AI_ASSISTANT_SUMMARY_MESSAGES.copySummary; + return ( +
+ {isReceiver && props.content?.adaptiveCard ? ( +
+ +
+ ) : null} + {showSummaryHeading ? ( +
+
+ ) : null} + {!isReceiver || receiverCardReady ? ( +
+ {!isReceiver && ( +
+ {fields.map(({key, value}) => { + const label = key === 'summaryText' ? undefined : AI_ASSISTANT_SUMMARY_MESSAGES.sectionLabels[key]; + const editable = isEditing && key !== 'resolution'; + if (key !== 'summaryText' && !value.trim() && !editable) return null; + if (editable) { + return ( + + + `; + expect(extractCardText(container, {summaryOnly: true})).toBe('Summary heading\nEdited summary'); + expect(extractCardText(container)).toContain('12:30'); + expect(extractCardText(container)).not.toContain('Edited summary'); + }); + + it('reports clipboard failure without showing success feedback', async () => { + Object.defineProperty(navigator, 'clipboard', { + configurable: true, + value: {writeText: jest.fn().mockRejectedValue(new Error('Clipboard unavailable'))}, + }); + const button = document.createElement('button'); + button.innerHTML = ''; + document.body.appendChild(button); + try { + expect(await copyToClipboard('Summary', button)).toBe(false); + expect(button).not.toHaveAttribute('data-copied'); + } finally { + button.remove(); + } + }); + + it('reports actual clipboard fallback success and removes its temporary textarea', async () => { + Object.defineProperty(navigator, 'clipboard', {configurable: true, value: undefined}); + const previousExecCommand = document.execCommand; + document.execCommand = jest.fn().mockReturnValue(false); + try { + expect(await copyToClipboard('Summary')).toBe(false); + expect(document.querySelector('textarea')).toBeNull(); + document.execCommand = jest.fn().mockReturnValue(true); + expect(await copyToClipboard('Summary')).toBe(true); + expect(document.querySelector('textarea')).toBeNull(); + } finally { + document.execCommand = previousExecCommand; + } + }); + it('uses transparent card containers and the compact action layout', () => { const config = buildHostConfig(); diff --git a/packages/contact-center/cc-components/tests/components/AIAssistant/ai-assistant.tsx b/packages/contact-center/cc-components/tests/components/AIAssistant/ai-assistant.tsx index 0afb26ee7..694aab93e 100644 --- a/packages/contact-center/cc-components/tests/components/AIAssistant/ai-assistant.tsx +++ b/packages/contact-center/cc-components/tests/components/AIAssistant/ai-assistant.tsx @@ -1,7 +1,8 @@ import React from 'react'; -import {fireEvent, render, screen, waitFor} from '@testing-library/react'; +import {act, fireEvent, render, screen, waitFor, within} from '@testing-library/react'; import '@testing-library/jest-dom'; import AIAssistantComponent from '../../../src/components/AIAssistant/ai-assistant'; +import {AI_ASSISTANT_SUMMARY_MESSAGES} from '../../../src/components/AIAssistant/AISummary'; import type { AIAssistantComponentProps, AIAssistantActionEvent, @@ -16,21 +17,56 @@ jest.mock('../../../src/components/AIAssistant/AdaptiveCardRenderer/adaptive-car return { __esModule: true, default: ({ + card, fallbackText, + displayOnly, + onRender, onUserAction, }: { + card?: unknown; fallbackText?: string; + displayOnly?: boolean; + onRender?: (rendered: boolean) => void; onUserAction?: (event: AIAssistantActionEvent) => void; - }) => - ReactModule.createElement( - 'button', + }) => { + const [rendered, setRendered] = ReactModule.useState( + !(card as {mockDeferredRender?: boolean} | undefined)?.mockDeferredRender + ); + ReactModule.useEffect(() => setRendered(true), [card]); + const shouldFallback = Boolean((card as {mockFallback?: boolean} | undefined)?.mockFallback); + const shouldRenderAction = !displayOnly && Boolean(onUserAction); + ReactModule.useEffect(() => { + onRender?.(rendered && !shouldFallback); + }, [onRender, rendered, shouldFallback]); + const renderedText = + typeof (card as {mockRenderedText?: unknown} | undefined)?.mockRenderedText === 'string' + ? (card as {mockRenderedText: string}).mockRenderedText + : 'Visible receiver card copy'; + return ReactModule.createElement( + 'div', { - type: 'button', - 'data-testid': 'mock-adaptive-card', - onClick: () => onUserAction?.({type: 'like', actionId: 'likeButton'}), + 'data-testid': 'ai-assistant:adaptive-card', }, - fallbackText - ), + shouldFallback + ? ReactModule.createElement('span', {'data-testid': 'ai-assistant:adaptive-card-fallback'}, fallbackText) + : ReactModule.createElement( + 'div', + {className: 'ai-assistant__card-host'}, + rendered ? ReactModule.createElement('span', {className: 'ac-textBlock'}, renderedText) : null, + shouldRenderAction + ? ReactModule.createElement( + 'button', + { + type: 'button', + 'data-testid': 'mock-adaptive-card', + onClick: () => onUserAction?.({type: 'like', actionId: 'likeButton'}), + }, + 'Mock card action' + ) + : null + ) + ); + }, }; }); @@ -58,6 +94,67 @@ const createProps = (overrides: Partial = {}): AIAssi ...overrides, }); +type ReceiverSummaryContent = Extract, {surface: 'content'}>; + +const createReceiverSummary = (overrides: Partial = {}): ReceiverSummaryContent => ({ + surface: 'content', + branchKey: 'interaction-1:agent-1:1', + content: { + conversationId: 'interaction-1', + adaptiveCard: {type: 'AdaptiveCard', version: '1.5', body: [{type: 'TextBlock', text: 'Receiver summary'}]}, + }, + selectedFeedback: 'none', + feedbackPending: false, + recordReceiverSummaryCopied: jest.fn().mockReturnValue(true), + setReceiverSummaryFeedback: jest.fn(), + ...overrides, +}); + +const renderStatefulReceiver = ( + initialChrome: AIAssistantComponentProps['chrome'], + receiverSummary: ReceiverSummaryContent = createReceiverSummary(), + overrides: Partial = {} +) => { + const controls = { + open: jest.fn(), + close: jest.fn(), + minimize: jest.fn(), + restore: jest.fn(), + }; + const Harness = () => { + const [chrome, setChrome] = React.useState(initialChrome); + + return ( + { + controls.open(); + setChrome('open'); + }, + close: () => { + controls.close(); + setChrome('closed'); + }, + minimize: () => { + controls.minimize(); + setChrome('minimized'); + }, + restore: () => { + controls.restore(); + setChrome('open'); + }, + })} + /> + ); + }; + + render(); + return controls; +}; + describe('AIAssistantComponent', () => { it('invokes the launcher, minimized, and header chrome actions', () => { const props = createProps({chrome: 'closed'}); @@ -140,6 +237,103 @@ describe('AIAssistantComponent', () => { expect(screen.getByText('Well-being break completed')).toBeInTheDocument(); }); + it.each(['header', 'minimized', 'external'] as const)( + 'returns to normal assistant content when the host reopens after %s close', + async (source) => { + let updateChrome: React.Dispatch> = () => undefined; + const receiverSummary = createReceiverSummary(); + const Harness = () => { + const [chrome, setChrome] = React.useState('closed'); + updateChrome = setChrome; + return ( + setChrome('open'), + close: () => setChrome('closed'), + minimize: () => setChrome('minimized'), + })} + /> + ); + }; + render(); + fireEvent.click(screen.getByTestId('ai-assistant:view-summary')); + expect(await screen.findByTestId('ai-assistant:receiver-summary')).toBeInTheDocument(); + + if (source === 'external') { + act(() => updateChrome('closed')); + } else { + if (source === 'minimized') { + fireEvent.click(screen.getByTestId('ai-assistant:header-minimize')); + } + fireEvent.click(screen.getByTestId(`ai-assistant:${source}-close`)); + } + expect(screen.getByTestId('ai-assistant:closed-chrome')).toBeInTheDocument(); + act(() => updateChrome('open')); + + expect(await screen.findByTestId('ai-assistant:empty')).toBeInTheDocument(); + expect(screen.queryByTestId('ai-assistant:receiver-summary')).not.toBeInTheDocument(); + } + ); + + it.each(['completed', 'declined'] as const)( + 'shows an explicitly selected summary over retained %s wellness history', + async (notice) => { + const wellness = { + enabled: true, + phase: 'idle' as const, + notice, + requestAvailable: false, + hasBlockingTasks: false, + elapsedSeconds: 0, + reducedMotion: false, + history: [{type: 'notice' as const, id: 'notice-1', createdAt: 1, notice}], + onRequest: jest.fn(), + onAccept: jest.fn(), + onLater: jest.fn(), + onMediaError: jest.fn(), + }; + renderStatefulReceiver('closed', createReceiverSummary(), {wellness}); + fireEvent.click(screen.getByTestId('ai-assistant:view-summary')); + + expect(await screen.findByTestId('ai-assistant:receiver-summary')).toBeInTheDocument(); + expect(screen.queryByTestId('wellness-break:history')).not.toBeInTheDocument(); + expect(screen.queryByTestId('wellness-break:request-card')).not.toBeInTheDocument(); + + fireEvent.click(screen.getByTestId('ai-assistant:header-close')); + fireEvent.click(screen.getByTestId('ai-assistant:launcher')); + expect(await screen.findByTestId('wellness-break:history')).toBeInTheDocument(); + expect(screen.queryByTestId('ai-assistant:receiver-summary')).not.toBeInTheDocument(); + } + ); + + it.each(['offer-pending', 'starting'] as const)( + 'keeps an active %s wellness flow ahead of an explicitly selected summary', + async (phase) => { + const wellness = { + enabled: true, + phase, + requestAvailable: false, + hasBlockingTasks: false, + elapsedSeconds: 0, + reducedMotion: false, + onRequest: jest.fn(), + onAccept: jest.fn(), + onLater: jest.fn(), + onMediaError: jest.fn(), + }; + renderStatefulReceiver('closed', createReceiverSummary(), {wellness}); + fireEvent.click(screen.getByTestId('ai-assistant:view-summary')); + + expect(await screen.findByTestId('ai-assistant:panel')).toBeInTheDocument(); + expect(screen.queryByTestId('ai-assistant:receiver-summary')).not.toBeInTheDocument(); + expect( + screen.getByTestId(phase === 'starting' ? 'wellness-break:overlay' : 'wellness-break:offer-card') + ).toBeInTheDocument(); + } + ); + it('renders the empty state and requests a suggestion', () => { const props = createProps(); render(); @@ -319,6 +513,363 @@ describe('AIAssistantComponent', () => { expect(onRealTimeAssistAction).toHaveBeenCalledWith({type: 'like', actionId: 'likeButton'}, assist); }); + it('renders the Momentum receiver trigger only in closed and minimized chrome slots', async () => { + const receiverSummary = createReceiverSummary(); + const {rerender} = render(); + + expect(screen.queryByTestId('ai-assistant:view-summary')).not.toBeInTheDocument(); + + rerender(); + + const trigger = await screen.findByRole('button', {name: AI_ASSISTANT_SUMMARY_MESSAGES.viewSummary}); + expect(trigger.tagName.toLowerCase()).toBe('mdc-button'); + expect(trigger).toHaveTextContent(AI_ASSISTANT_SUMMARY_MESSAGES.viewSummary); + expect(trigger).toHaveAttribute('variant', 'secondary'); + expect(trigger).toHaveAttribute('aria-label', AI_ASSISTANT_SUMMARY_MESSAGES.viewSummary); + expect(trigger).not.toHaveAttribute('title'); + expect(screen.getByTestId('ai-assistant:closed-chrome')).toContainElement(trigger); + + rerender(); + + expect(screen.getByTestId('ai-assistant:minimized-bar')).toContainElement( + screen.getByTestId('ai-assistant:view-summary') + ); + + for (const chrome of ['closed', 'minimized'] as const) { + rerender(); + expect(screen.queryByTestId('ai-assistant:view-summary')).not.toBeInTheDocument(); + + rerender(); + expect(screen.queryByTestId('ai-assistant:view-summary')).not.toBeInTheDocument(); + } + }); + + it('uses Momentum autofocus when opening the receiver branch from closed and minimized chrome', async () => { + const controls = renderStatefulReceiver('closed'); + + fireEvent.click(screen.getByTestId('ai-assistant:view-summary')); + + expect(await screen.findByTestId('ai-assistant:receiver-summary')).toBeInTheDocument(); + expect(controls.open).toHaveBeenCalledTimes(1); + await waitFor(() => expect(screen.getByTestId('ai-assistant:header-minimize')).toHaveFocus()); + + fireEvent.click(screen.getByTestId('ai-assistant:header-minimize')); + expect(await screen.findByTestId('ai-assistant:panel-minimized')).toBeInTheDocument(); + + fireEvent.click(screen.getByTestId('ai-assistant:view-summary')); + + expect(await screen.findByTestId('ai-assistant:receiver-summary')).toBeInTheDocument(); + expect(controls.restore).toHaveBeenCalledTimes(1); + expect(controls.open).toHaveBeenCalledTimes(1); + }); + + it('applies automatic direction to mixed-direction receiver summary content only', async () => { + const receiverSummary = createReceiverSummary({ + content: { + conversationId: 'interaction-1', + adaptiveCard: { + type: 'AdaptiveCard', + version: '1.5', + mockRenderedText: 'סיכום Receiver 42', + body: [{type: 'TextBlock', text: 'סיכום Receiver 42'}], + }, + }, + }); + renderStatefulReceiver('closed', receiverSummary); + + fireEvent.click(screen.getByTestId('ai-assistant:view-summary')); + + const receiverBranch = await screen.findByTestId('ai-assistant:receiver-summary'); + expect(receiverBranch).not.toHaveAttribute('dir', 'auto'); + const card = within(receiverBranch).getByTestId('ai-assistant:adaptive-card'); + expect(card.closest('[dir]')).toHaveAttribute('dir', 'auto'); + const actions = await screen.findByTestId('ai-summary:actions'); + expect(actions.closest('[dir]')).not.toHaveAttribute('dir', 'auto'); + expect(screen.getByTestId('ai-assistant:panel')).not.toHaveAttribute('dir'); + expect(within(receiverBranch).getByText('סיכום Receiver 42')).toBeInTheDocument(); + }); + + it('keeps receiver card actions inert and copies only visible rendered card text', async () => { + Object.defineProperty(navigator, 'clipboard', { + configurable: true, + value: {writeText: jest.fn().mockResolvedValue(undefined)}, + }); + const receiverSummary = createReceiverSummary({ + content: { + conversationId: 'interaction-1', + adaptiveCard: { + type: 'AdaptiveCard', + version: '1.5', + selectAction: {type: 'Action.Submit', id: 'selectCard', title: 'Select'}, + body: [ + {type: 'TextBlock', text: 'Receiver summary'}, + { + type: 'ActionSet', + actions: [ + {type: 'Action.Submit', id: 'copyButton', title: 'Copy'}, + {type: 'Action.Execute', id: 'likeButton', title: 'Like'}, + ], + }, + ], + actions: [{type: 'Action.Submit', id: 'dislikeButton', title: 'Dislike'}], + }, + }, + }); + renderStatefulReceiver('closed', receiverSummary); + + fireEvent.click(screen.getByTestId('ai-assistant:view-summary')); + + const card = await screen.findByTestId('ai-assistant:adaptive-card'); + expect(screen.getByTestId('ai-summary:mid-call-receiver')).toContainElement(card); + expect(within(card).queryByRole('button')).not.toBeInTheDocument(); + + const copyButton = await screen.findByRole('button', {name: AI_ASSISTANT_SUMMARY_MESSAGES.copySummary}); + expect(card).not.toContainElement(screen.getByTestId('ai-summary:actions')); + fireEvent.click(copyButton); + + await waitFor(() => expect(navigator.clipboard.writeText).toHaveBeenCalledWith('Visible receiver card copy')); + expect(receiverSummary.recordReceiverSummaryCopied).toHaveBeenCalledWith(); + }); + + it('reports receiver feedback through the bound callback and paints the selection it is given', async () => { + const receiverSummary = createReceiverSummary(); + renderStatefulReceiver('closed', receiverSummary); + + fireEvent.click(screen.getByTestId('ai-assistant:view-summary')); + const like = await screen.findByRole('button', {name: AI_ASSISTANT_SUMMARY_MESSAGES.like}); + fireEvent.click(like); + + expect(receiverSummary.setReceiverSummaryFeedback).toHaveBeenCalledWith('thumbs_up'); + expect(like).toHaveAttribute('aria-pressed', 'false'); + }); + + it('opens the receiver branch from View summary and disables pending feedback controls', async () => { + const receiverSummary = createReceiverSummary({feedbackPending: true}); + renderStatefulReceiver('closed', receiverSummary); + + const viewSummary = screen.getByTestId('ai-assistant:view-summary'); + expect(viewSummary).toHaveAttribute('aria-label', AI_ASSISTANT_SUMMARY_MESSAGES.viewSummary); + expect(viewSummary).not.toHaveAttribute('title'); + fireEvent.click(viewSummary); + + expect(await screen.findByTestId('ai-assistant:receiver-summary')).toBeInTheDocument(); + expect(await screen.findByRole('button', {name: AI_ASSISTANT_SUMMARY_MESSAGES.like})).toHaveAttribute('disabled'); + }); + + it('opens the receiver branch when Real-time Assist is disabled', async () => { + renderStatefulReceiver('closed', createReceiverSummary(), {isFeatureEnabled: false}); + + fireEvent.click(screen.getByTestId('ai-assistant:view-summary')); + + expect(await screen.findByTestId('ai-assistant:receiver-summary')).toBeInTheDocument(); + expect(screen.queryByTestId('ai-assistant:landing')).not.toBeInTheDocument(); + expect(screen.getByTestId('ai-assistant:footer')).toBeInTheDocument(); + }); + + it('renders receiver actions when the host opens chrome after activation settles', async () => { + const props = createProps({chrome: 'closed', receiverSummary: createReceiverSummary()}); + const {rerender} = render(); + fireEvent.click(screen.getByTestId('ai-assistant:view-summary')); + expect(props.open).toHaveBeenCalledTimes(1); + expect(screen.queryByTestId('ai-summary:actions')).not.toBeInTheDocument(); + rerender(); + expect(await screen.findByTestId('ai-summary:actions')).toBeInTheDocument(); + }); + + it('removes receiver actions when the renderer falls back', async () => { + const receiverSummary = createReceiverSummary(); + const fallbackReceiverSummary = createReceiverSummary({ + branchKey: receiverSummary.branchKey, + content: { + conversationId: 'interaction-1', + adaptiveCard: { + type: 'AdaptiveCard', + mockFallback: true, + }, + }, + }); + let setSummary: React.Dispatch> = () => undefined; + const Harness = () => { + const [chrome, setChrome] = React.useState('closed'); + const [summary, setSummaryState] = React.useState(receiverSummary); + setSummary = setSummaryState; + + return ( + setChrome('open'), + close: () => setChrome('closed'), + minimize: () => setChrome('minimized'), + restore: () => setChrome('open'), + })} + /> + ); + }; + render(); + + fireEvent.click(screen.getByTestId('ai-assistant:view-summary')); + const copyButton = await screen.findByRole('button', {name: AI_ASSISTANT_SUMMARY_MESSAGES.copySummary}); + act(() => { + copyButton.focus(); + }); + expect(copyButton).toHaveFocus(); + + act(() => { + setSummary(fallbackReceiverSummary); + }); + + expect(await screen.findByTestId('ai-assistant:adaptive-card-fallback')).toHaveTextContent( + AI_ASSISTANT_SUMMARY_MESSAGES.unavailable + ); + await waitFor(() => expect(screen.queryByTestId('ai-summary:actions')).not.toBeInTheDocument()); + expect(screen.queryByRole('button', {name: AI_ASSISTANT_SUMMARY_MESSAGES.copySummary})).not.toBeInTheDocument(); + expect(screen.getByTestId('ai-assistant:panel')).toBeInTheDocument(); + }); + + it.each([ + AI_ASSISTANT_SUMMARY_MESSAGES.copySummary, + AI_ASSISTANT_SUMMARY_MESSAGES.like, + AI_ASSISTANT_SUMMARY_MESSAGES.dislike, + ])('keeps the focused %s action connected when a valid receiver card is replaced', async (name) => { + const receiverSummary = createReceiverSummary(); + let replaceSummary: React.Dispatch> = () => undefined; + const Harness = () => { + const [chrome, setChrome] = React.useState('closed'); + const [summary, setSummary] = React.useState(receiverSummary); + replaceSummary = setSummary; + return ( + setChrome('open')})} /> + ); + }; + render(); + fireEvent.click(screen.getByTestId('ai-assistant:view-summary')); + const action = await screen.findByRole('button', {name}); + act(() => action.focus()); + + act(() => { + replaceSummary( + createReceiverSummary({ + content: { + conversationId: 'interaction-1', + adaptiveCard: { + type: 'AdaptiveCard', + mockDeferredRender: true, + body: [{type: 'TextBlock', text: 'Replacement summary'}], + mockRenderedText: 'Replacement summary', + }, + }, + }) + ); + }); + + await screen.findByText('Replacement summary'); + expect(screen.getByRole('button', {name})).toBe(action); + expect(action).toHaveFocus(); + }); + + it('preserves connected external focus when a receiver trigger is removed', async () => { + const props = createProps({chrome: 'closed', receiverSummary: createReceiverSummary()}); + const renderHost = (receiverSummary: AIAssistantComponentProps['receiverSummary']) => ( + <> + + + + ); + const {rerender} = render(renderHost(props.receiverSummary)); + const trigger = await screen.findByRole('button', {name: AI_ASSISTANT_SUMMARY_MESSAGES.viewSummary}); + act(() => trigger.focus()); + expect(trigger).toHaveFocus(); + const host = screen.getByRole('button', {name: 'Host control'}); + act(() => host.focus()); + rerender(renderHost(undefined)); + expect(host).toHaveFocus(); + }); + + it('returns to ordinary assistant content when the receiver summary branch is removed', async () => { + let clearSummary: () => void = () => undefined; + const receiverSummary = createReceiverSummary(); + const Harness = () => { + const [chrome, setChrome] = React.useState('closed'); + const [summary, setSummary] = React.useState(receiverSummary); + clearSummary = () => setSummary(undefined); + + return ( + setChrome('open'), + close: () => setChrome('closed'), + minimize: () => setChrome('minimized'), + restore: () => setChrome('open'), + })} + /> + ); + }; + render(); + + fireEvent.click(screen.getByTestId('ai-assistant:view-summary')); + const copyButton = await screen.findByRole('button', {name: AI_ASSISTANT_SUMMARY_MESSAGES.copySummary}); + act(() => { + copyButton.focus(); + }); + expect(copyButton).toHaveFocus(); + + act(() => { + clearSummary(); + }); + + await waitFor(() => expect(screen.queryByTestId('ai-assistant:receiver-summary')).not.toBeInTheDocument()); + expect(screen.getByTestId('ai-assistant:empty')).toBeInTheDocument(); + }); + + it('resets the receiver branch for ordinary chrome actions and owner changes', async () => { + const receiverSummary = createReceiverSummary(); + const props = createProps({chrome: 'closed', receiverSummary}); + const {rerender} = render(); + + fireEvent.click(screen.getByTestId('ai-assistant:view-summary')); + rerender(); + expect(screen.getByTestId('ai-assistant:receiver-summary')).toBeInTheDocument(); + + fireEvent.click(screen.getByTestId('ai-assistant:header-close')); + expect(props.close).toHaveBeenCalledTimes(1); + expect(screen.getByTestId('ai-assistant:empty')).toBeInTheDocument(); + + rerender(); + fireEvent.click(screen.getByTestId('ai-assistant:view-summary')); + rerender(); + expect(screen.getByTestId('ai-assistant:receiver-summary')).toBeInTheDocument(); + rerender(); + fireEvent.click(screen.getByTestId('ai-assistant:launcher')); + rerender(); + expect(screen.getByTestId('ai-assistant:empty')).toBeInTheDocument(); + + rerender(); + fireEvent.click(screen.getByTestId('ai-assistant:view-summary')); + rerender(); + expect(screen.getByTestId('ai-assistant:receiver-summary')).toBeInTheDocument(); + rerender(); + fireEvent.click(screen.getByTestId('ai-assistant:minimized-restore')); + rerender(); + expect(screen.getByTestId('ai-assistant:empty')).toBeInTheDocument(); + + rerender(); + fireEvent.click(screen.getByTestId('ai-assistant:view-summary')); + rerender(); + expect(screen.getByTestId('ai-assistant:receiver-summary')).toBeInTheDocument(); + rerender( + + ); + await waitFor(() => expect(screen.getByTestId('ai-assistant:empty')).toBeInTheDocument()); + }); + it('applies the full-screen class and matching header control label', () => { render(); diff --git a/packages/contact-center/cc-components/tests/components/task/CallControl/CallControlCustom/call-control-consult.tsx b/packages/contact-center/cc-components/tests/components/task/CallControl/CallControlCustom/call-control-consult.tsx index a18e43200..56552ad91 100644 --- a/packages/contact-center/cc-components/tests/components/task/CallControl/CallControlCustom/call-control-consult.tsx +++ b/packages/contact-center/cc-components/tests/components/task/CallControl/CallControlCustom/call-control-consult.tsx @@ -293,6 +293,18 @@ describe('CallControlConsultComponent', () => { expect(cancelIcon).toBeInTheDocument(); }); + it.each(['transfer', 'conference'] as const)( + 'should run the consult-surface %s immediately without a summary popover', + (buttonKey) => { + const screen = render(); + + fireEvent.click(screen.getByTestId(`${buttonKey}-consult-btn`)); + + expect(buttonKey === 'transfer' ? mockOnTransfer : mockConsultConference).toHaveBeenCalledTimes(1); + expect(screen.queryByTestId('consult-transfer:summary')).not.toBeInTheDocument(); + } + ); + it('verifies accessibility attributes for all buttons', async () => { const screen = await render(); diff --git a/packages/contact-center/cc-components/tests/components/task/CallControl/CallControlCustom/consult-transfer-list-item.tsx b/packages/contact-center/cc-components/tests/components/task/CallControl/CallControlCustom/consult-transfer-list-item.tsx index 3b8d22345..112d0fb99 100644 --- a/packages/contact-center/cc-components/tests/components/task/CallControl/CallControlCustom/consult-transfer-list-item.tsx +++ b/packages/contact-center/cc-components/tests/components/task/CallControl/CallControlCustom/consult-transfer-list-item.tsx @@ -117,6 +117,23 @@ describe('CallControlListItemPresentational', () => { expect(mockOnButtonPress).toHaveBeenCalledTimes(1); }); + it('uses Momentum selection styling and lets the staged destination be selected from its row', () => { + const view = render(); + const item = view.getByRole('listitem', {name: defaultProps.title}); + expect(view.getByRole('button')).toHaveAttribute('aria-pressed', 'false'); + expect(view.getByRole('button')).toHaveAttribute('data-size', '28'); + expect(view.getByRole('button')).toHaveAttribute('data-color', 'primary'); + expect(view.getByRole('button')).toHaveAttribute('data-ghost', 'true'); + expect(view.getByRole('button')).toHaveAttribute('data-outline', 'true'); + fireEvent.click(item); + expect(mockOnButtonPress).toHaveBeenCalledTimes(1); + view.rerender(); + expect(item).toHaveClass('active'); + expect(view.getByRole('button')).toHaveAttribute('aria-pressed', 'true'); + fireEvent.click(view.getByRole('button')); + expect(mockOnButtonPress).toHaveBeenCalledTimes(2); + }); + it('renders without subtitle when not provided', async () => { const propsWithoutSubtitle = { ...defaultProps, diff --git a/packages/contact-center/cc-components/tests/components/task/CallControl/CallControlCustom/consult-transfer-popover.tsx b/packages/contact-center/cc-components/tests/components/task/CallControl/CallControlCustom/consult-transfer-popover.tsx index 72acb140b..8cf5865e3 100644 --- a/packages/contact-center/cc-components/tests/components/task/CallControl/CallControlCustom/consult-transfer-popover.tsx +++ b/packages/contact-center/cc-components/tests/components/task/CallControl/CallControlCustom/consult-transfer-popover.tsx @@ -2,11 +2,19 @@ import React from 'react'; import {render, fireEvent, waitFor, act} from '@testing-library/react'; import '@testing-library/jest-dom'; import ConsultTransferPopoverComponent from '../../../../../src/components/task/CallControl/CallControlCustom/consult-transfer-popover'; -import {AddressBookEntry, ContactServiceQueue, EntryPointRecord, TaskUIControls} from '@webex/cc-store'; -import {DEFAULT_PAGE_SIZE} from '../../../../../src/components/task/constants'; +import {AddressBookEntry, AISummaryEntry, ContactServiceQueue, EntryPointRecord, TaskUIControls} from '@webex/cc-store'; +import {DEFAULT_PAGE_SIZE, SEARCH_PLACEHOLDER} from '../../../../../src/components/task/constants'; +import {AI_ASSISTANT_SUMMARY_MESSAGES as AI_SUMMARY_MESSAGES} from '../../../../../src/components/AIAssistant/AISummary'; type AvailableDestinations = TaskUIControls['consultTransferDestinations']['consult']; +const getVoiceSearchInput = (container: HTMLElement) => + waitFor(() => { + const input = container.querySelector('mdc-searchfield')?.shadowRoot?.querySelector('input'); + expect(input).toBeInstanceOf(HTMLInputElement); + return input as HTMLInputElement; + }); + const loggerMock = { log: jest.fn(), info: jest.fn(), @@ -27,6 +35,16 @@ afterAll(() => { // This test suite was previously skipped but is now enabled for 100% coverage describe('ConsultTransferPopoverComponent', () => { + const createSummary = (overrides = {}) => ({ + state: 'content' as const, + content: {conversationId: 'interaction-main-1', summaryText: 'Customer needs billing support.'}, + selectedFeedback: 'none' as const, + onEdit: jest.fn().mockReturnValue(true), + onCopy: jest.fn().mockReturnValue(true), + onFeedback: jest.fn(), + ...overrides, + }); + const mockOnAgentSelect = jest.fn(); const mockOnQueueSelect = jest.fn(); const baseProps = { @@ -71,6 +89,365 @@ describe('ConsultTransferPopoverComponent', () => { jest.clearAllMocks(); }); + it('keeps Exclude summary controlled and disables only summary controls while allowing the call action', async () => { + const onExcludeChange = jest.fn().mockReturnValue(true); + const summary = createSummary({excluded: false, onExcludeChange}); + const view = render(); + const exclude = view.container.querySelector('.consult-transfer-actions__exclude') as HTMLElement & { + checked: boolean; + }; + expect(exclude).toBeInTheDocument(); + expect(exclude.checked).toBe(false); + fireEvent.change(exclude); + expect(onExcludeChange).toHaveBeenCalledWith(true); + expect(mockOnAgentSelect).not.toHaveBeenCalled(); + view.rerender( + + ); + expect(exclude.checked).toBe(true); + expect(view.getByTestId('consult-transfer:summary')).toHaveClass('consult-transfer-summary--excluded'); + expect(view.getByTestId('consult-transfer:summary')).toHaveAttribute('aria-disabled', 'true'); + const edit = await view.findByRole('button', {name: AI_SUMMARY_MESSAGES.editSummary}); + await waitFor(() => expect(edit).toBeDisabled()); + fireEvent.click(view.getByRole('button', {name: 'Select Agent One'})); + const submit = view.getByRole('button', {name: 'Consult'}); + await waitFor(() => expect(submit).not.toBeDisabled()); + fireEvent.click(submit); + expect(mockOnAgentSelect).toHaveBeenCalledWith('agent1', 'Agent One', false); + }); + + it('separates cancellation from the successful action close', async () => { + const onCancel = jest.fn(); + const onClose = jest.fn(); + const view = render( + + ); + fireEvent.click(await view.findByRole('button', {name: 'Cancel'})); + expect(onCancel).toHaveBeenCalledTimes(1); + expect(onClose).not.toHaveBeenCalled(); + fireEvent.click(view.getByRole('button', {name: 'Close popover'})); + expect(onCancel).toHaveBeenCalledTimes(1); + expect(onClose).toHaveBeenCalledTimes(1); + fireEvent.click(view.getByRole('button', {name: 'Select Agent One'})); + const submit = view.getByRole('button', {name: 'Consult'}); + await waitFor(() => expect(submit).not.toBeDisabled()); + fireEvent.click(submit); + expect(onClose).toHaveBeenCalledTimes(2); + expect(onCancel).toHaveBeenCalledTimes(1); + }); + + it('uses capability-filtered voice radios before search and keeps nonvoice pills', async () => { + const view = render( + + ); + const radios = view.getAllByRole('radio'); + expect(radios).toHaveLength(4); + expect(view.getByRole('radio', {name: 'Agent'})).toBeChecked(); + expect(view.getByRole('radio', {name: 'Dial Number'})).toBeInTheDocument(); + expect(view.getByRole('radio', {name: 'Entry Point'})).toBeInTheDocument(); + expect(view.container.querySelectorAll('ul.agent-list > li')).toHaveLength(2); + expect(view.container.querySelector('ul.agent-list > div')).not.toBeInTheDocument(); + expect(view.container.querySelector('.consult-list-container')).not.toHaveAttribute('tabindex'); + const group = view.getByRole('radiogroup', {name: AI_SUMMARY_MESSAGES.midCall.destinationCategory}); + const search = await getVoiceSearchInput(view.container); + expect( + group.compareDocumentPosition(view.container.querySelector('mdc-searchfield')!) & Node.DOCUMENT_POSITION_FOLLOWING + ).toBeTruthy(); + expect(search).toHaveAttribute('aria-label', AI_SUMMARY_MESSAGES.midCall.searchDestinations); + expect(search).toHaveAttribute('placeholder', 'Search agents'); + expect(view.container.querySelector('.consult-search-icon')).not.toBeInTheDocument(); + expect(view.getByTestId('consult-reload-button')).toBeInTheDocument(); + expect(view.queryByText('1001')).not.toBeInTheDocument(); + fireEvent.click(view.getByRole('radio', {name: 'Queue'})); + await waitFor(() => expect(view.getByText('Queue One')).toBeInTheDocument()); + expect(view.getByRole('radio', {name: 'Queue'})).toBeChecked(); + view.rerender( + + ); + expect(view.getAllByRole('radio')).toHaveLength(1); + expect(view.queryByText('Organization')).not.toBeInTheDocument(); + view.unmount(); + + const nonVoice = render(); + expect(nonVoice.queryByRole('radiogroup')).not.toBeInTheDocument(); + expect(nonVoice.getByRole('button', {name: 'Agents'})).toBeInTheDocument(); + expect(nonVoice.getByPlaceholderText(SEARCH_PLACEHOLDER)).toBeInTheDocument(); + expect(nonVoice.getByTestId('consult-reload-button')).toBeInTheDocument(); + expect(nonVoice.queryByText('1001')).not.toBeInTheDocument(); + }); + + it('uses native Momentum row navigation and Enter to stage a voice destination', async () => { + const view = render(); + const firstAgent = view.getByRole('listitem', {name: 'Agent One'}); + const secondAgent = view.getByRole('listitem', {name: 'Agent Two'}); + expect(firstAgent).toHaveAttribute('tabindex', '0'); + expect(secondAgent).toHaveAttribute('tabindex', '-1'); + act(() => firstAgent.focus()); + fireEvent.keyDown(firstAgent, {key: 'ArrowDown'}); + await waitFor(() => expect(secondAgent).toHaveFocus()); + fireEvent.keyDown(secondAgent, {key: 'Enter'}); + fireEvent.keyUp(secondAgent, {key: 'Enter'}); + expect(secondAgent).toHaveClass('active'); + expect(mockOnAgentSelect).not.toHaveBeenCalled(); + fireEvent.click(view.getByRole('button', {name: 'Consult'})); + expect(mockOnAgentSelect).toHaveBeenCalledWith('agent2', 'Agent Two', false); + }); + + it('stages a voice destination until Consult is pressed and keeps refresh and Cancel available', async () => { + const loadBuddyAgents = jest.fn().mockResolvedValue(undefined); + const onClose = jest.fn(); + const view = render( + + ); + const submit = await view.findByRole('button', {name: 'Consult'}); + const agent = view.getByRole('button', {name: 'Select Agent One'}); + await waitFor(() => expect(submit).toBeDisabled()); + fireEvent.click(agent); + expect(agent).toHaveAttribute('aria-pressed', 'true'); + expect(mockOnAgentSelect).not.toHaveBeenCalled(); + await waitFor(() => expect(submit).not.toBeDisabled()); + expect(view.getByRole('button', {name: 'Cancel'})).toBeInTheDocument(); + fireEvent.click(view.getByTestId('consult-reload-button')); + expect(loadBuddyAgents).toHaveBeenCalledWith('Consult'); + await waitFor(() => expect(submit).toBeDisabled()); + fireEvent.click(agent); + await waitFor(() => expect(submit).not.toBeDisabled()); + fireEvent.click(submit); + expect(mockOnAgentSelect).toHaveBeenCalledWith('agent1', 'Agent One', false); + expect(onClose).toHaveBeenCalledTimes(1); + expect(mockOnAgentSelect).toHaveBeenCalledTimes(1); + }); + + it('disables a dispatched voice action until the picker closes', async () => { + const onClose = jest.fn(); + const view = render( + + ); + fireEvent.click(view.getByRole('button', {name: 'Select Agent One'})); + const submit = await view.findByRole('button', {name: 'Consult'}); + await waitFor(() => expect(submit).not.toBeDisabled()); + fireEvent.click(submit); + await waitFor(() => expect(submit).toBeDisabled()); + fireEvent.click(submit); + expect(mockOnAgentSelect).toHaveBeenCalledTimes(1); + expect(onClose).toHaveBeenCalledTimes(1); + await waitFor(() => expect(submit).toBeDisabled()); + fireEvent.click(view.getByRole('button', {name: 'Select Agent Two'})); + fireEvent.click(submit); + expect(mockOnAgentSelect).toHaveBeenCalledTimes(1); + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it('keeps the picker retryable when destination dispatch throws synchronously', async () => { + const onClose = jest.fn(); + const onAgentSelect = jest.fn().mockImplementationOnce(() => { + throw new Error('Dispatch failed'); + }); + const view = render( + + ); + fireEvent.click(view.getByRole('button', {name: 'Select Agent One'})); + const submit = await view.findByRole('button', {name: 'Consult'}); + await waitFor(() => expect(submit).not.toBeDisabled()); + fireEvent.click(submit); + expect(onClose).not.toHaveBeenCalled(); + expect(loggerMock.error).toHaveBeenCalledWith( + 'CC-Components: Failed to submit consult/transfer destination', + expect.objectContaining({method: 'submitVoiceDestination'}) + ); + await waitFor(() => expect(submit).not.toBeDisabled()); + fireEvent.click(submit); + expect(onAgentSelect).toHaveBeenCalledTimes(2); + expect(onClose).toHaveBeenCalledTimes(1); + await waitFor(() => expect(submit).toBeDisabled()); + }); + + it('retains each voice category selection and submits only the active Transfer destination', async () => { + const view = render( + + ); + fireEvent.click(view.getByRole('button', {name: 'Select Agent One'})); + fireEvent.click(view.getByRole('radio', {name: 'Queue'})); + fireEvent.click(await view.findByRole('button', {name: 'Select Queue Two'})); + expect(mockOnAgentSelect).not.toHaveBeenCalled(); + expect(mockOnQueueSelect).not.toHaveBeenCalled(); + fireEvent.click(view.getByRole('radio', {name: 'Agent'})); + expect(view.getByRole('button', {name: 'Select Agent One'})).toHaveAttribute('aria-pressed', 'true'); + fireEvent.click(view.getByRole('radio', {name: 'Queue'})); + const selectedQueue = await view.findByRole('button', {name: 'Select Queue Two'}); + expect(selectedQueue).toHaveAttribute('aria-pressed', 'true'); + fireEvent.click(view.getByRole('button', {name: 'Transfer'})); + expect(mockOnQueueSelect).toHaveBeenCalledWith('queue2', 'Queue Two', false); + expect(mockOnAgentSelect).not.toHaveBeenCalled(); + }); + + it('submits typed dial numbers through the voice footer and clears stale selection when searching', async () => { + const view = render( + + ); + const submit = await view.findByRole('button', {name: 'Consult'}); + fireEvent.click(view.getByRole('button', {name: 'Select Agent One'})); + const search = await getVoiceSearchInput(view.container); + fireEvent.input(search, {target: {value: 'Other'}, composed: true}); + await waitFor(() => expect(submit).toBeDisabled()); + fireEvent.click(view.getByRole('radio', {name: 'Dial Number'})); + fireEvent.input(search, {target: {value: '1234'}, composed: true}); + expect(view.queryByTestId('consult-quick-action:consult')).not.toBeInTheDocument(); + expect(baseProps.onDialNumberSelect).not.toHaveBeenCalled(); + await waitFor(() => expect(submit).not.toBeDisabled()); + fireEvent.input(search, {target: {value: 'invalid'}, composed: true}); + await waitFor(() => expect(submit).toBeDisabled()); + fireEvent.input(search, {target: {value: '1234'}, composed: true}); + await waitFor(() => expect(submit).not.toBeDisabled()); + fireEvent.click(submit); + expect(baseProps.onDialNumberSelect).toHaveBeenCalledWith('1234', false); + }); + + it('uses the native search clear action to reset the query and invalidate the staged destination', async () => { + const view = render( + + ); + const search = await getVoiceSearchInput(view.container); + fireEvent.input(search, {target: {value: 'Agent'}, composed: true}); + const agent = view.getByRole('button', {name: 'Select Agent One'}); + fireEvent.click(agent); + const submit = await view.findByRole('button', {name: 'Consult'}); + await waitFor(() => expect(submit).not.toBeDisabled()); + const searchfield = view.container.querySelector('mdc-searchfield')!; + const clear = await waitFor(() => { + const button = searchfield.shadowRoot?.querySelector('mdc-button[part="trailing-button"]'); + expect(button).toBeInTheDocument(); + return button!; + }); + fireEvent.click(clear); + await waitFor(() => expect(search).toHaveValue('')); + expect(searchfield.shadowRoot?.activeElement).toBe(search); + expect(agent).toHaveAttribute('aria-pressed', 'false'); + expect(submit).toBeDisabled(); + expect(mockOnAgentSelect).not.toHaveBeenCalled(); + }); + + it('disables the voice action when a selected destination disappears or its category is revoked', async () => { + const view = render(); + fireEvent.click(view.getByRole('button', {name: 'Select Agent One'})); + const submit = await view.findByRole('button', {name: 'Consult'}); + await waitFor(() => expect(submit).not.toBeDisabled()); + view.rerender( + + ); + await waitFor(() => expect(submit).toBeDisabled()); + fireEvent.click(submit); + expect(mockOnAgentSelect).not.toHaveBeenCalled(); + view.rerender( + + ); + await waitFor(() => expect(submit).toBeDisabled()); + fireEvent.click(submit); + expect(mockOnAgentSelect).not.toHaveBeenCalled(); + }); + + it('retains the staged voice layout when eligibility is revoked before summary content arrives', async () => { + const view = render( + + ); + const selectedAgent = view.getByRole('button', {name: 'Select Agent One'}); + fireEvent.click(selectedAgent); + const submit = await view.findByRole('button', {name: 'Consult'}); + await waitFor(() => expect(submit).not.toBeDisabled()); + view.rerender(); + expect(view.getByRole('radio', {name: 'Agent'})).toBeInTheDocument(); + expect(view.getByRole('button', {name: 'Select Agent One'})).toBe(selectedAgent); + expect(submit).toBeInTheDocument(); + fireEvent.click(submit); + expect(mockOnAgentSelect).toHaveBeenCalledWith('agent1', 'Agent One', false); + }); + + it.each(['Dial Number', 'Entry Point'])('stages %s selection until the voice footer action', async (category) => { + const view = render( + ({ + data: [{id: 'dn1', name: 'Dial destination', number: '12345'} as AddressBookEntry], + meta: {page: 0, totalPages: 1}, + })} + getEntryPoints={async () => ({ + data: [{id: 'ep1', name: 'Entry destination', number: '67890'} as EntryPointRecord], + meta: {page: 0, totalPages: 1}, + })} + /> + ); + fireEvent.click(view.getByRole('radio', {name: category})); + const destination = category === 'Dial Number' ? 'Dial destination' : 'Entry destination'; + fireEvent.click(await view.findByRole('button', {name: `Select ${destination}`})); + expect(baseProps.onDialNumberSelect).not.toHaveBeenCalled(); + expect(baseProps.onEntryPointSelect).not.toHaveBeenCalled(); + const submit = await view.findByRole('button', {name: 'Consult'}); + await waitFor(() => expect(submit).not.toBeDisabled()); + fireEvent.click(submit); + if (category === 'Dial Number') { + expect(baseProps.onDialNumberSelect).toHaveBeenCalledWith('12345', false); + } else { + expect(baseProps.onEntryPointSelect).toHaveBeenCalledWith('ep1', 'Entry destination', false); + } + }); + + it.each([ + ['Consult' as const, 'CONSULT'], + ['Transfer' as const, 'TRANSFER'], + ])('retries a failed %s summary with the currently open action', async (action, summaryAction) => { + const onSummaryRequest = jest.fn().mockResolvedValue(undefined); + const view = render( + + ); + fireEvent.click(await view.findByRole('button', {name: AI_SUMMARY_MESSAGES.retry})); + expect(onSummaryRequest).toHaveBeenCalledWith(summaryAction); + expect(mockOnAgentSelect).not.toHaveBeenCalled(); + expect(mockOnQueueSelect).not.toHaveBeenCalled(); + }); + it('renders heading and tabs when showTabs is true', async () => { const screen = await render(); @@ -346,7 +723,7 @@ describe('ConsultTransferPopoverComponent', () => { // Switch to Queues category fireEvent.click(screen.getByText('Queues')); - const input = screen.getByPlaceholderText('Search...') as HTMLInputElement; + const input = screen.getByPlaceholderText(SEARCH_PLACEHOLDER) as HTMLInputElement; fireEvent.change(input, {target: {value: 'q'}}); await act(async () => { @@ -377,7 +754,7 @@ describe('ConsultTransferPopoverComponent', () => { const screen = await render(); // Default category is Agents - const input = screen.getByPlaceholderText('Search...') as HTMLInputElement; + const input = screen.getByPlaceholderText(SEARCH_PLACEHOLDER) as HTMLInputElement; fireEvent.change(input, {target: {value: 'ab'}}); await act(async () => { jest.advanceTimersByTime(500); @@ -607,7 +984,7 @@ describe('ConsultTransferPopoverComponent', () => { }); // Enter search query - const input = screen.getByPlaceholderText('Search...') as HTMLInputElement; + const input = screen.getByPlaceholderText(SEARCH_PLACEHOLDER) as HTMLInputElement; fireEvent.change(input, {target: {value: 'test query'}}); await act(async () => { @@ -638,4 +1015,260 @@ describe('ConsultTransferPopoverComponent', () => { }); }); }); + + describe('AI summary subtree', () => { + it('renders the summary after the destination results without replacing the single search/results tree', async () => { + const screen = render( + + ); + + expect(screen.getByPlaceholderText(SEARCH_PLACEHOLDER)).toBeInTheDocument(); + expect(screen.container.querySelectorAll('.consult-list-container')).toHaveLength(1); + expect(screen.container.querySelectorAll('.agent-list')).toHaveLength(1); + expect(screen.getByTestId('consult-transfer:summary')).toHaveTextContent('Customer needs billing support.'); + expect(screen.queryByDisplayValue('Customer needs billing support.')).not.toBeInTheDocument(); + }); + + it('preserves the open destination tree, query, selected category and focus when the summary arrives and is revoked', async () => { + const view = render( + + ); + const queueRadio = view.getByRole('radio', {name: 'Queue'}); + fireEvent.click(queueRadio); + await waitFor(() => expect(view.getByText('Queue One')).toBeInTheDocument()); + + const search = await getVoiceSearchInput(view.container); + fireEvent.input(search, {target: {value: 'qu'}, composed: true}); + const results = view.container.querySelector('.consult-list-container') as HTMLElement; + const queueAction = view.getByRole('button', {name: 'Select Queue One'}); + act(() => { + queueAction.focus(); + }); + + expect(queueRadio).toBeChecked(); + expect(search).toHaveValue('qu'); + expect(queueAction).toHaveFocus(); + + const summary = createSummary(); + view.rerender(); + + expect(view.getByTestId('consult-transfer:summary')).toBeInTheDocument(); + expect(view.getByRole('radio', {name: 'Queue'})).toBe(queueRadio); + expect(await getVoiceSearchInput(view.container)).toBe(search); + expect(view.container.querySelector('.consult-list-container')).toBe(results); + expect(view.getByRole('button', {name: 'Select Queue One'})).toBe(queueAction); + expect(search).toHaveValue('qu'); + expect(queueRadio).toBeChecked(); + expect(queueAction).toHaveFocus(); + + view.rerender(); + + expect(view.queryByTestId('consult-transfer:summary')).not.toBeInTheDocument(); + expect(view.getByRole('radio', {name: 'Queue'})).toBe(queueRadio); + expect(await getVoiceSearchInput(view.container)).toBe(search); + expect(view.container.querySelector('.consult-list-container')).toBe(results); + expect(view.getByRole('button', {name: 'Select Queue One'})).toBe(queueAction); + expect(search).toHaveValue('qu'); + expect(queueRadio).toBeChecked(); + expect(queueAction).toHaveFocus(); + }); + + it('renders filtered voice-radio and non-voice-pill destinations with the same selected results', async () => { + const filterToAgentsAndQueues = {showDialNumberTab: false, showEntryPointTab: false}; + const renderTitles = (container: HTMLElement) => + Array.from(container.querySelectorAll('.call-control-list-item-title')).map((node) => node.textContent); + + const voice = render( + + ); + fireEvent.click(voice.getByRole('radio', {name: 'Queue'})); + await waitFor(() => expect(voice.getByText('Queue One')).toBeInTheDocument()); + const voiceTitles = renderTitles(voice.container); + + expect(voice.queryByRole('radio', {name: 'Dial Number'})).not.toBeInTheDocument(); + expect(voice.queryByRole('radio', {name: 'Entry Point'})).not.toBeInTheDocument(); + expect(voiceTitles).toEqual(['Queue One', 'Queue Two']); + voice.unmount(); + + const nonVoice = render( + + ); + fireEvent.click(nonVoice.getByRole('button', {name: 'Queues'})); + await waitFor(() => expect(nonVoice.getByText('Queue One')).toBeInTheDocument()); + + expect(nonVoice.queryByRole('button', {name: 'Dial Number'})).not.toBeInTheDocument(); + expect(nonVoice.queryByRole('button', {name: 'Entry Point'})).not.toBeInTheDocument(); + expect(renderTitles(nonVoice.container)).toEqual(voiceTitles); + }); + + it('should let the agent select and submit destinations while a refreshed summary is generating', async () => { + const summary = createSummary({controlsDisabled: true}); + const view = render( + + ); + const firstAgentButton = view.container.querySelector( + 'button[aria-label="Select Agent One"]' + ) as HTMLButtonElement; + + expect(firstAgentButton).not.toHaveAttribute('aria-disabled', 'true'); + fireEvent.click(firstAgentButton); + const submit = await view.findByRole('button', {name: 'Consult'}); + await waitFor(() => expect(submit).not.toBeDisabled()); + fireEvent.click(submit); + expect(mockOnAgentSelect).toHaveBeenCalledWith('agent1', 'Agent One', false); + view.unmount(); + const reopened = render( + + ); + fireEvent.click(reopened.getByRole('radio', {name: 'Dial Number'})); + const input = await getVoiceSearchInput(reopened.container); + fireEvent.input(input, {target: {value: '1234'}, composed: true}); + + expect(reopened.queryByTestId('consult-quick-action:consult')).not.toBeInTheDocument(); + const reopenedSubmit = await reopened.findByRole('button', {name: 'Consult'}); + await waitFor(() => expect(reopenedSubmit).not.toBeDisabled()); + fireEvent.click(reopenedSubmit); + + expect(baseProps.onDialNumberSelect).toHaveBeenCalled(); + }); + + it('uses the provisional transfer heading only for transfer preparations', () => { + const screen = render( + + ); + + expect(screen.getByText(AI_SUMMARY_MESSAGES.midCall.transferHeading)).toBeInTheDocument(); + expect(screen.getByText(AI_SUMMARY_MESSAGES.unavailable)).toBeInTheDocument(); + }); + + it('keeps the destination panel labelled when the summary subtree is removed', () => { + const summary = { + state: 'content' as const, + content: {conversationId: 'interaction-main-1', summaryText: 'Customer needs billing support.'}, + selectedFeedback: 'none' as const, + onEdit: jest.fn().mockReturnValue(true), + onCopy: jest.fn().mockReturnValue(true), + onFeedback: jest.fn(), + }; + const view = render(); + const popover = view.container.querySelector('.agent-popover-content') as HTMLElement; + + expect(view.getByText('Consult')).toBeInTheDocument(); + expect(popover).toHaveAttribute('aria-label', 'Consult'); + expect(popover).not.toHaveAttribute('tabindex'); + + view.rerender(); + + expect(view.container.querySelector('.agent-popover-content')).toBe(popover); + expect(popover).toHaveAttribute('aria-label', 'Consult'); + expect(view.queryByTestId('consult-transfer:summary')).not.toBeInTheDocument(); + }); + + it('retains the Edit control and its focus when summary content is replaced', async () => { + const firstContent: NonNullable = { + conversationId: 'interaction-main-1', + sections: { + initialContactReason: 'Summary value', + nextSteps: 'Follow-up value', + }, + }; + const followUpOnlyContent: NonNullable = { + conversationId: 'interaction-main-1', + sections: {nextSteps: 'Follow-up value'}, + }; + const view = render( + + ); + const summaryPreview = await view.findByRole('button', { + name: AI_SUMMARY_MESSAGES.editSummary, + }); + act(() => { + summaryPreview.focus(); + }); + expect(summaryPreview).toHaveFocus(); + + view.rerender( + + ); + + await waitFor(() => + expect( + view.getByRole('button', { + name: AI_SUMMARY_MESSAGES.editSummary, + }) + ).toHaveFocus() + ); + + expect(view.getByRole('button', {name: AI_SUMMARY_MESSAGES.editSummary})).toBe(summaryPreview); + }); + + it('preserves focus on a retained destination control when the summary is removed', async () => { + const view = render( + + ); + const copy = await view.findByRole('button', {name: AI_SUMMARY_MESSAGES.copySummary}); + const destination = view.getByRole('button', {name: 'Select Agent One'}); + act(() => { + copy.focus(); + }); + expect(copy).toHaveFocus(); + act(() => { + destination.focus(); + }); + expect(destination).toHaveFocus(); + + view.rerender(); + + expect(view.getByRole('button', {name: 'Select Agent One'})).toBe(destination); + expect(destination).toHaveFocus(); + expect(view.queryByTestId('consult-transfer:summary')).not.toBeInTheDocument(); + }); + }); }); diff --git a/packages/contact-center/cc-components/tests/components/task/CallControl/CallControlCustom/wrap-up-summary.tsx b/packages/contact-center/cc-components/tests/components/task/CallControl/CallControlCustom/wrap-up-summary.tsx new file mode 100644 index 000000000..27e0720f6 --- /dev/null +++ b/packages/contact-center/cc-components/tests/components/task/CallControl/CallControlCustom/wrap-up-summary.tsx @@ -0,0 +1,425 @@ +import fs from 'fs'; +import path from 'path'; +import {compileString} from 'sass'; +import React from 'react'; +import {act, fireEvent, render, screen, waitFor} from '@testing-library/react'; +import '@testing-library/jest-dom'; +import WrapUpSummary from '../../../../../src/components/task/CallControl/CallControlCustom/wrap-up-summary'; +import {WrapUpSummaryView} from '../../../../../src/components/task/task.types'; +import {AI_ASSISTANT_SUMMARY_MESSAGES as AI_SUMMARY_MESSAGES} from '../../../../../src/components/AIAssistant/AISummary'; + +const reasons = [ + {id: 'aux-1', name: 'Resolved'}, + {id: 'aux-2', name: 'Follow up needed'}, + {id: 'aux-3', name: 'Billing question'}, +]; + +const COMPLETE_WRAP_UP_LABEL = 'Submit Wrap Up'; +const REASON_GROUP_NAME = AI_SUMMARY_MESSAGES.postCall.chooseReason; + +const createSummary = (overrides: Partial = {}): WrapUpSummaryView => ({ + state: 'content', + content: { + conversationId: 'interaction-main-1', + sections: { + initialContactReason: 'Customer asked about billing.', + keyActionsTaken: 'Send invoice.', + }, + resolution: 'Issue resolved', + }, + selectedFeedback: 'none', + controlsDisabled: false, + onEdit: jest.fn().mockReturnValue(true), + onCopy: jest.fn().mockReturnValue(true), + onFeedback: jest.fn().mockReturnValue(true), + onRetry: jest.fn().mockResolvedValue(undefined), + ...overrides, +}); + +const changeReason = (value: string, label?: string) => { + const picker = screen.getByTestId('wrap-up-summary:reason-picker'); + fireEvent(picker, new CustomEvent('change', {detail: {value, label}, bubbles: true, composed: true})); +}; + +const installWrapUpSummaryStyles = (): (() => void) => { + const style = document.createElement('style'); + const source = fs + .readFileSync( + path.resolve( + __dirname, + '../../../../../src/components/task/CallControl/CallControlCustom/wrap-up-summary.styles.scss' + ), + 'utf8' + ) + .replace(/^\s*\/\/.*$/gm, ''); + style.textContent = compileString(source).css; + document.head.appendChild(style); + return () => style.remove(); +}; + +describe('WrapUpSummary', () => { + beforeEach(() => { + Object.defineProperty(navigator, 'clipboard', { + configurable: true, + value: {writeText: jest.fn().mockResolvedValue(undefined)}, + }); + }); + + it('uses the native searchable picker with compact wrap-up copy and no radio list', async () => { + render( + + ); + expect(screen.getByText('Wrap Up Reasons')).toBeInTheDocument(); + const picker = screen.getByTestId('wrap-up-summary:reason-picker'); + expect(picker.tagName.toLowerCase()).toBe('mdc-combobox'); + expect((picker as HTMLElement & {placeholder: string}).placeholder).toBe('Search topic'); + expect(picker).toHaveAttribute('data-aria-label', REASON_GROUP_NAME); + await waitFor(() => { + const label = picker.shadowRoot?.querySelector('label'); + const input = picker.shadowRoot?.querySelector('input[role="combobox"]'); + expect(label).toHaveTextContent(REASON_GROUP_NAME); + expect(input).toHaveAttribute('aria-labelledby', label?.id); + expect(picker.shadowRoot?.getElementById(label?.htmlFor ?? '')).toBeInstanceOf(HTMLInputElement); + }); + expect(picker.querySelectorAll('mdc-selectlistbox > mdc-option')).toHaveLength(3); + expect(screen.queryByRole('radiogroup')).not.toBeInTheDocument(); + expect(await screen.findByRole('button', {name: COMPLETE_WRAP_UP_LABEL})).toBeDisabled(); + expect(screen.getByText(AI_SUMMARY_MESSAGES.postCall.instructions)).toBeInTheDocument(); + }); + + it('clears selection when typing and accepts only configured IDs from picker events', async () => { + const onComplete = jest.fn(); + const onReasonChange = jest.fn(); + const Harness = () => { + const [selectedReasonId, setSelectedReasonId] = React.useState('aux-1'); + return ( + { + onReasonChange(reason); + setSelectedReasonId(reason.id); + }} + onComplete={onComplete} + /> + ); + }; + render(); + const complete = await screen.findByRole('button', {name: COMPLETE_WRAP_UP_LABEL}); + await waitFor(() => expect(complete).not.toBeDisabled()); + fireEvent( + screen.getByTestId('wrap-up-summary:reason-picker'), + new CustomEvent('input', {detail: {value: 'Billing'}, bubbles: true, composed: true}) + ); + expect(onReasonChange).toHaveBeenLastCalledWith({id: '', name: ''}); + await waitFor(() => expect(complete).toBeDisabled()); + changeReason('not-configured', 'Resolved'); + expect(complete).toBeDisabled(); + changeReason('aux-3', 'Untrusted event label'); + expect(onReasonChange).toHaveBeenLastCalledWith(reasons[2]); + await waitFor(() => expect(complete).not.toBeDisabled()); + fireEvent.click(complete); + expect(onComplete).toHaveBeenCalledWith(reasons[2]); + }); + + it('selects SDK suggested chips by known code, hides them after selection, and does not submit immediately', async () => { + const onComplete = jest.fn(); + const onReasonChange = jest.fn(); + const suggestionSummary = createSummary({ + content: { + ...createSummary().content, + suggestedWrapUpCodes: [{name: 'Billing question'}, {name: 'Unknown reason'}, {name: 'Billing question'}], + }, + }); + const Harness = () => { + const [selectedReasonId, setSelectedReasonId] = React.useState(); + return ( + { + onReasonChange(reason); + setSelectedReasonId(reason.id); + }} + onComplete={onComplete} + /> + ); + }; + render(); + const chips = await screen.findByTestId('wrap-up-summary:suggestions'); + expect(chips.querySelectorAll('mdc-filterchip')).toHaveLength(1); + const chip = chips.querySelector('mdc-filterchip') as HTMLElement; + fireEvent.click(chip); + expect(onReasonChange).toHaveBeenCalledWith(reasons[2]); + expect(onComplete).not.toHaveBeenCalled(); + expect(screen.queryByTestId('wrap-up-summary:suggestions')).not.toBeInTheDocument(); + const complete = await screen.findByRole('button', {name: COMPLETE_WRAP_UP_LABEL}); + await waitFor(() => expect(complete).not.toBeDisabled()); + fireEvent.click(complete); + expect(onComplete).toHaveBeenCalledWith(reasons[2]); + }); + + it('does not offer unknown IDs or ambiguous names as suggested actions', () => { + render( + + ); + expect(screen.queryByTestId('wrap-up-summary:suggestions')).not.toBeInTheDocument(); + expect(screen.getByText('No suitable suggestion is available.')).toBeInTheDocument(); + }); + + it('does not clear a picker selection when Momentum emits its preceding input event', () => { + const onReasonChange = jest.fn(); + render(); + fireEvent( + screen.getByTestId('wrap-up-summary:reason-picker'), + new CustomEvent('input', {detail: {value: 'aux-2', label: 'Follow up needed'}, bubbles: true, composed: true}) + ); + expect(onReasonChange).not.toHaveBeenCalled(); + changeReason('aux-2', 'Follow up needed'); + expect(onReasonChange).toHaveBeenCalledWith(reasons[1]); + }); + + it('retains the first typed search character after a selected reason and blocks stale submission', async () => { + const onComplete = jest.fn(); + const Harness = () => { + const [selectedReasonId, setSelectedReasonId] = React.useState('aux-1'); + return ( + setSelectedReasonId(reason.id)} + onComplete={onComplete} + /> + ); + }; + render(); + const picker = screen.getByTestId('wrap-up-summary:reason-picker'); + const input = await waitFor(() => { + const nativeInput = picker.shadowRoot?.querySelector('input[role="combobox"]'); + expect(nativeInput).toHaveValue('Resolved'); + return nativeInput as HTMLInputElement; + }); + fireEvent.input(input, {target: {value: 'B'}, composed: true}); + await waitFor(() => expect(input).toHaveValue('B')); + await act(async () => { + await Promise.resolve(); + }); + expect(input).toHaveValue('B'); + const complete = await screen.findByRole('button', {name: COMPLETE_WRAP_UP_LABEL}); + expect(complete).toBeDisabled(); + fireEvent.click(complete); + expect(onComplete).not.toHaveBeenCalled(); + }); + + it('projects controlled reason changes into the native picker outside a search', async () => { + const renderPanel = (selectedReasonId?: string) => ( + + ); + const {rerender} = render(renderPanel('aux-1')); + const picker = screen.getByTestId('wrap-up-summary:reason-picker'); + const input = await waitFor(() => { + const nativeInput = picker.shadowRoot?.querySelector('input[role="combobox"]'); + expect(nativeInput).toHaveValue('Resolved'); + return nativeInput as HTMLInputElement; + }); + + rerender(renderPanel('aux-2')); + await waitFor(() => expect(input).toHaveValue('Follow up needed')); + + rerender(renderPanel()); + await waitFor(() => expect(input).toHaveValue('')); + expect(await screen.findByRole('button', {name: COMPLETE_WRAP_UP_LABEL})).toBeDisabled(); + }); + + it('lets Momentum handle keyboard reason selection using the configured option ID', async () => { + const onReasonChange = jest.fn(); + render(); + const picker = screen.getByTestId('wrap-up-summary:reason-picker'); + const input = await waitFor(() => { + const nativeInput = picker.shadowRoot?.querySelector('input[role="combobox"]'); + expect(nativeInput).toBeInstanceOf(HTMLInputElement); + expect(picker.querySelector('mdc-option')).toHaveAttribute('aria-selected', 'false'); + return nativeInput as HTMLInputElement; + }); + fireEvent.keyDown(input, {key: 'ArrowDown', code: 'ArrowDown'}); + fireEvent.keyDown(input, {key: 'Enter', code: 'Enter'}); + await waitFor(() => expect(onReasonChange).toHaveBeenCalledWith(reasons[0])); + }); + + it('should not hold back Complete Wrap-Up while the summary is generating', async () => { + const onComplete = jest.fn(); + render( + + ); + + fireEvent.click(await screen.findByRole('button', {name: COMPLETE_WRAP_UP_LABEL})); + expect(onComplete).toHaveBeenCalledWith({id: 'aux-1', name: 'Resolved'}); + }); + + it('keeps a retained draft completable during regeneration without rendering an absent Outcome', async () => { + const onComplete = jest.fn(); + const retainedDraft = createSummary({ + controlsDisabled: true, + content: { + conversationId: 'interaction-main-1', + sections: { + initialContactReason: 'Edited summary survives.', + nextSteps: 'Call back tomorrow.', + }, + resolution: undefined, + }, + }); + render( + + ); + + expect(screen.queryByText(AI_SUMMARY_MESSAGES.sectionLabels.resolution)).not.toBeInTheDocument(); + expect(screen.getByText('Edited summary survives.')).toBeInTheDocument(); + const complete = await screen.findByRole('button', {name: COMPLETE_WRAP_UP_LABEL}); + expect(complete).not.toBeDisabled(); + + fireEvent.click(complete); + expect(onComplete).toHaveBeenCalledWith({id: 'aux-1', name: 'Resolved'}); + }); + + it('projects the selected native feedback control from the shared summary component', async () => { + render( + + ); + + expect(await screen.findByRole('button', {name: AI_SUMMARY_MESSAGES.like})).toHaveAttribute('aria-pressed', 'true'); + }); + + it('keeps reason selection and wrap-up available when summary controls are disabled', async () => { + const onReasonChange = jest.fn(); + const onComplete = jest.fn(); + render( + + ); + + expect(await screen.findByRole('button', {name: AI_SUMMARY_MESSAGES.editSummary})).toBeDisabled(); + expect(screen.getByTestId('wrap-up-summary:reason-picker')).not.toHaveAttribute('disabled'); + const complete = await screen.findByRole('button', {name: COMPLETE_WRAP_UP_LABEL}); + expect(complete).not.toBeDisabled(); + fireEvent.click(complete); + expect(onComplete).toHaveBeenCalledWith(reasons[0]); + changeReason('aux-2'); + expect(onReasonChange).toHaveBeenCalledWith(reasons[1]); + }); + + it('preserves the native reason picker and selected reason when the summary is removed', async () => { + const renderPanel = (showSummary: boolean) => ( + + ); + const {rerender} = render(renderPanel(true)); + const picker = screen.getByTestId('wrap-up-summary:reason-picker'); + const input = await waitFor(() => { + const nativeInput = picker.shadowRoot?.querySelector('input[role="combobox"]'); + expect(nativeInput).toHaveValue('Resolved'); + return nativeInput as HTMLInputElement; + }); + + rerender(renderPanel(false)); + + expect(screen.getByTestId('wrap-up-summary:reason-picker')).toBe(picker); + expect(input).toHaveValue('Resolved'); + expect(await screen.findByRole('button', {name: COMPLETE_WRAP_UP_LABEL})).not.toBeDisabled(); + expect(screen.queryByTestId('wrap-up-summary:body')).not.toBeInTheDocument(); + }); + + it('bounds panel and summary scrolling while keeping Submit Wrap Up outside both scroll regions', async () => { + const removeStyles = installWrapUpSummaryStyles(); + try { + const manyReasons = Array.from({length: 12}, (_, index) => ({ + id: `aux-${index + 1}`, + name: `Detailed wrap-up reason ${index + 1}`, + })); + render( +
+ +
+ ); + + const panel = screen.getByTestId('wrap-up-summary'); + const panelStyle = getComputedStyle(panel); + const scrollOwners = [panel, ...Array.from(panel.querySelectorAll('*'))].filter((element) => { + const overflowY = getComputedStyle(element).overflowY; + return overflowY === 'auto' || overflowY === 'scroll'; + }); + + expect(panelStyle.maxBlockSize).toBe('80vh'); + expect(scrollOwners).toEqual([ + screen.getByTestId('wrap-up-summary:scroll-content'), + screen.getByTestId('ai-summary:content'), + ]); + const submit = await screen.findByRole('button', {name: COMPLETE_WRAP_UP_LABEL}); + expect(panel).toContainElement(submit); + expect(getComputedStyle(submit).alignSelf).toBe('flex-end'); + expect(getComputedStyle(submit).flexShrink).toBe('0'); + scrollOwners.forEach((scrollOwner) => expect(scrollOwner).not.toContainElement(submit)); + } finally { + removeStyles(); + } + }); +}); diff --git a/packages/contact-center/cc-components/tests/components/task/CallControl/call-control.tsx b/packages/contact-center/cc-components/tests/components/task/CallControl/call-control.tsx index 944880c70..e68342d20 100644 --- a/packages/contact-center/cc-components/tests/components/task/CallControl/call-control.tsx +++ b/packages/contact-center/cc-components/tests/components/task/CallControl/call-control.tsx @@ -1,5 +1,6 @@ import React from 'react'; -import {render, fireEvent} from '@testing-library/react'; +import {act, render, fireEvent, waitFor, within} from '@testing-library/react'; +import type {RenderResult} from '@testing-library/react'; import '@testing-library/jest-dom'; import CallControlComponent from '../../../../src/components/task/CallControl/call-control'; import {CallControlComponentProps, CallControlMenuType, TARGET_TYPE} from '../../../../src/components/task/task.types'; @@ -41,6 +42,24 @@ describe('CallControlComponent', () => { {id: 'wrap2', name: 'Technical Support', isSystem: false}, ]; + const selectSummaryWrapupReason = async (view: RenderResult, name: string) => { + const reason = mockWrapupCodes.find((candidate) => candidate.name === name); + if (!reason) throw new Error(`Missing test wrap-up reason: ${name}`); + const panel = await view.findByTestId('wrap-up-summary'); + const picker = panel.querySelector('mdc-combobox'); + if (!picker) throw new Error('Missing Momentum wrap-up reason picker'); + fireEvent( + picker, + new CustomEvent('change', { + detail: {value: reason.id, label: reason.name}, + bubbles: true, + composed: true, + }) + ); + await waitFor(() => expect(picker.value).toBe(reason.id)); + return picker; + }; + const mockBuddyAgents = [ { agentId: 'agent1', @@ -64,6 +83,37 @@ describe('CallControlComponent', () => { const mockControls = createEnabledMainTaskUIControls(); + const deferred = () => { + let resolve!: (value: T) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return {promise, resolve, reject}; + }; + + const createMidCallSummary = () => ({ + state: 'content' as const, + content: {conversationId: 'interaction-main-1', summaryText: 'Customer needs billing support.'}, + selectedFeedback: 'none' as const, + onEdit: jest.fn(), + onCopy: jest.fn().mockReturnValue(true), + onFeedback: jest.fn().mockResolvedValue({outcome: 'confirmed'}), + }); + + const createPostCallSummary = (overrides = {}) => ({ + state: 'generating' as const, + content: {conversationId: 'interaction-main-1', summaryText: ''}, + selectedFeedback: 'none' as const, + controlsDisabled: true, + onEdit: jest.fn().mockReturnValue(false), + onCopy: jest.fn().mockReturnValue(false), + onFeedback: jest.fn().mockReturnValue(false), + onRetry: jest.fn().mockResolvedValue({outcome: 'blocked'}), + ...overrides, + }); + const defaultProps: CallControlComponentProps = { currentTask: mockCurrentTask, wrapupCodes: mockWrapupCodes, @@ -218,6 +268,228 @@ describe('CallControlComponent', () => { expect(wrapupButton).toHaveAttribute('type', 'button'); expect(wrapupButton).toHaveTextContent('Wrap up'); }); + + it('uses an anchored Momentum popover for summary wrap-up', async () => { + const requestPostCallSummary = jest.fn(); + const screen = render( + + ); + + const trigger = screen.getByTestId('call-control:wrapup-button'); + fireEvent.click(trigger); + const panel = await screen.findByTestId('wrap-up-summary'); + expect(panel.closest('.wrapup-popover')).toHaveAttribute('role', 'dialog'); + expect(screen.queryByTestId('call-control:wrapup-dialog')).not.toBeInTheDocument(); + expect(trigger).toHaveAttribute('aria-expanded', 'true'); + expect(requestPostCallSummary).toHaveBeenCalledTimes(1); + fireEvent.click(trigger); + await waitFor(() => expect(trigger).toHaveAttribute('aria-expanded', 'false')); + expect(requestPostCallSummary).toHaveBeenCalledTimes(1); + }); + + it('keeps Submit Wrap Up available while the post-call summary is generating', async () => { + const wrapupCall = jest.fn().mockResolvedValue(true); + const screen = render( + + ); + + fireEvent.click(screen.getByTestId('call-control:wrapup-button')); + await selectSummaryWrapupReason(screen, 'Customer Issue'); + + screen.rerender( + + ); + fireEvent.click(await screen.findByRole('button', {name: 'Submit Wrap Up'})); + + await waitFor(() => expect(wrapupCall).toHaveBeenCalledWith('Customer Issue', 'wrap1')); + }); + + it('renders the eligible post-call wrap-up branch even before summary content exists', async () => { + const screen = render( + + ); + + fireEvent.click(screen.getByTestId('call-control:wrapup-button')); + + expect(await screen.findByTestId('wrap-up-summary')).toBeInTheDocument(); + expect(screen.queryByTestId('call-control:wrapup-select')).not.toBeInTheDocument(); + }); + + it('should request the post-call summary when the wrap-up popover opens, not when a reason is selected', async () => { + const requestPostCallSummary = jest.fn(); + const screen = render( + + ); + expect(requestPostCallSummary).not.toHaveBeenCalled(); + + fireEvent.click(screen.getByTestId('call-control:wrapup-button')); + await screen.findByTestId('wrap-up-summary'); + expect(requestPostCallSummary).toHaveBeenCalledTimes(1); + + await selectSummaryWrapupReason(screen, 'Customer Issue'); + expect(requestPostCallSummary).toHaveBeenCalledTimes(1); + }); + + it('transitions post-call wrap-up branches in both directions while retaining the selected draft', async () => { + const wrapupCall = jest.fn().mockResolvedValue(true); + const retainedPostCallSummary = createPostCallSummary({ + state: 'content', + content: {conversationId: 'interaction-main-1', summaryText: 'Edited draft kept by store.'}, + controlsDisabled: false, + }); + const screen = render( + + ); + + fireEvent.click(screen.getByTestId('call-control:wrapup-button')); + expect(await screen.findByTestId('call-control:wrapup-select')).toBeInTheDocument(); + + screen.rerender( + + ); + expect(await screen.findByTestId('wrap-up-summary')).toBeInTheDocument(); + expect(screen.getByText('Edited draft kept by store.')).toBeInTheDocument(); + await selectSummaryWrapupReason(screen, 'Customer Issue'); + + screen.rerender( + + ); + expect(await screen.findByTestId('call-control:wrapup-select')).toBeInTheDocument(); + expect(screen.queryByTestId('wrap-up-summary')).not.toBeInTheDocument(); + + screen.rerender( + + ); + const complete = await screen.findByRole('button', {name: 'Submit Wrap Up'}); + expect(complete).not.toBeDisabled(); + expect(screen.getByText('Edited draft kept by store.')).toBeInTheDocument(); + + fireEvent.click(complete); + await waitFor(() => expect(wrapupCall).toHaveBeenCalledWith('Customer Issue', 'wrap1')); + }); + + it('clears the submitted reason immediately through the existing wrap-up flow', async () => { + const completion = deferred(); + const wrapupCall = jest.fn().mockReturnValue(completion.promise); + const screen = render( + + ); + + fireEvent.click(screen.getByTestId('call-control:wrapup-button')); + const picker = await selectSummaryWrapupReason(screen, 'Customer Issue'); + const complete = await screen.findByRole('button', {name: 'Submit Wrap Up'}); + fireEvent.click(complete); + + expect(wrapupCall).toHaveBeenCalledTimes(1); + expect(wrapupCall).toHaveBeenCalledWith('Customer Issue', 'wrap1'); + await waitFor(() => expect(picker.value).toBe('')); + await waitFor(() => expect(complete).toBeDisabled()); + fireEvent.click(complete); + expect(wrapupCall).toHaveBeenCalledTimes(1); + + await act(async () => completion.resolve(true)); + expect(wrapupCall).toHaveBeenCalledTimes(1); + expect(complete).toBeDisabled(); + }); + + it('does not clear a new task reason when a previous task completion settles', async () => { + const completion = deferred(); + const wrapupCall = jest.fn().mockReturnValue(completion.promise); + const postCall = createPostCallSummary(); + const renderControl = (interactionId: string) => ( + + ); + const screen = render(renderControl('first-task')); + fireEvent.click(screen.getByTestId('call-control:wrapup-button')); + await selectSummaryWrapupReason(screen, 'Customer Issue'); + fireEvent.click(await screen.findByRole('button', {name: 'Submit Wrap Up'})); + await waitFor(() => expect(wrapupCall).toHaveBeenCalledTimes(1)); + + screen.rerender(renderControl('second-task')); + const picker = await selectSummaryWrapupReason(screen, 'Technical Support'); + await act(async () => completion.resolve(true)); + + expect(picker.value).toBe('wrap2'); + expect(await screen.findByRole('button', {name: 'Submit Wrap Up'})).not.toBeDisabled(); + }); + it('renders all call control elements with proper attributes and accessibility features', async () => { const screen = await render(); @@ -434,11 +706,6 @@ describe('CallControlComponent', () => { jest.spyOn(callControlUtils, 'filterButtonsForConsultation').mockReturnValue([consultButton]); - // Mock popover event handlers - const mockHandleCloseButtonPress = jest.fn(); - - jest.spyOn(callControlUtils, 'handleCloseButtonPress').mockImplementation(mockHandleCloseButtonPress); - const modifiedProps = { ...defaultProps, buddyAgents: mockBuddyAgents, @@ -543,6 +810,255 @@ describe('CallControlComponent', () => { expect(screen.queryByRole('button', {name: 'Agents'})).not.toBeInTheDocument(); }); + it('requests an initiating summary when a voice consult popover opens', async () => { + jest.spyOn(callControlUtils, 'filterButtonsForConsultation').mockReturnValue([ + { + id: 'consult', + icon: 'consult', + tooltip: 'Consult', + className: 'call-control-button', + disabled: false, + menuType: 'Consult', + isVisible: true, + dataTestId: 'consult-button', + }, + ]); + const requestMidCallSummary = jest.fn().mockResolvedValue({outcome: 'accepted', revision: 7}); + + const screen = render( + + ); + + fireEvent.click(screen.getByLabelText('Consult')); + + await waitFor(() => expect(requestMidCallSummary).toHaveBeenCalledTimes(1)); + expect(requestMidCallSummary).toHaveBeenCalledWith('CONSULT'); + }); + + describe('mid-call summary request when the consult popover opens', () => { + const renderVoiceConsult = (aiSummary: CallControlComponentProps['aiSummary']) => { + jest.spyOn(callControlUtils, 'filterButtonsForConsultation').mockReturnValue([ + { + id: 'consult', + icon: 'consult', + tooltip: 'Consult', + className: 'call-control-button', + disabled: false, + menuType: 'Consult', + isVisible: true, + dataTestId: 'consult-button', + }, + ]); + + return render( + + ); + }; + + it('uses a controlled Momentum dialog for summary consult and closes without another request', async () => { + const requestMidCallSummary = jest.fn().mockResolvedValue({outcome: 'accepted'}); + const cancelMidCallSummary = jest.fn(); + const resetMidCallSummaryExclusion = jest.fn(); + const screen = renderVoiceConsult({ + midCall: createMidCallSummary(), + requestMidCallSummary, + cancelMidCallSummary, + resetMidCallSummaryExclusion, + }); + + fireEvent.click(screen.getByLabelText('Consult')); + + const dialog = await screen.findByTestId('call-control:consult-dialog'); + expect(dialog.tagName).toBe('MDC-DIALOG'); + expect(dialog).toHaveAttribute('visible'); + expect(dialog).toHaveAttribute('header-text', 'Consult Request'); + expect(screen.container.querySelector('.agent-popover.md-modal-container-wrapper')).not.toBeInTheDocument(); + expect(requestMidCallSummary).toHaveBeenCalledTimes(1); + fireEvent(dialog, new CustomEvent('close')); + await waitFor(() => expect(dialog).not.toHaveAttribute('visible')); + expect(screen.queryByTestId('consult-transfer:summary')).not.toBeInTheDocument(); + expect(requestMidCallSummary).toHaveBeenCalledTimes(1); + expect(cancelMidCallSummary).not.toHaveBeenCalled(); + expect(resetMidCallSummaryExclusion).toHaveBeenCalledTimes(1); + }); + + it('cancels once per opening when footer Cancel is followed by a native close', async () => { + const cancelMidCallSummary = jest.fn(); + const resetMidCallSummaryExclusion = jest.fn(); + const screen = renderVoiceConsult({ + midCall: createMidCallSummary(), + cancelMidCallSummary, + resetMidCallSummaryExclusion, + }); + const trigger = screen.getByLabelText('Consult'); + fireEvent.click(trigger); + const dialog = await screen.findByTestId('call-control:consult-dialog'); + const cancel = within(dialog).getByRole('button', {name: 'Cancel'}); + + fireEvent.click(cancel); + await waitFor(() => expect(dialog).not.toHaveAttribute('visible')); + fireEvent(dialog, new CustomEvent('close')); + + expect(cancelMidCallSummary).toHaveBeenCalledTimes(1); + expect(cancelMidCallSummary).toHaveBeenCalledWith('CONSULT'); + expect(cancelMidCallSummary.mock.invocationCallOrder[0]).toBeLessThan( + resetMidCallSummaryExclusion.mock.invocationCallOrder[0] + ); + expect(resetMidCallSummaryExclusion).toHaveBeenCalledTimes(1); + fireEvent.click(trigger); + fireEvent.click(await within(dialog).findByRole('button', {name: 'Cancel'})); + expect(cancelMidCallSummary).toHaveBeenCalledTimes(2); + expect(resetMidCallSummaryExclusion).toHaveBeenCalledTimes(2); + }); + + it('dispatches a confirmed destination without cancelling its summary', async () => { + const cancelMidCallSummary = jest.fn(); + const resetMidCallSummaryExclusion = jest.fn(); + const screen = renderVoiceConsult({ + midCall: createMidCallSummary(), + cancelMidCallSummary, + resetMidCallSummaryExclusion, + }); + fireEvent.click(screen.getByLabelText('Consult')); + fireEvent.click(await screen.findByRole('button', {name: 'Select John Doe'})); + expect(defaultProps.consultCall).not.toHaveBeenCalled(); + const confirm = within(screen.getByTestId('call-control:consult-dialog')).getByRole('button', { + name: 'Consult', + }); + await waitFor(() => expect(confirm).not.toBeDisabled()); + const dialog = screen.getByTestId('call-control:consult-dialog'); + fireEvent.click(confirm); + await waitFor(() => expect(dialog).not.toHaveAttribute('visible')); + fireEvent(dialog, new CustomEvent('close')); + expect(defaultProps.consultCall).toHaveBeenCalledTimes(1); + expect(cancelMidCallSummary).not.toHaveBeenCalled(); + expect(resetMidCallSummaryExclusion).toHaveBeenCalledTimes(1); + await waitFor(() => expect(screen.getByTestId('call-control:consult-dialog')).not.toHaveAttribute('visible')); + }); + + it('keeps one close control through summary activation and revocation in an open legacy popover', async () => { + const screen = renderVoiceConsult(undefined); + fireEvent.click(screen.getByLabelText('Consult')); + expect(await screen.findByLabelText('Close popover')).toBeInTheDocument(); + + screen.rerender( + + ); + + expect(await screen.findByTestId('consult-transfer:summary')).toBeInTheDocument(); + expect(screen.getAllByLabelText('Close popover')).toHaveLength(1); + + screen.rerender( + + ); + expect(screen.queryByTestId('consult-transfer:summary')).not.toBeInTheDocument(); + expect(screen.getAllByLabelText('Close popover')).toHaveLength(1); + fireEvent.click(screen.getByLabelText('Close popover')); + await waitFor(() => expect(screen.queryByLabelText('Close popover')).not.toBeInTheDocument()); + }); + + it('should request a fresh summary every time the popover opens', async () => { + const requestMidCallSummary = jest.fn().mockResolvedValue({outcome: 'accepted'}); + const screen = renderVoiceConsult({midCall: createMidCallSummary(), requestMidCallSummary}); + const consultButton = screen.getByLabelText('Consult'); + + fireEvent.click(consultButton); + await waitFor(() => expect(requestMidCallSummary).toHaveBeenCalledTimes(1)); + + fireEvent(await screen.findByTestId('call-control:consult-dialog'), new CustomEvent('close')); + await waitFor(() => expect(screen.getByTestId('call-control:consult-dialog')).not.toHaveAttribute('visible')); + fireEvent.click(consultButton); + + await waitFor(() => expect(requestMidCallSummary).toHaveBeenCalledTimes(2)); + expect(requestMidCallSummary).toHaveBeenNthCalledWith(2, 'CONSULT'); + }); + }); + + it.each([ + {id: 'transferConsult', label: 'Transfer Conference', withSummary: true}, + {id: 'conference', label: 'Merge', withSummary: true}, + {id: 'transferConsult', label: 'Transfer Conference', withSummary: false}, + {id: 'conference', label: 'Merge', withSummary: false}, + ] as const)( + 'should run $label immediately without a summary popover (summary available: $withSummary)', + ({id, label, withSummary}) => { + const telephony = jest.fn(); + const requestMidCallSummary = jest.fn(); + jest.spyOn(callControlUtils, 'filterButtonsForConsultation').mockReturnValue([ + { + id, + icon: 'next-bold', + tooltip: label, + onClick: telephony, + className: 'call-control-button', + disabled: false, + isVisible: true, + }, + ]); + + const screen = render( + + ); + + fireEvent.click(screen.getByLabelText(label)); + + expect(telephony).toHaveBeenCalledTimes(1); + expect(requestMidCallSummary).not.toHaveBeenCalled(); + expect(screen.queryByTestId('consult-transfer:summary')).not.toBeInTheDocument(); + } + ); + it('hides Dial Number and Entry Point tabs for non-telephony media', async () => { jest.spyOn(callControlUtils, 'filterButtonsForConsultation').mockReturnValue([ { 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 3b2008649..1d57ee206 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 @@ -13,7 +13,6 @@ import { filterButtonsForConsultation, getConsultFilterPhase, updateCallStateFromTask, - handleCloseButtonPress, handleWrapupReasonChange, handleAudioRef, onInputDialNumber, @@ -1375,18 +1374,6 @@ describe('CallControl Utils', () => { }); }); - describe('handleCloseButtonPress', () => { - it('should set showAgentMenu to false and agentMenuType to null', () => { - const mockSetShowAgentMenu = jest.fn(); - const mockSetAgentMenuType = jest.fn(); - - handleCloseButtonPress(mockSetShowAgentMenu, mockSetAgentMenuType); - - expect(mockSetShowAgentMenu).toHaveBeenCalledWith(false); - expect(mockSetAgentMenuType).toHaveBeenCalledWith(null); - }); - }); - describe('handleWrapupReasonChange', () => { const mockHandleWrapupChange = jest.fn(); diff --git a/packages/contact-center/store/ai-docs/store-spec.md b/packages/contact-center/store/ai-docs/store-spec.md index 62958d102..e4b9d1d02 100644 --- a/packages/contact-center/store/ai-docs/store-spec.md +++ b/packages/contact-center/store/ai-docs/store-spec.md @@ -48,7 +48,7 @@ Owns Contact Center client-side state and the SDK boundary: initialize/register ## 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.148. 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`. +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.158. 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 @@ -94,7 +94,7 @@ Compatibility notes: ## Requires (dependencies) -- `@webex/contact-center` SDK (pinned in `package.json` at `3.12.0-next.148`) — 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`). +- `@webex/contact-center` SDK (pinned in `package.json` at `3.12.0-next.158`) — 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. @@ -397,22 +397,35 @@ Unit tests are split by source file. `tests/store.ts` covers the singleton defau | `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 | +## Build declarations + +`build:src` cleans `dist`, then uses Webpack's full `ts-loader` to type-check and emit +JavaScript and the declarations under `dist/types` declared by `package.json` before +downstream workspaces build. Source interfaces such as `IStore` and `IStoreWrapper` +do not produce declarations when the loader uses `transpileOnly`. `build:watch` +refreshes both JavaScript and declarations when store source changes. + +Evidence: `webpack.config.js`, `tsconfig.json`, `package.json`. +Verification: `yarn run build` from the repository root. + ## AI summaries -`StoreWrapper` is the SDK boundary for summaries. `cc.aiSummaries[interactionId][role]` +`StoreWrapper` is the SDK boundary for summaries. `Store.aiSummaries[interactionId][role]` keeps the SDK summary, SDK response record and request state for `initiator`, `receiver`, -and `post-call`. Store requests and receiver events update these records inside -`runInAction`. Widget helpers own editing, copy tracking and deferred initiator/post-call -feedback through the SDK response fields; the store exposes no local UI action helpers. +and `post-call`. Store requests, receiver events and editable-summary response +actions update these records inside `runInAction`. Widget helpers bind editing, +copy tracking and deferred initiator/post-call feedback to store methods using the +displayed SDK content and Task identities. A new SDK result replaces the unchanged `content` and initializes a fresh response. Refreshing or failing a request retains content already displayed and its response record; task removal and logout clear them. -The store observes `cc` by reference. `registerCC` initializes the widget-owned -`cc.aiSummaries` map when the SDK does not provide one; it does not make SDK fields -observable. SDK events and request results update the map, while current consumers -read it through the summary APIs. A component that directly observes this map in the -future will need an observable state path. +`Store.aiSummaries` starts empty and uses `observable.ref`, preserving SDK summary +payload identities. `StoreWrapper.aiSummaries` exposes the map through a getter. +SDK events, request results and local response edits replace it inside actions, +notifying widgets without another task event. Re-registering the same SDK instance +preserves the map; registering a different instance clears it. The SDK instance +retains its own `observable.ref` boundary and receives no widget-owned properties. An agent can receive a transferred call and then consult or transfer it again. The received card remains in AI Assistant while the initiator summary is used in the @@ -425,6 +438,9 @@ interaction ID lets task cleanup remove them together; entries are created as ne summary kind, and return the SDK `AISummary` or `undefined`. Like suggested responses, summary requests and responses check the SDK flags directly, with no additional media-type restriction. Disabled requests are quiet UI no-ops rather than SDK validation errors. + Both entry points share one request lifecycle for loading, replacement, retained + content after failure and stale-result rejection; only the SDK request and + initiator action/exclusion metadata differ. The SDK owns validation, transport correlation and timeouts. The store keeps a request generation only to stop an older local Promise settlement, including a delayed SDK publish failure, from overwriting a newer UI request. It does not correlate backend replies. @@ -432,59 +448,91 @@ interaction ID lets task cleanup remove them together; entries are created as ne SDK updates `task.aiSummaryCapabilities`. The current task is refreshed so observers read the latest flags, including enablement arriving after the task and later disabling the feature. The SDK owns the flags; the store adds no capability map or enablement helper. + On refresh, recovered SDK Tasks start with both flags disabled until a feature-enablement + frame is received. The widget does not infer enablement from organization settings or + another application's session storage; live summary checks therefore need a replayed + frame or a fresh call after initialization. `TASK_MID_CALL_SUMMARY_RECEIVED` supplies the receiver summary. Listeners are rebound together when a task object is replaced, registered once per task object, and detached on task removal and logout. - Every role stores the complete SDK `AISummary` in `content`, including sections, summary text, cards and SDK metadata. The store does not filter sections, trim text, choose a display format or reject a received payload because of its format. Components - choose what they display from the SDK fields. Widget helpers keep edits in + choose what they display from the SDK fields. Editable-summary store actions keep edits in `response.summary` and retain the original SDK content as their comparison baseline. Consumers use the request status and SDK `areTranscriptsAvailable` flag to select a display state. A failed refresh keeps previously received content visible. Invalid transport payloads belong to the SDK boundary. -- Each entry stores an SDK `AISummaryResponse` in `response`. Widget helpers update its - copy, edit and deferred-feedback fields; senders forward that record to the SDK. +- Each entry stores an SDK `AISummaryResponse` in `response`. The store's + `editAISummary` and `setAISummaryFeedback` methods update initiator/post-call response + fields, including `feedback: none` to withdraw a selection; `recordAISummaryCopied` tracks copies for all three roles. These methods do + not change SDK content or submit a response. Each method accepts the displayed SDK payload and Task, rejecting task + switches, replaced content, unavailable transcripts, excluded summaries and pending refreshes. Edits + accept only SDK-defined editable section keys or plain summary text. The shared + `AI_SUMMARY_EDITABLE_SECTION_KEYS` constant is typed against SDK `AISummarySections`; + an omitted optional section has an empty-string baseline so the SDK edit card can fill it. + Unknown fields and the separate read-only resolution are rejected. Unchanged values preserve the + response identity and reverting a structured field removes its override. Receiver + copy updates are also rejected while feedback submission is pending. Senders forward + the record to the SDK. The SDK sends these fields verbatim and supplies no runtime defaults. Received summaries start with `DEFAULT` and one view. Requests without content start with - `IGNORED`, including retries; failure without content changes the state to `NOT_RECEIVED`. + `IGNORED`, including retries, unless the initiator excluded the summary; failure without content changes the state to `NOT_RECEIVED`. Structured responses start with `{}` and plain-text responses start with SDK summary text. +- `setAISummaryExcluded(excluded, expectedSummary, task)` keeps the initiator's picker choice + in the entry's optional `excluded` field and updates the local response state. It rejects + a different active task, replaced content or a missing entry; it neither changes SDK + content nor sends a network request. The checkbox remains available during generation + and on unavailable/error states. Selected exclusion uses `EXCLUDED`; clearing it restores + `DEFAULT` with content or `IGNORED` while waiting without content. A failed request without + content always uses `NOT_RECEIVED`, ahead of exclusion, matching Desktop submission precedence. + Request completion and Retry retain the current picker choice; closing the picker clears it. - `setReceiverSummaryFeedback(feedback, expectedSummary, task)` submits receiver feedback immediately. It accepts the displayed SDK payload itself, with no revision counter. - The store blocks overlapping sends and applies the acknowledgement only while that - same SDK payload remains current. Replacement events and task removal invalidate it. - A successful send selects the feedback; a failed send preserves the previous selection. + The store rejects callbacks from a different active Task, blocks overlapping sends + and applies the acknowledgement only while that same SDK payload remains current. + A task switch during submission still allows the matching entry to settle; + replacement events and task removal invalidate it. + A successful send confirms the feedback, including `none` to clear it; a failed send preserves the previous selection. Selection remains unchanged while acknowledgement is pending. Receiver responses use SDK `summaryText`, including when the payload also has sections or an adaptive card; absent text is sent as `''`. -- `sendMidCallSummaryResponse(action, task)` and `sendPostCallSummaryResponse(wrapUpCode, task)` +- `sendMidCallSummaryResponse(action, task, state?)` and `sendPostCallSummaryResponse(wrapUpCode, task)` return `AISummaryResponseSubmissionResult`: `submitted` after SDK acknowledgement, `response-failed` after a failed send, or `not-applicable` when the required task or summary capability is absent. Failed sends are logged and resolve with an outcome so the initiating consult, transfer or wrap-up action can continue. -- `sendPostCallSummaryResponse(wrapUpCode, task)` adds the wrap-up code to the SDK response - record and sends it on that task before `task.wrapup()` runs. This keeps the exact task - context available and removes the need for callers to capture and later submit a response. + The optional mid-call state creates a submission snapshot without changing the stored + response, so Cancel can send `MID_CALL_CANCELLED` before clearing the picker choice. + A successful Consult/Transfer sends the existing response once; closing after submission + does not send a cancellation. +- `sendPostCallSummaryResponse(wrapUpCode, task)` snapshots the SDK response and wrap-up + code synchronously without writing the submission payload back to summary state, + then dispatches it on that task. CallControl starts `task.wrapup()` + without waiting for summary acknowledgement; subsequent task cleanup cannot erase + the dispatched payload. SDK `3.12.0-next.158` snapshots the task IDs and response fields + before awaiting its independent HTTP request. Failed sends are logged. If no summary entry exists, it sends an ignored response without creating an entry. It resolves to `submitted` or `response-failed`; disabled post-call summaries return `not-applicable`. The integration consumes `AISummary`, `AISummarySections`, `AISummaryResponse`, `AISummaryAction`, and `AISummaryFeedback` from `@webex/contact-center`. -These APIs come from published SDK `3.12.0-next.148`. The store and React sample pin -that version; the root resolution also selects it for the sample's transitive Webex -SDK dependency. Three scoped resolutions replace unavailable transitive versions: -Device `3.12.0-next.55` uses published `next.54`, and Metrics `3.12.0-next.56` uses -published `next.55`; Mercury `3.12.0-next.62` uses published `next.61`. Remove these -resolutions once the referenced versions are published. No local SDK archive is used. +The store pins published SDK `3.12.0-next.158` from the `next` release track; +stable `3.12.0` lacks these public summary APIs. Only the store declares a direct +`@webex/contact-center` dependency. The React sample consumes the store API and +infers its station-logout response type from that API, without a separate SDK pin. +No local SDK archive is used. The React sample uses the SDK-supported `User requested logout` reason for page-unload logout. `AISummary` is the common incoming payload type; `AISummaryResponse` is the distinct feedback payload sent back to the SDK. The store retains both types directly. The UI entry adds request lifecycle, local request generation, the pending receiver-feedback -flag and the consult/transfer action. Copy counts, edits and feedback +flag, optional initiator exclusion choice and the consult/transfer action. Copy counts, edits and feedback use SDK response fields rather than duplicate widget fields. -Evidence: `src/storeEventsWrapper.ts`, `src/store.types.ts`, `src/task-utils.ts`; -tests: the AI summary cases in `tests/storeEventsWrapper.ts` and `tests/task-utils.ts`. +Evidence: `src/store.ts`, `src/storeEventsWrapper.ts`, `src/store.types.ts`, `src/task-utils.ts`; +tests: `tests/ai-summary-observability.ts` uses real MobX for asynchronous success, +failure and repeated registration; additional AI summary cases are in +`tests/storeEventsWrapper.ts` and `tests/task-utils.ts`. ## Traceability diff --git a/packages/contact-center/store/package.json b/packages/contact-center/store/package.json index c3b068eea..c3ad20d5c 100644 --- a/packages/contact-center/store/package.json +++ b/packages/contact-center/store/package.json @@ -23,7 +23,7 @@ "deploy:npm": "yarn npm publish" }, "dependencies": { - "@webex/contact-center": "3.12.0-next.148", + "@webex/contact-center": "3.12.0-next.158", "mobx": "6.13.5", "typescript": "5.6.3" }, diff --git a/packages/contact-center/store/src/store.ts b/packages/contact-center/store/src/store.ts index 4537ca00a..693b47aba 100644 --- a/packages/contact-center/store/src/store.ts +++ b/packages/contact-center/store/src/store.ts @@ -67,6 +67,7 @@ class Store implements IStore { showE911Modal: boolean = false; isEmergencyModalAlreadyDisplayed: boolean = false; realTimeAssist: Record = {}; + aiSummaries: IStore['aiSummaries'] = {}; offerActionErrors: Record = {}; isWellnessBreakEnabled = false; wellnessAgentSessionId = ''; @@ -81,6 +82,7 @@ class Store implements IStore { constructor() { makeAutoObservable(this, { cc: observable.ref, + aiSummaries: observable.ref, }); } @@ -96,8 +98,8 @@ class Store implements IStore { registerCC(webex?: WithWebex['webex']): Promise { if (webex) { + if (this.cc !== webex.cc) this.aiSummaries = {}; this.cc = webex.cc; - this.cc.aiSummaries ??= {}; } if (typeof webex === 'undefined' && typeof this.cc === 'undefined') { @@ -135,7 +137,6 @@ class Store implements IStore { this.legacyAuxCodeId = registeredSessionId ? response.lastStateAuxCodeId || '' : ''; this.legacyAgentState = this.legacyAuxCodeId ? (this.legacyAuxCodeId === '0' ? 'Available' : 'Idle') : ''; }); - //@ts-expect-error To be fixed in SDK - https://jira-eng-sjc12.cisco.com/jira/browse/CAI-6762 this.teams = response.teams; this.loginOptions = response.webRtcEnabled ? response.loginVoiceOptions diff --git a/packages/contact-center/store/src/store.types.ts b/packages/contact-center/store/src/store.types.ts index add56eb5d..17d5e3e46 100644 --- a/packages/contact-center/store/src/store.types.ts +++ b/packages/contact-center/store/src/store.types.ts @@ -59,8 +59,6 @@ import { // To be fixed in SDK - https://jira-eng-sjc12.cisco.com/jira/browse/CAI-6762 interface IContactCenter { - /** Widget-owned AI summary state associated with this SDK contact-center instance. */ - aiSummaries: Record>>; // eslint-disable-next-line @typescript-eslint/no-explicit-any on: (event: string, callback: (data: any) => void) => void; // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -319,6 +317,8 @@ interface IStore { showE911Modal: boolean; isEmergencyModalAlreadyDisplayed: boolean; realTimeAssist: Record; + /** Widget-owned AI summary state; SDK payloads retain their original identity. */ + aiSummaries: Record>>; offerActionErrors: Record; isWellnessBreakEnabled: boolean; wellnessAgentSessionId: string; @@ -384,12 +384,27 @@ interface IStoreWrapper extends IStore { requestMidCallSummary(action: AISummaryAction, task?: ITask): Promise; requestPostCallSummary(task?: ITask): Promise; - setReceiverSummaryFeedback( - feedback: Exclude, - expectedSummary: AISummary, + editAISummary( + role: Exclude, + key: keyof AISummarySections | 'summaryText', + value: string, + expectedSummary: AISummary | undefined, + task?: ITask + ): boolean; + recordAISummaryCopied(role: AISummaryRole, expectedSummary: AISummary | undefined, task?: ITask): boolean; + setAISummaryFeedback( + role: Exclude, + feedback: AISummaryFeedback, + expectedSummary: AISummary | undefined, task?: ITask - ): Promise; - sendMidCallSummaryResponse(action: AISummaryAction, task?: ITask): Promise; + ): boolean; + setReceiverSummaryFeedback(feedback: AISummaryFeedback, expectedSummary: AISummary, task?: ITask): Promise; + setAISummaryExcluded(excluded: boolean, expectedSummary: AISummary | undefined, task?: ITask): boolean; + sendMidCallSummaryResponse( + action: AISummaryAction, + task?: ITask, + state?: AISummaryResponse['state'] + ): Promise; sendPostCallSummaryResponse(wrapUpCode: string, task: ITask): Promise; } @@ -677,7 +692,17 @@ export const CAMPAIGN_PREVIEW_CAMPAIGN_TYPES = ['preview_standard', 'preview_dir /** The consult/transfer initiator's summary, the receiving agent's summary, or the wrap-up summary. */ export type AISummaryRole = 'initiator' | 'receiver' | 'post-call'; -/** One summary of an interaction, stored at `cc.aiSummaries[interactionId][role]`. */ +/** Editable structured fields declared by the SDK; the separate resolution is read-only. */ +export const AI_SUMMARY_EDITABLE_SECTION_KEYS = [ + 'initialContactReason', + 'additionalContactReasons', + 'additionalContext', + 'keyActionsTaken', + 'nextSteps', + 'reasonForTransferOrConsult', +] as const satisfies readonly (keyof AISummarySections)[]; + +/** One summary of an interaction, stored at `Store.aiSummaries[interactionId][role]`. */ export type AISummaryEntry = { /** Lifecycle of the latest request, or of the pushed receiver summary. */ status: 'loading' | 'ready' | 'error'; @@ -685,12 +710,14 @@ export type AISummaryEntry = { * The unchanged SDK summary, retained while a newer request loads or after it fails. */ content?: AISummary; - /** SDK submission record; widget helpers own local edits, copy counts and deferred feedback. */ + /** SDK submission record; edits, copy counts and feedback are updated through store actions. */ response: AISummaryResponse; /** Orders local request promise settlements; SDK transport correlation remains SDK-owned. */ requestGeneration?: number; /** The receiving agent's feedback response is being sent. */ feedbackPending?: boolean; + /** The initiator opted out of sending this summary in the current consult/transfer picker. */ + excluded?: boolean; /** The consult or transfer a mid-call summary was generated for. */ action?: AISummaryAction; }; diff --git a/packages/contact-center/store/src/storeEventsWrapper.ts b/packages/contact-center/store/src/storeEventsWrapper.ts index 69caabc0b..d36143bdb 100644 --- a/packages/contact-center/store/src/storeEventsWrapper.ts +++ b/packages/contact-center/store/src/storeEventsWrapper.ts @@ -32,10 +32,17 @@ import { WellnessBreakState, WidgetsBehavioralMetric, WELLNESS_BREAK_NOTIFICATION_ACTIONS, + AI_SUMMARY_EDITABLE_SECTION_KEYS, } from './store.types'; -import type {AISummaryEntry, AISummaryResponseSubmissionResult} from './store.types'; +import type {AISummaryEntry, AISummaryResponseSubmissionResult, AISummaryRole} from './store.types'; import Store from './store'; -import type {AISummary, AISummaryAction, AISummaryFeedback, AISummaryResponse} from '@webex/contact-center'; +import type { + AISummary, + AISummaryAction, + AISummaryFeedback, + AISummaryResponse, + AISummarySections, +} from '@webex/contact-center'; import { DEVICE_TYPE_BROWSER, MEDIA_TYPE_TELEPHONY_LOWER, @@ -229,6 +236,10 @@ class StoreWrapper implements IStoreWrapper { return this.store.realTimeAssist; } + get aiSummaries() { + return this.store.aiSummaries; + } + get isWellnessBreakEnabled() { return this.store.isWellnessBreakEnabled; } @@ -474,28 +485,36 @@ class StoreWrapper implements IStoreWrapper { }); }; - // The SDK owns validation, transport correlation and timeouts. This generation only - // prevents an older local promise settlement from overwriting the latest UI request. + requestMidCallSummary = (action: AISummaryAction, task: ITask = this.currentTask): Promise => + this.requestAISummary(action, task); - requestMidCallSummary = async ( - action: AISummaryAction, - task: ITask = this.currentTask - ): Promise => { - // Disabled summaries are a quiet UI no-op; the SDK validates enabled requests. - if (!task?.aiSummaryCapabilities?.midCallEnabled) { + requestPostCallSummary = (task: ITask = this.currentTask): Promise => + this.requestAISummary('post-call', task); + + // Both requests share the same lifecycle; the SDK owns transport correlation and timeouts. + private async requestAISummary(action: AISummaryAction | 'post-call', task: ITask): Promise { + const enabled = + action === 'post-call' + ? task?.aiSummaryCapabilities?.postCallEnabled + : task?.aiSummaryCapabilities?.midCallEnabled; + if (!enabled) { return undefined; } + const role = action === 'post-call' ? 'post-call' : 'initiator'; + const midCallAction = action === 'post-call' ? undefined : action; const interactionId = task.data.interactionId; const requestGeneration = ++this.aiSummaryRequestGeneration; - const entry = this.store.cc.aiSummaries[interactionId]?.initiator; + const entry = this.store.aiSummaries[interactionId]?.[role]; runInAction(() => { - this.store.cc.aiSummaries = { - ...this.store.cc.aiSummaries, + this.store.aiSummaries = { + ...this.store.aiSummaries, [interactionId]: { - ...this.store.cc.aiSummaries[interactionId], - initiator: { + ...this.store.aiSummaries[interactionId], + [role]: { ...entry, - response: entry?.content ? entry.response : {...EMPTY_AI_SUMMARY_RESPONSE}, + response: entry?.content + ? entry.response + : {...EMPTY_AI_SUMMARY_RESPONSE, state: midCallAction && entry?.excluded ? 'EXCLUDED' : 'IGNORED'}, status: 'loading', requestGeneration, }, @@ -505,14 +524,15 @@ class StoreWrapper implements IStoreWrapper { let result: AISummary | undefined; try { - result = await task.requestMidCallSummary(action); + result = action === 'post-call' ? await task.requestPostCallSummary() : await task.requestMidCallSummary(action); } catch (error) { this.store.logger?.error(`CC-Widgets: AI summary request failed: ${error}`, { module: 'storeEventsWrapper.ts', method: 'request', }); } - const current = this.store.cc.aiSummaries[interactionId]?.initiator; + const current = this.store.aiSummaries[interactionId]?.[role]; + // Ignore a superseded request, or one whose task was removed while it loaded. if (current?.requestGeneration !== requestGeneration) { return undefined; } @@ -522,107 +542,193 @@ class StoreWrapper implements IStoreWrapper { ? { status: 'ready', content: result, + ...(midCallAction ? {excluded: current.excluded, action: midCallAction} : {}), response: { ...EMPTY_AI_SUMMARY_RESPONSE, summary: result.sections ? {} : (result.summaryText ?? ''), - state: 'DEFAULT', + state: midCallAction && current.excluded ? 'EXCLUDED' : 'DEFAULT', numberOfTimesViewed: 1, }, - action, } : { ...current, status: 'error', response: current.content ? current.response : {...current.response, state: 'NOT_RECEIVED'}, - action: current.action ?? action, + ...(midCallAction ? {action: current.action ?? midCallAction} : {}), }; runInAction(() => { - this.store.cc.aiSummaries = { - ...this.store.cc.aiSummaries, - [interactionId]: {...this.store.cc.aiSummaries[interactionId], initiator: updated}, + this.store.aiSummaries = { + ...this.store.aiSummaries, + [interactionId]: {...this.store.aiSummaries[interactionId], [role]: updated}, }; }); return result; - }; + } - requestPostCallSummary = async (task: ITask = this.currentTask): Promise => { - if (!task?.aiSummaryCapabilities?.postCallEnabled) { - return undefined; + private updateAISummaryResponse( + role: AISummaryRole, + expectedSummary: AISummary | undefined, + update: (response: AISummaryResponse) => AISummaryResponse, + task: ITask + ): boolean { + const entry = task && this.store.aiSummaries[task.data.interactionId]?.[role]; + if ( + this.currentTask !== task || + !expectedSummary || + expectedSummary.areTranscriptsAvailable === false || + entry?.content !== expectedSummary || + entry.status === 'loading' || + entry.excluded || + (role === 'receiver' && entry.feedbackPending) + ) { + return false; + } + const response = update(entry.response); + if (response === entry.response) { + return true; } const interactionId = task.data.interactionId; - const requestGeneration = ++this.aiSummaryRequestGeneration; - const entry = this.store.cc.aiSummaries[interactionId]?.['post-call']; runInAction(() => { - this.store.cc.aiSummaries = { - ...this.store.cc.aiSummaries, + this.store.aiSummaries = { + ...this.store.aiSummaries, [interactionId]: { - ...this.store.cc.aiSummaries[interactionId], - 'post-call': { - ...entry, - response: entry?.content ? entry.response : {...EMPTY_AI_SUMMARY_RESPONSE}, - status: 'loading', - requestGeneration, - }, + ...this.store.aiSummaries[interactionId], + [role]: {...entry, response}, }, }; }); + return true; + } - let result: AISummary | undefined; - try { - result = await task.requestPostCallSummary(); - } catch (error) { - this.store.logger?.error(`CC-Widgets: AI summary request failed: ${error}`, { - module: 'storeEventsWrapper.ts', - method: 'request', - }); - } - const current = this.store.cc.aiSummaries[interactionId]?.['post-call']; - if (current?.requestGeneration !== requestGeneration) { - return undefined; + /** Keeps local edits in the response while retaining the displayed SDK summary as the baseline. */ + editAISummary = ( + role: Exclude, + key: keyof AISummarySections | 'summaryText', + value: string, + expectedSummary: AISummary | undefined, + task: ITask = this.currentTask + ): boolean => { + const originalValue = + key === 'summaryText' + ? expectedSummary && !expectedSummary.sections + ? (expectedSummary.summaryText ?? '') + : undefined + : AI_SUMMARY_EDITABLE_SECTION_KEYS.some((section) => section === key) && expectedSummary?.sections + ? (expectedSummary.sections[key] ?? '') + : undefined; + if (typeof originalValue !== 'string') { + return false; } - - // A new result resets edits, copies and feedback; a failed refresh keeps the summary already shown. - const updated: AISummaryEntry = result - ? { - status: 'ready', - content: result, - response: { - ...EMPTY_AI_SUMMARY_RESPONSE, - summary: result.sections ? {} : (result.summaryText ?? ''), - state: 'DEFAULT', - numberOfTimesViewed: 1, - }, + return this.updateAISummaryResponse( + role, + expectedSummary, + (response) => { + const displayedValue = + key === 'summaryText' + ? typeof response.summary === 'string' + ? response.summary + : originalValue + : typeof response.summary === 'object' + ? (response.summary[key] ?? originalValue) + : originalValue; + if (displayedValue === value) { + return response; } - : { - ...current, - status: 'error', - response: current.content ? current.response : {...current.response, state: 'NOT_RECEIVED'}, - }; + let summary: AISummaryResponse['summary'] = value; + if (key !== 'summaryText') { + const modified = {...(typeof response.summary === 'object' ? response.summary : {})}; + if (value === originalValue) { + delete modified[key]; + } else { + modified[key] = value; + } + summary = modified; + } + return {...response, summary, numberOfTimesEdited: 1}; + }, + task + ); + }; + + recordAISummaryCopied = ( + role: AISummaryRole, + expectedSummary: AISummary | undefined, + task: ITask = this.currentTask + ): boolean => + this.updateAISummaryResponse( + role, + expectedSummary, + (response) => ({...response, numberOfTimesCopied: response.numberOfTimesCopied + 1}), + task + ); + + /** Initiator and post-call feedback is kept locally until the owning call action submits it. */ + setAISummaryFeedback = ( + role: Exclude, + feedback: AISummaryFeedback, + expectedSummary: AISummary | undefined, + task: ITask = this.currentTask + ): boolean => this.updateAISummaryResponse(role, expectedSummary, (response) => ({...response, feedback}), task); + + /** Excluding a summary changes only the next action's local response; the SDK payload stays intact. */ + setAISummaryExcluded = ( + excluded: boolean, + expectedSummary: AISummary | undefined, + task: ITask = this.currentTask + ): boolean => { + const interactionId = task?.data.interactionId; + const entry = interactionId && this.store.aiSummaries[interactionId]?.initiator; + if ( + this.currentTask !== task || + (!task?.aiSummaryCapabilities?.midCallEnabled && excluded) || + !entry || + entry.content !== expectedSummary + ) { + return false; + } + // Desktop's failed/empty request takes precedence over Exclude; a new request can still retain the choice. + const state = + entry.status === 'error' && !entry.content + ? 'NOT_RECEIVED' + : excluded + ? 'EXCLUDED' + : entry.content + ? 'DEFAULT' + : 'IGNORED'; runInAction(() => { - this.store.cc.aiSummaries = { - ...this.store.cc.aiSummaries, - [interactionId]: {...this.store.cc.aiSummaries[interactionId], 'post-call': updated}, + this.store.aiSummaries = { + ...this.store.aiSummaries, + [interactionId]: { + ...this.store.aiSummaries[interactionId], + initiator: {...entry, excluded, response: {...entry.response, state}}, + }, }; }); - return result; + return true; }; /** The receiving agent's feedback is sent right away; the selection is kept once the SDK confirms it. */ setReceiverSummaryFeedback = async ( - feedback: Exclude, + feedback: AISummaryFeedback, expectedSummary: AISummary, task: ITask = this.currentTask ): Promise => { - const entry = task && this.store.cc.aiSummaries[task.data.interactionId]?.receiver; - if (!entry?.content || !entry.action || entry.content !== expectedSummary || entry.feedbackPending) { + const entry = task && this.store.aiSummaries[task.data.interactionId]?.receiver; + if ( + this.currentTask !== task || + !entry?.content || + !entry.action || + entry.content !== expectedSummary || + entry.feedbackPending + ) { return; } const interactionId = task.data.interactionId; runInAction(() => { - this.store.cc.aiSummaries = { - ...this.store.cc.aiSummaries, + this.store.aiSummaries = { + ...this.store.aiSummaries, [interactionId]: { - ...this.store.cc.aiSummaries[interactionId], + ...this.store.aiSummaries[interactionId], receiver: {...entry, feedbackPending: true}, }, }; @@ -639,14 +745,14 @@ class StoreWrapper implements IStoreWrapper { }); } - const current = this.store.cc.aiSummaries[interactionId]?.receiver; + const current = this.store.aiSummaries[interactionId]?.receiver; // A new summary replaced this one, or the task was removed, while the response was in flight. if (current?.content === expectedSummary && current.feedbackPending) { runInAction(() => { - this.store.cc.aiSummaries = { - ...this.store.cc.aiSummaries, + this.store.aiSummaries = { + ...this.store.aiSummaries, [interactionId]: { - ...this.store.cc.aiSummaries[interactionId], + ...this.store.aiSummaries[interactionId], receiver: { ...current, response: sent ? {...current.response, feedback} : current.response, @@ -664,16 +770,16 @@ class StoreWrapper implements IStoreWrapper { */ sendMidCallSummaryResponse = async ( action: AISummaryAction, - task: ITask = this.currentTask + task: ITask = this.currentTask, + state?: AISummaryResponse['state'] ): Promise => { if (!task?.aiSummaryCapabilities?.midCallEnabled) { return 'not-applicable'; } try { - await task.sendMidCallSummaryResponse( - this.store.cc.aiSummaries[task.data.interactionId]?.initiator?.response ?? EMPTY_AI_SUMMARY_RESPONSE, - action - ); + const response = + this.store.aiSummaries[task.data.interactionId]?.initiator?.response ?? EMPTY_AI_SUMMARY_RESPONSE; + await task.sendMidCallSummaryResponse(state ? {...response, state} : response, action); return 'submitted'; } catch (error) { this.store.logger?.error(`CC-Widgets: AI summary response failed: ${error}`, { @@ -689,28 +795,18 @@ class StoreWrapper implements IStoreWrapper { if (!task.aiSummaryCapabilities?.postCallEnabled) { return 'not-applicable'; } - const interactionId = task.data.interactionId; - const entry = this.store.cc.aiSummaries[interactionId]?.['post-call']; + const entry = this.store.aiSummaries[task.data.interactionId]?.['post-call']; const response = {...(entry?.response ?? EMPTY_AI_SUMMARY_RESPONSE), wrapUpCode}; - if (entry) { - runInAction(() => { - this.store.cc.aiSummaries = { - ...this.store.cc.aiSummaries, - [interactionId]: {...this.store.cc.aiSummaries[interactionId], 'post-call': {...entry, response}}, - }; - }); - } - let outcome: 'submitted' | 'response-failed' = 'submitted'; try { await task.sendPostCallSummaryResponse(response); + return 'submitted'; } catch (error) { - outcome = 'response-failed'; this.store.logger?.error(`CC-Widgets: AI summary response failed: ${error}`, { module: 'storeEventsWrapper.ts', method: 'response', }); + return 'response-failed'; } - return outcome; }; setCurrentTheme = (theme: string): void => { @@ -1142,11 +1238,11 @@ class StoreWrapper implements IStoreWrapper { this.store.realTimeAssist = next; }); } - if (taskId && this.store.cc.aiSummaries[taskId]) { + if (taskId && this.store.aiSummaries[taskId]) { runInAction(() => { - const next = {...this.store.cc.aiSummaries}; + const next = {...this.store.aiSummaries}; delete next[taskId]; - this.store.cc.aiSummaries = next; + this.store.aiSummaries = next; }); } } @@ -1652,10 +1748,10 @@ class StoreWrapper implements IStoreWrapper { const listener = (payload: AISummary) => { const interactionId = task.data.interactionId; runInAction(() => { - this.store.cc.aiSummaries = { - ...this.store.cc.aiSummaries, + this.store.aiSummaries = { + ...this.store.aiSummaries, [interactionId]: { - ...this.store.cc.aiSummaries[interactionId], + ...this.store.aiSummaries[interactionId], receiver: { status: 'ready', content: payload, @@ -2054,7 +2150,7 @@ class StoreWrapper implements IStoreWrapper { task.off(TASK_EVENTS.TASK_FEATURE_ENABLEMENT, this.refreshTaskList); }); this.aiSummaryReceivedListeners = {}; - this.store.cc.aiSummaries = {}; + this.store.aiSummaries = {}; }); }; diff --git a/packages/contact-center/store/tests/ai-summary-observability.ts b/packages/contact-center/store/tests/ai-summary-observability.ts new file mode 100644 index 000000000..620f221bf --- /dev/null +++ b/packages/contact-center/store/tests/ai-summary-observability.ts @@ -0,0 +1,206 @@ +import {autorun, isObservable, runInAction} from 'mobx'; +import type {IReactionDisposer} from 'mobx'; +import {mockCC, mockProfile, mockTask} from '@webex/test-fixtures'; +import {aiSummaryFixtures} from '../../test-fixtures/src/aiSummaryFixtures'; +import store from '../src/storeEventsWrapper'; +import {TASK_EVENTS} from '../src/store.types'; +import type {AISummaryEntry, IContactCenter, ITask, WithWebex} from '../src/store.types'; + +type Summary = NonNullable; + +const INTERACTION_ID = 'summary-observability-interaction'; + +const deferredSummary = () => { + let resolve: (summary: Summary) => void = () => undefined; + const promise = new Promise((complete) => { + resolve = complete; + }); + return {promise, resolve}; +}; + +describe('AI summary notifications with the real MobX store', () => { + let cc: IContactCenter; + let webex: WithWebex['webex']; + let task: ITask; + const disposers: IReactionDisposer[] = []; + + beforeEach(async () => { + cc = { + ...mockCC, + register: jest.fn().mockResolvedValue(mockProfile), + }; + webex = {cc, logger: cc.LoggerProxy}; + task = { + ...mockTask, + data: {...mockTask.data, interactionId: INTERACTION_ID}, + aiSummaryCapabilities: {midCallEnabled: true, postCallEnabled: true}, + }; + await store.registerCC(webex); + }); + + afterEach(() => { + disposers.splice(0).forEach((dispose) => dispose()); + runInAction(() => { + store.store.aiSummaries = {}; + store.store.currentTask = null; + }); + }); + + it.each(['mid-call', 'post-call'] as const)( + 'notifies observers when a %s request starts and completes without another task event', + async (kind) => { + const reply = deferredSummary(); + task.requestMidCallSummary = jest.fn().mockReturnValue(reply.promise); + task.requestPostCallSummary = jest.fn().mockReturnValue(reply.promise); + const role = kind === 'mid-call' ? 'initiator' : 'post-call'; + const states: string[] = []; + disposers.push( + autorun(() => { + states.push(store.aiSummaries[INTERACTION_ID]?.[role]?.status ?? 'idle'); + }) + ); + + const pending = + kind === 'mid-call' ? store.requestMidCallSummary('CONSULT', task) : store.requestPostCallSummary(task); + const summary = aiSummaryFixtures.initiatingMidCall.plainText; + reply.resolve(summary); + await pending; + + expect(states).toEqual(['idle', 'loading', 'ready']); + expect(store.aiSummaries[INTERACTION_ID][role].content).toBe(summary); + expect(store.cc).toBe(cc); + expect(isObservable(summary)).toBe(false); + expect(isObservable(cc.taskManager)).toBe(false); + } + ); + + it('notifies observers when a failed consult-summary request leaves the loading state', async () => { + task.requestMidCallSummary = jest.fn().mockRejectedValue(new Error('Summary unavailable')); + const states: string[] = []; + disposers.push( + autorun(() => { + states.push(store.aiSummaries[INTERACTION_ID]?.initiator?.status ?? 'idle'); + }) + ); + + await store.requestMidCallSummary('CONSULT', task); + + expect(states).toEqual(['idle', 'loading', 'error']); + }); + + it('notifies observers of receiver summary replacement and confirmed feedback', async () => { + const on = jest.fn(); + task = {...task, on, off: jest.fn()}; + runInAction(() => { + store.store.currentTask = task; + }); + task = store.currentTask; + (store as unknown as {registerTaskEventListeners: (task: ITask) => void}).registerTaskEventListeners(task); + const receive = on.mock.calls.find(([event]) => event === TASK_EVENTS.TASK_MID_CALL_SUMMARY_RECEIVED)[1] as ( + summary: Summary + ) => void; + const entries: Array = []; + disposers.push(autorun(() => entries.push(store.aiSummaries[INTERACTION_ID]?.receiver))); + + const unavailable: Summary = {conversationId: INTERACTION_ID, areTranscriptsAvailable: false}; + const summary = aiSummaryFixtures.receivingMidCall.adaptiveCard; + receive(unavailable); + receive(summary); + + let confirmFeedback: () => void = () => undefined; + task.sendMidCallSummaryResponse = jest.fn().mockReturnValue( + new Promise((resolve) => { + confirmFeedback = resolve; + }) + ); + const pending = store.setReceiverSummaryFeedback('thumbs_up', summary, task); + expect(entries.map((entry) => entry?.content)).toEqual([undefined, unavailable, summary, summary]); + expect(entries[3]).toMatchObject({feedbackPending: true, response: {feedback: 'none'}}); + + confirmFeedback(); + await pending; + + expect(entries).toHaveLength(5); + expect(entries[4]).toMatchObject({feedbackPending: false, response: {feedback: 'thumbs_up'}}); + expect(entries[4].content).toBe(summary); + }); + + it.each(['initiator', 'post-call'] as const)( + 'notifies observers of local %s response changes while preserving the SDK summary', + async (role) => { + runInAction(() => { + store.store.currentTask = task; + }); + task = store.currentTask; + const summary = aiSummaryFixtures.postCall.structured; + task.requestMidCallSummary = jest.fn().mockResolvedValue(summary); + task.requestPostCallSummary = jest.fn().mockResolvedValue(summary); + await (role === 'initiator' ? store.requestMidCallSummary('CONSULT', task) : store.requestPostCallSummary(task)); + const responses: AISummaryEntry['response'][] = []; + disposers.push( + autorun(() => { + responses.push(store.aiSummaries[INTERACTION_ID][role].response); + }) + ); + + expect(store.editAISummary(role, 'initialContactReason', 'Edited reason', summary, task)).toBe(true); + expect(store.recordAISummaryCopied(role, summary, task)).toBe(true); + expect(store.setAISummaryFeedback(role, 'thumbs_up', summary, task)).toBe(true); + + expect(responses).toHaveLength(4); + expect(responses[3]).toMatchObject({ + summary: {initialContactReason: 'Edited reason'}, + numberOfTimesEdited: 1, + numberOfTimesCopied: 1, + feedback: 'thumbs_up', + }); + expect(store.aiSummaries[INTERACTION_ID][role].content).toBe(summary); + expect(summary.sections.initialContactReason).toBe('Customer called about an invoice discrepancy.'); + expect(isObservable(summary)).toBe(false); + } + ); + + it('rejects local response changes from a different task with the same interaction ID', async () => { + runInAction(() => { + store.store.currentTask = task; + }); + task = store.currentTask; + const summary = aiSummaryFixtures.postCall.structured; + task.requestPostCallSummary = jest.fn().mockResolvedValue(summary); + await store.requestPostCallSummary(task); + const response = store.aiSummaries[INTERACTION_ID]['post-call'].response; + const differentTask = {...task}; + + expect(store.editAISummary('post-call', 'initialContactReason', 'Stale edit', summary, differentTask)).toBe(false); + expect(store.recordAISummaryCopied('post-call', summary, differentTask)).toBe(false); + expect(store.setAISummaryFeedback('post-call', 'thumbs_up', summary, differentTask)).toBe(false); + expect(store.aiSummaries[INTERACTION_ID]['post-call'].response).toBe(response); + }); + + it('preserves summaries for the same SDK instance and clears them when the SDK changes', async () => { + const summary = aiSummaryFixtures.initiatingMidCall.plainText; + task.requestMidCallSummary = jest.fn().mockResolvedValue(summary); + await store.requestMidCallSummary('CONSULT', task); + const api = cc.apiAIAssistant; + const previous = store.aiSummaries; + + await store.registerCC(webex); + + expect(cc.apiAIAssistant).toBe(api); + expect(api).not.toHaveProperty('aiSummaries'); + expect(store.aiSummaries).toBe(previous); + const contents: Array = []; + disposers.push( + autorun(() => { + contents.push(store.aiSummaries[INTERACTION_ID]?.initiator?.content); + }) + ); + const nextCC = {...cc}; + await store.registerCC({cc: nextCC, logger: nextCC.LoggerProxy}); + + expect(contents).toEqual([summary, undefined]); + expect(store.aiSummaries).toEqual({}); + expect(nextCC.apiAIAssistant).toBe(api); + expect(api).not.toHaveProperty('aiSummaries'); + }); +}); diff --git a/packages/contact-center/store/tests/store.ts b/packages/contact-center/store/tests/store.ts index 7d172622d..b3e0fc249 100644 --- a/packages/contact-center/store/tests/store.ts +++ b/packages/contact-center/store/tests/store.ts @@ -73,6 +73,7 @@ describe('Store', () => { expect(makeAutoObservable).toHaveBeenCalledWith(storeInstance, { cc: expect.any(Function), + aiSummaries: expect.any(Function), }); }); diff --git a/packages/contact-center/store/tests/storeEventsWrapper.ts b/packages/contact-center/store/tests/storeEventsWrapper.ts index bc3c18929..631272f81 100644 --- a/packages/contact-center/store/tests/storeEventsWrapper.ts +++ b/packages/contact-center/store/tests/storeEventsWrapper.ts @@ -63,8 +63,8 @@ jest.mock('../src/store', () => ({ getInstance: jest.fn().mockReturnValue({ teams: 'mockTeams', loginOptions: 'mockLoginOptions', + aiSummaries: {}, cc: { - aiSummaries: {}, on: jest.fn(), off: jest.fn(), addressBook: { @@ -3914,7 +3914,7 @@ describe('storeEventsWrapper', () => { const summaryError = (errorCode: string) => Object.assign(new Error(errorCode), {data: {errorCode}}); - const entryFor = (role: AISummaryRole) => storeWrapper.cc.aiSummaries[INTERACTION_ID]?.[role]; + const entryFor = (role: AISummaryRole) => storeWrapper.aiSummaries[INTERACTION_ID]?.[role]; const registerTaskEventListeners = (task: ITask) => (storeWrapper as unknown as {registerTaskEventListeners: (t: ITask) => void}).registerTaskEventListeners(task); @@ -3923,12 +3923,12 @@ describe('storeEventsWrapper', () => { task.on.mock.calls.find(([event]) => event === TASK_EVENTS.TASK_MID_CALL_SUMMARY_RECEIVED)?.[1]; beforeEach(() => { - storeWrapper['store'].cc.aiSummaries = {}; + storeWrapper['store'].aiSummaries = {}; storeWrapper['aiSummaryReceivedListeners'] = {}; }); afterEach(() => { - storeWrapper['store'].cc.aiSummaries = {}; + storeWrapper['store'].aiSummaries = {}; }); describe('requests', () => { @@ -3977,7 +3977,7 @@ describe('storeEventsWrapper', () => { expect(postCallOnly.requestMidCallSummary).not.toHaveBeenCalled(); expect(midCallOnly.requestPostCallSummary).not.toHaveBeenCalled(); - expect(storeWrapper.cc.aiSummaries).toEqual({}); + expect(storeWrapper.aiSummaries).toEqual({}); }); it('should use SDK summary enablement without filtering the task media type', async () => { @@ -4106,13 +4106,13 @@ describe('storeEventsWrapper', () => { reply.resolve(aiSummaryFixtures.initiatingMidCall.typedSections); await request; - expect(storeWrapper.cc.aiSummaries[INTERACTION_ID]).toBeUndefined(); + expect(storeWrapper.aiSummaries[INTERACTION_ID]).toBeUndefined(); }); it('should do nothing without a task', async () => { await storeWrapper.requestPostCallSummary(undefined); - expect(storeWrapper.cc.aiSummaries).toEqual({}); + expect(storeWrapper.aiSummaries).toEqual({}); }); }); @@ -4288,9 +4288,213 @@ describe('storeEventsWrapper', () => { }); }); + describe('editable summary fields', () => { + it('should edit plain text with an empty baseline when the SDK supplies a card without summaryText', async () => { + const task = makeAISummaryTask(); + storeWrapper['store'].currentTask = task; + const content: AISummary = { + conversationId: INTERACTION_ID, + editAdaptiveCard: {type: 'AdaptiveCard', body: [{type: 'Input.Text', id: 'conversationSummaryTextInput'}]}, + }; + task.requestMidCallSummary.mockResolvedValueOnce(content); + await storeWrapper.requestMidCallSummary('CONSULT', task); + + expect(storeWrapper.editAISummary('initiator', 'summaryText', 'New summary', content, task)).toBe(true); + expect(entryFor('initiator').response).toMatchObject({summary: 'New summary', numberOfTimesEdited: 1}); + expect(content.summaryText).toBeUndefined(); + expect(entryFor('initiator').content).toBe(content); + }); + + it.each(['initialContactReason', 'nextSteps'] as const)( + 'should accept a missing optional %s section with an empty baseline and remove a reverted override', + async (key) => { + const task = makeAISummaryTask(); + storeWrapper['store'].currentTask = task; + const content: AISummary = {conversationId: INTERACTION_ID, sections: {keyActionsTaken: 'Verified account'}}; + task.requestMidCallSummary.mockResolvedValueOnce(content); + await storeWrapper.requestMidCallSummary('CONSULT', task); + const initialResponse = entryFor('initiator').response; + + expect(storeWrapper.editAISummary('initiator', key, '', content, task)).toBe(true); + expect(entryFor('initiator').response).toBe(initialResponse); + expect(storeWrapper.editAISummary('initiator', key, 'Newly entered value', content, task)).toBe(true); + expect(entryFor('initiator').response).toMatchObject({ + summary: {[key]: 'Newly entered value'}, + numberOfTimesEdited: 1, + }); + expect(entryFor('initiator').content).toBe(content); + expect(content.sections[key]).toBeUndefined(); + expect(storeWrapper.editAISummary('initiator', key, '', content, task)).toBe(true); + expect(entryFor('initiator').response).toMatchObject({summary: {}, numberOfTimesEdited: 1}); + expect( + storeWrapper.editAISummary('initiator', 'resolution' as 'initialContactReason', 'Read only', content, task) + ).toBe(false); + expect( + storeWrapper.editAISummary('initiator', 'unknownField' as 'initialContactReason', 'Unknown', content, task) + ).toBe(false); + expect(task.sendMidCallSummaryResponse).not.toHaveBeenCalled(); + } + ); + }); + + describe('mid-call summary exclusion', () => { + it('should exclude and restore the summary locally without changing its content or edits', async () => { + const task = makeAISummaryTask(); + storeWrapper['store'].currentTask = task; + await storeWrapper.requestMidCallSummary('CONSULT', task); + const content = entryFor('initiator').content; + storeWrapper.editAISummary('initiator', 'reasonForTransferOrConsult', 'Edited reason', content, task); + const response = entryFor('initiator').response; + + expect(storeWrapper.setAISummaryExcluded(true, content, task)).toBe(true); + expect(entryFor('initiator')).toMatchObject({excluded: true, response: {...response, state: 'EXCLUDED'}}); + expect(entryFor('initiator').content).toBe(content); + expect( + storeWrapper.editAISummary('initiator', 'reasonForTransferOrConsult', 'Blocked edit', content, task) + ).toBe(false); + expect(storeWrapper.recordAISummaryCopied('initiator', content, task)).toBe(false); + expect(storeWrapper.setAISummaryFeedback('initiator', 'thumbs_up', content, task)).toBe(false); + expect(task.sendMidCallSummaryResponse).not.toHaveBeenCalled(); + + await storeWrapper.sendMidCallSummaryResponse('CONSULT', task); + expect(task.sendMidCallSummaryResponse).toHaveBeenCalledWith( + expect.objectContaining({state: 'EXCLUDED'}), + 'CONSULT' + ); + expect(storeWrapper.setAISummaryExcluded(false, content, task)).toBe(true); + expect(entryFor('initiator')).toMatchObject({excluded: false, response}); + }); + + it('should retain exclusion selected during generation and give a failed empty request precedence', async () => { + const task = makeAISummaryTask(); + storeWrapper['store'].currentTask = task; + const pending = deferred(); + task.requestMidCallSummary.mockReturnValueOnce(pending.promise); + const request = storeWrapper.requestMidCallSummary('CONSULT', task); + expect(storeWrapper.setAISummaryExcluded(true, undefined, task)).toBe(true); + expect(entryFor('initiator').response.state).toBe('EXCLUDED'); + expect(storeWrapper.setAISummaryExcluded(false, undefined, task)).toBe(true); + expect(entryFor('initiator').response.state).toBe('IGNORED'); + expect(storeWrapper.setAISummaryExcluded(true, undefined, task)).toBe(true); + pending.resolve(aiSummaryFixtures.initiatingMidCall.typedSections); + await request; + expect(entryFor('initiator')).toMatchObject({excluded: true, response: {state: 'EXCLUDED'}}); + + const replacement = {...aiSummaryFixtures.initiatingMidCall.typedSections}; + task.requestMidCallSummary.mockResolvedValueOnce(replacement); + await storeWrapper.requestMidCallSummary('TRANSFER', task); + expect(entryFor('initiator')).toMatchObject({excluded: true, response: {state: 'EXCLUDED'}}); + expect(entryFor('initiator').content).toBe(replacement); + + storeWrapper['store'].aiSummaries = {}; + const failure = deferred(); + task.requestMidCallSummary.mockReturnValueOnce(failure.promise); + const failedRequest = storeWrapper.requestMidCallSummary('CONSULT', task); + storeWrapper.setAISummaryExcluded(true, undefined, task); + failure.reject(summaryError('MID_CALL_SUMMARY_TIMEOUT')); + await failedRequest; + expect(entryFor('initiator')).toMatchObject({excluded: true, response: {state: 'NOT_RECEIVED'}}); + expect(storeWrapper.setAISummaryExcluded(false, undefined, task)).toBe(true); + expect(entryFor('initiator').response.state).toBe('NOT_RECEIVED'); + expect(storeWrapper.setAISummaryExcluded(true, undefined, task)).toBe(true); + expect(entryFor('initiator').response.state).toBe('NOT_RECEIVED'); + }); + + it('should reject stale exclusion controls and never recreate a removed summary', async () => { + const task = makeAISummaryTask(); + storeWrapper['store'].currentTask = task; + await storeWrapper.requestMidCallSummary('CONSULT', task); + const content = entryFor('initiator').content; + task.requestMidCallSummary.mockResolvedValueOnce({...aiSummaryFixtures.initiatingMidCall.typedSections}); + await storeWrapper.requestMidCallSummary('CONSULT', task); + expect(storeWrapper.setAISummaryExcluded(true, content, task)).toBe(false); + storeWrapper['store'].currentTask = makeAISummaryTask(); + expect(storeWrapper.setAISummaryExcluded(true, entryFor('initiator').content, task)).toBe(false); + storeWrapper['store'].currentTask = task; + storeWrapper['store'].aiSummaries = {}; + expect(storeWrapper.setAISummaryExcluded(true, undefined, task)).toBe(false); + expect(storeWrapper.aiSummaries).toEqual({}); + }); + + it('should clear a closed picker choice after mid-call capability is revoked', async () => { + const task = makeAISummaryTask(); + storeWrapper['store'].currentTask = task; + await storeWrapper.requestMidCallSummary('CONSULT', task); + const content = entryFor('initiator').content; + storeWrapper.setAISummaryExcluded(true, content, task); + Object.assign(task, {aiSummaryCapabilities: {midCallEnabled: false, postCallEnabled: true}}); + + expect(storeWrapper.setAISummaryExcluded(false, content, task)).toBe(true); + expect(entryFor('initiator')).toMatchObject({excluded: false, response: {state: 'DEFAULT'}}); + expect(storeWrapper.setAISummaryExcluded(true, content, task)).toBe(false); + expect(task.sendMidCallSummaryResponse).not.toHaveBeenCalled(); + }); + + it('should send cancellation as a response snapshot before resetting exclusion', async () => { + const task = makeAISummaryTask(); + storeWrapper['store'].currentTask = task; + await storeWrapper.requestMidCallSummary('CONSULT', task); + const content = entryFor('initiator').content; + storeWrapper.setAISummaryExcluded(true, content, task); + const response = entryFor('initiator').response; + const sent = deferred(); + task.sendMidCallSummaryResponse.mockReturnValueOnce(sent.promise); + + const cancellation = storeWrapper.sendMidCallSummaryResponse('CONSULT', task, 'MID_CALL_CANCELLED'); + storeWrapper.setAISummaryExcluded(false, content, task); + expect(task.sendMidCallSummaryResponse).toHaveBeenCalledWith( + {...response, state: 'MID_CALL_CANCELLED'}, + 'CONSULT' + ); + expect(entryFor('initiator').response.state).toBe('DEFAULT'); + sent.resolve(); + await expect(cancellation).resolves.toBe('submitted'); + }); + }); + describe('feedback', () => { + it('should clear deferred feedback without changing summary content, edits or copy counts', async () => { + const task = makeAISummaryTask(); + storeWrapper['store'].currentTask = task; + await storeWrapper.requestMidCallSummary('CONSULT', task); + const content = entryFor('initiator').content; + storeWrapper.setAISummaryFeedback('initiator', 'thumbs_up', content, task); + storeWrapper.editAISummary('initiator', 'reasonForTransferOrConsult', 'Edited reason', content, task); + storeWrapper.recordAISummaryCopied('initiator', content, task); + const response = entryFor('initiator').response; + + expect(storeWrapper.setAISummaryFeedback('initiator', 'none', content, task)).toBe(true); + + expect(entryFor('initiator').content).toBe(content); + expect(entryFor('initiator').response).toEqual({...response, feedback: 'none'}); + expect(task.sendMidCallSummaryResponse).not.toHaveBeenCalled(); + }); + + it('should send receiver feedback deselection and keep the previous selection until confirmed', async () => { + const task = makeAISummaryTask(); + storeWrapper['store'].currentTask = task; + registerTaskEventListeners(task); + const content = aiSummaryFixtures.receivingMidCall.adaptiveCard; + receivedListenerOf(task)(content); + await storeWrapper.setReceiverSummaryFeedback('thumbs_up', content, task); + const response = entryFor('receiver').response; + const send = deferred(); + task.sendMidCallSummaryResponse.mockReturnValueOnce(send.promise); + + const clear = storeWrapper.setReceiverSummaryFeedback('none', content, task); + + expect(task.sendMidCallSummaryResponse).toHaveBeenLastCalledWith({...response, feedback: 'none'}, 'TRANSFER'); + expect(entryFor('receiver')).toMatchObject({feedbackPending: true, response: {feedback: 'thumbs_up'}}); + send.resolve(); + await clear; + + expect(entryFor('receiver').content).toBe(content); + expect(entryFor('receiver')).toMatchObject({feedbackPending: false, response: {...response, feedback: 'none'}}); + }); + it('should send the receiving agent feedback with SDK text and keep it once the SDK confirms it', async () => { const task = makeAISummaryTask(); + storeWrapper['store'].currentTask = task; registerTaskEventListeners(task); receivedListenerOf(task)(aiSummaryFixtures.receivingMidCall.adaptiveCard); const send = deferred(); @@ -4312,7 +4516,7 @@ describe('storeEventsWrapper', () => { ); const current = entryFor('receiver'); - storeWrapper['store'].cc.aiSummaries[INTERACTION_ID].receiver = { + storeWrapper['store'].aiSummaries[INTERACTION_ID].receiver = { ...current, response: {...current.response, summary: 'Caller-owned text', numberOfTimesCopied: 1}, }; @@ -4326,6 +4530,7 @@ describe('storeEventsWrapper', () => { it('should send empty text when the receiver SDK payload contains only a card', async () => { const task = makeAISummaryTask(); + storeWrapper['store'].currentTask = task; registerTaskEventListeners(task); const payload: AISummary = { conversationId: INTERACTION_ID, @@ -4341,28 +4546,33 @@ describe('storeEventsWrapper', () => { ); }); - it('should keep the previous receiver selection when the feedback response fails', async () => { - const task = makeAISummaryTask(); - registerTaskEventListeners(task); - receivedListenerOf(task)(aiSummaryFixtures.receivingMidCall.adaptiveCard); - await storeWrapper.setReceiverSummaryFeedback( - 'thumbs_up', - aiSummaryFixtures.receivingMidCall.adaptiveCard, - task - ); - task.sendMidCallSummaryResponse.mockRejectedValueOnce(new Error('503')); - - await storeWrapper.setReceiverSummaryFeedback( - 'thumbs_down', - aiSummaryFixtures.receivingMidCall.adaptiveCard, - task - ); - - expect(entryFor('receiver')).toMatchObject({feedbackPending: false, response: {feedback: 'thumbs_up'}}); - }); + it.each(['thumbs_down', 'none'] as const)( + 'should keep the previous receiver selection when %s feedback fails', + async (feedback) => { + const task = makeAISummaryTask(); + storeWrapper['store'].currentTask = task; + registerTaskEventListeners(task); + receivedListenerOf(task)(aiSummaryFixtures.receivingMidCall.adaptiveCard); + await storeWrapper.setReceiverSummaryFeedback( + 'thumbs_up', + aiSummaryFixtures.receivingMidCall.adaptiveCard, + task + ); + task.sendMidCallSummaryResponse.mockRejectedValueOnce(new Error('503')); + + await storeWrapper.setReceiverSummaryFeedback( + feedback, + aiSummaryFixtures.receivingMidCall.adaptiveCard, + task + ); + + expect(entryFor('receiver')).toMatchObject({feedbackPending: false, response: {feedback: 'thumbs_up'}}); + } + ); it('should ignore overlapping or stale receiver feedback', async () => { const task = makeAISummaryTask(); + storeWrapper['store'].currentTask = task; registerTaskEventListeners(task); receivedListenerOf(task)(aiSummaryFixtures.receivingMidCall.adaptiveCard); task.sendMidCallSummaryResponse.mockReturnValue(deferred().promise); @@ -4390,6 +4600,7 @@ describe('storeEventsWrapper', () => { it('should not apply confirmed receiver feedback to a summary that replaced it in flight', async () => { const task = makeAISummaryTask(); + storeWrapper['store'].currentTask = task; registerTaskEventListeners(task); const original = aiSummaryFixtures.receivingMidCall.adaptiveCard; receivedListenerOf(task)(original); @@ -4409,6 +4620,7 @@ describe('storeEventsWrapper', () => { it('should not recreate a removed interaction when receiver feedback completes', async () => { const task = makeAISummaryTask(); + storeWrapper['store'].currentTask = task; registerTaskEventListeners(task); const original = aiSummaryFixtures.receivingMidCall.adaptiveCard; receivedListenerOf(task)(original); @@ -4420,11 +4632,12 @@ describe('storeEventsWrapper', () => { send.resolve(); await feedback; - expect(storeWrapper.cc.aiSummaries[INTERACTION_ID]).toBeUndefined(); + expect(storeWrapper.aiSummaries[INTERACTION_ID]).toBeUndefined(); }); it('should not apply old feedback after the interaction is removed and recreated with a new payload', async () => { const task = makeAISummaryTask(); + storeWrapper['store'].currentTask = task; registerTaskEventListeners(task); const original = aiSummaryFixtures.receivingMidCall.adaptiveCard; receivedListenerOf(task)(original); @@ -4521,7 +4734,7 @@ describe('storeEventsWrapper', () => { }); describe('post-call submission', () => { - it('should add the wrap-up code and send the SDK response before task wrap-up', async () => { + it('should send the wrap-up code without changing the stored summary response', async () => { const task = makeAISummaryTask(); await storeWrapper.requestPostCallSummary(task); @@ -4534,9 +4747,11 @@ describe('storeEventsWrapper', () => { numberOfTimesCopied: 3, }; entryFor('post-call').response = response; + const summaries = storeWrapper.aiSummaries; await expect(storeWrapper.sendPostCallSummaryResponse('aux-billing', task)).resolves.toBe('submitted'); - expect(entryFor('post-call').response).toEqual({...response, wrapUpCode: 'aux-billing'}); + expect(storeWrapper.aiSummaries).toBe(summaries); + expect(entryFor('post-call').response).toBe(response); expect(task.sendPostCallSummaryResponse).toHaveBeenCalledWith({...response, wrapUpCode: 'aux-billing'}); expect(entryFor('post-call').content).toBe(aiSummaryFixtures.postCall.plainText); @@ -4579,14 +4794,14 @@ describe('storeEventsWrapper', () => { it('should clear only the removed interaction summaries', async () => { const task = makeAISummaryTask(); await storeWrapper.requestMidCallSummary('CONSULT', task); - storeWrapper['store'].cc.aiSummaries = { - ...storeWrapper['store'].cc.aiSummaries, + storeWrapper['store'].aiSummaries = { + ...storeWrapper['store'].aiSummaries, 'other-interaction': {'post-call': entryFor('initiator')}, }; storeWrapper.handleTaskRemove(task); - expect(Object.keys(storeWrapper.cc.aiSummaries)).toEqual(['other-interaction']); + expect(Object.keys(storeWrapper.aiSummaries)).toEqual(['other-interaction']); }); }); diff --git a/packages/contact-center/store/webpack.config.js b/packages/contact-center/store/webpack.config.js index 601dfbd42..eb072fffc 100644 --- a/packages/contact-center/store/webpack.config.js +++ b/packages/contact-center/store/webpack.config.js @@ -13,12 +13,8 @@ module.exports = mergeWithCustomize({ { test: /\.(ts|tsx)$/, exclude: /node_modules/, - use: { - loader: 'ts-loader', - options: { - transpileOnly: true, - }, - }, + // Emit the declarations required by downstream workspace type imports. + use: 'ts-loader', }, ], }, diff --git a/packages/contact-center/task/ai-docs/task-spec.md b/packages/contact-center/task/ai-docs/task-spec.md index 356b545c5..bd950c428 100644 --- a/packages/contact-center/task/ai-docs/task-spec.md +++ b/packages/contact-center/task/ai-docs/task-spec.md @@ -435,7 +435,7 @@ The six widget shells are siblings that each bind to exactly one hook and one pr - **UC-5 Mute / unmute (CallControl):** Agent clicks mute → `toggleMute()` (gated by `controlVisibility.muteUnmute`) → `currentTask.toggleMute()` → `store.setIsMuted`, `onToggleMute`. Evidence: `src/helper.ts`, `tests/helper.ts` ("should successfully toggle mute…", "rapid toggleMute"). UI flow: shows error-safe revert on failure. - **UC-6 Consult an agent/queue/EP-DN then transfer or conference (CallControl):** Agent opens consult modal → component reads the matching ordered category array from `currentTask.uiControls.consultTransferDestinations` → `consultCall(dest,type,allow)` → on completion `consultTransfer()` (or `transferConference` in conference) or `endConsultCall()`. Evidence: `src/helper.ts`, `src/CallControl/index.tsx`, `packages/contact-center/cc-components/src/components/task/CallControl/call-control.tsx`. - **UC-7 Blind transfer (CallControl):** Agent picks destination → `transferCall(to,type)` → `currentTask.transfer(...)`; failure re-thrown to the modal. Evidence: `src/helper.ts`, `tests/helper.ts` ("should call transferCall successfully"). -- **UC-8 Wrap up a call (CallControl):** Call ends → wrap-up codes shown → Agent selects code → when enabled, await `store.sendPostCallSummaryResponse(auxCodeId, currentTask)` with that exact task and selected code → `currentTask.wrapup({wrapUpReason, auxCodeId})` → next task promoted to `currentTask`, agent → ENGAGED. The disabled capability skips summary submission; both `response-failed` and unexpected summary submission rejections allow wrap-up to continue. Evidence: `src/helper.ts`, `tests/helper.ts` ("should call wrapupCall" and "post-call summary submission during wrap-up", including deferred success ordering, both failure paths, and disabled capability). Auto-wrap-up: countdown shown with Cancel. UI flow: wrap-up dropdown + optional auto-wrap-up countdown. +- **UC-8 Wrap up a call (CallControl):** Call ends → wrap-up codes shown → Agent selects code → when enabled, dispatch `store.sendPostCallSummaryResponse(auxCodeId, currentTask)` with that exact task and selected code → immediately call `currentTask.wrapup({wrapUpReason, auxCodeId})` → next task promoted to `currentTask`, agent → ENGAGED. Summary acknowledgement does not delay wrap-up; disabled capability skips submission and failed sends are logged. Evidence: `src/helper.ts`, `tests/helper.ts` ("should call wrapupCall" and "post-call summary submission during wrap-up", including pending summary submission, send failures, and disabled capability). Auto-wrap-up: countdown shown with Cancel. UI flow: wrap-up dropdown + optional auto-wrap-up countdown. - **UC-9 Place an outbound call (OutdialCall):** Agent enters number, selects ANI → dial → `startOutdial(destination, origin)`; empty number alerts and aborts; disabled while a telephony task is active. Evidence: `src/helper.ts` (`useOutdialCall`), `tests/OutdialCall/index.tsx`. UI flow: dialpad with validation, ANI dropdown, address book toggled by `isAddressBookEnabled`. - **UC-10 Manage concurrent tasks (TaskList):** Agent sees all tasks → accept/decline per task or click to select → selection sets `currentTask` so CallControl follows. Evidence: `src/helper.ts` (`useTaskList`), `tests/helper.ts` (task-list cases), `tests/TaskList/index.tsx`. - **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`. @@ -558,6 +558,81 @@ Tests are split between widget-shell render tests (each `tests//index.ts | `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 | +## AI summaries in CallControl + +`getAISummaryProps` in `src/helper.ts` maps the current task's SDK capabilities and +`store.aiSummaries` entries into optional mid-call and post-call views. +It reads `task.aiSummaryCapabilities` directly, with no additional media-type filter, +and supplies no summary props when both capabilities are disabled. Each view receives +the entry's raw SDK content, which can be absent during generation or failure, and +feedback from `entry.response.feedback`. An enabled view without an entry starts in +the generating state. Unavailable transcripts take priority over content; retained +content remains visible during a refresh or after a failed refresh. The adapter imports +the view prop type from `@webex/cc-components` and passes the SDK content unchanged, +with `entry.response.summary` supplied separately as the editable display values. +Edits, copy counts and deferred feedback call the store's `editAISummary`, +`recordAISummaryCopied` and `setAISummaryFeedback` methods. The store updates that +existing SDK response record inside `runInAction`; the task helper does not import MobX +or mutate the SDK summary. Each callback captures the selected task and raw SDK +content identity, so the store rejects stale controls after a task switch, replacement +summary or pending refresh. Only canonical SDK section fields or plain summary text can be edited; +omitted optional sections shown by the SDK edit card use an empty-string baseline. Unknown keys +and the separate resolution remain read-only. The renderer forwards each real SDK `Input.Text.id` +and validates it against the shared canonical editable keys before recording a section edit. Unchanged +values leave the response untouched, and reverting a structured field removes that +field's response override. The edit count is the existing zero-or-one edited flag. +Requests and response submission remain store/SDK +operations, with no widget-owned transport validation or correlation. + +The mid-call view also receives `excluded` and `onExcludeChange`, wired to +`store.setAISummaryExcluded` with the displayed content and selected task identities. +Exclude changes only local state until the final action sends its response, disables +summary editing/copy/feedback, and leaves a valid destination actionable. The choice +survives generation completion and Retry. An empty failed request sends `NOT_RECEIVED` +ahead of `EXCLUDED`; clearing the checkbox restores the content or pending baseline state. +`cancelMidCallSummary(action)` sends a `MID_CALL_CANCELLED` response snapshot through +the store. Only footer Cancel invokes it before `resetMidCallSummaryExclusion`. +Header close, Escape, and successful submission close through the reset callback +without cancellation. Both callbacks are bound to the active task; +the reset reads its latest content so a completed in-flight request cannot retain an +old checkbox selection on reopening. Reopening therefore starts unchecked. + +Opening consult/transfer requests the corresponding mid-call summary exactly once. +The summary-enabled voice presentation uses the shared Momentum Dialog, while +non-summary destinations and keypad retain their popover presentation. Retry requests +the currently open Consult/Transfer action through the same widget callback. +Summary-enabled voice rows select a destination; only the explicit final Consult or +Transfer action invokes the widget callback. Search/reload invalidates stale +selections and Refresh stays available. Wrap-up reason searches clear the previous +selection before completion can use a hidden reason. Summary feedback callbacks +accept `none` to withdraw a previous selection. `consultCall` +and `transferCall` send the initiator response through the store as the existing SDK +operation starts; they do not wait for summary submission. When post-call summary +capability is enabled, `wrapupCall` dispatches +`store.sendPostCallSummaryResponse(auxCodeId, currentTask)` without awaiting its +acknowledgement, then immediately calls and awaits Task `wrapup` before promoting the +next task. The store snapshots the response synchronously and logs submission failures. +The response's `wrapUpCode` is the selected code ID; disabled capability skips submission. +An unedited structured summary has an empty `summary` object. The UI reuses the existing +wrap-up handler, which clears the selected reason immediately after dispatch. +Desktop sends the summary after successful wrap-up; widgets dispatch it beforehand to +capture the current response. Neither path delays wrap-up for summary acknowledgement. + +Widgets access summary APIs through `@webex/cc-store`. +Summary views expose one `controlsDisabled` flag for request loading. The +consult/transfer panel combines it with exclusion before rendering `AISummary`. + +### Reactive summary updates + +The store observes its own `aiSummaries` map by reference. CallControl's +observer reads that property through `getAISummaryProps`, so request completion, +failure and local response updates refresh an open summary view without another task +event or reopening the consult/transfer panel. SDK objects and summary payloads +retain their original identities. + +Evidence: `src/helper.ts`, `src/CallControl/index.tsx`, `src/task.types.ts`; +tests: AI summary cases in `tests/helper.ts`. + ## 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) diff --git a/packages/contact-center/task/src/helper.ts b/packages/contact-center/task/src/helper.ts index 8d7a57b1f..4dc441947 100644 --- a/packages/contact-center/task/src/helper.ts +++ b/packages/contact-center/task/src/helper.ts @@ -30,7 +30,9 @@ import store, { MEDIA_TYPE_TELEPHONY_LOWER, RealTimeTranscriptionData, } from '@webex/cc-store'; +import type {AISummaryRole} from '@webex/cc-store'; import {shouldShowWxAppTelephonyControls} from '@webex/cc-components'; +import type {CallControlAISummaryProps} from '@webex/cc-components'; import { getTelephonyToastDisplay, reportWxAppTelephonyFailure, @@ -495,6 +497,77 @@ export const useIncomingTask = (props: UseTaskProps) => { }; }; +/** + * The initiating agent's consult/transfer summary and the wrap-up summary of a task. + * Undefined when the SDK reports both as disabled for the task. + */ +export const getAISummaryProps = (currentTask?: ITask): CallControlAISummaryProps | undefined => { + const midCallEnabled = currentTask?.aiSummaryCapabilities?.midCallEnabled; + const postCallEnabled = currentTask?.aiSummaryCapabilities?.postCallEnabled; + if (!midCallEnabled && !postCallEnabled) { + return undefined; + } + + const interactionId = currentTask.data.interactionId; + const entries = store.aiSummaries[interactionId]; + const viewOf = (role: Exclude): NonNullable => { + const entry = entries?.[role]; + const content = entry?.content; + return { + state: + content?.areTranscriptsAvailable === false + ? 'unavailable' + : content + ? 'content' + : !entry || entry.status === 'loading' + ? 'generating' + : 'generic-error', + content, + editedSummary: entry?.response.summary, + selectedFeedback: entry?.response.feedback ?? 'none', + controlsDisabled: entry?.status === 'loading', + ...(role === 'initiator' + ? { + excluded: entry?.excluded ?? false, + onExcludeChange: (excluded: boolean) => store.setAISummaryExcluded(excluded, content, currentTask), + } + : {}), + onEdit: (key, value) => store.editAISummary(role, key, value, content, currentTask), + onCopy: () => store.recordAISummaryCopied(role, content, currentTask), + onFeedback: (feedback) => store.setAISummaryFeedback(role, feedback, content, currentTask), + }; + }; + return { + midCall: midCallEnabled ? viewOf('initiator') : undefined, + postCall: postCallEnabled + ? { + ...viewOf('post-call'), + onRetry: async () => { + await store.requestPostCallSummary(currentTask); + }, + } + : undefined, + // Like Agent Desktop, a wrap-up requests its summary once; Retry asks again. + requestPostCallSummary: () => { + if (!store.aiSummaries[interactionId]?.['post-call']) { + void store.requestPostCallSummary(currentTask); + } + }, + requestMidCallSummary: async (action) => { + await store.requestMidCallSummary(action, currentTask); + }, + cancelMidCallSummary: (action) => { + if (store.currentTask === currentTask) { + void store.sendMidCallSummaryResponse(action, currentTask, 'MID_CALL_CANCELLED'); + } + }, + resetMidCallSummaryExclusion: () => { + const current = store.aiSummaries[interactionId]?.initiator; + store.setAISummaryExcluded(false, current?.content, currentTask); + }, + }; +}; + export const useCallControl = (props: useCallControlProps) => { const { currentTask, @@ -1244,16 +1317,8 @@ export const useCallControl = (props: useCallControlProps) => { try { // Store auxCodeId for use in wrapupCallCallback lastWrapupAuxCodeIdRef.current = auxCodeId; - if (currentTask.aiSummaryCapabilities?.postCallEnabled) { - try { - await store.sendPostCallSummaryResponse(auxCodeId, currentTask); - } catch (error) { - logger?.error(`CC-Widgets: AI summary response failed: ${error}`, { - module: 'useCallControl', - method: 'wrapupCall', - }); - } + void store.sendPostCallSummaryResponse(auxCodeId, currentTask); } try { @@ -1278,6 +1343,7 @@ export const useCallControl = (props: useCallControlProps) => { }; const transferCall = async (to: string, type: DestinationType) => { + void store.sendMidCallSummaryResponse('TRANSFER', currentTask); try { await currentTask.transfer({to, destinationType: type}); logger.info('transferCall success', {module: 'useCallControl', method: 'transferCall'}); @@ -1352,6 +1418,7 @@ export const useCallControl = (props: useCallControlProps) => { store.setCurrentConsultQueueId(consultDestination); } + void store.sendMidCallSummaryResponse('CONSULT', currentTask); try { await currentTask.consult(consultPayload); store.setIsQueueConsultInProgress(false); @@ -1601,6 +1668,7 @@ export const useCallControl = (props: useCallControlProps) => { }, [currentTask, controls, agentId, consultMediaIsHold, consultMediaId, participantConsultState]); const isCampaignCall = currentTask ? isCampaignPreviewTask(currentTask) : false; + const aiSummary = getAISummaryProps(currentTask); return { currentTask, @@ -1652,6 +1720,7 @@ export const useCallControl = (props: useCallControlProps) => { getEntryPoints, getQueuesFetcher, isCampaignCall, + ...(aiSummary ? {aiSummary} : {}), telephonyToast, dismissTelephonyToast, }; diff --git a/packages/contact-center/task/tests/helper.ts b/packages/contact-center/task/tests/helper.ts index 6d0a4eaba..77d06fd05 100644 --- a/packages/contact-center/task/tests/helper.ts +++ b/packages/contact-center/task/tests/helper.ts @@ -1,5 +1,6 @@ import {renderHook, act, waitFor} from '@testing-library/react'; -import {useIncomingTask, useTaskList, useCallControl, useOutdialCall} from '../src/helper'; +import {runInAction} from 'mobx'; +import {useIncomingTask, useTaskList, useCallControl, useOutdialCall, getAISummaryProps} from '../src/helper'; import {resetMuteCoordinatorForTests} from '../src/mute-coordinator'; import {resetOfferActionAttemptsForTests} from '../src/offer-action-attempts'; import { @@ -22,6 +23,8 @@ import { createMockTaskUIControls, disabledControl, enabledControl, + makeMockTask, + aiSummaryFixtures, } from '@webex/test-fixtures'; import store from '@webex/cc-store'; const taskMock = { @@ -43,6 +46,57 @@ const onTaskDeclined = jest.fn(); const onTaskSelected = jest.fn().mockImplementation(() => {}); const logger = mockCC.LoggerProxy; +const initialStoreLogger = store.store.logger; + +const AI_SUMMARY_INTERACTION_ID = 'interaction-main-1'; + +const resetAISummaryStoreState = (): void => { + runInAction(() => { + store.store.aiSummaries = {}; + store.store.currentTask = null; + store.store.isAgentLoggedIn = true; + store.store.agentId = 'agent1'; + store.store.logger = initialStoreLogger; + }); +}; + +const createAISummaryTask = (overrides: Partial = {}): ITask => { + const baseTask = makeMockTask({ + data: { + interactionId: AI_SUMMARY_INTERACTION_ID, + mediaResourceId: AI_SUMMARY_INTERACTION_ID, + interaction: { + interactionId: AI_SUMMARY_INTERACTION_ID, + mainInteractionId: AI_SUMMARY_INTERACTION_ID, + mediaChannel: 'telephony', + owner: 'agent1', + media: { + [AI_SUMMARY_INTERACTION_ID]: { + mediaResourceId: AI_SUMMARY_INTERACTION_ID, + mType: 'mainCall', + participants: ['agent1', 'customer1'], + }, + }, + }, + }, + }); + return { + ...baseTask, + aiSummaryCapabilities: {midCallEnabled: true, postCallEnabled: true}, + requestMidCallSummary: jest.fn().mockResolvedValue(aiSummaryFixtures.initiatingMidCall.typedSections), + requestPostCallSummary: jest.fn().mockResolvedValue(aiSummaryFixtures.postCall.structured), + sendMidCallSummaryResponse: jest.fn().mockResolvedValue(undefined), + sendPostCallSummaryResponse: jest.fn().mockResolvedValue(undefined), + ...overrides, + }; +}; + +const activateAISummaryTask = (task: ITask = createAISummaryTask()): ITask => { + runInAction(() => { + store.store.currentTask = task; + }); + return store.currentTask; +}; const resetStoreOfferActionErrors = (): void => { if (typeof store.clearOfferActionError === 'function' && store.offerActionErrors) { @@ -76,6 +130,350 @@ beforeAll(() => { } as IContactCenter; }); +describe('getAISummaryProps', () => { + beforeEach(() => { + resetAISummaryStoreState(); + }); + + afterEach(() => { + resetAISummaryStoreState(); + jest.restoreAllMocks(); + }); + + it('should map SDK summaries and record local edits, copies and deferred feedback in their responses', async () => { + const sdkTask = createAISummaryTask(); + const task = activateAISummaryTask(sdkTask); + const initial = getAISummaryProps(task); + expect(initial?.midCall?.state).toBe('generating'); + expect(initial?.postCall?.state).toBe('generating'); + + await initial?.requestMidCallSummary?.('CONSULT'); + expect(sdkTask.requestMidCallSummary).toHaveBeenCalledWith('CONSULT'); + const midCall = getAISummaryProps(task)?.midCall; + expect(midCall?.state).toBe('content'); + expect(midCall?.content).toBe(store.aiSummaries[AI_SUMMARY_INTERACTION_ID]?.initiator?.content); + midCall?.onFeedback('thumbs_up'); + expect(getAISummaryProps(task)?.midCall?.selectedFeedback).toBe('thumbs_up'); + expect(sdkTask.sendMidCallSummaryResponse).not.toHaveBeenCalled(); + + getAISummaryProps(task)?.requestPostCallSummary?.(); + await Promise.resolve(); + expect(sdkTask.requestPostCallSummary).toHaveBeenCalledWith(); + const postCall = getAISummaryProps(task)?.postCall; + const sdkContent = postCall?.content; + expect(postCall?.state).toBe('content'); + expect(postCall?.onEdit('initialContactReason', 'Edited reason')).toBe(true); + expect(postCall?.onCopy()).toBe(true); + postCall?.onFeedback('thumbs_down'); + + const updated = getAISummaryProps(task)?.postCall; + expect(updated?.content).toBe(sdkContent); + expect(updated?.content?.sections?.initialContactReason).not.toBe('Edited reason'); + expect(updated?.editedSummary).toEqual({initialContactReason: 'Edited reason'}); + expect(updated?.selectedFeedback).toBe('thumbs_down'); + expect(sdkTask.sendPostCallSummaryResponse).not.toHaveBeenCalled(); + await store.sendPostCallSummaryResponse('wrap1', task); + expect(sdkTask.sendPostCallSummaryResponse).toHaveBeenCalledWith( + expect.objectContaining({ + summary: {initialContactReason: 'Edited reason'}, + feedback: 'thumbs_down', + numberOfTimesEdited: 1, + numberOfTimesCopied: 1, + wrapUpCode: 'wrap1', + }) + ); + }); + + it('should omit summaries the SDK reports as disabled for the task', () => { + const disabledTask = createAISummaryTask({ + aiSummaryCapabilities: {midCallEnabled: false, postCallEnabled: false}, + }); + expect(getAISummaryProps(disabledTask)).toBeUndefined(); + expect(getAISummaryProps()).toBeUndefined(); + + const props = getAISummaryProps( + createAISummaryTask({aiSummaryCapabilities: {midCallEnabled: false, postCallEnabled: true}}) + ); + expect(props?.midCall).toBeUndefined(); + expect(props?.postCall?.state).toBe('generating'); + }); + + it('should wire Exclude locally and retain the SDK summary until the owning action submits it', async () => { + const sdkTask = createAISummaryTask(); + const task = activateAISummaryTask(sdkTask); + await store.requestMidCallSummary('CONSULT', task); + const summary = getAISummaryProps(task)?.midCall; + const content = summary.content; + + expect(summary.excluded).toBe(false); + expect(summary.onExcludeChange?.(true)).toBe(true); + expect(getAISummaryProps(task)?.midCall).toMatchObject({excluded: true, content}); + expect(summary.onEdit('reasonForTransferOrConsult', 'Blocked edit')).toBe(false); + expect(summary.onCopy()).toBe(false); + expect(sdkTask.sendMidCallSummaryResponse).not.toHaveBeenCalled(); + + await store.sendMidCallSummaryResponse('CONSULT', task); + expect(sdkTask.sendMidCallSummaryResponse).toHaveBeenCalledWith( + expect.objectContaining({state: 'EXCLUDED'}), + 'CONSULT' + ); + getAISummaryProps(task)?.resetMidCallSummaryExclusion?.(); + expect(getAISummaryProps(task)?.midCall).toMatchObject({excluded: false}); + expect(getAISummaryProps(task)?.midCall?.content).toBe(content); + }); + + it('should send cancellation before clearing exclusion and ignore stale cancellation/reset controls', async () => { + const sdkTask = createAISummaryTask(); + const task = activateAISummaryTask(sdkTask); + await store.requestMidCallSummary('TRANSFER', task); + const props = getAISummaryProps(task); + props.midCall.onExcludeChange?.(true); + props.cancelMidCallSummary?.('TRANSFER'); + props.resetMidCallSummaryExclusion?.(); + expect(sdkTask.sendMidCallSummaryResponse).toHaveBeenCalledWith( + expect.objectContaining({state: 'MID_CALL_CANCELLED'}), + 'TRANSFER' + ); + expect(getAISummaryProps(task)?.midCall?.excluded).toBe(false); + props.midCall.onExcludeChange?.(true); + activateAISummaryTask(createAISummaryTask()); + props.cancelMidCallSummary?.('TRANSFER'); + props.resetMidCallSummaryExclusion?.(); + expect(sdkTask.sendMidCallSummaryResponse).toHaveBeenCalledTimes(1); + expect(store.aiSummaries[AI_SUMMARY_INTERACTION_ID].initiator.excluded).toBe(true); + }); + + it.each(['midCall', 'postCall'] as const)('should clear %s feedback without submitting a summary', async (role) => { + const sdkTask = createAISummaryTask(); + const task = activateAISummaryTask(sdkTask); + await store.requestMidCallSummary('CONSULT', task); + await store.requestPostCallSummary(task); + const summary = getAISummaryProps(task)?.[role]; + summary.onFeedback('thumbs_down'); + expect(getAISummaryProps(task)?.[role]?.selectedFeedback).toBe('thumbs_down'); + + summary.onFeedback('none'); + + expect(getAISummaryProps(task)?.[role]?.selectedFeedback).toBe('none'); + expect(sdkTask.sendMidCallSummaryResponse).not.toHaveBeenCalled(); + expect(sdkTask.sendPostCallSummaryResponse).not.toHaveBeenCalled(); + }); + + it('should prioritize SDK transcript availability and retained content when mapping request state', async () => { + const task = activateAISummaryTask(); + await store.requestMidCallSummary('CONSULT', task); + await store.requestPostCallSummary(task); + const entries = store.aiSummaries[AI_SUMMARY_INTERACTION_ID]; + runInAction(() => { + store.store.aiSummaries = { + [AI_SUMMARY_INTERACTION_ID]: { + initiator: {...entries.initiator, status: 'error', content: undefined}, + 'post-call': {...entries['post-call'], status: 'error'}, + }, + }; + }); + expect(getAISummaryProps(task)?.midCall).toMatchObject({state: 'generic-error', content: undefined}); + expect(getAISummaryProps(task)?.postCall?.state).toBe('content'); + + runInAction(() => { + const postCall = store.aiSummaries[AI_SUMMARY_INTERACTION_ID]['post-call']; + store.aiSummaries[AI_SUMMARY_INTERACTION_ID]['post-call'] = { + ...postCall, + status: 'loading', + content: {...postCall.content, areTranscriptsAvailable: false}, + }; + }); + expect(getAISummaryProps(task)?.postCall).toMatchObject({state: 'unavailable', controlsDisabled: true}); + }); + + it('should reject stale controls after another SDK summary replaces the displayed content', async () => { + const sdkTask = createAISummaryTask(); + const task = activateAISummaryTask(sdkTask); + await store.requestMidCallSummary('CONSULT', task); + const stale = getAISummaryProps(task)?.midCall; + (sdkTask.requestMidCallSummary as jest.Mock).mockResolvedValueOnce({ + ...aiSummaryFixtures.initiatingMidCall.typedSections, + summaryText: 'A replacement summary.', + }); + await store.requestMidCallSummary('TRANSFER', task); + const replacement = store.aiSummaries[AI_SUMMARY_INTERACTION_ID].initiator; + const response = replacement.response; + + expect(stale?.onEdit('reasonForTransferOrConsult', 'Stale edit')).toBe(false); + expect(stale?.onCopy()).toBe(false); + stale?.onFeedback('thumbs_up'); + expect(store.aiSummaries[AI_SUMMARY_INTERACTION_ID].initiator.response).toBe(response); + }); + + it('should reject local response changes after the selected task changes or is removed', async () => { + const task = activateAISummaryTask(); + await store.requestPostCallSummary(task); + const stale = getAISummaryProps(task)?.postCall; + const response = store.aiSummaries[AI_SUMMARY_INTERACTION_ID]['post-call'].response; + activateAISummaryTask(createAISummaryTask()); + expect(stale?.onEdit('initialContactReason', 'Stale edit')).toBe(false); + expect(stale?.onCopy()).toBe(false); + stale?.onFeedback('thumbs_up'); + runInAction(() => { + store.store.currentTask = null; + }); + expect(stale?.onCopy()).toBe(false); + expect(store.aiSummaries[AI_SUMMARY_INTERACTION_ID]['post-call'].response).toBe(response); + }); + + it('should disable response changes while a replacement request is pending and retain edits after failure', async () => { + const sdkTask = createAISummaryTask(); + const task = activateAISummaryTask(sdkTask); + await store.requestPostCallSummary(task); + const content = getAISummaryProps(task)?.postCall; + expect(content?.onEdit('initialContactReason', 'Kept draft')).toBe(true); + let resolveRequest: (value: undefined) => void; + (sdkTask.requestPostCallSummary as jest.Mock).mockImplementationOnce( + () => + new Promise((resolve) => { + resolveRequest = resolve; + }) + ); + const retry = getAISummaryProps(task)?.postCall?.onRetry(); + expect(getAISummaryProps(task)?.postCall).toMatchObject({state: 'content', controlsDisabled: true}); + expect(content?.onCopy()).toBe(false); + expect(content?.onEdit('initialContactReason', 'Pending edit')).toBe(false); + content?.onFeedback('thumbs_up'); + resolveRequest(undefined); + await expect(retry).resolves.toBeUndefined(); + expect(getAISummaryProps(task)?.postCall).toMatchObject({ + state: 'content', + controlsDisabled: false, + selectedFeedback: 'none', + editedSummary: {initialContactReason: 'Kept draft'}, + }); + }); + + it('should record a plain-text edit without changing the SDK summary', async () => { + const task = activateAISummaryTask( + createAISummaryTask({ + requestPostCallSummary: jest.fn().mockResolvedValue({ + conversationId: AI_SUMMARY_INTERACTION_ID, + summaryText: 'Original summary.', + }), + }) + ); + await store.requestPostCallSummary(task); + expect(getAISummaryProps(task)?.postCall?.onEdit('summaryText', 'Edited summary.')).toBe(true); + expect(getAISummaryProps(task)?.postCall).toMatchObject({ + content: {summaryText: 'Original summary.'}, + editedSummary: 'Edited summary.', + }); + }); + + it('should accept unchanged displayed values without changing the response or counting another edit', async () => { + const task = activateAISummaryTask(); + await store.requestPostCallSummary(task); + const summary = getAISummaryProps(task)?.postCall; + const original = summary.content.sections.initialContactReason; + const initialResponse = store.aiSummaries[AI_SUMMARY_INTERACTION_ID]['post-call'].response; + + expect(summary.onEdit('initialContactReason', original)).toBe(true); + expect(store.aiSummaries[AI_SUMMARY_INTERACTION_ID]['post-call'].response).toBe(initialResponse); + expect(initialResponse.numberOfTimesEdited).toBe(0); + expect(summary.onEdit('initialContactReason', 'Edited reason')).toBe(true); + const editedResponse = store.aiSummaries[AI_SUMMARY_INTERACTION_ID]['post-call'].response; + expect(summary.onEdit('initialContactReason', 'Edited reason')).toBe(true); + expect(store.aiSummaries[AI_SUMMARY_INTERACTION_ID]['post-call'].response).toBe(editedResponse); + expect(editedResponse.numberOfTimesEdited).toBe(1); + }); + + it('should remove reverted structured edits so displayed fields fall back to the raw SDK values', async () => { + const task = activateAISummaryTask(); + await store.requestPostCallSummary(task); + const summary = getAISummaryProps(task)?.postCall; + const sdkContent = summary.content; + const original = sdkContent.sections.initialContactReason; + + expect(summary.onEdit('initialContactReason', 'Edited reason')).toBe(true); + expect(summary.onEdit('keyActionsTaken', 'Another edit')).toBe(true); + expect(summary.onEdit('initialContactReason', original)).toBe(true); + const reverted = getAISummaryProps(task)?.postCall; + expect(reverted.content).toBe(sdkContent); + expect(reverted.editedSummary).toEqual({keyActionsTaken: 'Another edit'}); + expect(reverted.content.sections.initialContactReason).toBe(original); + expect(store.aiSummaries[AI_SUMMARY_INTERACTION_ID]['post-call'].response.numberOfTimesEdited).toBe(1); + }); + + it('should accept omitted SDK section fields while rejecting unknown keys and plain text on a structured summary', async () => { + const task = activateAISummaryTask(); + await store.requestMidCallSummary('CONSULT', task); + const summary = getAISummaryProps(task)?.midCall; + expect(summary.onEdit('initialContactReason', 'Newly entered reason')).toBe(true); + const response = store.aiSummaries[AI_SUMMARY_INTERACTION_ID].initiator.response; + expect(response.summary).toEqual({initialContactReason: 'Newly entered reason'}); + expect(summary.onEdit('summaryText', 'Structured summary text is not displayed')).toBe(false); + expect(summary.onEdit('unknownField' as 'initialContactReason', 'Unknown key')).toBe(false); + expect(store.aiSummaries[AI_SUMMARY_INTERACTION_ID].initiator.response).toBe(response); + }); + + it('should call the store for every mid-call summary request', async () => { + const task = createAISummaryTask(); + const requestSpy = jest.spyOn(store, 'requestMidCallSummary').mockResolvedValue(undefined); + const props = getAISummaryProps(task); + + await props?.requestMidCallSummary?.('CONSULT'); + await props?.requestMidCallSummary?.('CONSULT'); + await props?.requestMidCallSummary?.('TRANSFER'); + + expect(requestSpy).toHaveBeenCalledTimes(3); + expect(requestSpy).toHaveBeenLastCalledWith('TRANSFER', task); + }); + + it('should request the post-call summary only once per wrap-up', async () => { + const task = createAISummaryTask(); + const requestSpy = jest.spyOn(store, 'requestPostCallSummary'); + const props = getAISummaryProps(task); + + await act(async () => { + props?.requestPostCallSummary?.(); + await Promise.resolve(); + }); + props?.requestPostCallSummary?.(); + + expect(requestSpy).toHaveBeenCalledTimes(1); + expect(requestSpy).toHaveBeenCalledWith(task); + }); + + it('should report the initiator summary as each consult or transfer starts', async () => { + const task = createAISummaryTask(); + const order: string[] = []; + jest.spyOn(store, 'sendMidCallSummaryResponse').mockImplementation(async (action) => { + order.push(`summary:${action}`); + return 'submitted'; + }); + (task.consult as jest.Mock).mockImplementation(async () => { + order.push('consult'); + }); + (task.transfer as jest.Mock).mockImplementation(async () => { + order.push('transfer'); + }); + const {result} = renderHook(() => + useCallControl({ + currentTask: task, + logger, + isMuted: false, + conferenceEnabled: true, + agentId: 'agent1', + }) + ); + + await act(async () => { + await result.current.consultCall('agent2', 'agent', false); + await result.current.transferCall('agent3', 'agent'); + }); + + expect(order).toEqual(['summary:CONSULT', 'consult', 'summary:TRANSFER', 'transfer']); + expect(store.sendMidCallSummaryResponse).toHaveBeenCalledWith('CONSULT', task); + expect(store.sendMidCallSummaryResponse).toHaveBeenCalledWith('TRANSFER', task); + }); +}); + describe('useIncomingTask Hook', () => { const onRejected = jest.fn(); afterEach(() => { @@ -2924,6 +3322,54 @@ describe('useCallControl', () => { }); }); + it('should wrap up without a summary response when the task has no post-call summary', async () => { + const {result} = renderHook(() => + useCallControl({ + currentTask: mockCurrentTask, + onHoldResume: mockOnHoldResume, + onEnd: mockOnEnd, + onWrapUp: mockOnWrapUp, + logger: mockLogger, + isMuted: false, + conferenceEnabled: true, + agentId: 'test-agent-id', + }) + ); + + await act(async () => { + await result.current.wrapupCall('Wrap reason', '123'); + }); + + expect(mockCurrentTask.wrapup).toHaveBeenCalledWith({wrapUpReason: 'Wrap reason', auxCodeId: '123'}); + }); + + it('should keep the post-call summary when wrap-up fails', async () => { + resetAISummaryStoreState(); + const task = createAISummaryTask({wrapup: jest.fn().mockRejectedValue(new Error('wrap-up failed'))}); + await act(async () => { + await store.requestPostCallSummary(task); + }); + const {result} = renderHook(() => + useCallControl({ + currentTask: task, + onHoldResume: mockOnHoldResume, + onEnd: mockOnEnd, + onWrapUp: mockOnWrapUp, + logger: mockLogger, + isMuted: false, + conferenceEnabled: true, + agentId: 'agent1', + }) + ); + + await act(async () => { + await result.current.wrapupCall('Customer Issue', 'wrap1'); + }); + + expect(task.sendPostCallSummaryResponse).toHaveBeenCalledTimes(1); + expect(store.aiSummaries[AI_SUMMARY_INTERACTION_ID]?.['post-call']?.content).toBeDefined(); + }); + describe('post-call summary submission during wrap-up', () => { const createPostCallTask = () => ({ ...mockCurrentTask, @@ -2931,7 +3377,7 @@ describe('useCallControl', () => { wrapup: jest.fn().mockResolvedValue(undefined), }); - it('should await post-call summary submission with the selected code and task before wrapping up', async () => { + it('should dispatch the selected code and task without waiting for summary submission before wrapping up', async () => { const task = createPostCallTask(); let resolveSubmission!: (result: 'submitted') => void; const submissionPromise = new Promise<'submitted'>((resolve) => { @@ -2955,44 +3401,21 @@ describe('useCallControl', () => { expect(sendSummaryResponse).toHaveBeenCalledTimes(1); expect(sendSummaryResponse).toHaveBeenCalledWith('123', task); - expect(task.wrapup).not.toHaveBeenCalled(); - - await act(async () => { - resolveSubmission('submitted'); - await wrapupPromise; - }); - expect(task.wrapup).toHaveBeenCalledTimes(1); expect(task.wrapup).toHaveBeenCalledWith({wrapUpReason: 'Wrap reason', auxCodeId: '123'}); - }); - - it('should continue wrapping up when post-call summary submission returns response-failed', async () => { - const task = createPostCallTask(); - const sendSummaryResponse = jest.spyOn(store, 'sendPostCallSummaryResponse').mockResolvedValue('response-failed'); - const {result} = renderHook(() => - useCallControl({ - currentTask: task, - logger: mockLogger, - isMuted: false, - conferenceEnabled: true, - agentId: 'test-agent-id', - }) - ); await act(async () => { - await result.current.wrapupCall('Wrap reason', '123'); + await wrapupPromise; }); - - expect(sendSummaryResponse).toHaveBeenCalledWith('123', task); - expect(task.wrapup).toHaveBeenCalledTimes(1); - expect(task.wrapup).toHaveBeenCalledWith({wrapUpReason: 'Wrap reason', auxCodeId: '123'}); + resolveSubmission('submitted'); + await submissionPromise; }); - it('should log unexpected post-call summary rejection and continue wrapping up', async () => { - const task = createPostCallTask(); - const sendSummaryResponse = jest - .spyOn(store, 'sendPostCallSummaryResponse') - .mockRejectedValue(new Error('Unexpected summary submission error')); + it('should continue wrapping up when the SDK summary submission fails', async () => { + const task = { + ...createPostCallTask(), + sendPostCallSummaryResponse: jest.fn().mockRejectedValue(new Error('Summary submission failed')), + }; const {result} = renderHook(() => useCallControl({ currentTask: task, @@ -3007,11 +3430,7 @@ describe('useCallControl', () => { await result.current.wrapupCall('Wrap reason', '123'); }); - expect(sendSummaryResponse).toHaveBeenCalledWith('123', task); - expect(mockLogger.error).toHaveBeenCalledWith( - 'CC-Widgets: AI summary response failed: Error: Unexpected summary submission error', - {module: 'useCallControl', method: 'wrapupCall'} - ); + expect(task.sendPostCallSummaryResponse).toHaveBeenCalledWith(expect.objectContaining({wrapUpCode: '123'})); expect(task.wrapup).toHaveBeenCalledTimes(1); expect(task.wrapup).toHaveBeenCalledWith({wrapUpReason: 'Wrap reason', auxCodeId: '123'}); }); diff --git a/packages/contact-center/test-fixtures/ai-docs/test-fixtures-spec.md b/packages/contact-center/test-fixtures/ai-docs/test-fixtures-spec.md index d8f2b749c..81a3a0e8f 100644 --- a/packages/contact-center/test-fixtures/ai-docs/test-fixtures-spec.md +++ b/packages/contact-center/test-fixtures/ai-docs/test-fixtures-spec.md @@ -36,6 +36,10 @@ The load-bearing contract of this module is structural: each exported mock is ty `mockTask` and `makeMockTask` use the store's exported `ITask` contract. They provide disabled AI summary capabilities and stub request/response methods, so store consumers see the same task shape in tests. +`mockProfile.teams` follows the SDK `TeamList` shape: `id`, `name`, and the required +team, site, membership and queue-ranking metadata. The fixture uses the SDK's +current profile contract rather than the older `teamId`/`teamName` fields. + ## Purpose / Responsibility Provides shared, type-checked mock SDK instances, mock profile/task/queue/agent data, and component-prop fixtures consumed by other packages' Jest tests; it does NOT provide runtime behavior, test runners, or shared test-render helpers. @@ -124,8 +128,8 @@ Shape fidelity is achieved by importing the real SDK/store types and annotating Isolation is offered two ways. Static fixtures (`mockTask`, `mockCC`) are shared singletons — cheap but mutable, so tests that mutate must clone. Factory functions (`makeMockTask`, `makeMockCampaignTask`, `makeMockAddressBook`) return fresh objects with brand-new `jest.fn()`s each call, which is the safe path for tests that mutate or assert call counts. -`mockCC.aiSummaries` starts empty to match the widget-owned state on `IContactCenter`; tests that -populate it must reset or replace it to keep summary state isolated. +Summary state belongs to `Store.aiSummaries`; consumer tests reset that map between +cases. The SDK-shaped `mockCC` contains no widget-owned summary state. Composition keeps the SDK mock single-sourced: outdial fixtures spread `mockCC` rather than redeclaring it, so a change to `mockCC` propagates. The same principle is why `mockQueuesResponse` is derived by mapping `mockQueueDetails` instead of being hand-written twice. diff --git a/packages/contact-center/test-fixtures/src/fixtures.ts b/packages/contact-center/test-fixtures/src/fixtures.ts index 6e3103583..ee5831e9b 100644 --- a/packages/contact-center/test-fixtures/src/fixtures.ts +++ b/packages/contact-center/test-fixtures/src/fixtures.ts @@ -11,7 +11,20 @@ import type { import type {IContactCenter, ITask} from '@webex/cc-store'; const mockProfile: Profile = { - teams: [{teamId: 'team1', teamName: 'Team 1'}], + teams: [ + { + id: 'team1', + name: 'Team 1', + teamType: 'AGENT_BASED', + teamStatus: 'ACTIVE', + active: true, + siteId: 'site123', + siteName: 'Site 1', + userIds: ['agent1'], + rankQueuesForTeam: false, + queueRankings: [], + }, + ], defaultDn: '12345', dn: '12345', forceDefaultDn: false, @@ -608,7 +621,6 @@ const mockQueuesResponse: ContactServiceQueuesResponse = { }; const mockCC: IContactCenter = { - aiSummaries: {}, on: jest.fn(), off: jest.fn(), updateAgentProfile: jest.fn(), diff --git a/widgets-samples/cc/samples-cc-react-app/package.json b/widgets-samples/cc/samples-cc-react-app/package.json index d3b564de1..5a11b6ca7 100644 --- a/widgets-samples/cc/samples-cc-react-app/package.json +++ b/widgets-samples/cc/samples-cc-react-app/package.json @@ -24,7 +24,6 @@ "@momentum-ui/web-components": "^2.26.20", "@webex/cc-ai-assistant": "workspace:*", "@webex/cc-widgets": "workspace:*", - "@webex/contact-center": "3.12.0-next.133", "babel-loader": "^9.2.1", "file-loader": "^6.2.0", "html-webpack-plugin": "^5.6.3", diff --git a/widgets-samples/cc/samples-cc-react-app/src/App.tsx b/widgets-samples/cc/samples-cc-react-app/src/App.tsx index 379cf6bc6..62359afd2 100644 --- a/widgets-samples/cc/samples-cc-react-app/src/App.tsx +++ b/widgets-samples/cc/samples-cc-react-app/src/App.tsx @@ -11,7 +11,6 @@ import { RealTimeTranscript, } from '@webex/cc-widgets'; import {AIAssistant, type WellnessBreakOverlayTarget} from '@webex/cc-ai-assistant'; -import {StationLogoutResponse} from '@webex/contact-center'; import {ERROR_TRIGGERING_IDLE_CODES} from '@webex/cc-store'; import Webex from 'webex'; import { @@ -475,9 +474,7 @@ function App() { return; } - void store.cc - ?.stationLogout({logoutReason: 'Page unload'}) - .catch(() => undefined); + void store.cc?.stationLogout({logoutReason: 'User requested logout'}).catch(() => undefined); }; window.addEventListener('pagehide', handlePageHide); @@ -530,7 +527,7 @@ function App() { const stationLogout = () => { store.cc .stationLogout({logoutReason: 'User requested logout'}) - .then((res: StationLogoutResponse) => { + .then((res) => { if ('data' in res) console.log('Agent logged out successfully', res.data); }) .catch((error: Error) => { diff --git a/yarn.lock b/yarn.lock index 046b8b613..05a93ff8e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -9584,18 +9584,18 @@ __metadata: languageName: node linkType: hard -"@webex/calling@npm:3.12.0-next.113": - version: 3.12.0-next.113 - resolution: "@webex/calling@npm:3.12.0-next.113" +"@webex/calling@npm:3.12.0-next.120": + version: 3.12.0-next.120 + resolution: "@webex/calling@npm:3.12.0-next.120" dependencies: "@types/platform": "npm:1.3.4" "@webex/common": "npm:3.12.0-next.9" "@webex/common-timers": "npm:3.12.0-next.10" "@webex/internal-media-core": "npm:2.31.0" - "@webex/internal-plugin-device": "npm:3.12.0-next.55" - "@webex/internal-plugin-feature": "npm:3.12.0-next.57" - "@webex/internal-plugin-metrics": "npm:3.12.0-next.56" - "@webex/media-helpers": "npm:3.12.0-next.17" + "@webex/internal-plugin-device": "npm:3.12.0-next.58" + "@webex/internal-plugin-feature": "npm:3.12.0-next.63" + "@webex/internal-plugin-metrics": "npm:3.12.0-next.59" + "@webex/media-helpers": "npm:3.12.0-next.18" async-mutex: "npm:0.4.0" backoff: "npm:2.5.0" buffer: "npm:6.0.3" @@ -9604,7 +9604,7 @@ __metadata: uuid: "npm:8.3.2" ws: "npm:8.17.1" xstate: "npm:4.30.6" - checksum: 10c0/3f01242eb718d6f1240f4668231991793297e2036fe8ec7c7b3748969e57f9e8423b8ae56a93fdd79a8c72e391c9fc82209fa03b881966715028cef1504e0966 + checksum: 10c0/93099c85437b1e168c907805148f9c4b2945bb3411687f6af6c0a7a66c09d58b61ec79ff8fb04aa37c2450fae8ae43da32cea7f6a826764a21ca63237b4e844c languageName: node linkType: hard @@ -9816,7 +9816,7 @@ __metadata: "@testing-library/react": "npm:16.0.1" "@types/jest": "npm:29.5.14" "@types/react-test-renderer": "npm:18" - "@webex/contact-center": "npm:3.12.0-next.148" + "@webex/contact-center": "npm:3.12.0-next.158" "@webex/event-dictionary-ts": "npm:^1.0.2191" "@webex/test-fixtures": "workspace:*" babel-jest: "npm:29.7.0" @@ -10191,23 +10191,23 @@ __metadata: languageName: node linkType: hard -"@webex/contact-center@npm:3.12.0-next.148": - version: 3.12.0-next.148 - resolution: "@webex/contact-center@npm:3.12.0-next.148" +"@webex/contact-center@npm:3.12.0-next.158": + version: 3.12.0-next.158 + resolution: "@webex/contact-center@npm:3.12.0-next.158" dependencies: "@types/platform": "npm:1.3.4" - "@webex/calling": "npm:3.12.0-next.113" - "@webex/internal-plugin-mercury": "npm:3.12.0-next.61" - "@webex/internal-plugin-metrics": "npm:3.12.0-next.55" - "@webex/internal-plugin-support": "npm:3.12.0-next.69" - "@webex/plugin-authorization": "npm:3.12.0-next.57" - "@webex/plugin-logger": "npm:3.12.0-next.56" - "@webex/webex-core": "npm:3.12.0-next.57" + "@webex/calling": "npm:3.12.0-next.120" + "@webex/internal-plugin-mercury": "npm:3.12.0-next.66" + "@webex/internal-plugin-metrics": "npm:3.12.0-next.59" + "@webex/internal-plugin-support": "npm:3.12.0-next.77" + "@webex/plugin-authorization": "npm:3.12.0-next.63" + "@webex/plugin-logger": "npm:3.12.0-next.60" + "@webex/webex-core": "npm:3.12.0-next.63" jest-html-reporters: "npm:3.0.11" lodash: "npm:^4.17.21" uuid: "npm:^3.3.2" xstate: "npm:5.24.0" - checksum: 10c0/83be552140a91441abf441eaf3efbb7c1279c522096dc6cdf77227611ab20f3a8c59b6211026d986cd837b0207e16291e85c4d9a8c34906d980cd77ab71dd949 + checksum: 10c0/d71d1ea49616151cd1409069419b86d12a32ac7a02b7de68f4554a76bce6d6b7b94b88a35cd3470b46b7f8e2d1e4e03f01f95295c9180ddf420889f8f441dcf4 languageName: node linkType: hard @@ -10268,12 +10268,12 @@ __metadata: languageName: node linkType: hard -"@webex/helper-html@npm:3.12.0-next.10": - version: 3.12.0-next.10 - resolution: "@webex/helper-html@npm:3.12.0-next.10" +"@webex/helper-html@npm:3.12.0-next.11": + version: 3.12.0-next.11 + resolution: "@webex/helper-html@npm:3.12.0-next.11" dependencies: lodash: "npm:^4.17.21" - checksum: 10c0/98519a5669e4538cc2190b60a0d8037f2482b3f2dbee1f452b2892da61b0a7966b90d60a7503d1a256f191dbb80ec6c277d48ea094fd5fe67c4bd07908f1a774 + checksum: 10c0/351b6d6dfa9bda07c7e67d80793437de7153b569dbaea49507131807b9868ce9dd4cd22e6b00c0bc6082b79dece70324feb6343a92012ace0178195371aef50f languageName: node linkType: hard @@ -10320,37 +10320,37 @@ __metadata: languageName: node linkType: hard -"@webex/helper-image@npm:3.12.0-next.7": - version: 3.12.0-next.7 - resolution: "@webex/helper-image@npm:3.12.0-next.7" +"@webex/helper-image@npm:3.12.0-next.10": + version: 3.12.0-next.10 + resolution: "@webex/helper-image@npm:3.12.0-next.10" dependencies: - "@webex/http-core": "npm:3.12.0-next.7" - "@webex/test-helper-chai": "npm:3.12.0-next.7" - "@webex/test-helper-file": "npm:3.12.0-next.7" - "@webex/test-helper-mocha": "npm:3.12.0-next.7" + "@webex/http-core": "npm:3.12.0-next.9" + "@webex/test-helper-chai": "npm:3.12.0-next.9" + "@webex/test-helper-file": "npm:3.12.0-next.9" + "@webex/test-helper-mocha": "npm:3.12.0-next.9" exifr: "npm:^5.0.3" gm: "npm:^1.23.1" lodash: "npm:^4.17.21" mime: "npm:^2.4.4" safe-buffer: "npm:^5.2.0" - checksum: 10c0/7671cdae2dc90b70fb88b16f83b71567bc0df2f8dbcc14fd60ed20e9b8a2debec352667822a2c2efbb96d72e0dc661dd8e489534f9650e04b12f964d5797f379 + checksum: 10c0/b9fd72c33e15650a6789a2f4d4a34c97dd1cbba19c67cee67792fee21d3ef168f9ccaec2986d61117900a47cc6b1d5722993e02a3c87d9519efc292f8cd7b985 languageName: node linkType: hard -"@webex/helper-image@npm:3.12.0-next.9": - version: 3.12.0-next.9 - resolution: "@webex/helper-image@npm:3.12.0-next.9" +"@webex/helper-image@npm:3.12.0-next.7": + version: 3.12.0-next.7 + resolution: "@webex/helper-image@npm:3.12.0-next.7" dependencies: - "@webex/http-core": "npm:3.12.0-next.9" - "@webex/test-helper-chai": "npm:3.12.0-next.9" - "@webex/test-helper-file": "npm:3.12.0-next.9" - "@webex/test-helper-mocha": "npm:3.12.0-next.9" + "@webex/http-core": "npm:3.12.0-next.7" + "@webex/test-helper-chai": "npm:3.12.0-next.7" + "@webex/test-helper-file": "npm:3.12.0-next.7" + "@webex/test-helper-mocha": "npm:3.12.0-next.7" exifr: "npm:^5.0.3" gm: "npm:^1.23.1" lodash: "npm:^4.17.21" mime: "npm:^2.4.4" safe-buffer: "npm:^5.2.0" - checksum: 10c0/9e1069d3057be089003d33d83b4f3d7dfc79a1c359348badf43369ab4b62f0d01a53feed42cef9062092174bc75608c622a018fdcbf4f884d5437dafb9f9e52e + checksum: 10c0/7671cdae2dc90b70fb88b16f83b71567bc0df2f8dbcc14fd60ed20e9b8a2debec352667822a2c2efbb96d72e0dc661dd8e489534f9650e04b12f964d5797f379 languageName: node linkType: hard @@ -10588,21 +10588,21 @@ __metadata: languageName: node linkType: hard -"@webex/internal-plugin-conversation@npm:3.12.0-next.69": - version: 3.12.0-next.69 - resolution: "@webex/internal-plugin-conversation@npm:3.12.0-next.69" +"@webex/internal-plugin-conversation@npm:3.12.0-next.77": + version: 3.12.0-next.77 + resolution: "@webex/internal-plugin-conversation@npm:3.12.0-next.77" dependencies: "@webex/common": "npm:3.12.0-next.9" - "@webex/helper-html": "npm:3.12.0-next.10" - "@webex/helper-image": "npm:3.12.0-next.9" - "@webex/internal-plugin-encryption": "npm:3.12.0-next.65" - "@webex/internal-plugin-user": "npm:3.12.0-next.58" - "@webex/webex-core": "npm:3.12.0-next.57" + "@webex/helper-html": "npm:3.12.0-next.11" + "@webex/helper-image": "npm:3.12.0-next.10" + "@webex/internal-plugin-encryption": "npm:3.12.0-next.72" + "@webex/internal-plugin-user": "npm:3.12.0-next.64" + "@webex/webex-core": "npm:3.12.0-next.63" crypto-js: "npm:^4.1.1" lodash: "npm:^4.17.21" node-scr: "npm:^0.3.0" uuid: "npm:^3.3.2" - checksum: 10c0/2b5a3de63ffc60bf608f9efecbb2d2ca683f4023dfb6f913231c40e56c1c1c7c255f238e836be5b4ae97d0e8702ee5f4da17da54d5d004bcb49268d051bdbc95 + checksum: 10c0/50ebc593c732baff8955d9ae35b80616dd2401c57dc830cc220a7e278b6121759629fc3eb7de9cf1a459df2a6441161b4ae2ed405b2883d4f68b9d9a3e44bc91 languageName: node linkType: hard @@ -10656,37 +10656,20 @@ __metadata: languageName: node linkType: hard -"@webex/internal-plugin-device@npm:3.12.0-next.54": - version: 3.12.0-next.54 - resolution: "@webex/internal-plugin-device@npm:3.12.0-next.54" - dependencies: - "@webex/common": "npm:3.12.0-next.9" - "@webex/common-timers": "npm:3.12.0-next.10" - "@webex/http-core": "npm:3.12.0-next.9" - "@webex/internal-plugin-metrics": "npm:3.12.0-next.54" - "@webex/webex-core": "npm:3.12.0-next.54" - ampersand-collection: "npm:^2.0.2" - ampersand-state: "npm:^5.0.3" - lodash: "npm:^4.17.21" - uuid: "npm:^3.3.2" - checksum: 10c0/bd070078aa6943bb736474eaebc2fe5a842fec8f5b3e648e908a4308503f45438dadd10b7739cd0bfc1fd514a8210b55083f17c90acb6d71b86d011c48985301 - languageName: node - linkType: hard - -"@webex/internal-plugin-device@npm:3.12.0-next.55": - version: 3.12.0-next.55 - resolution: "@webex/internal-plugin-device@npm:3.12.0-next.55" +"@webex/internal-plugin-device@npm:3.12.0-next.58": + version: 3.12.0-next.58 + resolution: "@webex/internal-plugin-device@npm:3.12.0-next.58" dependencies: "@webex/common": "npm:3.12.0-next.9" "@webex/common-timers": "npm:3.12.0-next.10" "@webex/http-core": "npm:3.12.0-next.9" - "@webex/internal-plugin-metrics": "npm:3.12.0-next.56" - "@webex/webex-core": "npm:3.12.0-next.60" + "@webex/internal-plugin-metrics": "npm:3.12.0-next.59" + "@webex/webex-core": "npm:3.12.0-next.63" ampersand-collection: "npm:^2.0.2" ampersand-state: "npm:^5.0.3" lodash: "npm:^4.17.21" uuid: "npm:^3.3.2" - checksum: 10c0/5ea16f4b1fa2f4be789dfb63e84e8b7d121313a5df04099ce0b72102f59b5032a2fe51d359d0ba9974c5c28e4f4433d0c6334f7ffcd7fc694e83eea7afbe351a + checksum: 10c0/022b073f00865bff09b144fe8cb85b3b8ddc32a6b2a8d00f49a843e98f3e3e214c8655c408768f2db9da338f4e1f0fba48a49810d13dea96a68807290651cc2d languageName: node linkType: hard @@ -10782,17 +10765,17 @@ __metadata: languageName: node linkType: hard -"@webex/internal-plugin-encryption@npm:3.12.0-next.65": - version: 3.12.0-next.65 - resolution: "@webex/internal-plugin-encryption@npm:3.12.0-next.65" +"@webex/internal-plugin-encryption@npm:3.12.0-next.72": + version: 3.12.0-next.72 + resolution: "@webex/internal-plugin-encryption@npm:3.12.0-next.72" dependencies: "@webex/common": "npm:3.12.0-next.9" "@webex/common-timers": "npm:3.12.0-next.10" "@webex/http-core": "npm:3.12.0-next.9" - "@webex/internal-plugin-device": "npm:3.12.0-next.54" - "@webex/internal-plugin-mercury": "npm:3.12.0-next.62" + "@webex/internal-plugin-device": "npm:3.12.0-next.58" + "@webex/internal-plugin-mercury": "npm:3.12.0-next.66" "@webex/test-helper-file": "npm:3.12.0-next.9" - "@webex/webex-core": "npm:3.12.0-next.57" + "@webex/webex-core": "npm:3.12.0-next.63" asn1js: "npm:^2.0.26" debug: "npm:^4.3.4" isomorphic-webcrypto: "npm:^2.3.8" @@ -10804,7 +10787,7 @@ __metadata: safe-buffer: "npm:^5.2.0" uuid: "npm:^3.3.2" valid-url: "npm:^1.0.9" - checksum: 10c0/93596f6a5a63ba9105cb5499f3d80e1dc849568f9826402acfdc64e9ae7328bc592bab4feae46fb48cf46ca53082ec48448ac2d18caf6fdf73651685fb3eb70c + checksum: 10c0/558a77d5c37ae943d711427965982f94225006460f24736fe19a1fce3aa2b1f3d563d76bb476117e865b18983c472b393c637dc406876829e5e55f3e23ed5232 languageName: node linkType: hard @@ -10842,25 +10825,14 @@ __metadata: languageName: node linkType: hard -"@webex/internal-plugin-feature@npm:3.12.0-next.57": - version: 3.12.0-next.57 - resolution: "@webex/internal-plugin-feature@npm:3.12.0-next.57" +"@webex/internal-plugin-feature@npm:3.12.0-next.63": + version: 3.12.0-next.63 + resolution: "@webex/internal-plugin-feature@npm:3.12.0-next.63" dependencies: - "@webex/internal-plugin-device": "npm:3.12.0-next.55" - "@webex/webex-core": "npm:3.12.0-next.57" + "@webex/internal-plugin-device": "npm:3.12.0-next.58" + "@webex/webex-core": "npm:3.12.0-next.63" lodash: "npm:^4.17.21" - checksum: 10c0/db7c2b991d206b39e019af2e466f452dabc7acfe2bb599f6b6a5724fda967391795f80df71395f11614f975c5da0d0d1c21cd7ec0cd82264769598afd168ee5c - languageName: node - linkType: hard - -"@webex/internal-plugin-feature@npm:3.12.0-next.60": - version: 3.12.0-next.60 - resolution: "@webex/internal-plugin-feature@npm:3.12.0-next.60" - dependencies: - "@webex/internal-plugin-device": "npm:3.12.0-next.55" - "@webex/webex-core": "npm:3.12.0-next.60" - lodash: "npm:^4.17.21" - checksum: 10c0/315a926343254f507ff9ee7b2f8527a36555de38eaa03815415557745bad414f45754e2ae0367d69010dbe11b93b445e44676a3c3f190f8c43ef10a0aeaa868a + checksum: 10c0/e0865b51e15abb456aa7ea03319b4a4b27bd4ece70c413ff6a9d52a9cae7fb23c7e96da129f4190216588b00a2531e762f1318b29d30219760eadcacc569cb47 languageName: node linkType: hard @@ -11007,51 +10979,27 @@ __metadata: languageName: node linkType: hard -"@webex/internal-plugin-mercury@npm:3.12.0-next.61": - version: 3.12.0-next.61 - resolution: "@webex/internal-plugin-mercury@npm:3.12.0-next.61" - dependencies: - "@webex/common": "npm:3.12.0-next.9" - "@webex/common-timers": "npm:3.12.0-next.10" - "@webex/internal-plugin-device": "npm:3.12.0-next.55" - "@webex/internal-plugin-feature": "npm:3.12.0-next.57" - "@webex/internal-plugin-metrics": "npm:3.12.0-next.56" - "@webex/test-helper-chai": "npm:3.12.0-next.9" - "@webex/test-helper-mocha": "npm:3.12.0-next.9" - "@webex/test-helper-mock-web-socket": "npm:3.12.0-next.9" - "@webex/test-helper-mock-webex": "npm:3.12.0-next.9" - "@webex/test-helper-refresh-callback": "npm:3.12.0-next.9" - "@webex/test-helper-test-users": "npm:3.12.0-next.9" - "@webex/webex-core": "npm:3.12.0-next.57" - backoff: "npm:^2.5.0" - lodash: "npm:^4.17.21" - uuid: "npm:^3.3.2" - ws: "npm:^8.17.1" - checksum: 10c0/0e7c46aec1074ad658a4e4069c088f7be19152daf51fed70b9ebc8122e1d0053c1457ed7bbb2bac38e10a545f51550ac4aacaf5628f72d240819fba9e32b96da - languageName: node - linkType: hard - -"@webex/internal-plugin-mercury@npm:3.12.0-next.62": - version: 3.12.0-next.62 - resolution: "@webex/internal-plugin-mercury@npm:3.12.0-next.62" +"@webex/internal-plugin-mercury@npm:3.12.0-next.66": + version: 3.12.0-next.66 + resolution: "@webex/internal-plugin-mercury@npm:3.12.0-next.66" dependencies: "@webex/common": "npm:3.12.0-next.9" "@webex/common-timers": "npm:3.12.0-next.10" - "@webex/internal-plugin-device": "npm:3.12.0-next.55" - "@webex/internal-plugin-feature": "npm:3.12.0-next.60" - "@webex/internal-plugin-metrics": "npm:3.12.0-next.56" + "@webex/internal-plugin-device": "npm:3.12.0-next.58" + "@webex/internal-plugin-feature": "npm:3.12.0-next.63" + "@webex/internal-plugin-metrics": "npm:3.12.0-next.59" "@webex/test-helper-chai": "npm:3.12.0-next.9" "@webex/test-helper-mocha": "npm:3.12.0-next.9" "@webex/test-helper-mock-web-socket": "npm:3.12.0-next.9" "@webex/test-helper-mock-webex": "npm:3.12.0-next.9" "@webex/test-helper-refresh-callback": "npm:3.12.0-next.9" "@webex/test-helper-test-users": "npm:3.12.0-next.9" - "@webex/webex-core": "npm:3.12.0-next.60" + "@webex/webex-core": "npm:3.12.0-next.63" backoff: "npm:^2.5.0" lodash: "npm:^4.17.21" uuid: "npm:^3.3.2" ws: "npm:^8.17.1" - checksum: 10c0/1470378e5b3862b124ecb8b118d06c5dba56e275f26fc89f274408a33cd3bd38820947c1a3a8fd8486d4d6b2fb6ae239dae04e8368568b9fe7e07b7cc0bf50f3 + checksum: 10c0/98391e8aa82e0a6ea8a06a7a438fa6b01afe846a98ea22f74ee4dd2d160c8cb39a82ba56318b9742cfa2ce1a67ae11967936aa7718f719e05e7f8a62729ceb2f languageName: node linkType: hard @@ -11102,57 +11050,21 @@ __metadata: languageName: node linkType: hard -"@webex/internal-plugin-metrics@npm:3.12.0-next.54": - version: 3.12.0-next.54 - resolution: "@webex/internal-plugin-metrics@npm:3.12.0-next.54" - dependencies: - "@webex/common": "npm:3.12.0-next.9" - "@webex/common-timers": "npm:3.12.0-next.10" - "@webex/event-dictionary-ts": "npm:^1.0.2220" - "@webex/test-helper-chai": "npm:3.12.0-next.9" - "@webex/test-helper-mock-webex": "npm:3.12.0-next.9" - "@webex/webex-core": "npm:3.12.0-next.54" - ip-anonymize: "npm:^0.1.0" - isbot: "npm:5.2.1" - lodash: "npm:^4.17.21" - uuid: "npm:^3.3.2" - checksum: 10c0/1e84f5186a85f5db217e457a6d0d9f028c2391ea1d5bbab8ec09b852e7199c9979eb757b0773afa0a727029a1e33fdf07a44e8c9b28ed392f4d6c0131c5b5dbb - languageName: node - linkType: hard - -"@webex/internal-plugin-metrics@npm:3.12.0-next.55": - version: 3.12.0-next.55 - resolution: "@webex/internal-plugin-metrics@npm:3.12.0-next.55" - dependencies: - "@webex/common": "npm:3.12.0-next.9" - "@webex/common-timers": "npm:3.12.0-next.10" - "@webex/event-dictionary-ts": "npm:^1.0.2220" - "@webex/test-helper-chai": "npm:3.12.0-next.9" - "@webex/test-helper-mock-webex": "npm:3.12.0-next.9" - "@webex/webex-core": "npm:3.12.0-next.55" - ip-anonymize: "npm:^0.1.0" - isbot: "npm:5.2.1" - lodash: "npm:^4.17.21" - uuid: "npm:^3.3.2" - checksum: 10c0/04944fb40a298cbba5e25431a457943285bfb278131db82190e62efa45aef56cce85abfe1e60b7711f599042221d88568fbbcfa201b02a4731f7b0f468f223ea - languageName: node - linkType: hard - -"@webex/internal-plugin-metrics@npm:3.12.0-next.56": - version: 3.12.0-next.56 - resolution: "@webex/internal-plugin-metrics@npm:3.12.0-next.56" +"@webex/internal-plugin-metrics@npm:3.12.0-next.59": + version: 3.12.0-next.59 + resolution: "@webex/internal-plugin-metrics@npm:3.12.0-next.59" dependencies: "@webex/common": "npm:3.12.0-next.9" "@webex/common-timers": "npm:3.12.0-next.10" "@webex/event-dictionary-ts": "npm:^1.0.2220" "@webex/test-helper-chai": "npm:3.12.0-next.9" "@webex/test-helper-mock-webex": "npm:3.12.0-next.9" - "@webex/webex-core": "npm:3.12.0-next.60" + "@webex/webex-core": "npm:3.12.0-next.63" ip-anonymize: "npm:^0.1.0" isbot: "npm:5.2.1" lodash: "npm:^4.17.21" uuid: "npm:^3.3.2" - checksum: 10c0/16e099d61b190620043fdb5f40669ca5f50d5128914c7f82c9586b4893158686ae893a3bcb7cee3e84ee22e04a3368aea40c558b11d6003d319e656084157759 + checksum: 10c0/a64bbe26112cb8fd8dabbcdb472769cc8b866d606aa511b9bd10002e42f25ecf77703d9d2e93732a5e8989ceeac7b97e9ced8b2e23fc86a61f963530f4c38975 languageName: node linkType: hard @@ -11218,18 +11130,18 @@ __metadata: languageName: node linkType: hard -"@webex/internal-plugin-search@npm:3.12.0-next.69": - version: 3.12.0-next.69 - resolution: "@webex/internal-plugin-search@npm:3.12.0-next.69" +"@webex/internal-plugin-search@npm:3.12.0-next.77": + version: 3.12.0-next.77 + resolution: "@webex/internal-plugin-search@npm:3.12.0-next.77" dependencies: "@webex/common": "npm:3.12.0-next.9" - "@webex/internal-plugin-conversation": "npm:3.12.0-next.69" - "@webex/internal-plugin-device": "npm:3.12.0-next.54" - "@webex/internal-plugin-encryption": "npm:3.12.0-next.65" - "@webex/webex-core": "npm:3.12.0-next.57" + "@webex/internal-plugin-conversation": "npm:3.12.0-next.77" + "@webex/internal-plugin-device": "npm:3.12.0-next.58" + "@webex/internal-plugin-encryption": "npm:3.12.0-next.72" + "@webex/webex-core": "npm:3.12.0-next.63" lodash: "npm:^4.17.21" uuid: "npm:^3.3.2" - checksum: 10c0/e5c5699dcbea1fdec00f045207db7ca81e54d0efcfa8fead9d9de4a7ac294b368175ae796ae522e0de7818ac34c1bb0523ccf2ff54030dc6f752676a73ccc6d8 + checksum: 10c0/0d3d4a9d946b4b6956ccc039ed35a09bf2be791e15816f89e849e6886b480aff145ebd2698b71b60294b932c21969d7e99aff7cbb50923dcbf2da760194d93e7 languageName: node linkType: hard @@ -11267,20 +11179,20 @@ __metadata: languageName: node linkType: hard -"@webex/internal-plugin-support@npm:3.12.0-next.69": - version: 3.12.0-next.69 - resolution: "@webex/internal-plugin-support@npm:3.12.0-next.69" +"@webex/internal-plugin-support@npm:3.12.0-next.77": + version: 3.12.0-next.77 + resolution: "@webex/internal-plugin-support@npm:3.12.0-next.77" dependencies: - "@webex/internal-plugin-device": "npm:3.12.0-next.54" - "@webex/internal-plugin-search": "npm:3.12.0-next.69" + "@webex/internal-plugin-device": "npm:3.12.0-next.58" + "@webex/internal-plugin-search": "npm:3.12.0-next.77" "@webex/test-helper-chai": "npm:3.12.0-next.9" "@webex/test-helper-file": "npm:3.12.0-next.9" "@webex/test-helper-mock-webex": "npm:3.12.0-next.9" "@webex/test-helper-test-users": "npm:3.12.0-next.9" - "@webex/webex-core": "npm:3.12.0-next.57" + "@webex/webex-core": "npm:3.12.0-next.63" lodash: "npm:^4.17.21" uuid: "npm:^3.3.2" - checksum: 10c0/2abcdadd78e418ba3cc21bc0c30692765a00cd5c0e08c529df20924171915ae3476fdd9ded403ac3c08cf590fc45032f42228ddd874d0c0a411180ff3f449253 + checksum: 10c0/a95f520bc23dc24c79ed3b949a6aac1cb19fce26ea31f850a70e1310d8ff8c75427116d4c812a01948f67222d1350fe2c15b5cd90366c118a2a740f696a1be9f languageName: node linkType: hard @@ -11346,19 +11258,19 @@ __metadata: languageName: node linkType: hard -"@webex/internal-plugin-user@npm:3.12.0-next.58": - version: 3.12.0-next.58 - resolution: "@webex/internal-plugin-user@npm:3.12.0-next.58" +"@webex/internal-plugin-user@npm:3.12.0-next.64": + version: 3.12.0-next.64 + resolution: "@webex/internal-plugin-user@npm:3.12.0-next.64" dependencies: "@webex/common": "npm:3.12.0-next.9" - "@webex/internal-plugin-device": "npm:3.12.0-next.55" + "@webex/internal-plugin-device": "npm:3.12.0-next.58" "@webex/test-helper-chai": "npm:3.12.0-next.9" "@webex/test-helper-mock-webex": "npm:3.12.0-next.9" "@webex/test-helper-test-users": "npm:3.12.0-next.9" - "@webex/webex-core": "npm:3.12.0-next.57" + "@webex/webex-core": "npm:3.12.0-next.63" lodash: "npm:^4.17.21" uuid: "npm:^3.3.2" - checksum: 10c0/b5d9a1805f96668ab53ee1cee533e6f6658df6ccc3075ed0e40d2dbdbe717222d8c8e5ea8c05ed6ace6983afc6d8ec07a43eaca30317d82c371983ede99eae30 + checksum: 10c0/d0d5326460125a9b3317da0aceadc62a172937dd3e1fbf4dabf735a5771d67d669007fbd9315ace4e2df856c9af1c93d972369d3658ab42059e99caf55b088cd languageName: node linkType: hard @@ -11405,14 +11317,14 @@ __metadata: languageName: node linkType: hard -"@webex/media-helpers@npm:3.12.0-next.17": - version: 3.12.0-next.17 - resolution: "@webex/media-helpers@npm:3.12.0-next.17" +"@webex/media-helpers@npm:3.12.0-next.18": + version: 3.12.0-next.18 + resolution: "@webex/media-helpers@npm:3.12.0-next.18" dependencies: - "@webex/internal-media-core": "npm:2.30.1" + "@webex/internal-media-core": "npm:2.31.0" "@webex/ts-events": "npm:^1.1.0" "@webex/web-media-effects": "npm:2.37.0" - checksum: 10c0/02c114ca1776e1e86c7b059e0d13f89138391fb681b0fcb1d851dec3a9a935e1a9781f2f9829bde6f729dd743bba2b0625924a86ddf9048f9ac6e628fc0169f5 + checksum: 10c0/4d440edd3bdcb910cf2c7577afff3d313f16599d76108f0b288e7f14ea9726f5550222fa6de52057c130ba64d7c733e97930f47ce60b5c203dbe1795333d4b56 languageName: node linkType: hard @@ -11517,20 +11429,20 @@ __metadata: languageName: node linkType: hard -"@webex/plugin-authorization-browser@npm:3.12.0-next.57": - version: 3.12.0-next.57 - resolution: "@webex/plugin-authorization-browser@npm:3.12.0-next.57" +"@webex/plugin-authorization-browser@npm:3.12.0-next.63": + version: 3.12.0-next.63 + resolution: "@webex/plugin-authorization-browser@npm:3.12.0-next.63" dependencies: "@webex/common": "npm:3.12.0-next.9" - "@webex/internal-plugin-device": "npm:3.12.0-next.55" - "@webex/plugin-authorization-node": "npm:3.12.0-next.57" - "@webex/storage-adapter-local-storage": "npm:3.12.0-next.57" + "@webex/internal-plugin-device": "npm:3.12.0-next.58" + "@webex/plugin-authorization-node": "npm:3.12.0-next.63" + "@webex/storage-adapter-local-storage": "npm:3.12.0-next.63" "@webex/storage-adapter-spec": "npm:3.12.0-next.10" - "@webex/webex-core": "npm:3.12.0-next.57" + "@webex/webex-core": "npm:3.12.0-next.63" jsonwebtoken: "npm:^9.0.2" lodash: "npm:^4.17.21" uuid: "npm:^3.3.2" - checksum: 10c0/c91d3d31c51c0bd1d2f71d1437bfb753f2a062ebf84c3d58deb6ea752102a39b1ef33aa9a0d517daff91b74d6cb051ae684b2ea13baeecc0241e07586e6364b4 + checksum: 10c0/91efae25a50bc112b3dff65ce1fcd45e54b92f5794c71959a081e12646f41625893d96b66f27ea51f955ba5f7ba676f1dbe0e4a4db119a501e8f9eb1329af6f0 languageName: node linkType: hard @@ -11560,16 +11472,16 @@ __metadata: languageName: node linkType: hard -"@webex/plugin-authorization-node@npm:3.12.0-next.57": - version: 3.12.0-next.57 - resolution: "@webex/plugin-authorization-node@npm:3.12.0-next.57" +"@webex/plugin-authorization-node@npm:3.12.0-next.63": + version: 3.12.0-next.63 + resolution: "@webex/plugin-authorization-node@npm:3.12.0-next.63" dependencies: "@webex/common": "npm:3.12.0-next.9" - "@webex/internal-plugin-device": "npm:3.12.0-next.55" - "@webex/webex-core": "npm:3.12.0-next.57" + "@webex/internal-plugin-device": "npm:3.12.0-next.58" + "@webex/webex-core": "npm:3.12.0-next.63" jsonwebtoken: "npm:^9.0.0" uuid: "npm:^3.3.2" - checksum: 10c0/db7b18e414a42de92862838017790b738452946a7dca88eeb0d23bad8590c760cfc893476a45cbe13c06820fc8564a7d196510fc25eca1d943a9d3036687c7bd + checksum: 10c0/856b66a6ec7b8f84f2a3506548d833d6b3db0d36dc34eb6e3286082def0b120792926d7279c5dbdb21683ef559d4390e0f756bb353f3e00e67579487198da476 languageName: node linkType: hard @@ -11593,13 +11505,13 @@ __metadata: languageName: node linkType: hard -"@webex/plugin-authorization@npm:3.12.0-next.57": - version: 3.12.0-next.57 - resolution: "@webex/plugin-authorization@npm:3.12.0-next.57" +"@webex/plugin-authorization@npm:3.12.0-next.63": + version: 3.12.0-next.63 + resolution: "@webex/plugin-authorization@npm:3.12.0-next.63" dependencies: - "@webex/plugin-authorization-browser": "npm:3.12.0-next.57" - "@webex/plugin-authorization-node": "npm:3.12.0-next.57" - checksum: 10c0/44ae51deff8560b7d69b150533cca1aa7c423946773244c460595a98768bc1b4194b14bef5c7b4d01272c6c5e672f388cf671c1d80a29dad780cec6800108e73 + "@webex/plugin-authorization-browser": "npm:3.12.0-next.63" + "@webex/plugin-authorization-node": "npm:3.12.0-next.63" + checksum: 10c0/0ae6f5d58613a3f4997ce69bbd27755777399e4087cb006c552c27cb7eecad25000858d45477cbea68e1a5067e0ab29be53829a6481c92d2a2d40a583b9ef57f languageName: node linkType: hard @@ -11677,17 +11589,17 @@ __metadata: languageName: node linkType: hard -"@webex/plugin-logger@npm:3.12.0-next.56": - version: 3.12.0-next.56 - resolution: "@webex/plugin-logger@npm:3.12.0-next.56" +"@webex/plugin-logger@npm:3.12.0-next.60": + version: 3.12.0-next.60 + resolution: "@webex/plugin-logger@npm:3.12.0-next.60" dependencies: "@webex/common": "npm:3.12.0-next.9" "@webex/test-helper-chai": "npm:3.12.0-next.9" "@webex/test-helper-mocha": "npm:3.12.0-next.9" "@webex/test-helper-mock-webex": "npm:3.12.0-next.9" - "@webex/webex-core": "npm:3.12.0-next.56" + "@webex/webex-core": "npm:3.12.0-next.63" lodash: "npm:^4.17.21" - checksum: 10c0/6a14b867c499fb017563b1d0c71669d7ac0f3e0f3f31dbc86e3ee91a6af9c9c5c21198077c8cfb319f170373d1ddefc4b34eb7b807cbd0b9776e411bb6c79234 + checksum: 10c0/936a983e2ea46d6d4359d1e47b8a5eab3329b343848c3ce1f4a2a2780e95d2daf57c6115bf8b8efa0f932f23738421442d12cb1e9d9832341398b1889f9139a4 languageName: node linkType: hard @@ -12017,14 +11929,14 @@ __metadata: languageName: node linkType: hard -"@webex/storage-adapter-local-storage@npm:3.12.0-next.57": - version: 3.12.0-next.57 - resolution: "@webex/storage-adapter-local-storage@npm:3.12.0-next.57" +"@webex/storage-adapter-local-storage@npm:3.12.0-next.63": + version: 3.12.0-next.63 + resolution: "@webex/storage-adapter-local-storage@npm:3.12.0-next.63" dependencies: "@webex/storage-adapter-spec": "npm:3.12.0-next.10" "@webex/test-helper-mocha": "npm:3.12.0-next.9" - "@webex/webex-core": "npm:3.12.0-next.57" - checksum: 10c0/daa220715ce1a1a146beab6885e23237ee381be5be936d9d75cf5aba54e2c388e06dbc758a3c7db28fe0e3c35eefb61d47640256f8e2056838a93c544bdc52a0 + "@webex/webex-core": "npm:3.12.0-next.63" + checksum: 10c0/f2854dfa3782d7f1019cd54593a4bb0833c48d0a3ef7f00938e2ff3d6c20adfde71cf2c57b56395121aef4819ccb4b01e4b894a71314a5a3450fa5d8540aff58 languageName: node linkType: hard @@ -12687,89 +12599,9 @@ __metadata: languageName: node linkType: hard -"@webex/webex-core@npm:3.12.0-next.54": - version: 3.12.0-next.54 - resolution: "@webex/webex-core@npm:3.12.0-next.54" - dependencies: - "@webex/common": "npm:3.12.0-next.9" - "@webex/common-timers": "npm:3.12.0-next.10" - "@webex/http-core": "npm:3.12.0-next.9" - "@webex/storage-adapter-spec": "npm:3.12.0-next.10" - ampersand-collection: "npm:^2.0.2" - ampersand-events: "npm:^2.0.2" - ampersand-state: "npm:^5.0.3" - core-decorators: "npm:^0.20.0" - crypto-js: "npm:^4.1.1" - jsonwebtoken: "npm:^9.0.0" - lodash: "npm:^4.17.21" - uuid: "npm:^3.3.2" - checksum: 10c0/5a372bdb4be5c73e7f1ab835abc0cacdb9203b10b0e748220cd2c2ffa83902dd604f8507441192e420cb392b4f512492c57af649d3d59267b6992ce9dcabf580 - languageName: node - linkType: hard - -"@webex/webex-core@npm:3.12.0-next.55": - version: 3.12.0-next.55 - resolution: "@webex/webex-core@npm:3.12.0-next.55" - dependencies: - "@webex/common": "npm:3.12.0-next.9" - "@webex/common-timers": "npm:3.12.0-next.10" - "@webex/http-core": "npm:3.12.0-next.9" - "@webex/storage-adapter-spec": "npm:3.12.0-next.10" - ampersand-collection: "npm:^2.0.2" - ampersand-events: "npm:^2.0.2" - ampersand-state: "npm:^5.0.3" - core-decorators: "npm:^0.20.0" - crypto-js: "npm:^4.1.1" - jsonwebtoken: "npm:^9.0.0" - lodash: "npm:^4.17.21" - uuid: "npm:^3.3.2" - checksum: 10c0/ba0129b3aabc25632cad8971fd6bbd3cb9e9d3c982e479e9b7dffd04d7bf974c4d4061b628141056ec92177c69c0aad0f522cae3483f051e24a96dc6d4bdbcac - languageName: node - linkType: hard - -"@webex/webex-core@npm:3.12.0-next.56": - version: 3.12.0-next.56 - resolution: "@webex/webex-core@npm:3.12.0-next.56" - dependencies: - "@webex/common": "npm:3.12.0-next.9" - "@webex/common-timers": "npm:3.12.0-next.10" - "@webex/http-core": "npm:3.12.0-next.9" - "@webex/storage-adapter-spec": "npm:3.12.0-next.10" - ampersand-collection: "npm:^2.0.2" - ampersand-events: "npm:^2.0.2" - ampersand-state: "npm:^5.0.3" - core-decorators: "npm:^0.20.0" - crypto-js: "npm:^4.1.1" - jsonwebtoken: "npm:^9.0.0" - lodash: "npm:^4.17.21" - uuid: "npm:^3.3.2" - checksum: 10c0/79e68aa7965d6e1f3501a23252d48f6e9eb32739dd2b939df3e0d2eedd99554370f79443624c6cc9e695cb31e791f376af17357d50aa4e86271706aaeab048e6 - languageName: node - linkType: hard - -"@webex/webex-core@npm:3.12.0-next.57": - version: 3.12.0-next.57 - resolution: "@webex/webex-core@npm:3.12.0-next.57" - dependencies: - "@webex/common": "npm:3.12.0-next.9" - "@webex/common-timers": "npm:3.12.0-next.10" - "@webex/http-core": "npm:3.12.0-next.9" - "@webex/storage-adapter-spec": "npm:3.12.0-next.10" - ampersand-collection: "npm:^2.0.2" - ampersand-events: "npm:^2.0.2" - ampersand-state: "npm:^5.0.3" - core-decorators: "npm:^0.20.0" - crypto-js: "npm:^4.1.1" - jsonwebtoken: "npm:^9.0.0" - lodash: "npm:^4.17.21" - uuid: "npm:^3.3.2" - checksum: 10c0/63473635321a4d1e3f505224f70eea4757de1dc2ee3bef499e81570453e34253b62152d6076b72511437fc99bb6d398252f581f29d92a66820270e35277fe9d9 - languageName: node - linkType: hard - -"@webex/webex-core@npm:3.12.0-next.60": - version: 3.12.0-next.60 - resolution: "@webex/webex-core@npm:3.12.0-next.60" +"@webex/webex-core@npm:3.12.0-next.63": + version: 3.12.0-next.63 + resolution: "@webex/webex-core@npm:3.12.0-next.63" dependencies: "@webex/common": "npm:3.12.0-next.9" "@webex/common-timers": "npm:3.12.0-next.10" @@ -12783,7 +12615,7 @@ __metadata: jsonwebtoken: "npm:^9.0.0" lodash: "npm:^4.17.21" uuid: "npm:^3.3.2" - checksum: 10c0/1d760242c81296c2150c40616e34e4c985a6fa3345a688c1e9cf58bd82fdc661914659e118ba07011382600db5e4d3cf3ab02f5fd91af4251d1a748bb5ab1c78 + checksum: 10c0/63b732794723121626086ff6c0ccdf7e43ff25d85715ccf20cb22c6459b2ccda11a42d346ba9432340ff726b8432a77c0f390f3a8120630d18238c572614245d languageName: node linkType: hard @@ -31448,7 +31280,6 @@ __metadata: "@momentum-ui/web-components": "npm:^2.26.20" "@webex/cc-ai-assistant": "workspace:*" "@webex/cc-widgets": "workspace:*" - "@webex/contact-center": "npm:3.12.0-next.133" babel-loader: "npm:^9.2.1" eslint: "npm:^9.20.1" eslint-config-prettier: "npm:^10.0.1"