@runtypelabs/persona 4.14.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 (81) 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 +241 -16
  14. package/dist/context-mentions.d.ts +241 -16
  15. package/dist/context-mentions.js +11 -8
  16. package/dist/index.cjs +67 -66
  17. package/dist/index.cjs.map +1 -1
  18. package/dist/index.d.cts +301 -41
  19. package/dist/index.d.ts +301 -41
  20. package/dist/index.global.js +59 -56
  21. package/dist/index.global.js.map +1 -1
  22. package/dist/index.js +67 -66
  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 +241 -16
  27. package/dist/smart-dom-reader.d.ts +241 -16
  28. package/dist/theme-editor-preview.cjs +66 -66
  29. package/dist/theme-editor-preview.d.cts +255 -17
  30. package/dist/theme-editor-preview.d.ts +255 -17
  31. package/dist/theme-editor-preview.js +56 -56
  32. package/dist/theme-editor.cjs +5 -5
  33. package/dist/theme-editor.d.cts +242 -17
  34. package/dist/theme-editor.d.ts +242 -17
  35. package/dist/theme-editor.js +7 -7
  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/ask-user-question-tool.ts +10 -7
  43. package/src/components/artifact-preview.ts +1 -1
  44. package/src/components/composer-parts.test.ts +103 -4
  45. package/src/components/composer-parts.ts +53 -15
  46. package/src/components/messages.ts +5 -2
  47. package/src/components/panel.ts +31 -5
  48. package/src/components/pill-composer-builder.test.ts +18 -0
  49. package/src/components/suggestions.test.ts +233 -0
  50. package/src/components/suggestions.ts +463 -81
  51. package/src/components/widget-view.ts +4 -0
  52. package/src/defaults.ts +5 -3
  53. package/src/generated/runtype-openapi-contract.ts +17 -0
  54. package/src/index-core.ts +18 -2
  55. package/src/launcher-global.ts +1 -1
  56. package/src/plugins/types.ts +71 -3
  57. package/src/presets.ts +3 -3
  58. package/src/session.ts +10 -8
  59. package/src/styles/widget-styles.test.ts +162 -0
  60. package/src/styles/widget.css +430 -0
  61. package/src/suggest-replies-tool.test.ts +233 -2
  62. package/src/suggest-replies-tool.ts +115 -39
  63. package/src/theme-editor/preview-utils.test.ts +34 -0
  64. package/src/theme-editor/preview-utils.ts +77 -2
  65. package/src/theme-editor/sections.test.ts +33 -0
  66. package/src/theme-editor/sections.ts +53 -5
  67. package/src/theme-reference.ts +9 -2
  68. package/src/types/theme.ts +32 -0
  69. package/src/types.ts +152 -16
  70. package/src/ui.detached-panel.test.ts +1 -1
  71. package/src/ui.suggest-replies.test.ts +1066 -1
  72. package/src/ui.ts +268 -20
  73. package/src/utils/artifact-file.test.ts +1 -1
  74. package/src/utils/code-generators.test.ts +40 -0
  75. package/src/utils/code-generators.ts +22 -0
  76. package/src/utils/config-merge.test.ts +24 -0
  77. package/src/utils/icons.test.ts +26 -0
  78. package/src/utils/icons.ts +8 -0
  79. package/src/utils/theme.test.ts +79 -0
  80. package/src/utils/tokens.ts +64 -0
  81. package/dist/chunk-MMUPR2JW.js +0 -1
@@ -236,6 +236,418 @@
236
236
  border-radius: var(--persona-button-radius);
237
237
  }
238
238
 
239
+ /* Suggestions ----------------------------------------------------------- */
240
+
241
+ .persona-suggestions {
242
+ --persona-suggestion-icon-size: var(
243
+ --persona-components-suggestion-chip-iconSize,
244
+ 16px
245
+ );
246
+ display: flex;
247
+ align-items: stretch;
248
+ flex-shrink: 0;
249
+ gap: 8px;
250
+ min-width: 0;
251
+ }
252
+
253
+ .persona-suggestions[hidden] {
254
+ display: none !important;
255
+ }
256
+
257
+ .persona-suggestions[data-persona-suggestion-surface="starter"] {
258
+ margin-top: 16px;
259
+ }
260
+
261
+ [data-persona-suggestions="follow-up"] {
262
+ /* The transcript body uses a roomy 24px section gap; pull contextual
263
+ follow-ups closer to the answer they belong to. */
264
+ margin-top: -12px;
265
+ }
266
+
267
+ .persona-suggestions[data-variant="card"] {
268
+ --persona-suggestion-icon-size: var(
269
+ --persona-components-suggestion-card-iconSize,
270
+ 18px
271
+ );
272
+ display: grid;
273
+ grid-template-columns: repeat(auto-fit, minmax(min(100%, 240px), 1fr));
274
+ }
275
+
276
+ /* Welcome-surface policy, not a card property: the cap keeps a wide inline
277
+ panel at two starter columns instead of one stretched row. */
278
+ .persona-suggestions[data-variant="card"][data-persona-suggestion-surface="starter"] {
279
+ max-width: 600px;
280
+ }
281
+
282
+ .persona-suggestions[data-variant="list"] {
283
+ --persona-suggestion-icon-size: var(
284
+ --persona-components-suggestion-list-iconSize,
285
+ 17px
286
+ );
287
+ flex-direction: column;
288
+ }
289
+
290
+ .persona-suggestions[data-variant="chip"][data-overflow="scroll"] {
291
+ --persona-suggestion-scroll-fade-size: 32px;
292
+ flex-wrap: nowrap;
293
+ overflow-x: auto;
294
+ overscroll-behavior-inline: contain;
295
+ scrollbar-width: thin;
296
+ scroll-snap-type: inline proximity;
297
+ padding: 2px;
298
+ }
299
+
300
+ .persona-suggestions[data-variant="chip"][data-overflow="scroll"][data-scroll-right]:not(
301
+ [data-scroll-left]
302
+ ) {
303
+ -webkit-mask-image: linear-gradient(
304
+ to right,
305
+ #000 0,
306
+ #000 calc(100% - var(--persona-suggestion-scroll-fade-size)),
307
+ transparent 100%
308
+ );
309
+ mask-image: linear-gradient(
310
+ to right,
311
+ #000 0,
312
+ #000 calc(100% - var(--persona-suggestion-scroll-fade-size)),
313
+ transparent 100%
314
+ );
315
+ }
316
+
317
+ .persona-suggestions[data-variant="chip"][data-overflow="scroll"][data-scroll-left]:not(
318
+ [data-scroll-right]
319
+ ) {
320
+ -webkit-mask-image: linear-gradient(
321
+ to right,
322
+ transparent 0,
323
+ #000 var(--persona-suggestion-scroll-fade-size),
324
+ #000 100%
325
+ );
326
+ mask-image: linear-gradient(
327
+ to right,
328
+ transparent 0,
329
+ #000 var(--persona-suggestion-scroll-fade-size),
330
+ #000 100%
331
+ );
332
+ }
333
+
334
+ .persona-suggestions[data-variant="chip"][data-overflow="scroll"][data-scroll-left][data-scroll-right] {
335
+ -webkit-mask-image: linear-gradient(
336
+ to right,
337
+ transparent 0,
338
+ #000 var(--persona-suggestion-scroll-fade-size),
339
+ #000 calc(100% - var(--persona-suggestion-scroll-fade-size)),
340
+ transparent 100%
341
+ );
342
+ mask-image: linear-gradient(
343
+ to right,
344
+ transparent 0,
345
+ #000 var(--persona-suggestion-scroll-fade-size),
346
+ #000 calc(100% - var(--persona-suggestion-scroll-fade-size)),
347
+ transparent 100%
348
+ );
349
+ }
350
+
351
+ .persona-suggestions[data-variant="chip"][data-overflow="wrap"] {
352
+ flex-wrap: wrap;
353
+ }
354
+
355
+ .persona-suggestion {
356
+ appearance: none;
357
+ display: flex;
358
+ align-items: center;
359
+ justify-content: flex-start;
360
+ min-width: 0;
361
+ text-align: left;
362
+ font-family: inherit;
363
+ font-weight: 500;
364
+ cursor: pointer;
365
+ transition:
366
+ background-color 140ms ease,
367
+ border-color 140ms ease,
368
+ color 140ms ease,
369
+ box-shadow 140ms ease,
370
+ transform 100ms ease;
371
+ }
372
+
373
+ .persona-suggestion--chip {
374
+ flex: 0 0 auto;
375
+ scroll-snap-align: start;
376
+ min-height: var(--persona-components-suggestion-chip-minHeight, 36px);
377
+ gap: var(--persona-components-suggestion-chip-gap, 0.5rem);
378
+ padding: var(
379
+ --persona-components-suggestion-chip-padding,
380
+ 0.5rem 0.875rem
381
+ );
382
+ border: 1px solid
383
+ var(--persona-components-suggestion-chip-border, var(--persona-border));
384
+ border-radius: var(
385
+ --persona-components-suggestion-chip-borderRadius,
386
+ 9999px
387
+ );
388
+ background: var(
389
+ --persona-components-suggestion-chip-background,
390
+ var(--persona-surface)
391
+ );
392
+ color: var(
393
+ --persona-components-suggestion-chip-foreground,
394
+ var(--persona-text)
395
+ );
396
+ font-size: var(--persona-components-suggestion-chip-fontSize, 0.8125rem);
397
+ line-height: var(--persona-components-suggestion-chip-lineHeight, 1.25);
398
+ }
399
+
400
+ .persona-suggestion--card {
401
+ align-items: flex-start;
402
+ min-height: var(--persona-components-suggestion-card-minHeight, 72px);
403
+ gap: var(--persona-components-suggestion-card-gap, 0.625rem);
404
+ padding: var(--persona-components-suggestion-card-padding, 1rem);
405
+ border: 1px solid
406
+ var(--persona-components-suggestion-card-border, var(--persona-border));
407
+ border-radius: var(
408
+ --persona-components-suggestion-card-borderRadius,
409
+ var(--persona-radius-xl)
410
+ );
411
+ background: var(
412
+ --persona-components-suggestion-card-background,
413
+ var(--persona-surface)
414
+ );
415
+ color: var(
416
+ --persona-components-suggestion-card-foreground,
417
+ var(--persona-text)
418
+ );
419
+ box-shadow: var(--persona-components-suggestion-card-shadow, none);
420
+ font-size: var(--persona-components-suggestion-card-fontSize, 0.875rem);
421
+ line-height: var(--persona-components-suggestion-card-lineHeight, 1.35);
422
+ }
423
+
424
+ .persona-suggestion--list {
425
+ align-items: flex-start;
426
+ width: 100%;
427
+ min-height: var(--persona-components-suggestion-list-minHeight, 44px);
428
+ gap: var(--persona-components-suggestion-list-gap, 0.625rem);
429
+ padding: var(--persona-components-suggestion-list-padding, 0.75rem);
430
+ border: 1px solid
431
+ var(--persona-components-suggestion-list-border, var(--persona-border));
432
+ border-radius: var(
433
+ --persona-components-suggestion-list-borderRadius,
434
+ var(--persona-radius-md)
435
+ );
436
+ background: var(
437
+ --persona-components-suggestion-list-background,
438
+ transparent
439
+ );
440
+ color: var(
441
+ --persona-components-suggestion-list-foreground,
442
+ var(--persona-text)
443
+ );
444
+ font-size: var(--persona-components-suggestion-list-fontSize, 0.875rem);
445
+ line-height: var(--persona-components-suggestion-list-lineHeight, 1.35);
446
+ }
447
+
448
+ .persona-suggestion__icon,
449
+ .persona-suggestion__arrow {
450
+ flex: 0 0 auto;
451
+ }
452
+
453
+ /* The taller viewport box centers the graphic on the label's first line box;
454
+ the icon keeps its size because the width constraint drives `meet` scaling. */
455
+ .persona-suggestion--card .persona-suggestion__icon {
456
+ block-size: calc(
457
+ 1em * var(--persona-components-suggestion-card-lineHeight, 1.35)
458
+ );
459
+ }
460
+
461
+ .persona-suggestion--list .persona-suggestion__icon {
462
+ block-size: calc(
463
+ 1em * var(--persona-components-suggestion-list-lineHeight, 1.35)
464
+ );
465
+ }
466
+
467
+ .persona-suggestion__copy {
468
+ display: flex;
469
+ flex: 1 1 auto;
470
+ min-width: 0;
471
+ flex-direction: column;
472
+ gap: 3px;
473
+ }
474
+
475
+ .persona-suggestion__label {
476
+ overflow-wrap: anywhere;
477
+ }
478
+
479
+ .persona-suggestion__description {
480
+ color: var(--persona-text-muted);
481
+ font-size: 0.75rem;
482
+ font-weight: 400;
483
+ line-height: 1.35;
484
+ }
485
+
486
+ /* Hover-revealed: the arrow is an affordance hint, not standing decoration. */
487
+ .persona-suggestion__arrow {
488
+ margin-left: auto;
489
+ opacity: 0;
490
+ transition: opacity 140ms ease;
491
+ }
492
+
493
+ .persona-suggestion:focus-visible .persona-suggestion__arrow {
494
+ opacity: 0.55;
495
+ }
496
+
497
+ /* Quiet accent, never a solid fill: an emphasized item reads as a sibling of
498
+ the set rather than a CTA among ghost buttons. */
499
+ .persona-suggestion[data-emphasis="primary"] {
500
+ border-color: var(--persona-primary);
501
+ background: transparent;
502
+ background: color-mix(in srgb, var(--persona-primary) 6%, transparent);
503
+ }
504
+
505
+ .persona-suggestion[data-emphasis="primary"] .persona-suggestion__icon {
506
+ color: var(--persona-primary);
507
+ }
508
+
509
+ @media (hover: hover) {
510
+ .persona-suggestion--chip:hover {
511
+ border-color: var(
512
+ --persona-components-suggestion-chip-hoverBorder,
513
+ var(--persona-border)
514
+ );
515
+ background: var(
516
+ --persona-components-suggestion-chip-hoverBackground,
517
+ var(--persona-container)
518
+ );
519
+ color: var(
520
+ --persona-components-suggestion-chip-hoverForeground,
521
+ inherit
522
+ );
523
+ }
524
+
525
+ .persona-suggestion:hover .persona-suggestion__arrow {
526
+ opacity: 0.55;
527
+ }
528
+
529
+ .persona-suggestion--card:hover {
530
+ border-color: var(
531
+ --persona-components-suggestion-card-hoverBorder,
532
+ var(--persona-border)
533
+ );
534
+ background: var(
535
+ --persona-components-suggestion-card-hoverBackground,
536
+ var(--persona-container)
537
+ );
538
+ box-shadow: var(--persona-shadow-sm);
539
+ transform: translateY(-1px);
540
+ }
541
+
542
+ .persona-suggestion--list:hover {
543
+ border-color: var(
544
+ --persona-components-suggestion-list-hoverBorder,
545
+ var(--persona-border)
546
+ );
547
+ background: var(
548
+ --persona-components-suggestion-list-hoverBackground,
549
+ var(--persona-container)
550
+ );
551
+ }
552
+
553
+ .persona-suggestion[data-emphasis="primary"]:hover {
554
+ border-color: var(--persona-primary);
555
+ background: transparent;
556
+ background: color-mix(in srgb, var(--persona-primary) 12%, transparent);
557
+ }
558
+ }
559
+
560
+ .persona-suggestion:focus-visible {
561
+ outline: 2px solid
562
+ var(
563
+ --persona-components-suggestion-chip-focusRing,
564
+ var(--persona-accent)
565
+ );
566
+ outline-offset: 2px;
567
+ }
568
+
569
+ .persona-suggestion--card:focus-visible {
570
+ outline-color: var(
571
+ --persona-components-suggestion-card-focusRing,
572
+ var(--persona-accent)
573
+ );
574
+ }
575
+
576
+ .persona-suggestion--list:focus-visible {
577
+ outline-color: var(
578
+ --persona-components-suggestion-list-focusRing,
579
+ var(--persona-accent)
580
+ );
581
+ }
582
+
583
+ .persona-suggestion--chip:active {
584
+ background: var(
585
+ --persona-components-suggestion-chip-pressedBackground,
586
+ var(--persona-container)
587
+ );
588
+ transform: scale(0.98);
589
+ }
590
+
591
+ .persona-suggestion--card:active {
592
+ background: var(
593
+ --persona-components-suggestion-card-pressedBackground,
594
+ var(--persona-container)
595
+ );
596
+ transform: translateY(1px);
597
+ }
598
+
599
+ .persona-suggestion--list:active {
600
+ background: var(
601
+ --persona-components-suggestion-list-pressedBackground,
602
+ var(--persona-container)
603
+ );
604
+ }
605
+
606
+ .persona-suggestion[data-emphasis="primary"]:active {
607
+ background: transparent;
608
+ background: color-mix(in srgb, var(--persona-primary) 16%, transparent);
609
+ }
610
+
611
+ .persona-suggestion:disabled {
612
+ cursor: not-allowed;
613
+ opacity: var(
614
+ --persona-components-suggestion-chip-disabledOpacity,
615
+ 0.5
616
+ );
617
+ }
618
+
619
+ .persona-suggestion--card:disabled {
620
+ opacity: var(
621
+ --persona-components-suggestion-card-disabledOpacity,
622
+ 0.5
623
+ );
624
+ }
625
+
626
+ .persona-suggestion--list:disabled {
627
+ opacity: var(
628
+ --persona-components-suggestion-list-disabledOpacity,
629
+ 0.5
630
+ );
631
+ }
632
+
633
+ @media (pointer: coarse) {
634
+ .persona-suggestion {
635
+ min-height: 44px;
636
+ }
637
+
638
+ /* No hover to reveal it, so the arrow would be permanent decoration. */
639
+ .persona-suggestion__arrow {
640
+ display: none;
641
+ }
642
+ }
643
+
644
+ @media (prefers-reduced-motion: reduce) {
645
+ .persona-suggestion,
646
+ .persona-suggestion__arrow {
647
+ transition: none;
648
+ }
649
+ }
650
+
239
651
  /* Composer ghost icon buttons (attachment + mention): transparent, neutral
240
652
  foreground, subtle hover. Themeable via `components.button.ghost.*`
241
653
  → `--persona-button-ghost-*`. The `[data-persona-root]` prefix outranks the
@@ -990,6 +1402,24 @@
990
1402
  box-shadow: var(--persona-composer-shadow, none);
991
1403
  }
992
1404
 
1405
+ /* Composer spacing, themeable via `components.composer.padding` / `.gap`.
1406
+ The `[data-persona-root]` prefix outranks the `persona-px-4 persona-py-3
1407
+ persona-gap-2` utilities the form still carries; the fallbacks reproduce
1408
+ them exactly. The pill composer is excluded because it sets its own
1409
+ grid padding/gap (see `.persona-pill-composer`). */
1410
+ [data-persona-root] .persona-widget-composer:not(.persona-pill-composer) {
1411
+ padding: var(--persona-composer-padding, 0.75rem 1rem);
1412
+ gap: var(--persona-composer-gap, 0.5rem);
1413
+ }
1414
+
1415
+ /* Composer type scale, themeable via `components.composer.fontSize` /
1416
+ `.lineHeight`. Outranks the `persona-text-sm` utility on the textarea;
1417
+ fallbacks match it. Applies to the pill composer's textarea too. */
1418
+ [data-persona-root] .persona-composer-textarea {
1419
+ font-size: var(--persona-composer-font-size, 0.875rem);
1420
+ line-height: var(--persona-composer-line-height, 1.25rem);
1421
+ }
1422
+
993
1423
  .persona-form-grid {
994
1424
  display: flex;
995
1425
  flex-direction: column;
@@ -6,6 +6,7 @@ import {
6
6
  SUGGEST_REPLIES_TOOL_NAME,
7
7
  latestAgentSuggestions,
8
8
  parseSuggestRepliesPayload,
9
+ resolveFollowUpsFeature,
9
10
  } from "./suggest-replies-tool";
10
11
  import {
11
12
  ASK_USER_QUESTION_CLIENT_TOOL,
@@ -26,14 +27,27 @@ describe("SUGGEST_REPLIES_CLIENT_TOOL definition", () => {
26
27
  expect(SUGGEST_REPLIES_CLIENT_TOOL.annotations?.readOnlyHint).toBe(true);
27
28
  });
28
29
 
29
- it("bounds suggestions to 1-4 short strings", () => {
30
+ it("bounds suggestions to semantic-only object items", () => {
30
31
  const suggestions =
31
32
  SUGGEST_REPLIES_PARAMETERS_SCHEMA.properties.suggestions;
32
33
  expect(suggestions.minItems).toBe(1);
33
34
  expect(suggestions.maxItems).toBe(SUGGEST_REPLIES_MAX);
34
- expect(suggestions.items.maxLength).toBe(60);
35
+ expect(suggestions.items.type).toBe("object");
36
+ expect(suggestions.items.required).toEqual(["label"]);
37
+ expect(suggestions.items.additionalProperties).toBe(false);
35
38
  expect(SUGGEST_REPLIES_PARAMETERS_SCHEMA.required).toEqual(["suggestions"]);
36
39
  });
40
+
41
+ it("advertises no oneOf union and no presentation fields", () => {
42
+ const items = SUGGEST_REPLIES_PARAMETERS_SCHEMA.properties.suggestions
43
+ .items as Record<string, unknown>;
44
+ expect(items.oneOf).toBeUndefined();
45
+ expect(Object.keys(items.properties as Record<string, unknown>)).toEqual([
46
+ "label",
47
+ "prompt",
48
+ "description",
49
+ ]);
50
+ });
37
51
  });
38
52
 
39
53
  describe("parseSuggestRepliesPayload", () => {
@@ -55,6 +69,44 @@ describe("parseSuggestRepliesPayload", () => {
55
69
  ).toEqual(["A", "B"]);
56
70
  });
57
71
 
72
+ it("parses rich items while dropping malformed objects", () => {
73
+ expect(
74
+ parseSuggestRepliesPayload({
75
+ suggestions: [
76
+ {
77
+ label: " Pricing ",
78
+ prompt: " Tell me about pricing ",
79
+ description: " Compare plans ",
80
+ },
81
+ { label: " " },
82
+ { nope: true },
83
+ ],
84
+ }),
85
+ ).toEqual([
86
+ {
87
+ label: "Pricing",
88
+ prompt: "Tell me about pricing",
89
+ description: "Compare plans",
90
+ },
91
+ ]);
92
+ });
93
+
94
+ it("strips presentation fields a model may send anyway", () => {
95
+ expect(
96
+ parseSuggestRepliesPayload({
97
+ suggestions: [
98
+ {
99
+ id: "pricing",
100
+ label: "Pricing",
101
+ icon: "dollar-sign",
102
+ behavior: "fill",
103
+ emphasis: "primary",
104
+ },
105
+ ],
106
+ }),
107
+ ).toEqual([{ label: "Pricing" }]);
108
+ });
109
+
58
110
  it("truncates past the cap with a console warning", () => {
59
111
  const warn = vi.spyOn(console, "warn").mockImplementation(() => undefined);
60
112
  const result = parseSuggestRepliesPayload({
@@ -145,6 +197,172 @@ describe("latestAgentSuggestions", () => {
145
197
  });
146
198
  });
147
199
 
200
+ describe("resolveFollowUpsFeature", () => {
201
+ const cfg = (config: Partial<AgentWidgetConfig>): AgentWidgetConfig =>
202
+ config as AgentWidgetConfig;
203
+
204
+ it("defaults to enabled and not exposed", () => {
205
+ expect(resolveFollowUpsFeature(undefined)).toEqual({
206
+ enabled: true,
207
+ expose: false,
208
+ });
209
+ expect(resolveFollowUpsFeature(cfg({}))).toEqual({
210
+ enabled: true,
211
+ expose: false,
212
+ });
213
+ });
214
+
215
+ it("reads each key from suggestions.followUps", () => {
216
+ expect(
217
+ resolveFollowUpsFeature(
218
+ cfg({ suggestions: { followUps: { expose: true } } }),
219
+ ),
220
+ ).toEqual({ enabled: true, expose: true });
221
+ expect(
222
+ resolveFollowUpsFeature(
223
+ cfg({ suggestions: { followUps: { enabled: false } } }),
224
+ ),
225
+ ).toEqual({ enabled: false, expose: false });
226
+ });
227
+
228
+ it("falls back per key to the deprecated features.suggestReplies alias", () => {
229
+ expect(
230
+ resolveFollowUpsFeature(
231
+ cfg({
232
+ features: { suggestReplies: { enabled: true, expose: true } },
233
+ suggestions: { followUps: { variant: "card" } },
234
+ }),
235
+ ),
236
+ ).toEqual({ enabled: true, expose: true });
237
+ // New home supplies `expose`, alias still supplies `enabled`.
238
+ expect(
239
+ resolveFollowUpsFeature(
240
+ cfg({
241
+ features: { suggestReplies: { enabled: false } },
242
+ suggestions: { followUps: { expose: true } },
243
+ }),
244
+ ),
245
+ ).toEqual({ enabled: false, expose: false });
246
+ });
247
+
248
+ it("does not silently re-enable when only presentation keys are added", () => {
249
+ expect(
250
+ resolveFollowUpsFeature(
251
+ cfg({
252
+ features: { suggestReplies: { enabled: false } },
253
+ suggestions: { followUps: { variant: "card" } },
254
+ }),
255
+ ),
256
+ ).toEqual({ enabled: false, expose: false });
257
+ });
258
+
259
+ it("lets suggestions.followUps win on conflict", () => {
260
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => undefined);
261
+ expect(
262
+ resolveFollowUpsFeature(
263
+ cfg({
264
+ features: { suggestReplies: { enabled: false, expose: false } },
265
+ suggestions: { followUps: { enabled: true, expose: true } },
266
+ }),
267
+ ),
268
+ ).toEqual({ enabled: true, expose: true });
269
+ expect(
270
+ resolveFollowUpsFeature(
271
+ cfg({
272
+ features: { suggestReplies: { enabled: true, expose: true } },
273
+ suggestions: { followUps: { enabled: false } },
274
+ }),
275
+ ),
276
+ ).toEqual({ enabled: false, expose: false });
277
+ warn.mockRestore();
278
+ });
279
+
280
+ it("forces expose off when enabled resolves false", () => {
281
+ expect(
282
+ resolveFollowUpsFeature(
283
+ cfg({
284
+ suggestions: { followUps: { enabled: false, expose: true } },
285
+ }),
286
+ ),
287
+ ).toEqual({ enabled: false, expose: false });
288
+ });
289
+
290
+ });
291
+
292
+ describe("resolveFollowUpsFeature conflict warnings", () => {
293
+ const cfg = (config: Partial<AgentWidgetConfig>): AgentWidgetConfig =>
294
+ config as AgentWidgetConfig;
295
+
296
+ // The dedupe Set is module-level, so a shared conflict signature would let
297
+ // one test mask another. Each case gets a fresh module.
298
+ const freshResolve = async () => {
299
+ vi.resetModules();
300
+ return (await import("./suggest-replies-tool")).resolveFollowUpsFeature;
301
+ };
302
+
303
+ it("warns once per key without debug", async () => {
304
+ const resolve = await freshResolve();
305
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => undefined);
306
+
307
+ const production = cfg({
308
+ features: { suggestReplies: { enabled: true, expose: false } },
309
+ suggestions: { followUps: { enabled: false, expose: true } },
310
+ });
311
+ resolve(production);
312
+ resolve(production);
313
+ expect(warn).toHaveBeenCalledTimes(2);
314
+ expect(warn.mock.calls.map((call) => String(call[0]))).toEqual([
315
+ expect.stringContaining("suggestions.followUps.enabled"),
316
+ expect.stringContaining("suggestions.followUps.expose"),
317
+ ]);
318
+
319
+ warn.mockRestore();
320
+ });
321
+
322
+ it("warns with debug on too", async () => {
323
+ const resolve = await freshResolve();
324
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => undefined);
325
+
326
+ const debug = cfg({
327
+ debug: true,
328
+ features: { suggestReplies: { enabled: true, expose: false } },
329
+ suggestions: { followUps: { enabled: false, expose: true } },
330
+ });
331
+ resolve(debug);
332
+ resolve(debug);
333
+ expect(warn).toHaveBeenCalledTimes(2);
334
+
335
+ warn.mockRestore();
336
+ });
337
+
338
+ it("stays silent when a rebuilt config object repeats the same conflict", async () => {
339
+ const resolve = await freshResolve();
340
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => undefined);
341
+ const conflicting = () =>
342
+ cfg({
343
+ features: { suggestReplies: { enabled: true } },
344
+ suggestions: { followUps: { enabled: false } },
345
+ });
346
+
347
+ // `controller.update()` rebuilds the config object on every call.
348
+ resolve(conflicting());
349
+ resolve(conflicting());
350
+ resolve(conflicting());
351
+ expect(warn).toHaveBeenCalledTimes(1);
352
+
353
+ // A different conflict is still a distinct developer error.
354
+ resolve(
355
+ cfg({
356
+ features: { suggestReplies: { expose: true } },
357
+ suggestions: { followUps: { expose: false } },
358
+ }),
359
+ );
360
+ expect(warn).toHaveBeenCalledTimes(2);
361
+
362
+ warn.mockRestore();
363
+ });
364
+ });
365
+
148
366
  describe("builtInClientToolsForDispatch - suggest_replies gating", () => {
149
367
  it("returns nothing by default (expose is opt-in)", () => {
150
368
  expect(builtInClientToolsForDispatch(undefined)).toEqual([]);
@@ -173,6 +391,19 @@ describe("builtInClientToolsForDispatch - suggest_replies gating", () => {
173
391
  ).toEqual([]);
174
392
  });
175
393
 
394
+ it("reads expose from suggestions.followUps too", () => {
395
+ expect(
396
+ builtInClientToolsForDispatch({
397
+ suggestions: { followUps: { expose: true } },
398
+ } as AgentWidgetConfig),
399
+ ).toEqual([SUGGEST_REPLIES_CLIENT_TOOL]);
400
+ expect(
401
+ builtInClientToolsForDispatch({
402
+ suggestions: { followUps: { expose: true, enabled: false } },
403
+ } as AgentWidgetConfig),
404
+ ).toEqual([]);
405
+ });
406
+
176
407
  it("composes with ask_user_question, ask first", () => {
177
408
  expect(
178
409
  builtInClientToolsForDispatch({