-
Notifications
You must be signed in to change notification settings - Fork 1.6k
Pull based capabilities from adapter #5679
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
compulim
merged 4 commits into
microsoft:main
from
pranavjoshi001:feature/pull-based-capabilities-from-adapter
Jan 28, 2026
Merged
Changes from all commits
Commits
Show all changes
4 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,109 @@ | ||
| <!doctype html> | ||
| <html lang="en-US"> | ||
| <head> | ||
| <link href="/assets/index.css" rel="stylesheet" type="text/css" /> | ||
| </head> | ||
| <body> | ||
| <main id="webchat"></main> | ||
| <script type="importmap"> | ||
| { | ||
| "imports": { | ||
| "@testduet/wait-for": "https://unpkg.com/@testduet/wait-for@main/dist/wait-for.mjs", | ||
| "botframework-webchat": "/__dist__/packages/bundle/static/botframework-webchat.js", | ||
| "botframework-webchat/component": "/__dist__/packages/bundle/static/botframework-webchat/component.js", | ||
| "botframework-webchat/hook": "/__dist__/packages/bundle/static/botframework-webchat/hook.js", | ||
| "react": "https://esm.sh/react@18", | ||
| "react-dom": "https://esm.sh/react-dom@18", | ||
| "react-dom/": "https://esm.sh/react-dom@18/" | ||
| } | ||
| } | ||
| </script> | ||
| <script type="module"> | ||
| import '/test-harness.mjs'; | ||
| import '/test-page-object.mjs'; | ||
|
|
||
| import { waitFor } from '@testduet/wait-for'; | ||
| import { createStoreWithOptions, testIds } from 'botframework-webchat'; | ||
| import { useCapabilities } from 'botframework-webchat/hook'; | ||
| import createRenderHook from '/assets/esm/createRenderHook.js'; | ||
|
|
||
| const { createDirectLineEmulator } = window.testHelpers; | ||
|
|
||
| window.WebChat = { createStoreWithOptions, testIds }; | ||
|
|
||
| run(async function () { | ||
| // TEST 1: Initial fetch on mount - capabilities should be fetched when directLine is available | ||
| const { directLine, store } = createDirectLineEmulator(); | ||
|
|
||
| // Set initial capability BEFORE mount (simulating adapter already having capability) | ||
| directLine.setCapability('getVoiceConfiguration', { voice: 'en-US', speed: 1.0 }, { emitEvent: false }); | ||
|
|
||
| const renderHook = createRenderHook( | ||
| document.getElementById('webchat'), | ||
| { directLine, store }, | ||
| { renderWebChat: true } | ||
| ); | ||
|
|
||
| await renderHook(); | ||
| await pageConditions.uiConnected(); | ||
|
|
||
| // Get initial voiceConfiguration using selector | ||
| const initialVoiceConfig = await renderHook(() => useCapabilities(caps => caps.voiceConfiguration)); | ||
|
|
||
| expect(initialVoiceConfig).toEqual({ voice: 'en-US', speed: 1.0 }); | ||
|
|
||
| // TEST 2: Regular activity should NOT trigger capability re-calculation | ||
| // Store reference to current voiceConfiguration | ||
| const preActivityVoiceConfig = await renderHook(() => useCapabilities(caps => caps.voiceConfiguration)); | ||
|
|
||
| // Send a regular message (not capabilitiesChanged event) | ||
| await directLine.emulateIncomingActivity({ | ||
| type: 'message', | ||
| text: 'Hello! This is a regular message.', | ||
| from: { id: 'bot', role: 'bot' } | ||
| }); | ||
|
|
||
| // Wait for activity to be processed | ||
| await new Promise(resolve => setTimeout(resolve, 200)); | ||
|
|
||
| // Get voiceConfiguration after regular activity | ||
| const postActivityVoiceConfig = await renderHook(() => useCapabilities(caps => caps.voiceConfiguration)); | ||
|
|
||
| // Reference should be the same (no re-calculation for regular activities) | ||
| expect(postActivityVoiceConfig).toBe(preActivityVoiceConfig); | ||
|
|
||
| // TEST 3: capabilitiesChanged event SHOULD trigger re-calculation | ||
| const preChangeVoiceConfig = await renderHook(() => useCapabilities(caps => caps.voiceConfiguration)); | ||
|
|
||
| // Update capability and emit event | ||
| directLine.setCapability('getVoiceConfiguration', { voice: 'en-GB', speed: 1.5 }, { emitEvent: true }); | ||
|
|
||
| // Wait for event to be processed | ||
| await waitFor(async () => { | ||
| const voiceConfig = await renderHook(() => useCapabilities(caps => caps.voiceConfiguration)); | ||
| return voiceConfig?.voice === 'en-GB'; | ||
| }, { timeout: 2000 }); | ||
|
|
||
| const postChangeVoiceConfig = await renderHook(() => useCapabilities(caps => caps.voiceConfiguration)); | ||
|
|
||
| expect(postChangeVoiceConfig).toEqual({ voice: 'en-GB', speed: 1.5 }); | ||
| expect(postChangeVoiceConfig).not.toBe(preChangeVoiceConfig); | ||
|
|
||
| // TEST 4: Same value should reuse reference (shallow equality check) | ||
| const preNoChangeVoiceConfig = await renderHook(() => useCapabilities(caps => caps.voiceConfiguration)); | ||
|
|
||
| // Set same value and emit event | ||
| directLine.setCapability('getVoiceConfiguration', { voice: 'en-GB', speed: 1.5 }, { emitEvent: true }); | ||
|
|
||
| // Wait for event to be processed | ||
| await new Promise(resolve => setTimeout(resolve, 200)); | ||
|
|
||
| const postNoChangeVoiceConfig = await renderHook(() => useCapabilities(caps => caps.voiceConfiguration)); | ||
|
|
||
| // Reference should be the same when values are equal | ||
| expect(postNoChangeVoiceConfig).toBe(preNoChangeVoiceConfig); | ||
| expect(postNoChangeVoiceConfig).toEqual({ voice: 'en-GB', speed: 1.5 }); | ||
| }); | ||
| </script> | ||
| </body> | ||
| </html> |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,85 @@ | ||
| # Capabilities | ||
|
|
||
| Web Chat supports dynamic capability discovery from adapters. Capabilities allow adapters to expose configuration values that Web Chat components can consume and react to changes. | ||
|
|
||
| ## Using the hook | ||
|
|
||
| Use the `useCapabilities` hook with a selector to access specific capabilities: | ||
|
|
||
| ```js | ||
| import { useCapabilities } from 'botframework-webchat/hook'; | ||
|
|
||
| // Get voice configuration | ||
| const voiceConfig = useCapabilities(caps => caps.voiceConfiguration); | ||
|
|
||
| if (voiceConfig) { | ||
| console.log(`Sample rate: ${voiceConfig.sampleRate}`); | ||
| console.log(`Chunk interval: ${voiceConfig.chunkIntervalMs}ms`); | ||
| } | ||
| ``` | ||
|
|
||
| > **Note:** A selector function is required. This ensures components only re-render when their specific capability changes. | ||
|
|
||
| ## Available capabilities | ||
|
|
||
| | Capability | Type | Description | | ||
| | -------------------- | -------------------------------------------------- | ----------------------------------- | | ||
| | `voiceConfiguration` | `{ chunkIntervalMs: number, sampleRate: number }` | Audio settings for Speech-to-Speech | | ||
|
|
||
| ## How it works | ||
|
|
||
| 1. **Initial fetch** - When WebChat mounts, it checks if the adapter exposes capability getter functions and retrieves initial values | ||
| 2. **Event-driven updates** - When the adapter emits a `capabilitiesChanged` event, WebChat re-fetches all capabilities from the adapter | ||
| 3. **Optimized re-renders** - Only components consuming changed capabilities will re-render | ||
|
|
||
| ## For adapter implementers | ||
|
|
||
| To expose capabilities from your adapter: | ||
|
|
||
| ### 1. Implement getter functions | ||
|
|
||
| ```js | ||
| const adapter = { | ||
| // ... other adapter methods | ||
|
|
||
| getVoiceConfiguration() { | ||
| return { | ||
| sampleRate: 16000, | ||
| chunkIntervalMs: 100 | ||
| }; | ||
| } | ||
| }; | ||
| ``` | ||
|
|
||
| ### 2. Emit change events | ||
|
|
||
| When capability values change, emit a `capabilitiesChanged` event activity: | ||
|
|
||
| ```js | ||
| // When configuration changes, emit the nudge event | ||
| adapter.activity$.next({ | ||
| type: 'event', | ||
| name: 'capabilitiesChanged', | ||
| from: { id: 'bot', role: 'bot' } | ||
| }); | ||
| ``` | ||
|
|
||
| WebChat will then call all capability getter functions and update consumers if values changed. | ||
|
|
||
| ## Adding new capabilities | ||
|
|
||
| To add a new capability: | ||
|
|
||
| 1. Add the type to `Capabilities` in `packages/api/src/providers/Capabilities/types/Capabilities.ts` | ||
| 2. Add the registry entry in `packages/api/src/providers/Capabilities/private/capabilityRegistry.ts` | ||
| 3. Implement the getter in your adapter (e.g., `getMyCapability()`) | ||
|
|
||
| The registry maps capability keys to getter function names: | ||
|
|
||
| ```js | ||
| // capabilityRegistry.ts | ||
| { | ||
| key: 'voiceConfiguration', | ||
| getterName: 'getVoiceConfiguration' | ||
| } | ||
| ``` |
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
Oops, something went wrong.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
79 changes: 79 additions & 0 deletions
79
packages/api/src/providers/Capabilities/CapabilitiesComposer.tsx
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,79 @@ | ||
| import React, { memo, useCallback, useMemo, type ReactNode } from 'react'; | ||
| import { useReduceMemo } from 'use-reduce-memo'; | ||
| import type { WebChatActivity } from 'botframework-webchat-core'; | ||
| import { literal, object, safeParse } from 'valibot'; | ||
|
|
||
| import useActivities from '../../hooks/useActivities'; | ||
| import useWebChatAPIContext from '../../hooks/internal/useWebChatAPIContext'; | ||
| import CapabilitiesContext from './private/Context'; | ||
| import fetchCapabilitiesFromAdapter from './private/fetchCapabilitiesFromAdapter'; | ||
| import type { Capabilities } from './types/Capabilities'; | ||
|
|
||
| type Props = Readonly<{ children?: ReactNode | undefined }>; | ||
|
|
||
| const EMPTY_CAPABILITIES: Capabilities = Object.freeze({}); | ||
|
|
||
| // Synthetic marker to trigger initial fetch - must be a stable reference | ||
| const INIT_MARKER = Object.freeze({ type: 'capabilities:init' as const }); | ||
| type InitMarker = typeof INIT_MARKER; | ||
| type ReducerInput = WebChatActivity | InitMarker; | ||
|
|
||
| const CapabilitiesChangedEventSchema = object({ | ||
| type: literal('event'), | ||
| name: literal('capabilitiesChanged') | ||
| }); | ||
|
|
||
| const isInitMarker = (item: ReducerInput): item is InitMarker => item === INIT_MARKER; | ||
|
|
||
| const isCapabilitiesChangedEvent = (activity: ReducerInput): boolean => | ||
| safeParse(CapabilitiesChangedEventSchema, activity).success; | ||
|
|
||
| /** | ||
| * Composer that derives capabilities from the adapter using a pure derivation pattern. | ||
| * | ||
| * Design principles: | ||
| * 1. Initial fetch: Pulls capabilities from adapter on mount via synthetic init marker | ||
| * 2. Event-driven updates: Re-fetches only when 'capabilitiesChanged' event is detected | ||
| * 3. Stable references: Individual capability objects maintain reference equality if unchanged | ||
| * - This ensures consumers using selectors only re-render when their capability changes | ||
| */ | ||
| const CapabilitiesComposer = memo(({ children }: Props) => { | ||
| const [activities] = useActivities(); | ||
| const { directLine } = useWebChatAPIContext(); | ||
|
|
||
| const activitiesWithInit = useMemo<readonly ReducerInput[]>( | ||
| () => Object.freeze([INIT_MARKER, ...activities]), | ||
| [activities] | ||
| ); | ||
|
|
||
| // TODO: [P1] update to use EventTarget than activity$. | ||
| const capabilities = useReduceMemo( | ||
| activitiesWithInit, | ||
| useCallback( | ||
| (prevCapabilities: Capabilities, item: ReducerInput): Capabilities => { | ||
| const shouldFetch = isInitMarker(item) || isCapabilitiesChangedEvent(item); | ||
|
|
||
| if (!shouldFetch) { | ||
| return prevCapabilities; | ||
| } | ||
|
|
||
| const { capabilities: newCapabilities, hasChanged } = fetchCapabilitiesFromAdapter( | ||
| directLine, | ||
| prevCapabilities | ||
| ); | ||
|
|
||
| return hasChanged ? newCapabilities : prevCapabilities; | ||
| }, | ||
| [directLine] | ||
| ), | ||
| EMPTY_CAPABILITIES | ||
| ); | ||
|
|
||
pranavjoshi001 marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| const contextValue = useMemo(() => Object.freeze({ capabilities }), [capabilities]); | ||
|
|
||
| return <CapabilitiesContext.Provider value={contextValue}>{children}</CapabilitiesContext.Provider>; | ||
| }); | ||
|
|
||
| CapabilitiesComposer.displayName = 'CapabilitiesComposer'; | ||
|
|
||
| export default CapabilitiesComposer; | ||
13 changes: 13 additions & 0 deletions
13
packages/api/src/providers/Capabilities/private/Context.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,13 @@ | ||
| import { createContext } from 'react'; | ||
| import type { Capabilities } from '../types/Capabilities'; | ||
|
|
||
| type CapabilitiesContextType = Readonly<{ | ||
| capabilities: Capabilities; | ||
| }>; | ||
|
|
||
| const CapabilitiesContext = createContext<CapabilitiesContextType | undefined>(undefined); | ||
|
|
||
| CapabilitiesContext.displayName = 'CapabilitiesContext'; | ||
|
|
||
| export default CapabilitiesContext; | ||
| export type { CapabilitiesContextType }; |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.