@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.
- package/README.md +17 -10
- 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 +1 -1
- package/dist/codegen.js +1 -1
- package/dist/index.cjs +43 -43
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +320 -8
- package/dist/index.d.ts +320 -8
- package/dist/index.global.js +193 -192
- 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 +117 -117
- package/dist/launcher.global.js.map +1 -1
- package/dist/smart-dom-reader.d.cts +110 -2
- package/dist/smart-dom-reader.d.ts +110 -2
- package/dist/theme-editor.cjs +30 -30
- package/dist/theme-editor.d.cts +124 -5
- package/dist/theme-editor.d.ts +124 -5
- package/dist/theme-editor.js +30 -30
- package/package.json +3 -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/codegen.test.ts +0 -14
- package/src/components/messages.ts +10 -0
- package/src/components/suggestions.ts +49 -6
- package/src/index-core.ts +15 -0
- package/src/runtime/host-layout.test.ts +206 -9
- package/src/runtime/host-layout.ts +121 -8
- package/src/runtime/init.test.ts +2 -2
- 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 +108 -2
- package/src/ui.docked.test.ts +2 -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/voice/voice.test.ts +0 -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
|
-
/**
|
|
235
|
-
|
|
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
|
/**
|
package/src/ui.docked.test.ts
CHANGED
|
@@ -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.
|
|
289
|
+
expect(pushTrack?.style.marginLeft).toBe("-420px");
|
|
290
290
|
|
|
291
291
|
closeButton!.click();
|
|
292
292
|
|
|
293
|
-
expect(pushTrack?.style.
|
|
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
|
|
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/voice/voice.test.ts
CHANGED
|
@@ -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
|
-
});
|