@runtypelabs/persona 3.31.1 → 3.34.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 +17 -10
  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 +1 -1
  9. package/dist/codegen.js +1 -1
  10. package/dist/index.cjs +43 -43
  11. package/dist/index.cjs.map +1 -1
  12. package/dist/index.d.cts +320 -8
  13. package/dist/index.d.ts +320 -8
  14. package/dist/index.global.js +193 -192
  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 +117 -117
  19. package/dist/launcher.global.js.map +1 -1
  20. package/dist/smart-dom-reader.d.cts +110 -2
  21. package/dist/smart-dom-reader.d.ts +110 -2
  22. package/dist/theme-editor.cjs +30 -30
  23. package/dist/theme-editor.d.cts +124 -5
  24. package/dist/theme-editor.d.ts +124 -5
  25. package/dist/theme-editor.js +30 -30
  26. package/package.json +3 -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/codegen.test.ts +0 -14
  31. package/src/components/messages.ts +10 -0
  32. package/src/components/suggestions.ts +49 -6
  33. package/src/index-core.ts +15 -0
  34. package/src/runtime/host-layout.test.ts +206 -9
  35. package/src/runtime/host-layout.ts +121 -8
  36. package/src/runtime/init.test.ts +2 -2
  37. package/src/session.ts +188 -32
  38. package/src/session.webmcp.test.ts +48 -0
  39. package/src/suggest-replies-tool.test.ts +445 -0
  40. package/src/suggest-replies-tool.ts +152 -0
  41. package/src/theme-editor/index.ts +2 -0
  42. package/src/theme-editor/webmcp/index.ts +2 -0
  43. package/src/theme-editor/webmcp/types.ts +16 -3
  44. package/src/types.ts +108 -2
  45. package/src/ui.docked.test.ts +2 -2
  46. package/src/ui.suggest-replies.test.ts +237 -0
  47. package/src/ui.ts +57 -13
  48. package/src/utils/dock.test.ts +23 -1
  49. package/src/utils/dock.ts +2 -0
  50. package/src/voice/voice.test.ts +0 -51
  51. package/src/install-config.test.ts +0 -38
package/src/types.ts CHANGED
@@ -145,12 +145,41 @@ export type AgentToolsConfig = {
145
145
  mcpServers?: Array<Record<string, unknown>>;
146
146
  /** Maximum number of tool invocations per execution */
147
147
  maxToolCalls?: number;
148
+ /** How the model is steered toward tools: let it decide, force a call, or disable */
149
+ toolCallStrategy?: "auto" | "required" | "none";
150
+ /** Per-tool invocation limits / requirements keyed by tool name */
151
+ perToolLimits?: Record<string, { maxCalls?: number; required?: boolean }>;
148
152
  /** Tool approval configuration for human-in-the-loop workflows */
149
153
  approval?: {
150
154
  /** Tool names/patterns to require approval for, or true for all tools */
151
155
  require: string[] | boolean;
152
156
  /** Approval timeout in milliseconds (default: 300000 / 5 minutes) */
153
157
  timeout?: number;
158
+ /** Ask the agent to state its intent alongside approval requests (default: true) */
159
+ requestReason?: boolean;
160
+ };
161
+ /**
162
+ * Enables the synthesized `spawn_subagent` tool: the model can spin up
163
+ * ad-hoc child agents at runtime, restricted to `toolPool` (tool IDs /
164
+ * runtime-tool names already granted to the parent agent).
165
+ */
166
+ subagentConfig?: {
167
+ toolPool: string[];
168
+ defaultMaxTurns?: number;
169
+ maxTurnsLimit?: number;
170
+ maxSpawnsPerRun?: number;
171
+ defaultModel?: string;
172
+ allowNesting?: boolean;
173
+ defaultTimeoutMs?: number;
174
+ };
175
+ /**
176
+ * Enables the synthesized `code_mode` tool: the model writes JS that calls
177
+ * pool tools inside a sandbox instead of issuing individual tool calls.
178
+ */
179
+ codeModeConfig?: {
180
+ toolPool: string[];
181
+ description?: string;
182
+ timeoutMs?: number;
154
183
  };
155
184
  };
156
185
 
@@ -231,8 +260,13 @@ export type ClientToolDefinition = {
231
260
  description: string;
232
261
  /** JSON Schema (per WebMCP spec) — passed through as-is. */
233
262
  parametersSchema?: object;
234
- /** Set to `'webmcp'` for tools discovered via the polyfill. */
235
- origin?: 'webmcp' | 'local';
263
+ /**
264
+ * `'webmcp'` for tools discovered via the polyfill (server prepends the
265
+ * `webmcp:` wire prefix); `'sdk'` for widget/SDK-provided tools (name stays
266
+ * bare on the wire). Matches the server's accepted enum — any other value
267
+ * fails dispatch validation.
268
+ */
269
+ origin?: 'webmcp' | 'sdk';
236
270
  /** Origin of the page that registered the tool — for server-side audit. */
237
271
  pageOrigin?: string;
238
272
  /**
@@ -397,6 +431,13 @@ export type AgentMessageMetadata = {
397
431
  * paginated stepper. Persists alongside `askUserQuestionAnswers`.
398
432
  */
399
433
  askUserQuestionIndex?: number;
434
+ /**
435
+ * Set to `true` once a `suggest_replies` tool call's fire-and-forget
436
+ * `/resume` has been accepted by the server. Persisted belt-and-suspenders
437
+ * mirror of the in-memory resolved-key dedupe, so hydration/re-emit paths
438
+ * never re-resume the call.
439
+ */
440
+ suggestRepliesResolved?: boolean;
400
441
  };
401
442
 
402
443
  export type AgentWidgetRequestMiddlewareContext = {
@@ -1092,6 +1133,38 @@ export type AgentWidgetFeatureFlags = {
1092
1133
  * pills + optional free-text input.
1093
1134
  */
1094
1135
  askUserQuestion?: AgentWidgetAskUserQuestionFeature;
1136
+ /**
1137
+ * Built-in `suggest_replies` quick-reply chips. When the assistant invokes
1138
+ * the tool, the widget shows the suggestions as tappable chips above the
1139
+ * composer (reusing the suggestion-chips surface) and immediately resumes
1140
+ * the execution — fire-and-forget, no user input awaited.
1141
+ */
1142
+ suggestReplies?: AgentWidgetSuggestRepliesFeature;
1143
+ };
1144
+
1145
+ /**
1146
+ * Feature config for the built-in `suggest_replies` quick-reply chips.
1147
+ * Chips render in the existing suggestions slot above the composer and are
1148
+ * styled by the widget-level `suggestionChipsConfig`. A tapped chip is sent
1149
+ * verbatim as the user's next message; chips clear once any user message
1150
+ * follows them.
1151
+ */
1152
+ export type AgentWidgetSuggestRepliesFeature = {
1153
+ /**
1154
+ * Enable the feature. Defaults to true. When false, `suggest_replies`
1155
+ * renders as a regular tool bubble and is NOT auto-resumed — only set this
1156
+ * with no server-side `suggest_replies` declaration, or the execution
1157
+ * parks awaiting a resume that never comes.
1158
+ */
1159
+ enabled?: boolean;
1160
+ /**
1161
+ * Advertise the built-in `suggest_replies` tool to the agent on every
1162
+ * dispatch via `clientTools[]` — no server-side `runtimeTools` declaration
1163
+ * needed. Defaults to `false`: flows that already declare the tool via
1164
+ * `runtimeTools` would otherwise present it to the model twice. Ignored
1165
+ * when `enabled` is `false`.
1166
+ */
1167
+ expose?: boolean;
1095
1168
  };
1096
1169
 
1097
1170
  /**
@@ -1157,6 +1230,19 @@ export type AgentWidgetAskUserQuestionStyles = {
1157
1230
  export type AgentWidgetAskUserQuestionFeature = {
1158
1231
  /** Enable the feature. Defaults to true. When false, `ask_user_question` renders as a regular tool bubble. */
1159
1232
  enabled?: boolean;
1233
+ /**
1234
+ * Advertise the built-in `ask_user_question` tool to the agent on every
1235
+ * dispatch via `clientTools[]` — no server-side `runtimeTools` declaration
1236
+ * needed. The tool ships with a model-facing description and JSON schema
1237
+ * matching {@link AskUserQuestionPayload}; when the model calls it, the
1238
+ * existing answer-pill sheet renders and the answer resumes the execution.
1239
+ *
1240
+ * Defaults to `false`: flows that already declare `ask_user_question` via
1241
+ * `runtimeTools` would otherwise present the tool to the model twice.
1242
+ * Ignored when `enabled` is `false` — never offer the agent a question
1243
+ * tool the widget can't render an answer UI for.
1244
+ */
1245
+ expose?: boolean;
1160
1246
  /** Slide-in animation duration in ms. Defaults to 180. */
1161
1247
  slideInMs?: number;
1162
1248
  /** Label for the free-text pill. Defaults to "Other…". */
@@ -1350,6 +1436,26 @@ export type AgentWidgetDockConfig = {
1350
1436
  * it appears to emerge at full width like a floating widget.
1351
1437
  */
1352
1438
  reveal?: "resize" | "overlay" | "push" | "emerge";
1439
+ /**
1440
+ * Maximum height of the dock panel, applied as a viewport-overflow guard.
1441
+ *
1442
+ * The docked shell sizes itself with `height: 100%`, which only resolves when
1443
+ * an ancestor (usually `html, body { height: 100% }`) provides a definite
1444
+ * height. Without one, the dock column would otherwise grow with the
1445
+ * conversation and scroll off the page. This cap clamps the panel to the
1446
+ * viewport (and keeps the `resize`/`emerge` reveals pinned with
1447
+ * `position: sticky`; `push`/`overlay` get the cap only, since their
1448
+ * transform/absolute contexts defeat sticky) so a missing height chain
1449
+ * degrades gracefully instead of breaking the chat.
1450
+ *
1451
+ * - Set a CSS length (e.g. `"600px"`, `"80vh"`) to override the cap.
1452
+ * - Set `false` to disable the guard entirely (the panel then sizes purely
1453
+ * from the surrounding layout — make sure your page provides a definite
1454
+ * height all the way down to the dock target's parent).
1455
+ *
1456
+ * @default "100dvh"
1457
+ */
1458
+ maxHeight?: string | false;
1353
1459
  };
1354
1460
 
1355
1461
  /**
@@ -286,11 +286,11 @@ describe("createAgentExperience docked mode", () => {
286
286
  const closeButton = mount.querySelector<HTMLButtonElement>('[aria-label="Close chat"]');
287
287
 
288
288
  expect(pushTrack).not.toBeNull();
289
- expect(pushTrack?.style.transform).toBe("translateX(-420px)");
289
+ expect(pushTrack?.style.marginLeft).toBe("-420px");
290
290
 
291
291
  closeButton!.click();
292
292
 
293
- expect(pushTrack?.style.transform).toBe("translateX(0)");
293
+ expect(pushTrack?.style.marginLeft).toBe("0px");
294
294
 
295
295
  openUnsub();
296
296
  closeUnsub();
@@ -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
  };
@@ -32,58 +32,7 @@ function mockBrowserSupport(supported: boolean) {
32
32
  }
33
33
  }
34
34
 
35
- // Note: TypeScript interfaces don't exist at runtime, so we can't test them directly
36
- // We test the concrete implementations instead
37
-
38
- describe('RuntypeVoiceProvider', () => {
39
- it('should create instance with valid config', () => {
40
- const config = {
41
- agentId: 'test-agent',
42
- clientToken: 'test-token',
43
- host: 'localhost:8787',
44
- voiceId: 'rachel'
45
- };
46
-
47
- const provider = new RuntypeVoiceProvider(config);
48
- expect(provider).toBeInstanceOf(RuntypeVoiceProvider);
49
- expect(provider.type).toBe('runtype');
50
- });
51
-
52
- it('should have correct methods', () => {
53
- const config = {
54
- agentId: 'test-agent',
55
- clientToken: 'test-token'
56
- };
57
-
58
- const provider = new RuntypeVoiceProvider(config);
59
- expect(typeof provider.connect).toBe('function');
60
- expect(typeof provider.disconnect).toBe('function');
61
- expect(typeof provider.startListening).toBe('function');
62
- expect(typeof provider.stopListening).toBe('function');
63
- expect(typeof provider.onResult).toBe('function');
64
- expect(typeof provider.onError).toBe('function');
65
- expect(typeof provider.onStatusChange).toBe('function');
66
- });
67
- });
68
-
69
35
  describe('BrowserVoiceProvider', () => {
70
- it('should create instance with default config', () => {
71
- const provider = new BrowserVoiceProvider();
72
- expect(provider).toBeInstanceOf(BrowserVoiceProvider);
73
- expect(provider.type).toBe('browser');
74
- });
75
-
76
- it('should have correct methods', () => {
77
- const provider = new BrowserVoiceProvider();
78
- expect(typeof provider.connect).toBe('function');
79
- expect(typeof provider.disconnect).toBe('function');
80
- expect(typeof provider.startListening).toBe('function');
81
- expect(typeof provider.stopListening).toBe('function');
82
- expect(typeof provider.onResult).toBe('function');
83
- expect(typeof provider.onError).toBe('function');
84
- expect(typeof provider.onStatusChange).toBe('function');
85
- });
86
-
87
36
  it('should check browser support', () => {
88
37
  // Test supported
89
38
  mockBrowserSupport(true);
@@ -1,38 +0,0 @@
1
- import { describe, it, expect } from "vitest";
2
-
3
- describe("prototype pollution guard", () => {
4
- it("strips __proto__, constructor, and prototype from parsed config", () => {
5
- // Simulates the destructuring pattern used in install.ts
6
- const malicious = JSON.parse(
7
- '{"config":{"apiUrl":"http://localhost"},"__proto__":{"polluted":true},"constructor":{"polluted":true},"prototype":{"polluted":true}}'
8
- );
9
-
10
- // eslint-disable-next-line @typescript-eslint/no-unused-vars
11
- const { __proto__: _a, constructor: _b, prototype: _c, ...safeConfig } = malicious;
12
-
13
- const target: Record<string, unknown> = {};
14
- Object.assign(target, safeConfig);
15
-
16
- // The dangerous keys should not be on the target
17
- expect(Object.keys(target)).toEqual(["config"]);
18
- expect((target as any).__proto__).toBe(Object.prototype); // normal prototype, not polluted
19
- expect((target as any).constructor).toBe(Object); // normal constructor
20
-
21
- // Global prototype should not be polluted
22
- expect(({} as any).polluted).toBeUndefined();
23
- });
24
-
25
- it("preserves safe config properties", () => {
26
- const parsed = JSON.parse(
27
- '{"config":{"apiUrl":"http://localhost"},"clientToken":"tok_123","flowId":"flow-1"}'
28
- );
29
- // eslint-disable-next-line @typescript-eslint/no-unused-vars
30
- const { __proto__: _a, constructor: _b, prototype: _c, ...safeConfig } = parsed;
31
-
32
- expect(safeConfig).toEqual({
33
- config: { apiUrl: "http://localhost" },
34
- clientToken: "tok_123",
35
- flowId: "flow-1",
36
- });
37
- });
38
- });