@voxevolv/client 0.2.0 → 0.3.1

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.2.0";
5
+ export const SDK_VERSION = "0.3.1";
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();
@@ -42,18 +44,31 @@ export class RealtimeSession {
42
44
  connectionId = `conn_${Math.random().toString(36).slice(2)}_${Date.now().toString(36)}`;
43
45
  clientSeq = 0;
44
46
  lastHelloMessageId = null;
47
+ reconnectStartedAt = null;
48
+ pendingReconnect = null;
45
49
  agentReady = false;
46
50
  readyResolve = null;
47
51
  replayPromise = null;
48
52
  pendingCommands = new Map();
49
53
  toolHandlers = new Map();
50
54
  apiBase = "/v1";
55
+ hasConnectedValue = false;
56
+ sessionEnded = false;
57
+ pendingEndEvent = null;
58
+ endNotificationDelivered = false;
59
+ disconnectMode = null;
60
+ activeRecovery = null;
61
+ autoRejoinPromise = null;
62
+ autoRejoinController = null;
51
63
  useManagedMicrophone = true;
64
+ managedMicrophoneEnabled = true;
52
65
  applicationAudioTrack = null;
53
66
  connectDefaults;
54
67
  fetcher;
55
68
  endEndpoint;
56
69
  refreshCredentialsCallback;
70
+ autoRejoinOptions;
71
+ lifecycleCredentials;
57
72
  constructor(credentials, options = {}) {
58
73
  validateSessionCredentials(credentials);
59
74
  this.id = credentials.id;
@@ -66,6 +81,8 @@ export class RealtimeSession {
66
81
  this.fetcher = options.fetch ?? globalThis.fetch.bind(globalThis);
67
82
  this.endEndpoint = options.endEndpoint ?? null;
68
83
  this.refreshCredentialsCallback = options.refreshCredentials ?? null;
84
+ this.autoRejoinOptions = resolveAutoRejoinOptions(options.autoRejoin);
85
+ this.lifecycleCredentials = options.lifecycleCredentials ?? "same-origin";
69
86
  this.room = new Room({ adaptiveStream: true, dynacast: true });
70
87
  for (const [name, handler] of Object.entries(options.tools ?? {})) {
71
88
  this.registerTool(name, handler);
@@ -130,6 +147,9 @@ export class RealtimeSession {
130
147
  get state() {
131
148
  return this.stateValue;
132
149
  }
150
+ get hasConnectedBefore() {
151
+ return this.hasConnectedValue;
152
+ }
133
153
  /**
134
154
  * Stable stream holding the agent's current remote audio tracks. The stream
135
155
  * object never changes across track arrival, reconnects, or rejoin; tracks
@@ -152,6 +172,57 @@ export class RealtimeSession {
152
172
  this.gapListeners.add(listener);
153
173
  return () => this.gapListeners.delete(listener);
154
174
  }
175
+ onConnectionQuality(listener) {
176
+ this.connectionQualityListeners.add(listener);
177
+ this.notifyListeners(new Set([listener]), this.room.localParticipant.connectionQuality);
178
+ return () => this.connectionQualityListeners.delete(listener);
179
+ }
180
+ onRecovery(listener) {
181
+ this.recoveryListeners.add(listener);
182
+ return () => this.recoveryListeners.delete(listener);
183
+ }
184
+ waitForState(states, timeoutMs, options = {}) {
185
+ if (!states.length)
186
+ throw new TypeError("states must contain at least one connection state");
187
+ if (!Number.isFinite(timeoutMs) || timeoutMs < 0) {
188
+ throw new TypeError("timeoutMs must be a non-negative finite number");
189
+ }
190
+ const targets = new Set(states);
191
+ if (targets.has(this.stateValue))
192
+ return Promise.resolve(this.stateValue);
193
+ if (options.signal?.aborted) {
194
+ return Promise.reject(options.signal.reason ?? new DOMException("Aborted", "AbortError"));
195
+ }
196
+ return new Promise((resolve, reject) => {
197
+ let settled = false;
198
+ const finish = (state, error) => {
199
+ if (settled)
200
+ return;
201
+ settled = true;
202
+ globalThis.clearTimeout(timeout);
203
+ this.stateListeners.delete(onState);
204
+ options.signal?.removeEventListener("abort", onAbort);
205
+ if (error !== undefined) {
206
+ reject(error);
207
+ }
208
+ else if (state !== null) {
209
+ resolve(state);
210
+ }
211
+ };
212
+ const onState = (state) => {
213
+ if (targets.has(state))
214
+ finish(state);
215
+ };
216
+ const onAbort = () => {
217
+ finish(null, options.signal?.reason ?? new DOMException("Aborted", "AbortError"));
218
+ };
219
+ const timeout = globalThis.setTimeout(() => {
220
+ finish(null, new Error(`Session did not reach ${states.join(" or ")} within ${timeoutMs}ms`));
221
+ }, timeoutMs);
222
+ this.stateListeners.add(onState);
223
+ options.signal?.addEventListener("abort", onAbort, { once: true });
224
+ });
225
+ }
155
226
  onRemoteAudioTrack(listener) {
156
227
  this.remoteAudioTrackListeners.add(listener);
157
228
  return () => this.remoteAudioTrackListeners.delete(listener);
@@ -189,7 +260,8 @@ export class RealtimeSession {
189
260
  return () => this.audioPlaybackBlockedListeners.delete(listener);
190
261
  }
191
262
  registerTool(name, handler) {
192
- if (["connecting", "connected_not_ready", "connected", "reconnecting"].includes(this.stateValue)) {
263
+ if (["connecting", "connected_not_ready", "connected", "reconnecting", "resuming"]
264
+ .includes(this.stateValue)) {
193
265
  throw new Error("Browser tools must be registered before connect()");
194
266
  }
195
267
  if (!/^[a-z][a-z0-9_]{0,79}$/.test(name)) {
@@ -204,7 +276,18 @@ export class RealtimeSession {
204
276
  };
205
277
  }
206
278
  async connect(options = {}) {
207
- if (["connecting", "connected_not_ready", "connected", "reconnecting"].includes(this.stateValue)) {
279
+ if (["connecting", "connected_not_ready", "connected", "reconnecting", "resuming"]
280
+ .includes(this.stateValue)) {
281
+ throw new Error(`Cannot connect while session is ${this.stateValue}`);
282
+ }
283
+ this.cancelAutoRejoin();
284
+ await this.connectWithMode(options, false);
285
+ }
286
+ async connectWithMode(options, recovering, signal) {
287
+ const blockedStates = recovering
288
+ ? ["connecting", "connected_not_ready", "connected", "reconnecting"]
289
+ : ["connecting", "connected_not_ready", "connected", "reconnecting", "resuming"];
290
+ if (blockedStates.includes(this.stateValue)) {
208
291
  throw new Error(`Cannot connect while session is ${this.stateValue}`);
209
292
  }
210
293
  const resolved = { ...this.connectDefaults, ...options };
@@ -215,62 +298,110 @@ export class RealtimeSession {
215
298
  const apiBase = normalizeApiBase(resolved.apiBase ?? this.apiBase);
216
299
  this.validateAudioOptions(microphone, audioTrack);
217
300
  this.useManagedMicrophone = microphone;
301
+ if (!recovering)
302
+ this.managedMicrophoneEnabled = microphone;
218
303
  this.applicationAudioTrack = audioTrack;
219
- this.setState("connecting");
304
+ if (!recovering) {
305
+ this.sessionEnded = false;
306
+ this.pendingEndEvent = null;
307
+ this.endNotificationDelivered = false;
308
+ this.setState("connecting");
309
+ }
310
+ else {
311
+ this.setState("resuming");
312
+ }
220
313
  this.apiBase = apiBase;
221
314
  this.agentReady = false;
222
315
  try {
223
316
  await this.room.connect(this.credentials.url, this.credentials.token);
317
+ throwIfAborted(signal);
224
318
  if (audioTrack) {
225
319
  await this.publishApplicationAudioTrack(audioTrack);
226
320
  }
227
321
  else if (microphone) {
228
- await this.room.localParticipant.setMicrophoneEnabled(true);
322
+ await this.room.localParticipant.setMicrophoneEnabled(this.managedMicrophoneEnabled);
229
323
  }
230
- this.setState("connected_not_ready");
324
+ throwIfAborted(signal);
325
+ this.setState(recovering ? "resuming" : "connected_not_ready");
231
326
  await this.sendHello();
232
327
  const readyDeadline = Date.now() + readyTimeoutMs;
233
- await this.waitForAgentReady(readyTimeoutMs);
328
+ await this.waitForAgentReady(readyTimeoutMs, signal);
234
329
  if (waitForRemoteAudio)
235
- await this.waitForRemoteAudioTrack(readyDeadline);
330
+ await this.waitForRemoteAudioTrack(readyDeadline, signal);
331
+ throwIfAborted(signal);
332
+ this.hasConnectedValue = true;
236
333
  this.setState("connected");
237
334
  }
238
335
  catch (error) {
336
+ this.disconnectMode = recovering ? "rejoin" : "connect_failure";
239
337
  try {
240
338
  await this.room.disconnect();
241
339
  }
242
340
  catch {
243
341
  // Preserve the original connection/readiness failure.
244
342
  }
245
- this.setState("failed");
343
+ finally {
344
+ this.disconnectMode = null;
345
+ }
346
+ if (!recovering)
347
+ this.setState("failed");
246
348
  throw error;
247
349
  }
248
350
  }
249
351
  async rejoin(apiBase = this.apiBase) {
250
- if (["connecting", "reconnecting"].includes(this.stateValue)) {
352
+ if (["connecting", "reconnecting", "resuming"].includes(this.stateValue)) {
251
353
  throw new Error(`Cannot rejoin while session is ${this.stateValue}`);
252
354
  }
355
+ this.cancelAutoRejoin();
253
356
  const normalizedApiBase = normalizeApiBase(apiBase);
254
- let response = await this.fetchRoomToken(normalizedApiBase);
357
+ const recovering = this.hasConnectedValue;
358
+ if (recovering) {
359
+ this.beginRecovery("rejoin", 1);
360
+ this.setState("resuming");
361
+ }
362
+ try {
363
+ await this.performRejoin(normalizedApiBase, undefined, recovering);
364
+ if (recovering)
365
+ this.completeRecovery();
366
+ }
367
+ catch (error) {
368
+ const normalizedError = toError(error);
369
+ if (recovering)
370
+ this.failRecovery(normalizedError);
371
+ this.setState("failed");
372
+ throw error;
373
+ }
374
+ }
375
+ async performRejoin(apiBase, signal, recovering = true) {
376
+ let response = await this.fetchRoomToken(apiBase, signal);
255
377
  if (response.status === 401 && this.refreshCredentialsCallback) {
256
378
  await this.refreshCredentials();
257
- response = await this.fetchRoomToken(normalizedApiBase);
379
+ throwIfAborted(signal);
380
+ response = await this.fetchRoomToken(apiBase, signal);
258
381
  }
259
382
  if (!response.ok)
260
383
  throw await httpError(response, "Could not renew room token");
261
384
  const payload = (await response.json());
262
385
  this.credentials = payload.connection;
263
- if (["connected", "connected_not_ready"].includes(this.stateValue)) {
264
- await this.disconnect();
386
+ if (this.room.state !== LiveKitConnectionState.Disconnected) {
387
+ this.disconnectMode = "rejoin";
388
+ try {
389
+ await this.room.disconnect();
390
+ }
391
+ finally {
392
+ this.disconnectMode = null;
393
+ }
265
394
  }
266
- await this.connect({
267
- apiBase: normalizedApiBase,
395
+ throwIfAborted(signal);
396
+ await this.connectWithMode({
397
+ apiBase,
268
398
  microphone: this.useManagedMicrophone,
269
399
  ...(this.applicationAudioTrack ? { audioTrack: this.applicationAudioTrack } : {}),
270
- });
400
+ }, recovering, signal);
271
401
  }
272
402
  async setMicrophoneEnabled(enabled) {
273
403
  await this.room.localParticipant.setMicrophoneEnabled(enabled);
404
+ this.managedMicrophoneEnabled = enabled;
274
405
  }
275
406
  async sendCommand(type, payload, timeoutMs = 10_000) {
276
407
  const message = createClientMessage(this.connectionId, ++this.clientSeq, type, payload);
@@ -367,13 +498,23 @@ export class RealtimeSession {
367
498
  };
368
499
  }
369
500
  async disconnect() {
501
+ this.cancelAutoRejoin();
370
502
  if (this.stateValue === "disconnected")
371
503
  return;
372
504
  this.rejectPendingCommands(new Error("Session disconnected before command completion"));
373
- await this.room.disconnect();
374
- this.setState("disconnected");
505
+ this.disconnectMode = "intentional";
506
+ try {
507
+ await this.room.disconnect();
508
+ }
509
+ finally {
510
+ this.disconnectMode = null;
511
+ this.activeRecovery = null;
512
+ this.setState("disconnected");
513
+ }
375
514
  }
376
515
  async end(options = {}) {
516
+ this.cancelAutoRejoin();
517
+ this.sessionEnded = true;
377
518
  const endpoint = options.endpoint ?? this.endEndpoint ?? `${this.apiBase}/sessions/${this.id}`;
378
519
  const request = options.request ?? {};
379
520
  const headers = new Headers(request.headers);
@@ -385,6 +526,7 @@ export class RealtimeSession {
385
526
  ...request,
386
527
  method: request.method ?? "DELETE",
387
528
  headers,
529
+ credentials: request.credentials ?? this.lifecycleCredentials,
388
530
  });
389
531
  if (response.status === 401 && usesScopedSessionEndpoint && this.refreshCredentialsCallback) {
390
532
  await this.refreshCredentials();
@@ -393,6 +535,7 @@ export class RealtimeSession {
393
535
  ...request,
394
536
  method: request.method ?? "DELETE",
395
537
  headers,
538
+ credentials: request.credentials ?? this.lifecycleCredentials,
396
539
  });
397
540
  }
398
541
  if (!response.ok)
@@ -416,32 +559,29 @@ export class RealtimeSession {
416
559
  this.notifyListeners(this.stateListeners, state);
417
560
  }
418
561
  bindRoomEvents() {
419
- this.room.on(RoomEvent.Reconnecting, () => {
420
- this.agentReady = false;
421
- this.rejectPendingCommands(new Error("Connection interrupted before command completion"), true);
422
- this.setState("reconnecting");
423
- });
562
+ this.room.on(RoomEvent.SignalReconnecting, () => this.handleLiveKitReconnecting());
563
+ this.room.on(RoomEvent.Reconnecting, () => this.handleLiveKitReconnecting());
424
564
  this.room.on(RoomEvent.Reconnected, () => {
425
- this.connectionId = `conn_${Math.random().toString(36).slice(2)}_${Date.now().toString(36)}`;
426
- this.clientSeq = 0;
427
- void this.sendHello().catch(() => this.setState("failed"));
428
- this.setState("connected_not_ready");
565
+ void this.handleLiveKitReconnected();
429
566
  });
430
- this.room.on(RoomEvent.Disconnected, () => {
431
- this.agentReady = false;
432
- // Drop any remaining remote tracks so a stale, ended track cannot
433
- // satisfy the next connect()'s wait for agent audio.
434
- this.outputMediaStream?.getTracks().forEach((track) => {
435
- this.outputMediaStream?.removeTrack(track);
436
- });
437
- this.rejectPendingCommands(new Error("Session disconnected before command completion"));
438
- this.setState("disconnected");
567
+ this.room.on(RoomEvent.Disconnected, (reason) => {
568
+ this.handleLiveKitDisconnected(reason);
439
569
  });
440
570
  this.room.on(RoomEvent.ConnectionStateChanged, (state) => {
441
571
  if (state === LiveKitConnectionState.Connected) {
442
- this.setState(this.agentReady ? "connected" : "connected_not_ready");
572
+ const recovering = this.activeRecovery !== null || this.stateValue === "resuming";
573
+ this.setState(this.agentReady
574
+ ? "connected"
575
+ : recovering
576
+ ? "resuming"
577
+ : "connected_not_ready");
443
578
  }
444
579
  });
580
+ this.room.on(RoomEvent.ConnectionQualityChanged, (quality, participant) => {
581
+ if (!participant.isLocal)
582
+ return;
583
+ this.notifyListeners(this.connectionQualityListeners, quality);
584
+ });
445
585
  this.room.on(RoomEvent.TrackSubscribed, (track) => {
446
586
  if (track.kind !== Track.Kind.Audio)
447
587
  return;
@@ -507,6 +647,192 @@ export class RealtimeSession {
507
647
  this.notifyListeners(this.listeners, value);
508
648
  });
509
649
  }
650
+ handleLiveKitReconnecting() {
651
+ this.agentReady = false;
652
+ // Once full rejoin owns recovery, late LiveKit reconnect notifications
653
+ // from the old transport must not move the wrapper back to reconnecting.
654
+ // Doing so makes connectWithMode() reject the replacement connection.
655
+ if (this.autoRejoinPromise !== null
656
+ || this.activeRecovery?.strategy === "rejoin"
657
+ || this.disconnectMode === "rejoin") {
658
+ this.setState("resuming");
659
+ return;
660
+ }
661
+ this.reconnectStartedAt ??= Date.now();
662
+ this.rejectPendingCommands(new Error("Connection interrupted before command completion"), true);
663
+ if (this.activeRecovery === null) {
664
+ this.beginRecovery("livekit", 1);
665
+ }
666
+ this.setState("reconnecting");
667
+ }
668
+ async handleLiveKitReconnected() {
669
+ const recoveredAt = Date.now();
670
+ if (this.reconnectStartedAt !== null) {
671
+ this.pendingReconnect = {
672
+ started_at: new Date(this.reconnectStartedAt).toISOString(),
673
+ recovered_at: new Date(recoveredAt).toISOString(),
674
+ duration_ms: Math.max(recoveredAt - this.reconnectStartedAt, 0),
675
+ };
676
+ this.reconnectStartedAt = null;
677
+ }
678
+ if (this.hasConnectedValue && this.activeRecovery === null) {
679
+ this.beginRecovery("livekit", 1);
680
+ }
681
+ this.connectionId = `conn_${Math.random().toString(36).slice(2)}_${Date.now().toString(36)}`;
682
+ this.clientSeq = 0;
683
+ this.setState(this.hasConnectedValue ? "resuming" : "connected_not_ready");
684
+ try {
685
+ await this.sendHello();
686
+ }
687
+ catch (error) {
688
+ const normalizedError = toError(error);
689
+ if (this.shouldAutoRejoin()) {
690
+ this.startAutoRejoin(normalizedError);
691
+ }
692
+ else {
693
+ this.failRecovery(normalizedError);
694
+ this.setState("failed");
695
+ }
696
+ }
697
+ }
698
+ handleLiveKitDisconnected(reason) {
699
+ this.agentReady = false;
700
+ this.reconnectStartedAt = null;
701
+ this.pendingReconnect = null;
702
+ // Drop any remaining remote tracks so a stale, ended track cannot
703
+ // satisfy the next connect()'s wait for agent audio.
704
+ this.outputMediaStream?.getTracks().forEach((track) => {
705
+ this.outputMediaStream?.removeTrack(track);
706
+ });
707
+ this.rejectPendingCommands(new Error("Session disconnected before command completion"));
708
+ if (this.disconnectMode === "rejoin")
709
+ return;
710
+ if (this.disconnectMode === "connect_failure")
711
+ return;
712
+ if (this.disconnectMode === "intentional") {
713
+ this.activeRecovery = null;
714
+ this.deliverPendingEndEvent();
715
+ this.setState("disconnected");
716
+ return;
717
+ }
718
+ if (this.shouldAutoRejoin(reason)) {
719
+ this.startAutoRejoin(new Error(`LiveKit disconnected (${disconnectReasonName(reason)})`));
720
+ return;
721
+ }
722
+ if (this.activeRecovery !== null) {
723
+ this.failRecovery(new Error(`LiveKit disconnected (${disconnectReasonName(reason)})`));
724
+ }
725
+ this.deliverPendingEndEvent();
726
+ this.setState("disconnected");
727
+ }
728
+ // The terminal session.ended event is recorded durably after room teardown,
729
+ // so turn the earlier live announcement into the browser completion signal.
730
+ deliverPendingEndEvent() {
731
+ const ending = this.pendingEndEvent;
732
+ if (!ending)
733
+ return;
734
+ this.pendingEndEvent = null;
735
+ this.notifyEnded({ ...ending, type: "session.ended" });
736
+ }
737
+ notifyEnded(event) {
738
+ if (this.endNotificationDelivered)
739
+ return;
740
+ this.endNotificationDelivered = true;
741
+ this.notifyListeners(this.endedListeners, event);
742
+ }
743
+ beginRecovery(strategy, attempt) {
744
+ this.activeRecovery = { strategy, attempt };
745
+ this.notifyListeners(this.recoveryListeners, {
746
+ status: "started",
747
+ strategy,
748
+ attempt,
749
+ });
750
+ }
751
+ completeRecovery() {
752
+ const recovery = this.activeRecovery;
753
+ if (!recovery)
754
+ return;
755
+ this.activeRecovery = null;
756
+ this.notifyListeners(this.recoveryListeners, {
757
+ status: "succeeded",
758
+ strategy: recovery.strategy,
759
+ attempt: recovery.attempt,
760
+ });
761
+ }
762
+ failRecovery(error) {
763
+ const recovery = this.activeRecovery;
764
+ if (!recovery)
765
+ return;
766
+ this.activeRecovery = null;
767
+ this.notifyListeners(this.recoveryListeners, {
768
+ status: "failed",
769
+ strategy: recovery.strategy,
770
+ attempt: recovery.attempt,
771
+ error,
772
+ });
773
+ }
774
+ shouldAutoRejoin(reason) {
775
+ return (this.autoRejoinOptions !== null
776
+ && this.hasConnectedValue
777
+ && !this.sessionEnded
778
+ && !isTerminalDisconnectReason(reason));
779
+ }
780
+ startAutoRejoin(initialError) {
781
+ if (this.autoRejoinPromise || !this.shouldAutoRejoin())
782
+ return;
783
+ const controller = new AbortController();
784
+ this.autoRejoinController = controller;
785
+ const promise = this.runAutoRejoin(initialError, controller.signal)
786
+ .finally(() => {
787
+ if (this.autoRejoinController === controller) {
788
+ this.autoRejoinController = null;
789
+ this.autoRejoinPromise = null;
790
+ }
791
+ });
792
+ this.autoRejoinPromise = promise;
793
+ void promise;
794
+ }
795
+ async runAutoRejoin(initialError, signal) {
796
+ const options = this.autoRejoinOptions;
797
+ if (!options)
798
+ return;
799
+ let lastError = initialError;
800
+ if (this.activeRecovery?.strategy === "livekit") {
801
+ this.failRecovery(initialError);
802
+ }
803
+ this.setState("resuming");
804
+ for (let attempt = 1; attempt <= options.maxAttempts; attempt += 1) {
805
+ try {
806
+ await waitUntilOnline(signal);
807
+ if (attempt > 1) {
808
+ const exponentialDelay = options.baseDelayMs * (2 ** (attempt - 2));
809
+ const delayMs = Math.min(exponentialDelay, options.maxDelayMs);
810
+ await abortableDelay(delayMs + Math.random() * Math.min(delayMs, 500), signal);
811
+ }
812
+ throwIfAborted(signal);
813
+ this.beginRecovery("rejoin", attempt);
814
+ await this.performRejoin(this.apiBase, signal);
815
+ throwIfAborted(signal);
816
+ this.completeRecovery();
817
+ return;
818
+ }
819
+ catch (error) {
820
+ if (signal.aborted)
821
+ return;
822
+ lastError = toError(error);
823
+ if (isTerminalRejoinError(error))
824
+ break;
825
+ }
826
+ }
827
+ this.failRecovery(lastError);
828
+ this.setState("failed");
829
+ }
830
+ cancelAutoRejoin() {
831
+ this.autoRejoinController?.abort();
832
+ this.autoRejoinController = null;
833
+ this.autoRejoinPromise = null;
834
+ this.activeRecovery = null;
835
+ }
510
836
  notifyListeners(listeners, value) {
511
837
  for (const listener of listeners) {
512
838
  try {
@@ -540,10 +866,12 @@ export class RealtimeSession {
540
866
  throw new TypeError("audioTrack has already ended");
541
867
  }
542
868
  }
543
- async fetchRoomToken(apiBase) {
869
+ async fetchRoomToken(apiBase, signal) {
544
870
  return this.fetcher(`${apiBase}/sessions/${this.id}/room-tokens`, {
545
871
  method: "POST",
546
872
  headers: { Authorization: `Bearer ${this.accessToken}` },
873
+ credentials: this.lifecycleCredentials,
874
+ signal,
547
875
  });
548
876
  }
549
877
  async refreshCredentials() {
@@ -558,8 +886,28 @@ export class RealtimeSession {
558
886
  this.credentials = refreshed.connection;
559
887
  }
560
888
  observeConvenienceEvents(event) {
561
- if (event.type === "session.ended")
562
- this.notifyListeners(this.endedListeners, event);
889
+ if (event.type === "session.ending") {
890
+ // The server announced an intentional hang-up before tearing down the
891
+ // room, so the coming disconnect must not fail recovery or auto-rejoin.
892
+ this.sessionEnded = true;
893
+ this.cancelAutoRejoin();
894
+ this.pendingEndEvent = event;
895
+ }
896
+ if (event.type === "session.ended") {
897
+ this.pendingEndEvent = null;
898
+ const recoveryWasActive = (this.autoRejoinPromise !== null
899
+ || this.activeRecovery !== null
900
+ || this.stateValue === "reconnecting"
901
+ || this.stateValue === "resuming");
902
+ this.sessionEnded = true;
903
+ if (this.activeRecovery !== null) {
904
+ this.failRecovery(new Error("Session ended during recovery"));
905
+ }
906
+ this.cancelAutoRejoin();
907
+ this.notifyEnded(event);
908
+ if (recoveryWasActive)
909
+ this.setState("disconnected");
910
+ }
563
911
  if (event.type.endsWith(".error"))
564
912
  this.notifyListeners(this.errorListeners, event);
565
913
  const transcript = transcriptEvent(event);
@@ -581,13 +929,17 @@ export class RealtimeSession {
581
929
  }
582
930
  }
583
931
  async sendHello() {
932
+ const reconnect = this.pendingReconnect;
584
933
  const message = createClientMessage(this.connectionId, ++this.clientSeq, "client.hello", {
585
934
  last_server_seq: this.cursor.lastServerSeq,
586
935
  sdk: `@voxevolv/client@${SDK_VERSION}`,
587
936
  tool_handlers: [...this.toolHandlers.keys()].sort(),
937
+ ...(reconnect ? { reconnect } : {}),
588
938
  });
589
939
  this.lastHelloMessageId = message.message_id;
590
940
  await this.sendTrackedCommand(message, 10_000);
941
+ if (this.pendingReconnect === reconnect)
942
+ this.pendingReconnect = null;
591
943
  }
592
944
  async sendTrackedCommand(message, timeoutMs, retryTransfer) {
593
945
  const response = new Promise((resolve, reject) => {
@@ -621,49 +973,65 @@ export class RealtimeSession {
621
973
  try {
622
974
  await this.publishCommand(message);
623
975
  }
624
- catch (error) {
625
- const pending = this.pendingCommands.get(message.message_id);
626
- if (pending) {
627
- globalThis.clearTimeout(pending.timeout);
628
- globalThis.clearInterval(pending.retry);
629
- this.pendingCommands.delete(message.message_id);
630
- pending.reject(error instanceof Error ? error : new Error(String(error)));
631
- }
976
+ catch {
977
+ // The reliable data channel may still be warming immediately after a
978
+ // reconnect. Keep the command pending so the retry interval can publish
979
+ // it once the transport accepts writes, or reject at the normal timeout.
632
980
  }
633
981
  return response;
634
982
  }
635
983
  async publishCommand(message) {
636
984
  await this.room.localParticipant.publishData(new TextEncoder().encode(JSON.stringify(message)), { reliable: true, topic: "voxevolv.commands" });
637
985
  }
638
- waitForRemoteAudioTrack(deadline) {
986
+ waitForRemoteAudioTrack(deadline, signal) {
639
987
  if ((this.outputMediaStream?.getAudioTracks().length ?? 0) > 0)
640
988
  return Promise.resolve();
989
+ throwIfAborted(signal);
641
990
  return new Promise((resolve, reject) => {
642
- const timeout = globalThis.setTimeout(() => {
991
+ const cleanup = () => {
992
+ globalThis.clearTimeout(timeout);
993
+ signal?.removeEventListener("abort", onAbort);
643
994
  this.remoteAudioTrackResolve = null;
995
+ };
996
+ const timeout = globalThis.setTimeout(() => {
997
+ cleanup();
644
998
  reject(new Error("Agent audio track did not arrive within the ready timeout;"
645
999
  + " pass waitForRemoteAudio: false to resolve connect() on signaling readiness alone"));
646
1000
  }, Math.max(deadline - Date.now(), 0));
1001
+ const onAbort = () => {
1002
+ cleanup();
1003
+ reject(abortError(signal));
1004
+ };
647
1005
  this.remoteAudioTrackResolve = () => {
648
- globalThis.clearTimeout(timeout);
649
- this.remoteAudioTrackResolve = null;
1006
+ cleanup();
650
1007
  resolve();
651
1008
  };
1009
+ signal?.addEventListener("abort", onAbort, { once: true });
652
1010
  });
653
1011
  }
654
- waitForAgentReady(timeoutMs) {
1012
+ waitForAgentReady(timeoutMs, signal) {
655
1013
  if (this.agentReady)
656
1014
  return Promise.resolve();
1015
+ throwIfAborted(signal);
657
1016
  return new Promise((resolve, reject) => {
658
- const timeout = globalThis.setTimeout(() => {
1017
+ const cleanup = () => {
1018
+ globalThis.clearTimeout(timeout);
1019
+ signal?.removeEventListener("abort", onAbort);
659
1020
  this.readyResolve = null;
1021
+ };
1022
+ const timeout = globalThis.setTimeout(() => {
1023
+ cleanup();
660
1024
  reject(new Error(`Agent did not become ready within ${timeoutMs}ms`));
661
1025
  }, timeoutMs);
1026
+ const onAbort = () => {
1027
+ cleanup();
1028
+ reject(abortError(signal));
1029
+ };
662
1030
  this.readyResolve = () => {
663
- globalThis.clearTimeout(timeout);
664
- this.readyResolve = null;
1031
+ cleanup();
665
1032
  resolve();
666
1033
  };
1034
+ signal?.addEventListener("abort", onAbort, { once: true });
667
1035
  });
668
1036
  }
669
1037
  observeReadiness(event) {
@@ -676,8 +1044,12 @@ export class RealtimeSession {
676
1044
  return;
677
1045
  const wasReady = this.agentReady;
678
1046
  this.agentReady = true;
679
- if (this.room.state === LiveKitConnectionState.Connected)
1047
+ if (this.room.state === LiveKitConnectionState.Connected) {
1048
+ this.hasConnectedValue = true;
680
1049
  this.setState("connected");
1050
+ if (this.activeRecovery?.strategy === "livekit")
1051
+ this.completeRecovery();
1052
+ }
681
1053
  this.readyResolve?.();
682
1054
  if (!wasReady)
683
1055
  this.notifyListeners(this.readyListeners, event);
@@ -737,10 +1109,16 @@ export class RealtimeSession {
737
1109
  async fetchReplay(apiBase, afterSeq) {
738
1110
  let nextAfter = afterSeq;
739
1111
  while (true) {
740
- let response = await this.fetcher(`${apiBase}/sessions/${this.id}/events?after_seq=${nextAfter}&limit=200`, { headers: { Authorization: `Bearer ${this.accessToken}` } });
1112
+ let response = await this.fetcher(`${apiBase}/sessions/${this.id}/events?after_seq=${nextAfter}&limit=200`, {
1113
+ headers: { Authorization: `Bearer ${this.accessToken}` },
1114
+ credentials: this.lifecycleCredentials,
1115
+ });
741
1116
  if (response.status === 401 && this.refreshCredentialsCallback) {
742
1117
  await this.refreshCredentials();
743
- response = await this.fetcher(`${apiBase}/sessions/${this.id}/events?after_seq=${nextAfter}&limit=200`, { headers: { Authorization: `Bearer ${this.accessToken}` } });
1118
+ response = await this.fetcher(`${apiBase}/sessions/${this.id}/events?after_seq=${nextAfter}&limit=200`, {
1119
+ headers: { Authorization: `Bearer ${this.accessToken}` },
1120
+ credentials: this.lifecycleCredentials,
1121
+ });
744
1122
  }
745
1123
  if (!response.ok)
746
1124
  throw await httpError(response, "Could not replay session events");
@@ -769,6 +1147,96 @@ export class RealtimeSession {
769
1147
  }
770
1148
  }
771
1149
  }
1150
+ function resolveAutoRejoinOptions(value) {
1151
+ if (!value)
1152
+ return null;
1153
+ const options = value === true ? {} : value;
1154
+ const resolved = {
1155
+ maxAttempts: options.maxAttempts ?? 3,
1156
+ baseDelayMs: options.baseDelayMs ?? 750,
1157
+ maxDelayMs: options.maxDelayMs ?? 5_000,
1158
+ };
1159
+ if (!Number.isInteger(resolved.maxAttempts) || resolved.maxAttempts < 1) {
1160
+ throw new TypeError("autoRejoin.maxAttempts must be a positive integer");
1161
+ }
1162
+ if (!Number.isFinite(resolved.baseDelayMs) || resolved.baseDelayMs < 0) {
1163
+ throw new TypeError("autoRejoin.baseDelayMs must be a non-negative finite number");
1164
+ }
1165
+ if (!Number.isFinite(resolved.maxDelayMs) || resolved.maxDelayMs < 0) {
1166
+ throw new TypeError("autoRejoin.maxDelayMs must be a non-negative finite number");
1167
+ }
1168
+ return resolved;
1169
+ }
1170
+ function isTerminalDisconnectReason(reason) {
1171
+ return reason !== undefined && [
1172
+ DisconnectReason.CLIENT_INITIATED,
1173
+ DisconnectReason.DUPLICATE_IDENTITY,
1174
+ DisconnectReason.PARTICIPANT_REMOVED,
1175
+ DisconnectReason.ROOM_DELETED,
1176
+ DisconnectReason.ROOM_CLOSED,
1177
+ DisconnectReason.USER_UNAVAILABLE,
1178
+ DisconnectReason.USER_REJECTED,
1179
+ ].includes(reason);
1180
+ }
1181
+ function disconnectReasonName(reason) {
1182
+ if (reason === undefined)
1183
+ return "unknown";
1184
+ return DisconnectReason[reason] ?? String(reason);
1185
+ }
1186
+ function isTerminalRejoinError(error) {
1187
+ return error instanceof RealtimeSessionHttpError
1188
+ && [401, 403, 409].includes(error.status);
1189
+ }
1190
+ function toError(error) {
1191
+ return error instanceof Error ? error : new Error(String(error));
1192
+ }
1193
+ function abortError(signal) {
1194
+ return signal.reason ?? new DOMException("Aborted", "AbortError");
1195
+ }
1196
+ function throwIfAborted(signal) {
1197
+ if (signal?.aborted)
1198
+ throw abortError(signal);
1199
+ }
1200
+ function abortableDelay(delayMs, signal) {
1201
+ throwIfAborted(signal);
1202
+ if (delayMs <= 0)
1203
+ return Promise.resolve();
1204
+ return new Promise((resolve, reject) => {
1205
+ const timeout = globalThis.setTimeout(() => {
1206
+ signal.removeEventListener("abort", onAbort);
1207
+ resolve();
1208
+ }, delayMs);
1209
+ const onAbort = () => {
1210
+ globalThis.clearTimeout(timeout);
1211
+ reject(abortError(signal));
1212
+ };
1213
+ signal.addEventListener("abort", onAbort, { once: true });
1214
+ });
1215
+ }
1216
+ function waitUntilOnline(signal) {
1217
+ throwIfAborted(signal);
1218
+ if (typeof navigator === "undefined" || navigator.onLine !== false) {
1219
+ return Promise.resolve();
1220
+ }
1221
+ if (typeof globalThis.addEventListener !== "function")
1222
+ return Promise.resolve();
1223
+ return new Promise((resolve, reject) => {
1224
+ const cleanup = () => {
1225
+ globalThis.removeEventListener("online", onOnline);
1226
+ signal.removeEventListener("abort", onAbort);
1227
+ };
1228
+ const onOnline = () => {
1229
+ cleanup();
1230
+ resolve();
1231
+ };
1232
+ const onAbort = () => {
1233
+ cleanup();
1234
+ reject(abortError(signal));
1235
+ };
1236
+ globalThis.addEventListener("online", onOnline, { once: true });
1237
+ signal.addEventListener("abort", onAbort, { once: true });
1238
+ });
1239
+ }
772
1240
  async function sha256Hex(data) {
773
1241
  if (!globalThis.crypto?.subtle)
774
1242
  throw new Error("Web Crypto SHA-256 is unavailable");