@runtypelabs/persona 3.31.0 → 3.33.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 +2 -0
- package/dist/animations/glyph-cycle.d.cts +1 -1
- package/dist/animations/glyph-cycle.d.ts +1 -1
- package/dist/animations/{types-quh7NmYD.d.cts → types-CthJFfNx.d.cts} +7 -0
- package/dist/animations/{types-quh7NmYD.d.ts → types-CthJFfNx.d.ts} +7 -0
- package/dist/animations/wipe.d.cts +1 -1
- package/dist/animations/wipe.d.ts +1 -1
- package/dist/codegen.cjs +4 -4
- package/dist/codegen.js +3 -3
- package/dist/index.cjs +43 -43
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +288 -8
- package/dist/index.d.ts +288 -8
- package/dist/index.global.js +76 -79
- package/dist/index.global.js.map +1 -1
- package/dist/index.js +43 -43
- package/dist/index.js.map +1 -1
- package/dist/launcher.global.js.map +1 -1
- package/dist/smart-dom-reader.d.cts +78 -2
- package/dist/smart-dom-reader.d.ts +78 -2
- package/dist/theme-editor.cjs +30 -30
- package/dist/theme-editor.d.cts +92 -5
- package/dist/theme-editor.d.ts +92 -5
- package/dist/theme-editor.js +30 -30
- package/dist/webmcp-polyfill.js +4 -0
- package/package.json +4 -3
- package/src/ask-user-question-tool.test.ts +148 -0
- package/src/ask-user-question-tool.ts +138 -0
- package/src/client.ts +43 -9
- package/src/components/messages.ts +10 -0
- package/src/components/suggestions.ts +49 -6
- package/src/index-core.ts +15 -0
- package/src/index-global.ts +39 -0
- package/src/runtime/host-layout.test.ts +158 -3
- package/src/runtime/host-layout.ts +95 -1
- package/src/session.ts +188 -32
- package/src/session.webmcp.test.ts +48 -0
- package/src/suggest-replies-tool.test.ts +445 -0
- package/src/suggest-replies-tool.ts +152 -0
- package/src/theme-editor/index.ts +2 -0
- package/src/theme-editor/webmcp/index.ts +2 -0
- package/src/theme-editor/webmcp/types.ts +16 -3
- package/src/types.ts +79 -2
- package/src/ui.suggest-replies.test.ts +237 -0
- package/src/ui.ts +57 -13
- package/src/utils/dock.test.ts +23 -1
- package/src/utils/dock.ts +2 -0
- package/src/version.ts +5 -2
- package/src/webmcp-bridge.test.ts +60 -0
- package/src/webmcp-bridge.ts +34 -1
- package/src/webmcp-polyfill.ts +16 -0
package/src/types.ts
CHANGED
|
@@ -231,8 +231,13 @@ export type ClientToolDefinition = {
|
|
|
231
231
|
description: string;
|
|
232
232
|
/** JSON Schema (per WebMCP spec) — passed through as-is. */
|
|
233
233
|
parametersSchema?: object;
|
|
234
|
-
/**
|
|
235
|
-
|
|
234
|
+
/**
|
|
235
|
+
* `'webmcp'` for tools discovered via the polyfill (server prepends the
|
|
236
|
+
* `webmcp:` wire prefix); `'sdk'` for widget/SDK-provided tools (name stays
|
|
237
|
+
* bare on the wire). Matches the server's accepted enum — any other value
|
|
238
|
+
* fails dispatch validation.
|
|
239
|
+
*/
|
|
240
|
+
origin?: 'webmcp' | 'sdk';
|
|
236
241
|
/** Origin of the page that registered the tool — for server-side audit. */
|
|
237
242
|
pageOrigin?: string;
|
|
238
243
|
/**
|
|
@@ -397,6 +402,13 @@ export type AgentMessageMetadata = {
|
|
|
397
402
|
* paginated stepper. Persists alongside `askUserQuestionAnswers`.
|
|
398
403
|
*/
|
|
399
404
|
askUserQuestionIndex?: number;
|
|
405
|
+
/**
|
|
406
|
+
* Set to `true` once a `suggest_replies` tool call's fire-and-forget
|
|
407
|
+
* `/resume` has been accepted by the server. Persisted belt-and-suspenders
|
|
408
|
+
* mirror of the in-memory resolved-key dedupe, so hydration/re-emit paths
|
|
409
|
+
* never re-resume the call.
|
|
410
|
+
*/
|
|
411
|
+
suggestRepliesResolved?: boolean;
|
|
400
412
|
};
|
|
401
413
|
|
|
402
414
|
export type AgentWidgetRequestMiddlewareContext = {
|
|
@@ -1092,6 +1104,38 @@ export type AgentWidgetFeatureFlags = {
|
|
|
1092
1104
|
* pills + optional free-text input.
|
|
1093
1105
|
*/
|
|
1094
1106
|
askUserQuestion?: AgentWidgetAskUserQuestionFeature;
|
|
1107
|
+
/**
|
|
1108
|
+
* Built-in `suggest_replies` quick-reply chips. When the assistant invokes
|
|
1109
|
+
* the tool, the widget shows the suggestions as tappable chips above the
|
|
1110
|
+
* composer (reusing the suggestion-chips surface) and immediately resumes
|
|
1111
|
+
* the execution — fire-and-forget, no user input awaited.
|
|
1112
|
+
*/
|
|
1113
|
+
suggestReplies?: AgentWidgetSuggestRepliesFeature;
|
|
1114
|
+
};
|
|
1115
|
+
|
|
1116
|
+
/**
|
|
1117
|
+
* Feature config for the built-in `suggest_replies` quick-reply chips.
|
|
1118
|
+
* Chips render in the existing suggestions slot above the composer and are
|
|
1119
|
+
* styled by the widget-level `suggestionChipsConfig`. A tapped chip is sent
|
|
1120
|
+
* verbatim as the user's next message; chips clear once any user message
|
|
1121
|
+
* follows them.
|
|
1122
|
+
*/
|
|
1123
|
+
export type AgentWidgetSuggestRepliesFeature = {
|
|
1124
|
+
/**
|
|
1125
|
+
* Enable the feature. Defaults to true. When false, `suggest_replies`
|
|
1126
|
+
* renders as a regular tool bubble and is NOT auto-resumed — only set this
|
|
1127
|
+
* with no server-side `suggest_replies` declaration, or the execution
|
|
1128
|
+
* parks awaiting a resume that never comes.
|
|
1129
|
+
*/
|
|
1130
|
+
enabled?: boolean;
|
|
1131
|
+
/**
|
|
1132
|
+
* Advertise the built-in `suggest_replies` tool to the agent on every
|
|
1133
|
+
* dispatch via `clientTools[]` — no server-side `runtimeTools` declaration
|
|
1134
|
+
* needed. Defaults to `false`: flows that already declare the tool via
|
|
1135
|
+
* `runtimeTools` would otherwise present it to the model twice. Ignored
|
|
1136
|
+
* when `enabled` is `false`.
|
|
1137
|
+
*/
|
|
1138
|
+
expose?: boolean;
|
|
1095
1139
|
};
|
|
1096
1140
|
|
|
1097
1141
|
/**
|
|
@@ -1157,6 +1201,19 @@ export type AgentWidgetAskUserQuestionStyles = {
|
|
|
1157
1201
|
export type AgentWidgetAskUserQuestionFeature = {
|
|
1158
1202
|
/** Enable the feature. Defaults to true. When false, `ask_user_question` renders as a regular tool bubble. */
|
|
1159
1203
|
enabled?: boolean;
|
|
1204
|
+
/**
|
|
1205
|
+
* Advertise the built-in `ask_user_question` tool to the agent on every
|
|
1206
|
+
* dispatch via `clientTools[]` — no server-side `runtimeTools` declaration
|
|
1207
|
+
* needed. The tool ships with a model-facing description and JSON schema
|
|
1208
|
+
* matching {@link AskUserQuestionPayload}; when the model calls it, the
|
|
1209
|
+
* existing answer-pill sheet renders and the answer resumes the execution.
|
|
1210
|
+
*
|
|
1211
|
+
* Defaults to `false`: flows that already declare `ask_user_question` via
|
|
1212
|
+
* `runtimeTools` would otherwise present the tool to the model twice.
|
|
1213
|
+
* Ignored when `enabled` is `false` — never offer the agent a question
|
|
1214
|
+
* tool the widget can't render an answer UI for.
|
|
1215
|
+
*/
|
|
1216
|
+
expose?: boolean;
|
|
1160
1217
|
/** Slide-in animation duration in ms. Defaults to 180. */
|
|
1161
1218
|
slideInMs?: number;
|
|
1162
1219
|
/** Label for the free-text pill. Defaults to "Other…". */
|
|
@@ -1350,6 +1407,26 @@ export type AgentWidgetDockConfig = {
|
|
|
1350
1407
|
* it appears to emerge at full width like a floating widget.
|
|
1351
1408
|
*/
|
|
1352
1409
|
reveal?: "resize" | "overlay" | "push" | "emerge";
|
|
1410
|
+
/**
|
|
1411
|
+
* Maximum height of the dock panel, applied as a viewport-overflow guard.
|
|
1412
|
+
*
|
|
1413
|
+
* The docked shell sizes itself with `height: 100%`, which only resolves when
|
|
1414
|
+
* an ancestor (usually `html, body { height: 100% }`) provides a definite
|
|
1415
|
+
* height. Without one, the dock column would otherwise grow with the
|
|
1416
|
+
* conversation and scroll off the page. This cap clamps the panel to the
|
|
1417
|
+
* viewport (and keeps the `resize`/`emerge` reveals pinned with
|
|
1418
|
+
* `position: sticky`; `push`/`overlay` get the cap only, since their
|
|
1419
|
+
* transform/absolute contexts defeat sticky) so a missing height chain
|
|
1420
|
+
* degrades gracefully instead of breaking the chat.
|
|
1421
|
+
*
|
|
1422
|
+
* - Set a CSS length (e.g. `"600px"`, `"80vh"`) to override the cap.
|
|
1423
|
+
* - Set `false` to disable the guard entirely (the panel then sizes purely
|
|
1424
|
+
* from the surrounding layout — make sure your page provides a definite
|
|
1425
|
+
* height all the way down to the dock target's parent).
|
|
1426
|
+
*
|
|
1427
|
+
* @default "100dvh"
|
|
1428
|
+
*/
|
|
1429
|
+
maxHeight?: string | false;
|
|
1353
1430
|
};
|
|
1354
1431
|
|
|
1355
1432
|
/**
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
|
|
3
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
4
|
+
|
|
5
|
+
import { createAgentExperience } from "./ui";
|
|
6
|
+
import { SUGGEST_REPLIES_TOOL_NAME } from "./suggest-replies-tool";
|
|
7
|
+
|
|
8
|
+
const createMount = () => {
|
|
9
|
+
const mount = document.createElement("div");
|
|
10
|
+
document.body.appendChild(mount);
|
|
11
|
+
return mount;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
const makeController = (config?: Record<string, unknown>) => {
|
|
15
|
+
const mount = createMount();
|
|
16
|
+
const controller = createAgentExperience(mount, {
|
|
17
|
+
apiUrl: "https://api.example.com/chat",
|
|
18
|
+
launcher: { enabled: false },
|
|
19
|
+
suggestionChips: [],
|
|
20
|
+
...config,
|
|
21
|
+
} as unknown as Parameters<typeof createAgentExperience>[1]);
|
|
22
|
+
return { mount, controller };
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
const injectUserMessage = (
|
|
26
|
+
controller: ReturnType<typeof createAgentExperience>,
|
|
27
|
+
id = "u1",
|
|
28
|
+
createdAt = "2026-06-10T00:00:00.000Z",
|
|
29
|
+
) => {
|
|
30
|
+
controller.injectTestMessage({
|
|
31
|
+
type: "message",
|
|
32
|
+
message: {
|
|
33
|
+
id,
|
|
34
|
+
role: "user",
|
|
35
|
+
content: "hello",
|
|
36
|
+
createdAt,
|
|
37
|
+
streaming: false,
|
|
38
|
+
},
|
|
39
|
+
});
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
const injectSuggestReplies = (
|
|
43
|
+
controller: ReturnType<typeof createAgentExperience>,
|
|
44
|
+
{
|
|
45
|
+
id = "sr-1",
|
|
46
|
+
suggestions = ["Tell me more", "Show pricing"],
|
|
47
|
+
}: { id?: string; suggestions?: string[] } = {},
|
|
48
|
+
) => {
|
|
49
|
+
controller.injectTestMessage({
|
|
50
|
+
type: "message",
|
|
51
|
+
message: {
|
|
52
|
+
id,
|
|
53
|
+
role: "assistant",
|
|
54
|
+
content: "",
|
|
55
|
+
createdAt: "2026-06-10T00:00:01.000Z",
|
|
56
|
+
streaming: false,
|
|
57
|
+
variant: "tool",
|
|
58
|
+
toolCall: {
|
|
59
|
+
id,
|
|
60
|
+
name: SUGGEST_REPLIES_TOOL_NAME,
|
|
61
|
+
status: "complete",
|
|
62
|
+
args: { suggestions },
|
|
63
|
+
chunks: [],
|
|
64
|
+
},
|
|
65
|
+
// No executionId/awaitingLocalTool — rendering is driven purely by the
|
|
66
|
+
// message list; the auto-resume path is covered in
|
|
67
|
+
// suggest-replies-tool.test.ts.
|
|
68
|
+
},
|
|
69
|
+
});
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
const chipButtons = (mount: HTMLElement, label: string): HTMLButtonElement[] =>
|
|
73
|
+
Array.from(mount.querySelectorAll("button")).filter(
|
|
74
|
+
(btn) => btn.textContent === label,
|
|
75
|
+
);
|
|
76
|
+
|
|
77
|
+
describe("suggest_replies chips UI", () => {
|
|
78
|
+
afterEach(() => {
|
|
79
|
+
document.body.innerHTML = "";
|
|
80
|
+
if (typeof localStorage !== "undefined") localStorage.clear();
|
|
81
|
+
vi.restoreAllMocks();
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("renders agent-pushed chips mid-conversation (after a user message exists)", () => {
|
|
85
|
+
const { mount, controller } = makeController();
|
|
86
|
+
injectUserMessage(controller);
|
|
87
|
+
injectSuggestReplies(controller);
|
|
88
|
+
|
|
89
|
+
expect(chipButtons(mount, "Tell me more")).toHaveLength(1);
|
|
90
|
+
expect(chipButtons(mount, "Show pricing")).toHaveLength(1);
|
|
91
|
+
|
|
92
|
+
controller.destroy();
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("suppresses the transcript tool bubble for the suggest_replies message", () => {
|
|
96
|
+
const { mount, controller } = makeController();
|
|
97
|
+
injectUserMessage(controller);
|
|
98
|
+
injectSuggestReplies(controller);
|
|
99
|
+
|
|
100
|
+
// No tool bubble rendered for the suggest_replies tool message.
|
|
101
|
+
expect(mount.querySelector('[data-bubble-type="tool"]')).toBeNull();
|
|
102
|
+
expect(mount.textContent).not.toContain("suggest_replies");
|
|
103
|
+
|
|
104
|
+
controller.destroy();
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("clears the chips once a user message follows them", () => {
|
|
108
|
+
const { mount, controller } = makeController();
|
|
109
|
+
injectUserMessage(controller, "u1");
|
|
110
|
+
injectSuggestReplies(controller);
|
|
111
|
+
expect(chipButtons(mount, "Tell me more")).toHaveLength(1);
|
|
112
|
+
|
|
113
|
+
injectUserMessage(controller, "u2", "2026-06-10T00:00:02.000Z");
|
|
114
|
+
expect(chipButtons(mount, "Tell me more")).toHaveLength(0);
|
|
115
|
+
|
|
116
|
+
controller.destroy();
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it("shows only the latest call's chips when a turn carries several", () => {
|
|
120
|
+
const { mount, controller } = makeController();
|
|
121
|
+
injectUserMessage(controller);
|
|
122
|
+
injectSuggestReplies(controller, { id: "sr-1", suggestions: ["Old"] });
|
|
123
|
+
injectSuggestReplies(controller, { id: "sr-2", suggestions: ["New"] });
|
|
124
|
+
|
|
125
|
+
expect(chipButtons(mount, "Old")).toHaveLength(0);
|
|
126
|
+
expect(chipButtons(mount, "New")).toHaveLength(1);
|
|
127
|
+
|
|
128
|
+
controller.destroy();
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it("sends the chip text verbatim as a user message on click", async () => {
|
|
132
|
+
global.fetch = vi.fn().mockImplementation(async () => {
|
|
133
|
+
const encoder = new TextEncoder();
|
|
134
|
+
const stream = new ReadableStream({
|
|
135
|
+
start(c) {
|
|
136
|
+
c.enqueue(encoder.encode('data: {"type":"done"}\n\n'));
|
|
137
|
+
c.close();
|
|
138
|
+
},
|
|
139
|
+
});
|
|
140
|
+
return new Response(stream, {
|
|
141
|
+
headers: { "Content-Type": "text/event-stream" },
|
|
142
|
+
});
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
const { mount, controller } = makeController();
|
|
146
|
+
injectUserMessage(controller);
|
|
147
|
+
injectSuggestReplies(controller);
|
|
148
|
+
|
|
149
|
+
chipButtons(mount, "Tell me more")[0]!.click();
|
|
150
|
+
await Promise.resolve();
|
|
151
|
+
|
|
152
|
+
const sent = controller
|
|
153
|
+
.getMessages()
|
|
154
|
+
.filter((m) => m.role === "user")
|
|
155
|
+
.map((m) => m.content);
|
|
156
|
+
expect(sent).toContain("Tell me more");
|
|
157
|
+
// The chip click appended a user message, so the chips cleared.
|
|
158
|
+
expect(chipButtons(mount, "Tell me more")).toHaveLength(0);
|
|
159
|
+
|
|
160
|
+
controller.destroy();
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it("keeps live agent chips through a config update", () => {
|
|
164
|
+
const { mount, controller } = makeController();
|
|
165
|
+
injectUserMessage(controller);
|
|
166
|
+
injectSuggestReplies(controller);
|
|
167
|
+
expect(chipButtons(mount, "Tell me more")).toHaveLength(1);
|
|
168
|
+
|
|
169
|
+
// A display-only config update (e.g. theme tweak) re-renders the
|
|
170
|
+
// suggestions row — it must re-apply the agent-chips rule, not fall back
|
|
171
|
+
// to the static config chips (which are hidden mid-conversation).
|
|
172
|
+
controller.update({
|
|
173
|
+
apiUrl: "https://api.example.com/chat",
|
|
174
|
+
launcher: { enabled: false },
|
|
175
|
+
suggestionChips: [],
|
|
176
|
+
copy: { title: "Updated title" },
|
|
177
|
+
} as unknown as Parameters<typeof controller.update>[0]);
|
|
178
|
+
|
|
179
|
+
expect(chipButtons(mount, "Tell me more")).toHaveLength(1);
|
|
180
|
+
|
|
181
|
+
controller.destroy();
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it("renders no chips and falls back to the tool bubble when disabled", () => {
|
|
185
|
+
const { mount, controller } = makeController({
|
|
186
|
+
features: { suggestReplies: { enabled: false } },
|
|
187
|
+
});
|
|
188
|
+
injectUserMessage(controller);
|
|
189
|
+
injectSuggestReplies(controller);
|
|
190
|
+
|
|
191
|
+
expect(chipButtons(mount, "Tell me more")).toHaveLength(0);
|
|
192
|
+
// The generic tool bubble renders instead, keeping the parked call visible.
|
|
193
|
+
expect(mount.textContent).toContain("suggest_replies");
|
|
194
|
+
|
|
195
|
+
controller.destroy();
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it("dispatches persona:suggestReplies:shown and :selected DOM events", async () => {
|
|
199
|
+
global.fetch = vi.fn().mockImplementation(async () => {
|
|
200
|
+
const encoder = new TextEncoder();
|
|
201
|
+
const stream = new ReadableStream({
|
|
202
|
+
start(c) {
|
|
203
|
+
c.enqueue(encoder.encode('data: {"type":"done"}\n\n'));
|
|
204
|
+
c.close();
|
|
205
|
+
},
|
|
206
|
+
});
|
|
207
|
+
return new Response(stream, {
|
|
208
|
+
headers: { "Content-Type": "text/event-stream" },
|
|
209
|
+
});
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
const shown: string[][] = [];
|
|
213
|
+
const selected: string[] = [];
|
|
214
|
+
document.addEventListener("persona:suggestReplies:shown", (e) => {
|
|
215
|
+
shown.push((e as CustomEvent).detail.suggestions);
|
|
216
|
+
});
|
|
217
|
+
document.addEventListener("persona:suggestReplies:selected", (e) => {
|
|
218
|
+
selected.push((e as CustomEvent).detail.suggestion);
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
const { mount, controller } = makeController();
|
|
222
|
+
injectUserMessage(controller);
|
|
223
|
+
injectSuggestReplies(controller);
|
|
224
|
+
|
|
225
|
+
expect(shown).toEqual([["Tell me more", "Show pricing"]]);
|
|
226
|
+
|
|
227
|
+
// Re-rendering the same chip set must not re-fire `shown`.
|
|
228
|
+
injectSuggestReplies(controller, { id: "sr-1" });
|
|
229
|
+
expect(shown).toHaveLength(1);
|
|
230
|
+
|
|
231
|
+
chipButtons(mount, "Show pricing")[0]!.click();
|
|
232
|
+
await Promise.resolve();
|
|
233
|
+
expect(selected).toEqual(["Show pricing"]);
|
|
234
|
+
|
|
235
|
+
controller.destroy();
|
|
236
|
+
});
|
|
237
|
+
});
|
package/src/ui.ts
CHANGED
|
@@ -88,6 +88,10 @@ import {
|
|
|
88
88
|
removeAskUserQuestionSheet,
|
|
89
89
|
setCurrentAnswer,
|
|
90
90
|
} from "./components/ask-user-question-bubble";
|
|
91
|
+
import {
|
|
92
|
+
isSuggestRepliesMessage,
|
|
93
|
+
latestAgentSuggestions,
|
|
94
|
+
} from "./suggest-replies-tool";
|
|
91
95
|
import { formatElapsedMs } from "./utils/formatting";
|
|
92
96
|
import { approvalDetailsExpansionState, createApprovalBubble, updateApprovalDetailsUI } from "./components/approval-bubble";
|
|
93
97
|
import { createSuggestions } from "./components/suggestions";
|
|
@@ -2625,6 +2629,43 @@ export const createAgentExperience = (
|
|
|
2625
2629
|
const suggestionsManager = createSuggestions(suggestions);
|
|
2626
2630
|
let closeHandler: (() => void) | null = null;
|
|
2627
2631
|
let session: AgentWidgetSession;
|
|
2632
|
+
|
|
2633
|
+
// Single render rule for the suggestions row, shared by the message-change,
|
|
2634
|
+
// initial-paint, and config-update paths: agent-pushed `suggest_replies`
|
|
2635
|
+
// chips win when the latest-turn rule yields any (last suggest_replies tool
|
|
2636
|
+
// message with no user message after it); otherwise static config chips
|
|
2637
|
+
// keep their before-first-user-message behavior. Config updates MUST route
|
|
2638
|
+
// through here too — re-rendering with only `config.suggestionChips` would
|
|
2639
|
+
// drop a live agent chip row until the next message change.
|
|
2640
|
+
const renderSuggestions = (messages?: AgentWidgetMessage[]) => {
|
|
2641
|
+
if (!session) return;
|
|
2642
|
+
const current = messages ?? session.getMessages();
|
|
2643
|
+
const agentChips =
|
|
2644
|
+
config.features?.suggestReplies?.enabled !== false
|
|
2645
|
+
? latestAgentSuggestions(current)
|
|
2646
|
+
: null;
|
|
2647
|
+
if (agentChips) {
|
|
2648
|
+
suggestionsManager.render(
|
|
2649
|
+
agentChips,
|
|
2650
|
+
session,
|
|
2651
|
+
textarea,
|
|
2652
|
+
current,
|
|
2653
|
+
config.suggestionChipsConfig,
|
|
2654
|
+
{ agentPushed: true }
|
|
2655
|
+
);
|
|
2656
|
+
} else if (current.some((msg) => msg.role === "user")) {
|
|
2657
|
+
// Hide suggestions once a user message exists.
|
|
2658
|
+
suggestionsManager.render([], session, textarea, current);
|
|
2659
|
+
} else {
|
|
2660
|
+
suggestionsManager.render(
|
|
2661
|
+
config.suggestionChips,
|
|
2662
|
+
session,
|
|
2663
|
+
textarea,
|
|
2664
|
+
current,
|
|
2665
|
+
config.suggestionChipsConfig
|
|
2666
|
+
);
|
|
2667
|
+
}
|
|
2668
|
+
};
|
|
2628
2669
|
let isStreaming = false;
|
|
2629
2670
|
const messageCache = createMessageCache();
|
|
2630
2671
|
// Tracks the last fingerprint we rendered a plugin-rendered ask_user_question
|
|
@@ -3359,6 +3400,18 @@ export const createAgentExperience = (
|
|
|
3359
3400
|
return;
|
|
3360
3401
|
}
|
|
3361
3402
|
|
|
3403
|
+
// suggest_replies renders no transcript bubble — the chips above the
|
|
3404
|
+
// composer are the only UI, and the session auto-resumes the call.
|
|
3405
|
+
// When the feature is disabled the message falls through to the generic
|
|
3406
|
+
// tool bubble (and is never auto-resumed), keeping the parked execution
|
|
3407
|
+
// visible instead of silently swallowed.
|
|
3408
|
+
if (
|
|
3409
|
+
isSuggestRepliesMessage(message) &&
|
|
3410
|
+
config.features?.suggestReplies?.enabled !== false
|
|
3411
|
+
) {
|
|
3412
|
+
return;
|
|
3413
|
+
}
|
|
3414
|
+
|
|
3362
3415
|
if (
|
|
3363
3416
|
isAskUserQuestionMessage(message) &&
|
|
3364
3417
|
config.features?.askUserQuestion?.enabled !== false
|
|
@@ -4758,18 +4811,9 @@ export const createAgentExperience = (
|
|
|
4758
4811
|
renderMessagesWithPlugins(messagesWrapper, messages, postprocess);
|
|
4759
4812
|
// Start elapsed timer if any active tool has a live duration span
|
|
4760
4813
|
ensureToolElapsedTimer();
|
|
4761
|
-
// Re-render suggestions
|
|
4814
|
+
// Re-render suggestions — agent chips vs config chips, one shared rule.
|
|
4762
4815
|
// Pass messages directly to avoid calling session.getMessages() during construction
|
|
4763
|
-
|
|
4764
|
-
const hasUserMessage = messages.some((msg) => msg.role === "user");
|
|
4765
|
-
if (hasUserMessage) {
|
|
4766
|
-
// Hide suggestions if user message exists
|
|
4767
|
-
suggestionsManager.render([], session, textarea, messages);
|
|
4768
|
-
} else {
|
|
4769
|
-
// Show suggestions if no user message yet
|
|
4770
|
-
suggestionsManager.render(config.suggestionChips, session, textarea, messages, config.suggestionChipsConfig);
|
|
4771
|
-
}
|
|
4772
|
-
}
|
|
4816
|
+
renderSuggestions(messages);
|
|
4773
4817
|
scheduleAutoScroll(!isStreaming);
|
|
4774
4818
|
trackMessages(messages);
|
|
4775
4819
|
|
|
@@ -5698,7 +5742,7 @@ export const createAgentExperience = (
|
|
|
5698
5742
|
mount.appendChild(customLauncherElement);
|
|
5699
5743
|
}
|
|
5700
5744
|
updateOpenState();
|
|
5701
|
-
|
|
5745
|
+
renderSuggestions();
|
|
5702
5746
|
updateCopy();
|
|
5703
5747
|
setComposerDisabled(session.isStreaming());
|
|
5704
5748
|
if (getScrollMode() === "follow") {
|
|
@@ -6982,7 +7026,7 @@ export const createAgentExperience = (
|
|
|
6982
7026
|
session.getMessages(),
|
|
6983
7027
|
postprocess
|
|
6984
7028
|
);
|
|
6985
|
-
|
|
7029
|
+
renderSuggestions();
|
|
6986
7030
|
updateCopy();
|
|
6987
7031
|
setComposerDisabled(session.isStreaming());
|
|
6988
7032
|
|
package/src/utils/dock.test.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, expect, it } from "vitest";
|
|
2
|
-
import { isComposerBarMountMode, isDockedMountMode } from "./dock";
|
|
2
|
+
import { isComposerBarMountMode, isDockedMountMode, resolveDockConfig } from "./dock";
|
|
3
3
|
import type { AgentWidgetConfig } from "../types";
|
|
4
4
|
|
|
5
5
|
describe("isDockedMountMode", () => {
|
|
@@ -17,6 +17,28 @@ describe("isDockedMountMode", () => {
|
|
|
17
17
|
});
|
|
18
18
|
});
|
|
19
19
|
|
|
20
|
+
describe("resolveDockConfig", () => {
|
|
21
|
+
it("defaults maxHeight to the viewport guard", () => {
|
|
22
|
+
const config: AgentWidgetConfig = { apiUrl: "/api", launcher: { mountMode: "docked" } };
|
|
23
|
+
expect(resolveDockConfig(config).maxHeight).toBe("100dvh");
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("passes through a custom maxHeight and the false opt-out", () => {
|
|
27
|
+
expect(
|
|
28
|
+
resolveDockConfig({
|
|
29
|
+
apiUrl: "/api",
|
|
30
|
+
launcher: { mountMode: "docked", dock: { maxHeight: "600px" } },
|
|
31
|
+
}).maxHeight
|
|
32
|
+
).toBe("600px");
|
|
33
|
+
expect(
|
|
34
|
+
resolveDockConfig({
|
|
35
|
+
apiUrl: "/api",
|
|
36
|
+
launcher: { mountMode: "docked", dock: { maxHeight: false } },
|
|
37
|
+
}).maxHeight
|
|
38
|
+
).toBe(false);
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
|
|
20
42
|
describe("isComposerBarMountMode", () => {
|
|
21
43
|
it("returns true for mountMode: 'composer-bar'", () => {
|
|
22
44
|
const config: AgentWidgetConfig = {
|
package/src/utils/dock.ts
CHANGED
|
@@ -5,6 +5,7 @@ const DEFAULT_DOCK_CONFIG: Required<AgentWidgetDockConfig> = {
|
|
|
5
5
|
width: "420px",
|
|
6
6
|
animate: true,
|
|
7
7
|
reveal: "resize",
|
|
8
|
+
maxHeight: "100dvh",
|
|
8
9
|
};
|
|
9
10
|
|
|
10
11
|
export const isDockedMountMode = (config?: AgentWidgetConfig): boolean =>
|
|
@@ -29,5 +30,6 @@ export const resolveDockConfig = (
|
|
|
29
30
|
width: dock?.width ?? DEFAULT_DOCK_CONFIG.width,
|
|
30
31
|
animate: dock?.animate ?? DEFAULT_DOCK_CONFIG.animate,
|
|
31
32
|
reveal: dock?.reveal ?? DEFAULT_DOCK_CONFIG.reveal,
|
|
33
|
+
maxHeight: dock?.maxHeight ?? DEFAULT_DOCK_CONFIG.maxHeight,
|
|
32
34
|
};
|
|
33
35
|
};
|
package/src/version.ts
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
|
-
import
|
|
1
|
+
// Named import (not default) so esbuild tree-shakes the JSON module down to
|
|
2
|
+
// the one field we read — a default import inlines the entire package.json
|
|
3
|
+
// (~4.8 kB minified) into every bundle.
|
|
4
|
+
import { version } from "../package.json";
|
|
2
5
|
|
|
3
6
|
/**
|
|
4
7
|
* The current version of the @runtypelabs/persona package.
|
|
5
8
|
* This is automatically derived from package.json.
|
|
6
9
|
*/
|
|
7
|
-
export const VERSION =
|
|
10
|
+
export const VERSION = version;
|
|
@@ -31,6 +31,7 @@ import {
|
|
|
31
31
|
isWebMcpToolName,
|
|
32
32
|
stripWebMcpPrefix,
|
|
33
33
|
computeClientToolsFingerprint,
|
|
34
|
+
setWebMcpPolyfillLoader,
|
|
34
35
|
} from "./webmcp-bridge";
|
|
35
36
|
import type { ClientToolDefinition } from "./types";
|
|
36
37
|
|
|
@@ -114,6 +115,9 @@ beforeEach(() => {
|
|
|
114
115
|
|
|
115
116
|
afterEach(() => {
|
|
116
117
|
vi.unstubAllGlobals();
|
|
118
|
+
// The loader is module-global (page-global in production); reset so a test
|
|
119
|
+
// that registers one can't leak into the default-import tests.
|
|
120
|
+
setWebMcpPolyfillLoader(null);
|
|
117
121
|
});
|
|
118
122
|
|
|
119
123
|
describe("stripWebMcpPrefix", () => {
|
|
@@ -150,6 +154,9 @@ describe("WebMcpBridge.snapshotForDispatch", () => {
|
|
|
150
154
|
});
|
|
151
155
|
|
|
152
156
|
it("returns empty when initializeWebMCPPolyfill() throws", async () => {
|
|
157
|
+
// No pre-existing modelContext — otherwise install() short-circuits
|
|
158
|
+
// before importing the polyfill and the throwing init never runs.
|
|
159
|
+
vi.stubGlobal("document", {});
|
|
153
160
|
polyfillMock.initThrows = true;
|
|
154
161
|
const bridge = new WebMcpBridge({ enabled: true });
|
|
155
162
|
expect(await bridge.snapshotForDispatch()).toEqual([]);
|
|
@@ -204,6 +211,59 @@ describe("WebMcpBridge.snapshotForDispatch", () => {
|
|
|
204
211
|
});
|
|
205
212
|
});
|
|
206
213
|
|
|
214
|
+
describe("setWebMcpPolyfillLoader", () => {
|
|
215
|
+
it("uses the registered loader instead of the bare import when no modelContext exists", async () => {
|
|
216
|
+
// Start with no modelContext so install() actually loads the polyfill;
|
|
217
|
+
// the loader's init then installs the registry, like the real one would.
|
|
218
|
+
const doc: { modelContext?: ReturnType<typeof makeModelContext> } = {};
|
|
219
|
+
vi.stubGlobal("document", doc);
|
|
220
|
+
registry.tools = [fakeTool({ name: "search" })];
|
|
221
|
+
const loader = vi.fn(async () => ({
|
|
222
|
+
initializeWebMCPPolyfill: () => {
|
|
223
|
+
doc.modelContext = makeModelContext();
|
|
224
|
+
},
|
|
225
|
+
}));
|
|
226
|
+
setWebMcpPolyfillLoader(loader);
|
|
227
|
+
|
|
228
|
+
const bridge = new WebMcpBridge({ enabled: true });
|
|
229
|
+
const snap = await bridge.snapshotForDispatch();
|
|
230
|
+
|
|
231
|
+
expect(loader).toHaveBeenCalledTimes(1);
|
|
232
|
+
expect(snap.map((t) => t.name)).toEqual(["search"]);
|
|
233
|
+
expect(bridge.isOperational()).toBe(true);
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
it("disables WebMCP (with a warning) when the loader rejects", async () => {
|
|
237
|
+
vi.stubGlobal("document", {});
|
|
238
|
+
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
239
|
+
setWebMcpPolyfillLoader(() => Promise.reject(new Error("chunk 404")));
|
|
240
|
+
|
|
241
|
+
const bridge = new WebMcpBridge({ enabled: true });
|
|
242
|
+
expect(await bridge.snapshotForDispatch()).toEqual([]);
|
|
243
|
+
expect(bridge.isOperational()).toBe(false);
|
|
244
|
+
expect(warn).toHaveBeenCalledWith(
|
|
245
|
+
expect.stringContaining("Failed to load @mcp-b/webmcp-polyfill"),
|
|
246
|
+
expect.any(Error),
|
|
247
|
+
);
|
|
248
|
+
warn.mockRestore();
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
it("never invokes the loader when a compatible modelContext is already present", async () => {
|
|
252
|
+
// beforeEach stubs a compatible document.modelContext — the install
|
|
253
|
+
// short-circuit must win, so a failing loader is irrelevant.
|
|
254
|
+
registry.tools = [fakeTool({ name: "search" })];
|
|
255
|
+
const loader = vi.fn(() => Promise.reject(new Error("should not load")));
|
|
256
|
+
setWebMcpPolyfillLoader(loader);
|
|
257
|
+
|
|
258
|
+
const bridge = new WebMcpBridge({ enabled: true });
|
|
259
|
+
const snap = await bridge.snapshotForDispatch();
|
|
260
|
+
|
|
261
|
+
expect(loader).not.toHaveBeenCalled();
|
|
262
|
+
expect(snap.map((t) => t.name)).toEqual(["search"]);
|
|
263
|
+
expect(bridge.isOperational()).toBe(true);
|
|
264
|
+
});
|
|
265
|
+
});
|
|
266
|
+
|
|
207
267
|
describe("WebMCP display titles", () => {
|
|
208
268
|
it("records declared titles on snapshot and exposes them via getWebMcpToolDisplayTitle", async () => {
|
|
209
269
|
registry.tools = [
|
package/src/webmcp-bridge.ts
CHANGED
|
@@ -131,6 +131,28 @@ const log = {
|
|
|
131
131
|
},
|
|
132
132
|
};
|
|
133
133
|
|
|
134
|
+
/** The slice of `@mcp-b/webmcp-polyfill` the bridge consumes on install. */
|
|
135
|
+
export type WebMcpPolyfillModule = {
|
|
136
|
+
initializeWebMCPPolyfill: () => void;
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Override how the polyfill module is obtained. By default the bridge does
|
|
141
|
+
* `import("@mcp-b/webmcp-polyfill")`, which bundlers resolve for npm
|
|
142
|
+
* consumers. The IIFE/CDN build can't resolve a bare specifier at runtime, so
|
|
143
|
+
* its entry (`index-global.ts`) registers a loader that imports the
|
|
144
|
+
* self-contained `webmcp-polyfill.js` chunk from a URL derived from the
|
|
145
|
+
* widget script's own `src`. Page-global, like `document.modelContext`
|
|
146
|
+
* itself. Pass `null` to restore the default (used by tests).
|
|
147
|
+
*/
|
|
148
|
+
let polyfillLoader: (() => Promise<WebMcpPolyfillModule>) | null = null;
|
|
149
|
+
|
|
150
|
+
export const setWebMcpPolyfillLoader = (
|
|
151
|
+
loader: (() => Promise<WebMcpPolyfillModule>) | null,
|
|
152
|
+
): void => {
|
|
153
|
+
polyfillLoader = loader;
|
|
154
|
+
};
|
|
155
|
+
|
|
134
156
|
/**
|
|
135
157
|
* Compute a stable, order-independent fingerprint of a `ClientToolDefinition[]`
|
|
136
158
|
* snapshot, for the diff-only / send-once dispatch path (client-token mode).
|
|
@@ -449,7 +471,18 @@ export class WebMcpBridge {
|
|
|
449
471
|
|
|
450
472
|
private async install(): Promise<void> {
|
|
451
473
|
try {
|
|
452
|
-
|
|
474
|
+
// A compatible registry is already on the page (the host installed the
|
|
475
|
+
// polyfill, or a native impl) — initialize would no-op against it, so
|
|
476
|
+
// skip loading the module entirely. Pages that register tools before
|
|
477
|
+
// Persona's first dispatch always land here, because registering
|
|
478
|
+
// requires `document.modelContext` to exist.
|
|
479
|
+
if (this.getModelContext()) {
|
|
480
|
+
this.installed = true;
|
|
481
|
+
return;
|
|
482
|
+
}
|
|
483
|
+
const mod = polyfillLoader
|
|
484
|
+
? await polyfillLoader()
|
|
485
|
+
: await import("@mcp-b/webmcp-polyfill");
|
|
453
486
|
// Idempotent: no-ops if `document.modelContext` already exists (native or
|
|
454
487
|
// a prior install by the host page).
|
|
455
488
|
mod.initializeWebMCPPolyfill();
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Standalone WebMCP polyfill chunk (`dist/webmcp-polyfill.js`).
|
|
3
|
+
*
|
|
4
|
+
* The IIFE/CDN widget bundle marks `@mcp-b/webmcp-polyfill` external so the
|
|
5
|
+
* ~35 kB (minified) polyfill + its transitive `@cfworker/json-schema` never
|
|
6
|
+
* ship to consumers who don't enable WebMCP. When `config.webmcp.enabled` is
|
|
7
|
+
* true and no `document.modelContext` exists yet, the bridge dynamically
|
|
8
|
+
* imports this chunk from a URL derived from the widget script's own `src`
|
|
9
|
+
* (see the loader registered in `index-global.ts`).
|
|
10
|
+
*
|
|
11
|
+
* Built self-contained (`--no-external`) — it must work standalone on a CDN
|
|
12
|
+
* with no module resolution. npm/bundler consumers never load this file;
|
|
13
|
+
* their bundlers resolve the bare `import("@mcp-b/webmcp-polyfill")` in
|
|
14
|
+
* `webmcp-bridge.ts` directly.
|
|
15
|
+
*/
|
|
16
|
+
export { initializeWebMCPPolyfill } from "@mcp-b/webmcp-polyfill";
|