@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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@runtypelabs/persona",
3
- "version": "4.32.1",
3
+ "version": "4.33.0",
4
4
  "description": "Themeable, pluggable streaming agent widget for websites, in plain JS with support for voice input and reasoning / tool output.",
5
5
  "type": "module",
6
6
  "main": "dist/index.cjs",
@@ -1,7 +1,7 @@
1
1
  import { computeClientToolsFingerprint } from "./webmcp-bridge";
2
2
  import type { RuntypeClientInitResponse } from "./generated/runtype-openapi-contract";
3
3
  import type {
4
- AgentWidgetMessage, AgentWidgetRequestPayload, AgentWidgetAgentRequestPayload,
4
+ AgentWidgetMessage, AgentWidgetModelMessageRole, AgentWidgetRequestPayload, AgentWidgetAgentRequestPayload,
5
5
  AgentWidgetContextProvider, AgentWidgetRequestMiddleware, AgentWidgetCustomFetch,
6
6
  ClientSession, ClientChatRequest, ComposerOptionsPayload, WidgetHistoryInternals, ClientFeedbackRequest, ClientFeedbackType, ClientToolDefinition,
7
7
  } from "./types";
@@ -49,6 +49,13 @@ export interface ClientDispatchHost extends ClientResumeHost {
49
49
  streamResponse: (body: ReadableStream<Uint8Array>, onEvent: SSEHandler, assistantMessageId?: string, seedContent?: string, signal?: AbortSignal) => Promise<void>;
50
50
  }
51
51
 
52
+ /** A message whose role the model accepts. */
53
+ type ModelMessage = AgentWidgetMessage & { role: AgentWidgetModelMessageRole };
54
+
55
+ /**
56
+ * Whether a message belongs in a model request. Every payload builder gates on
57
+ * this before the host conversion hook, so local notices cannot enter context.
58
+ */
52
59
  const hasValidContent = (message: AgentWidgetMessage): boolean => {
53
60
  // Display-only voice captions are never conversation.
54
61
  if (message.voiceCaption) return false;
@@ -72,20 +79,37 @@ const hasValidContent = (message: AgentWidgetMessage): boolean => {
72
79
  return false;
73
80
  };
74
81
 
82
+ /** Reject malformed JS/CDN hook results before constructing a network request. */
83
+ const isModelContent = (content: unknown): boolean => {
84
+ if (typeof content === "string") return true;
85
+ if (!Array.isArray(content)) return false;
86
+ return content.every(part => {
87
+ if (!part || typeof part !== "object") return false;
88
+ switch (part.type) {
89
+ case "text": return typeof part.text === "string";
90
+ case "image": return typeof part.image === "string";
91
+ case "audio": return typeof part.audio === "string";
92
+ case "video": return typeof part.video === "string";
93
+ case "file": return typeof part.data === "string" && typeof part.mimeType === "string" && typeof part.filename === "string";
94
+ default: return false;
95
+ }
96
+ });
97
+ };
98
+
75
99
  const sortByCreatedAt = (messages: AgentWidgetMessage[]): AgentWidgetMessage[] =>
76
100
  messages
77
101
  .slice()
78
102
  .sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime());
79
103
 
80
104
  // Only replay explicit known assistant phase metadata; other widget metadata is private.
81
- const payloadPhase = (message: AgentWidgetMessage) => {
105
+ const payloadPhase = (message: ModelMessage) => {
82
106
  const phase = message.agentMetadata?.providerPhase ?? message.agentMetadata?.phase;
83
107
  return message.role === "assistant" && (phase === "commentary" || phase === "partial_answer" || phase === "final_answer")
84
108
  ? { phase } : {};
85
109
  };
86
110
 
87
111
  // Priority: contentParts (multi-modal) > llmContent (explicit LLM content) > rawContent (structured parsers) > content (display)
88
- const toPayloadMessage = (message: AgentWidgetMessage) => ({
112
+ const toPayloadMessage = (message: ModelMessage) => ({
89
113
  role: message.role,
90
114
  content: message.contentParts ?? message.llmContent ?? message.rawContent ?? message.content,
91
115
  createdAt: message.createdAt,
@@ -480,7 +504,10 @@ export class ClientDispatch {
480
504
  }
481
505
 
482
506
  // Build the standard payload to get context/metadata from middleware
483
- const basePayload = await this.#buildPayload(options.messages);
507
+ // Reuse the conversion: the hook runs once per transcript entry even
508
+ // though client-token dispatch also builds the middleware/context payload.
509
+ const modelMessages = new Map(sortByCreatedAt(options.messages).map(message => [message, this.#toModelMessage(message)]));
510
+ const basePayload = await this.#buildPayload(options.messages, undefined, modelMessages);
484
511
 
485
512
  // Build the chat request payload with message IDs for feedback tracking
486
513
  // Filter out sessionId from metadata if present (it's only for local storage)
@@ -495,19 +522,21 @@ export class ClientDispatch {
495
522
  const baseChatRequest: Omit<ClientChatRequest, 'clientTools' | 'clientToolsFingerprint'> = {
496
523
  sessionId: session.sessionId,
497
524
  // Filter out messages with empty content to prevent validation errors
498
- messages: options.messages.filter(hasValidContent).map(m => {
525
+ messages: sortByCreatedAt(options.messages).flatMap(m => {
526
+ const projected = modelMessages.get(m);
527
+ if (!projected) return [];
499
528
  // The visitor-visible projection rides along only where it diverges
500
529
  // from the model channel, and only on the history-capable plane.
501
530
  const displayContent = historyCapable
502
- ? divergentDisplayProjection(m)
531
+ ? divergentDisplayProjection(m, projected.content)
503
532
  : undefined;
504
- return {
505
- id: m.id, // Include message ID for tracking
506
- role: m.role,
533
+ return [{
534
+ id: m.id, // Include original message identity for tracking
535
+ role: projected.role,
507
536
  // Priority: contentParts (multi-modal) > llmContent (explicit LLM content) > rawContent (structured parsers) > content (display)
508
- content: m.contentParts ?? m.llmContent ?? m.rawContent ?? m.content,
537
+ content: projected.content,
509
538
  ...(displayContent !== undefined && { displayContent }),
510
- };
539
+ }];
511
540
  }),
512
541
  // Include pre-generated assistant message ID if provided
513
542
  ...(options.assistantMessageId && { assistantMessageId: options.assistantMessageId }),
@@ -888,6 +917,31 @@ export class ClientDispatch {
888
917
  return declared ? { ...agent, model: selected } : agent;
889
918
  }
890
919
 
920
+ #toModelMessage(message: AgentWidgetMessage): ReturnType<typeof toPayloadMessage> | null {
921
+ // These entries must never become model context, even through a host hook.
922
+ if (!hasValidContent(message)) return null;
923
+ const content = message.contentParts ?? message.llmContent ?? message.rawContent ?? message.content;
924
+ const modelMessage = message.role === "operator" ? null : toPayloadMessage(message as ModelMessage);
925
+ const transform = this.host.config.modelMessageTransform;
926
+ if (!transform) return modelMessage;
927
+ const result = transform({ message, content, modelMessage });
928
+ if (result === null) return null;
929
+ if (!result || typeof result !== "object" ||
930
+ !["user", "assistant", "system"].includes(result.role) ||
931
+ !isModelContent(result.content) ||
932
+ typeof result.createdAt !== "string") {
933
+ throw new TypeError("modelMessageTransform must return a supported model message or null");
934
+ }
935
+ // Only the established payload fields cross the wire. Host display metadata
936
+ // (including author) must be mapped intentionally into adapter model context.
937
+ return {
938
+ role: result.role,
939
+ content: result.content,
940
+ createdAt: result.createdAt,
941
+ ...(result.role === "assistant" && result.phase && { phase: result.phase }),
942
+ };
943
+ }
944
+
891
945
  async #buildAgentPayload(
892
946
  messages: AgentWidgetMessage[],
893
947
  composerOptions?: ComposerOptionsPayload
@@ -902,10 +956,8 @@ export class ClientDispatch {
902
956
  const normalizedMessages = serializeWithToolPairs(
903
957
  sortByCreatedAt(messages),
904
958
  (message) =>
905
- hasValidContent(message) &&
906
- (message.role === "user" || message.role === "assistant" || message.role === "system") &&
907
959
  (!message.variant || message.variant === "assistant")
908
- ? toPayloadMessage(message)
960
+ ? this.#toModelMessage(message)
909
961
  : null
910
962
  );
911
963
 
@@ -947,7 +999,8 @@ export class ClientDispatch {
947
999
 
948
1000
  async #buildPayload(
949
1001
  messages: AgentWidgetMessage[],
950
- composerOptions?: ComposerOptionsPayload
1002
+ composerOptions?: ComposerOptionsPayload,
1003
+ modelMessages?: Map<AgentWidgetMessage, ReturnType<typeof toPayloadMessage> | null>
951
1004
  ): Promise<AgentWidgetRequestPayload> {
952
1005
  // Filter out messages with empty content to prevent validation errors;
953
1006
  // answered client-tool calls replay as paired toolCalls/toolResults
@@ -955,7 +1008,7 @@ export class ClientDispatch {
955
1008
  // reads these, so the server stays the replay owner there.
956
1009
  const normalizedMessages = serializeWithToolPairs(
957
1010
  sortByCreatedAt(messages),
958
- (message) => (hasValidContent(message) ? toPayloadMessage(message) : null)
1011
+ (message) => modelMessages ? modelMessages.get(message) ?? null : this.#toModelMessage(message)
959
1012
  );
960
1013
 
961
1014
  const routed = this.host.routing();
@@ -0,0 +1,217 @@
1
+ import { afterEach, describe, expect, it, vi } from "vitest";
2
+ import { AgentWidgetClient } from "./client";
3
+ import type { AgentWidgetConfig, AgentWidgetMessage } from "./types";
4
+
5
+ /**
6
+ * Operator messages (a human on the agent's side, e.g. a support takeover)
7
+ * are omitted by default: a server-backed thread may already own their replay.
8
+ * Stateless adapters can opt into attributed context using supported model roles.
9
+ */
10
+
11
+ const sseComplete = () =>
12
+ `event: execution_complete\ndata: ${JSON.stringify({
13
+ type: "execution_complete",
14
+ executionId: "exec_1",
15
+ agentId: "agent_1",
16
+ success: true,
17
+ iterations: 1,
18
+ completedAt: "2026-01-01T00:00:10.000Z",
19
+ seq: 1,
20
+ })}\n\n`;
21
+
22
+ const captureFetch = () => {
23
+ const bodies: Array<{ url: string; body: Record<string, unknown> }> = [];
24
+ global.fetch = vi.fn().mockImplementation(async (url: string, options: RequestInit) => {
25
+ bodies.push({ url, body: JSON.parse(String(options.body)) });
26
+ const encoder = new TextEncoder();
27
+ return {
28
+ ok: true,
29
+ body: new ReadableStream({
30
+ start(controller) {
31
+ controller.enqueue(encoder.encode(sseComplete()));
32
+ controller.close();
33
+ },
34
+ }),
35
+ };
36
+ });
37
+ return bodies;
38
+ };
39
+
40
+ const transcript = (): AgentWidgetMessage[] => [
41
+ { id: "u1", role: "user", content: "Where is my order?", createdAt: "2026-01-01T00:00:00.000Z" },
42
+ { id: "a1", role: "assistant", content: "Let me check.", createdAt: "2026-01-01T00:00:01.000Z" },
43
+ {
44
+ id: "op1",
45
+ role: "operator",
46
+ content: "Hi, this is Sam from support. I've refunded it.",
47
+ llmContent: "operator llm content",
48
+ createdAt: "2026-01-01T00:00:02.000Z",
49
+ },
50
+ { id: "u2", role: "user", content: "Thanks!", createdAt: "2026-01-01T00:00:03.000Z" },
51
+ ];
52
+
53
+ const expectNoOperator = (messages: unknown) => {
54
+ expect(Array.isArray(messages)).toBe(true);
55
+ const list = messages as Array<Record<string, unknown>>;
56
+ expect(list.map((message) => message.role)).toEqual(["user", "assistant", "user"]);
57
+ expect(JSON.stringify(list)).not.toContain("support");
58
+ expect(JSON.stringify(list)).not.toContain("operator");
59
+ };
60
+
61
+ describe("operator role: outgoing request payloads", () => {
62
+ afterEach(() => {
63
+ vi.restoreAllMocks();
64
+ });
65
+
66
+ it("drops operator messages from the proxy payload", async () => {
67
+ const bodies = captureFetch();
68
+ const client = new AgentWidgetClient({ apiUrl: "https://api.example.com/chat" });
69
+
70
+ await client.dispatch({ messages: transcript() }, () => {});
71
+
72
+ expectNoOperator(bodies[0].body.messages);
73
+ });
74
+
75
+ it("drops operator messages from the agent payload", async () => {
76
+ const bodies = captureFetch();
77
+ const client = new AgentWidgetClient({
78
+ apiUrl: "https://api.example.com/agents",
79
+ agent: { name: "Test", model: "openai:gpt-4o-mini", systemPrompt: "test" },
80
+ } as AgentWidgetConfig);
81
+
82
+ await client.dispatch({ messages: transcript() }, () => {});
83
+
84
+ expectNoOperator(bodies[0].body.messages);
85
+ });
86
+
87
+ it("drops operator messages from the client-token chat request", async () => {
88
+ const bodies = captureFetch();
89
+ const client = new AgentWidgetClient({
90
+ apiUrl: "https://api.runtype.com",
91
+ clientToken: "ct_live_demo",
92
+ agentId: "agent_1",
93
+ });
94
+ (client as unknown as {
95
+ clientSession: { sessionId: string; expiresAt: Date };
96
+ }).clientSession = {
97
+ sessionId: "sess_operator",
98
+ expiresAt: new Date(Date.now() + 10 * 60_000),
99
+ };
100
+
101
+ await client.dispatch({ messages: transcript() }, () => {});
102
+
103
+ const chat = bodies.find((request) => request.url.endsWith("/v1/client/chat"));
104
+ expect(chat).toBeDefined();
105
+ expectNoOperator(chat!.body.messages);
106
+ });
107
+ });
108
+
109
+ // The adapter owns this conversion; the widget supplies resolved content and
110
+ // keeps its original UI role/identity intact.
111
+ const mapHumanHistory: NonNullable<AgentWidgetConfig["modelMessageTransform"]> = ({ message, content, modelMessage }) =>
112
+ message.role === "operator"
113
+ ? { role: "user", content: typeof content === "string" ? `Historical human support reply (${message.id}, ${message.author?.displayName ?? "Human operator"}): ${content}` : content, createdAt: message.createdAt }
114
+ : modelMessage;
115
+
116
+ const configFor = (transport: string): AgentWidgetConfig => ({
117
+ apiUrl: "https://api.example.com/chat",
118
+ ...(transport === "agent" && { agent: { name: "Test", model: "openai:gpt-4o-mini", systemPrompt: "test" } }),
119
+ ...(transport === "client-token" && { clientToken: "ct_live_demo", agentId: "agent_1", features: { history: { enabled: true } } }),
120
+ });
121
+ const seedClientSession = (client: AgentWidgetClient) => {
122
+ (client as unknown as { clientSession: { sessionId: string; expiresAt: Date; history?: { enabled: boolean } } }).clientSession = {
123
+ sessionId: "sess_operator", expiresAt: new Date(Date.now() + 600_000), history: { enabled: true },
124
+ };
125
+ };
126
+
127
+ describe("explicit model context conversion", () => {
128
+ afterEach(() => vi.restoreAllMocks());
129
+
130
+ it.each(["proxy", "agent", "client-token"])("replays each human contribution once, chronologically, for %s", async transport => {
131
+ const bodies = captureFetch();
132
+ const transform = vi.fn(mapHumanHistory);
133
+ const client = new AgentWidgetClient({ ...configFor(transport), modelMessageTransform: transform });
134
+ if (transport === "client-token") seedClientSession(client);
135
+ const messages = transcript();
136
+ messages[2].author = { id: "sam", displayName: "Sam", kind: "human" };
137
+ await client.dispatch({ messages: [...messages].reverse() }, () => {});
138
+ const request = bodies.find(entry => transport !== "client-token" || entry.url.endsWith("/v1/client/chat"))!;
139
+ const sent = request.body.messages as Array<{ id?: string; role: string; content: string; displayContent?: string }>;
140
+ expect(sent.map(message => message.role)).toEqual(["user", "assistant", "user", "user"]);
141
+ expect(sent.map(message => message.content)).toEqual([
142
+ "Where is my order?", "Let me check.", "Historical human support reply (op1, Sam): operator llm content", "Thanks!",
143
+ ]);
144
+ expect(transform).toHaveBeenCalledTimes(4);
145
+ expect(transform.mock.calls.map(([context]) => context.message.id)).toEqual(["u1", "a1", "op1", "u2"]);
146
+ expect(messages[2].role).toBe("operator");
147
+ if (transport === "client-token") {
148
+ expect(sent[2].id).toBe("op1");
149
+ expect(sent[2].displayContent).toBe(messages[2].content);
150
+ }
151
+ });
152
+
153
+ it.each(["proxy", "agent", "client-token"])("resolves content priority and preserves attachments for %s", async transport => {
154
+ const bodies = captureFetch();
155
+ const transform = vi.fn(mapHumanHistory);
156
+ const client = new AgentWidgetClient({ ...configFor(transport), modelMessageTransform: transform });
157
+ if (transport === "client-token") seedClientSession(client);
158
+ const human = transcript()[2];
159
+ const image = { type: "image" as const, image: "https://example.com/photo.png" };
160
+ const messages: AgentWidgetMessage[] = [
161
+ { ...human, id: "image", content: "", contentParts: [image] },
162
+ { ...human, id: "llm", rawContent: "raw" },
163
+ { ...human, id: "raw", llmContent: undefined, rawContent: "raw" },
164
+ { ...human, id: "visible", llmContent: undefined },
165
+ { ...human, id: "local", localOnly: true },
166
+ { ...human, id: "caption", voiceCaption: true },
167
+ ];
168
+ await client.dispatch({ messages }, () => {});
169
+ expect(transform).toHaveBeenCalledTimes(4);
170
+ expect(transform.mock.calls.map(([context]) => context.content)).toEqual([[image], "operator llm content", "raw", human.content]);
171
+ expect(transform.mock.calls.every(([context]) => context.modelMessage === null)).toBe(true);
172
+ const request = bodies.find(entry => transport !== "client-token" || entry.url.endsWith("/v1/client/chat"))!;
173
+ expect((request.body.messages as Array<{content: unknown}>)[0].content).toEqual([image]);
174
+ expect(JSON.stringify(request.body.messages)).not.toContain('"author"');
175
+ });
176
+
177
+ it("preserves an empty human display projection when client-token conversion adds attribution", async () => {
178
+ const bodies = captureFetch();
179
+ const client = new AgentWidgetClient({ ...configFor("client-token"), modelMessageTransform: ({ message, content, modelMessage }) =>
180
+ message.role === "operator" ? { role: "user", content: [{ type: "text", text: "Historical human support reply" }, ...(Array.isArray(content) ? content : [])], createdAt: message.createdAt } : modelMessage,
181
+ });
182
+ seedClientSession(client);
183
+ await client.dispatch({ messages: [{ ...transcript()[2], content: "", llmContent: undefined, contentParts: [{ type: "image", image: "https://example.com/photo.png" }] }] }, () => {});
184
+ const chat = bodies.find(entry => entry.url.endsWith("/v1/client/chat"))!;
185
+ expect((chat.body.messages as Array<{ displayContent?: string }>)[0].displayContent).toBe("");
186
+ });
187
+
188
+ it("does not hide attachments when a conversion only clones the model content", async () => {
189
+ const bodies = captureFetch();
190
+ const client = new AgentWidgetClient({
191
+ ...configFor("client-token"),
192
+ modelMessageTransform: ({ modelMessage }) => modelMessage
193
+ ? { ...modelMessage, content: structuredClone(modelMessage.content) }
194
+ : null,
195
+ });
196
+ seedClientSession(client);
197
+ await client.dispatch({ messages: [{ ...transcript()[0], content: "", contentParts: [{ type: "image", image: "https://example.com/photo.png" }] }] }, () => {});
198
+ const chat = bodies.find(entry => entry.url.endsWith("/v1/client/chat"))!;
199
+ expect((chat.body.messages as Array<{ displayContent?: string }>)[0].displayContent).toBeUndefined();
200
+ });
201
+
202
+ it.each([
203
+ { role: "operator", content: "unsupported", createdAt: "now" },
204
+ { role: "user", content: { text: "invalid" }, createdAt: "now" },
205
+ { role: "user", content: [{ type: "image", image: 42 }], createdAt: "now" },
206
+ Promise.resolve(null),
207
+ undefined,
208
+ ])("fails explicitly for a malformed JavaScript hook result", async result => {
209
+ const bodies = captureFetch();
210
+ const client = new AgentWidgetClient({
211
+ apiUrl: "https://api.example.com/chat",
212
+ modelMessageTransform: (() => result) as unknown as AgentWidgetConfig["modelMessageTransform"],
213
+ });
214
+ await expect(client.dispatch({ messages: transcript() }, () => {})).rejects.toThrow("modelMessageTransform");
215
+ expect(bodies).toHaveLength(0);
216
+ });
217
+ });
@@ -3,6 +3,7 @@ import { describe, it, expect } from "vitest";
3
3
  import {
4
4
  createStandardBubble,
5
5
  createMessageActions,
6
+ getBubbleClasses,
6
7
  createMessageInlineMentions,
7
8
  isSafeImageSrc,
8
9
  isSafeMediaSrc,
@@ -986,3 +987,171 @@ describe("createMessageActions: chromeless action buttons", () => {
986
987
  expect(buttons[0].className).toBe("persona-message-action-btn");
987
988
  });
988
989
  });
990
+
991
+ describe("createStandardBubble: operator role", () => {
992
+ const actions = {
993
+ enabled: true,
994
+ showCopy: true,
995
+ showUpvote: true,
996
+ showDownvote: true,
997
+ showReadAloud: true,
998
+ showRegenerate: true,
999
+ visibility: "hover",
1000
+ align: "right",
1001
+ layout: "pill-inside",
1002
+ } as const;
1003
+
1004
+ it.each(["bubble", "minimal", "flat"] as const)(
1005
+ "gets its own bubble class, never the assistant's, in the %s layout",
1006
+ (layout) => {
1007
+ const classes = getBubbleClasses("operator", layout);
1008
+ expect(classes).toContain("persona-message-operator-bubble");
1009
+ expect(classes).not.toContain("persona-message-assistant-bubble");
1010
+ expect(classes).not.toContain("persona-message-user-bubble");
1011
+ // Same side as the assistant: never pushed to the user's edge.
1012
+ expect(classes).not.toContain("persona-ml-auto");
1013
+ }
1014
+ );
1015
+
1016
+ it("paints from the operator tokens and marks its own theme zone", () => {
1017
+ const bubble = createStandardBubble(
1018
+ makeMessage({ id: "op-1", role: "operator", content: "Hi, Sam here." }),
1019
+ ({ text }) => text
1020
+ );
1021
+ expect(bubble.style.backgroundColor).toBe(
1022
+ "var(--persona-components-message-operator-background, var(--persona-container))"
1023
+ );
1024
+ expect(bubble.style.color).toBe(
1025
+ "var(--persona-components-message-operator-text, var(--persona-text))"
1026
+ );
1027
+ expect(bubble.getAttribute("data-persona-theme-zone")).toBe("operator-message");
1028
+ });
1029
+
1030
+ it("keeps the existing roles' bubbles unchanged", () => {
1031
+ const assistant = createStandardBubble(makeMessage({ id: "a", content: "Answer" }), ({ text }) => text);
1032
+ const user = createStandardBubble(makeMessage({ id: "u", role: "user", content: "Q" }), ({ text }) => text);
1033
+ expect(assistant.getAttribute("data-persona-theme-zone")).toBe("assistant-message");
1034
+ expect(assistant.style.backgroundColor).toBe(
1035
+ "var(--persona-message-assistant-bg, var(--persona-container))"
1036
+ );
1037
+ expect(user.getAttribute("data-persona-theme-zone")).toBe("user-message");
1038
+ expect(user.style.backgroundColor).toBe("var(--persona-message-user-bg, var(--persona-accent))");
1039
+ });
1040
+
1041
+ it("keeps the operator background in the flat layout, where assistant prose stays transparent", () => {
1042
+ const operator = createStandardBubble(
1043
+ makeMessage({ id: "flat-op", role: "operator", content: "Sam here." }),
1044
+ ({ text }) => text,
1045
+ { layout: "flat" }
1046
+ );
1047
+ const assistant = createStandardBubble(
1048
+ makeMessage({ id: "flat-a", content: "Answer" }),
1049
+ ({ text }) => text,
1050
+ { layout: "flat" }
1051
+ );
1052
+ expect(operator.style.backgroundColor).toBe(
1053
+ "var(--persona-components-message-operator-background, var(--persona-container))"
1054
+ );
1055
+ expect(operator.style.color).toBe(
1056
+ "var(--persona-components-message-operator-text, var(--persona-text))"
1057
+ );
1058
+ // The fill needs side padding; flat assistant prose has none.
1059
+ expect(operator.classList.contains("persona-px-3")).toBe(true);
1060
+ expect(assistant.style.backgroundColor).toBe("transparent");
1061
+ expect(assistant.classList.contains("persona-px-3")).toBe(false);
1062
+ });
1063
+
1064
+ it("uses operator geometry tokens with the v5 defaults, not the flat assistant's", () => {
1065
+ const bubble = createStandardBubble(
1066
+ makeMessage({ id: "op-geo", role: "operator", content: "Hi" }),
1067
+ ({ text }) => text, undefined, undefined, undefined,
1068
+ { widgetConfig: { future: { v5Defaults: true } } as AgentWidgetConfig }
1069
+ );
1070
+ expect(bubble.style.padding).toBe("var(--persona-message-operator-padding, 0.75rem 1.25rem)");
1071
+ expect(bubble.style.padding).not.toContain("assistant");
1072
+ });
1073
+
1074
+ it("offers copy but no model feedback, read-aloud or regenerate controls", () => {
1075
+ const bubble = createStandardBubble(
1076
+ makeMessage({ id: "op-actions", role: "operator", content: "I refunded it." }),
1077
+ ({ text }) => text,
1078
+ undefined,
1079
+ actions,
1080
+ undefined,
1081
+ { actionEligibility: { canRegenerate: true } }
1082
+ );
1083
+ const rendered = Array.from(bubble.querySelectorAll("[data-action]")).map((button) =>
1084
+ button.getAttribute("data-action")
1085
+ );
1086
+ expect(rendered).toEqual(["copy"]);
1087
+ });
1088
+
1089
+ it("shows a custom action only when it opts into the operator role", () => {
1090
+ const custom = [
1091
+ { id: "assistant-only", label: "Assistant only", onSelect: () => {} },
1092
+ { id: "flag", label: "Flag", roles: ["operator" as const], onSelect: () => {} },
1093
+ ];
1094
+ const bubble = createStandardBubble(
1095
+ makeMessage({ id: "op-custom", role: "operator", content: "Hello" }),
1096
+ ({ text }) => text,
1097
+ undefined,
1098
+ { ...actions, showCopy: false, custom }
1099
+ );
1100
+ const rendered = Array.from(bubble.querySelectorAll("[data-action]")).map((button) =>
1101
+ button.getAttribute("data-action")
1102
+ );
1103
+ expect(rendered).toEqual(["custom:flag"]);
1104
+ });
1105
+
1106
+ it("keeps opted-in actions on media-only human replies without an empty copy control", () => {
1107
+ const bubble = createStandardBubble(
1108
+ makeMessage({ id: "media-human", role: "operator", content: "", contentParts: [{ type: "image", image: "https://example.com/photo.png" }] }),
1109
+ ({ text }) => text,
1110
+ undefined,
1111
+ { ...actions, custom: [{ id: "flag", label: "Flag", roles: ["operator"], onSelect: () => {} }] }
1112
+ );
1113
+ expect(Array.from(bubble.querySelectorAll("[data-action]")).map(button => button.getAttribute("data-action"))).toEqual(["custom:flag"]);
1114
+ });
1115
+
1116
+ it("supports a localized visible and accessible human label", () => {
1117
+ const bubble = createStandardBubble(
1118
+ makeMessage({ role: "operator", content: "Bonjour", author: { displayName: "Sam", kind: "human" } }),
1119
+ ({ text }) => text, undefined, undefined, undefined,
1120
+ { widgetConfig: { copy: { operatorLabel: "Conseiller humain" } } }
1121
+ );
1122
+ expect(bubble.getAttribute("aria-label")).toBe("Sam · Conseiller humain");
1123
+ expect(bubble.querySelector(".persona-message-author")?.textContent).toBe("Sam · Conseiller humain");
1124
+ });
1125
+
1126
+ it("keeps a human label when JavaScript supplies malformed provenance or an empty label", () => {
1127
+ const bubble = createStandardBubble(
1128
+ makeMessage({ role: "operator", content: "Hello", author: { displayName: 42 } as unknown as AgentWidgetMessage["author"] }),
1129
+ ({ text }) => text, undefined, undefined, undefined,
1130
+ { widgetConfig: { copy: { operatorLabel: " " } } }
1131
+ );
1132
+ expect(bubble.getAttribute("aria-label")).toBe("Human operator");
1133
+ });
1134
+
1135
+ it("renders an operator avatar on the assistant side", () => {
1136
+ const wrapper = createStandardBubble(
1137
+ makeMessage({ id: "op-avatar", role: "operator", content: "Hello" }),
1138
+ ({ text }) => text,
1139
+ { avatar: { show: true, position: "left" } }
1140
+ );
1141
+ expect(wrapper.classList.contains("persona-message-with-avatar")).toBe(true);
1142
+ expect(wrapper.classList.contains("persona-flex-row-reverse")).toBe(false);
1143
+ const avatar = wrapper.firstElementChild as HTMLElement;
1144
+ expect(avatar.textContent).toBe("O");
1145
+ expect(avatar.classList.contains("persona-bg-persona-container")).toBe(true);
1146
+ expect(avatar.classList.contains("persona-bg-persona-primary")).toBe(false);
1147
+
1148
+ const custom = createStandardBubble(
1149
+ makeMessage({ id: "op-avatar-url", role: "operator", content: "Hello" }),
1150
+ ({ text }) => text,
1151
+ { avatar: { show: true, operatorAvatar: "https://example.com/sam.png" } }
1152
+ );
1153
+ const img = custom.querySelector("img");
1154
+ expect(img?.getAttribute("src")).toBe("https://example.com/sam.png");
1155
+ expect(img?.getAttribute("alt")).toBe("Operator");
1156
+ });
1157
+ });