@runtypelabs/persona 3.29.1 → 3.31.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +15 -2547
- package/dist/animations/glyph-cycle.d.cts +1 -1
- package/dist/animations/glyph-cycle.d.ts +1 -1
- package/dist/animations/{types-B_Qazlm4.d.cts → types-quh7NmYD.d.cts} +9 -0
- package/dist/animations/{types-B_Qazlm4.d.ts → types-quh7NmYD.d.ts} +9 -0
- package/dist/animations/wipe.d.cts +1 -1
- package/dist/animations/wipe.d.ts +1 -1
- package/dist/codegen.cjs +6 -6
- package/dist/codegen.js +4 -4
- package/dist/index.cjs +50 -50
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +164 -7
- package/dist/index.d.ts +164 -7
- package/dist/index.global.js +60 -60
- package/dist/index.global.js.map +1 -1
- package/dist/index.js +49 -49
- package/dist/index.js.map +1 -1
- package/dist/launcher.global.js +2 -2
- package/dist/launcher.global.js.map +1 -1
- package/dist/plugin-kit.cjs +1 -0
- package/dist/plugin-kit.d.cts +98 -0
- package/dist/plugin-kit.d.ts +98 -0
- package/dist/plugin-kit.js +1 -0
- package/dist/smart-dom-reader.d.cts +157 -4
- package/dist/smart-dom-reader.d.ts +157 -4
- package/dist/theme-editor.cjs +40 -40
- package/dist/theme-editor.d.cts +161 -6
- package/dist/theme-editor.d.ts +161 -6
- package/dist/theme-editor.js +40 -40
- package/dist/theme-reference.cjs +1 -1
- package/dist/theme-reference.d.cts +2 -0
- package/dist/theme-reference.d.ts +2 -0
- package/dist/theme-reference.js +1 -1
- package/dist/widget.css +19 -0
- package/package.json +8 -2
- package/src/client.ts +6 -0
- package/src/components/approval-bubble.test.ts +320 -0
- package/src/components/approval-bubble.ts +190 -20
- package/src/components/launcher.ts +6 -3
- package/src/components/panel.ts +7 -1
- package/src/components/tool-bubble.test.ts +39 -0
- package/src/components/tool-bubble.ts +4 -0
- package/src/defaults.ts +14 -0
- package/src/generated/runtype-openapi-contract.ts +4 -0
- package/src/index-core.ts +1 -0
- package/src/plugin-kit.test.ts +230 -0
- package/src/plugin-kit.ts +294 -0
- package/src/plugins/types.ts +49 -2
- package/src/session.test.ts +161 -0
- package/src/session.ts +41 -3
- package/src/styles/widget.css +19 -0
- package/src/theme-editor/preview-utils.test.ts +10 -0
- package/src/theme-editor/preview-utils.ts +29 -1
- package/src/theme-editor/sections.test.ts +17 -0
- package/src/theme-editor/sections.ts +31 -0
- package/src/theme-reference.ts +2 -2
- package/src/types/theme.ts +2 -0
- package/src/types.ts +109 -2
- package/src/ui.approval-plugin.test.ts +204 -0
- package/src/ui.scroll.test.ts +383 -0
- package/src/ui.ts +539 -56
- package/src/utils/auto-follow.test.ts +91 -0
- package/src/utils/auto-follow.ts +68 -0
- package/src/utils/theme.test.ts +6 -2
- package/src/utils/theme.ts +0 -8
- package/src/utils/tokens.ts +6 -1
- package/src/webmcp-bridge.test.ts +66 -0
- package/src/webmcp-bridge.ts +49 -0
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
import { describe, expect, it } from "vitest";
|
|
2
2
|
|
|
3
3
|
import {
|
|
4
|
+
computeAnchorScrollState,
|
|
5
|
+
computeShrunkSpacerHeight,
|
|
4
6
|
createFollowStateController,
|
|
5
7
|
getScrollBottomOffset,
|
|
8
|
+
hasSelectionWithin,
|
|
6
9
|
isElementNearBottom,
|
|
7
10
|
resolveFollowStateFromScroll,
|
|
8
11
|
resolveFollowStateFromWheel
|
|
@@ -90,6 +93,94 @@ describe("auto-follow utilities", () => {
|
|
|
90
93
|
expect(resume.action).toBe("resume");
|
|
91
94
|
});
|
|
92
95
|
|
|
96
|
+
it("detects an active selection touching the container", () => {
|
|
97
|
+
const inside = { name: "inside" } as unknown as Node;
|
|
98
|
+
const outside = { name: "outside" } as unknown as Node;
|
|
99
|
+
const container = {
|
|
100
|
+
contains: (node: Node | null) => node === inside
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
expect(hasSelectionWithin(null, container)).toBe(false);
|
|
104
|
+
expect(
|
|
105
|
+
hasSelectionWithin(
|
|
106
|
+
{ isCollapsed: true, anchorNode: inside, focusNode: inside },
|
|
107
|
+
container
|
|
108
|
+
)
|
|
109
|
+
).toBe(false);
|
|
110
|
+
expect(
|
|
111
|
+
hasSelectionWithin(
|
|
112
|
+
{ isCollapsed: false, anchorNode: outside, focusNode: outside },
|
|
113
|
+
container
|
|
114
|
+
)
|
|
115
|
+
).toBe(false);
|
|
116
|
+
expect(
|
|
117
|
+
hasSelectionWithin(
|
|
118
|
+
{ isCollapsed: false, anchorNode: inside, focusNode: outside },
|
|
119
|
+
container
|
|
120
|
+
)
|
|
121
|
+
).toBe(true);
|
|
122
|
+
expect(
|
|
123
|
+
hasSelectionWithin(
|
|
124
|
+
{ isCollapsed: false, anchorNode: outside, focusNode: inside },
|
|
125
|
+
container
|
|
126
|
+
)
|
|
127
|
+
).toBe(true);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it("computes anchor-top scroll geometry", () => {
|
|
131
|
+
// Anchored message deep in the transcript: spacer fills the shortfall so
|
|
132
|
+
// the target position is reachable.
|
|
133
|
+
expect(
|
|
134
|
+
computeAnchorScrollState({
|
|
135
|
+
anchorOffsetTop: 700,
|
|
136
|
+
topOffset: 16,
|
|
137
|
+
viewportHeight: 400,
|
|
138
|
+
contentHeight: 1000
|
|
139
|
+
})
|
|
140
|
+
).toEqual({ targetScrollTop: 684, spacerHeight: 84 });
|
|
141
|
+
|
|
142
|
+
// Message near the top of the transcript: target clamps to 0.
|
|
143
|
+
expect(
|
|
144
|
+
computeAnchorScrollState({
|
|
145
|
+
anchorOffsetTop: 10,
|
|
146
|
+
topOffset: 16,
|
|
147
|
+
viewportHeight: 400,
|
|
148
|
+
contentHeight: 1000
|
|
149
|
+
}).targetScrollTop
|
|
150
|
+
).toBe(0);
|
|
151
|
+
|
|
152
|
+
// Already enough content below: no spacer needed.
|
|
153
|
+
expect(
|
|
154
|
+
computeAnchorScrollState({
|
|
155
|
+
anchorOffsetTop: 500,
|
|
156
|
+
topOffset: 16,
|
|
157
|
+
viewportHeight: 400,
|
|
158
|
+
contentHeight: 2000
|
|
159
|
+
}).spacerHeight
|
|
160
|
+
).toBe(0);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it("shrinks the anchor spacer as content grows, never below zero or above initial", () => {
|
|
164
|
+
const base = {
|
|
165
|
+
initialSpacerHeight: 100,
|
|
166
|
+
contentHeightAtAnchor: 1000
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
expect(
|
|
170
|
+
computeShrunkSpacerHeight({ ...base, currentContentHeight: 1000 })
|
|
171
|
+
).toBe(100);
|
|
172
|
+
expect(
|
|
173
|
+
computeShrunkSpacerHeight({ ...base, currentContentHeight: 1040 })
|
|
174
|
+
).toBe(60);
|
|
175
|
+
expect(
|
|
176
|
+
computeShrunkSpacerHeight({ ...base, currentContentHeight: 1500 })
|
|
177
|
+
).toBe(0);
|
|
178
|
+
// Content shrank (e.g. a collapsed tool row): spacer never grows back.
|
|
179
|
+
expect(
|
|
180
|
+
computeShrunkSpacerHeight({ ...base, currentContentHeight: 900 })
|
|
181
|
+
).toBe(100);
|
|
182
|
+
});
|
|
183
|
+
|
|
93
184
|
it("resolves wheel intent for pause and resume", () => {
|
|
94
185
|
expect(
|
|
95
186
|
resolveFollowStateFromWheel({
|
package/src/utils/auto-follow.ts
CHANGED
|
@@ -110,3 +110,71 @@ export function resolveFollowStateFromWheel(
|
|
|
110
110
|
|
|
111
111
|
return "none";
|
|
112
112
|
}
|
|
113
|
+
|
|
114
|
+
export type SelectionLike = Pick<Selection, "isCollapsed"> & {
|
|
115
|
+
anchorNode: Node | null;
|
|
116
|
+
focusNode: Node | null;
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* True when the user has a non-collapsed text selection that touches the
|
|
121
|
+
* given container. Used to pause auto-follow while the user is selecting
|
|
122
|
+
* transcript text, so the streaming scroll doesn't drag content out from
|
|
123
|
+
* under an in-progress drag-selection.
|
|
124
|
+
*/
|
|
125
|
+
export function hasSelectionWithin(
|
|
126
|
+
selection: SelectionLike | null,
|
|
127
|
+
container: Pick<Node, "contains">
|
|
128
|
+
): boolean {
|
|
129
|
+
if (!selection || selection.isCollapsed) return false;
|
|
130
|
+
return (
|
|
131
|
+
container.contains(selection.anchorNode) ||
|
|
132
|
+
container.contains(selection.focusNode)
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export type AnchorScrollInput = {
|
|
137
|
+
/** Top of the anchored user message, relative to the scroll content. */
|
|
138
|
+
anchorOffsetTop: number;
|
|
139
|
+
/** Desired gap kept between the anchored message and the viewport top. */
|
|
140
|
+
topOffset: number;
|
|
141
|
+
/** clientHeight of the scroll container. */
|
|
142
|
+
viewportHeight: number;
|
|
143
|
+
/** scrollHeight of the scroll container, excluding any anchor spacer. */
|
|
144
|
+
contentHeight: number;
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Geometry for anchor-top ("pin the sent user message near the viewport
|
|
149
|
+
* top") scrolling. The spacer height is the extra scrollable room needed so
|
|
150
|
+
* the target scroll position is reachable before the streamed response has
|
|
151
|
+
* grown tall enough to provide it naturally.
|
|
152
|
+
*/
|
|
153
|
+
export function computeAnchorScrollState(input: AnchorScrollInput): {
|
|
154
|
+
targetScrollTop: number;
|
|
155
|
+
spacerHeight: number;
|
|
156
|
+
} {
|
|
157
|
+
const targetScrollTop = Math.max(0, input.anchorOffsetTop - input.topOffset);
|
|
158
|
+
const spacerHeight = Math.max(
|
|
159
|
+
0,
|
|
160
|
+
targetScrollTop + input.viewportHeight - input.contentHeight
|
|
161
|
+
);
|
|
162
|
+
return { targetScrollTop, spacerHeight };
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Shrink-only spacer update: as streamed content grows beneath the anchored
|
|
167
|
+
* message, the spacer gives back exactly that much room so total scroll
|
|
168
|
+
* height stays constant and nothing jumps. Never grows after anchoring.
|
|
169
|
+
*/
|
|
170
|
+
export function computeShrunkSpacerHeight(input: {
|
|
171
|
+
initialSpacerHeight: number;
|
|
172
|
+
contentHeightAtAnchor: number;
|
|
173
|
+
currentContentHeight: number;
|
|
174
|
+
}): number {
|
|
175
|
+
const growth = Math.max(
|
|
176
|
+
0,
|
|
177
|
+
input.currentContentHeight - input.contentHeightAtAnchor
|
|
178
|
+
);
|
|
179
|
+
return Math.max(0, input.initialSpacerHeight - growth);
|
|
180
|
+
}
|
package/src/utils/theme.test.ts
CHANGED
|
@@ -288,7 +288,7 @@ describe('theme utils', () => {
|
|
|
288
288
|
);
|
|
289
289
|
});
|
|
290
290
|
|
|
291
|
-
it('
|
|
291
|
+
it('drives --persona-tool-bubble-shadow from the theme token (config.toolCall.shadow is applied inline on the bubble, not the root var)', () => {
|
|
292
292
|
const el = document.createElement('div');
|
|
293
293
|
applyThemeVariables(el, {
|
|
294
294
|
colorScheme: 'light',
|
|
@@ -297,8 +297,12 @@ describe('theme utils', () => {
|
|
|
297
297
|
toolBubble: { shadow: '0 1px 2px rgba(255,0,0,0.5)' },
|
|
298
298
|
},
|
|
299
299
|
},
|
|
300
|
+
// config.toolCall.shadow no longer rewrites the root variable — the
|
|
301
|
+
// override is applied inline by createToolBubble (see tool-bubble tests).
|
|
300
302
|
toolCall: { shadow: 'none' },
|
|
301
303
|
});
|
|
302
|
-
expect(el.style.getPropertyValue('--persona-tool-bubble-shadow').trim()).toBe(
|
|
304
|
+
expect(el.style.getPropertyValue('--persona-tool-bubble-shadow').trim()).toBe(
|
|
305
|
+
'0 1px 2px rgba(255,0,0,0.5)'
|
|
306
|
+
);
|
|
303
307
|
});
|
|
304
308
|
});
|
package/src/utils/theme.ts
CHANGED
|
@@ -194,14 +194,6 @@ export const applyThemeVariables = (
|
|
|
194
194
|
for (const [name, value] of Object.entries(cssVars)) {
|
|
195
195
|
element.style.setProperty(name, value);
|
|
196
196
|
}
|
|
197
|
-
|
|
198
|
-
const toolCallShadow = (config as AgentWidgetConfig | undefined)?.toolCall?.shadow;
|
|
199
|
-
if (toolCallShadow !== undefined) {
|
|
200
|
-
element.style.setProperty(
|
|
201
|
-
'--persona-tool-bubble-shadow',
|
|
202
|
-
toolCallShadow.trim() === '' ? 'none' : toolCallShadow
|
|
203
|
-
);
|
|
204
|
-
}
|
|
205
197
|
};
|
|
206
198
|
|
|
207
199
|
export const createThemeObserver = (
|
package/src/utils/tokens.ts
CHANGED
|
@@ -333,7 +333,7 @@ export const DEFAULT_COMPONENTS: ComponentTokens = {
|
|
|
333
333
|
shadow: 'palette.shadows.sm',
|
|
334
334
|
},
|
|
335
335
|
composer: {
|
|
336
|
-
shadow: 'none',
|
|
336
|
+
shadow: 'palette.shadows.none',
|
|
337
337
|
},
|
|
338
338
|
markdown: {
|
|
339
339
|
inlineCode: {
|
|
@@ -389,6 +389,7 @@ export const DEFAULT_COMPONENTS: ComponentTokens = {
|
|
|
389
389
|
background: 'palette.colors.warning.50',
|
|
390
390
|
border: 'palette.colors.warning.200',
|
|
391
391
|
text: 'palette.colors.gray.900',
|
|
392
|
+
shadow: '0 5px 15px rgba(15, 23, 42, 0.08)',
|
|
392
393
|
},
|
|
393
394
|
approve: {
|
|
394
395
|
background: 'palette.colors.success.500',
|
|
@@ -683,6 +684,7 @@ export function themeToCssVariables(theme: PersonaTheme): Record<string, string>
|
|
|
683
684
|
cssVars['--persona-approval-bg'] = cssVars['--persona-components-approval-requested-background'] ?? cssVars['--persona-palette-colors-warning-50'];
|
|
684
685
|
cssVars['--persona-approval-border'] = cssVars['--persona-components-approval-requested-border'] ?? cssVars['--persona-palette-colors-warning-200'];
|
|
685
686
|
cssVars['--persona-approval-text'] = cssVars['--persona-components-approval-requested-text'] ?? cssVars['--persona-palette-colors-gray-900'];
|
|
687
|
+
cssVars['--persona-approval-shadow'] = cssVars['--persona-components-approval-requested-shadow'] ?? '0 5px 15px rgba(15, 23, 42, 0.08)';
|
|
686
688
|
cssVars['--persona-approval-approve-bg'] = cssVars['--persona-components-approval-approve-background'] ?? cssVars['--persona-palette-colors-success-500'];
|
|
687
689
|
cssVars['--persona-approval-deny-bg'] = cssVars['--persona-components-approval-deny-background'] ?? cssVars['--persona-palette-colors-error-500'];
|
|
688
690
|
|
|
@@ -737,6 +739,9 @@ export function themeToCssVariables(theme: PersonaTheme): Record<string, string>
|
|
|
737
739
|
cssVars['--persona-components-panel-shadow'] ??
|
|
738
740
|
cssVars['--persona-palette-shadows-xl'] ??
|
|
739
741
|
'0 25px 50px -12px rgba(0, 0, 0, 0.25)';
|
|
742
|
+
cssVars['--persona-launcher-shadow'] =
|
|
743
|
+
cssVars['--persona-components-launcher-shadow'] ??
|
|
744
|
+
'0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -4px rgba(0, 0, 0, 0.1)';
|
|
740
745
|
cssVars['--persona-input-radius'] =
|
|
741
746
|
cssVars['--persona-components-input-borderRadius'] ??
|
|
742
747
|
cssVars['--persona-radius-lg'] ??
|
|
@@ -27,6 +27,7 @@ vi.mock("@mcp-b/webmcp-polyfill", () => ({
|
|
|
27
27
|
// Import AFTER vi.mock so the bridge's dynamic import resolves to the mock.
|
|
28
28
|
import {
|
|
29
29
|
WebMcpBridge,
|
|
30
|
+
getWebMcpToolDisplayTitle,
|
|
30
31
|
isWebMcpToolName,
|
|
31
32
|
stripWebMcpPrefix,
|
|
32
33
|
computeClientToolsFingerprint,
|
|
@@ -39,6 +40,7 @@ type MockTool = {
|
|
|
39
40
|
name: string;
|
|
40
41
|
description?: string;
|
|
41
42
|
inputSchema?: object;
|
|
43
|
+
title?: string;
|
|
42
44
|
execute: (args: Record<string, unknown>, client: MockClient) => unknown;
|
|
43
45
|
};
|
|
44
46
|
|
|
@@ -47,10 +49,13 @@ const registry: { tools: MockTool[] } = { tools: [] };
|
|
|
47
49
|
/** A fake `document.modelContext` exposing the strict consumer surface. */
|
|
48
50
|
const makeModelContext = () => ({
|
|
49
51
|
async getTools() {
|
|
52
|
+
// Mirrors the real polyfill's getRegisteredToolInfos(): `title` is always
|
|
53
|
+
// present, "" when the tool didn't declare one; annotations are absent.
|
|
50
54
|
return registry.tools.map((t) => ({
|
|
51
55
|
name: t.name,
|
|
52
56
|
description: t.description ?? `mock ${t.name}`,
|
|
53
57
|
inputSchema: JSON.stringify(t.inputSchema ?? { type: "object" }),
|
|
58
|
+
title: t.title ?? "",
|
|
54
59
|
}));
|
|
55
60
|
},
|
|
56
61
|
async executeTool(
|
|
@@ -199,6 +204,67 @@ describe("WebMcpBridge.snapshotForDispatch", () => {
|
|
|
199
204
|
});
|
|
200
205
|
});
|
|
201
206
|
|
|
207
|
+
describe("WebMCP display titles", () => {
|
|
208
|
+
it("records declared titles on snapshot and exposes them via getWebMcpToolDisplayTitle", async () => {
|
|
209
|
+
registry.tools = [
|
|
210
|
+
fakeTool({ name: "add_to_cart", title: "Add to Cart" }),
|
|
211
|
+
fakeTool({ name: "search_products" }),
|
|
212
|
+
];
|
|
213
|
+
const bridge = new WebMcpBridge({ enabled: true });
|
|
214
|
+
await bridge.snapshotForDispatch();
|
|
215
|
+
expect(getWebMcpToolDisplayTitle("add_to_cart")).toBe("Add to Cart");
|
|
216
|
+
expect(getWebMcpToolDisplayTitle("webmcp:add_to_cart")).toBe("Add to Cart");
|
|
217
|
+
expect(getWebMcpToolDisplayTitle("search_products")).toBeUndefined();
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
it("evicts a stale title when the tool re-registers without one", async () => {
|
|
221
|
+
registry.tools = [fakeTool({ name: "add_to_cart", title: "Add to Cart" })];
|
|
222
|
+
const bridge = new WebMcpBridge({ enabled: true });
|
|
223
|
+
await bridge.snapshotForDispatch();
|
|
224
|
+
expect(getWebMcpToolDisplayTitle("add_to_cart")).toBe("Add to Cart");
|
|
225
|
+
|
|
226
|
+
registry.tools = [fakeTool({ name: "add_to_cart" })];
|
|
227
|
+
await bridge.snapshotForDispatch();
|
|
228
|
+
expect(getWebMcpToolDisplayTitle("add_to_cart")).toBeUndefined();
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
it("evicts the title when the tool is removed from the registry entirely", async () => {
|
|
232
|
+
registry.tools = [
|
|
233
|
+
fakeTool({ name: "add_to_cart", title: "Add to Cart" }),
|
|
234
|
+
fakeTool({ name: "search_products", title: "Search the catalog" }),
|
|
235
|
+
];
|
|
236
|
+
const bridge = new WebMcpBridge({ enabled: true });
|
|
237
|
+
await bridge.snapshotForDispatch();
|
|
238
|
+
expect(getWebMcpToolDisplayTitle("add_to_cart")).toBe("Add to Cart");
|
|
239
|
+
|
|
240
|
+
registry.tools = [fakeTool({ name: "search_products", title: "Search the catalog" })];
|
|
241
|
+
await bridge.snapshotForDispatch();
|
|
242
|
+
expect(getWebMcpToolDisplayTitle("add_to_cart")).toBeUndefined();
|
|
243
|
+
expect(getWebMcpToolDisplayTitle("search_products")).toBe("Search the catalog");
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
it("passes the declared title to the confirm gate", async () => {
|
|
247
|
+
registry.tools = [fakeTool({ name: "add_to_cart", title: "Add to Cart" })];
|
|
248
|
+
const onConfirm = vi.fn(async () => true);
|
|
249
|
+
const bridge = new WebMcpBridge({ enabled: true, onConfirm });
|
|
250
|
+
const r = await bridge.executeToolCall("webmcp:add_to_cart", {});
|
|
251
|
+
expect(r.isError).toBeFalsy();
|
|
252
|
+
expect(onConfirm).toHaveBeenCalledWith(
|
|
253
|
+
expect.objectContaining({ toolName: "add_to_cart", title: "Add to Cart" })
|
|
254
|
+
);
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
it("omits title from the confirm gate when the tool didn't declare one", async () => {
|
|
258
|
+
registry.tools = [fakeTool({ name: "add_to_cart" })];
|
|
259
|
+
const onConfirm = vi.fn(async () => true);
|
|
260
|
+
const bridge = new WebMcpBridge({ enabled: true, onConfirm });
|
|
261
|
+
await bridge.executeToolCall("webmcp:add_to_cart", {});
|
|
262
|
+
expect(onConfirm).toHaveBeenCalledWith(
|
|
263
|
+
expect.not.objectContaining({ title: expect.anything() })
|
|
264
|
+
);
|
|
265
|
+
});
|
|
266
|
+
});
|
|
267
|
+
|
|
202
268
|
describe("WebMcpBridge.executeToolCall", () => {
|
|
203
269
|
it("returns isError when WebMCP is not enabled", async () => {
|
|
204
270
|
const bridge = new WebMcpBridge({} as AgentWidgetWebMcpConfig);
|
package/src/webmcp-bridge.ts
CHANGED
|
@@ -66,6 +66,14 @@ interface ModelContextToolInfo {
|
|
|
66
66
|
description: string;
|
|
67
67
|
/** JSON-encoded JSON Schema for the tool's input. */
|
|
68
68
|
inputSchema?: string;
|
|
69
|
+
/**
|
|
70
|
+
* Display title declared on the tool (`ToolDescriptor.title` in the WebMCP
|
|
71
|
+
* spec). The polyfill returns `""` when the tool didn't declare one. Note:
|
|
72
|
+
* `annotations` (incl. the legacy `annotations.title`) are NOT exposed on
|
|
73
|
+
* this strict consumer surface — top-level `title` is the only display-name
|
|
74
|
+
* channel available to us.
|
|
75
|
+
*/
|
|
76
|
+
title?: string;
|
|
69
77
|
}
|
|
70
78
|
|
|
71
79
|
interface ModelContextCoreLike {
|
|
@@ -77,6 +85,43 @@ interface ModelContextCoreLike {
|
|
|
77
85
|
): Promise<string | null>;
|
|
78
86
|
}
|
|
79
87
|
|
|
88
|
+
/**
|
|
89
|
+
* Page-global map of bare tool name → declared display title
|
|
90
|
+
* (`ToolDescriptor.title`). `document.modelContext` is page-global, so a
|
|
91
|
+
* single map shared across widget/bridge instances is semantically correct.
|
|
92
|
+
* Refreshed on every registry read (`snapshotForDispatch` / `executeToolCall`)
|
|
93
|
+
* and consumed by the approval bubble's summary line via
|
|
94
|
+
* `getWebMcpToolDisplayTitle`.
|
|
95
|
+
*/
|
|
96
|
+
const webMcpToolDisplayTitles = new Map<string, string>();
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Record declared display titles from a fresh `getTools()` read. The map is
|
|
100
|
+
* rebuilt from scratch — callers always pass the FULL registry snapshot — so
|
|
101
|
+
* a tool that unregistered or dropped its title can't leave a stale label
|
|
102
|
+
* behind. Exported for tests; production callers are the bridge's registry
|
|
103
|
+
* reads.
|
|
104
|
+
*/
|
|
105
|
+
export const recordWebMcpToolDisplayTitles = (
|
|
106
|
+
infos: ModelContextToolInfo[],
|
|
107
|
+
): void => {
|
|
108
|
+
webMcpToolDisplayTitles.clear();
|
|
109
|
+
for (const info of infos) {
|
|
110
|
+
const title = info.title?.trim();
|
|
111
|
+
if (title) webMcpToolDisplayTitles.set(info.name, title);
|
|
112
|
+
}
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Look up the display title a page tool declared via the WebMCP spec's
|
|
117
|
+
* `ToolDescriptor.title`. Accepts wire (`webmcp:add_to_cart`) or bare
|
|
118
|
+
* (`add_to_cart`) names. Returns `undefined` when the tool didn't declare
|
|
119
|
+
* one (callers fall back to humanizing the tool name).
|
|
120
|
+
*/
|
|
121
|
+
export const getWebMcpToolDisplayTitle = (
|
|
122
|
+
toolName: string,
|
|
123
|
+
): string | undefined => webMcpToolDisplayTitles.get(stripWebMcpPrefix(toolName));
|
|
124
|
+
|
|
80
125
|
const log = {
|
|
81
126
|
warn(message: string, ...rest: unknown[]): void {
|
|
82
127
|
if (typeof console !== "undefined" && typeof console.warn === "function") {
|
|
@@ -220,6 +265,7 @@ export class WebMcpBridge {
|
|
|
220
265
|
log.warn("getTools() threw — shipping an empty WebMCP snapshot.", err);
|
|
221
266
|
return [];
|
|
222
267
|
}
|
|
268
|
+
recordWebMcpToolDisplayTitles(infos);
|
|
223
269
|
|
|
224
270
|
const pageOrigin = typeof location !== "undefined" ? location.origin : "";
|
|
225
271
|
|
|
@@ -295,6 +341,7 @@ export class WebMcpBridge {
|
|
|
295
341
|
const message = err instanceof Error ? err.message : String(err);
|
|
296
342
|
return errorResult(`Failed to read WebMCP registry: ${message}`);
|
|
297
343
|
}
|
|
344
|
+
recordWebMcpToolDisplayTitles(infos);
|
|
298
345
|
const info = infos.find((candidate) => candidate.name === bareName);
|
|
299
346
|
|
|
300
347
|
if (!info) {
|
|
@@ -323,10 +370,12 @@ export class WebMcpBridge {
|
|
|
323
370
|
|
|
324
371
|
// Confirm-by-default gate. Every `webmcp:*` call routes through here,
|
|
325
372
|
// regardless of `annotations.readOnlyHint`.
|
|
373
|
+
const displayTitle = getWebMcpToolDisplayTitle(bareName);
|
|
326
374
|
const gateInfo: WebMcpConfirmInfo = {
|
|
327
375
|
toolName: bareName,
|
|
328
376
|
args,
|
|
329
377
|
description: info.description,
|
|
378
|
+
...(displayTitle ? { title: displayTitle } : {}),
|
|
330
379
|
reason: "gate",
|
|
331
380
|
};
|
|
332
381
|
if (!(await this.requestConfirm(gateInfo))) {
|