@voxevolv/client 0.1.0 → 0.3.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/index.js CHANGED
@@ -1,8 +1,8 @@
1
- import { ConnectionState as LiveKitConnectionState, DataPacket_Kind, Room, RoomEvent, RpcError, Track, } from "livekit-client";
1
+ import { ConnectionState as LiveKitConnectionState, DataPacket_Kind, DisconnectReason, Room, RoomEvent, RpcError, Track, } from "livekit-client";
2
2
  import { createClientMessage, CommandError, EventCursor, isCommandAckPayload, isCommandErrorPayload, isContextRejectedPayload, isContextUpdatePayload, isContextUpdatedPayload, isRealtimeEvent, } from "./protocol.js";
3
3
  export { CommandError, EventCursor, createClientMessage, isCommandAckPayload, isCommandErrorPayload, isContextRejectedPayload, isContextUpdatePayload, isContextUpdatedPayload, isRealtimeEvent, } from "./protocol.js";
4
4
  export { decodeAgentSnapshotPayload, decodeClientHelloPayload, decodeClientMessage, decodeCommandAckPayload, decodeCommandErrorPayload, decodeContextRejectedPayload, decodeContextUpdatePayload, decodeContextUpdatedPayload, decodeRealtimeEvent, } from "./generated/protocol-codecs.js";
5
- export const SDK_VERSION = "0.1.0";
5
+ export const SDK_VERSION = "0.3.0";
6
6
  export class RealtimeSessionHttpError extends Error {
7
7
  status;
8
8
  code;
@@ -26,6 +26,8 @@ export class RealtimeSession {
26
26
  listeners = new Set();
27
27
  stateListeners = new Set();
28
28
  gapListeners = new Set();
29
+ connectionQualityListeners = new Set();
30
+ recoveryListeners = new Set();
29
31
  remoteAudioTrackListeners = new Set();
30
32
  remoteAudioTrackUnsubscribedListeners = new Set();
31
33
  readyListeners = new Set();
@@ -36,22 +38,35 @@ export class RealtimeSession {
36
38
  toolCallListeners = new Set();
37
39
  audioPlaybackBlockedListeners = new Set();
38
40
  remoteAudioStreams = new WeakMap();
41
+ outputMediaStream = null;
42
+ remoteAudioTrackResolve = null;
39
43
  cursor = new EventCursor();
40
44
  connectionId = `conn_${Math.random().toString(36).slice(2)}_${Date.now().toString(36)}`;
41
45
  clientSeq = 0;
42
46
  lastHelloMessageId = null;
47
+ reconnectStartedAt = null;
48
+ pendingReconnect = null;
43
49
  agentReady = false;
44
50
  readyResolve = null;
45
51
  replayPromise = null;
46
52
  pendingCommands = new Map();
47
53
  toolHandlers = new Map();
48
54
  apiBase = "/v1";
55
+ hasConnectedValue = false;
56
+ sessionEnded = false;
57
+ disconnectMode = null;
58
+ activeRecovery = null;
59
+ autoRejoinPromise = null;
60
+ autoRejoinController = null;
49
61
  useManagedMicrophone = true;
62
+ managedMicrophoneEnabled = true;
50
63
  applicationAudioTrack = null;
51
64
  connectDefaults;
52
65
  fetcher;
53
66
  endEndpoint;
54
67
  refreshCredentialsCallback;
68
+ autoRejoinOptions;
69
+ lifecycleCredentials;
55
70
  constructor(credentials, options = {}) {
56
71
  validateSessionCredentials(credentials);
57
72
  this.id = credentials.id;
@@ -64,6 +79,8 @@ export class RealtimeSession {
64
79
  this.fetcher = options.fetch ?? globalThis.fetch.bind(globalThis);
65
80
  this.endEndpoint = options.endEndpoint ?? null;
66
81
  this.refreshCredentialsCallback = options.refreshCredentials ?? null;
82
+ this.autoRejoinOptions = resolveAutoRejoinOptions(options.autoRejoin);
83
+ this.lifecycleCredentials = options.lifecycleCredentials ?? "same-origin";
67
84
  this.room = new Room({ adaptiveStream: true, dynacast: true });
68
85
  for (const [name, handler] of Object.entries(options.tools ?? {})) {
69
86
  this.registerTool(name, handler);
@@ -107,7 +124,7 @@ export class RealtimeSession {
107
124
  if ("connect" in options) {
108
125
  throw new TypeError("RealtimeSession.create() accepts apiBase and media options at the top level, not in connect");
109
126
  }
110
- const { endpoint, request, microphone, audioTrack, readyTimeoutMs, apiBase, ...sessionOptions } = options;
127
+ const { endpoint, request, microphone, audioTrack, readyTimeoutMs, waitForRemoteAudio, apiBase, ...sessionOptions } = options;
111
128
  if (!endpoint)
112
129
  throw new TypeError("endpoint is required");
113
130
  if (!apiBase) {
@@ -122,12 +139,24 @@ export class RealtimeSession {
122
139
  return new RealtimeSession(credentials, {
123
140
  ...sessionOptions,
124
141
  fetch: fetcher,
125
- connect: { microphone, audioTrack, readyTimeoutMs, apiBase: normalizedApiBase },
142
+ connect: { microphone, audioTrack, readyTimeoutMs, waitForRemoteAudio, apiBase: normalizedApiBase },
126
143
  });
127
144
  }
128
145
  get state() {
129
146
  return this.stateValue;
130
147
  }
148
+ get hasConnectedBefore() {
149
+ return this.hasConnectedValue;
150
+ }
151
+ /**
152
+ * Stable stream holding the agent's current remote audio tracks. The stream
153
+ * object never changes across track arrival, reconnects, or rejoin; tracks
154
+ * are added and removed as the room subscribes and unsubscribes them.
155
+ */
156
+ get outputStream() {
157
+ this.outputMediaStream ??= new MediaStream();
158
+ return this.outputMediaStream;
159
+ }
131
160
  onEvent(listener) {
132
161
  this.listeners.add(listener);
133
162
  return () => this.listeners.delete(listener);
@@ -141,6 +170,57 @@ export class RealtimeSession {
141
170
  this.gapListeners.add(listener);
142
171
  return () => this.gapListeners.delete(listener);
143
172
  }
173
+ onConnectionQuality(listener) {
174
+ this.connectionQualityListeners.add(listener);
175
+ this.notifyListeners(new Set([listener]), this.room.localParticipant.connectionQuality);
176
+ return () => this.connectionQualityListeners.delete(listener);
177
+ }
178
+ onRecovery(listener) {
179
+ this.recoveryListeners.add(listener);
180
+ return () => this.recoveryListeners.delete(listener);
181
+ }
182
+ waitForState(states, timeoutMs, options = {}) {
183
+ if (!states.length)
184
+ throw new TypeError("states must contain at least one connection state");
185
+ if (!Number.isFinite(timeoutMs) || timeoutMs < 0) {
186
+ throw new TypeError("timeoutMs must be a non-negative finite number");
187
+ }
188
+ const targets = new Set(states);
189
+ if (targets.has(this.stateValue))
190
+ return Promise.resolve(this.stateValue);
191
+ if (options.signal?.aborted) {
192
+ return Promise.reject(options.signal.reason ?? new DOMException("Aborted", "AbortError"));
193
+ }
194
+ return new Promise((resolve, reject) => {
195
+ let settled = false;
196
+ const finish = (state, error) => {
197
+ if (settled)
198
+ return;
199
+ settled = true;
200
+ globalThis.clearTimeout(timeout);
201
+ this.stateListeners.delete(onState);
202
+ options.signal?.removeEventListener("abort", onAbort);
203
+ if (error !== undefined) {
204
+ reject(error);
205
+ }
206
+ else if (state !== null) {
207
+ resolve(state);
208
+ }
209
+ };
210
+ const onState = (state) => {
211
+ if (targets.has(state))
212
+ finish(state);
213
+ };
214
+ const onAbort = () => {
215
+ finish(null, options.signal?.reason ?? new DOMException("Aborted", "AbortError"));
216
+ };
217
+ const timeout = globalThis.setTimeout(() => {
218
+ finish(null, new Error(`Session did not reach ${states.join(" or ")} within ${timeoutMs}ms`));
219
+ }, timeoutMs);
220
+ this.stateListeners.add(onState);
221
+ options.signal?.addEventListener("abort", onAbort, { once: true });
222
+ });
223
+ }
144
224
  onRemoteAudioTrack(listener) {
145
225
  this.remoteAudioTrackListeners.add(listener);
146
226
  return () => this.remoteAudioTrackListeners.delete(listener);
@@ -178,7 +258,8 @@ export class RealtimeSession {
178
258
  return () => this.audioPlaybackBlockedListeners.delete(listener);
179
259
  }
180
260
  registerTool(name, handler) {
181
- if (["connecting", "connected_not_ready", "connected", "reconnecting"].includes(this.stateValue)) {
261
+ if (["connecting", "connected_not_ready", "connected", "reconnecting", "resuming"]
262
+ .includes(this.stateValue)) {
182
263
  throw new Error("Browser tools must be registered before connect()");
183
264
  }
184
265
  if (!/^[a-z][a-z0-9_]{0,79}$/.test(name)) {
@@ -193,69 +274,130 @@ export class RealtimeSession {
193
274
  };
194
275
  }
195
276
  async connect(options = {}) {
196
- if (["connecting", "connected_not_ready", "connected", "reconnecting"].includes(this.stateValue)) {
277
+ if (["connecting", "connected_not_ready", "connected", "reconnecting", "resuming"]
278
+ .includes(this.stateValue)) {
279
+ throw new Error(`Cannot connect while session is ${this.stateValue}`);
280
+ }
281
+ this.cancelAutoRejoin();
282
+ await this.connectWithMode(options, false);
283
+ }
284
+ async connectWithMode(options, recovering, signal) {
285
+ const blockedStates = recovering
286
+ ? ["connecting", "connected_not_ready", "connected", "reconnecting"]
287
+ : ["connecting", "connected_not_ready", "connected", "reconnecting", "resuming"];
288
+ if (blockedStates.includes(this.stateValue)) {
197
289
  throw new Error(`Cannot connect while session is ${this.stateValue}`);
198
290
  }
199
291
  const resolved = { ...this.connectDefaults, ...options };
200
292
  const audioTrack = resolved.audioTrack ?? null;
201
293
  const microphone = resolved.microphone ?? audioTrack === null;
202
294
  const readyTimeoutMs = resolved.readyTimeoutMs ?? 15_000;
295
+ const waitForRemoteAudio = resolved.waitForRemoteAudio ?? true;
203
296
  const apiBase = normalizeApiBase(resolved.apiBase ?? this.apiBase);
204
297
  this.validateAudioOptions(microphone, audioTrack);
205
298
  this.useManagedMicrophone = microphone;
299
+ if (!recovering)
300
+ this.managedMicrophoneEnabled = microphone;
206
301
  this.applicationAudioTrack = audioTrack;
207
- this.setState("connecting");
302
+ if (!recovering) {
303
+ this.sessionEnded = false;
304
+ this.setState("connecting");
305
+ }
306
+ else {
307
+ this.setState("resuming");
308
+ }
208
309
  this.apiBase = apiBase;
209
310
  this.agentReady = false;
210
311
  try {
211
312
  await this.room.connect(this.credentials.url, this.credentials.token);
313
+ throwIfAborted(signal);
212
314
  if (audioTrack) {
213
315
  await this.publishApplicationAudioTrack(audioTrack);
214
316
  }
215
317
  else if (microphone) {
216
- await this.room.localParticipant.setMicrophoneEnabled(true);
318
+ await this.room.localParticipant.setMicrophoneEnabled(this.managedMicrophoneEnabled);
217
319
  }
218
- this.setState("connected_not_ready");
320
+ throwIfAborted(signal);
321
+ this.setState(recovering ? "resuming" : "connected_not_ready");
219
322
  await this.sendHello();
220
- await this.waitForAgentReady(readyTimeoutMs);
323
+ const readyDeadline = Date.now() + readyTimeoutMs;
324
+ await this.waitForAgentReady(readyTimeoutMs, signal);
325
+ if (waitForRemoteAudio)
326
+ await this.waitForRemoteAudioTrack(readyDeadline, signal);
327
+ throwIfAborted(signal);
328
+ this.hasConnectedValue = true;
221
329
  this.setState("connected");
222
330
  }
223
331
  catch (error) {
332
+ this.disconnectMode = recovering ? "rejoin" : "connect_failure";
224
333
  try {
225
334
  await this.room.disconnect();
226
335
  }
227
336
  catch {
228
337
  // Preserve the original connection/readiness failure.
229
338
  }
230
- this.setState("failed");
339
+ finally {
340
+ this.disconnectMode = null;
341
+ }
342
+ if (!recovering)
343
+ this.setState("failed");
231
344
  throw error;
232
345
  }
233
346
  }
234
347
  async rejoin(apiBase = this.apiBase) {
235
- if (["connecting", "reconnecting"].includes(this.stateValue)) {
348
+ if (["connecting", "reconnecting", "resuming"].includes(this.stateValue)) {
236
349
  throw new Error(`Cannot rejoin while session is ${this.stateValue}`);
237
350
  }
351
+ this.cancelAutoRejoin();
238
352
  const normalizedApiBase = normalizeApiBase(apiBase);
239
- let response = await this.fetchRoomToken(normalizedApiBase);
353
+ const recovering = this.hasConnectedValue;
354
+ if (recovering) {
355
+ this.beginRecovery("rejoin", 1);
356
+ this.setState("resuming");
357
+ }
358
+ try {
359
+ await this.performRejoin(normalizedApiBase, undefined, recovering);
360
+ if (recovering)
361
+ this.completeRecovery();
362
+ }
363
+ catch (error) {
364
+ const normalizedError = toError(error);
365
+ if (recovering)
366
+ this.failRecovery(normalizedError);
367
+ this.setState("failed");
368
+ throw error;
369
+ }
370
+ }
371
+ async performRejoin(apiBase, signal, recovering = true) {
372
+ let response = await this.fetchRoomToken(apiBase, signal);
240
373
  if (response.status === 401 && this.refreshCredentialsCallback) {
241
374
  await this.refreshCredentials();
242
- response = await this.fetchRoomToken(normalizedApiBase);
375
+ throwIfAborted(signal);
376
+ response = await this.fetchRoomToken(apiBase, signal);
243
377
  }
244
378
  if (!response.ok)
245
379
  throw await httpError(response, "Could not renew room token");
246
380
  const payload = (await response.json());
247
381
  this.credentials = payload.connection;
248
- if (["connected", "connected_not_ready"].includes(this.stateValue)) {
249
- await this.disconnect();
382
+ if (this.room.state !== LiveKitConnectionState.Disconnected) {
383
+ this.disconnectMode = "rejoin";
384
+ try {
385
+ await this.room.disconnect();
386
+ }
387
+ finally {
388
+ this.disconnectMode = null;
389
+ }
250
390
  }
251
- await this.connect({
252
- apiBase: normalizedApiBase,
391
+ throwIfAborted(signal);
392
+ await this.connectWithMode({
393
+ apiBase,
253
394
  microphone: this.useManagedMicrophone,
254
395
  ...(this.applicationAudioTrack ? { audioTrack: this.applicationAudioTrack } : {}),
255
- });
396
+ }, recovering, signal);
256
397
  }
257
398
  async setMicrophoneEnabled(enabled) {
258
399
  await this.room.localParticipant.setMicrophoneEnabled(enabled);
400
+ this.managedMicrophoneEnabled = enabled;
259
401
  }
260
402
  async sendCommand(type, payload, timeoutMs = 10_000) {
261
403
  const message = createClientMessage(this.connectionId, ++this.clientSeq, type, payload);
@@ -352,13 +494,23 @@ export class RealtimeSession {
352
494
  };
353
495
  }
354
496
  async disconnect() {
497
+ this.cancelAutoRejoin();
355
498
  if (this.stateValue === "disconnected")
356
499
  return;
357
500
  this.rejectPendingCommands(new Error("Session disconnected before command completion"));
358
- await this.room.disconnect();
359
- this.setState("disconnected");
501
+ this.disconnectMode = "intentional";
502
+ try {
503
+ await this.room.disconnect();
504
+ }
505
+ finally {
506
+ this.disconnectMode = null;
507
+ this.activeRecovery = null;
508
+ this.setState("disconnected");
509
+ }
360
510
  }
361
511
  async end(options = {}) {
512
+ this.cancelAutoRejoin();
513
+ this.sessionEnded = true;
362
514
  const endpoint = options.endpoint ?? this.endEndpoint ?? `${this.apiBase}/sessions/${this.id}`;
363
515
  const request = options.request ?? {};
364
516
  const headers = new Headers(request.headers);
@@ -370,6 +522,7 @@ export class RealtimeSession {
370
522
  ...request,
371
523
  method: request.method ?? "DELETE",
372
524
  headers,
525
+ credentials: request.credentials ?? this.lifecycleCredentials,
373
526
  });
374
527
  if (response.status === 401 && usesScopedSessionEndpoint && this.refreshCredentialsCallback) {
375
528
  await this.refreshCredentials();
@@ -378,6 +531,7 @@ export class RealtimeSession {
378
531
  ...request,
379
532
  method: request.method ?? "DELETE",
380
533
  headers,
534
+ credentials: request.credentials ?? this.lifecycleCredentials,
381
535
  });
382
536
  }
383
537
  if (!response.ok)
@@ -401,27 +555,29 @@ export class RealtimeSession {
401
555
  this.notifyListeners(this.stateListeners, state);
402
556
  }
403
557
  bindRoomEvents() {
404
- this.room.on(RoomEvent.Reconnecting, () => {
405
- this.agentReady = false;
406
- this.rejectPendingCommands(new Error("Connection interrupted before command completion"), true);
407
- this.setState("reconnecting");
408
- });
558
+ this.room.on(RoomEvent.SignalReconnecting, () => this.handleLiveKitReconnecting());
559
+ this.room.on(RoomEvent.Reconnecting, () => this.handleLiveKitReconnecting());
409
560
  this.room.on(RoomEvent.Reconnected, () => {
410
- this.connectionId = `conn_${Math.random().toString(36).slice(2)}_${Date.now().toString(36)}`;
411
- this.clientSeq = 0;
412
- void this.sendHello().catch(() => this.setState("failed"));
413
- this.setState("connected_not_ready");
561
+ void this.handleLiveKitReconnected();
414
562
  });
415
- this.room.on(RoomEvent.Disconnected, () => {
416
- this.agentReady = false;
417
- this.rejectPendingCommands(new Error("Session disconnected before command completion"));
418
- this.setState("disconnected");
563
+ this.room.on(RoomEvent.Disconnected, (reason) => {
564
+ this.handleLiveKitDisconnected(reason);
419
565
  });
420
566
  this.room.on(RoomEvent.ConnectionStateChanged, (state) => {
421
567
  if (state === LiveKitConnectionState.Connected) {
422
- this.setState(this.agentReady ? "connected" : "connected_not_ready");
568
+ const recovering = this.activeRecovery !== null || this.stateValue === "resuming";
569
+ this.setState(this.agentReady
570
+ ? "connected"
571
+ : recovering
572
+ ? "resuming"
573
+ : "connected_not_ready");
423
574
  }
424
575
  });
576
+ this.room.on(RoomEvent.ConnectionQualityChanged, (quality, participant) => {
577
+ if (!participant.isLocal)
578
+ return;
579
+ this.notifyListeners(this.connectionQualityListeners, quality);
580
+ });
425
581
  this.room.on(RoomEvent.TrackSubscribed, (track) => {
426
582
  if (track.kind !== Track.Kind.Audio)
427
583
  return;
@@ -448,7 +604,9 @@ export class RealtimeSession {
448
604
  }
449
605
  const stream = new MediaStream([track.mediaStreamTrack]);
450
606
  this.remoteAudioStreams.set(track, stream);
607
+ this.outputStream.addTrack(track.mediaStreamTrack);
451
608
  this.notifyRemoteAudioListeners(this.remoteAudioTrackListeners, track.mediaStreamTrack, stream);
609
+ this.remoteAudioTrackResolve?.();
452
610
  });
453
611
  this.room.on(RoomEvent.TrackUnsubscribed, (track) => {
454
612
  if (track.kind !== Track.Kind.Audio)
@@ -456,6 +614,7 @@ export class RealtimeSession {
456
614
  const stream = this.remoteAudioStreams.get(track) ?? new MediaStream([track.mediaStreamTrack]);
457
615
  this.notifyRemoteAudioListeners(this.remoteAudioTrackUnsubscribedListeners, track.mediaStreamTrack, stream);
458
616
  this.remoteAudioStreams.delete(track);
617
+ this.outputMediaStream?.removeTrack(track.mediaStreamTrack);
459
618
  track.detach().forEach((element) => element.remove());
460
619
  });
461
620
  this.room.on(RoomEvent.DataReceived, (data, _participant, kind, topic) => {
@@ -484,6 +643,175 @@ export class RealtimeSession {
484
643
  this.notifyListeners(this.listeners, value);
485
644
  });
486
645
  }
646
+ handleLiveKitReconnecting() {
647
+ this.agentReady = false;
648
+ // Once full rejoin owns recovery, late LiveKit reconnect notifications
649
+ // from the old transport must not move the wrapper back to reconnecting.
650
+ // Doing so makes connectWithMode() reject the replacement connection.
651
+ if (this.autoRejoinPromise !== null
652
+ || this.activeRecovery?.strategy === "rejoin"
653
+ || this.disconnectMode === "rejoin") {
654
+ this.setState("resuming");
655
+ return;
656
+ }
657
+ this.reconnectStartedAt ??= Date.now();
658
+ this.rejectPendingCommands(new Error("Connection interrupted before command completion"), true);
659
+ if (this.activeRecovery === null) {
660
+ this.beginRecovery("livekit", 1);
661
+ }
662
+ this.setState("reconnecting");
663
+ }
664
+ async handleLiveKitReconnected() {
665
+ const recoveredAt = Date.now();
666
+ if (this.reconnectStartedAt !== null) {
667
+ this.pendingReconnect = {
668
+ started_at: new Date(this.reconnectStartedAt).toISOString(),
669
+ recovered_at: new Date(recoveredAt).toISOString(),
670
+ duration_ms: Math.max(recoveredAt - this.reconnectStartedAt, 0),
671
+ };
672
+ this.reconnectStartedAt = null;
673
+ }
674
+ if (this.hasConnectedValue && this.activeRecovery === null) {
675
+ this.beginRecovery("livekit", 1);
676
+ }
677
+ this.connectionId = `conn_${Math.random().toString(36).slice(2)}_${Date.now().toString(36)}`;
678
+ this.clientSeq = 0;
679
+ this.setState(this.hasConnectedValue ? "resuming" : "connected_not_ready");
680
+ try {
681
+ await this.sendHello();
682
+ }
683
+ catch (error) {
684
+ const normalizedError = toError(error);
685
+ if (this.shouldAutoRejoin()) {
686
+ this.startAutoRejoin(normalizedError);
687
+ }
688
+ else {
689
+ this.failRecovery(normalizedError);
690
+ this.setState("failed");
691
+ }
692
+ }
693
+ }
694
+ handleLiveKitDisconnected(reason) {
695
+ this.agentReady = false;
696
+ this.reconnectStartedAt = null;
697
+ this.pendingReconnect = null;
698
+ // Drop any remaining remote tracks so a stale, ended track cannot
699
+ // satisfy the next connect()'s wait for agent audio.
700
+ this.outputMediaStream?.getTracks().forEach((track) => {
701
+ this.outputMediaStream?.removeTrack(track);
702
+ });
703
+ this.rejectPendingCommands(new Error("Session disconnected before command completion"));
704
+ if (this.disconnectMode === "rejoin")
705
+ return;
706
+ if (this.disconnectMode === "connect_failure")
707
+ return;
708
+ if (this.disconnectMode === "intentional") {
709
+ this.activeRecovery = null;
710
+ this.setState("disconnected");
711
+ return;
712
+ }
713
+ if (this.shouldAutoRejoin(reason)) {
714
+ this.startAutoRejoin(new Error(`LiveKit disconnected (${disconnectReasonName(reason)})`));
715
+ return;
716
+ }
717
+ if (this.activeRecovery !== null) {
718
+ this.failRecovery(new Error(`LiveKit disconnected (${disconnectReasonName(reason)})`));
719
+ }
720
+ this.setState("disconnected");
721
+ }
722
+ beginRecovery(strategy, attempt) {
723
+ this.activeRecovery = { strategy, attempt };
724
+ this.notifyListeners(this.recoveryListeners, {
725
+ status: "started",
726
+ strategy,
727
+ attempt,
728
+ });
729
+ }
730
+ completeRecovery() {
731
+ const recovery = this.activeRecovery;
732
+ if (!recovery)
733
+ return;
734
+ this.activeRecovery = null;
735
+ this.notifyListeners(this.recoveryListeners, {
736
+ status: "succeeded",
737
+ strategy: recovery.strategy,
738
+ attempt: recovery.attempt,
739
+ });
740
+ }
741
+ failRecovery(error) {
742
+ const recovery = this.activeRecovery;
743
+ if (!recovery)
744
+ return;
745
+ this.activeRecovery = null;
746
+ this.notifyListeners(this.recoveryListeners, {
747
+ status: "failed",
748
+ strategy: recovery.strategy,
749
+ attempt: recovery.attempt,
750
+ error,
751
+ });
752
+ }
753
+ shouldAutoRejoin(reason) {
754
+ return (this.autoRejoinOptions !== null
755
+ && this.hasConnectedValue
756
+ && !this.sessionEnded
757
+ && !isTerminalDisconnectReason(reason));
758
+ }
759
+ startAutoRejoin(initialError) {
760
+ if (this.autoRejoinPromise || !this.shouldAutoRejoin())
761
+ return;
762
+ const controller = new AbortController();
763
+ this.autoRejoinController = controller;
764
+ const promise = this.runAutoRejoin(initialError, controller.signal)
765
+ .finally(() => {
766
+ if (this.autoRejoinController === controller) {
767
+ this.autoRejoinController = null;
768
+ this.autoRejoinPromise = null;
769
+ }
770
+ });
771
+ this.autoRejoinPromise = promise;
772
+ void promise;
773
+ }
774
+ async runAutoRejoin(initialError, signal) {
775
+ const options = this.autoRejoinOptions;
776
+ if (!options)
777
+ return;
778
+ let lastError = initialError;
779
+ if (this.activeRecovery?.strategy === "livekit") {
780
+ this.failRecovery(initialError);
781
+ }
782
+ this.setState("resuming");
783
+ for (let attempt = 1; attempt <= options.maxAttempts; attempt += 1) {
784
+ try {
785
+ await waitUntilOnline(signal);
786
+ if (attempt > 1) {
787
+ const exponentialDelay = options.baseDelayMs * (2 ** (attempt - 2));
788
+ const delayMs = Math.min(exponentialDelay, options.maxDelayMs);
789
+ await abortableDelay(delayMs + Math.random() * Math.min(delayMs, 500), signal);
790
+ }
791
+ throwIfAborted(signal);
792
+ this.beginRecovery("rejoin", attempt);
793
+ await this.performRejoin(this.apiBase, signal);
794
+ throwIfAborted(signal);
795
+ this.completeRecovery();
796
+ return;
797
+ }
798
+ catch (error) {
799
+ if (signal.aborted)
800
+ return;
801
+ lastError = toError(error);
802
+ if (isTerminalRejoinError(error))
803
+ break;
804
+ }
805
+ }
806
+ this.failRecovery(lastError);
807
+ this.setState("failed");
808
+ }
809
+ cancelAutoRejoin() {
810
+ this.autoRejoinController?.abort();
811
+ this.autoRejoinController = null;
812
+ this.autoRejoinPromise = null;
813
+ this.activeRecovery = null;
814
+ }
487
815
  notifyListeners(listeners, value) {
488
816
  for (const listener of listeners) {
489
817
  try {
@@ -517,10 +845,12 @@ export class RealtimeSession {
517
845
  throw new TypeError("audioTrack has already ended");
518
846
  }
519
847
  }
520
- async fetchRoomToken(apiBase) {
848
+ async fetchRoomToken(apiBase, signal) {
521
849
  return this.fetcher(`${apiBase}/sessions/${this.id}/room-tokens`, {
522
850
  method: "POST",
523
851
  headers: { Authorization: `Bearer ${this.accessToken}` },
852
+ credentials: this.lifecycleCredentials,
853
+ signal,
524
854
  });
525
855
  }
526
856
  async refreshCredentials() {
@@ -535,8 +865,20 @@ export class RealtimeSession {
535
865
  this.credentials = refreshed.connection;
536
866
  }
537
867
  observeConvenienceEvents(event) {
538
- if (event.type === "session.ended")
868
+ if (event.type === "session.ended") {
869
+ const recoveryWasActive = (this.autoRejoinPromise !== null
870
+ || this.activeRecovery !== null
871
+ || this.stateValue === "reconnecting"
872
+ || this.stateValue === "resuming");
873
+ this.sessionEnded = true;
874
+ if (this.activeRecovery !== null) {
875
+ this.failRecovery(new Error("Session ended during recovery"));
876
+ }
877
+ this.cancelAutoRejoin();
878
+ if (recoveryWasActive)
879
+ this.setState("disconnected");
539
880
  this.notifyListeners(this.endedListeners, event);
881
+ }
540
882
  if (event.type.endsWith(".error"))
541
883
  this.notifyListeners(this.errorListeners, event);
542
884
  const transcript = transcriptEvent(event);
@@ -558,13 +900,17 @@ export class RealtimeSession {
558
900
  }
559
901
  }
560
902
  async sendHello() {
903
+ const reconnect = this.pendingReconnect;
561
904
  const message = createClientMessage(this.connectionId, ++this.clientSeq, "client.hello", {
562
905
  last_server_seq: this.cursor.lastServerSeq,
563
906
  sdk: `@voxevolv/client@${SDK_VERSION}`,
564
907
  tool_handlers: [...this.toolHandlers.keys()].sort(),
908
+ ...(reconnect ? { reconnect } : {}),
565
909
  });
566
910
  this.lastHelloMessageId = message.message_id;
567
911
  await this.sendTrackedCommand(message, 10_000);
912
+ if (this.pendingReconnect === reconnect)
913
+ this.pendingReconnect = null;
568
914
  }
569
915
  async sendTrackedCommand(message, timeoutMs, retryTransfer) {
570
916
  const response = new Promise((resolve, reject) => {
@@ -598,33 +944,65 @@ export class RealtimeSession {
598
944
  try {
599
945
  await this.publishCommand(message);
600
946
  }
601
- catch (error) {
602
- const pending = this.pendingCommands.get(message.message_id);
603
- if (pending) {
604
- globalThis.clearTimeout(pending.timeout);
605
- globalThis.clearInterval(pending.retry);
606
- this.pendingCommands.delete(message.message_id);
607
- pending.reject(error instanceof Error ? error : new Error(String(error)));
608
- }
947
+ catch {
948
+ // The reliable data channel may still be warming immediately after a
949
+ // reconnect. Keep the command pending so the retry interval can publish
950
+ // it once the transport accepts writes, or reject at the normal timeout.
609
951
  }
610
952
  return response;
611
953
  }
612
954
  async publishCommand(message) {
613
955
  await this.room.localParticipant.publishData(new TextEncoder().encode(JSON.stringify(message)), { reliable: true, topic: "voxevolv.commands" });
614
956
  }
615
- waitForAgentReady(timeoutMs) {
616
- if (this.agentReady)
957
+ waitForRemoteAudioTrack(deadline, signal) {
958
+ if ((this.outputMediaStream?.getAudioTracks().length ?? 0) > 0)
617
959
  return Promise.resolve();
960
+ throwIfAborted(signal);
618
961
  return new Promise((resolve, reject) => {
962
+ const cleanup = () => {
963
+ globalThis.clearTimeout(timeout);
964
+ signal?.removeEventListener("abort", onAbort);
965
+ this.remoteAudioTrackResolve = null;
966
+ };
619
967
  const timeout = globalThis.setTimeout(() => {
968
+ cleanup();
969
+ reject(new Error("Agent audio track did not arrive within the ready timeout;"
970
+ + " pass waitForRemoteAudio: false to resolve connect() on signaling readiness alone"));
971
+ }, Math.max(deadline - Date.now(), 0));
972
+ const onAbort = () => {
973
+ cleanup();
974
+ reject(abortError(signal));
975
+ };
976
+ this.remoteAudioTrackResolve = () => {
977
+ cleanup();
978
+ resolve();
979
+ };
980
+ signal?.addEventListener("abort", onAbort, { once: true });
981
+ });
982
+ }
983
+ waitForAgentReady(timeoutMs, signal) {
984
+ if (this.agentReady)
985
+ return Promise.resolve();
986
+ throwIfAborted(signal);
987
+ return new Promise((resolve, reject) => {
988
+ const cleanup = () => {
989
+ globalThis.clearTimeout(timeout);
990
+ signal?.removeEventListener("abort", onAbort);
620
991
  this.readyResolve = null;
992
+ };
993
+ const timeout = globalThis.setTimeout(() => {
994
+ cleanup();
621
995
  reject(new Error(`Agent did not become ready within ${timeoutMs}ms`));
622
996
  }, timeoutMs);
997
+ const onAbort = () => {
998
+ cleanup();
999
+ reject(abortError(signal));
1000
+ };
623
1001
  this.readyResolve = () => {
624
- globalThis.clearTimeout(timeout);
625
- this.readyResolve = null;
1002
+ cleanup();
626
1003
  resolve();
627
1004
  };
1005
+ signal?.addEventListener("abort", onAbort, { once: true });
628
1006
  });
629
1007
  }
630
1008
  observeReadiness(event) {
@@ -637,8 +1015,12 @@ export class RealtimeSession {
637
1015
  return;
638
1016
  const wasReady = this.agentReady;
639
1017
  this.agentReady = true;
640
- if (this.room.state === LiveKitConnectionState.Connected)
1018
+ if (this.room.state === LiveKitConnectionState.Connected) {
1019
+ this.hasConnectedValue = true;
641
1020
  this.setState("connected");
1021
+ if (this.activeRecovery?.strategy === "livekit")
1022
+ this.completeRecovery();
1023
+ }
642
1024
  this.readyResolve?.();
643
1025
  if (!wasReady)
644
1026
  this.notifyListeners(this.readyListeners, event);
@@ -698,10 +1080,16 @@ export class RealtimeSession {
698
1080
  async fetchReplay(apiBase, afterSeq) {
699
1081
  let nextAfter = afterSeq;
700
1082
  while (true) {
701
- let response = await this.fetcher(`${apiBase}/sessions/${this.id}/events?after_seq=${nextAfter}&limit=200`, { headers: { Authorization: `Bearer ${this.accessToken}` } });
1083
+ let response = await this.fetcher(`${apiBase}/sessions/${this.id}/events?after_seq=${nextAfter}&limit=200`, {
1084
+ headers: { Authorization: `Bearer ${this.accessToken}` },
1085
+ credentials: this.lifecycleCredentials,
1086
+ });
702
1087
  if (response.status === 401 && this.refreshCredentialsCallback) {
703
1088
  await this.refreshCredentials();
704
- response = await this.fetcher(`${apiBase}/sessions/${this.id}/events?after_seq=${nextAfter}&limit=200`, { headers: { Authorization: `Bearer ${this.accessToken}` } });
1089
+ response = await this.fetcher(`${apiBase}/sessions/${this.id}/events?after_seq=${nextAfter}&limit=200`, {
1090
+ headers: { Authorization: `Bearer ${this.accessToken}` },
1091
+ credentials: this.lifecycleCredentials,
1092
+ });
705
1093
  }
706
1094
  if (!response.ok)
707
1095
  throw await httpError(response, "Could not replay session events");
@@ -730,6 +1118,96 @@ export class RealtimeSession {
730
1118
  }
731
1119
  }
732
1120
  }
1121
+ function resolveAutoRejoinOptions(value) {
1122
+ if (!value)
1123
+ return null;
1124
+ const options = value === true ? {} : value;
1125
+ const resolved = {
1126
+ maxAttempts: options.maxAttempts ?? 3,
1127
+ baseDelayMs: options.baseDelayMs ?? 750,
1128
+ maxDelayMs: options.maxDelayMs ?? 5_000,
1129
+ };
1130
+ if (!Number.isInteger(resolved.maxAttempts) || resolved.maxAttempts < 1) {
1131
+ throw new TypeError("autoRejoin.maxAttempts must be a positive integer");
1132
+ }
1133
+ if (!Number.isFinite(resolved.baseDelayMs) || resolved.baseDelayMs < 0) {
1134
+ throw new TypeError("autoRejoin.baseDelayMs must be a non-negative finite number");
1135
+ }
1136
+ if (!Number.isFinite(resolved.maxDelayMs) || resolved.maxDelayMs < 0) {
1137
+ throw new TypeError("autoRejoin.maxDelayMs must be a non-negative finite number");
1138
+ }
1139
+ return resolved;
1140
+ }
1141
+ function isTerminalDisconnectReason(reason) {
1142
+ return reason !== undefined && [
1143
+ DisconnectReason.CLIENT_INITIATED,
1144
+ DisconnectReason.DUPLICATE_IDENTITY,
1145
+ DisconnectReason.PARTICIPANT_REMOVED,
1146
+ DisconnectReason.ROOM_DELETED,
1147
+ DisconnectReason.ROOM_CLOSED,
1148
+ DisconnectReason.USER_UNAVAILABLE,
1149
+ DisconnectReason.USER_REJECTED,
1150
+ ].includes(reason);
1151
+ }
1152
+ function disconnectReasonName(reason) {
1153
+ if (reason === undefined)
1154
+ return "unknown";
1155
+ return DisconnectReason[reason] ?? String(reason);
1156
+ }
1157
+ function isTerminalRejoinError(error) {
1158
+ return error instanceof RealtimeSessionHttpError
1159
+ && [401, 403, 409].includes(error.status);
1160
+ }
1161
+ function toError(error) {
1162
+ return error instanceof Error ? error : new Error(String(error));
1163
+ }
1164
+ function abortError(signal) {
1165
+ return signal.reason ?? new DOMException("Aborted", "AbortError");
1166
+ }
1167
+ function throwIfAborted(signal) {
1168
+ if (signal?.aborted)
1169
+ throw abortError(signal);
1170
+ }
1171
+ function abortableDelay(delayMs, signal) {
1172
+ throwIfAborted(signal);
1173
+ if (delayMs <= 0)
1174
+ return Promise.resolve();
1175
+ return new Promise((resolve, reject) => {
1176
+ const timeout = globalThis.setTimeout(() => {
1177
+ signal.removeEventListener("abort", onAbort);
1178
+ resolve();
1179
+ }, delayMs);
1180
+ const onAbort = () => {
1181
+ globalThis.clearTimeout(timeout);
1182
+ reject(abortError(signal));
1183
+ };
1184
+ signal.addEventListener("abort", onAbort, { once: true });
1185
+ });
1186
+ }
1187
+ function waitUntilOnline(signal) {
1188
+ throwIfAborted(signal);
1189
+ if (typeof navigator === "undefined" || navigator.onLine !== false) {
1190
+ return Promise.resolve();
1191
+ }
1192
+ if (typeof globalThis.addEventListener !== "function")
1193
+ return Promise.resolve();
1194
+ return new Promise((resolve, reject) => {
1195
+ const cleanup = () => {
1196
+ globalThis.removeEventListener("online", onOnline);
1197
+ signal.removeEventListener("abort", onAbort);
1198
+ };
1199
+ const onOnline = () => {
1200
+ cleanup();
1201
+ resolve();
1202
+ };
1203
+ const onAbort = () => {
1204
+ cleanup();
1205
+ reject(abortError(signal));
1206
+ };
1207
+ globalThis.addEventListener("online", onOnline, { once: true });
1208
+ signal.addEventListener("abort", onAbort, { once: true });
1209
+ });
1210
+ }
733
1211
  async function sha256Hex(data) {
734
1212
  if (!globalThis.crypto?.subtle)
735
1213
  throw new Error("Web Crypto SHA-256 is unavailable");