@runtypelabs/persona 4.24.0 → 4.25.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.
- package/dist/approval-ui.d.cts +63 -0
- package/dist/approval-ui.d.ts +63 -0
- package/dist/artifacts-ui.d.cts +63 -0
- package/dist/artifacts-ui.d.ts +63 -0
- package/dist/codegen.cjs +1 -1
- package/dist/codegen.js +1 -1
- package/dist/context-mentions.d.cts +63 -0
- package/dist/context-mentions.d.ts +63 -0
- package/dist/event-stream-view.d.cts +63 -0
- package/dist/event-stream-view.d.ts +63 -0
- package/dist/forms-ui.d.cts +74 -0
- package/dist/forms-ui.d.ts +74 -0
- package/dist/index.cjs +42 -42
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +75 -1
- package/dist/index.d.ts +75 -1
- package/dist/index.global.js +27 -27
- package/dist/index.global.js.map +1 -1
- package/dist/index.js +42 -42
- package/dist/index.js.map +1 -1
- package/dist/session-reconnect.d.cts +63 -0
- package/dist/session-reconnect.d.ts +63 -0
- package/dist/smart-dom-reader.d.cts +63 -0
- package/dist/smart-dom-reader.d.ts +63 -0
- package/dist/theme-editor-preview.cjs +49 -49
- package/dist/theme-editor-preview.d.cts +63 -0
- package/dist/theme-editor-preview.d.ts +63 -0
- package/dist/theme-editor-preview.js +27 -27
- package/dist/theme-editor.d.cts +63 -0
- package/dist/theme-editor.d.ts +63 -0
- package/dist/voice-runtime-7DA7MJH4.js +1 -0
- package/dist/voice-runtime.cjs +1 -1
- package/dist/voice-runtime.d.cts +33 -0
- package/dist/voice-runtime.d.ts +33 -0
- package/dist/voice-runtime.js +1 -1
- package/package.json +1 -1
- package/src/index-core.ts +2 -0
- package/src/session.ts +30 -1
- package/src/session.voice.test.ts +72 -1
- package/src/types.ts +71 -0
- package/src/ui.ts +59 -0
- package/src/ui.voice-prewarm.test.ts +222 -0
- package/src/voice/runtype-voice-provider.ts +238 -27
- package/src/voice/voice.test.ts +272 -2
- package/dist/voice-runtime-EFR3R5ES.js +0 -1
package/src/types.ts
CHANGED
|
@@ -4614,6 +4614,24 @@ export type AgentWidgetStatusIndicatorConfig = {
|
|
|
4614
4614
|
resumingText?: string;
|
|
4615
4615
|
};
|
|
4616
4616
|
|
|
4617
|
+
/** Built-in `voiceRecognition.prewarm` triggers. See {@link AgentWidgetVoiceRecognitionConfig.prewarm}. */
|
|
4618
|
+
export type AgentWidgetVoicePrewarmTrigger = 'hover';
|
|
4619
|
+
|
|
4620
|
+
/**
|
|
4621
|
+
* Custom `voiceRecognition.prewarm` trigger. Runs while the composer has a mic
|
|
4622
|
+
* button, and again when the button is rebuilt or `update()` swaps in a
|
|
4623
|
+
* different function. Call `warm()` whenever the visitor shows intent to talk
|
|
4624
|
+
* (it is safe to call repeatedly). Return a cleanup to run on replacement.
|
|
4625
|
+
*/
|
|
4626
|
+
export type AgentWidgetVoicePrewarmHook = (context: {
|
|
4627
|
+
/** Fire-and-forget voice warm-up; latched and error-swallowing. */
|
|
4628
|
+
warm: () => void;
|
|
4629
|
+
/** The composer's current mic button. */
|
|
4630
|
+
micButton: HTMLButtonElement;
|
|
4631
|
+
/** The widget's mount element. */
|
|
4632
|
+
mount: HTMLElement;
|
|
4633
|
+
}) => void | (() => void);
|
|
4634
|
+
|
|
4617
4635
|
export type AgentWidgetVoiceRecognitionConfig = {
|
|
4618
4636
|
/**
|
|
4619
4637
|
* Enable the mic button and voice input. The mic still only renders when the
|
|
@@ -4685,6 +4703,30 @@ export type AgentWidgetVoiceRecognitionConfig = {
|
|
|
4685
4703
|
*/
|
|
4686
4704
|
onMetrics?: (metrics: VoiceMetrics) => void;
|
|
4687
4705
|
|
|
4706
|
+
/**
|
|
4707
|
+
* Warm the voice path when the visitor shows intent to talk, before the mic
|
|
4708
|
+
* click, so the call starts faster.
|
|
4709
|
+
*
|
|
4710
|
+
* - `'hover'`: when the pointer enters the mic button, it receives focus, or
|
|
4711
|
+
* (on touch) the first pointer-down on it.
|
|
4712
|
+
* - `false`: never.
|
|
4713
|
+
* - A function: custom trigger. Called with `{ warm, micButton, mount }`
|
|
4714
|
+
* while the mic button exists (and again when the button is rebuilt or
|
|
4715
|
+
* `update()` swaps in a different function); call `warm()` whenever you
|
|
4716
|
+
* decide, e.g. when the panel opens. May return a cleanup that runs on
|
|
4717
|
+
* destroy, rebuild, or replacement. Replaces the built-in trigger.
|
|
4718
|
+
*
|
|
4719
|
+
* What `warm()` does depends on the provider: `runtype` fires its prewarm
|
|
4720
|
+
* (see `provider.runtype.prewarmMode`), a `custom` provider's optional
|
|
4721
|
+
* {@link VoiceProvider.prewarm} runs, and the browser (Web Speech) path
|
|
4722
|
+
* starts the client-token session init (as `sessionInit` does), since speech
|
|
4723
|
+
* recognition itself cannot start before the click. Warming is
|
|
4724
|
+
* fire-and-forget, latched, and never surfaces an error or UI state.
|
|
4725
|
+
*
|
|
4726
|
+
* @default 'hover' in client token mode, otherwise `false`
|
|
4727
|
+
*/
|
|
4728
|
+
prewarm?: AgentWidgetVoicePrewarmTrigger | AgentWidgetVoicePrewarmHook | false;
|
|
4729
|
+
|
|
4688
4730
|
// Voice provider configuration
|
|
4689
4731
|
provider?: {
|
|
4690
4732
|
type: 'browser' | 'runtype' | 'custom';
|
|
@@ -4707,6 +4749,23 @@ export type AgentWidgetVoiceRecognitionConfig = {
|
|
|
4707
4749
|
* `@runtypelabs/persona/voice-worklet-player` for a jitter-buffered engine.
|
|
4708
4750
|
*/
|
|
4709
4751
|
createPlaybackEngine?: () => VoicePlaybackEngine | Promise<VoicePlaybackEngine>;
|
|
4752
|
+
/**
|
|
4753
|
+
* How `prewarm()` warms the voice path (see `voiceRecognition.prewarm`).
|
|
4754
|
+
*
|
|
4755
|
+
* - `'request'` (default): a fire-and-forget
|
|
4756
|
+
* `POST {host}/v1/client/agents/{agentId}/voice/prewarm`.
|
|
4757
|
+
* - `'attach'`: open the voice WebSocket ahead of the click without
|
|
4758
|
+
* starting a call; the click then starts the call on that socket. It can
|
|
4759
|
+
* create server work before the visitor clicks, so it is opt-in.
|
|
4760
|
+
* @default 'request'
|
|
4761
|
+
*/
|
|
4762
|
+
prewarmMode?: 'request' | 'attach';
|
|
4763
|
+
/**
|
|
4764
|
+
* `'attach'` mode: how long (ms) an attached socket waits for the click
|
|
4765
|
+
* before it closes. The server clamps it to 30000..600000.
|
|
4766
|
+
* @default 30000
|
|
4767
|
+
*/
|
|
4768
|
+
attachIdleMs?: number;
|
|
4710
4769
|
/** @deprecated No-op on the realtime path: the server's STT owns turn-taking. */
|
|
4711
4770
|
pauseDuration?: number;
|
|
4712
4771
|
/** @deprecated No-op on the realtime path: the server's STT owns turn-taking. */
|
|
@@ -5014,6 +5073,10 @@ export type VoiceConfig = {
|
|
|
5014
5073
|
* `@runtypelabs/persona/voice-worklet-player` for a jitter-buffered engine.
|
|
5015
5074
|
*/
|
|
5016
5075
|
createPlaybackEngine?: () => VoicePlaybackEngine | Promise<VoicePlaybackEngine>;
|
|
5076
|
+
/** See `voiceRecognition.provider.runtype.prewarmMode`. @default 'request' */
|
|
5077
|
+
prewarmMode?: 'request' | 'attach';
|
|
5078
|
+
/** See `voiceRecognition.provider.runtype.attachIdleMs`. @default 30000 */
|
|
5079
|
+
attachIdleMs?: number;
|
|
5017
5080
|
/** @deprecated No-op on the realtime path: the server's STT owns turn-taking. */
|
|
5018
5081
|
pauseDuration?: number;
|
|
5019
5082
|
/** @deprecated No-op on the realtime path: the server's STT owns turn-taking. */
|
|
@@ -5085,6 +5148,14 @@ export interface VoiceProvider {
|
|
|
5085
5148
|
|
|
5086
5149
|
/** Stop playback / cancel in-flight request without starting recording */
|
|
5087
5150
|
stopPlayback?(): void;
|
|
5151
|
+
|
|
5152
|
+
/**
|
|
5153
|
+
* Warm the provider ahead of `startListening()` (the widget calls it on mic
|
|
5154
|
+
* intent, see `voiceRecognition.prewarm`). Must be fire-and-forget: never
|
|
5155
|
+
* throw, emit errors or status, or acquire the microphone. May be called
|
|
5156
|
+
* repeatedly; throttle as needed.
|
|
5157
|
+
*/
|
|
5158
|
+
prewarm?(): void;
|
|
5088
5159
|
}
|
|
5089
5160
|
|
|
5090
5161
|
/**
|
package/src/ui.ts
CHANGED
|
@@ -16,6 +16,7 @@ import {
|
|
|
16
16
|
AgentWidgetConfig,
|
|
17
17
|
AgentWidgetConfigPatch,
|
|
18
18
|
AgentWidgetSessionInitHook,
|
|
19
|
+
AgentWidgetVoicePrewarmHook,
|
|
19
20
|
AgentWidgetApprovalDecisionOptions,
|
|
20
21
|
AgentWidgetMessage,
|
|
21
22
|
AgentWidgetEvent,
|
|
@@ -12981,6 +12982,62 @@ export const createAgentExperience = (
|
|
|
12981
12982
|
|
|
12982
12983
|
composerVoiceBridge = handleMicButtonClick;
|
|
12983
12984
|
|
|
12985
|
+
// Voice prewarm on mic intent (`voiceRecognition.prewarm`). Delegated on the
|
|
12986
|
+
// mount so composer rebuilds stay covered, and `config` is read at event time
|
|
12987
|
+
// so `update()` applies without re-wiring. Default: 'hover' in client token
|
|
12988
|
+
// mode, off otherwise.
|
|
12989
|
+
const warmVoice = () => session.prewarmVoice();
|
|
12990
|
+
const voicePrewarmSetting = () => {
|
|
12991
|
+
const voice = config.voiceRecognition;
|
|
12992
|
+
if (voice?.enabled !== true) return false;
|
|
12993
|
+
return voice.prewarm ?? (config.clientToken ? "hover" : false);
|
|
12994
|
+
};
|
|
12995
|
+
// `pointerover` covers mouse hover and precedes a touch's pointer-down;
|
|
12996
|
+
// `focusin` covers keyboard focus.
|
|
12997
|
+
const handleVoicePrewarmIntent = (event: Event) => {
|
|
12998
|
+
if (voicePrewarmSetting() !== "hover") return;
|
|
12999
|
+
if (micButton?.contains(event.target as Node)) warmVoice();
|
|
13000
|
+
};
|
|
13001
|
+
const voicePrewarmEvents = ["pointerover", "pointerdown", "focusin"];
|
|
13002
|
+
voicePrewarmEvents.forEach((type) => mount.addEventListener(type, handleVoicePrewarmIntent));
|
|
13003
|
+
// A custom prewarm hook is bound to one mic button: it re-runs (after its
|
|
13004
|
+
// cleanup) when the button is rebuilt or `update()` swaps the function.
|
|
13005
|
+
let voicePrewarmHook: AgentWidgetVoicePrewarmHook | undefined;
|
|
13006
|
+
let voicePrewarmHookButton: HTMLButtonElement | null = null;
|
|
13007
|
+
let voicePrewarmHookCleanup: ReturnType<AgentWidgetVoicePrewarmHook>;
|
|
13008
|
+
// Host hook code may throw; neither setup nor cleanup may break the widget.
|
|
13009
|
+
const releaseVoicePrewarmHook = () => {
|
|
13010
|
+
const cleanup = voicePrewarmHookCleanup;
|
|
13011
|
+
voicePrewarmHookCleanup = undefined;
|
|
13012
|
+
try {
|
|
13013
|
+
if (typeof cleanup === "function") cleanup();
|
|
13014
|
+
} catch {
|
|
13015
|
+
// ignored, see above
|
|
13016
|
+
}
|
|
13017
|
+
};
|
|
13018
|
+
const syncVoicePrewarmHook = () => {
|
|
13019
|
+
const setting = voicePrewarmSetting();
|
|
13020
|
+
const next = typeof setting === "function" && micButton ? setting : undefined;
|
|
13021
|
+
const button = next ? micButton : null;
|
|
13022
|
+
if (next === voicePrewarmHook && button === voicePrewarmHookButton) return;
|
|
13023
|
+
releaseVoicePrewarmHook();
|
|
13024
|
+
voicePrewarmHook = next;
|
|
13025
|
+
voicePrewarmHookButton = button;
|
|
13026
|
+
if (!next || !button) return;
|
|
13027
|
+
try {
|
|
13028
|
+
voicePrewarmHookCleanup = next({ warm: warmVoice, micButton: button, mount });
|
|
13029
|
+
} catch {
|
|
13030
|
+
// ignored, see above
|
|
13031
|
+
}
|
|
13032
|
+
};
|
|
13033
|
+
syncVoicePrewarmHook();
|
|
13034
|
+
destroyCallbacks.push(() => {
|
|
13035
|
+
voicePrewarmEvents.forEach((type) =>
|
|
13036
|
+
mount.removeEventListener(type, handleVoicePrewarmIntent)
|
|
13037
|
+
);
|
|
13038
|
+
releaseVoicePrewarmHook();
|
|
13039
|
+
});
|
|
13040
|
+
|
|
12984
13041
|
if (micButton) {
|
|
12985
13042
|
// The click listener itself is registered by `wireComposerSurface`, so a
|
|
12986
13043
|
// composer rebuild unwires it with the rest of the surface.
|
|
@@ -13578,6 +13635,7 @@ export const createAgentExperience = (
|
|
|
13578
13635
|
if (micButton) {
|
|
13579
13636
|
composerBindings.addListener(micButton, "click", handleMicButtonClick);
|
|
13580
13637
|
}
|
|
13638
|
+
syncVoicePrewarmHook();
|
|
13581
13639
|
};
|
|
13582
13640
|
|
|
13583
13641
|
wireComposerSurface();
|
|
@@ -14764,6 +14822,7 @@ export const createAgentExperience = (
|
|
|
14764
14822
|
}
|
|
14765
14823
|
}
|
|
14766
14824
|
}
|
|
14825
|
+
syncVoicePrewarmHook();
|
|
14767
14826
|
|
|
14768
14827
|
// Update attachment button visibility based on attachments config
|
|
14769
14828
|
const attachmentsEnabled = config.attachments?.enabled === true;
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
|
|
3
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
4
|
+
import { createAgentExperience } from "./ui";
|
|
5
|
+
import type {
|
|
6
|
+
AgentWidgetConfig,
|
|
7
|
+
AgentWidgetVoicePrewarmHook,
|
|
8
|
+
AgentWidgetVoiceRecognitionConfig,
|
|
9
|
+
} from "./types";
|
|
10
|
+
|
|
11
|
+
let controller: ReturnType<typeof createAgentExperience> | undefined;
|
|
12
|
+
|
|
13
|
+
beforeEach(() => {
|
|
14
|
+
window.scrollTo = vi.fn();
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
afterEach(() => {
|
|
18
|
+
controller?.destroy();
|
|
19
|
+
controller = undefined;
|
|
20
|
+
document.body.replaceChildren();
|
|
21
|
+
vi.restoreAllMocks();
|
|
22
|
+
vi.unstubAllGlobals();
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
const initResponse = () =>
|
|
26
|
+
Response.json({
|
|
27
|
+
sessionId: "session-1",
|
|
28
|
+
expiresAt: new Date(Date.now() + 600_000).toISOString(),
|
|
29
|
+
flow: { id: "agent-1", name: "Agent", description: null },
|
|
30
|
+
config: { welcomeMessage: null, placeholder: "Ask...", theme: null },
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
const runtypeVoice = (
|
|
34
|
+
extra: Partial<AgentWidgetVoiceRecognitionConfig> = {}
|
|
35
|
+
): AgentWidgetVoiceRecognitionConfig => ({
|
|
36
|
+
enabled: true,
|
|
37
|
+
provider: { type: "runtype", runtype: { agentId: "agent-1" } },
|
|
38
|
+
...extra,
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
function mountWidget(config: Partial<AgentWidgetConfig>) {
|
|
42
|
+
const prewarms: Array<{ url: string; init?: RequestInit }> = [];
|
|
43
|
+
const inits: string[] = [];
|
|
44
|
+
vi.stubGlobal(
|
|
45
|
+
"fetch",
|
|
46
|
+
vi.fn(async (url: string, init?: RequestInit) => {
|
|
47
|
+
if (url.endsWith("/voice/prewarm")) {
|
|
48
|
+
prewarms.push({ url, init });
|
|
49
|
+
return new Response(null, { status: 204 });
|
|
50
|
+
}
|
|
51
|
+
if (url.endsWith("/v1/client/init")) {
|
|
52
|
+
inits.push(url);
|
|
53
|
+
return initResponse();
|
|
54
|
+
}
|
|
55
|
+
return new Response('data: {"type":"done"}\n\n', {
|
|
56
|
+
headers: { "Content-Type": "text/event-stream" },
|
|
57
|
+
});
|
|
58
|
+
})
|
|
59
|
+
);
|
|
60
|
+
const mount = document.createElement("div");
|
|
61
|
+
document.body.appendChild(mount);
|
|
62
|
+
controller = createAgentExperience(mount, {
|
|
63
|
+
apiUrl: "https://api.example.com",
|
|
64
|
+
agentId: "agent-1",
|
|
65
|
+
launcher: { enabled: false },
|
|
66
|
+
persistState: false,
|
|
67
|
+
...config,
|
|
68
|
+
} as AgentWidgetConfig);
|
|
69
|
+
const mic = () => mount.querySelector<HTMLButtonElement>("[data-persona-composer-mic]")!;
|
|
70
|
+
const hover = () => mic().dispatchEvent(new Event("pointerover", { bubbles: true }));
|
|
71
|
+
return { mount, mic, hover, prewarms, inits };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Lets the lazy voice runtime install and any fire-and-forget fetch run.
|
|
75
|
+
const settle = async () => {
|
|
76
|
+
for (let i = 0; i < 5; i += 1) await new Promise((r) => setTimeout(r, 0));
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
describe("voiceRecognition.prewarm: default", () => {
|
|
80
|
+
it("prewarms once on mic hover in client token mode (latched)", async () => {
|
|
81
|
+
const w = mountWidget({ clientToken: "ct_test", voiceRecognition: runtypeVoice() });
|
|
82
|
+
await settle();
|
|
83
|
+
expect(w.prewarms).toHaveLength(0);
|
|
84
|
+
|
|
85
|
+
w.hover();
|
|
86
|
+
await vi.waitFor(() => expect(w.prewarms).toHaveLength(1));
|
|
87
|
+
expect(w.prewarms[0].url).toBe(
|
|
88
|
+
"https://api.example.com/v1/client/agents/agent-1/voice/prewarm"
|
|
89
|
+
);
|
|
90
|
+
expect(w.prewarms[0].init?.headers).toEqual({ Authorization: "Bearer ct_test" });
|
|
91
|
+
|
|
92
|
+
w.hover();
|
|
93
|
+
w.mic().dispatchEvent(new Event("pointerdown", { bubbles: true }));
|
|
94
|
+
w.mic().dispatchEvent(new FocusEvent("focusin", { bubbles: true }));
|
|
95
|
+
await settle();
|
|
96
|
+
expect(w.prewarms).toHaveLength(1);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("also prewarms on keyboard focus", async () => {
|
|
100
|
+
const w = mountWidget({ clientToken: "ct_test", voiceRecognition: runtypeVoice() });
|
|
101
|
+
await settle();
|
|
102
|
+
w.mic().dispatchEvent(new FocusEvent("focusin", { bubbles: true }));
|
|
103
|
+
await vi.waitFor(() => expect(w.prewarms).toHaveLength(1));
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("ignores hover elsewhere in the widget", async () => {
|
|
107
|
+
const w = mountWidget({ clientToken: "ct_test", voiceRecognition: runtypeVoice() });
|
|
108
|
+
await settle();
|
|
109
|
+
w.mount
|
|
110
|
+
.querySelector("[data-persona-composer-input]")!
|
|
111
|
+
.dispatchEvent(new Event("pointerover", { bubbles: true }));
|
|
112
|
+
await settle();
|
|
113
|
+
expect(w.prewarms).toHaveLength(0);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it("does not auto-prewarm outside client token mode", async () => {
|
|
117
|
+
const w = mountWidget({
|
|
118
|
+
voiceRecognition: {
|
|
119
|
+
enabled: true,
|
|
120
|
+
provider: { type: "runtype", runtype: { agentId: "agent-1", clientToken: "ct_voice" } },
|
|
121
|
+
},
|
|
122
|
+
});
|
|
123
|
+
await settle();
|
|
124
|
+
w.hover();
|
|
125
|
+
await settle();
|
|
126
|
+
expect(w.prewarms).toHaveLength(0);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it("warms the client session on the browser (Web Speech) path", async () => {
|
|
130
|
+
vi.stubGlobal("SpeechRecognition", vi.fn());
|
|
131
|
+
const w = mountWidget({ clientToken: "ct_test", voiceRecognition: { enabled: true } });
|
|
132
|
+
await settle();
|
|
133
|
+
expect(w.inits).toHaveLength(0);
|
|
134
|
+
w.hover();
|
|
135
|
+
await vi.waitFor(() => expect(w.inits).toHaveLength(1));
|
|
136
|
+
expect(w.prewarms).toHaveLength(0);
|
|
137
|
+
});
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
describe("voiceRecognition.prewarm: explicit settings", () => {
|
|
141
|
+
it("false opts out", async () => {
|
|
142
|
+
const w = mountWidget({
|
|
143
|
+
clientToken: "ct_test",
|
|
144
|
+
voiceRecognition: runtypeVoice({ prewarm: false }),
|
|
145
|
+
});
|
|
146
|
+
await settle();
|
|
147
|
+
w.hover();
|
|
148
|
+
await settle();
|
|
149
|
+
expect(w.prewarms).toHaveLength(0);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it("'hover' enables it outside client token mode", async () => {
|
|
153
|
+
const w = mountWidget({
|
|
154
|
+
voiceRecognition: {
|
|
155
|
+
enabled: true,
|
|
156
|
+
prewarm: "hover",
|
|
157
|
+
provider: { type: "runtype", runtype: { agentId: "agent-1", clientToken: "ct_voice" } },
|
|
158
|
+
},
|
|
159
|
+
});
|
|
160
|
+
await settle();
|
|
161
|
+
w.hover();
|
|
162
|
+
await vi.waitFor(() => expect(w.prewarms).toHaveLength(1));
|
|
163
|
+
expect(w.prewarms[0].init?.headers).toEqual({ Authorization: "Bearer ct_voice" });
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it("a custom hook receives warm and the mic button, replaces hover, and is cleaned up", async () => {
|
|
167
|
+
const cleanup = vi.fn();
|
|
168
|
+
let context: Parameters<AgentWidgetVoicePrewarmHook>[0] | undefined;
|
|
169
|
+
const hook = vi.fn((ctx: Parameters<AgentWidgetVoicePrewarmHook>[0]) => {
|
|
170
|
+
context = ctx;
|
|
171
|
+
return cleanup;
|
|
172
|
+
});
|
|
173
|
+
const w = mountWidget({
|
|
174
|
+
clientToken: "ct_test",
|
|
175
|
+
voiceRecognition: runtypeVoice({ prewarm: hook }),
|
|
176
|
+
});
|
|
177
|
+
await settle();
|
|
178
|
+
expect(hook).toHaveBeenCalledOnce();
|
|
179
|
+
expect(context?.micButton).toBe(w.mic());
|
|
180
|
+
expect(context?.mount).toBe(w.mount);
|
|
181
|
+
|
|
182
|
+
w.hover();
|
|
183
|
+
await settle();
|
|
184
|
+
expect(w.prewarms).toHaveLength(0);
|
|
185
|
+
|
|
186
|
+
context!.warm();
|
|
187
|
+
await vi.waitFor(() => expect(w.prewarms).toHaveLength(1));
|
|
188
|
+
|
|
189
|
+
controller!.destroy();
|
|
190
|
+
controller = undefined;
|
|
191
|
+
expect(cleanup).toHaveBeenCalledOnce();
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
it("update() swaps the hook and turning prewarm off releases it", async () => {
|
|
195
|
+
const cleanupA = vi.fn();
|
|
196
|
+
const hookA = vi.fn(() => cleanupA);
|
|
197
|
+
const hookB = vi.fn();
|
|
198
|
+
mountWidget({ clientToken: "ct_test", voiceRecognition: runtypeVoice({ prewarm: hookA }) });
|
|
199
|
+
await settle();
|
|
200
|
+
|
|
201
|
+
controller!.update({ voiceRecognition: runtypeVoice({ prewarm: hookB }) });
|
|
202
|
+
expect(cleanupA).toHaveBeenCalledOnce();
|
|
203
|
+
expect(hookB).toHaveBeenCalledOnce();
|
|
204
|
+
|
|
205
|
+
controller!.update({ voiceRecognition: runtypeVoice({ prewarm: false }) });
|
|
206
|
+
expect(hookB).toHaveBeenCalledOnce();
|
|
207
|
+
expect(hookA).toHaveBeenCalledOnce();
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
it("a throwing hook does not break mount", async () => {
|
|
211
|
+
const w = mountWidget({
|
|
212
|
+
clientToken: "ct_test",
|
|
213
|
+
voiceRecognition: runtypeVoice({
|
|
214
|
+
prewarm: () => {
|
|
215
|
+
throw new Error("host bug");
|
|
216
|
+
},
|
|
217
|
+
}),
|
|
218
|
+
});
|
|
219
|
+
await settle();
|
|
220
|
+
expect(w.mic()).toBeTruthy();
|
|
221
|
+
});
|
|
222
|
+
});
|