@runtypelabs/persona 3.34.0 → 3.35.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 (186) hide show
  1. package/README.md +11 -11
  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-CthJFfNx.d.cts → types-DgYsuwXL.d.cts} +15 -14
  5. package/dist/animations/{types-CthJFfNx.d.ts → types-DgYsuwXL.d.ts} +15 -14
  6. package/dist/animations/wipe.d.cts +1 -1
  7. package/dist/animations/wipe.d.ts +1 -1
  8. package/dist/codegen.cjs +11 -11
  9. package/dist/codegen.js +10 -10
  10. package/dist/index.cjs +46 -44
  11. package/dist/index.cjs.map +1 -1
  12. package/dist/index.d.cts +514 -158
  13. package/dist/index.d.ts +514 -158
  14. package/dist/index.global.js +41 -89
  15. package/dist/index.global.js.map +1 -1
  16. package/dist/index.js +46 -44
  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/launcher.global.js +2 -2
  21. package/dist/launcher.global.js.map +1 -1
  22. package/dist/markdown-parsers.js +53 -0
  23. package/dist/plugin-kit.d.cts +4 -4
  24. package/dist/plugin-kit.d.ts +4 -4
  25. package/dist/runtype-tts.js +1 -0
  26. package/dist/smart-dom-reader.cjs +1 -1
  27. package/dist/smart-dom-reader.d.cts +352 -103
  28. package/dist/smart-dom-reader.d.ts +352 -103
  29. package/dist/smart-dom-reader.js +1 -1
  30. package/dist/theme-editor-preview.cjs +203 -0
  31. package/dist/theme-editor-preview.d.cts +5748 -0
  32. package/dist/theme-editor-preview.d.ts +5748 -0
  33. package/dist/theme-editor-preview.js +203 -0
  34. package/dist/theme-editor.cjs +14 -131
  35. package/dist/theme-editor.d.cts +362 -574
  36. package/dist/theme-editor.d.ts +362 -574
  37. package/dist/theme-editor.js +14 -131
  38. package/dist/theme-reference.cjs +1 -1
  39. package/dist/theme-reference.d.cts +1 -1
  40. package/dist/theme-reference.d.ts +1 -1
  41. package/dist/theme-reference.js +1 -1
  42. package/dist/voice-worklet-player.cjs +80 -0
  43. package/dist/voice-worklet-player.d.cts +215 -0
  44. package/dist/voice-worklet-player.d.ts +215 -0
  45. package/dist/voice-worklet-player.js +80 -0
  46. package/dist/widget.css +54 -36
  47. package/package.json +16 -2
  48. package/src/animations/glyph-cycle.ts +11 -14
  49. package/src/animations/wipe.ts +1 -1
  50. package/src/ask-user-question-tool.test.ts +1 -1
  51. package/src/ask-user-question-tool.ts +6 -8
  52. package/src/client.test.ts +67 -12
  53. package/src/client.ts +40 -32
  54. package/src/codegen.test.ts +1 -1
  55. package/src/codegen.ts +1 -1
  56. package/src/components/approval-bubble.test.ts +1 -1
  57. package/src/components/approval-bubble.ts +3 -3
  58. package/src/components/artifact-pane.ts +3 -3
  59. package/src/components/ask-user-question-bubble.test.ts +14 -14
  60. package/src/components/ask-user-question-bubble.ts +10 -10
  61. package/src/components/composer-builder.ts +17 -16
  62. package/src/components/composer-parts.test.ts +54 -0
  63. package/src/components/composer-parts.ts +95 -126
  64. package/src/components/demo-carousel.ts +1 -1
  65. package/src/components/event-stream-view.test.ts +7 -7
  66. package/src/components/event-stream-view.ts +5 -5
  67. package/src/components/header-builder.ts +33 -27
  68. package/src/components/header-layouts.ts +1 -1
  69. package/src/components/header-parts.test.ts +90 -0
  70. package/src/components/header-parts.ts +62 -93
  71. package/src/components/launcher.test.ts +11 -0
  72. package/src/components/launcher.ts +1 -1
  73. package/src/components/message-bubble.test.ts +74 -2
  74. package/src/components/message-bubble.ts +25 -11
  75. package/src/components/messages.ts +2 -2
  76. package/src/components/panel.test.ts +1 -1
  77. package/src/components/panel.ts +92 -97
  78. package/src/components/pill-composer-builder.ts +28 -24
  79. package/src/components/reasoning-bubble.ts +2 -2
  80. package/src/components/suggestions.ts +1 -1
  81. package/src/components/tool-bubble.ts +1 -1
  82. package/src/components/widget-view.test.ts +181 -0
  83. package/src/components/widget-view.ts +234 -0
  84. package/src/defaults.ts +54 -51
  85. package/src/generated/runtype-openapi-contract.ts +5 -0
  86. package/src/index-core.ts +30 -4
  87. package/src/index-global.ts +73 -6
  88. package/src/index.ts +7 -1
  89. package/src/install.test.ts +11 -11
  90. package/src/install.ts +28 -18
  91. package/src/launcher-global.ts +25 -14
  92. package/src/markdown-parsers-eager.ts +20 -0
  93. package/src/markdown-parsers-entry.ts +4 -0
  94. package/src/markdown-parsers-loader.ts +49 -0
  95. package/src/plugin-kit.test.ts +1 -1
  96. package/src/plugin-kit.ts +7 -7
  97. package/src/plugins/types.ts +5 -5
  98. package/src/postprocessors.ts +27 -14
  99. package/src/runtime/host-layout.test.ts +1 -1
  100. package/src/runtime/host-layout.ts +4 -5
  101. package/src/runtime/init.ts +2 -2
  102. package/src/session.test.ts +5 -6
  103. package/src/session.ts +333 -227
  104. package/src/session.voice.test.ts +146 -0
  105. package/src/session.webmcp.test.ts +24 -25
  106. package/src/smart-dom-reader.ts +3 -4
  107. package/src/styles/widget.css +54 -36
  108. package/src/suggest-replies-tool.test.ts +3 -3
  109. package/src/suggest-replies-tool.ts +5 -5
  110. package/src/testing/index.ts +1 -1
  111. package/src/theme-editor/color-utils.ts +2 -2
  112. package/src/theme-editor/index.ts +4 -13
  113. package/src/theme-editor/presets.ts +1 -1
  114. package/src/theme-editor/preview-utils.ts +22 -20
  115. package/src/theme-editor/preview.ts +4 -1
  116. package/src/theme-editor/role-mappings.ts +3 -3
  117. package/src/theme-editor/sections.ts +21 -20
  118. package/src/theme-editor/types.ts +1 -1
  119. package/src/theme-editor/webmcp/index.ts +1 -1
  120. package/src/theme-editor/webmcp/summary.ts +1 -2
  121. package/src/theme-editor/webmcp/tools.ts +30 -30
  122. package/src/theme-editor/webmcp/types.ts +2 -2
  123. package/src/theme-editor-preview.ts +10 -0
  124. package/src/theme-reference.ts +26 -26
  125. package/src/types.ts +368 -99
  126. package/src/ui.approval-plugin.test.ts +3 -3
  127. package/src/ui.ask-user-question-plugin.test.ts +9 -9
  128. package/src/ui.component-directive.test.ts +4 -4
  129. package/src/ui.composer-bar.test.ts +10 -11
  130. package/src/ui.composer-keyboard.test.ts +5 -5
  131. package/src/ui.header-icon-color.test.ts +59 -0
  132. package/src/ui.scroll.test.ts +5 -6
  133. package/src/ui.stop-button.test.ts +3 -4
  134. package/src/ui.suggest-replies.test.ts +2 -2
  135. package/src/ui.ts +292 -131
  136. package/src/utils/buttons.ts +1 -1
  137. package/src/utils/code-generators.ts +1 -1
  138. package/src/utils/composer-history.ts +3 -3
  139. package/src/utils/dom-context.test.ts +2 -2
  140. package/src/utils/dom-context.ts +7 -7
  141. package/src/utils/dom.test.ts +67 -0
  142. package/src/utils/dom.ts +75 -0
  143. package/src/utils/dropdown.ts +2 -2
  144. package/src/utils/event-stream-buffer.test.ts +2 -2
  145. package/src/utils/message-fingerprint.test.ts +9 -0
  146. package/src/utils/message-fingerprint.ts +5 -0
  147. package/src/utils/morph.ts +1 -1
  148. package/src/utils/sanitize.ts +32 -16
  149. package/src/utils/sequence-buffer.test.ts +8 -8
  150. package/src/utils/sequence-buffer.ts +4 -4
  151. package/src/utils/smart-dom-adapter.test.ts +1 -1
  152. package/src/utils/smart-dom-adapter.ts +4 -5
  153. package/src/utils/speech-text.test.ts +122 -0
  154. package/src/utils/speech-text.ts +101 -0
  155. package/src/utils/stream-animation.test.ts +3 -3
  156. package/src/utils/stream-animation.ts +6 -6
  157. package/src/utils/theme.test.ts +1 -1
  158. package/src/utils/throughput-tracker.test.ts +11 -11
  159. package/src/utils/throughput-tracker.ts +9 -9
  160. package/src/utils/tokens.ts +13 -13
  161. package/src/utils/virtual-scroller.ts +1 -1
  162. package/src/vendor/smart-dom-reader/README.md +4 -4
  163. package/src/vendor/smart-dom-reader/index.d.ts +1 -1
  164. package/src/vendor/smart-dom-reader/index.js +2 -2
  165. package/src/version.ts +1 -1
  166. package/src/voice/audio-playback-manager.test.ts +159 -0
  167. package/src/voice/audio-playback-manager.ts +145 -30
  168. package/src/voice/browser-speech-engine.ts +130 -0
  169. package/src/voice/fallback-speech-engine.ts +89 -0
  170. package/src/voice/index.ts +21 -0
  171. package/src/voice/read-aloud-controller.test.ts +143 -0
  172. package/src/voice/read-aloud-controller.ts +136 -0
  173. package/src/voice/runtype-speech-engine.test.ts +280 -0
  174. package/src/voice/runtype-speech-engine.ts +211 -0
  175. package/src/voice/runtype-tts-entry.ts +10 -0
  176. package/src/voice/runtype-tts-loader.test.ts +47 -0
  177. package/src/voice/runtype-tts-loader.ts +42 -0
  178. package/src/voice/runtype-voice-provider.ts +332 -537
  179. package/src/voice/voice-factory.ts +26 -4
  180. package/src/voice/voice.test.ts +321 -7
  181. package/src/voice/worklet-playback-engine.ts +272 -0
  182. package/src/voice-worklet-player.ts +39 -0
  183. package/src/webmcp-bridge.test.ts +6 -6
  184. package/src/webmcp-bridge.ts +24 -25
  185. package/src/webmcp-polyfill.ts +1 -1
  186. package/src/voice/voice-activity-detector.ts +0 -90
@@ -0,0 +1,143 @@
1
+ import { describe, it, expect, beforeEach } from "vitest";
2
+ import { ReadAloudController } from "./read-aloud-controller";
3
+ import type { ReadAloudState, SpeechCallbacks, SpeechEngine, SpeechRequest } from "../types";
4
+
5
+ class FakeEngine implements SpeechEngine {
6
+ readonly id = "fake";
7
+ readonly supportsPause: boolean;
8
+ speakCalls: SpeechRequest[] = [];
9
+ lastCallbacks: SpeechCallbacks | null = null;
10
+ pauseCount = 0;
11
+ resumeCount = 0;
12
+ stopCount = 0;
13
+
14
+ constructor(supportsPause = true) {
15
+ this.supportsPause = supportsPause;
16
+ }
17
+
18
+ speak(request: SpeechRequest, callbacks: SpeechCallbacks): void {
19
+ this.speakCalls.push(request);
20
+ this.lastCallbacks = callbacks;
21
+ }
22
+ pause(): void {
23
+ this.pauseCount++;
24
+ }
25
+ resume(): void {
26
+ this.resumeCount++;
27
+ }
28
+ stop(): void {
29
+ this.stopCount++;
30
+ }
31
+ }
32
+
33
+ const tick = () => new Promise((resolve) => setTimeout(resolve, 0));
34
+ const req = (text: string): SpeechRequest => ({ text });
35
+
36
+ describe("ReadAloudController", () => {
37
+ let engine: FakeEngine;
38
+ let controller: ReadAloudController;
39
+ let states: Array<[string | null, ReadAloudState]>;
40
+
41
+ beforeEach(() => {
42
+ engine = new FakeEngine(true);
43
+ controller = new ReadAloudController(() => engine);
44
+ states = [];
45
+ controller.onChange((id, state) => states.push([id, state]));
46
+ });
47
+
48
+ it("goes idle → loading → playing on first toggle", async () => {
49
+ controller.toggle("a", req("hello"));
50
+ // loading is set synchronously, before the engine resolves
51
+ expect(controller.stateFor("a")).toBe("loading");
52
+ await tick();
53
+ expect(engine.speakCalls).toHaveLength(1);
54
+ engine.lastCallbacks!.onStart!();
55
+ expect(controller.stateFor("a")).toBe("playing");
56
+ expect(states.map((s) => s[1])).toEqual(["loading", "playing"]);
57
+ });
58
+
59
+ it("pauses then resumes a playing message", async () => {
60
+ controller.toggle("a", req("hello"));
61
+ await tick();
62
+ engine.lastCallbacks!.onStart!();
63
+
64
+ controller.toggle("a", req("hello")); // playing → pause
65
+ expect(engine.pauseCount).toBe(1);
66
+ expect(controller.stateFor("a")).toBe("paused");
67
+
68
+ controller.toggle("a", req("hello")); // paused → resume
69
+ expect(engine.resumeCount).toBe(1);
70
+ expect(controller.stateFor("a")).toBe("playing");
71
+ });
72
+
73
+ it("stops a playing message (no resume) when the engine can't pause", async () => {
74
+ engine = new FakeEngine(false);
75
+ controller = new ReadAloudController(() => engine);
76
+ controller.toggle("a", req("hello"));
77
+ await tick();
78
+ engine.lastCallbacks!.onStart!();
79
+
80
+ controller.toggle("a", req("hello")); // playing → stop (no pause support)
81
+ expect(engine.pauseCount).toBe(0);
82
+ expect(engine.stopCount).toBeGreaterThanOrEqual(1);
83
+ expect(controller.stateFor("a")).toBe("idle");
84
+ });
85
+
86
+ it("stops the previous message when a different one starts", async () => {
87
+ controller.toggle("a", req("first"));
88
+ await tick();
89
+ engine.lastCallbacks!.onStart!();
90
+ expect(controller.stateFor("a")).toBe("playing");
91
+
92
+ controller.toggle("b", req("second")); // switch
93
+ await tick();
94
+ expect(engine.stopCount).toBeGreaterThanOrEqual(1);
95
+ engine.lastCallbacks!.onStart!();
96
+ expect(controller.stateFor("a")).toBe("idle");
97
+ expect(controller.stateFor("b")).toBe("playing");
98
+ });
99
+
100
+ it("returns to idle on natural end", async () => {
101
+ controller.toggle("a", req("hello"));
102
+ await tick();
103
+ engine.lastCallbacks!.onStart!();
104
+ engine.lastCallbacks!.onEnd!();
105
+ expect(controller.stateFor("a")).toBe("idle");
106
+ expect(controller.activeMessageId()).toBeNull();
107
+ });
108
+
109
+ it("ignores a stale callback from a superseded request", async () => {
110
+ controller.toggle("a", req("first"));
111
+ await tick();
112
+ const staleCallbacks = engine.lastCallbacks!;
113
+ staleCallbacks.onStart!();
114
+
115
+ controller.toggle("b", req("second")); // supersedes "a"
116
+ await tick();
117
+ engine.lastCallbacks!.onStart!();
118
+ expect(controller.stateFor("b")).toBe("playing");
119
+
120
+ // Late onEnd from the superseded "a" request must not clobber "b".
121
+ staleCallbacks.onEnd!();
122
+ expect(controller.stateFor("b")).toBe("playing");
123
+ });
124
+
125
+ it("stop() resets to idle and stops the engine", async () => {
126
+ controller.toggle("a", req("hello"));
127
+ await tick();
128
+ engine.lastCallbacks!.onStart!();
129
+ controller.stop();
130
+ expect(controller.stateFor("a")).toBe("idle");
131
+ expect(engine.stopCount).toBeGreaterThanOrEqual(1);
132
+ });
133
+
134
+ it("reports a null engine resolution as idle", async () => {
135
+ const nullController = new ReadAloudController(() => null);
136
+ const seen: ReadAloudState[] = [];
137
+ nullController.onChange((_id, state) => seen.push(state));
138
+ nullController.toggle("a", req("hello"));
139
+ await tick();
140
+ expect(nullController.stateFor("a")).toBe("idle");
141
+ expect(seen).toEqual(["loading", "idle"]);
142
+ });
143
+ });
@@ -0,0 +1,136 @@
1
+ // Read-Aloud Controller
2
+ //
3
+ // Engine-agnostic orchestrator for the per-message "Read aloud" action and the
4
+ // auto-speak path. Owns the three things the UI shouldn't: which message is
5
+ // currently active, its playback state, and the single speech engine instance.
6
+ //
7
+ // The button drives a single `toggle(id, request)`; listeners are notified on
8
+ // every state change so the UI can reflect play / pause / resume. The engine is
9
+ // resolved lazily (on first playback, inside the user gesture) via the
10
+ // `resolveEngine` factory, so swapping the browser engine for a hosted one
11
+ // (Runtype or custom) is a config change — this class is unchanged.
12
+
13
+ import type { ReadAloudState, SpeechEngine, SpeechRequest } from "../types";
14
+
15
+ export type ReadAloudListener = (
16
+ activeId: string | null,
17
+ state: ReadAloudState
18
+ ) => void;
19
+
20
+ export class ReadAloudController {
21
+ private engine: SpeechEngine | null = null;
22
+ private activeId: string | null = null;
23
+ private state: ReadAloudState = "idle";
24
+ private listeners = new Set<ReadAloudListener>();
25
+ // Bumped on every play/stop so a late async engine creation or a stale
26
+ // utterance callback from a superseded request can't mutate current state.
27
+ private generation = 0;
28
+
29
+ constructor(
30
+ private resolveEngine: () => SpeechEngine | Promise<SpeechEngine> | null
31
+ ) {}
32
+
33
+ /** Whether the active engine supports pause/resume (vs. stop-only). */
34
+ get supportsPause(): boolean {
35
+ return this.engine?.supportsPause ?? true;
36
+ }
37
+
38
+ /** Playback state for a message id (`idle` unless it's the active message). */
39
+ stateFor(id: string): ReadAloudState {
40
+ return this.activeId === id ? this.state : "idle";
41
+ }
42
+
43
+ /** The message currently being read aloud, if any. */
44
+ activeMessageId(): string | null {
45
+ return this.activeId;
46
+ }
47
+
48
+ /** Subscribe to state changes. Returns an unsubscribe function. */
49
+ onChange(listener: ReadAloudListener): () => void {
50
+ this.listeners.add(listener);
51
+ return () => this.listeners.delete(listener);
52
+ }
53
+
54
+ /**
55
+ * Primary entry point for the button: cycle this message through
56
+ * play → pause → resume (or play → stop when the engine can't pause), and
57
+ * start fresh when a different message is requested.
58
+ */
59
+ toggle(id: string, request: SpeechRequest): void {
60
+ if (this.activeId === id) {
61
+ if (this.state === "playing") {
62
+ if (this.engine?.supportsPause) {
63
+ this.engine.pause();
64
+ this.set(id, "paused");
65
+ } else {
66
+ this.stop();
67
+ }
68
+ return;
69
+ }
70
+ if (this.state === "paused") {
71
+ this.engine?.resume();
72
+ this.set(id, "playing");
73
+ return;
74
+ }
75
+ if (this.state === "loading") {
76
+ this.stop();
77
+ return;
78
+ }
79
+ }
80
+ void this.play(id, request);
81
+ }
82
+
83
+ /** Start (or restart) playback for a message, stopping any current playback. */
84
+ async play(id: string, request: SpeechRequest): Promise<void> {
85
+ const generation = ++this.generation;
86
+ this.engine?.stop();
87
+ this.set(id, "loading");
88
+
89
+ try {
90
+ if (!this.engine) {
91
+ const resolved = await this.resolveEngine();
92
+ if (generation !== this.generation) return; // superseded mid-resolve
93
+ if (!resolved) {
94
+ this.set(null, "idle");
95
+ return;
96
+ }
97
+ this.engine = resolved;
98
+ }
99
+
100
+ this.engine.speak(request, {
101
+ onStart: () => {
102
+ if (generation === this.generation) this.set(id, "playing");
103
+ },
104
+ onEnd: () => {
105
+ if (generation === this.generation) this.set(null, "idle");
106
+ },
107
+ onError: () => {
108
+ if (generation === this.generation) this.set(null, "idle");
109
+ },
110
+ });
111
+ } catch {
112
+ if (generation === this.generation) this.set(null, "idle");
113
+ }
114
+ }
115
+
116
+ /** Stop playback and return to idle. */
117
+ stop(): void {
118
+ this.generation++;
119
+ this.engine?.stop();
120
+ this.set(null, "idle");
121
+ }
122
+
123
+ /** Drop the controller and its engine (called on widget teardown). */
124
+ destroy(): void {
125
+ this.stop();
126
+ this.engine?.destroy?.();
127
+ this.engine = null;
128
+ this.listeners.clear();
129
+ }
130
+
131
+ private set(id: string | null, state: ReadAloudState): void {
132
+ this.activeId = state === "idle" ? null : id;
133
+ this.state = state;
134
+ for (const listener of this.listeners) listener(this.activeId, this.state);
135
+ }
136
+ }
@@ -0,0 +1,280 @@
1
+ // Tests for the Runtype hosted TTS engine and the browser-fallback wrapper.
2
+ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
3
+ import type {
4
+ PcmStreamPlayer,
5
+ SpeechCallbacks,
6
+ SpeechEngine,
7
+ SpeechRequest,
8
+ } from "../types";
9
+ import {
10
+ RuntypeSpeechEngine,
11
+ type RuntypeSpeechEngineOptions,
12
+ } from "./runtype-speech-engine";
13
+ import { FallbackSpeechEngine } from "./fallback-speech-engine";
14
+
15
+ /**
16
+ * A fake {@link PcmStreamPlayer} whose started/finished callbacks the test can
17
+ * fire. Injected via `createPlaybackEngine` so the engine never touches a real
18
+ * AudioContext (and so the test no longer mocks the worklet module — the engine
19
+ * defaults to the in-bundle AudioPlaybackManager, which we substitute here).
20
+ */
21
+ function makeFakePlayer() {
22
+ let startedCbs: Array<() => void> = [];
23
+ let finishedCbs: Array<() => void> = [];
24
+ return {
25
+ enqueue: vi.fn(),
26
+ markStreamEnd: vi.fn(),
27
+ flush: vi.fn(() => {
28
+ startedCbs = [];
29
+ finishedCbs = [];
30
+ }),
31
+ resume: vi.fn(),
32
+ pause: vi.fn(),
33
+ destroy: vi.fn(),
34
+ onStarted: vi.fn((cb: () => void) => startedCbs.push(cb)),
35
+ onFinished: vi.fn((cb: () => void) => finishedCbs.push(cb)),
36
+ fireStarted: () => startedCbs.slice().forEach((c) => c()),
37
+ fireFinished: () => finishedCbs.slice().forEach((c) => c()),
38
+ };
39
+ }
40
+
41
+ /** Yield to the macrotask queue so the engine's async read loop fully drains. */
42
+ const settle = () => new Promise((r) => setTimeout(r, 0));
43
+
44
+ /** A fetch Response stub that streams the given chunks then ends. */
45
+ function streamingResponse(chunks: Uint8Array[]) {
46
+ let i = 0;
47
+ return {
48
+ ok: true,
49
+ status: 200,
50
+ body: {
51
+ getReader: () => ({
52
+ read: async () =>
53
+ i < chunks.length
54
+ ? { done: false, value: chunks[i++] }
55
+ : { done: true, value: undefined },
56
+ cancel: async () => {},
57
+ }),
58
+ },
59
+ };
60
+ }
61
+
62
+ describe("RuntypeSpeechEngine", () => {
63
+ let fetchMock: ReturnType<typeof vi.fn>;
64
+ let player: ReturnType<typeof makeFakePlayer>;
65
+
66
+ beforeEach(() => {
67
+ vi.clearAllMocks();
68
+ fetchMock = vi.fn();
69
+ (globalThis as unknown as { fetch: unknown }).fetch = fetchMock;
70
+ player = makeFakePlayer();
71
+ });
72
+
73
+ /** Build an engine whose playback is the injected fake (no AudioContext). */
74
+ const makeEngine = (opts: Partial<RuntypeSpeechEngineOptions> = {}) =>
75
+ new RuntypeSpeechEngine({
76
+ host: "https://api.runtype.com",
77
+ agentId: "agent_123",
78
+ clientToken: "ct_live_abc",
79
+ createPlaybackEngine: () => player as unknown as PcmStreamPlayer,
80
+ ...opts,
81
+ });
82
+
83
+ afterEach(() => {
84
+ delete (globalThis as unknown as { fetch?: unknown }).fetch;
85
+ });
86
+
87
+ it("POSTs to /v1/agents/:id/speak with bearer auth and pcm body", async () => {
88
+ fetchMock.mockResolvedValue(streamingResponse([new Uint8Array([1, 2, 3, 4])]));
89
+ const engine = makeEngine({ host: "https://api.runtype.com/" });
90
+
91
+ engine.speak({ text: "hello there" }, {});
92
+ await settle();
93
+
94
+ expect(fetchMock).toHaveBeenCalledTimes(1);
95
+ const [url, init] = fetchMock.mock.calls[0];
96
+ // Trailing slash on host must not double up.
97
+ expect(url).toBe("https://api.runtype.com/v1/agents/agent_123/speak");
98
+ expect(init.method).toBe("POST");
99
+ expect(init.headers.Authorization).toBe("Bearer ct_live_abc");
100
+ expect(JSON.parse(init.body)).toEqual({
101
+ text: "hello there",
102
+ voice: undefined,
103
+ format: "pcm",
104
+ });
105
+ });
106
+
107
+ it("streams chunks into the player and marks stream end", async () => {
108
+ const a = new Uint8Array([1, 2]);
109
+ const b = new Uint8Array([3, 4]);
110
+ fetchMock.mockResolvedValue(streamingResponse([a, b]));
111
+ const engine = makeEngine();
112
+
113
+ engine.speak({ text: "hi" }, {});
114
+ await settle();
115
+
116
+ expect(player.enqueue).toHaveBeenCalledTimes(2);
117
+ expect(player.enqueue).toHaveBeenNthCalledWith(1, a);
118
+ expect(player.enqueue).toHaveBeenNthCalledWith(2, b);
119
+ expect(player.markStreamEnd).toHaveBeenCalledTimes(1);
120
+ });
121
+
122
+ it("drives onStart/onEnd from the player, not chunk arrival", async () => {
123
+ fetchMock.mockResolvedValue(streamingResponse([new Uint8Array([1])]));
124
+ const onStart = vi.fn();
125
+ const onEnd = vi.fn();
126
+ const engine = makeEngine();
127
+
128
+ engine.speak({ text: "hi" }, { onStart, onEnd });
129
+ await settle();
130
+
131
+ expect(onStart).not.toHaveBeenCalled();
132
+ player.fireStarted();
133
+ expect(onStart).toHaveBeenCalledTimes(1);
134
+ expect(onEnd).not.toHaveBeenCalled();
135
+ player.fireFinished();
136
+ expect(onEnd).toHaveBeenCalledTimes(1);
137
+ });
138
+
139
+ it("sends the request voice when provided, else the engine default", async () => {
140
+ fetchMock.mockResolvedValue(streamingResponse([]));
141
+ const engine = makeEngine({ voice: "default-voice" });
142
+
143
+ engine.speak({ text: "x", voice: "request-voice" }, {});
144
+ await settle();
145
+ expect(JSON.parse(fetchMock.mock.calls[0][1].body).voice).toBe("request-voice");
146
+
147
+ engine.speak({ text: "y" }, {});
148
+ await settle();
149
+ expect(JSON.parse(fetchMock.mock.calls[1][1].body).voice).toBe("default-voice");
150
+ });
151
+
152
+ it("reports a descriptive error on a non-OK response", async () => {
153
+ fetchMock.mockResolvedValue({
154
+ ok: false,
155
+ status: 401,
156
+ body: null,
157
+ json: async () => ({ error: "Invalid or expired client token." }),
158
+ });
159
+ const onError = vi.fn();
160
+ const optsOnError = vi.fn();
161
+ const engine = makeEngine({ clientToken: "bad", onError: optsOnError });
162
+
163
+ engine.speak({ text: "hi" }, { onError });
164
+ await settle();
165
+
166
+ expect(onError).toHaveBeenCalledTimes(1);
167
+ expect(onError.mock.calls[0][0]).toBeInstanceOf(Error);
168
+ expect(onError.mock.calls[0][0].message).toContain("Invalid or expired");
169
+ expect(optsOnError).toHaveBeenCalledTimes(1);
170
+ });
171
+
172
+ it("ignores callbacks from a superseded speak()", async () => {
173
+ fetchMock.mockResolvedValue(streamingResponse([new Uint8Array([1])]));
174
+ const firstEnd = vi.fn();
175
+ const engine = makeEngine();
176
+
177
+ engine.speak({ text: "first" }, { onEnd: firstEnd });
178
+ engine.speak({ text: "second" }, {}); // supersedes the first
179
+ await settle();
180
+
181
+ // The first request's onEnd must not fire even if its (stale) finish lands.
182
+ player.fireFinished();
183
+ expect(firstEnd).not.toHaveBeenCalled();
184
+ });
185
+ });
186
+
187
+ /** A scriptable SpeechEngine that exposes the callbacks handed to speak(). */
188
+ function fakeEngine(id: string) {
189
+ let cbs: SpeechCallbacks = {};
190
+ return {
191
+ id,
192
+ supportsPause: true,
193
+ speak: vi.fn((_req: SpeechRequest, c: SpeechCallbacks) => {
194
+ cbs = c;
195
+ }),
196
+ pause: vi.fn(),
197
+ resume: vi.fn(),
198
+ stop: vi.fn(),
199
+ destroy: vi.fn(),
200
+ emitStart: () => cbs.onStart?.(),
201
+ emitEnd: () => cbs.onEnd?.(),
202
+ emitError: (e: Error) => cbs.onError?.(e),
203
+ };
204
+ }
205
+
206
+ describe("FallbackSpeechEngine", () => {
207
+ it("falls back to the secondary engine when the primary fails before audio", () => {
208
+ const primary = fakeEngine("primary");
209
+ const fallback = fakeEngine("fallback");
210
+ const onFallback = vi.fn();
211
+ const engine = new FallbackSpeechEngine(
212
+ primary as unknown as SpeechEngine,
213
+ fallback as unknown as SpeechEngine,
214
+ { onFallback },
215
+ );
216
+ const onError = vi.fn();
217
+ const req: SpeechRequest = { text: "hi" };
218
+
219
+ engine.speak(req, { onError });
220
+ primary.emitError(new Error("no endpoint"));
221
+
222
+ expect(onFallback).toHaveBeenCalledTimes(1);
223
+ expect(fallback.speak).toHaveBeenCalledTimes(1);
224
+ expect(fallback.speak.mock.calls[0][0]).toBe(req);
225
+ expect(onError).not.toHaveBeenCalled();
226
+ });
227
+
228
+ it("surfaces a failure that happens after playback has started", () => {
229
+ const primary = fakeEngine("primary");
230
+ const fallback = fakeEngine("fallback");
231
+ const engine = new FallbackSpeechEngine(
232
+ primary as unknown as SpeechEngine,
233
+ fallback as unknown as SpeechEngine,
234
+ );
235
+ const onError = vi.fn();
236
+
237
+ engine.speak({ text: "hi" }, { onError });
238
+ primary.emitStart();
239
+ primary.emitError(new Error("mid-stream drop"));
240
+
241
+ expect(fallback.speak).not.toHaveBeenCalled();
242
+ expect(onError).toHaveBeenCalledTimes(1);
243
+ expect(onError.mock.calls[0][0].message).toBe("mid-stream drop");
244
+ });
245
+
246
+ it("routes pause/resume/stop to the active engine", () => {
247
+ const primary = fakeEngine("primary");
248
+ const fallback = fakeEngine("fallback");
249
+ const engine = new FallbackSpeechEngine(
250
+ primary as unknown as SpeechEngine,
251
+ fallback as unknown as SpeechEngine,
252
+ );
253
+
254
+ // Before any failure, the primary is active.
255
+ engine.speak({ text: "hi" }, {});
256
+ engine.pause();
257
+ expect(primary.pause).toHaveBeenCalledTimes(1);
258
+ expect(fallback.pause).not.toHaveBeenCalled();
259
+
260
+ // After a pre-start failure the fallback becomes active.
261
+ primary.emitError(new Error("fail"));
262
+ engine.resume();
263
+ engine.stop();
264
+ expect(fallback.resume).toHaveBeenCalledTimes(1);
265
+ expect(fallback.stop).toHaveBeenCalledTimes(1);
266
+ expect(primary.resume).not.toHaveBeenCalled();
267
+ });
268
+
269
+ it("destroys both wrapped engines", () => {
270
+ const primary = fakeEngine("primary");
271
+ const fallback = fakeEngine("fallback");
272
+ const engine = new FallbackSpeechEngine(
273
+ primary as unknown as SpeechEngine,
274
+ fallback as unknown as SpeechEngine,
275
+ );
276
+ engine.destroy();
277
+ expect(primary.destroy).toHaveBeenCalledTimes(1);
278
+ expect(fallback.destroy).toHaveBeenCalledTimes(1);
279
+ });
280
+ });