@runtypelabs/persona 3.37.0 → 4.0.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 (50) hide show
  1. package/README.md +0 -1
  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-DgYsuwXL.d.cts → types-B_xbfvR0.d.cts} +263 -181
  5. package/dist/animations/{types-DgYsuwXL.d.ts → types-B_xbfvR0.d.ts} +263 -181
  6. package/dist/animations/wipe.d.cts +1 -1
  7. package/dist/animations/wipe.d.ts +1 -1
  8. package/dist/codegen.cjs +1 -1
  9. package/dist/codegen.js +1 -1
  10. package/dist/index.cjs +52 -52
  11. package/dist/index.cjs.map +1 -1
  12. package/dist/index.d.cts +327 -838
  13. package/dist/index.d.ts +327 -838
  14. package/dist/index.global.js +39 -39
  15. package/dist/index.global.js.map +1 -1
  16. package/dist/index.js +52 -52
  17. package/dist/index.js.map +1 -1
  18. package/dist/install.global.js +1 -1
  19. package/dist/install.global.js.map +1 -1
  20. package/dist/smart-dom-reader.d.cts +268 -191
  21. package/dist/smart-dom-reader.d.ts +268 -191
  22. package/dist/theme-editor-preview.cjs +53 -53
  23. package/dist/theme-editor-preview.d.cts +268 -191
  24. package/dist/theme-editor-preview.d.ts +268 -191
  25. package/dist/theme-editor-preview.js +55 -55
  26. package/dist/theme-editor.d.cts +268 -191
  27. package/dist/theme-editor.d.ts +268 -191
  28. package/package.json +1 -1
  29. package/src/client.test.ts +446 -1041
  30. package/src/client.ts +684 -967
  31. package/src/components/tool-bubble.ts +46 -33
  32. package/src/generated/runtype-openapi-contract.ts +210 -714
  33. package/src/index-core.ts +2 -3
  34. package/src/install.test.ts +1 -34
  35. package/src/install.ts +1 -26
  36. package/src/runtime/init.test.ts +8 -67
  37. package/src/runtime/init.ts +2 -17
  38. package/src/session.test.ts +8 -8
  39. package/src/session.ts +1 -1
  40. package/src/types.ts +11 -12
  41. package/src/ui.postprocess.test.ts +107 -0
  42. package/src/ui.ts +40 -5
  43. package/src/utils/__fixtures__/unified-translator.oracle.ts +62 -14
  44. package/src/utils/copy-selection.test.ts +37 -0
  45. package/src/utils/copy-selection.ts +19 -0
  46. package/src/utils/event-stream-capture.test.ts +9 -64
  47. package/src/utils/sequence-buffer.test.ts +0 -256
  48. package/src/utils/sequence-buffer.ts +0 -130
  49. package/src/utils/unified-event-bridge.test.ts +0 -263
  50. package/src/utils/unified-event-bridge.ts +0 -431
package/src/index-core.ts CHANGED
@@ -108,11 +108,10 @@ export type {
108
108
  } from "./types";
109
109
 
110
110
  export type {
111
- RuntypeAgentSSEEvent,
111
+ RuntypeExecutionStreamEvent,
112
112
  RuntypeFlowSSEEvent,
113
- RuntypeDispatchSSEEvent,
114
113
  RuntypeStreamEventOf,
115
- RuntypeAgentTurnCompleteEvent,
114
+ RuntypeTurnCompleteEvent,
116
115
  RuntypeStepCompleteEvent,
117
116
  RuntypeStopReasonKind,
118
117
  RuntypeClientInitRequest,
@@ -15,7 +15,6 @@ const PERSONA_EVENTS = [
15
15
  "persona:script-load",
16
16
  "persona:launcher-shown",
17
17
  "persona:chat-ready",
18
- "persona:ready",
19
18
  "persona:error",
20
19
  ] as const;
21
20
 
@@ -28,7 +27,6 @@ let launcherDestroy: ReturnType<typeof vi.fn>;
28
27
  let launcherElement: HTMLButtonElement;
29
28
  let initAgentWidget: ReturnType<typeof vi.fn>;
30
29
  let fakeHandle: FakeHandle;
31
- let warnSpy: ReturnType<typeof vi.spyOn>;
32
30
  let errorSpy: ReturnType<typeof vi.spyOn>;
33
31
 
34
32
  /** Subscribe to every persona:* event and record dispatch order + detail. */
@@ -128,7 +126,7 @@ beforeEach(() => {
128
126
  fakeHandle = { open: vi.fn(), close: vi.fn(), on: vi.fn() };
129
127
  initAgentWidget = vi.fn(() => fakeHandle);
130
128
 
131
- warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
129
+ vi.spyOn(console, "warn").mockImplementation(() => {});
132
130
  errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
133
131
  });
134
132
 
@@ -345,37 +343,6 @@ describe("install.ts: deferral gate", () => {
345
343
  });
346
344
  });
347
345
 
348
- describe("install.ts: onReady deprecation alias", () => {
349
- it("still calls the deprecated onReady and warns once", async () => {
350
- markCssLoaded();
351
- provideFullBundle();
352
- const onReady = vi.fn();
353
-
354
- await install({ config: { apiUrl: "/api", launcher: { enabled: false } }, onReady });
355
- await flush();
356
-
357
- expect(onReady).toHaveBeenCalledTimes(1);
358
- expect(onReady).toHaveBeenCalledWith(fakeHandle);
359
- expect(warnSpy).toHaveBeenCalledTimes(1);
360
- expect(String(warnSpy.mock.calls[0]?.[0])).toContain("`onReady` is deprecated");
361
- });
362
-
363
- it("prefers onChatReady over onReady and does not warn", async () => {
364
- markCssLoaded();
365
- provideFullBundle();
366
- const onReady = vi.fn();
367
- const onChatReady = vi.fn();
368
-
369
- await install({ config: { apiUrl: "/api", launcher: { enabled: false } }, onReady, onChatReady });
370
- await flush();
371
-
372
- expect(onChatReady).toHaveBeenCalledTimes(1);
373
- expect(onChatReady).toHaveBeenCalledWith(fakeHandle);
374
- expect(onReady).not.toHaveBeenCalled();
375
- expect(warnSpy).not.toHaveBeenCalled();
376
- });
377
- });
378
-
379
346
  describe("install.ts: onError", () => {
380
347
  it("fires onError with phase 'init' and dispatches persona:error when initialization throws", async () => {
381
348
  markCssLoaded();
package/src/install.ts CHANGED
@@ -18,9 +18,6 @@ interface SiteAgentInstallConfig {
18
18
  clientToken?: string;
19
19
  flowId?: string;
20
20
  apiUrl?: string;
21
- /** Wire vocabulary requested from the dispatch endpoint; 'unified' opts into the
22
- * API's neutral event schema (transparently bridged back to the same rendering). */
23
- events?: "legacy" | "unified";
24
21
  // Optional query param key that gates widget installation in preview mode
25
22
  previewQueryParam?: string;
26
23
  // Shadow DOM option (defaults to false for better CSS compatibility)
@@ -46,11 +43,6 @@ interface SiteAgentInstallConfig {
46
43
  * installs: on page load.
47
44
  */
48
45
  onChatReady?: (handle: any) => void;
49
- /**
50
- * @deprecated Use `onChatReady`. Retained as a working alias; it will be
51
- * removed in the next major version.
52
- */
53
- onReady?: (handle: any) => void;
54
46
  /**
55
47
  * Fired when a load step fails (stylesheet, full bundle, or init), so you can
56
48
  * detect ad-blocked / timed-out installs instead of failing silently.
@@ -163,21 +155,6 @@ declare global {
163
155
  safeCall(config.onError, { phase, error });
164
156
  dispatchLifecycle("persona:error", { phase, error });
165
157
  };
166
- // `onReady` is the deprecated alias of `onChatReady`; warn once if it's used.
167
- let warnedOnReadyDeprecated = false;
168
- const resolveChatReady = (): ((handle: any) => void) | undefined => {
169
- if (config.onChatReady) return config.onChatReady;
170
- if (config.onReady) {
171
- if (!warnedOnReadyDeprecated) {
172
- warnedOnReadyDeprecated = true;
173
- console.warn(
174
- "[Persona] `onReady` is deprecated: use `onChatReady`. `onReady` still works but is removed in the next major."
175
- );
176
- }
177
- return config.onReady;
178
- }
179
- return undefined;
180
- };
181
158
  // True when the config renders a standard floating launcher button: the only
182
159
  // case that paints a clickable launcher at load. Shared by the deferral gate
183
160
  // and the eager-path `onLauncherShown` so the event name stays honest.
@@ -380,7 +357,6 @@ declare global {
380
357
  if (config.apiUrl && !widgetConfig.apiUrl) widgetConfig.apiUrl = config.apiUrl;
381
358
  if (config.clientToken && !widgetConfig.clientToken) widgetConfig.clientToken = config.clientToken;
382
359
  if (config.flowId && !widgetConfig.flowId) widgetConfig.flowId = config.flowId;
383
- if (config.events && !widgetConfig.events) widgetConfig.events = config.events;
384
360
 
385
361
  const hasApiConfig = !!(widgetConfig.apiUrl || widgetConfig.clientToken);
386
362
  return { target, widgetConfig, hasApiConfig };
@@ -433,9 +409,8 @@ declare global {
433
409
  }
434
410
 
435
411
  // The full widget is initialized and its controller API is callable.
436
- safeCall(resolveChatReady(), handle);
412
+ safeCall(config.onChatReady, handle);
437
413
  dispatchLifecycle("persona:chat-ready", handle);
438
- dispatchLifecycle("persona:ready", handle); // deprecated alias: removed next major
439
414
  return handle;
440
415
  } catch (error) {
441
416
  fail("init", error);
@@ -122,21 +122,6 @@ describe("initAgentWidget windowKey and ready notifications", () => {
122
122
  handle.destroy();
123
123
  });
124
124
 
125
- it("calls onReady after initialization", async () => {
126
- const { initAgentWidget } = await import("./init");
127
- document.body.innerHTML = `<div id="target"></div>`;
128
-
129
- const onReady = vi.fn();
130
- const handle = initAgentWidget({
131
- target: "#target",
132
- onReady,
133
- config: { launcher: { enabled: false } },
134
- });
135
-
136
- expect(onReady).toHaveBeenCalledOnce();
137
- handle.destroy();
138
- });
139
-
140
125
  it("the window key handle proxies controller methods", async () => {
141
126
  const { initAgentWidget } = await import("./init");
142
127
  document.body.innerHTML = `<div id="target"></div>`;
@@ -159,7 +144,7 @@ describe("initAgentWidget windowKey and ready notifications", () => {
159
144
  });
160
145
  });
161
146
 
162
- describe("initAgentWidget onChatReady / onReady deprecation", () => {
147
+ describe("initAgentWidget onChatReady", () => {
163
148
  beforeEach(() => {
164
149
  document.body.innerHTML = "";
165
150
  createAgentExperienceMock.mockReset();
@@ -184,65 +169,21 @@ describe("initAgentWidget onChatReady / onReady deprecation", () => {
184
169
  handle.destroy();
185
170
  warn.mockRestore();
186
171
  });
187
-
188
- it("prefers onChatReady over the deprecated onReady (onReady never fires, no warning)", async () => {
189
- const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
190
- const { initAgentWidget } = await import("./init");
191
- document.body.innerHTML = `<div id="target"></div>`;
192
-
193
- const onChatReady = vi.fn();
194
- const onReady = vi.fn();
195
- const handle = initAgentWidget({
196
- target: "#target",
197
- onChatReady,
198
- onReady,
199
- config: { launcher: { enabled: false } },
200
- });
201
-
202
- expect(onChatReady).toHaveBeenCalledOnce();
203
- expect(onReady).not.toHaveBeenCalled();
204
- expect(warn).not.toHaveBeenCalled();
205
-
206
- handle.destroy();
207
- warn.mockRestore();
208
- });
209
-
210
- it("still calls the deprecated onReady but warns at most once across multiple widgets", async () => {
211
- // Fresh module so the once-per-page warn flag starts unset.
212
- vi.resetModules();
213
- const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
214
- const { initAgentWidget } = await import("./init");
215
- document.body.innerHTML = `<div id="a"></div><div id="b"></div>`;
216
-
217
- const onReadyA = vi.fn();
218
- const onReadyB = vi.fn();
219
- const a = initAgentWidget({ target: "#a", onReady: onReadyA, config: { launcher: { enabled: false } } });
220
- const b = initAgentWidget({ target: "#b", onReady: onReadyB, config: { launcher: { enabled: false } } });
221
-
222
- expect(onReadyA).toHaveBeenCalledOnce();
223
- expect(onReadyB).toHaveBeenCalledOnce();
224
- expect(warn).toHaveBeenCalledTimes(1);
225
- expect(String(warn.mock.calls[0]?.[0])).toContain("`onReady` is deprecated");
226
-
227
- a.destroy();
228
- b.destroy();
229
- warn.mockRestore();
230
- });
231
172
  });
232
173
 
233
- describe("install script onReady and persona:ready event", () => {
174
+ describe("install script persona:chat-ready event", () => {
234
175
  beforeEach(() => {
235
176
  document.body.innerHTML = "";
236
177
  createAgentExperienceMock.mockReset();
237
178
  createAgentExperienceMock.mockImplementation((_mount, config) => createMockController(config));
238
179
  });
239
180
 
240
- it("persona:ready event fires with the handle as detail", async () => {
181
+ it("persona:chat-ready event fires with the handle as detail", async () => {
241
182
  const { initAgentWidget } = await import("./init");
242
183
  document.body.innerHTML = `<div id="target"></div>`;
243
184
 
244
185
  const eventPromise = new Promise<any>((resolve) => {
245
- window.addEventListener("persona:ready", (e) => {
186
+ window.addEventListener("persona:chat-ready", (e) => {
246
187
  resolve((e as CustomEvent).detail);
247
188
  }, { once: true });
248
189
  });
@@ -254,7 +195,7 @@ describe("install script onReady and persona:ready event", () => {
254
195
  });
255
196
 
256
197
  // Simulate what install.ts does after initAgentWidget returns
257
- window.dispatchEvent(new CustomEvent("persona:ready", { detail: handle }));
198
+ window.dispatchEvent(new CustomEvent("persona:chat-ready", { detail: handle }));
258
199
 
259
200
  const detail = await eventPromise;
260
201
  expect(detail).toBe(handle);
@@ -264,12 +205,12 @@ describe("install script onReady and persona:ready event", () => {
264
205
  handle.destroy();
265
206
  });
266
207
 
267
- it("persona:ready event listener set up before init receives the handle", async () => {
208
+ it("persona:chat-ready event listener set up before init receives the handle", async () => {
268
209
  const { initAgentWidget } = await import("./init");
269
210
  document.body.innerHTML = `<div id="target"></div>`;
270
211
 
271
212
  const received: any[] = [];
272
- window.addEventListener("persona:ready", (e) => {
213
+ window.addEventListener("persona:chat-ready", (e) => {
273
214
  received.push((e as CustomEvent).detail);
274
215
  }, { once: true });
275
216
 
@@ -279,7 +220,7 @@ describe("install script onReady and persona:ready event", () => {
279
220
  });
280
221
 
281
222
  // Simulate install.ts dispatching the event
282
- window.dispatchEvent(new CustomEvent("persona:ready", { detail: handle }));
223
+ window.dispatchEvent(new CustomEvent("persona:chat-ready", { detail: handle }));
283
224
 
284
225
  expect(received).toHaveLength(1);
285
226
  expect(received[0]).toBe(handle);
@@ -3,9 +3,6 @@ import { AgentWidgetConfig as _AgentWidgetConfig, AgentWidgetInitOptions, AgentW
3
3
  import { isComposerBarMountMode, isDockedMountMode } from "../utils/dock";
4
4
  import { createWidgetHostLayout } from "./host-layout";
5
5
 
6
- // Warn at most once per page when the deprecated `onReady` alias is used.
7
- let warnedOnReadyDeprecated = false;
8
-
9
6
  const ensureTarget = (target: string | HTMLElement): HTMLElement => {
10
7
  if (typeof window === "undefined" || typeof document === "undefined") {
11
8
  throw new Error("Chat widget can only be mounted in a browser environment");
@@ -160,20 +157,8 @@ export const initAgentWidget = (
160
157
  };
161
158
 
162
159
  mountController();
163
- // Fired when the controller is mounted and its API is callable. `onReady` is
164
- // the deprecated alias of `onChatReady` (removed next major): warn once.
165
- if (options.onChatReady) {
166
- options.onChatReady();
167
- } else if (options.onReady) {
168
- if (!warnedOnReadyDeprecated) {
169
- warnedOnReadyDeprecated = true;
170
- // eslint-disable-next-line no-console
171
- console.warn(
172
- "[Persona] `onReady` is deprecated: use `onChatReady`. `onReady` still works but is removed in the next major."
173
- );
174
- }
175
- options.onReady();
176
- }
160
+ // Fired when the controller is mounted and its API is callable.
161
+ options.onChatReady?.();
177
162
 
178
163
  const rebuildLayout = (nextConfig?: _AgentWidgetConfig) => {
179
164
  destroyCurrentController();
@@ -893,7 +893,7 @@ describe('AgentWidgetSession.resolveApproval', () => {
893
893
  });
894
894
  });
895
895
 
896
- describe('AgentWidgetSession - approval context across agent_approval_complete', () => {
896
+ describe('AgentWidgetSession - approval context across approval_complete', () => {
897
897
  const sseStream = (events: Array<Record<string, unknown>>): ReadableStream<Uint8Array> => {
898
898
  const encoder = new TextEncoder();
899
899
  const body = events
@@ -919,14 +919,14 @@ describe('AgentWidgetSession - approval context across agent_approval_complete',
919
919
  }
920
920
  );
921
921
 
922
- // `agent_approval_complete` carries only the resolution: none of the
923
- // context fields from `agent_approval_start`. The session merge must keep
924
- // them so a full re-render of the resolved bubble (morph, virtual-scroll
925
- // re-mount, storage restore) still shows the tool, description, and the
926
- // agent's stated reason.
922
+ // Unified `approval_complete` carries only the resolution: none of the
923
+ // context fields from `approval_start`. The session merge must keep them so
924
+ // a full re-render of the resolved bubble (morph, virtual-scroll re-mount,
925
+ // storage restore) still shows the tool, description, and the agent's stated
926
+ // reason.
927
927
  await session.connectStream(sseStream([
928
928
  {
929
- type: 'agent_approval_start',
929
+ type: 'approval_start',
930
930
  executionId: 'exec_abc',
931
931
  approvalId: 'appr_1',
932
932
  toolName: 'send_email',
@@ -936,7 +936,7 @@ describe('AgentWidgetSession - approval context across agent_approval_complete',
936
936
  parameters: { to: 'customer@example.com' },
937
937
  },
938
938
  {
939
- type: 'agent_approval_complete',
939
+ type: 'approval_complete',
940
940
  executionId: 'exec_abc',
941
941
  approvalId: 'appr_1',
942
942
  decision: 'approved',
package/src/session.ts CHANGED
@@ -2920,7 +2920,7 @@ export class AgentWidgetSession {
2920
2920
  awaitingLocalTool: false,
2921
2921
  };
2922
2922
  }
2923
- // Approval equivalent: `agent_approval_complete` carries only the
2923
+ // Approval equivalent: `approval_complete` carries only the
2924
2924
  // resolution (approvalId, decision, resolvedBy): the runtime does not
2925
2925
  // re-send toolName/description/toolType/reason/parameters, so client.ts
2926
2926
  // rebuilds the approval with empty required fields and the optional
package/src/types.ts CHANGED
@@ -3588,6 +3588,11 @@ export type AgentWidgetConfig = {
3588
3588
  * When provided, the widget uses agent loop execution instead of flow dispatch.
3589
3589
  * Mutually exclusive with `flowId`.
3590
3590
  *
3591
+ * Note: `@runtypelabs/persona-proxy` rejects a client-supplied `agent` (it was
3592
+ * an open relay). This option is only for endpoints authorized to accept a
3593
+ * client agent — your own backend, or token-scoped direct-to-Runtype dispatch.
3594
+ * For the proxy, pin the agent server-side with `agentConfig`/`agentId`.
3595
+ *
3591
3596
  * @example
3592
3597
  * ```typescript
3593
3598
  * config: {
@@ -4056,16 +4061,6 @@ export type AgentWidgetConfig = {
4056
4061
  * ```
4057
4062
  */
4058
4063
  parseSSEEvent?: AgentWidgetSSEEventParser;
4059
- /**
4060
- * Wire vocabulary requested from the dispatch endpoint.
4061
- * - `'legacy'` (default): the current `agent_*` / `flow_*` SSE events.
4062
- * - `'unified'`: opt into the API's neutral unified event vocabulary by sending
4063
- * `?events=unified`; incoming frames are transparently bridged back to the same
4064
- * handlers, so rendering is unchanged. Requires an upstream that honors the
4065
- * param — the widget detects the actual wire mode from the first stream frame
4066
- * and falls back to legacy automatically if the param was ignored.
4067
- */
4068
- events?: "legacy" | "unified";
4069
4064
  /**
4070
4065
  * Called for every parsed SSE frame (after JSON parse), before native handling.
4071
4066
  * Use for lightweight side effects (e.g. telemetry). Does not replace native
@@ -4254,6 +4249,12 @@ export type AgentWidgetReasoning = {
4254
4249
  id: string;
4255
4250
  status: "pending" | "streaming" | "complete";
4256
4251
  chunks: string[];
4252
+ /**
4253
+ * Reasoning channel scope (unified spec). `"turn"` is ordinary per-turn
4254
+ * thinking; `"loop"` is a cross-iteration agent reflection (the fold that
4255
+ * replaced the legacy `agent_reflection` event). Absent for legacy streams.
4256
+ */
4257
+ scope?: "turn" | "loop";
4257
4258
  startedAt?: number;
4258
4259
  completedAt?: number;
4259
4260
  durationMs?: number;
@@ -4633,8 +4634,6 @@ export type AgentWidgetInitOptions = {
4633
4634
  useShadowDom?: boolean;
4634
4635
  /** Fired when the widget controller is mounted and its API is callable. */
4635
4636
  onChatReady?: () => void;
4636
- /** @deprecated Use `onChatReady`. Retained as an alias; removed in the next major. */
4637
- onReady?: () => void;
4638
4637
  windowKey?: string; // If provided, stores the controller on window[windowKey] for global access
4639
4638
  debugTools?: boolean;
4640
4639
  };
@@ -0,0 +1,107 @@
1
+ // @vitest-environment jsdom
2
+
3
+ import { afterEach, describe, expect, it, vi } from "vitest";
4
+
5
+ import { buildPostprocessor } from "./ui";
6
+ import { escapeHtml } from "./postprocessors";
7
+ import * as parsersLoader from "./markdown-parsers-loader";
8
+ import type { AgentWidgetConfig, AgentWidgetMessage } from "./types";
9
+
10
+ /**
11
+ * Regression tests for the degraded (parsers-not-loaded) render path.
12
+ *
13
+ * In the IIFE/CDN build, `marked` + `DOMPurify` load lazily. Until they resolve
14
+ * (or permanently, if the chunk 404s), both the markdown processor and the
15
+ * sanitizer fall back to `escapeHtml`. The old blanket `sanitize(html)` composed
16
+ * the two fallbacks and escaped twice ("I'll" -> "I&amp;#39;"). The bug lives in
17
+ * the COMPOSITION inside `buildPostprocessor`, so these tests drive the transform
18
+ * end-to-end rather than testing the primitives in isolation.
19
+ *
20
+ * `vitest.setup.ts` eager-provides parsers, so `getMarkdownParsersSync()` is
21
+ * non-null by default -> that's the "loaded" path. The degraded path is simulated
22
+ * by spying on the loader and returning null. Each consuming module
23
+ * (`ui.ts`, `sanitize.ts`, `postprocessors.ts`) calls the imported binding
24
+ * directly per invocation, so the spy propagates.
25
+ */
26
+
27
+ const SAMPLE = "a & b < c, I'll go";
28
+
29
+ const callTransform = (
30
+ cfg: AgentWidgetConfig | undefined,
31
+ text: string
32
+ ): string => {
33
+ const transform = buildPostprocessor(cfg, undefined, undefined);
34
+ const message: AgentWidgetMessage = {
35
+ id: "1",
36
+ role: "assistant",
37
+ content: text,
38
+ createdAt: "2026-06-17T00:00:00.000Z",
39
+ };
40
+ return transform({ text, message, streaming: false });
41
+ };
42
+
43
+ const simulateParsersNotLoaded = () => {
44
+ vi.spyOn(parsersLoader, "getMarkdownParsersSync").mockReturnValue(null);
45
+ };
46
+
47
+ afterEach(() => {
48
+ vi.restoreAllMocks();
49
+ });
50
+
51
+ describe("buildPostprocessor degraded path (parsers not loaded)", () => {
52
+ it("escapes plain text exactly once (no markdown config, default sanitize)", () => {
53
+ simulateParsersNotLoaded();
54
+ const out = callTransform(undefined, SAMPLE);
55
+
56
+ expect(out).toContain("I&#39;ll");
57
+ expect(out).toContain("a &amp; b");
58
+ expect(out).not.toContain("&amp;#39;");
59
+ expect(out).not.toContain("&amp;amp;");
60
+ });
61
+
62
+ it("escapes exactly once when a markdown config is set", () => {
63
+ simulateParsersNotLoaded();
64
+ const out = callTransform({ markdown: {} }, SAMPLE);
65
+
66
+ // Markdown processor falls back to escapeHtml while parsers are not loaded,
67
+ // and the sanitizer is skipped (parsersReady === false) so it stays single.
68
+ expect(out).toBe(escapeHtml(SAMPLE));
69
+ expect(out).not.toContain("&amp;#39;");
70
+ expect(out).not.toContain("&amp;amp;");
71
+ });
72
+
73
+ it("escapes custom postprocessMessage HTML exactly once (default sanitize)", () => {
74
+ simulateParsersNotLoaded();
75
+ const cfg: AgentWidgetConfig = {
76
+ postprocessMessage: () => "<b>I'll</b>",
77
+ };
78
+ const out = callTransform(cfg, SAMPLE);
79
+
80
+ // The sanitizer's degraded fallback escapes the raw custom HTML — but only
81
+ // once, because custom HTML is NOT pre-escaped.
82
+ expect(out).toBe("&lt;b&gt;I&#39;ll&lt;/b&gt;");
83
+ expect(out).not.toContain("&amp;#39;");
84
+ });
85
+
86
+ it("regression: degraded plain text is single-escaped, never double-applied", () => {
87
+ simulateParsersNotLoaded();
88
+ const out = callTransform(undefined, SAMPLE);
89
+
90
+ expect(out).toBe(escapeHtml(SAMPLE));
91
+ expect(out).not.toBe(escapeHtml(escapeHtml(SAMPLE)));
92
+ });
93
+ });
94
+
95
+ describe("buildPostprocessor loaded path (parsers available)", () => {
96
+ it("renders markdown to real HTML, normalizes apostrophes, strips scripts", () => {
97
+ // No spy: vitest.setup.ts eager-provides parsers, so this is the loaded path.
98
+ const text = "**bold** I'll go\n\n<script>alert(1)</script>";
99
+ const out = callTransform({ markdown: {} }, text);
100
+
101
+ expect(out).toContain("<strong>bold</strong>");
102
+ expect(out).toContain("<p>");
103
+ expect(out).toContain("I'll");
104
+ expect(out).not.toContain("<script");
105
+ expect(out).not.toContain("&amp;#39;");
106
+ });
107
+ });
package/src/ui.ts CHANGED
@@ -36,6 +36,7 @@ import { resolveTokenValue } from "./utils/tokens";
36
36
  import { renderLucideIcon } from "./utils/icons";
37
37
  import { createElement, createElementInDocument } from "./utils/dom";
38
38
  import { morphMessages } from "./utils/morph";
39
+ import { normalizeCopiedSelectionText } from "./utils/copy-selection";
39
40
  import {
40
41
  navigateComposerHistory,
41
42
  INITIAL_HISTORY_STATE,
@@ -403,7 +404,7 @@ type Controller = {
403
404
  ) => Promise<void>;
404
405
  };
405
406
 
406
- const buildPostprocessor = (
407
+ export const buildPostprocessor = (
407
408
  cfg: AgentWidgetConfig | undefined,
408
409
  actionManager?: ReturnType<typeof createActionManager>,
409
410
  onResubmitRequested?: () => void
@@ -452,21 +453,35 @@ const buildPostprocessor = (
452
453
  }
453
454
  }
454
455
 
455
- // Priority: postprocessMessage > markdown config > escapeHtml
456
+ // Priority: postprocessMessage > markdown config > escapeHtml.
457
+ //
458
+ // Degraded path (IIFE/CDN build before `markdown-parsers.js` resolves, or if
459
+ // it never loads): the markdown processor and the sanitizer BOTH fall back to
460
+ // escapeHtml, so the old `sanitize(markdownProcessor(text))` escaped twice and
461
+ // displayed entities literally (I'll -> &amp;#39;). Only run the sanitizer when
462
+ // it can actually parse HTML; escapeHtml output is already inert. Checked per
463
+ // render (not once at setup): the chunk lands later and the self-heal re-renders.
464
+ const parsersReady = getMarkdownParsersSync() !== null;
456
465
  let html: string;
457
466
  if (cfg?.postprocessMessage) {
458
- html = cfg.postprocessMessage({
467
+ const out = cfg.postprocessMessage({
459
468
  ...context,
460
469
  text: nextText,
461
470
  raw: rawPayload ?? context.text ?? ""
462
471
  });
472
+ // Custom HTML is NOT pre-escaped, so this stays a single pass even via the
473
+ // sanitizer's degraded fallback. Honors `sanitize: false` (pass-through) as before.
474
+ html = sanitize ? sanitize(out) : out;
463
475
  } else if (markdownProcessor) {
464
- html = markdownProcessor(nextText);
476
+ // Already escapeHtml(text) (single, safe) while parsers are not loaded.
477
+ const out = markdownProcessor(nextText);
478
+ html = sanitize && parsersReady ? sanitize(out) : out;
465
479
  } else {
480
+ // Plain text: escapeHtml output is inert — never re-sanitize (the second escape).
466
481
  html = escapeHtml(nextText);
467
482
  }
468
483
 
469
- return sanitize ? sanitize(html) : html;
484
+ return html;
470
485
  };
471
486
  };
472
487
 
@@ -1355,6 +1370,26 @@ export const createAgentExperience = (
1355
1370
  }
1356
1371
  });
1357
1372
 
1373
+ // Normalize manual (triple-click + Ctrl/Cmd-C) copies of message text. The
1374
+ // browser serializes the DOM selection, and block-level markdown elements
1375
+ // (<p>, <li>, <pre>, …) emit surrounding newlines — so a single-message copy
1376
+ // arrives with stray trailing/leading blank lines. Rewrite the clipboard's
1377
+ // plain text to the trimmed selection so the buffer matches what was visibly
1378
+ // highlighted. (The Copy action button is unaffected; it uses message.content.)
1379
+ messagesWrapper.addEventListener('copy', (event) => {
1380
+ const { clipboardData } = event;
1381
+ if (!clipboardData) return;
1382
+ const root = messagesWrapper.getRootNode() as { getSelection?: () => Selection | null };
1383
+ const selection =
1384
+ typeof root.getSelection === 'function' ? root.getSelection() : window.getSelection();
1385
+ if (!selection || selection.isCollapsed) return;
1386
+ const raw = selection.toString();
1387
+ const normalized = normalizeCopiedSelectionText(raw);
1388
+ if (!normalized || normalized === raw) return;
1389
+ clipboardData.setData('text/plain', normalized);
1390
+ event.preventDefault();
1391
+ });
1392
+
1358
1393
  // Add event delegation for message action buttons (upvote, downvote, copy)
1359
1394
  // This handles clicks even after idiomorph morphs the DOM and strips inline listeners
1360
1395
  const messageVoteState = new Map<string, "upvote" | "downvote">();