Skip to content

Commit d02108c

Browse files
Pull based capabilities from adapter (#5679)
* pull based capabilities * chanagelog updated * review comment fixed
1 parent 46a12f8 commit d02108c

File tree

19 files changed

+521
-16
lines changed

19 files changed

+521
-16
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,7 @@ Breaking changes in this release:
149149
- Breakpoint: open <kbd>F12</kbd>, select the subject in Element pane, type `$0.webChat.breakpoint.incomingActivity`
150150
- The `botframework-webchat` package now uses CSS modules for styling purposes, in PR [#5666](https://github.com/microsoft/BotFramework-WebChat/pull/5666), in PR [#5677](https://github.com/microsoft/BotFramework-WebChat/pull/5677) by [@OEvgeny](https://github.com/OEvgeny)
151151
- 👷🏻 Added `npm run build-browser` script for building test harness package only, in PR [#5667](https://github.com/microsoft/BotFramework-WebChat/pull/5667), by [@compulim](https://github.com/compulim)
152+
- Added pull-based capabilities system for dynamically discovering adapter capabilities at runtime, in PR [#5679](https://github.com/microsoft/BotFramework-WebChat/pull/5679), by [@pranavjoshi001](https://github.com/pranavjoshi001)
152153

153154
### Changed
154155

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
<!doctype html>
2+
<html lang="en-US">
3+
<head>
4+
<link href="/assets/index.css" rel="stylesheet" type="text/css" />
5+
</head>
6+
<body>
7+
<main id="webchat"></main>
8+
<script type="importmap">
9+
{
10+
"imports": {
11+
"@testduet/wait-for": "https://unpkg.com/@testduet/wait-for@main/dist/wait-for.mjs",
12+
"botframework-webchat": "/__dist__/packages/bundle/static/botframework-webchat.js",
13+
"botframework-webchat/component": "/__dist__/packages/bundle/static/botframework-webchat/component.js",
14+
"botframework-webchat/hook": "/__dist__/packages/bundle/static/botframework-webchat/hook.js",
15+
"react": "https://esm.sh/react@18",
16+
"react-dom": "https://esm.sh/react-dom@18",
17+
"react-dom/": "https://esm.sh/react-dom@18/"
18+
}
19+
}
20+
</script>
21+
<script type="module">
22+
import '/test-harness.mjs';
23+
import '/test-page-object.mjs';
24+
25+
import { waitFor } from '@testduet/wait-for';
26+
import { createStoreWithOptions, testIds } from 'botframework-webchat';
27+
import { useCapabilities } from 'botframework-webchat/hook';
28+
import createRenderHook from '/assets/esm/createRenderHook.js';
29+
30+
const { createDirectLineEmulator } = window.testHelpers;
31+
32+
window.WebChat = { createStoreWithOptions, testIds };
33+
34+
run(async function () {
35+
// TEST 1: Initial fetch on mount - capabilities should be fetched when directLine is available
36+
const { directLine, store } = createDirectLineEmulator();
37+
38+
// Set initial capability BEFORE mount (simulating adapter already having capability)
39+
directLine.setCapability('getVoiceConfiguration', { voice: 'en-US', speed: 1.0 }, { emitEvent: false });
40+
41+
const renderHook = createRenderHook(
42+
document.getElementById('webchat'),
43+
{ directLine, store },
44+
{ renderWebChat: true }
45+
);
46+
47+
await renderHook();
48+
await pageConditions.uiConnected();
49+
50+
// Get initial voiceConfiguration using selector
51+
const initialVoiceConfig = await renderHook(() => useCapabilities(caps => caps.voiceConfiguration));
52+
53+
expect(initialVoiceConfig).toEqual({ voice: 'en-US', speed: 1.0 });
54+
55+
// TEST 2: Regular activity should NOT trigger capability re-calculation
56+
// Store reference to current voiceConfiguration
57+
const preActivityVoiceConfig = await renderHook(() => useCapabilities(caps => caps.voiceConfiguration));
58+
59+
// Send a regular message (not capabilitiesChanged event)
60+
await directLine.emulateIncomingActivity({
61+
type: 'message',
62+
text: 'Hello! This is a regular message.',
63+
from: { id: 'bot', role: 'bot' }
64+
});
65+
66+
// Wait for activity to be processed
67+
await new Promise(resolve => setTimeout(resolve, 200));
68+
69+
// Get voiceConfiguration after regular activity
70+
const postActivityVoiceConfig = await renderHook(() => useCapabilities(caps => caps.voiceConfiguration));
71+
72+
// Reference should be the same (no re-calculation for regular activities)
73+
expect(postActivityVoiceConfig).toBe(preActivityVoiceConfig);
74+
75+
// TEST 3: capabilitiesChanged event SHOULD trigger re-calculation
76+
const preChangeVoiceConfig = await renderHook(() => useCapabilities(caps => caps.voiceConfiguration));
77+
78+
// Update capability and emit event
79+
directLine.setCapability('getVoiceConfiguration', { voice: 'en-GB', speed: 1.5 }, { emitEvent: true });
80+
81+
// Wait for event to be processed
82+
await waitFor(async () => {
83+
const voiceConfig = await renderHook(() => useCapabilities(caps => caps.voiceConfiguration));
84+
return voiceConfig?.voice === 'en-GB';
85+
}, { timeout: 2000 });
86+
87+
const postChangeVoiceConfig = await renderHook(() => useCapabilities(caps => caps.voiceConfiguration));
88+
89+
expect(postChangeVoiceConfig).toEqual({ voice: 'en-GB', speed: 1.5 });
90+
expect(postChangeVoiceConfig).not.toBe(preChangeVoiceConfig);
91+
92+
// TEST 4: Same value should reuse reference (shallow equality check)
93+
const preNoChangeVoiceConfig = await renderHook(() => useCapabilities(caps => caps.voiceConfiguration));
94+
95+
// Set same value and emit event
96+
directLine.setCapability('getVoiceConfiguration', { voice: 'en-GB', speed: 1.5 }, { emitEvent: true });
97+
98+
// Wait for event to be processed
99+
await new Promise(resolve => setTimeout(resolve, 200));
100+
101+
const postNoChangeVoiceConfig = await renderHook(() => useCapabilities(caps => caps.voiceConfiguration));
102+
103+
// Reference should be the same when values are equal
104+
expect(postNoChangeVoiceConfig).toBe(preNoChangeVoiceConfig);
105+
expect(postNoChangeVoiceConfig).toEqual({ voice: 'en-GB', speed: 1.5 });
106+
});
107+
</script>
108+
</body>
109+
</html>

docs/CAPABILITIES.md

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
# Capabilities
2+
3+
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.
4+
5+
## Using the hook
6+
7+
Use the `useCapabilities` hook with a selector to access specific capabilities:
8+
9+
```js
10+
import { useCapabilities } from 'botframework-webchat/hook';
11+
12+
// Get voice configuration
13+
const voiceConfig = useCapabilities(caps => caps.voiceConfiguration);
14+
15+
if (voiceConfig) {
16+
console.log(`Sample rate: ${voiceConfig.sampleRate}`);
17+
console.log(`Chunk interval: ${voiceConfig.chunkIntervalMs}ms`);
18+
}
19+
```
20+
21+
> **Note:** A selector function is required. This ensures components only re-render when their specific capability changes.
22+
23+
## Available capabilities
24+
25+
| Capability | Type | Description |
26+
| -------------------- | -------------------------------------------------- | ----------------------------------- |
27+
| `voiceConfiguration` | `{ chunkIntervalMs: number, sampleRate: number }` | Audio settings for Speech-to-Speech |
28+
29+
## How it works
30+
31+
1. **Initial fetch** - When WebChat mounts, it checks if the adapter exposes capability getter functions and retrieves initial values
32+
2. **Event-driven updates** - When the adapter emits a `capabilitiesChanged` event, WebChat re-fetches all capabilities from the adapter
33+
3. **Optimized re-renders** - Only components consuming changed capabilities will re-render
34+
35+
## For adapter implementers
36+
37+
To expose capabilities from your adapter:
38+
39+
### 1. Implement getter functions
40+
41+
```js
42+
const adapter = {
43+
// ... other adapter methods
44+
45+
getVoiceConfiguration() {
46+
return {
47+
sampleRate: 16000,
48+
chunkIntervalMs: 100
49+
};
50+
}
51+
};
52+
```
53+
54+
### 2. Emit change events
55+
56+
When capability values change, emit a `capabilitiesChanged` event activity:
57+
58+
```js
59+
// When configuration changes, emit the nudge event
60+
adapter.activity$.next({
61+
type: 'event',
62+
name: 'capabilitiesChanged',
63+
from: { id: 'bot', role: 'bot' }
64+
});
65+
```
66+
67+
WebChat will then call all capability getter functions and update consumers if values changed.
68+
69+
## Adding new capabilities
70+
71+
To add a new capability:
72+
73+
1. Add the type to `Capabilities` in `packages/api/src/providers/Capabilities/types/Capabilities.ts`
74+
2. Add the registry entry in `packages/api/src/providers/Capabilities/private/capabilityRegistry.ts`
75+
3. Implement the getter in your adapter (e.g., `getMyCapability()`)
76+
77+
The registry maps capability keys to getter function names:
78+
79+
```js
80+
// capabilityRegistry.ts
81+
{
82+
key: 'voiceConfiguration',
83+
getterName: 'getVoiceConfiguration'
84+
}
85+
```

package-lock.json

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/api/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,7 @@
155155
"react-redux": "7.2.9",
156156
"redux": "5.0.1",
157157
"simple-update-in": "2.2.0",
158+
"use-reduce-memo": "0.1.0",
158159
"use-ref-from": "0.1.0",
159160
"valibot": "1.2.0"
160161
},

packages/api/src/boot/hook.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ export {
77
useAvatarForUser,
88
useBuildRenderActivityCallback,
99
useByteFormatter,
10+
useCapabilities,
1011
useConnectivityStatus,
1112
useCreateActivityRenderer,
1213
useCreateActivityStatusRenderer,

packages/api/src/hooks/Composer.tsx

Lines changed: 19 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@ import ActivityListenerComposer from '../providers/ActivityListener/ActivityList
6060
import ActivitySendStatusComposer from '../providers/ActivitySendStatus/ActivitySendStatusComposer';
6161
import ActivitySendStatusTelemetryComposer from '../providers/ActivitySendStatusTelemetry/ActivitySendStatusTelemetryComposer';
6262
import ActivityTypingComposer from '../providers/ActivityTyping/ActivityTypingComposer';
63+
import CapabilitiesComposer from '../providers/Capabilities/CapabilitiesComposer';
6364
import GroupActivitiesComposer from '../providers/GroupActivities/GroupActivitiesComposer';
6465
import PonyfillComposer from '../providers/Ponyfill/PonyfillComposer';
6566
import StyleOptionsComposer from '../providers/StyleOptions/StyleOptionsComposer';
@@ -592,22 +593,24 @@ const ComposerCore = ({
592593

593594
return (
594595
<WebChatAPIContext.Provider value={context}>
595-
<ActivityListenerComposer>
596-
<ActivitySendStatusComposer>
597-
<ActivityTypingComposer>
598-
<SendBoxMiddlewareProvider middleware={sendBoxMiddleware || EMPTY_ARRAY}>
599-
<SendBoxToolbarMiddlewareProvider middleware={sendBoxToolbarMiddleware || EMPTY_ARRAY}>
600-
<GroupActivitiesComposer groupActivitiesMiddleware={singleToArray(groupActivitiesMiddleware)}>
601-
<PolymiddlewareComposer polymiddleware={polymiddleware}>
602-
{typeof children === 'function' ? children(context) : children}
603-
</PolymiddlewareComposer>
604-
</GroupActivitiesComposer>
605-
<ActivitySendStatusTelemetryComposer />
606-
</SendBoxToolbarMiddlewareProvider>
607-
</SendBoxMiddlewareProvider>
608-
</ActivityTypingComposer>
609-
</ActivitySendStatusComposer>
610-
</ActivityListenerComposer>
596+
<CapabilitiesComposer>
597+
<ActivityListenerComposer>
598+
<ActivitySendStatusComposer>
599+
<ActivityTypingComposer>
600+
<SendBoxMiddlewareProvider middleware={sendBoxMiddleware || EMPTY_ARRAY}>
601+
<SendBoxToolbarMiddlewareProvider middleware={sendBoxToolbarMiddleware || EMPTY_ARRAY}>
602+
<GroupActivitiesComposer groupActivitiesMiddleware={singleToArray(groupActivitiesMiddleware)}>
603+
<PolymiddlewareComposer polymiddleware={polymiddleware}>
604+
{typeof children === 'function' ? children(context) : children}
605+
</PolymiddlewareComposer>
606+
</GroupActivitiesComposer>
607+
<ActivitySendStatusTelemetryComposer />
608+
</SendBoxToolbarMiddlewareProvider>
609+
</SendBoxMiddlewareProvider>
610+
</ActivityTypingComposer>
611+
</ActivitySendStatusComposer>
612+
</ActivityListenerComposer>
613+
</CapabilitiesComposer>
611614
{onTelemetry && <Tracker />}
612615
</WebChatAPIContext.Provider>
613616
);

packages/api/src/hooks/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import useCapabilities from '../providers/Capabilities/useCapabilities';
12
import useGroupActivities from '../providers/GroupActivities/useGroupActivities';
23
import useGroupActivitiesByName from '../providers/GroupActivities/useGroupActivitiesByName';
34
import useActiveTyping from './useActiveTyping';
@@ -83,6 +84,7 @@ export {
8384
useAvatarForBot,
8485
useAvatarForUser,
8586
useByteFormatter,
87+
useCapabilities,
8688
useConnectivityStatus,
8789
useCreateActivityRenderer,
8890
useCreateActivityStatusRenderer,
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
import React, { memo, useCallback, useMemo, type ReactNode } from 'react';
2+
import { useReduceMemo } from 'use-reduce-memo';
3+
import type { WebChatActivity } from 'botframework-webchat-core';
4+
import { literal, object, safeParse } from 'valibot';
5+
6+
import useActivities from '../../hooks/useActivities';
7+
import useWebChatAPIContext from '../../hooks/internal/useWebChatAPIContext';
8+
import CapabilitiesContext from './private/Context';
9+
import fetchCapabilitiesFromAdapter from './private/fetchCapabilitiesFromAdapter';
10+
import type { Capabilities } from './types/Capabilities';
11+
12+
type Props = Readonly<{ children?: ReactNode | undefined }>;
13+
14+
const EMPTY_CAPABILITIES: Capabilities = Object.freeze({});
15+
16+
// Synthetic marker to trigger initial fetch - must be a stable reference
17+
const INIT_MARKER = Object.freeze({ type: 'capabilities:init' as const });
18+
type InitMarker = typeof INIT_MARKER;
19+
type ReducerInput = WebChatActivity | InitMarker;
20+
21+
const CapabilitiesChangedEventSchema = object({
22+
type: literal('event'),
23+
name: literal('capabilitiesChanged')
24+
});
25+
26+
const isInitMarker = (item: ReducerInput): item is InitMarker => item === INIT_MARKER;
27+
28+
const isCapabilitiesChangedEvent = (activity: ReducerInput): boolean =>
29+
safeParse(CapabilitiesChangedEventSchema, activity).success;
30+
31+
/**
32+
* Composer that derives capabilities from the adapter using a pure derivation pattern.
33+
*
34+
* Design principles:
35+
* 1. Initial fetch: Pulls capabilities from adapter on mount via synthetic init marker
36+
* 2. Event-driven updates: Re-fetches only when 'capabilitiesChanged' event is detected
37+
* 3. Stable references: Individual capability objects maintain reference equality if unchanged
38+
* - This ensures consumers using selectors only re-render when their capability changes
39+
*/
40+
const CapabilitiesComposer = memo(({ children }: Props) => {
41+
const [activities] = useActivities();
42+
const { directLine } = useWebChatAPIContext();
43+
44+
const activitiesWithInit = useMemo<readonly ReducerInput[]>(
45+
() => Object.freeze([INIT_MARKER, ...activities]),
46+
[activities]
47+
);
48+
49+
// TODO: [P1] update to use EventTarget than activity$.
50+
const capabilities = useReduceMemo(
51+
activitiesWithInit,
52+
useCallback(
53+
(prevCapabilities: Capabilities, item: ReducerInput): Capabilities => {
54+
const shouldFetch = isInitMarker(item) || isCapabilitiesChangedEvent(item);
55+
56+
if (!shouldFetch) {
57+
return prevCapabilities;
58+
}
59+
60+
const { capabilities: newCapabilities, hasChanged } = fetchCapabilitiesFromAdapter(
61+
directLine,
62+
prevCapabilities
63+
);
64+
65+
return hasChanged ? newCapabilities : prevCapabilities;
66+
},
67+
[directLine]
68+
),
69+
EMPTY_CAPABILITIES
70+
);
71+
72+
const contextValue = useMemo(() => Object.freeze({ capabilities }), [capabilities]);
73+
74+
return <CapabilitiesContext.Provider value={contextValue}>{children}</CapabilitiesContext.Provider>;
75+
});
76+
77+
CapabilitiesComposer.displayName = 'CapabilitiesComposer';
78+
79+
export default CapabilitiesComposer;
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import { createContext } from 'react';
2+
import type { Capabilities } from '../types/Capabilities';
3+
4+
type CapabilitiesContextType = Readonly<{
5+
capabilities: Capabilities;
6+
}>;
7+
8+
const CapabilitiesContext = createContext<CapabilitiesContextType | undefined>(undefined);
9+
10+
CapabilitiesContext.displayName = 'CapabilitiesContext';
11+
12+
export default CapabilitiesContext;
13+
export type { CapabilitiesContextType };

0 commit comments

Comments
 (0)