@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.
Files changed (51) hide show
  1. package/README.md +2 -0
  2. package/dist/animations/glyph-cycle.d.cts +1 -1
  3. package/dist/animations/glyph-cycle.d.ts +1 -1
  4. package/dist/animations/{types-quh7NmYD.d.cts → types-CthJFfNx.d.cts} +7 -0
  5. package/dist/animations/{types-quh7NmYD.d.ts → types-CthJFfNx.d.ts} +7 -0
  6. package/dist/animations/wipe.d.cts +1 -1
  7. package/dist/animations/wipe.d.ts +1 -1
  8. package/dist/codegen.cjs +4 -4
  9. package/dist/codegen.js +3 -3
  10. package/dist/index.cjs +43 -43
  11. package/dist/index.cjs.map +1 -1
  12. package/dist/index.d.cts +288 -8
  13. package/dist/index.d.ts +288 -8
  14. package/dist/index.global.js +76 -79
  15. package/dist/index.global.js.map +1 -1
  16. package/dist/index.js +43 -43
  17. package/dist/index.js.map +1 -1
  18. package/dist/launcher.global.js.map +1 -1
  19. package/dist/smart-dom-reader.d.cts +78 -2
  20. package/dist/smart-dom-reader.d.ts +78 -2
  21. package/dist/theme-editor.cjs +30 -30
  22. package/dist/theme-editor.d.cts +92 -5
  23. package/dist/theme-editor.d.ts +92 -5
  24. package/dist/theme-editor.js +30 -30
  25. package/dist/webmcp-polyfill.js +4 -0
  26. package/package.json +4 -3
  27. package/src/ask-user-question-tool.test.ts +148 -0
  28. package/src/ask-user-question-tool.ts +138 -0
  29. package/src/client.ts +43 -9
  30. package/src/components/messages.ts +10 -0
  31. package/src/components/suggestions.ts +49 -6
  32. package/src/index-core.ts +15 -0
  33. package/src/index-global.ts +39 -0
  34. package/src/runtime/host-layout.test.ts +158 -3
  35. package/src/runtime/host-layout.ts +95 -1
  36. package/src/session.ts +188 -32
  37. package/src/session.webmcp.test.ts +48 -0
  38. package/src/suggest-replies-tool.test.ts +445 -0
  39. package/src/suggest-replies-tool.ts +152 -0
  40. package/src/theme-editor/index.ts +2 -0
  41. package/src/theme-editor/webmcp/index.ts +2 -0
  42. package/src/theme-editor/webmcp/types.ts +16 -3
  43. package/src/types.ts +79 -2
  44. package/src/ui.suggest-replies.test.ts +237 -0
  45. package/src/ui.ts +57 -13
  46. package/src/utils/dock.test.ts +23 -1
  47. package/src/utils/dock.ts +2 -0
  48. package/src/version.ts +5 -2
  49. package/src/webmcp-bridge.test.ts +60 -0
  50. package/src/webmcp-bridge.ts +34 -1
  51. 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
- /** Set to `'webmcp'` for tools discovered via the polyfill. */
235
- origin?: 'webmcp' | 'local';
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 to hide them after first user message
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
- if (session) {
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
- suggestionsManager.render(config.suggestionChips, session, textarea, undefined, config.suggestionChipsConfig);
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
- suggestionsManager.render(config.suggestionChips, session, textarea, undefined, config.suggestionChipsConfig);
7029
+ renderSuggestions();
6986
7030
  updateCopy();
6987
7031
  setComposerDisabled(session.isStreaming());
6988
7032
 
@@ -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 packageJson from "../package.json";
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 = packageJson.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 = [
@@ -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
- const mod = await import("@mcp-b/webmcp-polyfill");
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";