@runtypelabs/persona 4.30.0 → 4.32.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 (139) hide show
  1. package/README.md +22 -1
  2. package/dist/activity-ui.cjs +2 -2
  3. package/dist/activity-ui.d.cts +11 -3
  4. package/dist/activity-ui.d.ts +11 -3
  5. package/dist/activity-ui.js +1 -1
  6. package/dist/animations/glyph-cycle.d.cts +1 -1
  7. package/dist/animations/glyph-cycle.d.ts +1 -1
  8. package/dist/animations/wipe.d.cts +1 -1
  9. package/dist/animations/wipe.d.ts +1 -1
  10. package/dist/approval-ui.d.cts +2 -2
  11. package/dist/approval-ui.d.ts +2 -2
  12. package/dist/artifacts-ui.d.cts +3 -3
  13. package/dist/artifacts-ui.d.ts +3 -3
  14. package/dist/background-runtime.d.cts +1 -1
  15. package/dist/background-runtime.d.ts +1 -1
  16. package/dist/chunk-C2IbR39s.js +1 -0
  17. package/dist/chunk-CkE90FtA.js +2 -0
  18. package/dist/{chunk-n96BB0YH.js → chunk-CzZBmTLd.js} +1 -1
  19. package/dist/chunk-DABqu7C3.js +2 -0
  20. package/dist/chunk-DDAwu89-.js +1 -0
  21. package/dist/{chunk-CqEWCkJt.js → chunk-DZg9bFK6.js} +1 -1
  22. package/dist/chunk-DbD5qyBw.js +3 -0
  23. package/dist/{chunk-gayfbmEU.js → chunk-FEzOcZof.js} +1 -1
  24. package/dist/{chunk-UyZbbbz3.js → chunk-MdlQetfC.js} +2 -2
  25. package/dist/client-dispatch.js +1 -1
  26. package/dist/client-history.js +1 -1
  27. package/dist/codegen.cjs +1 -1
  28. package/dist/codegen.js +1 -1
  29. package/dist/composer-actions-runtime.d.cts +2 -2
  30. package/dist/composer-actions-runtime.d.ts +2 -2
  31. package/dist/{composer-input-C76gfIoK.d.cts → composer-input-dwg_o9Z-.d.cts} +1 -1
  32. package/dist/{composer-input-C76gfIoK.d.ts → composer-input-dwg_o9Z-.d.ts} +1 -1
  33. package/dist/{composer-store-C7tvaz90.d.cts → composer-store-Dw7Zbqxr.d.cts} +1 -1
  34. package/dist/{composer-store-C7tvaz90.d.ts → composer-store-Dw7Zbqxr.d.ts} +1 -1
  35. package/dist/{context-mention-manager-IfT4JHma.d.cts → context-mention-manager-D2p0-JH-.d.cts} +2 -2
  36. package/dist/{context-mention-manager-IfT4JHma.d.ts → context-mention-manager-D2p0-JH-.d.ts} +2 -2
  37. package/dist/context-mentions-inline.d.cts +2 -2
  38. package/dist/context-mentions-inline.d.ts +2 -2
  39. package/dist/context-mentions.d.cts +3 -3
  40. package/dist/context-mentions.d.ts +3 -3
  41. package/dist/{defaults-Cc_p0PGM.d.cts → defaults-CjX0kKzb.d.cts} +1 -1
  42. package/dist/{defaults-Cc_p0PGM.d.ts → defaults-CjX0kKzb.d.ts} +1 -1
  43. package/dist/dictation-runtime.d.cts +2 -2
  44. package/dist/dictation-runtime.d.ts +2 -2
  45. package/dist/event-stream-view.d.cts +1 -1
  46. package/dist/event-stream-view.d.ts +1 -1
  47. package/dist/{fallback-speech-engine-CSxWpG0A.d.cts → fallback-speech-engine-DmF7MczI.d.cts} +1 -1
  48. package/dist/{fallback-speech-engine-CSxWpG0A.d.ts → fallback-speech-engine-DmF7MczI.d.ts} +1 -1
  49. package/dist/{feature-preferences-eeSOmDxg.d.cts → feature-preferences-CVFDt9Xf.d.cts} +1 -1
  50. package/dist/{feature-preferences-eeSOmDxg.d.ts → feature-preferences-CVFDt9Xf.d.ts} +1 -1
  51. package/dist/forms-ui.d.cts +2 -2
  52. package/dist/forms-ui.d.ts +2 -2
  53. package/dist/{history-confirm-dialog-DXkuAgh-.d.cts → history-confirm-dialog-DJEXsTyE.d.cts} +1 -1
  54. package/dist/{history-confirm-dialog-DXkuAgh-.d.ts → history-confirm-dialog-DJEXsTyE.d.ts} +1 -1
  55. package/dist/history-shell.d.cts +6 -6
  56. package/dist/history-shell.d.ts +6 -6
  57. package/dist/history-view.d.cts +1 -1
  58. package/dist/history-view.d.ts +1 -1
  59. package/dist/index.cjs +49 -47
  60. package/dist/index.cjs.map +1 -1
  61. package/dist/index.d.cts +9 -9
  62. package/dist/index.d.ts +9 -9
  63. package/dist/index.global.js +1 -1
  64. package/dist/index.global.js.map +1 -1
  65. package/dist/index.js +43 -41
  66. package/dist/index.js.map +1 -1
  67. package/dist/{loading-label-runtime-loader-C53YyPJ-.d.cts → loading-label-runtime-loader-CPxnoaoi.d.cts} +1 -1
  68. package/dist/{loading-label-runtime-loader-C53YyPJ-.d.ts → loading-label-runtime-loader-CPxnoaoi.d.ts} +1 -1
  69. package/dist/loading-label-runtime.d.cts +1 -1
  70. package/dist/loading-label-runtime.d.ts +1 -1
  71. package/dist/markdown-parsers.js +18 -15
  72. package/dist/{message-bubble-Cg_sTXUN.d.cts → message-bubble-CkZGlCLC.d.cts} +1 -1
  73. package/dist/{message-bubble-Cg_sTXUN.d.ts → message-bubble-CkZGlCLC.d.ts} +1 -1
  74. package/dist/preferences-runtime.d.cts +1 -1
  75. package/dist/preferences-runtime.d.ts +1 -1
  76. package/dist/{preview-BeMKALry.d.cts → preview-DdUvoLb4.d.cts} +2 -2
  77. package/dist/{preview-BeMKALry.d.ts → preview-DdUvoLb4.d.ts} +2 -2
  78. package/dist/{registry-BBe-Lesw.d.ts → registry-Dz4iHNST.d.cts} +3 -1
  79. package/dist/{registry-BBe-Lesw.d.cts → registry-Dz4iHNST.d.ts} +3 -1
  80. package/dist/{session-C0fSteA4.d.cts → session-Baxp9iRK.d.cts} +24 -9
  81. package/dist/{session-C0fSteA4.d.ts → session-Baxp9iRK.d.ts} +24 -9
  82. package/dist/session-reconnect.d.cts +2 -2
  83. package/dist/session-reconnect.d.ts +2 -2
  84. package/dist/smart-dom-reader.d.cts +1 -1
  85. package/dist/smart-dom-reader.d.ts +1 -1
  86. package/dist/theme-editor-preview.cjs +47 -45
  87. package/dist/theme-editor-preview.d.cts +1 -1
  88. package/dist/theme-editor-preview.d.ts +1 -1
  89. package/dist/theme-editor-preview.js +23 -24
  90. package/dist/theme-editor.d.cts +2 -2
  91. package/dist/theme-editor.d.ts +2 -2
  92. package/dist/theme-reference.d.cts +2 -2
  93. package/dist/theme-reference.d.ts +2 -2
  94. package/dist/{tool-loading-animation-BvdaZfwh.d.cts → tool-loading-animation-un3kA5Ur.d.cts} +1 -1
  95. package/dist/{tool-loading-animation-BvdaZfwh.d.ts → tool-loading-animation-un3kA5Ur.d.ts} +1 -1
  96. package/dist/{turn-activity-ui-BYBlInZQ.d.cts → turn-activity-ui-BGNhtMcH.d.cts} +2 -2
  97. package/dist/{turn-activity-ui-BYBlInZQ.d.ts → turn-activity-ui-BGNhtMcH.d.ts} +2 -2
  98. package/dist/turn-activity-ui.d.cts +1 -1
  99. package/dist/turn-activity-ui.d.ts +1 -1
  100. package/dist/{types-CiXi4ICM.d.cts → types-BOZHQIk0.d.cts} +13 -1
  101. package/dist/{types-CiXi4ICM.d.ts → types-BOZHQIk0.d.ts} +13 -1
  102. package/dist/voice-client-tools.d.cts +1 -1
  103. package/dist/voice-client-tools.d.ts +1 -1
  104. package/dist/voice-runtime.cjs +1 -1
  105. package/dist/voice-runtime.d.cts +2 -2
  106. package/dist/voice-runtime.d.ts +2 -2
  107. package/dist/voice-runtime.js +1 -1
  108. package/dist/voice-worklet-player.d.cts +2 -2
  109. package/dist/voice-worklet-player.d.ts +2 -2
  110. package/dist/{webmcp-runtime-entry-BTbOg3Au.d.cts → webmcp-runtime-entry-DuAG1_yF.d.cts} +1 -1
  111. package/dist/{webmcp-runtime-entry-BTbOg3Au.d.ts → webmcp-runtime-entry-DuAG1_yF.d.ts} +1 -1
  112. package/dist/webmcp-runtime.d.cts +1 -1
  113. package/dist/webmcp-runtime.d.ts +1 -1
  114. package/dist/widget.css +1 -1
  115. package/package.json +2 -2
  116. package/src/activity-ui.ts +23 -0
  117. package/src/components/disclosure-animation.ts +5 -0
  118. package/src/components/tool-bubble.ts +1 -0
  119. package/src/index-core.ts +2 -0
  120. package/src/markdown-parsers-eager.ts +2 -1
  121. package/src/markdown-parsers-entry.ts +1 -0
  122. package/src/markdown-parsers-loader.ts +2 -0
  123. package/src/postprocessors.ts +14 -0
  124. package/src/session.ts +3 -0
  125. package/src/styles/widget.css +6 -0
  126. package/src/types.ts +13 -0
  127. package/src/ui.background-conversations.test.ts +6 -0
  128. package/src/ui.footer-visibility.test.ts +54 -0
  129. package/src/ui.page-cache.test.ts +268 -0
  130. package/src/ui.postprocess.test.ts +80 -1
  131. package/src/ui.reveal-tool-call.test.ts +258 -0
  132. package/src/ui.ts +222 -32
  133. package/src/utils/actions.ts +3 -0
  134. package/src/utils/streaming-markdown.test.ts +103 -0
  135. package/src/utils/streaming-markdown.ts +186 -0
  136. package/dist/chunk-18tPmsiJ.js +0 -1
  137. package/dist/chunk-B3OrH9Ng.js +0 -2
  138. package/dist/chunk-Dp2vWE9V.js +0 -1
  139. package/dist/chunk-VloQ2dnh.js +0 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@runtypelabs/persona",
3
- "version": "4.30.0",
3
+ "version": "4.32.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",
@@ -236,7 +236,7 @@
236
236
  "check:dist-vite8": "node scripts/check-dist-no-in-for-init.mjs",
237
237
  "build:styles": "node scripts/build-styles.mjs",
238
238
  "lint": "oxlint",
239
- "typecheck": "pnpm run check:runtype-types && tsgo --noEmit",
239
+ "typecheck": "pnpm run check:runtype-types && node ../../scripts/typecheck.mjs --noEmit",
240
240
  "test": "vitest",
241
241
  "test:ui": "vitest --ui",
242
242
  "test:run": "vitest run",
@@ -8,6 +8,29 @@ import { updateToolBubbleUI } from "./components/tool-bubble";
8
8
  import { updateReasoningBubbleUI } from "./components/reasoning-bubble";
9
9
  import type { AgentWidgetConfig, AgentWidgetMessage } from "./types";
10
10
  import { createActivityOverflow } from "./utils/activity-overflow";
11
+ import { afterDisclosureAnimation } from "./components/disclosure-animation";
12
+
13
+ type DisclosureAnchor = { spacerHeight: number; initialSpacerHeight: number; contentHeightAtAnchor: number; pending: number };
14
+
15
+ /** Keep scroll reserve throughout manual disclosure animations and reversals. */
16
+ export function holdScroll(
17
+ bubble: HTMLElement, body: HTMLElement, anchor: DisclosureAnchor,
18
+ measure: (spacer: number) => number, setSpacer: (height: number) => void,
19
+ slack: number, isCurrent: () => boolean
20
+ ): () => void {
21
+ // Reserve the whole possible collapse before changing display or starting an
22
+ // animation. ResizeObserver must not drain it during intermediate frames.
23
+ setSpacer(anchor.spacerHeight + bubble.offsetHeight);
24
+ anchor.pending++;
25
+ return () => afterDisclosureAnimation(bubble, () => {
26
+ if (--anchor.pending || !isCurrent()) return;
27
+ // Respect scrolling that the reader did while the animation was running.
28
+ setSpacer(body.scrollTop + body.clientHeight + slack - measure(anchor.spacerHeight));
29
+ anchor.initialSpacerHeight = anchor.spacerHeight;
30
+ // Match the resize drain, including the flex gap before the spacer.
31
+ anchor.contentHeightAtAnchor = body.scrollHeight - anchor.spacerHeight;
32
+ });
33
+ }
11
34
 
12
35
  /** Keep automatic disclosure updates in the optional activity chunk. */
13
36
  export function createActivityLifecycle(
@@ -7,6 +7,11 @@ type Settings = { animation: "instant" | "height"; duration: number; easing: str
7
7
  type Running = { effect: Animation; expanded: boolean; settings: string; finish: () => void };
8
8
  const running = new WeakMap<HTMLElement, Running>();
9
9
 
10
+ /** Observe this disclosure's height effect without waiting for looping children. */
11
+ export function afterDisclosureAnimation(bubble: HTMLElement, finish: () => void): void {
12
+ running.get(bubble)?.effect.finished?.then(finish, finish) ?? finish();
13
+ }
14
+
10
15
  export function disclosureSettings(config?: AgentWidgetConfig): Settings {
11
16
  const options = config?.expansion;
12
17
  return {
@@ -131,6 +131,7 @@ export const createToolBubble = (
131
131
  if (!tool) {
132
132
  return applyActivityRow(bubble, message, config, "tool");
133
133
  }
134
+ if (tool.id) bubble.dataset.toolCallId = tool.id;
134
135
 
135
136
  const toolDisplayConfig = config.features!.toolCallDisplay!;
136
137
  const expandable = toolDisplayConfig.expandable ?? DEFAULT_TOOL_CALL_DISPLAY.expandable;
package/src/index-core.ts CHANGED
@@ -226,6 +226,7 @@ export type {
226
226
  // Visitor conversation history (client-token mode) types
227
227
  AgentWidgetHistoryFeature,
228
228
  AgentWidgetOpenConversationOptions,
229
+ AgentWidgetRevealToolCallOptions,
229
230
  BackgroundConversationAdapter,
230
231
  ConversationExecutionState,
231
232
  AgentWidgetHistoryListAction,
@@ -395,6 +396,7 @@ export {
395
396
  } from "./utils/actions";
396
397
  export {
397
398
  markdownPostprocessor,
399
+ preprocessStreamingMarkdown,
398
400
  escapeHtml,
399
401
  directivePostprocessor,
400
402
  createMarkdownProcessor,
@@ -16,5 +16,6 @@
16
16
  import { Marked } from "marked";
17
17
  import DOMPurify from "dompurify";
18
18
  import { provideMarkdownParsers } from "./markdown-parsers-loader";
19
+ import { stabilizeStreamingMarkdown } from "./utils/streaming-markdown";
19
20
 
20
- provideMarkdownParsers({ Marked, DOMPurify });
21
+ provideMarkdownParsers({ Marked, DOMPurify, stabilizeStreamingMarkdown });
@@ -2,3 +2,4 @@ import { Marked } from "marked";
2
2
  import DOMPurify from "dompurify";
3
3
 
4
4
  export { Marked, DOMPurify };
5
+ export { stabilizeStreamingMarkdown } from "./utils/streaming-markdown";
@@ -5,6 +5,8 @@ import type DOMPurify from "dompurify";
5
5
  export type MarkdownParsersModule = {
6
6
  Marked: typeof Marked;
7
7
  DOMPurify: typeof DOMPurify;
8
+ /** Optional for compatibility with custom parser loaders. Display-only repairs. */
9
+ stabilizeStreamingMarkdown?: (source: string) => string;
8
10
  };
9
11
 
10
12
  // Memoization + rejection-retry semantics live in `createChunkLoader`. The
@@ -1,6 +1,20 @@
1
1
  import type { RendererObject } from "marked";
2
2
  import type { AgentWidgetMarkdownConfig, AgentWidgetMarkdownRendererOverrides, AgentWidgetMarkdownOptions } from "./types";
3
3
  import { getMarkdownParsersSync } from "./markdown-parsers-loader";
4
+ import { stabilizeStreamingTables } from "./utils/streaming-table";
5
+
6
+ /**
7
+ * Repair incomplete Markdown for a streaming display before parsing it as HTML.
8
+ * Apply custom text transforms first, then pass the result and the callback's
9
+ * `streaming` flag here. Completed text is returned unchanged. This never
10
+ * mutates message content or raw payloads and does not load the parser chunk;
11
+ * until that chunk is ready, only the lightweight table repairs are available.
12
+ */
13
+ export const preprocessStreamingMarkdown = (text: string, streaming: boolean): string =>
14
+ streaming
15
+ ? (getMarkdownParsersSync()?.stabilizeStreamingMarkdown ?? stabilizeStreamingTables)(text)
16
+ : text;
17
+
4
18
 
5
19
  /**
6
20
  * Options for creating a markdown processor
package/src/session.ts CHANGED
@@ -875,11 +875,14 @@ export class AgentWidgetSession {
875
875
  * Toggle voice recognition on/off
876
876
  */
877
877
  public async toggleVoice() {
878
+ const generation = this.#voiceSetupGeneration;
878
879
  // A click can race the setup chunk load; wait for it rather than
879
880
  // erroring (prefetch at construction makes this window ~one microtask).
880
881
  if (!this.#voiceProvider && this.#voiceSetupPromise) {
881
882
  await this.#voiceSetupPromise;
882
883
  }
884
+ // A navigation or config replacement supersedes a click waiting on setup.
885
+ if (generation !== this.#voiceSetupGeneration) return;
883
886
  if (!this.#voiceProvider) {
884
887
  console.error('Voice not configured');
885
888
  return;
@@ -6773,6 +6773,12 @@
6773
6773
  outline-offset: 2px;
6774
6774
  }
6775
6775
 
6776
+ /* revealToolCall() marks the revealed tool briefly. */
6777
+ .persona-tool-bubble[data-persona-revealed] {
6778
+ outline: 2px solid var(--persona-focus-ring, var(--persona-primary, #171717));
6779
+ outline-offset: 2px;
6780
+ }
6781
+
6776
6782
  /* Compact approval disclosures share the tool detail surfaces. */
6777
6783
  .persona-approval-row .persona-approval-details {
6778
6784
  display: grid;
package/src/types.ts CHANGED
@@ -3568,6 +3568,14 @@ export type AgentWidgetOpenConversationOptions = {
3568
3568
  busy?: "reject" | "stop" | "background";
3569
3569
  };
3570
3570
 
3571
+ /** Options for `controller.revealToolCall()`. */
3572
+ export type AgentWidgetRevealToolCallOptions = {
3573
+ /** Expand the tool's details. Default true. */
3574
+ expand?: boolean;
3575
+ /** How long the bubble keeps `data-persona-revealed`, in ms; 0 skips the mark. Default 2000. */
3576
+ highlightMs?: number;
3577
+ };
3578
+
3571
3579
  /** The single in-flight history operation, or `null` when idle. */
3572
3580
  export type HistoryPendingAction =
3573
3581
  | { kind: "refresh" | "load-more" | "start-new" | "clear" | "reset" }
@@ -8557,6 +8565,11 @@ export type AgentWidgetConfig = {
8557
8565
  * ```
8558
8566
  */
8559
8567
  webmcp?: AgentWidgetWebMcpConfig;
8568
+ /**
8569
+ * Custom HTML renderer; receives unmodified streaming text. To opt into the
8570
+ * default Markdown display repairs after your text transforms, call
8571
+ * `preprocessStreamingMarkdown(text, streaming)` before your Markdown parser.
8572
+ */
8560
8573
  postprocessMessage?: (context: {
8561
8574
  text: string;
8562
8575
  message: AgentWidgetMessage;
@@ -43,6 +43,12 @@ it.each([false, true])("restores the draft and emits conversation-scoped backgro
43
43
  expect(widget.getComposerState().text).toBe("");
44
44
  expect(states).toHaveBeenCalledWith(expect.objectContaining({ conversationId: "a", status: "running", detached: true }), expect.objectContaining({ instanceId: expect.any(String) }));
45
45
  widget.setMessage("Draft B");
46
+ window.dispatchEvent(new PageTransitionEvent("pagehide", { persisted: true }));
47
+ window.dispatchEvent(new PageTransitionEvent("pageshow", { persisted: true }));
48
+ // Cached restoration must not discard A's detached replay checkpoint or
49
+ // attach a foreground reader until the visitor opens that conversation.
50
+ expect(widget.getComposerState().text).toBe("Draft B");
51
+ expect(reconnect).not.toHaveBeenCalled();
46
52
  await widget.openConversation("a");
47
53
  await vi.waitFor(() => expect(widget.getMessages().filter(message => message.content === "Partial reply")).toHaveLength(1));
48
54
  expect(widget.getComposerState().text).toBe("Follow-up draft A");
@@ -0,0 +1,54 @@
1
+ // @vitest-environment jsdom
2
+
3
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
4
+ import { initAgentWidget } from "./runtime/init";
5
+ import { createAgentExperience, type AgentWidgetController } from "./ui";
6
+
7
+ let mount: HTMLElement;
8
+ let controller: { destroy: () => void } | undefined;
9
+
10
+ const footer = () => mount.querySelector<HTMLElement>(".persona-widget-footer");
11
+ const settle = async () => {
12
+ await vi.dynamicImportSettled();
13
+ await new Promise((resolve) => setTimeout(resolve, 0));
14
+ };
15
+
16
+ beforeEach(() => {
17
+ window.localStorage.clear();
18
+ window.scrollTo = vi.fn();
19
+ mount = document.createElement("div");
20
+ mount.id = "chat";
21
+ document.body.appendChild(mount);
22
+ });
23
+
24
+ afterEach(() => {
25
+ controller?.destroy();
26
+ controller = undefined;
27
+ document.body.replaceChildren();
28
+ });
29
+
30
+ describe.each([false, true])("layout.showFooter: false (V5 defaults: %s)", (v5Defaults) => {
31
+ const config = {
32
+ apiUrl: "/test",
33
+ persistState: false,
34
+ future: { v5Defaults },
35
+ launcher: { enabled: false },
36
+ layout: { embedded: true, showFooter: false },
37
+ initialMessages: [{ id: "m1", role: "user" as const, content: "Hi", createdAt: "2026-01-01T00:00:00Z" }],
38
+ };
39
+
40
+ it("keeps the footer hidden after the widget is ready", async () => {
41
+ controller = initAgentWidget({ target: "#chat", useShadowDom: false, config });
42
+ await settle();
43
+ expect(footer()?.style.display).toBe("none");
44
+ });
45
+
46
+ it("keeps the footer hidden when an update rebuilds the composer", async () => {
47
+ const experience: AgentWidgetController = createAgentExperience(mount, config);
48
+ controller = experience;
49
+ await settle();
50
+ experience.update({ contextMentions: { enabled: false } });
51
+ await settle();
52
+ expect(footer()?.style.display).toBe("none");
53
+ });
54
+ });
@@ -0,0 +1,268 @@
1
+ // @vitest-environment jsdom
2
+ import { afterEach, beforeEach, expect, it, vi } from "vitest";
3
+ import { createAgentExperience } from "./ui";
4
+ import type { AgentWidgetConfig, SpeechEngine, VoiceProvider, VoiceResult, VoiceStatus } from "./types";
5
+
6
+ const controllers: ReturnType<typeof createAgentExperience>[] = [];
7
+ beforeEach(() => { window.scrollTo = vi.fn(); });
8
+ afterEach(() => {
9
+ controllers.splice(0).forEach(controller => controller.destroy());
10
+ document.body.replaceChildren();
11
+ localStorage.clear();
12
+ sessionStorage.clear();
13
+ vi.restoreAllMocks();
14
+ vi.unstubAllGlobals();
15
+ });
16
+ function mount(config: AgentWidgetConfig = {}) {
17
+ const element = document.createElement("div");
18
+ document.body.append(element);
19
+ const controller = createAgentExperience(element, {
20
+ apiUrl: "/local-chat", launcher: { enabled: false }, persistState: false,
21
+ suggestionChips: [], ...config,
22
+ });
23
+ controllers.push(controller);
24
+ return { controller, element };
25
+ }
26
+ const transition = (name: "pagehide" | "pageshow", persisted = true) =>
27
+ window.dispatchEvent(new PageTransitionEvent(name, { persisted }));
28
+
29
+ function voice() {
30
+ let active = false;
31
+ let status: (status: VoiceStatus) => void = () => {};
32
+ let result: (result: VoiceResult) => void = () => {};
33
+ const provider: VoiceProvider = {
34
+ type: "custom", connect: vi.fn(async () => {}),
35
+ disconnect: vi.fn(async () => { active = false; }),
36
+ startListening: vi.fn(async () => { active = true; status("listening"); }),
37
+ stopListening: vi.fn(async () => { active = false; status("idle"); }),
38
+ onStatusChange: callback => { status = callback; },
39
+ onResult: callback => { result = callback; }, onError: () => {},
40
+ isBargeInActive: () => active,
41
+ };
42
+ return { provider, result: (text: string) => result({ text, isFinal: true, provider: "custom" }) };
43
+ }
44
+
45
+ it("aborts an active reply while retaining the same transcript and draft through repeated Back restores", async () => {
46
+ const signals: AbortSignal[] = [];
47
+ const customFetch = vi.fn((_url, init) => {
48
+ const signal = init!.signal!;
49
+ signals.push(signal);
50
+ return new Promise<Response>((_resolve, reject) => {
51
+ signal.addEventListener("abort", () => reject(new DOMException("Stopped", "AbortError")), { once: true });
52
+ });
53
+ });
54
+ const { controller, element } = mount({ customFetch });
55
+ for (let cycle = 0; cycle < 3; cycle++) {
56
+ expect(controller.submitMessage(`Question ${cycle}`)).toBe(true);
57
+ await vi.waitFor(() => expect(signals).toHaveLength(cycle + 1));
58
+ const input = element.querySelector<HTMLTextAreaElement>("[data-persona-composer-input]")!;
59
+ input.value = "Keep this draft";
60
+ input.dispatchEvent(new Event("input", { bubbles: true }));
61
+ transition("pagehide");
62
+ expect(signals[cycle].aborted).toBe(true);
63
+ expect(controller.getState().streaming).toBe(false);
64
+ transition("pageshow");
65
+ expect(element.querySelector("[data-persona-composer-input]")).toBe(input);
66
+ expect(controller.getComposerState().text).toBe("Keep this draft");
67
+ expect(controller.getMessages().some(message => message.content === `Question ${cycle}`)).toBe(true);
68
+ await Promise.resolve();
69
+ expect(customFetch).toHaveBeenCalledTimes(cycle + 1);
70
+ }
71
+ });
72
+
73
+ it("disconnects session voice on cached hide and keeps the controls usable without restarting the call", async () => {
74
+ const { provider } = voice();
75
+ const { controller } = mount({ voiceRecognition: { enabled: true, provider: { type: "custom", custom: () => provider } } });
76
+ for (let cycle = 0; cycle < 2; cycle++) {
77
+ controller.startVoiceRecognition();
78
+ await vi.waitFor(() => expect(provider.startListening).toHaveBeenCalledTimes(cycle + 1));
79
+ transition("pagehide");
80
+ expect(provider.disconnect).toHaveBeenCalledTimes(cycle + 1);
81
+ expect(controller.isVoiceActive()).toBe(false);
82
+ transition("pageshow");
83
+ await Promise.resolve();
84
+ expect(provider.startListening).toHaveBeenCalledTimes(cycle + 1);
85
+ }
86
+ });
87
+
88
+ it("does not let a pending microphone start cross a cached navigation", async () => {
89
+ const { provider } = voice();
90
+ const { controller } = mount({ voiceRecognition: { enabled: true, provider: { type: "custom", custom: () => provider } } });
91
+ controller.startVoiceRecognition();
92
+ transition("pagehide");
93
+ transition("pageshow");
94
+ await new Promise(resolve => setTimeout(resolve, 50));
95
+ expect(provider.startListening).not.toHaveBeenCalled();
96
+ controller.startVoiceRecognition();
97
+ await vi.waitFor(() => expect(provider.startListening).toHaveBeenCalledOnce());
98
+ });
99
+
100
+ it("cancels dictation without submitting its final result or losing the previous draft", async () => {
101
+ const { provider, result } = voice();
102
+ const { controller } = mount({ voiceRecognition: { enabled: true, completionBehavior: "review",
103
+ dictation: { provider: { type: "custom", custom: () => provider } } } });
104
+ controller.setMessage("Original draft");
105
+ controller.startDictation();
106
+ await vi.waitFor(() => expect(controller.getDictationState().phase).toBe("recording"));
107
+ transition("pagehide");
108
+ result("Late transcript");
109
+ transition("pageshow");
110
+ await vi.waitFor(() => expect(provider.disconnect).toHaveBeenCalled());
111
+ expect(controller.getDictationState().phase).toBe("idle");
112
+ expect(controller.getComposerState().text).toBe("Original draft");
113
+ expect(controller.getMessages()).toHaveLength(0);
114
+ });
115
+
116
+ it("stops browser recognition without accepting a trailing result and can record again on Back", () => {
117
+ const recordings: Recognition[] = [];
118
+ class Recognition {
119
+ onresult: ((event: unknown) => void) | null = null;
120
+ onend: (() => void) | null = null;
121
+ onerror: ((event: unknown) => void) | null = null;
122
+ start = vi.fn();
123
+ stop = vi.fn();
124
+ constructor() { recordings.push(this); }
125
+ }
126
+ vi.stubGlobal("SpeechRecognition", Recognition);
127
+ const { controller } = mount({ voiceRecognition: { enabled: true } });
128
+ controller.setMessage("Keep my draft");
129
+ expect(controller.startVoiceRecognition()).toBe(true);
130
+ expect(recordings[0].start).toHaveBeenCalledOnce();
131
+ transition("pagehide");
132
+ expect(recordings[0].stop).toHaveBeenCalledOnce();
133
+ recordings[0].onresult?.({ results: [Object.assign([{ transcript: "Late result" }], { isFinal: true })] });
134
+ transition("pageshow");
135
+ expect(controller.getComposerState().text).toBe("Keep my draft");
136
+ expect(controller.startVoiceRecognition()).toBe(true);
137
+ expect(recordings[1].start).toHaveBeenCalledOnce();
138
+ expect(controller.getMessages()).toHaveLength(0);
139
+ });
140
+
141
+ it("stops read-aloud playback on cached hide and does not resume it on Back", async () => {
142
+ const engine: SpeechEngine = { id: "cache-test", supportsPause: false,
143
+ speak: vi.fn((_request, callbacks) => { callbacks.onStart?.(); }),
144
+ stop: vi.fn(), pause: vi.fn(), resume: vi.fn(),
145
+ };
146
+ const { controller } = mount({ textToSpeech: { enabled: true, createEngine: () => engine } });
147
+ const message = controller.injectAssistantMessage({ content: "Read this reply aloud." });
148
+ controller.toggleReadAloud(message.id);
149
+ await vi.waitFor(() => expect(controller.getReadAloudState(message.id)).toBe("playing"));
150
+ transition("pagehide");
151
+ expect(engine.stop).toHaveBeenCalled();
152
+ expect(controller.getReadAloudState(message.id)).toBe("idle");
153
+ transition("pageshow");
154
+ await Promise.resolve();
155
+ expect(engine.speak).toHaveBeenCalledOnce();
156
+ });
157
+
158
+ it("abandons an asynchronous before-send hook and leaves its draft sendable after Back", async () => {
159
+ let finishHook: () => void = () => {};
160
+ let signal: AbortSignal | undefined;
161
+ const hook = vi.fn((_snapshot, context) => {
162
+ signal = context.signal;
163
+ return new Promise<void>(resolve => { finishHook = resolve; });
164
+ });
165
+ const customFetch = vi.fn(async () => new Response("", { headers: { "content-type": "text/event-stream" } }));
166
+ const { controller, element } = mount({ composer: { onBeforeSend: hook }, customFetch });
167
+ controller.setMessage("Unsent draft");
168
+ element.querySelector<HTMLButtonElement>("[data-persona-composer-submit]")!.click();
169
+ await vi.waitFor(() => expect(hook).toHaveBeenCalledOnce());
170
+ transition("pagehide");
171
+ expect(signal?.aborted).toBe(true);
172
+ transition("pageshow");
173
+ expect(customFetch).not.toHaveBeenCalled();
174
+ expect(controller.getComposerState().text).toBe("Unsent draft");
175
+ let finishNext: () => void = () => {};
176
+ const nextHook = vi.fn(() => new Promise<void>(resolve => { finishNext = resolve; }));
177
+ controller.update({ composer: { onBeforeSend: nextHook } });
178
+ element.querySelector<HTMLButtonElement>("[data-persona-composer-submit]")!.click();
179
+ await vi.waitFor(() => expect(nextHook).toHaveBeenCalledOnce());
180
+ finishHook();
181
+ await Promise.resolve();
182
+ element.querySelector<HTMLButtonElement>("[data-persona-composer-submit]")!.click();
183
+ expect(nextHook).toHaveBeenCalledOnce();
184
+ expect(customFetch).not.toHaveBeenCalled();
185
+ finishNext();
186
+ await vi.waitFor(() => expect(customFetch).toHaveBeenCalledOnce());
187
+ });
188
+
189
+ it("retains the live-call metadata used to redial on a genuine document navigation", async () => {
190
+ const { provider } = voice();
191
+ const { controller } = mount({ voiceRecognition: { enabled: true, provider: { type: "custom", custom: () => provider } } });
192
+ controller.startVoiceRecognition();
193
+ await vi.waitFor(() => expect(provider.startListening).toHaveBeenCalledOnce());
194
+ transition("pagehide", false);
195
+ expect(provider.disconnect).not.toHaveBeenCalled();
196
+ controller.destroy();
197
+ controllers.splice(controllers.indexOf(controller), 1);
198
+ expect(provider.disconnect).toHaveBeenCalledOnce();
199
+ expect(controller.getPersistentMetadata().voiceState).toMatchObject({ active: true });
200
+ });
201
+
202
+ it.each(["queued", "late", "injected"])("discards %s action resubmits across Back and accepts fresh actions", async mode => {
203
+ vi.useFakeTimers();
204
+ try {
205
+ let trigger = () => {};
206
+ const customFetch = vi.fn(async () => new Response("", { headers: { "content-type": "text/event-stream" } }));
207
+ const { controller } = mount({ customFetch, actionHandlers: [(_action, context) => {
208
+ trigger = context.triggerResubmit;
209
+ return { handled: true, displayText: "Action handled", resubmit: mode === "injected" };
210
+ }] });
211
+ controller.injectAssistantMessage({ content: '{"action":"search"}' });
212
+ await vi.advanceTimersByTimeAsync(0);
213
+ if (mode === "queued") trigger();
214
+ transition("pagehide");
215
+ transition("pageshow");
216
+ if (mode === "late") trigger();
217
+ if (mode === "injected") controller.injectAssistantMessage({ content: "Late result" });
218
+ await vi.advanceTimersByTimeAsync(200);
219
+ expect(customFetch).not.toHaveBeenCalled();
220
+ controller.injectAssistantMessage({ content: '{"action":"search"}' });
221
+ await vi.advanceTimersByTimeAsync(0);
222
+ if (mode !== "injected") trigger();
223
+ await vi.advanceTimersByTimeAsync(200);
224
+ expect(customFetch).toHaveBeenCalledOnce();
225
+ } finally { vi.useRealTimers(); }
226
+ });
227
+
228
+ it.each(["back", "destroy", "navigation"])("guards queued metadata voice restoration on %s", async lifecycle => {
229
+ vi.useFakeTimers();
230
+ try {
231
+ const { provider } = voice();
232
+ const { controller } = mount({
233
+ persistState: true, storageAdapter: { load: () => ({ messages: [], metadata: { voiceState: { active: true, timestamp: Date.now() } } }), save: () => {} },
234
+ voiceRecognition: { enabled: true, provider: { type: "custom", custom: () => provider } },
235
+ });
236
+ if (lifecycle === "back") { transition("pagehide"); transition("pageshow"); }
237
+ if (lifecycle === "destroy") controller.destroy();
238
+ await vi.advanceTimersByTimeAsync(1100);
239
+ expect(provider.startListening).toHaveBeenCalledTimes(lifecycle === "navigation" ? 1 : 0);
240
+ if (lifecycle === "back") {
241
+ controller.startVoiceRecognition();
242
+ await vi.advanceTimersByTimeAsync(0);
243
+ expect(provider.startListening).toHaveBeenCalledOnce();
244
+ }
245
+ } finally { vi.useRealTimers(); }
246
+ });
247
+
248
+ it("discards the delayed browser voice restore before Back while allowing a fresh mic tap", async () => {
249
+ vi.useFakeTimers();
250
+ try {
251
+ const start = vi.fn();
252
+ class Recognition { start = start; stop = vi.fn(); }
253
+ vi.stubGlobal("SpeechRecognition", Recognition);
254
+ const { controller } = mount({
255
+ persistState: true,
256
+ storageAdapter: { load: () => ({ messages: [], metadata: { voiceState: { active: true, timestamp: Date.now() } } }), save: () => {} },
257
+ voiceRecognition: { enabled: true },
258
+ });
259
+ await vi.advanceTimersByTimeAsync(100);
260
+ expect(start).not.toHaveBeenCalled();
261
+ transition("pagehide");
262
+ transition("pageshow");
263
+ await vi.advanceTimersByTimeAsync(1100);
264
+ expect(start).not.toHaveBeenCalled();
265
+ expect(controller.startVoiceRecognition()).toBe(true);
266
+ expect(start).toHaveBeenCalledOnce();
267
+ } finally { vi.useRealTimers(); }
268
+ });
@@ -3,7 +3,7 @@
3
3
  import { afterEach, describe, expect, it, vi } from "vitest";
4
4
 
5
5
  import { buildPostprocessor } from "./ui";
6
- import { escapeHtml } from "./postprocessors";
6
+ import { escapeHtml, markdownPostprocessor, preprocessStreamingMarkdown } from "./postprocessors";
7
7
  import * as parsersLoader from "./markdown-parsers-loader";
8
8
  import type { AgentWidgetConfig, AgentWidgetMessage } from "./types";
9
9
 
@@ -93,6 +93,62 @@ describe("buildPostprocessor degraded path (parsers not loaded)", () => {
93
93
  });
94
94
 
95
95
  describe("buildPostprocessor loaded path (parsers available)", () => {
96
+ it("lets custom callbacks transform text before opting into the shared streaming repairs", () => {
97
+ const text = "<answer>Read **unfinished";
98
+ const raw = JSON.stringify({ answer: text });
99
+ const message: AgentWidgetMessage = {
100
+ id: "custom-stream", role: "assistant", content: text, rawContent: raw, createdAt: "2026-10-20T00:00:00.000Z",
101
+ };
102
+ const custom = vi.fn(({ text, streaming }: { text: string; streaming: boolean }) =>
103
+ markdownPostprocessor(preprocessStreamingMarkdown(text.replace("<answer>", ""), streaming)));
104
+ const transform = buildPostprocessor({ postprocessMessage: custom, sanitize: true }, undefined, undefined);
105
+ const displayText = text.replace("<answer>", "");
106
+ const builtIn = buildPostprocessor({ markdown: {} }, undefined, undefined);
107
+ expect(transform({ text, raw, message, streaming: true })).toBe(
108
+ builtIn({ text: displayText, message, streaming: true }));
109
+ expect(transform({ text, raw, message, streaming: false })).toBe("<p>Read **unfinished</p>\n");
110
+ expect(message.content).toBe(text);
111
+ expect(message.rawContent).toBe(raw);
112
+ expect(custom.mock.calls[0][0]).toMatchObject({ text, raw, streaming: true });
113
+ });
114
+
115
+ it("repairs streaming display without mutating the stored or final Markdown", () => {
116
+ const text = "Read **unfinished";
117
+ const message: AgentWidgetMessage = {
118
+ id: "stream", role: "assistant", content: text, createdAt: "2026-10-20T00:00:00.000Z",
119
+ };
120
+ const transform = buildPostprocessor({ markdown: {} }, undefined, undefined);
121
+ expect(transform({ text, message, streaming: true })).toContain("<strong>unfinished</strong>");
122
+ expect(message.content).toBe(text);
123
+ expect(transform({ text, message, streaming: false })).toBe("<p>Read **unfinished</p>\n");
124
+ });
125
+
126
+ it("leaves custom postprocessors and plain-text rendering untouched during streaming", () => {
127
+ const text = "**unfinished";
128
+ const message: AgentWidgetMessage = {
129
+ id: "stream", role: "assistant", content: text, createdAt: "2026-10-20T00:00:00.000Z",
130
+ };
131
+ const custom = vi.fn(({ text }: { text: string }) => escapeHtml(text));
132
+ const transform = buildPostprocessor({ markdown: {}, postprocessMessage: custom }, undefined, undefined);
133
+ expect(transform({ text, message, streaming: true })).toBe(text);
134
+ expect(custom.mock.calls[0][0].text).toBe(text);
135
+ expect(buildPostprocessor(undefined, undefined, undefined)({ text, message, streaming: true })).toBe(text);
136
+ });
137
+
138
+ it("sanitizes completed links after streaming repair and retains legacy custom parser compatibility", () => {
139
+ const text = "[bad](javascript:alert(1)) **hello";
140
+ const message: AgentWidgetMessage = {
141
+ id: "stream", role: "assistant", content: text, createdAt: "2026-10-20T00:00:00.000Z",
142
+ };
143
+ const transform = buildPostprocessor({ markdown: {} }, undefined, undefined);
144
+ const out = transform({ text, message, streaming: true });
145
+ expect(out).toContain("<strong>hello</strong>");
146
+ expect(out).not.toContain("javascript:");
147
+ const parsers = parsersLoader.getMarkdownParsersSync()!;
148
+ vi.spyOn(parsersLoader, "getMarkdownParsersSync").mockReturnValue({ Marked: parsers.Marked, DOMPurify: parsers.DOMPurify });
149
+ expect(transform({ text, message, streaming: true })).toContain("**hello");
150
+ });
151
+
96
152
  it("renders markdown to real HTML, normalizes apostrophes, strips scripts", () => {
97
153
  // No spy: vitest.setup.ts eager-provides parsers, so this is the loaded path.
98
154
  const text = "**bold** I'll go\n\n<script>alert(1)</script>";
@@ -152,3 +208,26 @@ describe("buildPostprocessor failure containment", () => {
152
208
  expect(out).toBe(escapeHtml(SAMPLE));
153
209
  });
154
210
  });
211
+
212
+ describe("preprocessStreamingMarkdown parser readiness", () => {
213
+ it("leaves final text unchanged even when a parser repair would modify it", () => {
214
+ const repair = vi.fn(() => "repaired");
215
+ const parsers = parsersLoader.getMarkdownParsersSync()!;
216
+ vi.spyOn(parsersLoader, "getMarkdownParsersSync").mockReturnValue({ ...parsers, stabilizeStreamingMarkdown: repair });
217
+ expect(preprocessStreamingMarkdown("**unfinished", false)).toBe("**unfinished");
218
+ expect(repair).not.toHaveBeenCalled();
219
+ expect(preprocessStreamingMarkdown("**unfinished", true)).toBe("repaired");
220
+ });
221
+
222
+ it("uses the same lightweight table fallback before parsers load and with legacy loaders", () => {
223
+ const parsers = parsersLoader.getMarkdownParsersSync()!;
224
+ const text = "| A | B |\n| ---";
225
+ vi.spyOn(parsersLoader, "getMarkdownParsersSync").mockReturnValue(null);
226
+ const beforeLoad = preprocessStreamingMarkdown(text, true);
227
+ expect(beforeLoad).not.toBe(text);
228
+ expect(preprocessStreamingMarkdown("**unfinished", true)).toBe("**unfinished");
229
+ vi.mocked(parsersLoader.getMarkdownParsersSync).mockReturnValue({ Marked: parsers.Marked, DOMPurify: parsers.DOMPurify });
230
+ expect(preprocessStreamingMarkdown(text, true)).toBe(beforeLoad);
231
+ expect(preprocessStreamingMarkdown(text, false)).toBe(text);
232
+ });
233
+ });