@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
@@ -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({
@@ -2,14 +2,13 @@
2
2
  * Built-in `suggest_replies` client tool.
3
3
  *
4
4
  * The widget can advertise this tool to the agent on every dispatch via
5
- * `clientTools[]` (set `features.suggestReplies.expose: true`): the same
5
+ * `clientTools[]` (set `suggestions.followUps.expose: true`): the same
6
6
  * wire surface as `ask_user_question` and WebMCP page tools. When the model
7
- * calls it, the execution pauses with a `step_await`
8
- * (`awaitReason: "local_tool_required"`); unlike `ask_user_question`, the
9
- * widget resolves it FIRE-AND-FORGET: it renders the suggestions as tappable
10
- * chips above the composer and immediately resumes the execution with a
11
- * canned "shown" result, so the agent's turn completes without waiting on the
12
- * user. Tapping a chip sends its text verbatim as the user's next message.
7
+ * calls it, the execution pauses with a `step_await` (a local-tool pause
8
+ * carries no `awaitReason`; that discriminator is internal). Unlike
9
+ * `ask_user_question`, the widget resolves it FIRE-AND-FORGET: it renders the
10
+ * configured follow-up surface and immediately resumes the execution with a
11
+ * canned "shown" result, so the turn completes without waiting on the user.
13
12
  *
14
13
  * Chip visibility is DERIVED state, not imperative show/hide: the chips of
15
14
  * the last `suggest_replies` tool message with no user message after it are
@@ -21,6 +20,7 @@
21
20
  import type {
22
21
  AgentWidgetConfig,
23
22
  AgentWidgetMessage,
23
+ AgentWidgetSuggestion,
24
24
  ClientToolDefinition,
25
25
  } from "./types";
26
26
 
@@ -30,9 +30,10 @@ export const SUGGEST_REPLIES_TOOL_NAME = "suggest_replies";
30
30
  export const SUGGEST_REPLIES_MAX = 4;
31
31
 
32
32
  /**
33
- * JSON Schema for the tool's parameters. Mirrors what
34
- * {@link parseSuggestRepliesPayload} hydrates the chips from, so the schema
35
- * the model is held to and the shape the renderer expects can never drift.
33
+ * JSON Schema for the tool's parameters. The model owns semantics only: id,
34
+ * icon, behavior, and emphasis are presentation, and hosts set them with the
35
+ * `transformSuggestions` plugin hook. A single object type is used because
36
+ * strict structured-output modes reject or flatten `oneOf`.
36
37
  */
37
38
  export const SUGGEST_REPLIES_PARAMETERS_SCHEMA = {
38
39
  type: "object",
@@ -43,7 +44,16 @@ export const SUGGEST_REPLIES_PARAMETERS_SCHEMA = {
43
44
  maxItems: SUGGEST_REPLIES_MAX,
44
45
  description:
45
46
  "1-4 short, distinct follow-up replies, phrased in the user's voice.",
46
- items: { type: "string", minLength: 1, maxLength: 60 },
47
+ items: {
48
+ type: "object",
49
+ properties: {
50
+ label: { type: "string", minLength: 1, maxLength: 80 },
51
+ prompt: { type: "string", minLength: 1, maxLength: 500 },
52
+ description: { type: "string", minLength: 1, maxLength: 160 },
53
+ },
54
+ required: ["label"],
55
+ additionalProperties: false,
56
+ },
47
57
  },
48
58
  },
49
59
  required: ["suggestions"],
@@ -52,7 +62,7 @@ export const SUGGEST_REPLIES_PARAMETERS_SCHEMA = {
52
62
 
53
63
  /**
54
64
  * The `ClientToolDefinition` shipped on `dispatch.clientTools[]` when
55
- * `features.suggestReplies.expose` is on. Exported so integrators who prefer
65
+ * `suggestions.followUps.expose` is on. Exported so integrators who prefer
56
66
  * declaring the tool server-side (a flow's `runtimeTools`) can reuse the same
57
67
  * description and schema instead of hand-writing them.
58
68
  */
@@ -60,12 +70,15 @@ export const SUGGEST_REPLIES_CLIENT_TOOL: ClientToolDefinition = {
60
70
  name: SUGGEST_REPLIES_TOOL_NAME,
61
71
  description:
62
72
  "Offer the user tappable quick-reply suggestions for their next message. " +
63
- "Call at most once per turn, as the LAST action after your reply text is " +
64
- "complete. Each suggestion is sent verbatim as the user's next message, " +
65
- 'so phrase suggestions in the user\'s voice (e.g. "Tell me more about ' +
66
- 'pricing"). Keep them short and distinct. The result only confirms the ' +
67
- "suggestions were shown: do not add further commentary after calling " +
68
- "this tool; end your turn.",
73
+ "Call at most once per turn, as the last action after your reply text is " +
74
+ "complete. Give each suggestion a `label` with the short visible text, an " +
75
+ "optional `prompt` with the fuller text placed as the user's message " +
76
+ "(defaults to the label), and an optional `description` with one line of " +
77
+ "supporting copy. Suggestions are sent as the user's next message, so " +
78
+ 'phrase prompts in the user\'s voice (e.g. "Tell me more about pricing"). ' +
79
+ "Keep them short and distinct. The result only confirms the suggestions " +
80
+ "were shown: do not add further commentary after calling this tool; end " +
81
+ "your turn.",
69
82
  parametersSchema: SUGGEST_REPLIES_PARAMETERS_SCHEMA,
70
83
  origin: "sdk",
71
84
  annotations: { readOnlyHint: true },
@@ -90,11 +103,15 @@ export const isSuggestRepliesMessage = (
90
103
  message.toolCall?.name === SUGGEST_REPLIES_TOOL_NAME;
91
104
 
92
105
  /**
93
- * Tolerant parse of a `suggest_replies` tool call's args into chip labels:
94
- * accepts a JSON string or object, coerces items to trimmed strings, drops
95
- * empties, and truncates past the renderer cap with a console warning.
106
+ * Tolerant parse of a `suggest_replies` tool call's args into suggestion
107
+ * items. Plain strings stay strings for older flows that emit
108
+ * `suggestions: string[]`; objects keep only label, prompt, and description,
109
+ * so a model that ignores `additionalProperties` cannot set presentation.
110
+ * Hosts add id, icon, behavior, and emphasis via `transformSuggestions`.
96
111
  */
97
- export const parseSuggestRepliesPayload = (args: unknown): string[] => {
112
+ export const parseSuggestRepliesPayload = (
113
+ args: unknown
114
+ ): AgentWidgetSuggestion[] => {
98
115
  let parsed: unknown = args;
99
116
  if (typeof parsed === "string") {
100
117
  try {
@@ -106,17 +123,32 @@ export const parseSuggestRepliesPayload = (args: unknown): string[] => {
106
123
  const raw = (parsed as { suggestions?: unknown } | null | undefined)
107
124
  ?.suggestions;
108
125
  if (!Array.isArray(raw)) return [];
109
- const chips = raw
110
- .filter((item): item is string => typeof item === "string")
111
- .map((item) => item.trim())
112
- .filter((item) => item.length > 0);
113
- if (chips.length > SUGGEST_REPLIES_MAX) {
126
+ const suggestions = raw.flatMap<AgentWidgetSuggestion>((item) => {
127
+ if (typeof item === "string") {
128
+ const value = item.trim();
129
+ return value ? [value] : [];
130
+ }
131
+ if (!item || typeof item !== "object") return [];
132
+ const value = item as Record<string, unknown>;
133
+ if (typeof value.label !== "string" || !value.label.trim()) return [];
134
+ const suggestion: Exclude<AgentWidgetSuggestion, string> = {
135
+ label: value.label.trim(),
136
+ };
137
+ if (typeof value.prompt === "string" && value.prompt.trim()) {
138
+ suggestion.prompt = value.prompt.trim();
139
+ }
140
+ if (typeof value.description === "string" && value.description.trim()) {
141
+ suggestion.description = value.description.trim();
142
+ }
143
+ return [suggestion];
144
+ });
145
+ if (suggestions.length > SUGGEST_REPLIES_MAX) {
114
146
  console.warn(
115
- `[persona] suggest_replies: ${chips.length} suggestions exceeds the cap of ${SUGGEST_REPLIES_MAX}; extra suggestions dropped.`,
147
+ `[persona] suggest_replies: ${suggestions.length} suggestions exceeds the cap of ${SUGGEST_REPLIES_MAX}; extra suggestions dropped.`,
116
148
  );
117
- return chips.slice(0, SUGGEST_REPLIES_MAX);
149
+ return suggestions.slice(0, SUGGEST_REPLIES_MAX);
118
150
  }
119
- return chips;
151
+ return suggestions;
120
152
  };
121
153
 
122
154
  /**
@@ -127,7 +159,7 @@ export const parseSuggestRepliesPayload = (args: unknown): string[] => {
127
159
  */
128
160
  export const latestAgentSuggestions = (
129
161
  messages: AgentWidgetMessage[],
130
- ): string[] | null => {
162
+ ): AgentWidgetSuggestion[] | null => {
131
163
  for (let i = messages.length - 1; i >= 0; i--) {
132
164
  const message = messages[i];
133
165
  if (message.role === "user") return null;
@@ -138,15 +170,59 @@ export const latestAgentSuggestions = (
138
170
  return null;
139
171
  };
140
172
 
173
+ export type ResolvedFollowUpsFeature = {
174
+ /** Widget renders follow-up chips and auto-resumes the tool call. */
175
+ enabled: boolean;
176
+ /** Tool ships on `dispatch.clientTools[]`. */
177
+ expose: boolean;
178
+ };
179
+
180
+ // Keyed on the conflict itself, not the config object: `controller.update()`
181
+ // rebuilds the config on every call, so an object-keyed dedupe re-warns
182
+ // forever. Trade-off: two widgets with the identical conflict warn once total
183
+ // per page load.
184
+ const conflictWarnings = new Set<string>();
185
+
186
+ /** Always warns, debug or not: a config conflict is a developer error. */
187
+ const warnConflict = (
188
+ key: "enabled" | "expose",
189
+ legacyValue: boolean,
190
+ newValue: boolean,
191
+ ): void => {
192
+ const signature = `${key}:${legacyValue}:${newValue}`;
193
+ if (conflictWarnings.has(signature)) return;
194
+ conflictWarnings.add(signature);
195
+ console.warn(
196
+ `[persona] suggestions.followUps.${key} and features.suggestReplies.${key} disagree; suggestions.followUps.${key} wins.`,
197
+ );
198
+ };
199
+
141
200
  /**
142
- * Gate for advertising the tool: `expose` opts it into the agent's catalog,
143
- * and `enabled !== false` guarantees the widget will actually auto-resolve
144
- * and render chips for it: exposing the tool with the feature disabled
145
- * would park the execution on a generic tool bubble with no resume coming.
201
+ * Single source of truth for the follow-ups capability flags. Resolution is
202
+ * per key so a `features.suggestReplies` embed that adds presentation-only
203
+ * `suggestions.followUps` keys is not silently re-enabled. `expose` is forced
204
+ * off when `enabled` resolves false: advertising the tool without the widget
205
+ * auto-resolving it parks the execution on a generic tool bubble.
146
206
  */
147
- export const shouldExposeSuggestReplies = (
207
+ export const resolveFollowUpsFeature = (
148
208
  config: AgentWidgetConfig | undefined,
149
- ): boolean => {
150
- const feature = config?.features?.suggestReplies;
151
- return feature?.expose === true && feature.enabled !== false;
209
+ ): ResolvedFollowUpsFeature => {
210
+ const followUps = config?.suggestions?.followUps;
211
+ const legacy = config?.features?.suggestReplies;
212
+ const warnIfConflicting = (key: "enabled" | "expose"): void => {
213
+ const newValue = followUps?.[key];
214
+ const legacyValue = legacy?.[key];
215
+ if (newValue === undefined || legacyValue === undefined) return;
216
+ if (newValue === legacyValue) return;
217
+ warnConflict(key, legacyValue, newValue);
218
+ };
219
+ if (config) {
220
+ warnIfConflicting("enabled");
221
+ warnIfConflicting("expose");
222
+ }
223
+ const enabled = followUps?.enabled ?? legacy?.enabled ?? true;
224
+ const expose = enabled
225
+ ? followUps?.expose ?? legacy?.expose ?? false
226
+ : false;
227
+ return { enabled, expose };
152
228
  };
@@ -43,6 +43,8 @@ export {
43
43
  COMPONENT_COLOR_SECTIONS,
44
44
  CONFIGURE_SECTIONS,
45
45
  CONFIGURE_SUB_GROUPS,
46
+ STREAM_AND_ARTIFACTS_SUB_GROUP,
47
+ ARTIFACT_DISPLAY_PREFERENCE_SECTION,
46
48
  ALL_TABS,
47
49
  scopeSection,
48
50
  findSection,
@@ -90,6 +92,9 @@ export type {
90
92
  TranscriptStreamFrame,
91
93
  BuildTranscriptStreamFramesOptions,
92
94
  } from './preview-utils';
95
+ // Type-only: the full preview implementation stays in
96
+ // `@runtypelabs/persona/theme-editor/preview`.
97
+ export type { CompareMode, PreviewShellMode } from './preview';
93
98
 
94
99
  // Role mappings (Interface Roles editor)
95
100
  export {
@@ -0,0 +1,43 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import type { AgentWidgetConfig } from '../types';
3
+ import { getActiveTheme, themeToCssVariables } from '../utils/theme';
4
+ import { BUILT_IN_PRESETS, getThemeEditorPreset } from './presets';
5
+
6
+ describe('theme editor presets', () => {
7
+ it('offers the paired Persona default before the single-scheme presets', () => {
8
+ const preset = getThemeEditorPreset('persona-default');
9
+
10
+ expect(BUILT_IN_PRESETS[0]).toBe(preset);
11
+ expect(preset).toMatchObject({
12
+ name: 'Persona Default',
13
+ tags: ['light', 'dark', 'adaptive'],
14
+ preview: { surface: '#ffffff' },
15
+ darkPreview: { surface: '#1f2937' },
16
+ });
17
+ expect(preset?.darkTheme).toBeDefined();
18
+ });
19
+
20
+ it('resolves the paired default to distinct light and dark surfaces', () => {
21
+ const preset = getThemeEditorPreset('persona-default')!;
22
+ const config = {
23
+ theme: preset.theme,
24
+ darkTheme: preset.darkTheme,
25
+ } as AgentWidgetConfig;
26
+
27
+ const light = themeToCssVariables(getActiveTheme({ ...config, colorScheme: 'light' }));
28
+ const dark = themeToCssVariables(getActiveTheme({ ...config, colorScheme: 'dark' }));
29
+
30
+ expect(light).toMatchObject({
31
+ '--persona-surface': '#f9fafb',
32
+ '--persona-background': '#f9fafb',
33
+ '--persona-text': '#111827',
34
+ '--persona-border': '#e5e7eb',
35
+ });
36
+ expect(dark).toMatchObject({
37
+ '--persona-surface': '#1f2937',
38
+ '--persona-background': '#111827',
39
+ '--persona-text': '#f3f4f6',
40
+ '--persona-border': '#374151',
41
+ });
42
+ });
43
+ });