@runtypelabs/persona 3.17.0 → 3.19.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 +143 -1
- package/dist/animations/glyph-cycle.d.cts +1 -1
- package/dist/animations/glyph-cycle.d.ts +1 -1
- package/dist/animations/{types-HPZY7oAI.d.cts → types-cwY5HaFD.d.cts} +25 -0
- package/dist/animations/{types-HPZY7oAI.d.ts → types-cwY5HaFD.d.ts} +25 -0
- package/dist/animations/wipe.d.cts +1 -1
- package/dist/animations/wipe.d.ts +1 -1
- package/dist/index.cjs +47 -47
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +580 -4
- package/dist/index.d.ts +580 -4
- package/dist/index.global.js +102 -1636
- package/dist/index.global.js.map +1 -1
- package/dist/index.js +45 -45
- package/dist/index.js.map +1 -1
- package/dist/theme-editor.cjs +2844 -752
- package/dist/theme-editor.d.cts +337 -1
- package/dist/theme-editor.d.ts +337 -1
- package/dist/theme-editor.js +2958 -752
- package/dist/theme-reference.cjs +1 -1
- package/dist/theme-reference.d.cts +14 -0
- package/dist/theme-reference.d.ts +14 -0
- package/dist/widget.css +780 -0
- package/package.json +1 -1
- package/src/client.test.ts +134 -0
- package/src/client.ts +71 -0
- package/src/components/ask-user-question-bubble.test.ts +583 -0
- package/src/components/ask-user-question-bubble.ts +924 -0
- package/src/components/composer-builder.test.ts +52 -0
- package/src/components/composer-builder.ts +67 -490
- package/src/components/composer-parts.test.ts +152 -0
- package/src/components/composer-parts.ts +452 -0
- package/src/components/header-builder.ts +22 -299
- package/src/components/header-parts.ts +360 -0
- package/src/components/messages.ts +33 -1
- package/src/components/panel.test.ts +61 -0
- package/src/components/panel.ts +303 -9
- package/src/components/pill-composer-builder.test.ts +85 -0
- package/src/components/pill-composer-builder.ts +183 -0
- package/src/defaults.ts +21 -0
- package/src/index.ts +20 -1
- package/src/plugins/types.ts +57 -0
- package/src/runtime/init.ts +4 -2
- package/src/runtime/persist-state.test.ts +152 -0
- package/src/session.test.ts +183 -0
- package/src/session.ts +242 -3
- package/src/styles/widget.css +780 -0
- package/src/types/theme.ts +15 -0
- package/src/types.ts +271 -1
- package/src/ui.ask-user-question-plugin.test.ts +649 -0
- package/src/ui.component-directive.test.ts +183 -0
- package/src/ui.composer-bar.test.ts +1009 -0
- package/src/ui.ts +1439 -76
- package/src/utils/attachment-manager.ts +1 -1
- package/src/utils/dock.test.ts +45 -0
- package/src/utils/dock.ts +3 -0
- package/src/utils/icons.ts +314 -58
- package/src/utils/storage.ts +10 -2
- package/src/utils/stream-animation.ts +7 -2
- package/src/utils/theme.test.ts +36 -0
- package/src/utils/tokens.ts +23 -0
|
@@ -0,0 +1,649 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
|
|
3
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
4
|
+
|
|
5
|
+
import { createAgentExperience } from "./ui";
|
|
6
|
+
import type { AgentWidgetPlugin } from "./plugins/types";
|
|
7
|
+
|
|
8
|
+
const createMount = () => {
|
|
9
|
+
const mount = document.createElement("div");
|
|
10
|
+
document.body.appendChild(mount);
|
|
11
|
+
return mount;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
const injectAskUserQuestion = (
|
|
15
|
+
controller: ReturnType<typeof createAgentExperience>,
|
|
16
|
+
{ id = "tool-1", status = "complete" as const, args }: {
|
|
17
|
+
id?: string;
|
|
18
|
+
status?: "pending" | "running" | "complete";
|
|
19
|
+
args?: unknown;
|
|
20
|
+
} = {}
|
|
21
|
+
) => {
|
|
22
|
+
controller.injectTestMessage({
|
|
23
|
+
type: "message",
|
|
24
|
+
message: {
|
|
25
|
+
id,
|
|
26
|
+
role: "assistant",
|
|
27
|
+
content: "",
|
|
28
|
+
createdAt: "2026-04-24T00:00:00.000Z",
|
|
29
|
+
streaming: false,
|
|
30
|
+
variant: "tool",
|
|
31
|
+
toolCall: {
|
|
32
|
+
id,
|
|
33
|
+
name: "ask_user_question",
|
|
34
|
+
status,
|
|
35
|
+
args: args ?? {
|
|
36
|
+
questions: [
|
|
37
|
+
{ question: "Which audience?", options: [{ label: "Hobbyists" }, { label: "Pros" }] },
|
|
38
|
+
],
|
|
39
|
+
},
|
|
40
|
+
chunks: [],
|
|
41
|
+
},
|
|
42
|
+
agentMetadata: {
|
|
43
|
+
executionId: "exec_123",
|
|
44
|
+
awaitingLocalTool: true,
|
|
45
|
+
},
|
|
46
|
+
},
|
|
47
|
+
});
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
describe("renderAskUserQuestion plugin hook", () => {
|
|
51
|
+
afterEach(() => {
|
|
52
|
+
document.body.innerHTML = "";
|
|
53
|
+
if (typeof localStorage !== "undefined") localStorage.clear();
|
|
54
|
+
vi.restoreAllMocks();
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("renders a plugin-returned element inline in the transcript when provided", () => {
|
|
58
|
+
const plugin: AgentWidgetPlugin = {
|
|
59
|
+
id: "custom-asker",
|
|
60
|
+
renderAskUserQuestion: ({ payload }) => {
|
|
61
|
+
const root = document.createElement("div");
|
|
62
|
+
root.setAttribute("data-test-id", "custom-ask");
|
|
63
|
+
root.textContent = payload?.questions?.[0]?.question ?? "";
|
|
64
|
+
return root;
|
|
65
|
+
},
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
const mount = createMount();
|
|
69
|
+
const controller = createAgentExperience(mount, {
|
|
70
|
+
apiUrl: "https://api.example.com/chat",
|
|
71
|
+
launcher: { enabled: false },
|
|
72
|
+
plugins: [plugin],
|
|
73
|
+
} as unknown as Parameters<typeof createAgentExperience>[1]);
|
|
74
|
+
|
|
75
|
+
injectAskUserQuestion(controller);
|
|
76
|
+
|
|
77
|
+
const custom = mount.querySelector('[data-test-id="custom-ask"]');
|
|
78
|
+
expect(custom).not.toBeNull();
|
|
79
|
+
expect(custom?.textContent).toBe("Which audience?");
|
|
80
|
+
|
|
81
|
+
// The built-in overlay sheet must NOT be mounted when a plugin handles the UI.
|
|
82
|
+
const overlaySheet = mount.querySelector("[data-persona-ask-sheet-for]");
|
|
83
|
+
expect(overlaySheet).toBeNull();
|
|
84
|
+
|
|
85
|
+
controller.destroy();
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("falls back to the built-in overlay sheet when the plugin returns null", () => {
|
|
89
|
+
const plugin: AgentWidgetPlugin = {
|
|
90
|
+
id: "passthrough-asker",
|
|
91
|
+
renderAskUserQuestion: () => null,
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
const mount = createMount();
|
|
95
|
+
const controller = createAgentExperience(mount, {
|
|
96
|
+
apiUrl: "https://api.example.com/chat",
|
|
97
|
+
launcher: { enabled: false },
|
|
98
|
+
plugins: [plugin],
|
|
99
|
+
} as unknown as Parameters<typeof createAgentExperience>[1]);
|
|
100
|
+
|
|
101
|
+
injectAskUserQuestion(controller);
|
|
102
|
+
|
|
103
|
+
const overlaySheet = mount.querySelector("[data-persona-ask-sheet-for]");
|
|
104
|
+
expect(overlaySheet).not.toBeNull();
|
|
105
|
+
|
|
106
|
+
controller.destroy();
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it("does not duplicate the built-in sheet on re-render when a plugin owns the message", () => {
|
|
110
|
+
const plugin: AgentWidgetPlugin = {
|
|
111
|
+
id: "owning-plugin",
|
|
112
|
+
renderAskUserQuestion: ({ payload }) => {
|
|
113
|
+
const root = document.createElement("div");
|
|
114
|
+
root.setAttribute("data-test-id", "owning-plugin");
|
|
115
|
+
root.textContent = payload?.questions?.[0]?.question ?? "";
|
|
116
|
+
return root;
|
|
117
|
+
},
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
const mount = createMount();
|
|
121
|
+
const controller = createAgentExperience(mount, {
|
|
122
|
+
apiUrl: "https://api.example.com/chat",
|
|
123
|
+
launcher: { enabled: false },
|
|
124
|
+
plugins: [plugin],
|
|
125
|
+
} as unknown as Parameters<typeof createAgentExperience>[1]);
|
|
126
|
+
|
|
127
|
+
injectAskUserQuestion(controller);
|
|
128
|
+
expect(mount.querySelector("[data-persona-ask-sheet-for]")).toBeNull();
|
|
129
|
+
|
|
130
|
+
// Force a re-render by injecting a second message — the ask_user_question
|
|
131
|
+
// wrapper now goes through the render path again. The built-in overlay
|
|
132
|
+
// sheet must NOT appear; the plugin still owns the UI.
|
|
133
|
+
controller.injectTestMessage({
|
|
134
|
+
type: "message",
|
|
135
|
+
message: {
|
|
136
|
+
id: "user-1",
|
|
137
|
+
role: "user",
|
|
138
|
+
content: "ping",
|
|
139
|
+
createdAt: "2026-04-26T00:00:00.000Z",
|
|
140
|
+
streaming: false,
|
|
141
|
+
},
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
expect(mount.querySelector("[data-persona-ask-sheet-for]")).toBeNull();
|
|
145
|
+
expect(mount.querySelector('[data-test-id="owning-plugin"]')).not.toBeNull();
|
|
146
|
+
|
|
147
|
+
controller.destroy();
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it("preserves plugin button click listeners across morph re-renders", () => {
|
|
151
|
+
const resolveSpy = vi.fn();
|
|
152
|
+
const plugin: AgentWidgetPlugin = {
|
|
153
|
+
id: "click-listener-plugin",
|
|
154
|
+
renderAskUserQuestion: ({ resolve }) => {
|
|
155
|
+
const root = document.createElement("div");
|
|
156
|
+
root.setAttribute("data-test-id", "click-root");
|
|
157
|
+
const btn = document.createElement("button");
|
|
158
|
+
btn.type = "button";
|
|
159
|
+
btn.setAttribute("data-test-id", "click-pill");
|
|
160
|
+
btn.textContent = "Pick me";
|
|
161
|
+
// Single delegated listener at root — pattern recommended for plugins.
|
|
162
|
+
root.addEventListener("click", (e) => {
|
|
163
|
+
if ((e.target as HTMLElement).getAttribute("data-test-id") === "click-pill") {
|
|
164
|
+
resolve("Pick me");
|
|
165
|
+
resolveSpy();
|
|
166
|
+
}
|
|
167
|
+
});
|
|
168
|
+
root.appendChild(btn);
|
|
169
|
+
return root;
|
|
170
|
+
},
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
const fetchMock = vi.fn().mockResolvedValue({
|
|
174
|
+
ok: true,
|
|
175
|
+
body: new ReadableStream({
|
|
176
|
+
start(c) {
|
|
177
|
+
c.enqueue(new TextEncoder().encode('data: {"type":"flow_complete","success":true}\n\n'));
|
|
178
|
+
c.close();
|
|
179
|
+
},
|
|
180
|
+
}),
|
|
181
|
+
});
|
|
182
|
+
global.fetch = fetchMock as unknown as typeof fetch;
|
|
183
|
+
|
|
184
|
+
const mount = createMount();
|
|
185
|
+
const controller = createAgentExperience(mount, {
|
|
186
|
+
apiUrl: "https://api.example.com/chat",
|
|
187
|
+
launcher: { enabled: false },
|
|
188
|
+
plugins: [plugin],
|
|
189
|
+
} as unknown as Parameters<typeof createAgentExperience>[1]);
|
|
190
|
+
|
|
191
|
+
injectAskUserQuestion(controller);
|
|
192
|
+
|
|
193
|
+
// Force a re-render before the click — this is exactly the scenario
|
|
194
|
+
// where innerHTML-based morph used to drop listeners on the freshly-built
|
|
195
|
+
// plugin root.
|
|
196
|
+
controller.injectTestMessage({
|
|
197
|
+
type: "message",
|
|
198
|
+
message: {
|
|
199
|
+
id: "noise",
|
|
200
|
+
role: "user",
|
|
201
|
+
content: "noise",
|
|
202
|
+
createdAt: "2026-04-26T00:00:00.000Z",
|
|
203
|
+
streaming: false,
|
|
204
|
+
},
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
const pill = mount.querySelector<HTMLButtonElement>('[data-test-id="click-pill"]');
|
|
208
|
+
expect(pill).not.toBeNull();
|
|
209
|
+
pill!.click();
|
|
210
|
+
expect(resolveSpy).toHaveBeenCalled();
|
|
211
|
+
|
|
212
|
+
controller.destroy();
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
it("suppresses the plugin's interactive sheet once answered and injects Q→A pair messages", async () => {
|
|
216
|
+
const fetchMock = vi.fn().mockResolvedValue({
|
|
217
|
+
ok: true,
|
|
218
|
+
body: new ReadableStream({
|
|
219
|
+
start(c) {
|
|
220
|
+
c.enqueue(new TextEncoder().encode('data: {"type":"flow_complete","success":true}\n\n'));
|
|
221
|
+
c.close();
|
|
222
|
+
},
|
|
223
|
+
}),
|
|
224
|
+
});
|
|
225
|
+
global.fetch = fetchMock as unknown as typeof fetch;
|
|
226
|
+
|
|
227
|
+
// Plugin renders an interactive card while the question is awaiting an
|
|
228
|
+
// answer. Once answered, the widget suppresses the original tool message
|
|
229
|
+
// entirely — the plugin renderer is invoked but returns null — and the
|
|
230
|
+
// session injects Q→A pair messages (assistant question + user answer)
|
|
231
|
+
// that render through the standard transcript pipeline.
|
|
232
|
+
const plugin: AgentWidgetPlugin = {
|
|
233
|
+
id: "click-pill-plugin",
|
|
234
|
+
renderAskUserQuestion: ({ message, payload, resolve }) => {
|
|
235
|
+
if (message?.agentMetadata?.askUserQuestionAnswered === true) {
|
|
236
|
+
return null;
|
|
237
|
+
}
|
|
238
|
+
const root = document.createElement("div");
|
|
239
|
+
root.setAttribute("data-test-id", "interactive-card");
|
|
240
|
+
const opts = payload?.questions?.[0]?.options ?? [];
|
|
241
|
+
opts.forEach((opt) => {
|
|
242
|
+
if (!opt?.label) return;
|
|
243
|
+
const btn = document.createElement("button");
|
|
244
|
+
btn.type = "button";
|
|
245
|
+
btn.textContent = opt.label;
|
|
246
|
+
btn.setAttribute("data-test-id", `pick-${opt.label}`);
|
|
247
|
+
btn.addEventListener("click", () => resolve(opt.label));
|
|
248
|
+
root.appendChild(btn);
|
|
249
|
+
});
|
|
250
|
+
return root;
|
|
251
|
+
},
|
|
252
|
+
};
|
|
253
|
+
|
|
254
|
+
const mount = createMount();
|
|
255
|
+
const controller = createAgentExperience(mount, {
|
|
256
|
+
apiUrl: "https://api.example.com/chat",
|
|
257
|
+
launcher: { enabled: false },
|
|
258
|
+
plugins: [plugin],
|
|
259
|
+
} as unknown as Parameters<typeof createAgentExperience>[1]);
|
|
260
|
+
|
|
261
|
+
injectAskUserQuestion(controller);
|
|
262
|
+
|
|
263
|
+
expect(mount.querySelector('[data-test-id="interactive-card"]')).not.toBeNull();
|
|
264
|
+
|
|
265
|
+
const pick = mount.querySelector<HTMLButtonElement>('[data-test-id="pick-Hobbyists"]');
|
|
266
|
+
expect(pick).not.toBeNull();
|
|
267
|
+
pick!.click();
|
|
268
|
+
|
|
269
|
+
await Promise.resolve();
|
|
270
|
+
await Promise.resolve();
|
|
271
|
+
|
|
272
|
+
// After answer: interactive card gone, no built-in answered card, and
|
|
273
|
+
// the transcript contains the Q→A pair messages with the picked answer.
|
|
274
|
+
expect(mount.querySelector('[data-test-id="interactive-card"]')).toBeNull();
|
|
275
|
+
expect(mount.querySelector('[data-ask-answered-card="true"]')).toBeNull();
|
|
276
|
+
expect(mount.textContent).toContain("Which audience?");
|
|
277
|
+
expect(mount.textContent).toContain("Hobbyists");
|
|
278
|
+
|
|
279
|
+
controller.destroy();
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
it("ignores rapid double-clicks on the same answer pill (idempotent resolve)", async () => {
|
|
283
|
+
const fetchMock = vi.fn().mockResolvedValue({
|
|
284
|
+
ok: true,
|
|
285
|
+
body: new ReadableStream({
|
|
286
|
+
start(c) {
|
|
287
|
+
c.enqueue(new TextEncoder().encode('data: {"type":"flow_complete","success":true}\n\n'));
|
|
288
|
+
c.close();
|
|
289
|
+
},
|
|
290
|
+
}),
|
|
291
|
+
});
|
|
292
|
+
global.fetch = fetchMock as unknown as typeof fetch;
|
|
293
|
+
|
|
294
|
+
let resolveRef: ((answer: string) => void) | undefined;
|
|
295
|
+
const plugin: AgentWidgetPlugin = {
|
|
296
|
+
id: "double-click-plugin",
|
|
297
|
+
renderAskUserQuestion: ({ resolve }) => {
|
|
298
|
+
resolveRef = resolve;
|
|
299
|
+
const el = document.createElement("div");
|
|
300
|
+
el.setAttribute("data-test-id", "dbl");
|
|
301
|
+
return el;
|
|
302
|
+
},
|
|
303
|
+
};
|
|
304
|
+
|
|
305
|
+
const mount = createMount();
|
|
306
|
+
const controller = createAgentExperience(mount, {
|
|
307
|
+
apiUrl: "https://api.example.com/chat",
|
|
308
|
+
launcher: { enabled: false },
|
|
309
|
+
plugins: [plugin],
|
|
310
|
+
} as unknown as Parameters<typeof createAgentExperience>[1]);
|
|
311
|
+
|
|
312
|
+
injectAskUserQuestion(controller);
|
|
313
|
+
expect(resolveRef).toBeDefined();
|
|
314
|
+
|
|
315
|
+
// Three rapid resolves before any re-render cycle settles.
|
|
316
|
+
resolveRef!("First");
|
|
317
|
+
resolveRef!("Second");
|
|
318
|
+
resolveRef!("Third");
|
|
319
|
+
|
|
320
|
+
await Promise.resolve();
|
|
321
|
+
await Promise.resolve();
|
|
322
|
+
|
|
323
|
+
// Only the FIRST answer should have hit the network.
|
|
324
|
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
325
|
+
const body = JSON.parse((fetchMock.mock.calls[0][1] as RequestInit).body as string);
|
|
326
|
+
expect(body.toolOutputs.ask_user_question).toBe("First");
|
|
327
|
+
|
|
328
|
+
controller.destroy();
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
it("auto-advances to the next page when a single-select pill is picked in grouped mode (default)", () => {
|
|
332
|
+
const mount = createMount();
|
|
333
|
+
const controller = createAgentExperience(mount, {
|
|
334
|
+
apiUrl: "https://api.example.com/chat",
|
|
335
|
+
launcher: { enabled: false },
|
|
336
|
+
} as unknown as Parameters<typeof createAgentExperience>[1]);
|
|
337
|
+
|
|
338
|
+
injectAskUserQuestion(controller, {
|
|
339
|
+
args: {
|
|
340
|
+
questions: [
|
|
341
|
+
{ question: "Q1?", options: [{ label: "A" }, { label: "B" }] },
|
|
342
|
+
{ question: "Q2?", options: [{ label: "C" }, { label: "D" }] },
|
|
343
|
+
{ question: "Q3?", options: [{ label: "E" }, { label: "F" }] },
|
|
344
|
+
],
|
|
345
|
+
},
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
const sheet = mount.querySelector<HTMLElement>("[data-persona-ask-sheet-for]")!;
|
|
349
|
+
expect(sheet.getAttribute("data-ask-current-index")).toBe("0");
|
|
350
|
+
(sheet.querySelector('[data-option-label="A"]') as HTMLElement).click();
|
|
351
|
+
expect(sheet.getAttribute("data-ask-current-index")).toBe("1");
|
|
352
|
+
|
|
353
|
+
// Final page: pick should NOT auto-submit — Submit-all button still present.
|
|
354
|
+
(sheet.querySelector('[data-option-label="C"]') as HTMLElement).click();
|
|
355
|
+
expect(sheet.getAttribute("data-ask-current-index")).toBe("2");
|
|
356
|
+
(sheet.querySelector('[data-option-label="E"]') as HTMLElement).click();
|
|
357
|
+
expect(sheet.getAttribute("data-ask-current-index")).toBe("2");
|
|
358
|
+
expect(sheet.querySelector('[data-ask-user-action="submit-all"]')).not.toBeNull();
|
|
359
|
+
expect(mount.querySelector("[data-persona-ask-sheet-for]")).not.toBeNull();
|
|
360
|
+
|
|
361
|
+
controller.destroy();
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
it("injects Q→A pair messages with the user's picks (not '*Skipped*') after submit-all", async () => {
|
|
365
|
+
const fetchMock = vi.fn().mockResolvedValue({
|
|
366
|
+
ok: true,
|
|
367
|
+
body: new ReadableStream({
|
|
368
|
+
start(c) {
|
|
369
|
+
c.enqueue(new TextEncoder().encode('data: {"type":"flow_complete","success":true}\n\n'));
|
|
370
|
+
c.close();
|
|
371
|
+
},
|
|
372
|
+
}),
|
|
373
|
+
});
|
|
374
|
+
global.fetch = fetchMock as unknown as typeof fetch;
|
|
375
|
+
|
|
376
|
+
const mount = createMount();
|
|
377
|
+
const controller = createAgentExperience(mount, {
|
|
378
|
+
apiUrl: "https://api.example.com/chat",
|
|
379
|
+
launcher: { enabled: false },
|
|
380
|
+
} as unknown as Parameters<typeof createAgentExperience>[1]);
|
|
381
|
+
|
|
382
|
+
injectAskUserQuestion(controller, {
|
|
383
|
+
args: {
|
|
384
|
+
questions: [
|
|
385
|
+
{ header: "Tone", question: "Pick a tone", options: [{ label: "Story-driven" }, { label: "Punchy" }] },
|
|
386
|
+
{ header: "Sections", question: "Pick a section", options: [{ label: "Testimonials" }, { label: "Hero" }] },
|
|
387
|
+
{ header: "CTA", question: "Pick a CTA", options: [{ label: "Sign up" }, { label: "Buy now" }] },
|
|
388
|
+
],
|
|
389
|
+
},
|
|
390
|
+
});
|
|
391
|
+
|
|
392
|
+
const sheet = mount.querySelector<HTMLElement>("[data-persona-ask-sheet-for]")!;
|
|
393
|
+
|
|
394
|
+
// Page 1 → pick Story-driven, auto-advance to page 2.
|
|
395
|
+
(sheet.querySelector('[data-option-label="Story-driven"]') as HTMLElement).click();
|
|
396
|
+
expect(sheet.getAttribute("data-ask-current-index")).toBe("1");
|
|
397
|
+
|
|
398
|
+
// Page 2 → pick Testimonials, auto-advance to page 3.
|
|
399
|
+
(sheet.querySelector('[data-option-label="Testimonials"]') as HTMLElement).click();
|
|
400
|
+
expect(sheet.getAttribute("data-ask-current-index")).toBe("2");
|
|
401
|
+
|
|
402
|
+
// Page 3 (final) → pick Sign up. Final page does NOT auto-submit.
|
|
403
|
+
(sheet.querySelector('[data-option-label="Sign up"]') as HTMLElement).click();
|
|
404
|
+
expect(sheet.getAttribute("data-ask-current-index")).toBe("2");
|
|
405
|
+
|
|
406
|
+
// Click Submit-all → fires resolveAskUserQuestion + transitions to answered.
|
|
407
|
+
const submitAll = sheet.querySelector<HTMLButtonElement>(
|
|
408
|
+
'[data-ask-user-action="submit-all"]'
|
|
409
|
+
)!;
|
|
410
|
+
expect(submitAll.disabled).toBe(false);
|
|
411
|
+
submitAll.click();
|
|
412
|
+
|
|
413
|
+
await Promise.resolve();
|
|
414
|
+
await Promise.resolve();
|
|
415
|
+
|
|
416
|
+
// Original tool message suppressed; transcript now contains assistant Q
|
|
417
|
+
// bubbles + user A bubbles in alternating order. Each picked answer
|
|
418
|
+
// appears in the transcript, none rendered as "*Skipped*".
|
|
419
|
+
expect(mount.querySelector('[data-ask-answered-card="true"]')).toBeNull();
|
|
420
|
+
const transcriptText = mount.textContent ?? "";
|
|
421
|
+
expect(transcriptText).toContain("Pick a tone");
|
|
422
|
+
expect(transcriptText).toContain("Story-driven");
|
|
423
|
+
expect(transcriptText).toContain("Pick a section");
|
|
424
|
+
expect(transcriptText).toContain("Testimonials");
|
|
425
|
+
expect(transcriptText).toContain("Pick a CTA");
|
|
426
|
+
expect(transcriptText).toContain("Sign up");
|
|
427
|
+
expect(transcriptText).not.toContain("*Skipped*");
|
|
428
|
+
|
|
429
|
+
controller.destroy();
|
|
430
|
+
});
|
|
431
|
+
|
|
432
|
+
it("includes pending free-text answer on the final page in the Q→A transcript (not '*Skipped*')", async () => {
|
|
433
|
+
const fetchMock = vi.fn().mockResolvedValue({
|
|
434
|
+
ok: true,
|
|
435
|
+
body: new ReadableStream({
|
|
436
|
+
start(c) {
|
|
437
|
+
c.enqueue(new TextEncoder().encode('data: {"type":"flow_complete","success":true}\n\n'));
|
|
438
|
+
c.close();
|
|
439
|
+
},
|
|
440
|
+
}),
|
|
441
|
+
});
|
|
442
|
+
global.fetch = fetchMock as unknown as typeof fetch;
|
|
443
|
+
|
|
444
|
+
const mount = createMount();
|
|
445
|
+
const controller = createAgentExperience(mount, {
|
|
446
|
+
apiUrl: "https://api.example.com/chat",
|
|
447
|
+
launcher: { enabled: false },
|
|
448
|
+
} as unknown as Parameters<typeof createAgentExperience>[1]);
|
|
449
|
+
|
|
450
|
+
injectAskUserQuestion(controller, {
|
|
451
|
+
args: {
|
|
452
|
+
questions: [
|
|
453
|
+
{ header: "Tone", question: "Pick a tone", options: [{ label: "Story-driven" }, { label: "Punchy" }] },
|
|
454
|
+
{ header: "Sections", question: "Pick a section", options: [{ label: "Testimonials" }, { label: "Hero" }] },
|
|
455
|
+
{ header: "CTA", question: "Pick a CTA", options: [{ label: "Sign up" }, { label: "Buy now" }] },
|
|
456
|
+
],
|
|
457
|
+
},
|
|
458
|
+
});
|
|
459
|
+
|
|
460
|
+
const sheet = mount.querySelector<HTMLElement>("[data-persona-ask-sheet-for]")!;
|
|
461
|
+
|
|
462
|
+
// Page 1 → pick Story-driven, auto-advance to page 2.
|
|
463
|
+
(sheet.querySelector('[data-option-label="Story-driven"]') as HTMLElement).click();
|
|
464
|
+
// Page 2 → pick Testimonials, auto-advance to page 3.
|
|
465
|
+
(sheet.querySelector('[data-option-label="Testimonials"]') as HTMLElement).click();
|
|
466
|
+
expect(sheet.getAttribute("data-ask-current-index")).toBe("2");
|
|
467
|
+
|
|
468
|
+
// Page 3 → user types free-text "test" without clicking Send first, then
|
|
469
|
+
// hits Submit-all directly. The free-text input value must be flushed
|
|
470
|
+
// through to BOTH the structured payload AND the persisted metadata so
|
|
471
|
+
// the answered review card reflects the user's actual answer.
|
|
472
|
+
// User flow: type "test", press Enter to submit-free-text (which is the
|
|
473
|
+
// gate that persists the answer to message metadata + enables Submit-all),
|
|
474
|
+
// then click Submit-all.
|
|
475
|
+
const freeInput = sheet.querySelector<HTMLInputElement>(
|
|
476
|
+
'[data-ask-free-text-input="true"]'
|
|
477
|
+
)!;
|
|
478
|
+
freeInput.value = "test";
|
|
479
|
+
freeInput.dispatchEvent(
|
|
480
|
+
new KeyboardEvent("keydown", { key: "Enter", bubbles: true })
|
|
481
|
+
);
|
|
482
|
+
|
|
483
|
+
const submitAll = sheet.querySelector<HTMLButtonElement>(
|
|
484
|
+
'[data-ask-user-action="submit-all"]'
|
|
485
|
+
)!;
|
|
486
|
+
submitAll.click();
|
|
487
|
+
|
|
488
|
+
await Promise.resolve();
|
|
489
|
+
await Promise.resolve();
|
|
490
|
+
|
|
491
|
+
expect(mount.querySelector('[data-ask-answered-card="true"]')).toBeNull();
|
|
492
|
+
const transcriptText = mount.textContent ?? "";
|
|
493
|
+
expect(transcriptText).toContain("Story-driven");
|
|
494
|
+
expect(transcriptText).toContain("Testimonials");
|
|
495
|
+
// The free-text value typed on the final page should also appear.
|
|
496
|
+
expect(transcriptText).toContain("test");
|
|
497
|
+
expect(transcriptText).not.toContain("*Skipped*");
|
|
498
|
+
|
|
499
|
+
controller.destroy();
|
|
500
|
+
});
|
|
501
|
+
|
|
502
|
+
it("digit keyboard shortcut picks the matching row even when focus is on document.body", () => {
|
|
503
|
+
const mount = createMount();
|
|
504
|
+
const controller = createAgentExperience(mount, {
|
|
505
|
+
apiUrl: "https://api.example.com/chat",
|
|
506
|
+
launcher: { enabled: false },
|
|
507
|
+
} as unknown as Parameters<typeof createAgentExperience>[1]);
|
|
508
|
+
|
|
509
|
+
injectAskUserQuestion(controller, {
|
|
510
|
+
args: {
|
|
511
|
+
questions: [
|
|
512
|
+
{ question: "Q1?", options: [{ label: "Alpha" }, { label: "Beta" }, { label: "Gamma" }] },
|
|
513
|
+
{ question: "Q2?", options: [{ label: "X" }, { label: "Y" }] },
|
|
514
|
+
],
|
|
515
|
+
},
|
|
516
|
+
});
|
|
517
|
+
|
|
518
|
+
const sheet = mount.querySelector<HTMLElement>("[data-persona-ask-sheet-for]")!;
|
|
519
|
+
expect(sheet.getAttribute("data-ask-layout")).toBe("rows");
|
|
520
|
+
expect(sheet.getAttribute("data-ask-current-index")).toBe("0");
|
|
521
|
+
|
|
522
|
+
// Focus on body — no element inside the overlay subtree is focused.
|
|
523
|
+
if (document.activeElement && document.activeElement !== document.body) {
|
|
524
|
+
(document.activeElement as HTMLElement).blur?.();
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
// Dispatch the digit keypress on `mount` — the listener on `mount`
|
|
528
|
+
// catches it regardless of focus location. In grouped single-select mode,
|
|
529
|
+
// a successful pick auto-advances to the next page (default behavior).
|
|
530
|
+
mount.dispatchEvent(
|
|
531
|
+
new KeyboardEvent("keydown", { key: "2", bubbles: true })
|
|
532
|
+
);
|
|
533
|
+
|
|
534
|
+
expect(sheet.getAttribute("data-ask-current-index")).toBe("1");
|
|
535
|
+
|
|
536
|
+
controller.destroy();
|
|
537
|
+
});
|
|
538
|
+
|
|
539
|
+
it("digit keypress is not hijacked when typing into the free-text input", () => {
|
|
540
|
+
const mount = createMount();
|
|
541
|
+
const controller = createAgentExperience(mount, {
|
|
542
|
+
apiUrl: "https://api.example.com/chat",
|
|
543
|
+
launcher: { enabled: false },
|
|
544
|
+
} as unknown as Parameters<typeof createAgentExperience>[1]);
|
|
545
|
+
|
|
546
|
+
injectAskUserQuestion(controller, {
|
|
547
|
+
args: {
|
|
548
|
+
questions: [
|
|
549
|
+
{
|
|
550
|
+
question: "Pick one",
|
|
551
|
+
options: [{ label: "Alpha" }, { label: "Beta" }, { label: "Gamma" }],
|
|
552
|
+
allowFreeText: true,
|
|
553
|
+
},
|
|
554
|
+
],
|
|
555
|
+
},
|
|
556
|
+
});
|
|
557
|
+
|
|
558
|
+
const sheet = mount.querySelector<HTMLElement>("[data-persona-ask-sheet-for]")!;
|
|
559
|
+
const input = sheet.querySelector<HTMLInputElement>(
|
|
560
|
+
'[data-ask-free-text-input="true"]'
|
|
561
|
+
)!;
|
|
562
|
+
input.focus();
|
|
563
|
+
input.dispatchEvent(
|
|
564
|
+
new KeyboardEvent("keydown", { key: "2", bubbles: true })
|
|
565
|
+
);
|
|
566
|
+
|
|
567
|
+
// No row should have been picked — input is the focused target so the
|
|
568
|
+
// mount-level handler bails out.
|
|
569
|
+
const beta = sheet.querySelector<HTMLElement>('[data-option-label="Beta"]')!;
|
|
570
|
+
expect(beta.getAttribute("aria-pressed")).toBe("false");
|
|
571
|
+
|
|
572
|
+
controller.destroy();
|
|
573
|
+
});
|
|
574
|
+
|
|
575
|
+
it("respects groupedAutoAdvance: false — pick stays on current page", () => {
|
|
576
|
+
const mount = createMount();
|
|
577
|
+
const controller = createAgentExperience(mount, {
|
|
578
|
+
apiUrl: "https://api.example.com/chat",
|
|
579
|
+
launcher: { enabled: false },
|
|
580
|
+
features: { askUserQuestion: { groupedAutoAdvance: false } },
|
|
581
|
+
} as unknown as Parameters<typeof createAgentExperience>[1]);
|
|
582
|
+
|
|
583
|
+
injectAskUserQuestion(controller, {
|
|
584
|
+
args: {
|
|
585
|
+
questions: [
|
|
586
|
+
{ question: "Q1?", options: [{ label: "A" }, { label: "B" }] },
|
|
587
|
+
{ question: "Q2?", options: [{ label: "C" }, { label: "D" }] },
|
|
588
|
+
],
|
|
589
|
+
},
|
|
590
|
+
});
|
|
591
|
+
|
|
592
|
+
const sheet = mount.querySelector<HTMLElement>("[data-persona-ask-sheet-for]")!;
|
|
593
|
+
(sheet.querySelector('[data-option-label="A"]') as HTMLElement).click();
|
|
594
|
+
expect(sheet.getAttribute("data-ask-current-index")).toBe("0");
|
|
595
|
+
const pillA = sheet.querySelector('[data-option-label="A"]');
|
|
596
|
+
expect(pillA?.getAttribute("aria-pressed")).toBe("true");
|
|
597
|
+
|
|
598
|
+
controller.destroy();
|
|
599
|
+
});
|
|
600
|
+
|
|
601
|
+
it("wires resolve(answer) to session.resolveAskUserQuestion via /resume", async () => {
|
|
602
|
+
const fetchMock = vi.fn().mockResolvedValue({
|
|
603
|
+
ok: true,
|
|
604
|
+
body: new ReadableStream({
|
|
605
|
+
start(c) {
|
|
606
|
+
c.enqueue(new TextEncoder().encode('data: {"type":"flow_complete","success":true}\n\n'));
|
|
607
|
+
c.close();
|
|
608
|
+
},
|
|
609
|
+
}),
|
|
610
|
+
});
|
|
611
|
+
global.fetch = fetchMock as unknown as typeof fetch;
|
|
612
|
+
|
|
613
|
+
let resolveRef: ((answer: string) => void) | undefined;
|
|
614
|
+
const plugin: AgentWidgetPlugin = {
|
|
615
|
+
id: "capturing-asker",
|
|
616
|
+
renderAskUserQuestion: ({ resolve }) => {
|
|
617
|
+
resolveRef = resolve;
|
|
618
|
+
const el = document.createElement("div");
|
|
619
|
+
el.setAttribute("data-test-id", "capturing");
|
|
620
|
+
return el;
|
|
621
|
+
},
|
|
622
|
+
};
|
|
623
|
+
|
|
624
|
+
const mount = createMount();
|
|
625
|
+
const controller = createAgentExperience(mount, {
|
|
626
|
+
apiUrl: "https://api.example.com/chat",
|
|
627
|
+
launcher: { enabled: false },
|
|
628
|
+
plugins: [plugin],
|
|
629
|
+
} as unknown as Parameters<typeof createAgentExperience>[1]);
|
|
630
|
+
|
|
631
|
+
injectAskUserQuestion(controller);
|
|
632
|
+
|
|
633
|
+
expect(resolveRef).toBeDefined();
|
|
634
|
+
resolveRef!("Hobbyists");
|
|
635
|
+
await Promise.resolve();
|
|
636
|
+
|
|
637
|
+
expect(fetchMock).toHaveBeenCalled();
|
|
638
|
+
const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit];
|
|
639
|
+
expect(url).toMatch(/\/resume$/);
|
|
640
|
+
const body = JSON.parse(init.body as string);
|
|
641
|
+
expect(body).toEqual({
|
|
642
|
+
executionId: "exec_123",
|
|
643
|
+
toolOutputs: { ["ask_user_question"]: "Hobbyists" },
|
|
644
|
+
streamResponse: true,
|
|
645
|
+
});
|
|
646
|
+
|
|
647
|
+
controller.destroy();
|
|
648
|
+
});
|
|
649
|
+
});
|