@runtypelabs/persona 4.32.1 → 4.33.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 (165) hide show
  1. package/dist/activity-ui.d.cts +3 -3
  2. package/dist/activity-ui.d.ts +3 -3
  3. package/dist/animations/glyph-cycle.d.cts +1 -1
  4. package/dist/animations/glyph-cycle.d.ts +1 -1
  5. package/dist/animations/wipe.d.cts +1 -1
  6. package/dist/animations/wipe.d.ts +1 -1
  7. package/dist/approval-ui.d.cts +2 -2
  8. package/dist/approval-ui.d.ts +2 -2
  9. package/dist/artifacts-ui.d.cts +3 -3
  10. package/dist/artifacts-ui.d.ts +3 -3
  11. package/dist/background-runtime.d.cts +1 -1
  12. package/dist/background-runtime.d.ts +1 -1
  13. package/dist/{chunk-BbXc58OA.js → chunk-6CUrH_jm.js} +1 -1
  14. package/dist/{chunk-B_H0l3u2.js → chunk-BRM3bHcJ.js} +1 -1
  15. package/dist/chunk-CEPM3FOp.js +1 -0
  16. package/dist/{chunk-CumoXK3u.js → chunk-CXQJG7pb.js} +1 -1
  17. package/dist/{chunk-UWMfWW2a.js → chunk-D1G8tgYc.js} +1 -1
  18. package/dist/{chunk-BpjtKnGI.js → chunk-D6U0E82p.js} +1 -1
  19. package/dist/{chunk-2FnmZ9AA.js → chunk-DOZmcve8.js} +1 -1
  20. package/dist/chunk-DVgzoVCB.js +1 -0
  21. package/dist/{chunk-C9uOhfk5.js → chunk-DW7Vg_l2.js} +1 -1
  22. package/dist/{chunk-B3l7TOOv.js → chunk-DbGLjU2P.js} +1 -1
  23. package/dist/{chunk-DTGvDx8o.js → chunk-Dj2a9f9w.js} +1 -1
  24. package/dist/{chunk-DgZZqy14.js → chunk-d54BJdKd.js} +1 -1
  25. package/dist/chunk-dLJWKfOb.js +1 -0
  26. package/dist/{chunk-DNdeIFJ6.js → chunk-gu34lr-P.js} +1 -1
  27. package/dist/chunk-i4qGOJAL.js +1 -0
  28. package/dist/{chunk-DVZ8ri1z.js → chunk-ql5N2I_t.js} +1 -1
  29. package/dist/client-dispatch.js +1 -1
  30. package/dist/client-history.js +1 -1
  31. package/dist/codegen.cjs +1 -1
  32. package/dist/codegen.js +1 -1
  33. package/dist/composer-actions-runtime.d.cts +2 -2
  34. package/dist/composer-actions-runtime.d.ts +2 -2
  35. package/dist/{composer-input-dwg_o9Z-.d.cts → composer-input-DnqGCXZz.d.cts} +1 -1
  36. package/dist/{composer-input-dwg_o9Z-.d.ts → composer-input-DnqGCXZz.d.ts} +1 -1
  37. package/dist/{composer-store-Dw7Zbqxr.d.cts → composer-store-6Iwy6WLI.d.cts} +1 -1
  38. package/dist/{composer-store-Dw7Zbqxr.d.ts → composer-store-6Iwy6WLI.d.ts} +1 -1
  39. package/dist/{context-mention-manager-D2p0-JH-.d.cts → context-mention-manager-B-rHF98n.d.cts} +2 -2
  40. package/dist/{context-mention-manager-D2p0-JH-.d.ts → context-mention-manager-B-rHF98n.d.ts} +2 -2
  41. package/dist/context-mentions-inline.d.cts +2 -2
  42. package/dist/context-mentions-inline.d.ts +2 -2
  43. package/dist/context-mentions.d.cts +3 -3
  44. package/dist/context-mentions.d.ts +3 -3
  45. package/dist/{defaults-CjX0kKzb.d.ts → defaults-BuOjqUAG.d.cts} +2 -1
  46. package/dist/{defaults-CjX0kKzb.d.cts → defaults-BuOjqUAG.d.ts} +2 -1
  47. package/dist/dictation-runtime.d.cts +2 -2
  48. package/dist/dictation-runtime.d.ts +2 -2
  49. package/dist/event-stream-view.d.cts +1 -1
  50. package/dist/event-stream-view.d.ts +1 -1
  51. package/dist/{fallback-speech-engine-DmF7MczI.d.cts → fallback-speech-engine-SqEfkGda.d.cts} +1 -1
  52. package/dist/{fallback-speech-engine-DmF7MczI.d.ts → fallback-speech-engine-SqEfkGda.d.ts} +1 -1
  53. package/dist/{feature-preferences-CVFDt9Xf.d.cts → feature-preferences-DqAmje-2.d.cts} +1 -1
  54. package/dist/{feature-preferences-CVFDt9Xf.d.ts → feature-preferences-DqAmje-2.d.ts} +1 -1
  55. package/dist/forms-ui.d.cts +2 -2
  56. package/dist/forms-ui.d.ts +2 -2
  57. package/dist/{history-confirm-dialog-DJEXsTyE.d.cts → history-confirm-dialog-B3L7QNcO.d.cts} +1 -1
  58. package/dist/{history-confirm-dialog-DJEXsTyE.d.ts → history-confirm-dialog-B3L7QNcO.d.ts} +1 -1
  59. package/dist/history-shell.cjs +1 -1
  60. package/dist/history-shell.d.cts +6 -6
  61. package/dist/history-shell.d.ts +6 -6
  62. package/dist/history-shell.js +1 -1
  63. package/dist/history-view.d.cts +1 -1
  64. package/dist/history-view.d.ts +1 -1
  65. package/dist/index.cjs +38 -38
  66. package/dist/index.cjs.map +1 -1
  67. package/dist/index.d.cts +9 -9
  68. package/dist/index.d.ts +9 -9
  69. package/dist/index.global.js +1 -1
  70. package/dist/index.global.js.map +1 -1
  71. package/dist/index.js +52 -52
  72. package/dist/index.js.map +1 -1
  73. package/dist/install.global.js +1 -1
  74. package/dist/install.global.js.map +1 -1
  75. package/dist/launcher.global.js +2 -2
  76. package/dist/launcher.global.js.map +1 -1
  77. package/dist/{loading-label-runtime-loader-CPxnoaoi.d.cts → loading-label-runtime-loader-JecjOfJS.d.cts} +1 -1
  78. package/dist/{loading-label-runtime-loader-CPxnoaoi.d.ts → loading-label-runtime-loader-JecjOfJS.d.ts} +1 -1
  79. package/dist/loading-label-runtime.d.cts +1 -1
  80. package/dist/loading-label-runtime.d.ts +1 -1
  81. package/dist/{message-bubble-CkZGlCLC.d.ts → message-bubble-CBYtpDsT.d.cts} +2 -2
  82. package/dist/{message-bubble-CkZGlCLC.d.cts → message-bubble-CBYtpDsT.d.ts} +2 -2
  83. package/dist/preferences-runtime.d.cts +1 -1
  84. package/dist/preferences-runtime.d.ts +1 -1
  85. package/dist/{preview-DdUvoLb4.d.cts → preview-_UJwwt2L.d.cts} +2 -2
  86. package/dist/{preview-DdUvoLb4.d.ts → preview-_UJwwt2L.d.ts} +2 -2
  87. package/dist/{registry-Dz4iHNST.d.cts → registry-B5ZV3K-h.d.cts} +1 -1
  88. package/dist/{registry-Dz4iHNST.d.ts → registry-B5ZV3K-h.d.ts} +1 -1
  89. package/dist/{session-Baxp9iRK.d.cts → session-Bys8_XuB.d.cts} +10 -9
  90. package/dist/{session-Baxp9iRK.d.ts → session-Bys8_XuB.d.ts} +10 -9
  91. package/dist/session-reconnect.d.cts +2 -2
  92. package/dist/session-reconnect.d.ts +2 -2
  93. package/dist/smart-dom-reader.d.cts +1 -1
  94. package/dist/smart-dom-reader.d.ts +1 -1
  95. package/dist/theme-editor-preview.cjs +31 -31
  96. package/dist/theme-editor-preview.d.cts +1 -1
  97. package/dist/theme-editor-preview.d.ts +1 -1
  98. package/dist/theme-editor-preview.js +19 -19
  99. package/dist/theme-editor.cjs +1 -1
  100. package/dist/theme-editor.d.cts +2 -2
  101. package/dist/theme-editor.d.ts +2 -2
  102. package/dist/theme-editor.js +1 -1
  103. package/dist/theme-reference.cjs +1 -1
  104. package/dist/theme-reference.d.cts +4 -2
  105. package/dist/theme-reference.d.ts +4 -2
  106. package/dist/theme-reference.js +1 -1
  107. package/dist/{tool-loading-animation-un3kA5Ur.d.cts → tool-loading-animation-d5qAQcwv.d.cts} +1 -1
  108. package/dist/{tool-loading-animation-un3kA5Ur.d.ts → tool-loading-animation-d5qAQcwv.d.ts} +1 -1
  109. package/dist/{turn-activity-ui-BGNhtMcH.d.cts → turn-activity-ui-q6fHVnx-.d.cts} +2 -2
  110. package/dist/{turn-activity-ui-BGNhtMcH.d.ts → turn-activity-ui-q6fHVnx-.d.ts} +2 -2
  111. package/dist/turn-activity-ui.d.cts +1 -1
  112. package/dist/turn-activity-ui.d.ts +1 -1
  113. package/dist/{types-BOZHQIk0.d.cts → types-B_mj21jg.d.cts} +91 -7
  114. package/dist/{types-BOZHQIk0.d.ts → types-B_mj21jg.d.ts} +91 -7
  115. package/dist/voice-client-tools.d.cts +1 -1
  116. package/dist/voice-client-tools.d.ts +1 -1
  117. package/dist/voice-runtime.cjs +1 -1
  118. package/dist/voice-runtime.d.cts +2 -2
  119. package/dist/voice-runtime.d.ts +2 -2
  120. package/dist/voice-runtime.js +1 -1
  121. package/dist/voice-worklet-player.d.cts +2 -2
  122. package/dist/voice-worklet-player.d.ts +2 -2
  123. package/dist/{webmcp-runtime-entry-DuAG1_yF.d.cts → webmcp-runtime-entry-ClEO3qdI.d.cts} +1 -1
  124. package/dist/{webmcp-runtime-entry-DuAG1_yF.d.ts → webmcp-runtime-entry-ClEO3qdI.d.ts} +1 -1
  125. package/dist/webmcp-runtime.d.cts +1 -1
  126. package/dist/webmcp-runtime.d.ts +1 -1
  127. package/dist/widget.css +1 -1
  128. package/package.json +1 -1
  129. package/src/client-dispatch.ts +69 -16
  130. package/src/client.operator-role.test.ts +217 -0
  131. package/src/components/message-bubble.test.ts +169 -0
  132. package/src/components/message-bubble.ts +97 -28
  133. package/src/defaults-parity.test.ts +14 -0
  134. package/src/index-core.ts +7 -0
  135. package/src/index-global.ts +2 -1
  136. package/src/install.test.ts +6 -5
  137. package/src/install.ts +2 -2
  138. package/src/launcher-global.ts +2 -1
  139. package/src/session.resubmit-from.test.ts +27 -0
  140. package/src/session.test.ts +51 -0
  141. package/src/session.ts +22 -12
  142. package/src/styles/widget.css +23 -1
  143. package/src/theme-reference.ts +3 -1
  144. package/src/types/theme.ts +21 -0
  145. package/src/types.ts +71 -6
  146. package/src/ui.ask-user-question-plugin.test.ts +154 -0
  147. package/src/ui.operator-role.test.ts +336 -0
  148. package/src/ui.ts +37 -12
  149. package/src/utils/__snapshots__/tokens-parity.test.ts.snap +236 -0
  150. package/src/utils/brand.test.ts +14 -0
  151. package/src/utils/brand.ts +8 -4
  152. package/src/utils/cdn-release.test.ts +20 -0
  153. package/src/utils/cdn-release.ts +14 -0
  154. package/src/utils/history-messages.test.ts +30 -0
  155. package/src/utils/history-messages.ts +26 -8
  156. package/src/utils/message-fingerprint.ts +4 -0
  157. package/src/utils/resubmit-turn.test.ts +13 -0
  158. package/src/utils/resubmit-turn.ts +12 -1
  159. package/src/utils/tokens.ts +31 -0
  160. package/src/welcome.test.ts +16 -0
  161. package/src/welcome.ts +10 -2
  162. package/dist/chunk-BkOYJ11B.js +0 -1
  163. package/dist/chunk-Bz9jAgba.js +0 -1
  164. package/dist/chunk-CdpVP6LZ.js +0 -1
  165. package/dist/chunk-DM-OJt5q.js +0 -1
@@ -580,6 +580,27 @@ export interface MessageTokens {
580
580
  /** Assistant bubble box-shadow (token ref or raw CSS, e.g. `none`). */
581
581
  shadow?: string;
582
582
  };
583
+ /**
584
+ * Operator bubble: a human speaking on the agent's side (`role: "operator"`).
585
+ * Renders on the assistant's side. Defaults tint the container with the
586
+ * theme primary (`--persona-primary`), so `brand` themes follow without
587
+ * extra config. Geometry keys fall back to the layout preset, not to the
588
+ * assistant's geometry.
589
+ */
590
+ operator?: MessageGeometryTokens & {
591
+ /** @default "color-mix(in srgb, var(--persona-primary) 6%, var(--persona-container))" */
592
+ background?: TokenReference<'color'>;
593
+ /** @default semantic.colors.text */
594
+ text?: TokenReference<'color'>;
595
+ /** @default palette.radius.lg */
596
+ borderRadius?: TokenReference<'radius'>;
597
+ /** @default "color-mix(in srgb, var(--persona-primary) 20%, var(--persona-container))" */
598
+ border?: TokenReference<'color'>;
599
+ /** Border width; set to 0px to drop it. @default "1px" */
600
+ borderWidth?: string;
601
+ /** Operator bubble box-shadow (token ref or raw CSS, e.g. `none`). */
602
+ shadow?: string;
603
+ };
583
604
  /** Border color between messages in the thread. */
584
605
  border?: TokenReference<'color'>;
585
606
  }
package/src/types.ts CHANGED
@@ -616,7 +616,7 @@ export type AgentWidgetReplayedToolResult = {
616
616
  * from its own stored transcript there.
617
617
  */
618
618
  export type AgentWidgetRequestPayloadMessage = {
619
- role: AgentWidgetMessageRole | "tool";
619
+ role: AgentWidgetModelMessageRole | "tool";
620
620
  content: MessageContent;
621
621
  createdAt: string;
622
622
  /** Optional assistant phase for backend adapters that replay provider history. */
@@ -1123,6 +1123,21 @@ export type AgentMessageMetadata = {
1123
1123
  };
1124
1124
  };
1125
1125
 
1126
+ /** Ordinary model context only; the serializer owns tool-call/result pairing. */
1127
+ export type AgentWidgetModelMessageProjection = Pick<AgentWidgetRequestPayloadMessage, "content" | "createdAt" | "phase"> & {
1128
+ role: AgentWidgetModelMessageRole;
1129
+ };
1130
+
1131
+ /** Synchronous, per-message conversion before any dispatch transport serializes history. */
1132
+ export type AgentWidgetModelMessageTransform = (context: {
1133
+ /** Full UI transcript entry, including identity and human provenance. */
1134
+ message: Readonly<AgentWidgetMessage>;
1135
+ /** Resolved model content: contentParts > llmContent > rawContent > content. */
1136
+ content: MessageContent;
1137
+ /** Normal model projection; null for operator messages. Return null to omit. */
1138
+ modelMessage: AgentWidgetModelMessageProjection | null;
1139
+ }) => AgentWidgetModelMessageProjection | null;
1140
+
1126
1141
  export type AgentWidgetRequestMiddlewareContext = {
1127
1142
  payload: AgentWidgetRequestPayload;
1128
1143
  config: AgentWidgetConfig;
@@ -1391,7 +1406,7 @@ export type AgentWidgetMessageActionsConfig = {
1391
1406
  };
1392
1407
 
1393
1408
  /** Which roles a custom message action appears on. */
1394
- export type AgentWidgetCustomMessageActionRole = "user" | "assistant";
1409
+ export type AgentWidgetCustomMessageActionRole = "user" | "assistant" | "operator";
1395
1410
 
1396
1411
  /** One host-contributed message action. */
1397
1412
  export type AgentWidgetCustomMessageAction = {
@@ -7191,6 +7206,11 @@ export type AgentWidgetAvatarConfig = {
7191
7206
  userAvatar?: string;
7192
7207
  /** URL or emoji for assistant avatar */
7193
7208
  assistantAvatar?: string;
7209
+ /**
7210
+ * URL or emoji for operator (human takeover) avatar. Defaults to the letter
7211
+ * "O" on a neutral fill.
7212
+ */
7213
+ operatorAvatar?: string;
7194
7214
  };
7195
7215
 
7196
7216
  /**
@@ -7246,7 +7266,10 @@ export type AgentWidgetMessageLayoutConfig = {
7246
7266
  groupConsecutive?: boolean;
7247
7267
  /** Width behavior for user-authored message rows */
7248
7268
  user?: AgentWidgetMessageRoleLayout;
7249
- /** Width behavior for assistant-authored message rows and assistant UI variants */
7269
+ /**
7270
+ * Width behavior for assistant-authored message rows and assistant UI
7271
+ * variants. Operator rows sit on the same side and use it too.
7272
+ */
7250
7273
  assistant?: AgentWidgetMessageRoleLayout;
7251
7274
  /**
7252
7275
  * Custom renderer for user messages
@@ -8398,6 +8421,8 @@ export type AgentWidgetConfig = {
8398
8421
  */
8399
8422
  getHeaders?: AgentWidgetHeadersFunction;
8400
8423
  copy?: {
8424
+ /** Visible and accessible label for human operator replies. @default "Human operator" */
8425
+ operatorLabel?: string;
8401
8426
  /** @deprecated Use `welcome.title`. */
8402
8427
  welcomeTitle?: string;
8403
8428
  /** @deprecated Use `welcome.subtitle`. */
@@ -8590,6 +8615,16 @@ export type AgentWidgetConfig = {
8590
8615
  * See `docs/context-mentions-plan.md`.
8591
8616
  */
8592
8617
  contextMentions?: AgentWidgetContextMentionConfig;
8618
+ /**
8619
+ * Synchronous model-context conversion for each nonempty conversation entry.
8620
+ * Runs on proxy, agent and client-token dispatch, after local-only notices and
8621
+ * voice captions are excluded. By default operator messages are omitted;
8622
+ * stateless adapters can explicitly map their historical human contributions
8623
+ * into supported model roles. Do not replay contributions already owned by
8624
+ * server history. This does not authorize steering, takeover or tool approval.
8625
+ * Invalid return values fail dispatch. See docs/MESSAGE-INJECTION.md.
8626
+ */
8627
+ modelMessageTransform?: AgentWidgetModelMessageTransform;
8593
8628
  requestMiddleware?: AgentWidgetRequestMiddleware;
8594
8629
  actionParsers?: AgentWidgetActionParser[];
8595
8630
  actionHandlers?: AgentWidgetActionHandler[];
@@ -9093,7 +9128,25 @@ type ConfigPatch<T> = T extends (...args: never[]) => unknown
9093
9128
  ? { [K in keyof T]?: ConfigPatch<T[K]> }
9094
9129
  : T;
9095
9130
 
9096
- export type AgentWidgetMessageRole = "user" | "assistant" | "system";
9131
+ /**
9132
+ * Who authored a message.
9133
+ *
9134
+ * - `user`: the end user.
9135
+ * - `assistant`: the agent (model output).
9136
+ * - `system`: context for the model, rendered like an assistant row.
9137
+ * - `operator`: a human speaking on the agent's side of the conversation, such
9138
+ * as a support agent who has taken over the thread. It renders on the
9139
+ * assistant's side with its own bubble style (`theme.components.message.operator`).
9140
+ * Operator messages are omitted from model requests by default. A stateless
9141
+ * adapter can explicitly convert them with `modelMessageTransform`.
9142
+ */
9143
+ export type AgentWidgetMessageRole = "user" | "assistant" | "system" | "operator";
9144
+
9145
+ /**
9146
+ * Roles that reach a model request. Human operator UI entries must be explicitly
9147
+ * converted to a supported model role by an adapter.
9148
+ */
9149
+ export type AgentWidgetModelMessageRole = Exclude<AgentWidgetMessageRole, "operator">;
9097
9150
 
9098
9151
  export type AgentWidgetReasoning = {
9099
9152
  id: string;
@@ -9216,11 +9269,18 @@ export type AgentWidgetTurnActivityConfig = {
9216
9269
  }) => HTMLElement | string | null | undefined;
9217
9270
  };
9218
9271
 
9272
+ /** Display provenance, supplied by the host; never an authorization credential. */
9273
+ export type AgentWidgetMessageAuthor = {
9274
+ id?: string;
9275
+ displayName?: string;
9276
+ kind?: "human" | "ai" | "system";
9277
+ };
9278
+
9219
9279
  /**
9220
9280
  * Represents a message in the chat conversation.
9221
9281
  *
9222
9282
  * @property id - Unique message identifier
9223
- * @property role - Message role: "user", "assistant", or "system"
9283
+ * @property role - Message role: "user", "assistant", "system", or "operator"
9224
9284
  * @property content - Message text content (for display)
9225
9285
  * @property contentParts - Original multi-modal content parts (for API requests)
9226
9286
  * @property createdAt - ISO timestamp when message was created
@@ -9234,6 +9294,8 @@ export type AgentWidgetTurnActivityConfig = {
9234
9294
  * Useful for implementing voice-specific behaviors like auto-reactivation.
9235
9295
  */
9236
9296
  export type AgentWidgetMessage = {
9297
+ /** Preserved by injection, history and persistence; not sent to models automatically. */
9298
+ author?: AgentWidgetMessageAuthor;
9237
9299
  /** Opt-in whole-turn presentation metadata. Does not change model-visible content. */
9238
9300
  turnActivity?: AgentWidgetTurnActivity;
9239
9301
  id: string;
@@ -9388,10 +9450,13 @@ export type AgentWidgetMessage = {
9388
9450
  * }
9389
9451
  */
9390
9452
  export type InjectMessageOptions = {
9453
+ /** Host-supplied display provenance. Does not grant model or backend authority. */
9454
+ author?: AgentWidgetMessageAuthor;
9391
9455
  /** Mark assistant content as dispatch failure detail for `errorDisplay`. */
9392
9456
  dispatchError?: boolean;
9393
9457
  /**
9394
- * Message role: "assistant", "user", or "system"
9458
+ * Message role: "assistant", "user", "system", or "operator". Operator
9459
+ * messages are omitted from requests unless modelMessageTransform converts them.
9395
9460
  */
9396
9461
  role: AgentWidgetMessageRole;
9397
9462
 
@@ -689,3 +689,157 @@ describe("renderAskUserQuestion plugin hook", () => {
689
689
  controller.destroy();
690
690
  });
691
691
  });
692
+
693
+ describe("typing indicator while ask_user_question arguments stream", () => {
694
+ afterEach(() => {
695
+ document.body.innerHTML = "";
696
+ if (typeof localStorage !== "undefined") localStorage.clear();
697
+ vi.restoreAllMocks();
698
+ vi.unstubAllGlobals();
699
+ });
700
+
701
+ // Streaming transcript updates render once per animation frame; queue the
702
+ // frames so each injection can be rendered synchronously.
703
+ let frames = new Map<number, (time: number) => void>();
704
+ let nextFrameId = 1;
705
+ const renderFrame = () => {
706
+ const pending = [...frames.values()];
707
+ frames.clear();
708
+ pending.forEach((callback) => callback(0));
709
+ };
710
+
711
+ const mountStreaming = async (config: Record<string, unknown> = {}) => {
712
+ frames = new Map();
713
+ vi.stubGlobal("requestAnimationFrame", (callback: (time: number) => void) => {
714
+ const id = nextFrameId++;
715
+ frames.set(id, callback);
716
+ return id;
717
+ });
718
+ vi.stubGlobal("cancelAnimationFrame", (id: number) => frames.delete(id));
719
+ global.fetch = vi.fn().mockImplementation(
720
+ () => new Promise(() => {})
721
+ ) as unknown as typeof fetch;
722
+
723
+ const mount = createMount();
724
+ const controller = createAgentExperience(mount, {
725
+ apiUrl: "https://api.example.com/chat",
726
+ launcher: { enabled: false },
727
+ ...config,
728
+ } as unknown as Parameters<typeof createAgentExperience>[1]);
729
+
730
+ controller.submitMessage("hello");
731
+ await Promise.resolve();
732
+ await Promise.resolve();
733
+ return { mount, controller };
734
+ };
735
+
736
+ // Stamped in the future so the rows sort after the submitted user message.
737
+ const sequence = Date.now() + 60_000;
738
+ const createdAt = new Date(sequence).toISOString();
739
+ const fullArgs = {
740
+ questions: [
741
+ { question: "Which audience?", options: [{ label: "Hobbyists" }, { label: "Pros" }] },
742
+ ],
743
+ };
744
+
745
+ const injectStreamingAsk = (
746
+ controller: ReturnType<typeof createAgentExperience>,
747
+ { chunks = [], args }: { chunks?: string[]; args?: unknown } = {}
748
+ ) => {
749
+ controller.injectTestMessage({
750
+ type: "message",
751
+ message: {
752
+ id: "ask-1",
753
+ role: "assistant",
754
+ content: "",
755
+ createdAt,
756
+ sequence,
757
+ streaming: true,
758
+ variant: "tool",
759
+ toolCall: { id: "ask-1", name: "ask_user_question", status: "running", chunks, args },
760
+ },
761
+ });
762
+ renderFrame();
763
+ };
764
+
765
+ const injectAwaitingAsk = (controller: ReturnType<typeof createAgentExperience>) => {
766
+ controller.injectTestMessage({
767
+ type: "message",
768
+ message: {
769
+ id: "ask-1",
770
+ role: "assistant",
771
+ content: "",
772
+ createdAt,
773
+ sequence,
774
+ streaming: false,
775
+ variant: "tool",
776
+ toolCall: { id: "ask-1", name: "ask_user_question", status: "complete", chunks: [], args: fullArgs },
777
+ agentMetadata: { executionId: "exec_123", awaitingLocalTool: true },
778
+ },
779
+ });
780
+ renderFrame();
781
+ };
782
+
783
+ const typingIndicator = (mount: HTMLElement) =>
784
+ mount.querySelector('[data-typing-indicator="true"]');
785
+ const askSheet = (mount: HTMLElement) => mount.querySelector("[data-persona-ask-sheet-for]");
786
+
787
+ it("keeps the dots until the built-in sheet mounts when no plugin renders the question", async () => {
788
+ const { mount, controller } = await mountStreaming();
789
+
790
+ injectStreamingAsk(controller);
791
+ expect(askSheet(mount)).toBeNull();
792
+ expect(typingIndicator(mount)).not.toBeNull();
793
+
794
+ injectAwaitingAsk(controller);
795
+ expect(askSheet(mount)).not.toBeNull();
796
+ expect(typingIndicator(mount)).toBeNull();
797
+
798
+ controller.destroy();
799
+ });
800
+
801
+ it("keeps the dots while the plugin returns null for a partial payload", async () => {
802
+ const plugin: AgentWidgetPlugin = {
803
+ id: "partial-asker",
804
+ renderAskUserQuestion: ({ payload }) => {
805
+ if (!payload?.questions?.[0]?.options?.length) return null;
806
+ const root = document.createElement("div");
807
+ root.setAttribute("data-test-id", "partial-ask");
808
+ return root;
809
+ },
810
+ };
811
+ const { mount, controller } = await mountStreaming({ plugins: [plugin] });
812
+
813
+ injectStreamingAsk(controller, { chunks: ['{"questions":[{"question":"Which'] });
814
+ expect(mount.querySelector('[data-test-id="partial-ask"]')).toBeNull();
815
+ expect(askSheet(mount)).toBeNull();
816
+ expect(typingIndicator(mount)).not.toBeNull();
817
+
818
+ injectStreamingAsk(controller, { args: fullArgs });
819
+ expect(mount.querySelector('[data-test-id="partial-ask"]')).not.toBeNull();
820
+ expect(typingIndicator(mount)).toBeNull();
821
+
822
+ controller.destroy();
823
+ });
824
+
825
+ it.each([true, false])("hides the dots as soon as the plugin renders a bubble (showToolCalls=%s)", async (showToolCalls) => {
826
+ const plugin: AgentWidgetPlugin = {
827
+ id: "eager-asker",
828
+ renderAskUserQuestion: () => {
829
+ const root = document.createElement("div");
830
+ root.setAttribute("data-test-id", "eager-ask");
831
+ return root;
832
+ },
833
+ };
834
+ const { mount, controller } = await mountStreaming({
835
+ plugins: [plugin],
836
+ features: { showToolCalls },
837
+ });
838
+
839
+ injectStreamingAsk(controller);
840
+ expect(mount.querySelector('[data-test-id="eager-ask"]')).not.toBeNull();
841
+ expect(typingIndicator(mount)).toBeNull();
842
+
843
+ controller.destroy();
844
+ });
845
+ });
@@ -0,0 +1,336 @@
1
+ // @vitest-environment jsdom
2
+
3
+ import { readFileSync } from "node:fs";
4
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
5
+
6
+ import { createAgentExperience } from "./ui";
7
+ import type { AgentWidgetMessage, AgentWidgetStoredState } from "./types";
8
+ import type { AgentWidgetPlugin } from "./plugins/types";
9
+
10
+ const mounts: HTMLElement[] = [];
11
+ const controllers: ReturnType<typeof createAgentExperience>[] = [];
12
+
13
+ const makeController = (config: Record<string, unknown> = {}) => {
14
+ const mount = document.createElement("div");
15
+ document.body.appendChild(mount);
16
+ mounts.push(mount);
17
+ const controller = createAgentExperience(mount, {
18
+ apiUrl: "https://api.example.com/chat",
19
+ launcher: { enabled: false },
20
+ persistState: false,
21
+ suggestionChips: [],
22
+ ...config,
23
+ } as unknown as Parameters<typeof createAgentExperience>[1]);
24
+ controllers.push(controller);
25
+ return { mount, controller };
26
+ };
27
+
28
+ /** Let microtasks and the coalesced render frame run. */
29
+ const settle = async () => {
30
+ for (let i = 0; i < 8; i += 1) {
31
+ // eslint-disable-next-line no-await-in-loop
32
+ await Promise.resolve();
33
+ }
34
+ await new Promise((resolve) => setTimeout(resolve, 32));
35
+ };
36
+
37
+ const row = (mount: HTMLElement, id: string) =>
38
+ mount.querySelector<HTMLElement>(`[data-wrapper-id="${id}"]`);
39
+
40
+ describe("operator role in the transcript", () => {
41
+ beforeEach(() => {
42
+ window.scrollTo = vi.fn();
43
+ });
44
+
45
+ afterEach(() => {
46
+ controllers.splice(0).forEach((controller) => {
47
+ try {
48
+ controller.destroy();
49
+ } catch {
50
+ /* already destroyed */
51
+ }
52
+ });
53
+ mounts.splice(0).forEach((mount) => mount.remove());
54
+ document.body.innerHTML = "";
55
+ window.localStorage.clear();
56
+ vi.restoreAllMocks();
57
+ });
58
+
59
+ it("renders an operator row on the assistant side with its own bubble", async () => {
60
+ const { mount, controller } = makeController();
61
+ controller.injectUserMessage({ id: "u1", content: "Where is my order?" });
62
+ controller.injectAssistantMessage({ id: "a1", content: "Let me check." });
63
+ controller.injectMessage({ id: "op1", role: "operator", content: "Sam here, I refunded it." });
64
+ await settle();
65
+
66
+ const operatorRow = row(mount, "op1")!;
67
+ expect(operatorRow.getAttribute("data-message-role")).toBe("operator");
68
+ expect(operatorRow.classList.contains("persona-message-row-operator")).toBe(true);
69
+ expect(operatorRow.classList.contains("persona-justify-end")).toBe(false);
70
+ expect(operatorRow.querySelector(".persona-message-operator-bubble")).not.toBeNull();
71
+ expect(operatorRow.querySelector(".persona-message-assistant-bubble")).toBeNull();
72
+
73
+ // Existing roles are untouched.
74
+ expect(row(mount, "u1")!.classList.contains("persona-justify-end")).toBe(true);
75
+ expect(row(mount, "a1")!.querySelector(".persona-message-assistant-bubble")).not.toBeNull();
76
+ });
77
+
78
+ it("names human replies with avatars off and updates provenance on reinjection", async () => {
79
+ const { mount, controller } = makeController();
80
+ controller.injectMessage({ id: "human", role: "operator", content: "Refunded" });
81
+ await settle();
82
+ expect(row(mount, "human")!.querySelector('[role="group"]')?.getAttribute("aria-label")).toBe("Human operator");
83
+ controller.injectMessage({ id: "human", role: "operator", content: "Refunded", author: { id: "sam", displayName: "Sam", kind: "human" } });
84
+ await settle();
85
+ expect(row(mount, "human")!.querySelector(".persona-message-author")?.textContent).toBe("Sam · Human operator");
86
+ expect(row(mount, "human")!.querySelector('[role="group"]')?.getAttribute("aria-label")).toBe("Sam · Human operator");
87
+ expect(controller.getMessages().find(message => message.id === "human")?.author).toEqual({ id: "sam", displayName: "Sam", kind: "human" });
88
+ });
89
+
90
+ it("persists and restores structured author provenance", async () => {
91
+ let state: AgentWidgetStoredState | null = null;
92
+ const adapter = {
93
+ load: () => state,
94
+ save: (value: AgentWidgetStoredState) => { state = JSON.parse(JSON.stringify(value)); },
95
+ clear: () => { state = null; },
96
+ };
97
+ const { controller } = makeController({ persistState: true, storageAdapter: adapter });
98
+ controller.injectMessage({ id: "saved", role: "operator", content: "Refunded", author: { id: "sam", displayName: "Sam", kind: "human" } });
99
+ await vi.waitFor(() => expect(state?.messages?.some(message => message.id === "saved")).toBe(true));
100
+ const { mount, controller: restored } = makeController({ persistState: true, storageAdapter: adapter });
101
+ await settle();
102
+ expect(restored.getMessages().find(message => message.id === "saved")?.author).toEqual({ id: "sam", displayName: "Sam", kind: "human" });
103
+ expect(row(mount, "saved")!.querySelector(".persona-message-author")?.textContent).toBe("Sam · Human operator");
104
+ });
105
+
106
+ it("hides destructive retry and edit before human replies while allowing a subsequent turn", async () => {
107
+ const { mount, controller } = makeController({ messageActions: { enabled: true, showEdit: true, showRegenerate: true } });
108
+ controller.injectUserMessage({ id: "before", content: "Question" });
109
+ controller.injectAssistantMessage({ id: "answer", content: "Answer" });
110
+ await settle();
111
+ expect(row(mount, "answer")!.querySelector('[data-action="regenerate"]')).not.toBeNull();
112
+ controller.injectMessage({ id: "human", role: "operator", content: "Refunded" });
113
+ await settle();
114
+ expect(row(mount, "before")!.querySelector('[data-action="edit"]')).toBeNull();
115
+ expect(row(mount, "answer")!.querySelector('[data-action="regenerate"]')).toBeNull();
116
+ controller.injectUserMessage({ id: "after", content: "Follow up" });
117
+ controller.injectAssistantMessage({ id: "next", content: "Next answer" });
118
+ await settle();
119
+ expect(row(mount, "after")!.querySelector('[data-action="edit"]')).not.toBeNull();
120
+ expect(row(mount, "next")!.querySelector('[data-action="regenerate"]')).not.toBeNull();
121
+ });
122
+
123
+ it("does not report operator messages as assistant messages", async () => {
124
+ const { controller } = makeController();
125
+ const assistantMessages: AgentWidgetMessage[] = [];
126
+ controller.on("assistant:message", (message) => assistantMessages.push(message));
127
+ controller.injectMessage({ id: "op1", role: "operator", content: "Hello from support" });
128
+ controller.injectAssistantMessage({ id: "a1", content: "Agent reply" });
129
+ await settle();
130
+ expect(assistantMessages.map((message) => message.id)).toEqual(["a1"]);
131
+ });
132
+
133
+ it("renders operator messages from initialMessages", async () => {
134
+ const { mount } = makeController({
135
+ initialMessages: [
136
+ { id: "in1", role: "user", content: "Hi", createdAt: "2026-01-01T00:00:00.000Z" },
137
+ { id: "op1", role: "operator", content: "Hello, Sam here.", createdAt: "2026-01-01T00:00:01.000Z" },
138
+ ] satisfies AgentWidgetMessage[],
139
+ });
140
+ await settle();
141
+ expect(row(mount, "in1")!.getAttribute("data-message-role")).toBe("user");
142
+ expect(row(mount, "op1")!.getAttribute("data-message-role")).toBe("operator");
143
+ expect(row(mount, "op1")!.querySelector(".persona-message-operator-bubble")).not.toBeNull();
144
+ });
145
+
146
+ it("shrink-wraps operator rows even when the assistant is full width", async () => {
147
+ const { mount, controller } = makeController({
148
+ layout: { messages: { assistant: { width: "full" } } },
149
+ });
150
+ controller.injectAssistantMessage({ id: "a1", content: "Let me check." });
151
+ controller.injectMessage({ id: "op1", role: "operator", content: "Sam here." });
152
+ await settle();
153
+ expect(row(mount, "a1")!.getAttribute("data-message-width")).toBe("full");
154
+ const operatorRow = row(mount, "op1")!;
155
+ expect(operatorRow.getAttribute("data-message-width")).toBe("content");
156
+ // It keeps the assistant's full-width cap, so a long message can still fill the column.
157
+ expect(operatorRow.style.getPropertyValue("--persona-message-row-max-width")).toBe(
158
+ "var(--persona-message-operator-max-width, var(--persona-message-assistant-max-width, 100%))"
159
+ );
160
+ });
161
+ });
162
+
163
+ describe("operator bubble styles", () => {
164
+ let stylesheet: HTMLStyleElement;
165
+
166
+ beforeEach(() => {
167
+ window.scrollTo = vi.fn();
168
+ stylesheet = document.createElement("style");
169
+ stylesheet.textContent = readFileSync("src/styles/widget.css", "utf8");
170
+ document.head.append(stylesheet);
171
+ });
172
+
173
+ afterEach(() => {
174
+ controllers.splice(0).forEach((controller) => controller.destroy());
175
+ mounts.splice(0).forEach((mount) => mount.remove());
176
+ stylesheet.remove();
177
+ document.body.innerHTML = "";
178
+ });
179
+
180
+ it.each(["bubble", "flat", "minimal"] as const)(
181
+ "outlines the V5 operator bubble in the %s layout",
182
+ async (layout) => {
183
+ const { mount, controller } = makeController({
184
+ future: { v5Defaults: true },
185
+ layout: { messages: { layout } },
186
+ });
187
+ controller.injectMessage({ id: "op1", role: "operator", content: "Sam here." });
188
+ await settle();
189
+ const bubble = row(mount, "op1")!.querySelector<HTMLElement>("#bubble-op1")!;
190
+ const style = getComputedStyle(bubble);
191
+ expect(style.borderTopStyle).toBe("solid");
192
+ // Width and fill come from the V5 operator tokens on the themed ancestor.
193
+ const token = (name: string) => {
194
+ for (let node: HTMLElement | null = bubble; node; node = node.parentElement) {
195
+ const value = node.style.getPropertyValue(name).trim();
196
+ if (value) return value;
197
+ }
198
+ return "";
199
+ };
200
+ expect(token("--persona-components-message-operator-borderWidth")).toBe("1px");
201
+ expect(token("--persona-components-message-operator-background")).toBe("transparent");
202
+ }
203
+ );
204
+
205
+ it.each(["flat", "minimal"] as const)("keeps the V4 %s operator borderless", async (layout) => {
206
+ const { mount, controller } = makeController({ layout: { messages: { layout } } });
207
+ controller.injectMessage({ id: "op1", role: "operator", content: "Sam here." });
208
+ await settle();
209
+ const bubble = row(mount, "op1")!.querySelector<HTMLElement>("#bubble-op1")!;
210
+ expect(getComputedStyle(bubble).borderTopStyle).not.toBe("solid");
211
+ });
212
+ });
213
+
214
+ /**
215
+ * Hosts draw per-message badges (delivery status, author label, errors) with a
216
+ * `renderMessage` plugin: it wraps the default bubble, appends a
217
+ * `data-preserve-runtime` slot, and the host fills that slot itself. These
218
+ * tests pin the route for operator rows.
219
+ */
220
+ describe("operator messages through a renderMessage plugin slot", () => {
221
+ beforeEach(() => {
222
+ window.scrollTo = vi.fn();
223
+ });
224
+
225
+ afterEach(() => {
226
+ controllers.splice(0).forEach((controller) => controller.destroy());
227
+ mounts.splice(0).forEach((mount) => mount.remove());
228
+ document.body.innerHTML = "";
229
+ vi.restoreAllMocks();
230
+ });
231
+
232
+ const slotPlugin = (seen: AgentWidgetMessage["role"][]): AgentWidgetPlugin => ({
233
+ id: "delivery-status",
234
+ renderMessage: ({ message, defaultRenderer }) => {
235
+ seen.push(message.role);
236
+ if (message.role !== "operator" && message.role !== "assistant") return null;
237
+ const element = defaultRenderer();
238
+ const slot = document.createElement("div");
239
+ slot.setAttribute("data-preserve-runtime", "true");
240
+ slot.setAttribute("data-status-slot", message.id);
241
+ element.insertBefore(slot, element.querySelector(":scope > .persona-message-actions"));
242
+ return element;
243
+ },
244
+ });
245
+
246
+ const slot = (mount: HTMLElement, id: string) =>
247
+ row(mount, id)?.querySelector<HTMLElement>(`[data-status-slot="${id}"]`) ?? null;
248
+
249
+ /** What a host does once the row is on screen: fill the slot with its own UI. */
250
+ const fillSlot = (target: HTMLElement, text: string) => {
251
+ const badge = document.createElement("span");
252
+ badge.className = "host-status";
253
+ badge.textContent = text;
254
+ target.replaceChildren(badge);
255
+ };
256
+
257
+ it("runs the hook for operator rows and uses the element it returns", async () => {
258
+ const seen: AgentWidgetMessage["role"][] = [];
259
+ const { mount, controller } = makeController({ plugins: [slotPlugin(seen)] });
260
+ controller.injectUserMessage({ id: "u1", content: "Where is my order?" });
261
+ controller.injectMessage({ id: "op1", role: "operator", content: "Sam here, I refunded it." });
262
+ await settle();
263
+
264
+ expect(seen).toContain("operator");
265
+ expect(seen).toContain("user");
266
+ const operatorRow = row(mount, "op1")!;
267
+ expect(operatorRow.getAttribute("data-message-role")).toBe("operator");
268
+ expect(slot(mount, "op1")).not.toBeNull();
269
+ // The plugin returned null for the user row, so it keeps the default bubble.
270
+ expect(row(mount, "u1")!.querySelector("[data-status-slot]")).toBeNull();
271
+ expect(row(mount, "u1")!.querySelector(".persona-message-user-bubble")).not.toBeNull();
272
+ });
273
+
274
+ it("keeps the operator bubble class when a plugin wraps the default renderer", async () => {
275
+ const { mount, controller } = makeController({ plugins: [slotPlugin([])] });
276
+ controller.injectMessage({ id: "op1", role: "operator", content: "Sam here." });
277
+ await settle();
278
+
279
+ const bubble = row(mount, "op1")!.querySelector<HTMLElement>("#bubble-op1")!;
280
+ expect(bubble.classList.contains("persona-message-operator-bubble")).toBe(true);
281
+ expect(bubble.classList.contains("persona-message-assistant-bubble")).toBe(false);
282
+ expect(bubble.getAttribute("data-persona-theme-zone")).toBe("operator-message");
283
+ expect(bubble.contains(slot(mount, "op1"))).toBe(true);
284
+ });
285
+
286
+ it("keeps the host-filled slot node across controller.update()", async () => {
287
+ const seen: AgentWidgetMessage["role"][] = [];
288
+ const { mount, controller } = makeController({ plugins: [slotPlugin(seen)] });
289
+ controller.injectMessage({ id: "op1", role: "operator", content: "Sam here." });
290
+ await settle();
291
+ const first = slot(mount, "op1")!;
292
+ fillSlot(first, "Delivered");
293
+ const rendersBefore = seen.length;
294
+
295
+ controller.update({ copy: { welcomeTitle: "Support" } });
296
+ await settle();
297
+ controller.update({ theme: { components: { message: { operator: { borderRadius: "4px" } } } } });
298
+ await settle();
299
+
300
+ // The plugin rendered the row again, and the morph kept the live slot.
301
+ expect(seen.length).toBeGreaterThan(rendersBefore);
302
+ expect(slot(mount, "op1")).toBe(first);
303
+ expect(first.isConnected).toBe(true);
304
+ expect(first.querySelector(".host-status")?.textContent).toBe("Delivered");
305
+ expect(row(mount, "op1")!.querySelector(".persona-message-operator-bubble")).not.toBeNull();
306
+ });
307
+
308
+ it("keeps the host-filled slot node when injectMessage upserts the same id", async () => {
309
+ const seen: AgentWidgetMessage["role"][] = [];
310
+ const { mount, controller } = makeController({ plugins: [slotPlugin(seen)] });
311
+ controller.injectMessage({ id: "op1", role: "operator", content: "Sam here." });
312
+ await settle();
313
+ const first = slot(mount, "op1")!;
314
+ fillSlot(first, "Sending");
315
+
316
+ // Same id, same text: a status-only change on the host side.
317
+ controller.injectMessage({ id: "op1", role: "operator", content: "Sam here." });
318
+ await settle();
319
+ expect(slot(mount, "op1")).toBe(first);
320
+
321
+ // Same id, new text: the plugin renders the row again and the slot survives.
322
+ const rendersBefore = seen.length;
323
+ controller.injectMessage({ id: "op1", role: "operator", content: "Sam here, refund sent." });
324
+ await settle();
325
+ expect(seen.length).toBeGreaterThan(rendersBefore);
326
+
327
+ expect(mount.querySelectorAll('[data-wrapper-id="op1"]')).toHaveLength(1);
328
+ expect(slot(mount, "op1")).toBe(first);
329
+ // The host's content is still there; the host then updates it in place.
330
+ expect(first.querySelector(".host-status")?.textContent).toBe("Sending");
331
+ fillSlot(first, "Delivered");
332
+ expect(slot(mount, "op1")?.querySelector(".host-status")?.textContent).toBe("Delivered");
333
+ expect(row(mount, "op1")!.querySelector("#bubble-op1")?.textContent).toContain("refund sent");
334
+ expect(row(mount, "op1")!.querySelector(".persona-message-operator-bubble")).not.toBeNull();
335
+ });
336
+ });