@runtypelabs/persona 4.13.0 → 4.15.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 (106) hide show
  1. package/README.md +4 -4
  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-DveIaNx6.d.cts → types-BpWGm7_6.d.cts} +17 -0
  5. package/dist/animations/{types-DveIaNx6.d.ts → types-BpWGm7_6.d.ts} +17 -0
  6. package/dist/animations/wipe.d.cts +1 -1
  7. package/dist/animations/wipe.d.ts +1 -1
  8. package/dist/chunk-QJ4LV2KW.js +1 -0
  9. package/dist/codegen.cjs +12 -11
  10. package/dist/codegen.js +14 -13
  11. package/dist/{context-mentions-ONG7A7M6.js → context-mentions-BTSTIBFC.js} +1 -1
  12. package/dist/context-mentions.cjs +11 -8
  13. package/dist/context-mentions.d.cts +339 -22
  14. package/dist/context-mentions.d.ts +339 -22
  15. package/dist/context-mentions.js +11 -8
  16. package/dist/index.cjs +68 -67
  17. package/dist/index.cjs.map +1 -1
  18. package/dist/index.d.cts +483 -48
  19. package/dist/index.d.ts +483 -48
  20. package/dist/index.global.js +61 -58
  21. package/dist/index.global.js.map +1 -1
  22. package/dist/index.js +70 -69
  23. package/dist/index.js.map +1 -1
  24. package/dist/launcher.global.js +5 -2
  25. package/dist/launcher.global.js.map +1 -1
  26. package/dist/smart-dom-reader.d.cts +339 -22
  27. package/dist/smart-dom-reader.d.ts +339 -22
  28. package/dist/theme-editor-preview.cjs +67 -67
  29. package/dist/theme-editor-preview.d.cts +355 -23
  30. package/dist/theme-editor-preview.d.ts +355 -23
  31. package/dist/theme-editor-preview.js +75 -75
  32. package/dist/theme-editor.cjs +10 -10
  33. package/dist/theme-editor.d.cts +376 -24
  34. package/dist/theme-editor.d.ts +376 -24
  35. package/dist/theme-editor.js +10 -10
  36. package/dist/theme-reference.cjs +1 -1
  37. package/dist/theme-reference.d.cts +38 -0
  38. package/dist/theme-reference.d.ts +38 -0
  39. package/dist/theme-reference.js +1 -1
  40. package/dist/widget.css +1 -1
  41. package/package.json +1 -1
  42. package/src/artifacts-session.test.ts +106 -0
  43. package/src/ask-user-question-tool.ts +10 -7
  44. package/src/client.test.ts +2 -2
  45. package/src/client.ts +4 -1
  46. package/src/components/artifact-card.ts +13 -2
  47. package/src/components/artifact-inline.ts +11 -2
  48. package/src/components/artifact-preview.ts +1 -1
  49. package/src/components/composer-builder.test.ts +2 -0
  50. package/src/components/composer-builder.ts +1 -1
  51. package/src/components/composer-parts.test.ts +105 -4
  52. package/src/components/composer-parts.ts +54 -16
  53. package/src/components/message-bubble.test.ts +7 -1
  54. package/src/components/message-bubble.ts +11 -8
  55. package/src/components/messages.ts +5 -2
  56. package/src/components/panel.ts +31 -5
  57. package/src/components/pill-composer-builder.test.ts +18 -0
  58. package/src/components/suggestions.test.ts +233 -0
  59. package/src/components/suggestions.ts +463 -81
  60. package/src/components/widget-view.ts +4 -0
  61. package/src/defaults.ts +5 -3
  62. package/src/generated/runtype-openapi-contract.ts +17 -0
  63. package/src/index-core.ts +52 -2
  64. package/src/launcher-global.ts +1 -1
  65. package/src/plugins/types.ts +71 -3
  66. package/src/presets.ts +3 -3
  67. package/src/session.ts +55 -12
  68. package/src/styles/widget-styles.test.ts +162 -0
  69. package/src/styles/widget.css +440 -1
  70. package/src/suggest-replies-tool.test.ts +233 -2
  71. package/src/suggest-replies-tool.ts +115 -39
  72. package/src/theme-editor/index.ts +5 -0
  73. package/src/theme-editor/presets.test.ts +43 -0
  74. package/src/theme-editor/presets.ts +101 -87
  75. package/src/theme-editor/preview-utils.test.ts +34 -0
  76. package/src/theme-editor/preview-utils.ts +77 -2
  77. package/src/theme-editor/sections.test.ts +72 -7
  78. package/src/theme-editor/sections.ts +113 -13
  79. package/src/theme-editor/state-preferences.test.ts +15 -0
  80. package/src/theme-editor/state.ts +46 -0
  81. package/src/theme-editor/types.ts +15 -1
  82. package/src/theme-editor/webmcp/tools.test.ts +13 -0
  83. package/src/theme-editor/webmcp/tools.ts +5 -0
  84. package/src/theme-reference.ts +9 -2
  85. package/src/types/theme.ts +32 -0
  86. package/src/types.ts +291 -25
  87. package/src/ui.artifact-pane-gating.test.ts +5 -5
  88. package/src/ui.config-preferences.test.ts +166 -0
  89. package/src/ui.detached-panel.test.ts +1 -1
  90. package/src/ui.suggest-replies.test.ts +1066 -1
  91. package/src/ui.ts +311 -45
  92. package/src/ui.typing-indicator.test.ts +135 -0
  93. package/src/utils/artifact-display.test.ts +180 -16
  94. package/src/utils/artifact-display.ts +155 -14
  95. package/src/utils/artifact-file.test.ts +1 -1
  96. package/src/utils/code-generators.test.ts +40 -0
  97. package/src/utils/code-generators.ts +22 -0
  98. package/src/utils/config-merge.test.ts +84 -0
  99. package/src/utils/config-merge.ts +6 -1
  100. package/src/utils/feature-preferences.test.ts +326 -0
  101. package/src/utils/feature-preferences.ts +498 -0
  102. package/src/utils/icons.test.ts +26 -0
  103. package/src/utils/icons.ts +8 -0
  104. package/src/utils/theme.test.ts +171 -0
  105. package/src/utils/tokens.ts +71 -7
  106. package/dist/chunk-MMUPR2JW.js +0 -1
@@ -4,6 +4,8 @@ import { afterEach, describe, expect, it, vi } from "vitest";
4
4
 
5
5
  import { createAgentExperience } from "./ui";
6
6
  import { SUGGEST_REPLIES_TOOL_NAME } from "./suggest-replies-tool";
7
+ import type { AgentWidgetPlugin } from "./plugins/types";
8
+ import type { AgentWidgetSuggestion } from "./types";
7
9
 
8
10
  const createMount = () => {
9
11
  const mount = document.createElement("div");
@@ -39,12 +41,29 @@ const injectUserMessage = (
39
41
  });
40
42
  };
41
43
 
44
+ const injectAssistantMessage = (
45
+ controller: ReturnType<typeof createAgentExperience>,
46
+ id = "a1",
47
+ createdAt = "2026-06-10T00:00:01.000Z",
48
+ ) => {
49
+ controller.injectTestMessage({
50
+ type: "message",
51
+ message: {
52
+ id,
53
+ role: "assistant",
54
+ content: "Here is the answer.",
55
+ createdAt,
56
+ streaming: false,
57
+ },
58
+ });
59
+ };
60
+
42
61
  const injectSuggestReplies = (
43
62
  controller: ReturnType<typeof createAgentExperience>,
44
63
  {
45
64
  id = "sr-1",
46
65
  suggestions = ["Tell me more", "Show pricing"],
47
- }: { id?: string; suggestions?: string[] } = {},
66
+ }: { id?: string; suggestions?: AgentWidgetSuggestion[] } = {},
48
67
  ) => {
49
68
  controller.injectTestMessage({
50
69
  type: "message",
@@ -69,6 +88,25 @@ const injectSuggestReplies = (
69
88
  });
70
89
  };
71
90
 
91
+ /** SSE body in the wire's data-only shape: one JSON frame per event. */
92
+ const sseStream = (frames: Record<string, unknown>[]) => {
93
+ const encoder = new TextEncoder();
94
+ return new ReadableStream<Uint8Array>({
95
+ start(controller) {
96
+ for (const frame of frames) {
97
+ controller.enqueue(encoder.encode(`data: ${JSON.stringify(frame)}\n\n`));
98
+ }
99
+ controller.close();
100
+ },
101
+ });
102
+ };
103
+
104
+ /** Drives a real stream to completion so the session flips back to idle. */
105
+ const completeStream = (
106
+ controller: ReturnType<typeof createAgentExperience>,
107
+ frames: Record<string, unknown>[] = [{ type: "done" }],
108
+ ) => controller.connectStream(sseStream(frames));
109
+
72
110
  const chipButtons = (mount: HTMLElement, label: string): HTMLButtonElement[] =>
73
111
  Array.from(mount.querySelectorAll("button")).filter(
74
112
  (btn) => btn.textContent === label,
@@ -92,6 +130,203 @@ describe("suggest_replies chips UI", () => {
92
130
  controller.destroy();
93
131
  });
94
132
 
133
+ it("renders rich starter cards in the welcome surface", () => {
134
+ const { mount, controller } = makeController({
135
+ suggestions: {
136
+ starters: {
137
+ variant: "card",
138
+ items: [
139
+ {
140
+ id: "pricing",
141
+ label: "Compare plans",
142
+ prompt: "Help me compare plans",
143
+ description: "See features and pricing side by side",
144
+ icon: "dollar-sign",
145
+ emphasis: "primary",
146
+ },
147
+ ],
148
+ },
149
+ },
150
+ });
151
+
152
+ const welcome = mount.querySelector(
153
+ '[data-persona-suggestions="starter"]',
154
+ );
155
+ const button = welcome?.querySelector<HTMLButtonElement>(
156
+ '[data-suggestion-id="pricing"]',
157
+ );
158
+ expect(welcome?.getAttribute("data-variant")).toBe("card");
159
+ expect(button?.textContent).toContain("Compare plans");
160
+ expect(button?.textContent).toContain(
161
+ "See features and pricing side by side",
162
+ );
163
+ expect(button?.dataset.emphasis).toBe("primary");
164
+
165
+ controller.destroy();
166
+ });
167
+
168
+ it("wraps starters by default and honors an explicit overflow", () => {
169
+ const wrapping = makeController({
170
+ suggestions: { starters: { items: ["Compare plans"] } },
171
+ });
172
+ expect(
173
+ wrapping.mount
174
+ .querySelector('[data-persona-suggestions="starter"]')
175
+ ?.getAttribute("data-overflow"),
176
+ ).toBe("wrap");
177
+ wrapping.controller.destroy();
178
+
179
+ const scrolling = makeController({
180
+ suggestions: {
181
+ starters: { items: ["Compare plans"], overflow: "scroll" },
182
+ },
183
+ });
184
+ expect(
185
+ scrolling.mount
186
+ .querySelector('[data-persona-suggestions="starter"]')
187
+ ?.getAttribute("data-overflow"),
188
+ ).toBe("scroll");
189
+ scrolling.controller.destroy();
190
+ });
191
+
192
+ it("auto placement follows welcome-card visibility", () => {
193
+ const withCard = makeController({
194
+ suggestions: { starters: { items: ["Compare plans"] } },
195
+ });
196
+ expect(
197
+ withCard.mount.querySelector('[data-persona-suggestions="starter"]')
198
+ ?.textContent,
199
+ ).toContain("Compare plans");
200
+ withCard.controller.destroy();
201
+
202
+ const withoutCard = makeController({
203
+ copy: { showWelcomeCard: false },
204
+ suggestions: { starters: { items: ["Compare plans"] } },
205
+ });
206
+ expect(
207
+ withoutCard.mount.querySelector('[data-persona-suggestions="starter"]')
208
+ ?.textContent,
209
+ ).not.toContain("Compare plans");
210
+ expect(
211
+ withoutCard.mount.querySelector("[data-persona-composer-suggestions]")
212
+ ?.textContent,
213
+ ).toContain("Compare plans");
214
+ withoutCard.controller.destroy();
215
+ });
216
+
217
+ it("renders nothing and warns in debug when welcome is pinned without the card", () => {
218
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => undefined);
219
+ const { mount, controller } = makeController({
220
+ debug: true,
221
+ copy: { showWelcomeCard: false },
222
+ suggestions: {
223
+ starters: { items: ["Compare plans"], placement: "welcome" },
224
+ },
225
+ });
226
+
227
+ expect(chipButtons(mount, "Compare plans")).toHaveLength(0);
228
+ const placementWarnings = warn.mock.calls.filter((call) =>
229
+ String(call[0]).includes("suggestions.starters.placement"),
230
+ );
231
+ expect(placementWarnings).toHaveLength(1);
232
+
233
+ // Re-rendering the surface must not repeat the warning.
234
+ controller.update({
235
+ copy: { showWelcomeCard: false, welcomeTitle: "Updated" },
236
+ });
237
+ expect(
238
+ warn.mock.calls.filter((call) =>
239
+ String(call[0]).includes("suggestions.starters.placement"),
240
+ ),
241
+ ).toHaveLength(1);
242
+
243
+ controller.destroy();
244
+ });
245
+
246
+ it("honors an explicit composer placement while the welcome card shows", () => {
247
+ const { mount, controller } = makeController({
248
+ suggestions: {
249
+ starters: { items: ["Compare plans"], placement: "composer" },
250
+ },
251
+ });
252
+
253
+ expect(
254
+ mount.querySelector("[data-persona-composer-suggestions]")?.textContent,
255
+ ).toContain("Compare plans");
256
+ expect(
257
+ mount.querySelector('[data-persona-suggestions="starter"]')?.textContent,
258
+ ).not.toContain("Compare plans");
259
+
260
+ controller.destroy();
261
+ });
262
+
263
+ it("places structured follow-ups after the transcript", () => {
264
+ const { mount, controller } = makeController({
265
+ suggestions: {
266
+ followUps: {
267
+ placement: "after-message",
268
+ variant: "list",
269
+ },
270
+ },
271
+ });
272
+ injectUserMessage(controller);
273
+ injectSuggestReplies(controller, {
274
+ suggestions: [
275
+ {
276
+ label: "See examples",
277
+ description: "Browse common implementations",
278
+ },
279
+ ],
280
+ });
281
+
282
+ const transcriptHost = mount.querySelector(
283
+ '[data-persona-suggestions="follow-up"]',
284
+ );
285
+ expect(transcriptHost?.getAttribute("data-variant")).toBe("list");
286
+ expect(transcriptHost?.textContent).toContain("See examples");
287
+ expect(transcriptHost?.textContent).toContain(
288
+ "Browse common implementations",
289
+ );
290
+ expect(
291
+ mount.querySelector("[data-persona-composer-suggestions]")?.textContent,
292
+ ).not.toContain("See examples");
293
+
294
+ controller.destroy();
295
+ });
296
+
297
+ it("fills the composer without sending when behavior is fill", () => {
298
+ global.fetch = vi.fn();
299
+ const { mount, controller } = makeController({
300
+ suggestions: {
301
+ followUps: {
302
+ placement: "composer",
303
+ behavior: "fill",
304
+ },
305
+ },
306
+ });
307
+ injectUserMessage(controller);
308
+ injectSuggestReplies(controller, {
309
+ suggestions: [
310
+ {
311
+ label: "Customize this",
312
+ prompt: "Customize this for my team",
313
+ },
314
+ ],
315
+ });
316
+
317
+ chipButtons(mount, "Customize this")[0]!.click();
318
+
319
+ expect(
320
+ mount.querySelector<HTMLTextAreaElement>("textarea")?.value,
321
+ ).toBe("Customize this for my team");
322
+ expect(global.fetch).not.toHaveBeenCalled();
323
+ expect(
324
+ controller.getMessages().filter((message) => message.role === "user"),
325
+ ).toHaveLength(1);
326
+
327
+ controller.destroy();
328
+ });
329
+
95
330
  it("suppresses the transcript tool bubble for the suggest_replies message", () => {
96
331
  const { mount, controller } = makeController();
97
332
  injectUserMessage(controller);
@@ -181,6 +416,133 @@ describe("suggest_replies chips UI", () => {
181
416
  controller.destroy();
182
417
  });
183
418
 
419
+ it("updates follow-up presentation and placement through live config", () => {
420
+ const { mount, controller } = makeController({
421
+ suggestions: {
422
+ followUps: {
423
+ placement: "after-message",
424
+ variant: "card",
425
+ },
426
+ },
427
+ });
428
+ injectUserMessage(controller);
429
+ injectSuggestReplies(controller);
430
+
431
+ expect(
432
+ mount
433
+ .querySelector('[data-persona-suggestions="follow-up"]')
434
+ ?.getAttribute("data-variant"),
435
+ ).toBe("card");
436
+
437
+ controller.update({
438
+ suggestions: {
439
+ followUps: {
440
+ placement: "composer",
441
+ variant: "list",
442
+ behavior: "fill",
443
+ },
444
+ },
445
+ });
446
+
447
+ const composerSurface = mount.querySelector(
448
+ '[data-persona-composer-suggestions][data-persona-suggestion-surface="follow-up"]',
449
+ );
450
+ expect(composerSurface?.getAttribute("data-variant")).toBe("list");
451
+ const updatedButton = Array.from(
452
+ composerSurface?.querySelectorAll<HTMLButtonElement>("button") ?? [],
453
+ ).find((button) => button.textContent?.includes("Tell me more"));
454
+ updatedButton?.click();
455
+ expect(mount.querySelector<HTMLTextAreaElement>("textarea")?.value).toBe(
456
+ "Tell me more",
457
+ );
458
+
459
+ controller.destroy();
460
+ });
461
+
462
+ it("tracks the visible overflow edges for horizontally scrolling chips", async () => {
463
+ const { mount, controller } = makeController({
464
+ suggestions: {
465
+ followUps: {
466
+ placement: "after-message",
467
+ variant: "chip",
468
+ overflow: "scroll",
469
+ },
470
+ },
471
+ });
472
+ injectUserMessage(controller);
473
+ injectSuggestReplies(controller);
474
+
475
+ const surface = mount.querySelector<HTMLElement>(
476
+ '[data-persona-suggestions="follow-up"]',
477
+ )!;
478
+ Object.defineProperties(surface, {
479
+ clientWidth: { configurable: true, value: 200 },
480
+ scrollWidth: { configurable: true, value: 500 },
481
+ });
482
+ const flushOverflowFrame = () =>
483
+ new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
484
+
485
+ // Scroll snap may settle just inside the 2px visual padding.
486
+ surface.scrollLeft = 2;
487
+ surface.dispatchEvent(new Event("scroll"));
488
+ await flushOverflowFrame();
489
+ expect(surface.hasAttribute("data-scroll-left")).toBe(false);
490
+ expect(surface.hasAttribute("data-scroll-right")).toBe(true);
491
+
492
+ surface.scrollLeft = 150;
493
+ surface.dispatchEvent(new Event("scroll"));
494
+ await flushOverflowFrame();
495
+ expect(surface.hasAttribute("data-scroll-left")).toBe(true);
496
+ expect(surface.hasAttribute("data-scroll-right")).toBe(true);
497
+
498
+ surface.scrollLeft = 300;
499
+ surface.dispatchEvent(new Event("scroll"));
500
+ await flushOverflowFrame();
501
+ expect(surface.hasAttribute("data-scroll-left")).toBe(true);
502
+ expect(surface.hasAttribute("data-scroll-right")).toBe(false);
503
+
504
+ controller.destroy();
505
+ });
506
+
507
+ it("maps horizontal overflow edges correctly in RTL", async () => {
508
+ const { mount, controller } = makeController({
509
+ suggestions: {
510
+ followUps: {
511
+ placement: "after-message",
512
+ variant: "chip",
513
+ overflow: "scroll",
514
+ },
515
+ },
516
+ });
517
+ injectUserMessage(controller);
518
+ injectSuggestReplies(controller);
519
+
520
+ const surface = mount.querySelector<HTMLElement>(
521
+ '[data-persona-suggestions="follow-up"]',
522
+ )!;
523
+ surface.style.direction = "rtl";
524
+ Object.defineProperties(surface, {
525
+ clientWidth: { configurable: true, value: 200 },
526
+ scrollWidth: { configurable: true, value: 500 },
527
+ });
528
+ const flushOverflowFrame = () =>
529
+ new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
530
+
531
+ surface.scrollLeft = 0;
532
+ surface.dispatchEvent(new Event("scroll"));
533
+ await flushOverflowFrame();
534
+ expect(surface.hasAttribute("data-scroll-left")).toBe(true);
535
+ expect(surface.hasAttribute("data-scroll-right")).toBe(false);
536
+
537
+ surface.scrollLeft = -300;
538
+ surface.dispatchEvent(new Event("scroll"));
539
+ await flushOverflowFrame();
540
+ expect(surface.hasAttribute("data-scroll-left")).toBe(false);
541
+ expect(surface.hasAttribute("data-scroll-right")).toBe(true);
542
+
543
+ controller.destroy();
544
+ });
545
+
184
546
  it("renders no chips and falls back to the tool bubble when disabled", () => {
185
547
  const { mount, controller } = makeController({
186
548
  features: { suggestReplies: { enabled: false } },
@@ -234,4 +596,707 @@ describe("suggest_replies chips UI", () => {
234
596
 
235
597
  controller.destroy();
236
598
  });
599
+
600
+ it("dispatches unified suggestion events with surface and behavior metadata", () => {
601
+ const shown: CustomEvent["detail"][] = [];
602
+ const selected: CustomEvent["detail"][] = [];
603
+ document.addEventListener("persona:suggestion:shown", (event) => {
604
+ shown.push((event as CustomEvent).detail);
605
+ });
606
+ document.addEventListener("persona:suggestion:selected", (event) => {
607
+ selected.push((event as CustomEvent).detail);
608
+ });
609
+
610
+ const { mount, controller } = makeController({
611
+ suggestions: {
612
+ starters: {
613
+ behavior: "fill",
614
+ items: [{ id: "draft", label: "Draft a reply" }],
615
+ },
616
+ },
617
+ });
618
+
619
+ chipButtons(mount, "Draft a reply")[0]!.click();
620
+
621
+ expect(shown).toHaveLength(1);
622
+ expect(shown[0]).toMatchObject({
623
+ surface: "starter",
624
+ source: "config",
625
+ variant: "card",
626
+ });
627
+ expect(selected[0]).toMatchObject({
628
+ surface: "starter",
629
+ source: "config",
630
+ behavior: "fill",
631
+ suggestion: { id: "draft", prompt: "Draft a reply" },
632
+ });
633
+
634
+ controller.destroy();
635
+ });
636
+
637
+ it("lets plugins transform starter and follow-up suggestion sets", () => {
638
+ const transformSuggestions = vi.fn<
639
+ NonNullable<AgentWidgetPlugin["transformSuggestions"]>
640
+ >(({ suggestions, surface }) =>
641
+ suggestions.map((suggestion, index) => ({
642
+ ...suggestion,
643
+ id: `${surface}-${index}`,
644
+ label: `${suggestion.label} · curated`,
645
+ description: `Transformed on the ${surface} surface`,
646
+ emphasis: index === 0 ? "primary" : "default",
647
+ })));
648
+ const { mount, controller } = makeController({
649
+ plugins: [{ id: "curate", transformSuggestions }],
650
+ suggestions: {
651
+ starters: {
652
+ items: ["Compare plans", "Browse docs"],
653
+ maxItems: 1,
654
+ },
655
+ },
656
+ });
657
+
658
+ expect(mount.textContent).toContain("Compare plans · curated");
659
+ expect(mount.textContent).toContain(
660
+ "Transformed on the starter surface",
661
+ );
662
+ expect(mount.textContent).not.toContain("Browse docs · curated");
663
+ // Hooks receive normalized items: the string shorthand is already expanded
664
+ // and the surface behavior resolved before the first transform runs.
665
+ expect(transformSuggestions).toHaveBeenCalledWith(
666
+ expect.objectContaining({
667
+ surface: "starter",
668
+ source: "config",
669
+ suggestions: [
670
+ {
671
+ id: "Compare plans",
672
+ label: "Compare plans",
673
+ prompt: "Compare plans",
674
+ emphasis: "default",
675
+ behavior: "send",
676
+ },
677
+ {
678
+ id: "Browse docs",
679
+ label: "Browse docs",
680
+ prompt: "Browse docs",
681
+ emphasis: "default",
682
+ behavior: "send",
683
+ },
684
+ ],
685
+ }),
686
+ );
687
+
688
+ injectUserMessage(controller);
689
+ injectSuggestReplies(controller, { suggestions: ["See examples"] });
690
+ expect(mount.textContent).toContain("See examples · curated");
691
+ expect(transformSuggestions).toHaveBeenLastCalledWith(
692
+ expect.objectContaining({
693
+ surface: "followUp",
694
+ source: "agent",
695
+ suggestions: [
696
+ expect.objectContaining({
697
+ label: "See examples",
698
+ prompt: "See examples",
699
+ behavior: "send",
700
+ }),
701
+ ],
702
+ }),
703
+ );
704
+
705
+ controller.destroy();
706
+ });
707
+
708
+ it("composes suggestion transforms in plugin priority order", () => {
709
+ const append = (
710
+ suffix: string,
711
+ ): NonNullable<AgentWidgetPlugin["transformSuggestions"]> =>
712
+ ({ suggestions }) =>
713
+ suggestions.map((suggestion) => ({
714
+ ...suggestion,
715
+ label: `${suggestion.label} · ${suffix}`,
716
+ }));
717
+ const { mount, controller } = makeController({
718
+ plugins: [
719
+ { id: "low", priority: 0, transformSuggestions: append("low") },
720
+ { id: "high", priority: 10, transformSuggestions: append("high") },
721
+ ],
722
+ suggestions: {
723
+ starters: { items: ["Original"] },
724
+ },
725
+ });
726
+
727
+ expect(mount.textContent).toContain("Original · high · low");
728
+
729
+ controller.destroy();
730
+ });
731
+
732
+ it("re-normalizes string shorthand returned by a transform", () => {
733
+ const shorthand: AgentWidgetPlugin = {
734
+ id: "shorthand",
735
+ transformSuggestions: ({ suggestions }) => [
736
+ ...suggestions,
737
+ "Appended shorthand",
738
+ ],
739
+ };
740
+ const { mount, controller } = makeController({
741
+ plugins: [shorthand],
742
+ suggestions: {
743
+ starters: { items: [{ label: "Configured" }] },
744
+ },
745
+ });
746
+
747
+ const appended = chipButtons(mount, "Appended shorthand")[0];
748
+ expect(appended).toBeDefined();
749
+ expect(appended?.dataset.suggestionId).toBe("Appended shorthand");
750
+ expect(appended?.dataset.behavior).toBe("send");
751
+
752
+ controller.destroy();
753
+ });
754
+
755
+ it("keeps a per-item behavior set by a transform", () => {
756
+ const fillLongPrompts: AgentWidgetPlugin = {
757
+ id: "long-prompts-fill",
758
+ transformSuggestions: ({ suggestions }) =>
759
+ suggestions.map((suggestion) =>
760
+ suggestion.label === "Draft"
761
+ ? { ...suggestion, behavior: "fill" as const }
762
+ : suggestion,
763
+ ),
764
+ };
765
+ const { mount, controller } = makeController({
766
+ plugins: [fillLongPrompts],
767
+ suggestions: {
768
+ starters: { behavior: "send", items: ["Draft", "Send"] },
769
+ },
770
+ });
771
+
772
+ expect(chipButtons(mount, "Draft")[0]?.dataset.behavior).toBe("fill");
773
+ expect(chipButtons(mount, "Send")[0]?.dataset.behavior).toBe("send");
774
+
775
+ chipButtons(mount, "Draft")[0]!.click();
776
+ expect(mount.querySelector<HTMLTextAreaElement>("textarea")?.value).toBe(
777
+ "Draft",
778
+ );
779
+
780
+ controller.destroy();
781
+ });
782
+
783
+ it("caps after the full transform chain, so transforms can add items", () => {
784
+ const expand: AgentWidgetPlugin = {
785
+ id: "expand",
786
+ transformSuggestions: ({ suggestions }) => [
787
+ ...suggestions,
788
+ "Added one",
789
+ "Added two",
790
+ "Beyond the cap",
791
+ ],
792
+ };
793
+ const { mount, controller } = makeController({
794
+ plugins: [expand],
795
+ suggestions: {
796
+ starters: { items: ["Configured"], maxItems: 3 },
797
+ },
798
+ });
799
+
800
+ expect(chipButtons(mount, "Configured")).toHaveLength(1);
801
+ expect(chipButtons(mount, "Added one")).toHaveLength(1);
802
+ expect(chipButtons(mount, "Added two")).toHaveLength(1);
803
+ expect(chipButtons(mount, "Beyond the cap")).toHaveLength(0);
804
+
805
+ controller.destroy();
806
+ });
807
+
808
+ it("hides the surface when a transform returns an empty array", () => {
809
+ const { mount, controller } = makeController({
810
+ plugins: [{ id: "hide-all", transformSuggestions: () => [] }],
811
+ suggestions: {
812
+ starters: { items: ["Compare plans", "Browse docs"] },
813
+ },
814
+ });
815
+
816
+ expect(chipButtons(mount, "Compare plans")).toHaveLength(0);
817
+ const surfaces = Array.from(
818
+ mount.querySelectorAll<HTMLElement>(
819
+ '[data-persona-suggestions="starter"]',
820
+ ),
821
+ );
822
+ expect(surfaces.length).toBeGreaterThan(0);
823
+ expect(surfaces.every((surface) => surface.hidden)).toBe(true);
824
+
825
+ controller.destroy();
826
+ });
827
+
828
+ it("lets a renderSuggestion plugin own the item UI and use select()", () => {
829
+ const renderSuggestion = vi.fn<
830
+ NonNullable<AgentWidgetPlugin["renderSuggestion"]>
831
+ >(({ suggestion, surface, select }) => {
832
+ const button = document.createElement("button");
833
+ button.type = "button";
834
+ button.className = "custom-suggestion";
835
+ button.textContent = `${surface}: ${suggestion.label}`;
836
+ button.addEventListener("click", select);
837
+ return button;
838
+ });
839
+ const { mount, controller } = makeController({
840
+ plugins: [{ id: "custom-suggestion", renderSuggestion }],
841
+ suggestions: {
842
+ starters: {
843
+ behavior: "fill",
844
+ items: [{ id: "draft", label: "Draft a reply" }],
845
+ },
846
+ },
847
+ });
848
+
849
+ const custom = mount.querySelector<HTMLButtonElement>(
850
+ ".custom-suggestion",
851
+ );
852
+ expect(custom?.textContent).toBe("starter: Draft a reply");
853
+ expect(custom?.dataset.suggestionId).toBe("draft");
854
+ custom?.click();
855
+ expect(mount.querySelector<HTMLTextAreaElement>("textarea")?.value).toBe(
856
+ "Draft a reply",
857
+ );
858
+ expect(renderSuggestion).toHaveBeenCalledWith(
859
+ expect.objectContaining({
860
+ index: 0,
861
+ surface: "starter",
862
+ source: "config",
863
+ variant: "card",
864
+ suggestion: expect.objectContaining({
865
+ id: "draft",
866
+ behavior: "fill",
867
+ }),
868
+ defaultRenderer: expect.any(Function),
869
+ select: expect.any(Function),
870
+ }),
871
+ );
872
+
873
+ controller.destroy();
874
+ });
875
+
876
+ it("falls back to the built-in renderer when renderSuggestion returns null", () => {
877
+ const { mount, controller } = makeController({
878
+ plugins: [
879
+ {
880
+ id: "decline-suggestion",
881
+ renderSuggestion: () => null,
882
+ },
883
+ ],
884
+ suggestions: {
885
+ starters: { items: ["Use the default"] },
886
+ },
887
+ });
888
+
889
+ expect(
890
+ chipButtons(mount, "Use the default")[0]?.classList.contains(
891
+ "persona-suggestion--card",
892
+ ),
893
+ ).toBe(true);
894
+
895
+ controller.destroy();
896
+ });
897
+
898
+ it("lets selection hooks and cancelable DOM events prevent the default action", () => {
899
+ const onSuggestionSelect = vi.fn((): boolean => false);
900
+ const { mount, controller } = makeController({
901
+ plugins: [{ id: "selection-guard", onSuggestionSelect }],
902
+ suggestions: {
903
+ starters: {
904
+ behavior: "fill",
905
+ items: ["Plugin guarded", "DOM guarded"],
906
+ },
907
+ },
908
+ });
909
+ const textarea = mount.querySelector<HTMLTextAreaElement>("textarea")!;
910
+
911
+ chipButtons(mount, "Plugin guarded")[0]!.click();
912
+ expect(onSuggestionSelect).toHaveBeenCalledWith(
913
+ expect.objectContaining({
914
+ surface: "starter",
915
+ source: "config",
916
+ variant: "card",
917
+ suggestion: expect.objectContaining({
918
+ label: "Plugin guarded",
919
+ behavior: "fill",
920
+ }),
921
+ }),
922
+ );
923
+ expect(textarea.value).toBe("");
924
+
925
+ onSuggestionSelect.mockReturnValue(true);
926
+ mount.addEventListener(
927
+ "persona:suggestion:selected",
928
+ (event) => event.preventDefault(),
929
+ { once: true },
930
+ );
931
+ chipButtons(mount, "DOM guarded")[0]!.click();
932
+ expect(textarea.value).toBe("");
933
+
934
+ controller.destroy();
935
+ });
936
+ });
937
+
938
+ describe("minimal BYO wire sequence", () => {
939
+ afterEach(() => {
940
+ document.body.innerHTML = "";
941
+ if (typeof localStorage !== "undefined") localStorage.clear();
942
+ vi.restoreAllMocks();
943
+ });
944
+
945
+ // Pins the four-frame sequence documented in UI-COMPONENTS.md: no
946
+ // turn_start, no text frames, no await/resume. Args ride tool_start.
947
+ it("renders chips from execution_start, tool_start, tool_complete, execution_complete", async () => {
948
+ const { mount, controller } = makeController();
949
+ injectUserMessage(controller);
950
+
951
+ await completeStream(controller, [
952
+ { type: "execution_start", executionId: "exec-byo" },
953
+ {
954
+ type: "tool_start",
955
+ executionId: "exec-byo",
956
+ toolCallId: "call_byo_1",
957
+ toolName: SUGGEST_REPLIES_TOOL_NAME,
958
+ toolType: "local",
959
+ parameters: {
960
+ suggestions: [
961
+ { label: "Tell me more" },
962
+ { label: "Show pricing", prompt: "Show me the pricing tiers" },
963
+ ],
964
+ },
965
+ },
966
+ {
967
+ type: "tool_complete",
968
+ executionId: "exec-byo",
969
+ toolCallId: "call_byo_1",
970
+ success: true,
971
+ result: { content: [{ type: "text", text: "Suggestions shown to the user." }] },
972
+ },
973
+ { type: "execution_complete", executionId: "exec-byo" },
974
+ ]);
975
+
976
+ const toolMessage = controller
977
+ .getMessages()
978
+ .find((message) => message.toolCall?.name === SUGGEST_REPLIES_TOOL_NAME);
979
+ expect(toolMessage?.variant).toBe("tool");
980
+ expect(toolMessage?.toolCall?.args).toEqual({
981
+ suggestions: [
982
+ { label: "Tell me more" },
983
+ { label: "Show pricing", prompt: "Show me the pricing tiers" },
984
+ ],
985
+ });
986
+
987
+ const chips = chipButtons(mount, "Tell me more");
988
+ expect(chips).toHaveLength(1);
989
+ expect(chips[0]!.disabled).toBe(false);
990
+ expect(chipButtons(mount, "Show pricing")).toHaveLength(1);
991
+
992
+ controller.destroy();
993
+ });
994
+ });
995
+
996
+ describe("follow-ups never called debug hint", () => {
997
+ afterEach(() => {
998
+ document.body.innerHTML = "";
999
+ if (typeof localStorage !== "undefined") localStorage.clear();
1000
+ vi.restoreAllMocks();
1001
+ });
1002
+
1003
+ const spyHints = () => {
1004
+ const info = vi.spyOn(console, "info").mockImplementation(() => undefined);
1005
+ info.mockClear();
1006
+ return () =>
1007
+ info.mock.calls.filter((call) =>
1008
+ String(call[0]).includes("has not called suggest_replies"),
1009
+ );
1010
+ };
1011
+
1012
+ it("fires once after a stream completes with the default flags", async () => {
1013
+ const hints = spyHints();
1014
+ const { controller } = makeController({ debug: true });
1015
+ injectUserMessage(controller);
1016
+ injectAssistantMessage(controller);
1017
+
1018
+ await completeStream(controller);
1019
+ expect(hints()).toHaveLength(1);
1020
+ expect(String(hints()[0]![0])).toContain("suggestions.followUps.expose");
1021
+
1022
+ await completeStream(controller);
1023
+ expect(hints()).toHaveLength(1);
1024
+
1025
+ controller.destroy();
1026
+ });
1027
+
1028
+ it("does not fire on a restored transcript with no dispatch this session", () => {
1029
+ const hints = spyHints();
1030
+ const { controller } = makeController({ debug: true });
1031
+ injectUserMessage(controller);
1032
+ injectAssistantMessage(controller);
1033
+ injectAssistantMessage(controller, "a2", "2026-06-10T00:00:03.000Z");
1034
+
1035
+ expect(hints()).toHaveLength(0);
1036
+
1037
+ controller.destroy();
1038
+ });
1039
+
1040
+ it("does not fire when the host drives follow-ups itself", async () => {
1041
+ const hints = spyHints();
1042
+ const { controller } = makeController({ debug: true });
1043
+ injectUserMessage(controller);
1044
+ injectAssistantMessage(controller);
1045
+ controller.setFollowUpSuggestions(["Book a demo"]);
1046
+
1047
+ await completeStream(controller);
1048
+ // The overlay expires on the next user message; the hint stays off anyway.
1049
+ injectUserMessage(controller, "u2", "2026-06-10T00:00:04.000Z");
1050
+ await completeStream(controller);
1051
+ expect(hints()).toHaveLength(0);
1052
+
1053
+ controller.destroy();
1054
+ });
1055
+
1056
+ it("does not fire when the widget exposes the tool itself", async () => {
1057
+ const hints = spyHints();
1058
+ const { controller } = makeController({
1059
+ debug: true,
1060
+ suggestions: { followUps: { expose: true } },
1061
+ });
1062
+ injectUserMessage(controller);
1063
+ injectAssistantMessage(controller);
1064
+ await completeStream(controller);
1065
+
1066
+ expect(hints()).toHaveLength(0);
1067
+
1068
+ controller.destroy();
1069
+ });
1070
+
1071
+ it("does not fire once a suggest_replies message exists", async () => {
1072
+ const hints = spyHints();
1073
+ const { controller } = makeController({ debug: true });
1074
+ injectUserMessage(controller);
1075
+ injectSuggestReplies(controller);
1076
+ await completeStream(controller);
1077
+
1078
+ expect(hints()).toHaveLength(0);
1079
+
1080
+ controller.destroy();
1081
+ });
1082
+
1083
+ it("does not fire when the feature is disabled", async () => {
1084
+ const hints = spyHints();
1085
+ const { controller } = makeController({
1086
+ debug: true,
1087
+ suggestions: { followUps: { enabled: false } },
1088
+ });
1089
+ injectUserMessage(controller);
1090
+ injectAssistantMessage(controller);
1091
+ await completeStream(controller);
1092
+
1093
+ expect(hints()).toHaveLength(0);
1094
+
1095
+ controller.destroy();
1096
+ });
1097
+
1098
+ it("does not fire without debug", async () => {
1099
+ const hints = spyHints();
1100
+ const { controller } = makeController();
1101
+ injectUserMessage(controller);
1102
+ injectAssistantMessage(controller);
1103
+ await completeStream(controller);
1104
+
1105
+ expect(hints()).toHaveLength(0);
1106
+
1107
+ controller.destroy();
1108
+ });
1109
+ });
1110
+
1111
+ describe("host-set follow-up suggestions", () => {
1112
+ afterEach(() => {
1113
+ document.body.innerHTML = "";
1114
+ if (typeof localStorage !== "undefined") localStorage.clear();
1115
+ vi.restoreAllMocks();
1116
+ });
1117
+
1118
+ it("renders through the followUps config and reports source host", () => {
1119
+ const transformSuggestions = vi.fn<
1120
+ NonNullable<AgentWidgetPlugin["transformSuggestions"]>
1121
+ >(({ suggestions }) => suggestions);
1122
+ const { mount, controller } = makeController({
1123
+ plugins: [{ id: "spy", transformSuggestions }],
1124
+ suggestions: {
1125
+ followUps: { placement: "after-message", variant: "list" },
1126
+ },
1127
+ });
1128
+ const shown: CustomEvent["detail"][] = [];
1129
+ const legacyShown: string[][] = [];
1130
+ mount.addEventListener("persona:suggestion:shown", (event) => {
1131
+ shown.push((event as CustomEvent).detail);
1132
+ });
1133
+ mount.addEventListener("persona:suggestReplies:shown", (event) => {
1134
+ legacyShown.push((event as CustomEvent).detail.suggestions);
1135
+ });
1136
+
1137
+ injectUserMessage(controller);
1138
+ controller.setFollowUpSuggestions([
1139
+ { label: "Book a demo", description: "Talk to the team" },
1140
+ ]);
1141
+
1142
+ const surface = mount.querySelector(
1143
+ '[data-persona-suggestions="follow-up"]',
1144
+ );
1145
+ expect(surface?.getAttribute("data-variant")).toBe("list");
1146
+ expect(surface?.textContent).toContain("Book a demo");
1147
+ expect(surface?.textContent).toContain("Talk to the team");
1148
+ expect(transformSuggestions).toHaveBeenLastCalledWith(
1149
+ expect.objectContaining({ surface: "followUp", source: "host" }),
1150
+ );
1151
+ expect(shown.at(-1)).toMatchObject({
1152
+ surface: "followUp",
1153
+ source: "host",
1154
+ variant: "list",
1155
+ });
1156
+ expect(legacyShown.at(-1)).toEqual(["Book a demo"]);
1157
+
1158
+ controller.destroy();
1159
+ });
1160
+
1161
+ it("reports source host to the selection hook and legacy event", () => {
1162
+ const onSuggestionSelect = vi.fn((): boolean => false);
1163
+ const { mount, controller } = makeController({
1164
+ plugins: [{ id: "guard", onSuggestionSelect }],
1165
+ suggestions: { followUps: { placement: "composer" } },
1166
+ });
1167
+ const legacySelected: string[] = [];
1168
+ mount.addEventListener("persona:suggestReplies:selected", (event) => {
1169
+ legacySelected.push((event as CustomEvent).detail.suggestion);
1170
+ });
1171
+
1172
+ injectUserMessage(controller);
1173
+ controller.setFollowUpSuggestions(["Show pricing"]);
1174
+ chipButtons(mount, "Show pricing")[0]!.click();
1175
+
1176
+ expect(onSuggestionSelect).toHaveBeenCalledWith(
1177
+ expect.objectContaining({ surface: "followUp", source: "host" }),
1178
+ );
1179
+ expect(legacySelected).toEqual(["Show pricing"]);
1180
+
1181
+ controller.destroy();
1182
+ });
1183
+
1184
+ it("clears on the next user message", () => {
1185
+ const { mount, controller } = makeController();
1186
+ injectUserMessage(controller, "u1");
1187
+ controller.setFollowUpSuggestions(["Keep going"]);
1188
+ expect(chipButtons(mount, "Keep going")).toHaveLength(1);
1189
+
1190
+ injectUserMessage(controller, "u2", "2026-06-10T00:00:02.000Z");
1191
+ expect(chipButtons(mount, "Keep going")).toHaveLength(0);
1192
+
1193
+ controller.destroy();
1194
+ });
1195
+
1196
+ it("clears via clearFollowUpSuggestions and via an empty array", () => {
1197
+ const { mount, controller } = makeController();
1198
+ injectUserMessage(controller);
1199
+
1200
+ controller.setFollowUpSuggestions(["Keep going"]);
1201
+ expect(chipButtons(mount, "Keep going")).toHaveLength(1);
1202
+ controller.clearFollowUpSuggestions();
1203
+ expect(chipButtons(mount, "Keep going")).toHaveLength(0);
1204
+
1205
+ controller.setFollowUpSuggestions(["Keep going"]);
1206
+ expect(chipButtons(mount, "Keep going")).toHaveLength(1);
1207
+ controller.setFollowUpSuggestions([]);
1208
+ expect(chipButtons(mount, "Keep going")).toHaveLength(0);
1209
+
1210
+ controller.destroy();
1211
+ });
1212
+
1213
+ it("overrides agent chips shown at the time of the call", () => {
1214
+ const { mount, controller } = makeController();
1215
+ injectUserMessage(controller);
1216
+ injectSuggestReplies(controller);
1217
+ expect(chipButtons(mount, "Tell me more")).toHaveLength(1);
1218
+
1219
+ controller.setFollowUpSuggestions(["Host wins"]);
1220
+ expect(chipButtons(mount, "Host wins")).toHaveLength(1);
1221
+ expect(chipButtons(mount, "Tell me more")).toHaveLength(0);
1222
+
1223
+ controller.destroy();
1224
+ });
1225
+
1226
+ it("yields to a suggest_replies payload that arrives afterwards", () => {
1227
+ const { mount, controller } = makeController();
1228
+ injectUserMessage(controller);
1229
+ injectSuggestReplies(controller, { id: "sr-1", suggestions: ["First"] });
1230
+ controller.setFollowUpSuggestions(["Host wins"]);
1231
+ expect(chipButtons(mount, "Host wins")).toHaveLength(1);
1232
+
1233
+ injectSuggestReplies(controller, { id: "sr-2", suggestions: ["Newest"] });
1234
+ expect(chipButtons(mount, "Host wins")).toHaveLength(0);
1235
+ expect(chipButtons(mount, "Newest")).toHaveLength(1);
1236
+
1237
+ controller.destroy();
1238
+ });
1239
+
1240
+ it("renders even when the follow-ups feature is disabled", () => {
1241
+ const { mount, controller } = makeController({
1242
+ suggestions: { followUps: { enabled: false } },
1243
+ });
1244
+ injectUserMessage(controller);
1245
+ injectSuggestReplies(controller);
1246
+ expect(chipButtons(mount, "Tell me more")).toHaveLength(0);
1247
+
1248
+ controller.setFollowUpSuggestions(["Still rendered"]);
1249
+ expect(chipButtons(mount, "Still rendered")).toHaveLength(1);
1250
+
1251
+ controller.destroy();
1252
+ });
1253
+
1254
+ it("renders with no suggestions config present", () => {
1255
+ const { mount, controller } = makeController();
1256
+ injectUserMessage(controller);
1257
+ controller.setFollowUpSuggestions(["Defaults apply"]);
1258
+
1259
+ const surface = mount.querySelector(
1260
+ '[data-persona-composer-suggestions][data-persona-suggestion-surface="follow-up"]',
1261
+ );
1262
+ expect(surface?.getAttribute("data-variant")).toBe("chip");
1263
+ expect(surface?.textContent).toContain("Defaults apply");
1264
+
1265
+ controller.destroy();
1266
+ });
1267
+
1268
+ it("keeps items set while the panel is closed and shows them on open", () => {
1269
+ const mount = createMount();
1270
+ const controller = createAgentExperience(mount, {
1271
+ apiUrl: "https://api.example.com/chat",
1272
+ launcher: { enabled: true },
1273
+ suggestionChips: [],
1274
+ } as unknown as Parameters<typeof createAgentExperience>[1]);
1275
+
1276
+ expect(controller.isOpen()).toBe(false);
1277
+ injectUserMessage(controller);
1278
+ controller.setFollowUpSuggestions(["Set while closed"]);
1279
+
1280
+ controller.open();
1281
+ expect(chipButtons(mount, "Set while closed")).toHaveLength(1);
1282
+
1283
+ controller.destroy();
1284
+ });
1285
+
1286
+ it("keeps the overlay out of the session transcript", () => {
1287
+ const { controller } = makeController();
1288
+ injectUserMessage(controller);
1289
+ const before = controller.getMessages();
1290
+
1291
+ controller.setFollowUpSuggestions([
1292
+ { label: "Book a demo", prompt: "Book a demo for my team" },
1293
+ ]);
1294
+
1295
+ expect(controller.getMessages()).toEqual(before);
1296
+ expect(
1297
+ JSON.stringify(controller.getMessages()),
1298
+ ).not.toContain("Book a demo");
1299
+
1300
+ controller.destroy();
1301
+ });
237
1302
  });