@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.
Files changed (45) hide show
  1. package/dist/approval-ui.d.cts +63 -0
  2. package/dist/approval-ui.d.ts +63 -0
  3. package/dist/artifacts-ui.d.cts +63 -0
  4. package/dist/artifacts-ui.d.ts +63 -0
  5. package/dist/codegen.cjs +1 -1
  6. package/dist/codegen.js +1 -1
  7. package/dist/context-mentions.d.cts +63 -0
  8. package/dist/context-mentions.d.ts +63 -0
  9. package/dist/event-stream-view.d.cts +63 -0
  10. package/dist/event-stream-view.d.ts +63 -0
  11. package/dist/forms-ui.d.cts +74 -0
  12. package/dist/forms-ui.d.ts +74 -0
  13. package/dist/index.cjs +42 -42
  14. package/dist/index.cjs.map +1 -1
  15. package/dist/index.d.cts +75 -1
  16. package/dist/index.d.ts +75 -1
  17. package/dist/index.global.js +27 -27
  18. package/dist/index.global.js.map +1 -1
  19. package/dist/index.js +42 -42
  20. package/dist/index.js.map +1 -1
  21. package/dist/session-reconnect.d.cts +63 -0
  22. package/dist/session-reconnect.d.ts +63 -0
  23. package/dist/smart-dom-reader.d.cts +63 -0
  24. package/dist/smart-dom-reader.d.ts +63 -0
  25. package/dist/theme-editor-preview.cjs +49 -49
  26. package/dist/theme-editor-preview.d.cts +63 -0
  27. package/dist/theme-editor-preview.d.ts +63 -0
  28. package/dist/theme-editor-preview.js +27 -27
  29. package/dist/theme-editor.d.cts +63 -0
  30. package/dist/theme-editor.d.ts +63 -0
  31. package/dist/voice-runtime-7DA7MJH4.js +1 -0
  32. package/dist/voice-runtime.cjs +1 -1
  33. package/dist/voice-runtime.d.cts +33 -0
  34. package/dist/voice-runtime.d.ts +33 -0
  35. package/dist/voice-runtime.js +1 -1
  36. package/package.json +1 -1
  37. package/src/index-core.ts +2 -0
  38. package/src/session.ts +30 -1
  39. package/src/session.voice.test.ts +72 -1
  40. package/src/types.ts +71 -0
  41. package/src/ui.ts +59 -0
  42. package/src/ui.voice-prewarm.test.ts +222 -0
  43. package/src/voice/runtype-voice-provider.ts +238 -27
  44. package/src/voice/voice.test.ts +272 -2
  45. package/dist/voice-runtime-EFR3R5ES.js +0 -1
@@ -16,6 +16,14 @@
16
16
  // A continuous always-hot mic is, in UX terms, a permanent barge-in session, so
17
17
  // `getInterruptionMode()` reports the constant `'barge-in'` and the existing
18
18
  // mic-button wiring (ui.ts) treats a click as "hang up at any state" unchanged.
19
+ //
20
+ // Prewarm (`prewarm()`, called on mic intent): by default a fire-and-forget
21
+ // `POST /v1/client/agents/:agentId/voice/prewarm`. With `prewarmMode: 'attach'`
22
+ // the WebSocket opens early with `clientCapabilities=attach` and the extra
23
+ // `runtype.attach` subprotocol. If the server negotiates `runtype.attach`, the
24
+ // socket idles (no call) until `startListening()` sends `{"type":"start"}`; if
25
+ // it negotiates plain `runtype.bearer`, it ignored attach and the call is
26
+ // already live, so `startListening()` adopts it as is.
19
27
 
20
28
  import type {
21
29
  VoiceProvider,
@@ -37,6 +45,29 @@ const CAPTURE_BUFFER_SIZE = 4096;
37
45
  */
38
46
  const LEVEL_RMS_SCALE = 4;
39
47
  const RIFF_MAGIC = 0x52494646; // "RIFF"
48
+ /** `prewarm()` warms at most once per this window per provider instance. */
49
+ const PREWARM_THROTTLE_MS = 30_000;
50
+ /** How long `startListening()` waits on an attach still handshaking. */
51
+ const ATTACH_HANDSHAKE_TIMEOUT_MS = 5_000;
52
+ /** Attached-socket idle window: server default and clamp range. */
53
+ const ATTACH_IDLE_DEFAULT_MS = 30_000;
54
+ const ATTACH_IDLE_MIN_MS = 30_000;
55
+ const ATTACH_IDLE_MAX_MS = 600_000;
56
+
57
+ /** A socket opened by an `'attach'` prewarm, waiting for the click. */
58
+ type AttachedSocket = {
59
+ ws: WebSocket;
60
+ /**
61
+ * `connecting` until the handshake completes; then `attached` (no call yet,
62
+ * needs `start`) or `live` (the server ignored attach and the call is live).
63
+ */
64
+ state: "connecting" | "attached" | "live";
65
+ /** Settles when the handshake completes, fails, or the socket is released. */
66
+ ready: Promise<void>;
67
+ settle: () => void;
68
+ openedAt: number;
69
+ idleTimer: ReturnType<typeof setTimeout> | undefined;
70
+ };
40
71
 
41
72
  /**
42
73
  * Strip the canonical 44-byte WAV header (if present) and return the raw PCM16
@@ -63,6 +94,16 @@ function toWsBase(host: string): string {
63
94
  return `${secure ? "wss:" : "ws:"}//${trimmed}`;
64
95
  }
65
96
 
97
+ /** Derive an http(s):// base URL from a configured host (the ws base, reversed). */
98
+ function toHttpBase(host: string): string {
99
+ return toWsBase(host).replace(/^ws/i, "http");
100
+ }
101
+
102
+ function clampAttachIdleMs(ms: number | undefined): number {
103
+ if (typeof ms !== "number" || !Number.isFinite(ms)) return ATTACH_IDLE_DEFAULT_MS;
104
+ return Math.min(ATTACH_IDLE_MAX_MS, Math.max(ATTACH_IDLE_MIN_MS, Math.round(ms)));
105
+ }
106
+
66
107
  export class RuntypeVoiceProvider implements VoiceProvider {
67
108
  type: "runtype" = "runtype";
68
109
 
@@ -87,6 +128,10 @@ export class RuntypeVoiceProvider implements VoiceProvider {
87
128
  // Distinguishes a user-initiated close (code 1000) from a dropped connection.
88
129
  private intentionalClose = false;
89
130
 
131
+ // Prewarm latch and the (at most one) socket an `'attach'` prewarm opened.
132
+ private lastPrewarmAt = Number.NEGATIVE_INFINITY;
133
+ private attached: AttachedSocket | null = null;
134
+
90
135
  private resultCallbacks: ((result: VoiceResult) => void)[] = [];
91
136
  private errorCallbacks: ((error: Error) => void)[] = [];
92
137
  private statusCallbacks: ((status: VoiceStatus) => void)[] = [];
@@ -104,6 +149,153 @@ export class RuntypeVoiceProvider implements VoiceProvider {
104
149
  /** No-op: the WS session opens lazily in `startListening` (the "call"). */
105
150
  async connect(): Promise<void> {}
106
151
 
152
+ /**
153
+ * Warm the voice path ahead of the click. Fire-and-forget and throttled to
154
+ * once per {@link PREWARM_THROTTLE_MS}; never emits an error or a status.
155
+ */
156
+ prewarm(): void {
157
+ if (this.callLive || this.attached) return;
158
+ const agentId = this.config?.agentId;
159
+ const token = this.config?.clientToken;
160
+ const host = this.config?.host;
161
+ if (!agentId || !token || !host) return;
162
+ const now = Date.now();
163
+ if (now - this.lastPrewarmAt < PREWARM_THROTTLE_MS) return;
164
+ this.lastPrewarmAt = now;
165
+ try {
166
+ if (this.config?.prewarmMode === "attach") {
167
+ this.attach(host, agentId, token);
168
+ } else {
169
+ this.requestPrewarm(host, agentId, token);
170
+ }
171
+ } catch {
172
+ // A prewarm must never surface an error; the click connects normally.
173
+ }
174
+ }
175
+
176
+ private requestPrewarm(host: string, agentId: string, token: string): void {
177
+ if (typeof fetch !== "function") return;
178
+ const url = `${toHttpBase(host)}/v1/client/agents/${encodeURIComponent(agentId)}/voice/prewarm`;
179
+ void fetch(url, {
180
+ method: "POST",
181
+ headers: { Authorization: `Bearer ${token}` },
182
+ keepalive: true,
183
+ }).catch(() => {});
184
+ }
185
+
186
+ private attach(host: string, agentId: string, token: string): void {
187
+ const params = new URLSearchParams({ clientCapabilities: "attach" });
188
+ const configuredIdleMs = this.config?.attachIdleMs;
189
+ const idleMs = clampAttachIdleMs(configuredIdleMs);
190
+ if (configuredIdleMs !== undefined) params.set("attachIdleMs", String(idleMs));
191
+ const ws = new WebSocket(`${this.voiceSocketUrl(host, agentId)}?${params}`, [
192
+ "runtype.bearer",
193
+ "runtype.attach",
194
+ token,
195
+ ]);
196
+ ws.binaryType = "arraybuffer";
197
+ let settle: () => void = () => {};
198
+ const ready = new Promise<void>((resolve) => {
199
+ settle = resolve;
200
+ });
201
+ const entry: AttachedSocket = {
202
+ ws,
203
+ state: "connecting",
204
+ ready,
205
+ settle,
206
+ openedAt: 0,
207
+ idleTimer: undefined,
208
+ };
209
+ this.attached = entry;
210
+
211
+ ws.onopen = () => {
212
+ if (this.attached !== entry) return;
213
+ entry.state = ws.protocol === "runtype.attach" ? "attached" : "live";
214
+ entry.openedAt = Date.now();
215
+ this.armAttachIdle(entry, idleMs);
216
+ if (entry.state === "attached") ws.send('{"type":"ping"}');
217
+ entry.settle();
218
+ };
219
+ ws.onmessage = (event) => {
220
+ if (this.attached !== entry || typeof event.data !== "string") return;
221
+ let msg: { type?: unknown; idleMs?: unknown };
222
+ try {
223
+ msg = JSON.parse(event.data);
224
+ } catch {
225
+ return;
226
+ }
227
+ // The server's idle window is authoritative; close no later than it does.
228
+ if (msg.type === "attached" && typeof msg.idleMs === "number") {
229
+ this.armAttachIdle(entry, msg.idleMs - (Date.now() - entry.openedAt));
230
+ }
231
+ };
232
+ ws.onerror = () => {};
233
+ ws.onclose = () => {
234
+ if (this.attached === entry) this.releaseAttached();
235
+ };
236
+ }
237
+
238
+ private armAttachIdle(entry: AttachedSocket, ms: number): void {
239
+ clearTimeout(entry.idleTimer);
240
+ entry.idleTimer = setTimeout(() => {
241
+ if (this.attached === entry) this.releaseAttached();
242
+ }, Math.max(0, ms));
243
+ }
244
+
245
+ /** Close and forget the prewarmed socket, if any. Silent by design. */
246
+ private releaseAttached(): void {
247
+ const entry = this.attached;
248
+ if (!entry) return;
249
+ this.attached = null;
250
+ clearTimeout(entry.idleTimer);
251
+ const { ws } = entry;
252
+ ws.onopen = null;
253
+ ws.onmessage = null;
254
+ ws.onerror = null;
255
+ ws.onclose = null;
256
+ entry.settle();
257
+ if (ws.readyState === WebSocket.CONNECTING || ws.readyState === WebSocket.OPEN) {
258
+ try {
259
+ ws.close(1000, "prewarm released");
260
+ } catch {
261
+ // ignore
262
+ }
263
+ }
264
+ }
265
+
266
+ /**
267
+ * Hand the prewarmed socket to the call, waiting up to
268
+ * {@link ATTACH_HANDSHAKE_TIMEOUT_MS} for a handshake still in flight.
269
+ * Resolves `null` when there is none, it stalled, or it dropped, so the
270
+ * caller opens a fresh socket.
271
+ */
272
+ private async takeAttachedSocket(): Promise<{ ws: WebSocket; live: boolean } | null> {
273
+ const entry = this.attached;
274
+ if (!entry) return null;
275
+ if (entry.state === "connecting") {
276
+ let timer: ReturnType<typeof setTimeout> | undefined;
277
+ await Promise.race([
278
+ entry.ready,
279
+ new Promise<void>((resolve) => {
280
+ timer = setTimeout(resolve, ATTACH_HANDSHAKE_TIMEOUT_MS);
281
+ }),
282
+ ]);
283
+ clearTimeout(timer);
284
+ }
285
+ if (this.attached !== entry) return null;
286
+ if (entry.state === "connecting" || entry.ws.readyState !== WebSocket.OPEN) {
287
+ this.releaseAttached();
288
+ return null;
289
+ }
290
+ this.attached = null;
291
+ clearTimeout(entry.idleTimer);
292
+ return { ws: entry.ws, live: entry.state === "live" };
293
+ }
294
+
295
+ private voiceSocketUrl(host: string, agentId: string): string {
296
+ return `${toWsBase(host)}/ws/agents/${encodeURIComponent(agentId)}/voice`;
297
+ }
298
+
107
299
  /** Start the call: acquire mic, open the WS, stream PCM until hang-up. */
108
300
  async startListening(): Promise<void> {
109
301
  if (this.callLive) return; // idempotent: a call is already live
@@ -164,11 +356,25 @@ export class RuntypeVoiceProvider implements VoiceProvider {
164
356
  }
165
357
  });
166
358
 
167
- const wsUrl = `${toWsBase(host)}/ws/agents/${encodeURIComponent(agentId)}/voice`;
359
+ const adopted = await this.takeAttachedSocket();
360
+ if (generation !== this.callGeneration) {
361
+ adopted?.ws.close(1000, "client ended call");
362
+ return;
363
+ }
364
+ if (adopted) {
365
+ const { ws } = adopted;
366
+ this.ws = ws;
367
+ this.bindCallSocket(ws, generation);
368
+ if (!adopted.live) ws.send('{"type":"start"}');
369
+ this.emitStatus("listening");
370
+ this.startCapture(captureContext, stream, ws, generation);
371
+ return;
372
+ }
373
+
168
374
  // Token rides the subprotocol; `runtype.bearer` is the marker the server
169
375
  // echoes as the negotiated subprotocol (browsers fail the handshake if an
170
376
  // offered subprotocol goes unanswered).
171
- const ws = new WebSocket(wsUrl, ["runtype.bearer", token]);
377
+ const ws = new WebSocket(this.voiceSocketUrl(host, agentId), ["runtype.bearer", token]);
172
378
  ws.binaryType = "arraybuffer";
173
379
  this.ws = ws;
174
380
 
@@ -177,31 +383,7 @@ export class RuntypeVoiceProvider implements VoiceProvider {
177
383
  this.emitStatus("listening");
178
384
  this.startCapture(captureContext, stream, ws, generation);
179
385
  };
180
-
181
- ws.onmessage = (event) => this.handleMessage(event, generation);
182
-
183
- ws.onerror = () => {
184
- if (generation !== this.callGeneration) return;
185
- this.emitError(new Error("Voice connection failed"));
186
- this.emitStatus("error");
187
- this.cleanup();
188
- };
189
-
190
- ws.onclose = (evt) => {
191
- if (this.intentionalClose) {
192
- this.intentionalClose = false;
193
- return;
194
- }
195
- if (generation !== this.callGeneration) return;
196
- if (evt.code !== 1000) {
197
- const codeMsg = evt.code ? ` (code ${evt.code})` : "";
198
- this.emitError(new Error(`Voice connection closed${codeMsg}`));
199
- this.emitStatus("error");
200
- } else {
201
- this.emitStatus("idle");
202
- }
203
- this.cleanup();
204
- };
386
+ this.bindCallSocket(ws, generation);
205
387
  } catch (error) {
206
388
  this.cleanup();
207
389
  this.emitError(error as Error);
@@ -210,6 +392,34 @@ export class RuntypeVoiceProvider implements VoiceProvider {
210
392
  }
211
393
  }
212
394
 
395
+ /** Route a call socket's frames, errors, and close into this call. */
396
+ private bindCallSocket(ws: WebSocket, generation: number): void {
397
+ ws.onmessage = (event) => this.handleMessage(event, generation);
398
+
399
+ ws.onerror = () => {
400
+ if (generation !== this.callGeneration) return;
401
+ this.emitError(new Error("Voice connection failed"));
402
+ this.emitStatus("error");
403
+ this.cleanup();
404
+ };
405
+
406
+ ws.onclose = (evt) => {
407
+ if (this.intentionalClose) {
408
+ this.intentionalClose = false;
409
+ return;
410
+ }
411
+ if (generation !== this.callGeneration) return;
412
+ if (evt.code !== 1000) {
413
+ const codeMsg = evt.code ? ` (code ${evt.code})` : "";
414
+ this.emitError(new Error(`Voice connection closed${codeMsg}`));
415
+ this.emitStatus("error");
416
+ } else {
417
+ this.emitStatus("idle");
418
+ }
419
+ this.cleanup();
420
+ };
421
+ }
422
+
213
423
  /** End the call (hang up). */
214
424
  async stopListening(): Promise<void> {
215
425
  this.cleanup();
@@ -374,6 +584,7 @@ export class RuntypeVoiceProvider implements VoiceProvider {
374
584
  this.callGeneration += 1;
375
585
  this.callLive = false;
376
586
  this.isSpeaking = false;
587
+ this.releaseAttached();
377
588
 
378
589
  if (this.processor) {
379
590
  this.processor.onaudioprocess = null;
@@ -217,14 +217,17 @@ describe('Voice Support Check', () => {
217
217
  const WS_OPEN = 1;
218
218
 
219
219
  class MockWebSocket {
220
+ static CONNECTING = 0;
220
221
  static OPEN = WS_OPEN;
221
222
  static instances: MockWebSocket[] = [];
222
223
 
223
224
  url: string;
224
225
  protocols: string | string[] | undefined;
226
+ /** Negotiated subprotocol, set by the test before `triggerOpen()`. */
227
+ protocol = '';
225
228
  binaryType = '';
226
229
  readyState = 0;
227
- sent: ArrayBuffer[] = [];
230
+ sent: Array<ArrayBuffer | string> = [];
228
231
  closeCalls: Array<{ code?: number; reason?: string }> = [];
229
232
  onopen: (() => void) | null = null;
230
233
  onmessage: ((ev: { data: unknown }) => void) | null = null;
@@ -237,7 +240,7 @@ class MockWebSocket {
237
240
  MockWebSocket.instances.push(this);
238
241
  }
239
242
 
240
- send(data: ArrayBuffer) {
243
+ send(data: ArrayBuffer | string) {
241
244
  this.sent.push(data);
242
245
  }
243
246
 
@@ -535,4 +538,271 @@ describe('RuntypeVoiceProvider (realtime streaming)', () => {
535
538
  engine.finishedCb?.(); // playback drained
536
539
  expect(statuses).toEqual(['listening']);
537
540
  });
541
+ });
542
+
543
+ describe('RuntypeVoiceProvider prewarm', () => {
544
+ let getUserMedia: ReturnType<typeof vi.fn>;
545
+ let fetchMock: ReturnType<typeof vi.fn>;
546
+
547
+ beforeEach(() => {
548
+ vi.useFakeTimers();
549
+ MockWebSocket.instances = [];
550
+ getUserMedia = vi.fn(async () => makeStream().stream);
551
+ fetchMock = vi.fn(async () => new Response(null, { status: 204 }));
552
+ vi.stubGlobal('WebSocket', MockWebSocket);
553
+ vi.stubGlobal('navigator', { mediaDevices: { getUserMedia } });
554
+ vi.stubGlobal('fetch', fetchMock);
555
+ (globalThis as any).window.AudioContext = MockAudioContext;
556
+ (globalThis as any).window.webkitAudioContext = MockAudioContext;
557
+ (globalThis as any).window.location = { protocol: 'https:' };
558
+ });
559
+
560
+ afterEach(() => {
561
+ vi.useRealTimers();
562
+ vi.unstubAllGlobals();
563
+ delete (globalThis as any).window.AudioContext;
564
+ delete (globalThis as any).window.webkitAudioContext;
565
+ delete (globalThis as any).window.location;
566
+ });
567
+
568
+ const config = (extra: Partial<NonNullable<VoiceConfig['runtype']>> = {}) => ({
569
+ agentId: 'agent/1',
570
+ clientToken: 'ct_secret',
571
+ host: 'https://api.example.com',
572
+ ...extra,
573
+ });
574
+ const attachConfig = (extra: Partial<NonNullable<VoiceConfig['runtype']>> = {}) =>
575
+ config({ prewarmMode: 'attach', ...extra });
576
+ const lastWs = () => MockWebSocket.instances[MockWebSocket.instances.length - 1];
577
+ const openAs = (ws: MockWebSocket, protocol: string) => {
578
+ ws.protocol = protocol;
579
+ ws.triggerOpen();
580
+ };
581
+
582
+ describe("'request' mode (default)", () => {
583
+ it('POSTs the prewarm endpoint with the bearer token and no body', () => {
584
+ new RuntypeVoiceProvider(config()).prewarm();
585
+
586
+ expect(fetchMock).toHaveBeenCalledOnce();
587
+ const [url, init] = fetchMock.mock.calls[0];
588
+ expect(url).toBe('https://api.example.com/v1/client/agents/agent%2F1/voice/prewarm');
589
+ expect(init).toEqual({
590
+ method: 'POST',
591
+ headers: { Authorization: 'Bearer ct_secret' },
592
+ keepalive: true,
593
+ });
594
+ expect(MockWebSocket.instances).toHaveLength(0);
595
+ });
596
+
597
+ it.each([
598
+ ['http://localhost:8787', 'http://localhost:8787'],
599
+ ['wss://api.example.com', 'https://api.example.com'],
600
+ ['api.example.com', 'https://api.example.com'],
601
+ ])('derives the http base from %s', (host, base) => {
602
+ new RuntypeVoiceProvider(config({ host })).prewarm();
603
+ expect(fetchMock.mock.calls[0][0]).toBe(`${base}/v1/client/agents/agent%2F1/voice/prewarm`);
604
+ });
605
+
606
+ it('fires at most once per 30s per provider', () => {
607
+ const provider = new RuntypeVoiceProvider(config());
608
+ provider.prewarm();
609
+ provider.prewarm();
610
+ vi.advanceTimersByTime(29_999);
611
+ provider.prewarm();
612
+ expect(fetchMock).toHaveBeenCalledOnce();
613
+ vi.advanceTimersByTime(1);
614
+ provider.prewarm();
615
+ expect(fetchMock).toHaveBeenCalledTimes(2);
616
+ });
617
+
618
+ it('swallows rejected and throwing fetches without emitting errors or status', async () => {
619
+ const errors: Error[] = [];
620
+ const statuses: string[] = [];
621
+ fetchMock.mockRejectedValueOnce(new Error('offline'));
622
+ const provider = new RuntypeVoiceProvider(config());
623
+ provider.onError((e) => errors.push(e));
624
+ provider.onStatusChange((s) => statuses.push(s));
625
+ provider.prewarm();
626
+ await vi.advanceTimersByTimeAsync(30_000);
627
+ fetchMock.mockImplementationOnce(() => {
628
+ throw new Error('sync');
629
+ });
630
+ expect(() => provider.prewarm()).not.toThrow();
631
+ await vi.advanceTimersByTimeAsync(0);
632
+ expect(fetchMock).toHaveBeenCalledTimes(2);
633
+ expect(errors).toEqual([]);
634
+ expect(statuses).toEqual([]);
635
+ });
636
+
637
+ it('does nothing without credentials or during a live call', async () => {
638
+ new RuntypeVoiceProvider(config({ clientToken: undefined })).prewarm();
639
+ expect(fetchMock).not.toHaveBeenCalled();
640
+
641
+ const provider = new RuntypeVoiceProvider(config());
642
+ await provider.startListening();
643
+ provider.prewarm();
644
+ expect(fetchMock).not.toHaveBeenCalled();
645
+ });
646
+ });
647
+
648
+ describe("'attach' mode", () => {
649
+ it('opens the voice socket with the attach capability and subprotocol, then pings', () => {
650
+ new RuntypeVoiceProvider(attachConfig()).prewarm();
651
+
652
+ const ws = lastWs();
653
+ expect(ws.url).toBe('wss://api.example.com/ws/agents/agent%2F1/voice?clientCapabilities=attach');
654
+ expect(ws.protocols).toEqual(['runtype.bearer', 'runtype.attach', 'ct_secret']);
655
+ expect(fetchMock).not.toHaveBeenCalled();
656
+ openAs(ws, 'runtype.attach');
657
+ expect(ws.sent).toEqual(['{"type":"ping"}']);
658
+ });
659
+
660
+ it('sends a clamped attachIdleMs when configured', () => {
661
+ new RuntypeVoiceProvider(attachConfig({ attachIdleMs: 5_000 })).prewarm();
662
+ expect(lastWs().url).toContain('clientCapabilities=attach&attachIdleMs=30000');
663
+ });
664
+
665
+ it('never attaches twice concurrently', () => {
666
+ const provider = new RuntypeVoiceProvider(attachConfig());
667
+ provider.prewarm();
668
+ vi.advanceTimersByTime(60_000);
669
+ provider.prewarm();
670
+ expect(MockWebSocket.instances).toHaveLength(1);
671
+ });
672
+
673
+ it('reuses an attached socket on click: sends start and captures on it', async () => {
674
+ const statuses: string[] = [];
675
+ const provider = new RuntypeVoiceProvider(attachConfig());
676
+ provider.onStatusChange((s) => statuses.push(s));
677
+ provider.prewarm();
678
+ const ws = lastWs();
679
+ openAs(ws, 'runtype.attach');
680
+ ws.triggerMessage('{"type":"attached","idleMs":30000}');
681
+
682
+ await provider.startListening();
683
+
684
+ expect(MockWebSocket.instances).toHaveLength(1);
685
+ expect(ws.sent).toEqual(['{"type":"ping"}', '{"type":"start"}']);
686
+ expect(statuses).toEqual(['listening']);
687
+ pumpCapture(constantBuffer(0.1));
688
+ expect(ws.sent).toHaveLength(3);
689
+ // The call owns the socket now: the attach idle timer no longer closes it.
690
+ vi.advanceTimersByTime(600_000);
691
+ expect(ws.closeCalls).toEqual([]);
692
+ });
693
+
694
+ it('adopts a socket whose server ignored attach without sending start', async () => {
695
+ const transcripts: string[] = [];
696
+ const provider = new RuntypeVoiceProvider(attachConfig());
697
+ provider.onTranscript((_role, text) => transcripts.push(text));
698
+ provider.prewarm();
699
+ const ws = lastWs();
700
+ openAs(ws, 'runtype.bearer');
701
+ expect(ws.sent).toEqual([]);
702
+
703
+ await provider.startListening();
704
+
705
+ expect(MockWebSocket.instances).toHaveLength(1);
706
+ expect(ws.sent).toEqual([]);
707
+ ws.triggerMessage(JSON.stringify({ type: 'transcript_final', role: 'user', text: 'hi' }));
708
+ expect(transcripts).toEqual(['hi']);
709
+ });
710
+
711
+ it('opens a fresh socket when the attached one dropped before the click', async () => {
712
+ const provider = new RuntypeVoiceProvider(attachConfig());
713
+ provider.prewarm();
714
+ const attached = lastWs();
715
+ openAs(attached, 'runtype.attach');
716
+ attached.triggerClose(4408);
717
+
718
+ await provider.startListening();
719
+
720
+ expect(MockWebSocket.instances).toHaveLength(2);
721
+ expect(lastWs().url).toBe('wss://api.example.com/ws/agents/agent%2F1/voice');
722
+ expect(lastWs().protocols).toEqual(['runtype.bearer', 'ct_secret']);
723
+ });
724
+
725
+ it('abandons a handshake stalled for 5s and opens a fresh socket', async () => {
726
+ const provider = new RuntypeVoiceProvider(attachConfig());
727
+ provider.prewarm();
728
+ const stalled = lastWs();
729
+
730
+ const started = provider.startListening();
731
+ await vi.advanceTimersByTimeAsync(4_999);
732
+ expect(MockWebSocket.instances).toHaveLength(1);
733
+ await vi.advanceTimersByTimeAsync(1);
734
+ await started;
735
+
736
+ expect(stalled.closeCalls).toEqual([{ code: 1000, reason: 'prewarm released' }]);
737
+ expect(MockWebSocket.instances).toHaveLength(2);
738
+ expect(lastWs().protocols).toEqual(['runtype.bearer', 'ct_secret']);
739
+ });
740
+
741
+ it('uses an attach that completes while the click waits on it', async () => {
742
+ const provider = new RuntypeVoiceProvider(attachConfig());
743
+ provider.prewarm();
744
+ const ws = lastWs();
745
+
746
+ const started = provider.startListening();
747
+ await vi.advanceTimersByTimeAsync(1_000);
748
+ openAs(ws, 'runtype.attach');
749
+ await started;
750
+
751
+ expect(MockWebSocket.instances).toHaveLength(1);
752
+ expect(ws.sent).toContain('{"type":"start"}');
753
+ });
754
+
755
+ it('closes an unused attached socket at the idle window', () => {
756
+ const errors: Error[] = [];
757
+ const provider = new RuntypeVoiceProvider(attachConfig());
758
+ provider.onError((e) => errors.push(e));
759
+ provider.prewarm();
760
+ const ws = lastWs();
761
+ openAs(ws, 'runtype.attach');
762
+
763
+ vi.advanceTimersByTime(29_999);
764
+ expect(ws.closeCalls).toEqual([]);
765
+ vi.advanceTimersByTime(1);
766
+ expect(ws.closeCalls).toEqual([{ code: 1000, reason: 'prewarm released' }]);
767
+ expect(errors).toEqual([]);
768
+ });
769
+
770
+ it("follows the server's idle window from the attached reply", () => {
771
+ const provider = new RuntypeVoiceProvider(attachConfig({ attachIdleMs: 120_000 }));
772
+ provider.prewarm();
773
+ const ws = lastWs();
774
+ openAs(ws, 'runtype.attach');
775
+ vi.advanceTimersByTime(1_000);
776
+ ws.triggerMessage('{"type":"attached","idleMs":60000}');
777
+
778
+ vi.advanceTimersByTime(58_999);
779
+ expect(ws.closeCalls).toEqual([]);
780
+ vi.advanceTimersByTime(1);
781
+ expect(ws.closeCalls).toHaveLength(1);
782
+ });
783
+
784
+ it('closes the attaching socket when the call is hung up mid-handshake', async () => {
785
+ const provider = new RuntypeVoiceProvider(attachConfig());
786
+ provider.prewarm();
787
+ const ws = lastWs();
788
+
789
+ const started = provider.startListening();
790
+ await vi.advanceTimersByTimeAsync(100);
791
+ await provider.stopListening();
792
+ await started;
793
+
794
+ expect(ws.closeCalls).toEqual([{ code: 1000, reason: 'prewarm released' }]);
795
+ expect(MockWebSocket.instances).toHaveLength(1);
796
+ expect(provider.isBargeInActive()).toBe(false);
797
+ });
798
+
799
+ it('disconnect closes an attached socket', async () => {
800
+ const provider = new RuntypeVoiceProvider(attachConfig());
801
+ provider.prewarm();
802
+ const ws = lastWs();
803
+ openAs(ws, 'runtype.attach');
804
+ await provider.disconnect();
805
+ expect(ws.closeCalls).toEqual([{ code: 1000, reason: 'prewarm released' }]);
806
+ });
807
+ });
538
808
  });
@@ -1 +0,0 @@
1
- import{a as m}from"./chunk-5EIIHQLQ.js";var g=16e3,y=24e3,k=4096,C=4,S=1380533830;function E(r){return r.byteLength>=44&&new DataView(r).getUint32(0,!1)===S?new Uint8Array(r,44):new Uint8Array(r)}function V(r){let t=r.replace(/\/+$/,"");return/^wss?:\/\//i.test(t)?t:/^https?:\/\//i.test(t)?t.replace(/^http/i,"ws"):`${typeof window<"u"&&window.location?.protocol==="https:"?"wss:":"ws:"}//${t}`}var d=class{constructor(t){this.config=t;this.type="runtype";this.ws=null;this.captureContext=null;this.levelCallbacks=[];this.mediaStream=null;this.sourceNode=null;this.processor=null;this.playback=null;this.callLive=!1;this.isSpeaking=!1;this.callGeneration=0;this.intentionalClose=!1;this.resultCallbacks=[];this.errorCallbacks=[];this.statusCallbacks=[];this.transcriptCallbacks=[];this.metricsCallbacks=[]}async connect(){}async startListening(){if(this.callLive)return;let t=this.config?.agentId,e=this.config?.clientToken,i=this.config?.host;if(!t)throw new Error("Runtype voice requires an agentId");if(!e)throw new Error("Runtype voice requires a clientToken");if(!i)throw new Error("Runtype voice requires a host (or widget apiUrl)");let s=++this.callGeneration;this.intentionalClose=!1,this.callLive=!0;try{let o=await navigator.mediaDevices.getUserMedia({audio:{sampleRate:g,channelCount:1,echoCancellation:!0}});if(s!==this.callGeneration){o.getTracks().forEach(a=>a.stop());return}this.mediaStream=o;let p=window.AudioContext||window.webkitAudioContext,u=new p({sampleRate:g});u.state==="suspended"&&await u.resume().catch(()=>{}),this.captureContext=u;let l=this.config?.createPlaybackEngine?await this.config.createPlaybackEngine():new m(y);if(s!==this.callGeneration){l.destroy(),o.getTracks().forEach(a=>a.stop()),u.close().catch(()=>{});return}this.playback=l,l.onFinished(()=>{s===this.callGeneration&&(this.isSpeaking=!1,this.ws&&this.ws.readyState===WebSocket.OPEN&&this.emitStatus("listening"))});let f=`${V(i)}/ws/agents/${encodeURIComponent(t)}/voice`,n=new WebSocket(f,["runtype.bearer",e]);n.binaryType="arraybuffer",this.ws=n,n.onopen=()=>{s===this.callGeneration&&(this.emitStatus("listening"),this.startCapture(u,o,n,s))},n.onmessage=a=>this.handleMessage(a,s),n.onerror=()=>{s===this.callGeneration&&(this.emitError(new Error("Voice connection failed")),this.emitStatus("error"),this.cleanup())},n.onclose=a=>{if(this.intentionalClose){this.intentionalClose=!1;return}if(s===this.callGeneration){if(a.code!==1e3){let c=a.code?` (code ${a.code})`:"";this.emitError(new Error(`Voice connection closed${c}`)),this.emitStatus("error")}else this.emitStatus("idle");this.cleanup()}}}catch(o){throw this.cleanup(),this.emitError(o),this.emitStatus("error"),o}}async stopListening(){this.cleanup(),this.emitStatus("idle")}async disconnect(){this.cleanup(),this.emitStatus("disconnected"),this.resultCallbacks=[],this.errorCallbacks=[],this.statusCallbacks=[],this.transcriptCallbacks=[],this.metricsCallbacks=[]}stopPlayback(){this.playback&&this.playback.flush(),this.isSpeaking=!1,this.ws&&this.ws.readyState===WebSocket.OPEN&&this.emitStatus("listening")}getInterruptionMode(){return"barge-in"}isBargeInActive(){return this.callLive}onLevel(t){this.levelCallbacks.push(t)}async deactivateBargeIn(){this.cleanup(),this.emitStatus("idle")}startCapture(t,e,i,s){let o=t.createMediaStreamSource(e);this.sourceNode=o;let p=t.createScriptProcessor(k,1,1);this.processor=p,p.onaudioprocess=u=>{if(s!==this.callGeneration||i.readyState!==WebSocket.OPEN)return;let l=u.inputBuffer.getChannelData(0),f=new Int16Array(l.length),n=0;for(let a=0;a<l.length;a++){let c=Math.max(-1,Math.min(1,l[a]));n+=c*c,f[a]=c<0?c*32768:c*32767}if(i.send(f.buffer),this.levelCallbacks.length>0){let a=Math.sqrt(n/l.length),c=Math.max(0,Math.min(1,a*C));for(let b of this.levelCallbacks)b(c)}},o.connect(p),p.connect(t.destination)}handleMessage(t,e){if(e!==this.callGeneration)return;if(t.data instanceof ArrayBuffer){this.handleAudioFrame(t.data,e);return}let i;try{i=JSON.parse(t.data)}catch{return}switch(i.type){case"transcript_interim":this.emitStatus("listening"),this.emitTranscript("user",i.text??"",!1);break;case"transcript_final":{let s=i.role==="assistant"?"assistant":"user";this.emitStatus(s==="user"?"processing":"speaking"),this.emitTranscript(s,i.text??"",!0);break}case"audio_end":this.playback?this.playback.markStreamEnd():(this.isSpeaking=!1,this.emitStatus("listening"));break;case"metrics":this.emitMetrics({llmMs:i.llm_ms,ttsMs:i.tts_ms,firstAudioMs:i.first_audio_ms,totalMs:i.total_ms});break;case"error":this.emitError(new Error(i.error||"Voice error")),this.emitStatus("error");break}}handleAudioFrame(t,e){if(e!==this.callGeneration||!this.playback)return;let i=E(t);i.length!==0&&(this.isSpeaking||(this.isSpeaking=!0,this.emitStatus("speaking")),this.playback.enqueue(i))}cleanup(){if(this.callGeneration+=1,this.callLive=!1,this.isSpeaking=!1,this.processor&&(this.processor.onaudioprocess=null,this.processor.disconnect(),this.processor=null),this.sourceNode&&(this.sourceNode.disconnect(),this.sourceNode=null),this.mediaStream&&(this.mediaStream.getTracks().forEach(t=>t.stop()),this.mediaStream=null),this.captureContext&&(this.captureContext.close().catch(()=>{}),this.captureContext=null),this.playback&&(this.playback.destroy(),this.playback=null),this.ws){this.intentionalClose=!0;try{this.ws.close(1e3,"client ended call")}catch{}this.ws=null}}onResult(t){this.resultCallbacks.push(t)}onError(t){this.errorCallbacks.push(t)}onStatusChange(t){this.statusCallbacks.push(t)}onTranscript(t){this.transcriptCallbacks.push(t)}onMetrics(t){this.metricsCallbacks.push(t)}emitStatus(t){this.statusCallbacks.forEach(e=>e(t))}emitError(t){this.errorCallbacks.forEach(e=>e(t))}emitTranscript(t,e,i){this.transcriptCallbacks.forEach(s=>s(t,e,i))}emitMetrics(t){this.metricsCallbacks.forEach(e=>e(t))}};var h=class{constructor(t={}){this.config=t;this.type="browser";this.recognition=null;this.resultCallbacks=[];this.errorCallbacks=[];this.statusCallbacks=[];this.isListening=!1;this.w=typeof window<"u"?window:void 0}async connect(){this.statusCallbacks.forEach(t=>t("connected"))}async startListening(){try{if(this.isListening)throw new Error("Already listening");if(!this.w)throw new Error("Window object not available");let t=this.w.SpeechRecognition||this.w.webkitSpeechRecognition;if(!t)throw new Error("Browser speech recognition not supported");this.recognition=new t,this.recognition.lang=this.config?.language||"en-US",this.recognition.continuous=this.config?.continuous||!1,this.recognition.interimResults=!0,this.recognition.onresult=e=>{let i=Array.from(e.results).map(o=>o[0]).map(o=>o.transcript).join(""),s=e.results[e.results.length-1].isFinal;this.resultCallbacks.forEach(o=>o({text:i,confidence:s?.8:.5,provider:"browser"})),s&&!this.config?.continuous&&this.stopListening()},this.recognition.onerror=e=>{this.errorCallbacks.forEach(i=>i(new Error(e.error))),this.statusCallbacks.forEach(i=>i("error"))},this.recognition.onstart=()=>{this.isListening=!0,this.statusCallbacks.forEach(e=>e("listening"))},this.recognition.onend=()=>{this.isListening=!1,this.statusCallbacks.forEach(e=>e("idle"))},this.recognition.start()}catch(t){throw this.errorCallbacks.forEach(e=>e(t)),this.statusCallbacks.forEach(e=>e("error")),t}}async stopListening(){this.recognition&&(this.recognition.stop(),this.recognition=null),this.isListening=!1,this.statusCallbacks.forEach(t=>t("idle"))}onResult(t){this.resultCallbacks.push(t)}onError(t){this.errorCallbacks.push(t)}onStatusChange(t){this.statusCallbacks.push(t)}async disconnect(){await this.stopListening(),this.statusCallbacks.forEach(t=>t("disconnected"))}static isSupported(){return"SpeechRecognition"in window||"webkitSpeechRecognition"in window}};function v(r){switch(r.type){case"runtype":if(!r.runtype)throw new Error("Runtype voice provider requires configuration");return new d(r.runtype);case"browser":if(!h.isSupported())throw new Error("Browser speech recognition not supported");return new h(r.browser||{});case"custom":{let t=r.custom;if(!t)throw new Error("Custom voice provider requires a `custom` provider instance or factory");let e=typeof t=="function"?t():t;if(!e||typeof e.startListening!="function")throw new Error("Custom voice provider `custom` must be a VoiceProvider (or a factory returning one)");return e}default:throw new Error(`Unknown voice provider type: ${r.type}`)}}function w(r){if(r?.type==="custom"&&r.custom)return v({type:"custom",custom:r.custom});if(r?.type==="runtype"&&r.runtype)return v({type:"runtype",runtype:r.runtype});if(h.isSupported())return v({type:"browser",browser:r?.browser||{language:"en-US"}});throw new Error("No supported voice providers available")}function P(r){try{return w(r),!0}catch{return!1}}export{h as BrowserVoiceProvider,d as RuntypeVoiceProvider,w as createBestAvailableVoiceProvider,v as createVoiceProvider,P as isVoiceSupported};