@voxevolv/client 0.3.6 → 0.3.8

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,20 +1,20 @@
1
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
- import { ReplacementContextQueue } from "./context-queue.js";
4
- import { ClientDiagnosticBuffer, } from "./diagnostics.js";
3
+ import { ContextDeliveryError, contextRetryDelay, ReplacementContextQueue } from "./context-queue.js";
4
+ import { ClientDiagnosticBuffer, DiagnosticDeliveryError, } from "./diagnostics.js";
5
5
  import { TranscriptStore } from "./transcripts.js";
6
6
  export { TranscriptStore } from "./transcripts.js";
7
7
  export { CommandError, EventCursor, createClientMessage, isCommandAckPayload, isCommandErrorPayload, isContextRejectedPayload, isContextUpdatePayload, isContextUpdatedPayload, isRealtimeEvent, } from "./protocol.js";
8
8
  export { decodeAgentSnapshotPayload, decodeClientHelloPayload, decodeClientMessage, decodeCommandAckPayload, decodeCommandErrorPayload, decodeContextRejectedPayload, decodeContextUpdatePayload, decodeContextUpdatedPayload, decodeRealtimeEvent, } from "./generated/protocol-codecs.js";
9
- export const SDK_VERSION = "0.3.6";
9
+ export const SDK_VERSION = "0.3.8";
10
10
  const CLIENT_HELLO_TIMEOUT_MS = 30_000;
11
11
  const DIAGNOSTIC_REPORT_TIMEOUT_MS = 5_000;
12
12
  const DEFAULT_STARTUP_ATTEMPTS = [
13
13
  { delayBeforeMs: 0, readyTimeoutMs: 15_000 },
14
14
  { delayBeforeMs: 1_000, readyTimeoutMs: 20_000 },
15
- { delayBeforeMs: 3_000, readyTimeoutMs: 25_000 },
15
+ { delayBeforeMs: 3_000, readyTimeoutMs: 50_000 },
16
16
  ];
17
- const DEFAULT_STARTUP_TIMEOUT_MS = 65_000;
17
+ const DEFAULT_STARTUP_TIMEOUT_MS = 90_000;
18
18
  export class RealtimeSessionHttpError extends Error {
19
19
  status;
20
20
  code;
@@ -88,11 +88,14 @@ export class RealtimeSession {
88
88
  hasConnectedValue = false;
89
89
  sessionEnded = false;
90
90
  pendingEndEvent = null;
91
+ agentDisconnected = false;
92
+ terminalSessionError = null;
91
93
  endNotificationDelivered = false;
92
94
  disconnectMode = null;
93
95
  activeRecovery = null;
94
96
  autoRejoinPromise = null;
95
97
  autoRejoinController = null;
98
+ manualRejoinController = null;
96
99
  startupConnectController = null;
97
100
  startupAttemptController = null;
98
101
  useManagedMicrophone = true;
@@ -107,6 +110,10 @@ export class RealtimeSession {
107
110
  lifecycleCredentials;
108
111
  diagnostics = new ClientDiagnosticBuffer({ sdkVersion: SDK_VERSION });
109
112
  diagnosticReportTimeoutMs = DIAGNOSTIC_REPORT_TIMEOUT_MS;
113
+ onDiagnosticVisibilityChange = () => {
114
+ if (globalThis.document?.visibilityState === "hidden")
115
+ this.flushDiagnostics("page_hidden");
116
+ };
110
117
  constructor(credentials, options = {}) {
111
118
  validateSessionCredentials(credentials);
112
119
  this.id = credentials.id;
@@ -331,7 +338,7 @@ export class RealtimeSession {
331
338
  .includes(this.stateValue)) {
332
339
  throw new Error(`Cannot connect while session is ${this.stateValue}`);
333
340
  }
334
- this.cancelAutoRejoin();
341
+ this.cancelRecovery();
335
342
  const controller = new AbortController();
336
343
  this.startupConnectController = controller;
337
344
  try {
@@ -344,7 +351,11 @@ export class RealtimeSession {
344
351
  }
345
352
  }
346
353
  catch (error) {
347
- if (this.startupConnectController === controller && !this.sessionEnded) {
354
+ if (this.startupConnectController === controller && this.terminalSessionError) {
355
+ this.sessionEnded = true;
356
+ this.setState("failed");
357
+ }
358
+ else if (this.startupConnectController === controller && !this.sessionEnded) {
348
359
  this.setState("failed");
349
360
  }
350
361
  throw error;
@@ -362,10 +373,13 @@ export class RealtimeSession {
362
373
  const startupOptions = this.startupRetryOptions;
363
374
  if (!startupOptions)
364
375
  return;
376
+ const startupStartedAt = performance.now();
365
377
  const timeout = globalThis.setTimeout(() => {
366
378
  controller.abort(new Error(`Session startup did not complete within ${startupOptions.timeoutMs}ms`));
367
379
  }, startupOptions.timeoutMs);
368
380
  let lastError = new Error("Session startup failed");
381
+ let startupRegistered = this.hasConnectedValue;
382
+ this.apiBase = normalizeApiBase(options.apiBase ?? this.connectDefaults.apiBase ?? this.apiBase);
369
383
  try {
370
384
  for (const [index, attempt] of startupOptions.attempts.entries()) {
371
385
  const attemptNumber = index + 1;
@@ -378,6 +392,11 @@ export class RealtimeSession {
378
392
  this.startupAttemptController = attemptController;
379
393
  this.notifyStartupAttempt("started", attemptNumber, attempt.readyTimeoutMs);
380
394
  try {
395
+ if (!startupRegistered) {
396
+ const remainingMs = startupOptions.timeoutMs - (performance.now() - startupStartedAt);
397
+ await this.registerStartupRecovery(remainingMs, attemptController.signal);
398
+ startupRegistered = true;
399
+ }
381
400
  if (index === 0) {
382
401
  await this.connectWithMode({ ...options, readyTimeoutMs: attempt.readyTimeoutMs }, false, attemptController.signal, false);
383
402
  }
@@ -389,6 +408,7 @@ export class RealtimeSession {
389
408
  markFailureTerminal: false,
390
409
  });
391
410
  }
411
+ throwIfAborted(controller.signal);
392
412
  this.notifyStartupAttempt("succeeded", attemptNumber, attempt.readyTimeoutMs);
393
413
  return;
394
414
  }
@@ -414,6 +434,38 @@ export class RealtimeSession {
414
434
  globalThis.clearTimeout(timeout);
415
435
  }
416
436
  }
437
+ async registerStartupRecovery(timeoutMs, signal) {
438
+ const reserveMs = Math.min(5_000, timeoutMs / 10);
439
+ const controller = createLinkedAbortController(signal);
440
+ const requestTimeout = globalThis.setTimeout(() => {
441
+ controller.abort(new Error("Startup registration timed out"));
442
+ }, Math.max(1, Math.min(3_000, reserveMs)));
443
+ try {
444
+ // Leave room for registration latency and final readiness before the
445
+ // browser's existing overall deadline. Rejoins never renew this window.
446
+ const send = () => raceWithAbort(this.fetcher(`${this.apiBase}/sessions/${this.id}/startup`, {
447
+ method: "POST",
448
+ headers: { Authorization: `Bearer ${this.accessToken}`, "Content-Type": "application/json" },
449
+ credentials: this.lifecycleCredentials,
450
+ body: JSON.stringify({ timeout_ms: Math.max(1, Math.floor(Math.min(timeoutMs - reserveMs, 85_000))) }),
451
+ signal: controller.signal,
452
+ }), controller.signal);
453
+ let response = await send();
454
+ if (response.status === 401 && this.refreshCredentialsCallback) {
455
+ await raceWithAbort(this.refreshCredentials(), controller.signal);
456
+ response = await send();
457
+ }
458
+ if (!response.ok)
459
+ throw await httpError(response, "Could not register session startup");
460
+ const body = await raceWithAbort(response.json(), controller.signal);
461
+ if (typeof body.remaining_ms !== "number" || body.remaining_ms <= 0) {
462
+ throw new Error("Session startup window has expired");
463
+ }
464
+ }
465
+ finally {
466
+ globalThis.clearTimeout(requestTimeout);
467
+ }
468
+ }
417
469
  notifyStartupAttempt(status, attempt, readyTimeoutMs, error) {
418
470
  const maxAttempts = this.startupRetryOptions?.attempts.length ?? 1;
419
471
  this.notifyListeners(this.startupAttemptListeners, {
@@ -451,6 +503,8 @@ export class RealtimeSession {
451
503
  this.agentStreamId = null;
452
504
  this.reconnectStartedAt = null;
453
505
  this.pendingEndEvent = null;
506
+ this.agentDisconnected = false;
507
+ this.terminalSessionError = null;
454
508
  this.endNotificationDelivered = false;
455
509
  this.setState("connecting");
456
510
  }
@@ -479,7 +533,7 @@ export class RealtimeSession {
479
533
  if (this.sessionEnded || generation !== this.contextGeneration) {
480
534
  throw new Error("Connection restoration interrupted");
481
535
  }
482
- await raceWithAbort(this.replacementContext.resume(recovering), signal);
536
+ this.resumeContextDelivery(recovering);
483
537
  throwIfAborted(signal);
484
538
  if (this.sessionEnded || generation !== this.contextGeneration) {
485
539
  throw new Error("Connection restoration interrupted");
@@ -541,28 +595,42 @@ export class RealtimeSession {
541
595
  });
542
596
  }
543
597
  async rejoin(apiBase = this.apiBase) {
598
+ if (this.sessionEnded) {
599
+ throw this.terminalSessionError ?? new Error("Cannot rejoin a session that has ended");
600
+ }
544
601
  if (["connecting", "reconnecting", "resuming"].includes(this.stateValue)) {
545
602
  throw new Error(`Cannot rejoin while session is ${this.stateValue}`);
546
603
  }
547
- this.cancelAutoRejoin();
604
+ this.cancelRecovery();
548
605
  const normalizedApiBase = normalizeApiBase(apiBase);
549
606
  const recovering = this.hasConnectedValue;
607
+ const controller = new AbortController();
608
+ this.manualRejoinController = controller;
550
609
  if (recovering) {
551
610
  this.beginRecovery("rejoin", 1, { trigger: "manual_rejoin" });
552
611
  this.setState("resuming");
553
612
  }
554
613
  try {
555
- await this.performRejoin(normalizedApiBase, { recovering });
614
+ await this.performRejoin(normalizedApiBase, { recovering, signal: controller.signal });
615
+ if (this.sessionEnded)
616
+ return;
556
617
  if (recovering)
557
618
  this.completeRecovery();
558
619
  }
559
620
  catch (error) {
621
+ if (controller.signal.aborted && !this.terminalSessionError)
622
+ return;
560
623
  const normalizedError = toError(error);
561
624
  if (recovering)
562
625
  this.failRecovery(normalizedError);
563
626
  this.setState("failed");
564
627
  throw error;
565
628
  }
629
+ finally {
630
+ if (this.manualRejoinController === controller) {
631
+ this.manualRejoinController = null;
632
+ }
633
+ }
566
634
  }
567
635
  async performRejoin(apiBase, options = {}) {
568
636
  const { signal, recovering = true, connect = {}, markFailureTerminal = true, } = options;
@@ -575,7 +643,15 @@ export class RealtimeSession {
575
643
  throwIfAborted(signal);
576
644
  response = await this.fetchRoomToken(apiBase, signal);
577
645
  }
646
+ // A live ending may arrive while token renewal is in flight.
647
+ if (this.sessionEnded) {
648
+ if (this.terminalSessionError)
649
+ throw this.terminalSessionError;
650
+ return;
651
+ }
578
652
  if (!response.ok) {
653
+ if (response.status === 409 && await this.recoverSessionEnd(apiBase, signal))
654
+ return;
579
655
  throw await raceWithAbort(httpError(response, "Could not renew room token"), signal);
580
656
  }
581
657
  const payload = (await raceWithAbort(response.json(), signal));
@@ -583,7 +659,7 @@ export class RealtimeSession {
583
659
  if (this.room.state !== LiveKitConnectionState.Disconnected) {
584
660
  this.disconnectMode = "rejoin";
585
661
  try {
586
- await this.room.disconnect();
662
+ await raceWithAbort(this.room.disconnect(), signal);
587
663
  }
588
664
  finally {
589
665
  this.disconnectMode = null;
@@ -633,11 +709,15 @@ export class RealtimeSession {
633
709
  context_key: update.key,
634
710
  payload_bytes: payloadBytes,
635
711
  });
712
+ const uploading = "image" in update ? this.uploadImageContext(update) : this.uploadTextContext(update);
713
+ const transferSettled = uploading.then(() => { }, () => { });
636
714
  let transferTimeout;
637
715
  const transferExpired = new Promise((_resolve, reject) => {
638
716
  transferTimeout = globalThis.setTimeout(() => {
639
717
  transferTimedOut = true;
640
- const error = new Error(`Context transfer timed out after ${timeoutMs}ms`);
718
+ const error = new ContextDeliveryError(`Context transfer timed out after ${timeoutMs}ms`, {
719
+ retryAfter: transferSettled,
720
+ });
641
721
  this.recordDiagnostic({
642
722
  operation: "context_transfer",
643
723
  status: "timed_out",
@@ -648,19 +728,13 @@ export class RealtimeSession {
648
728
  timeout_ms: timeoutMs,
649
729
  });
650
730
  this.flushDiagnostics("context_transfer_timeout");
651
- this.startAutoRejoin(error, {
652
- trigger: "context_transfer_timeout",
653
- operation: "context_transfer",
654
- contextKey: update.key,
655
- timeoutMs,
656
- });
657
731
  reject(error);
658
732
  }, timeoutMs);
659
733
  });
660
734
  let transfer;
661
735
  try {
662
736
  transfer = await Promise.race([
663
- "image" in update ? this.uploadImageContext(update) : this.uploadTextContext(update),
737
+ uploading,
664
738
  transferExpired,
665
739
  ]);
666
740
  }
@@ -676,7 +750,9 @@ export class RealtimeSession {
676
750
  });
677
751
  this.flushDiagnostics("command_failed");
678
752
  }
679
- throw error;
753
+ if (error instanceof TypeError || error instanceof ContextDeliveryError)
754
+ throw error;
755
+ throw new ContextDeliveryError(toError(error).message, { cause: error });
680
756
  }
681
757
  finally {
682
758
  globalThis.clearTimeout(transferTimeout);
@@ -700,6 +776,11 @@ export class RealtimeSession {
700
776
  const message = createClientMessage(this.connectionId, ++this.clientSeq, "context.update", { ...payload });
701
777
  return this.sendTrackedCommand(message, timeoutMs, transfer.resend);
702
778
  }
779
+ resumeContextDelivery(restore) {
780
+ // Audio readiness owns connection recovery. Context delivery has its own
781
+ // deadlines and retries, and may be interrupted by a later disconnect.
782
+ void this.replacementContext.resume(restore).catch(() => { });
783
+ }
703
784
  async uploadTextContext(update) {
704
785
  const data = new TextEncoder().encode(update.text);
705
786
  if (data.byteLength === 0)
@@ -782,7 +863,8 @@ export class RealtimeSession {
782
863
  async disconnect() {
783
864
  this.abortStartupConnect(new Error("Session disconnected"));
784
865
  this.startupConnectController = null;
785
- this.cancelAutoRejoin();
866
+ this.failRecovery(new Error("Session disconnected during recovery"));
867
+ this.cancelRecovery();
786
868
  this.contextGeneration += 1;
787
869
  this.replacementContext.clear(new Error("Session disconnected"));
788
870
  if (this.stateValue === "disconnected")
@@ -801,7 +883,8 @@ export class RealtimeSession {
801
883
  async end(options = {}) {
802
884
  this.abortStartupConnect(new Error("Session ended"));
803
885
  this.startupConnectController = null;
804
- this.cancelAutoRejoin();
886
+ this.failRecovery(new Error("Session ended during recovery"));
887
+ this.cancelRecovery();
805
888
  this.sessionEnded = true;
806
889
  const endpoint = options.endpoint ?? this.endEndpoint ?? `${this.apiBase}/sessions/${this.id}`;
807
890
  const request = options.request ?? {};
@@ -844,11 +927,29 @@ export class RealtimeSession {
844
927
  if (this.stateValue === state)
845
928
  return;
846
929
  this.stateValue = state;
930
+ if (state === "connecting" || state === "resuming") {
931
+ globalThis.document?.addEventListener?.("visibilitychange", this.onDiagnosticVisibilityChange);
932
+ }
933
+ if (state === "disconnected" || state === "failed") {
934
+ globalThis.document?.removeEventListener?.("visibilitychange", this.onDiagnosticVisibilityChange);
935
+ this.flushDiagnostics("session_finished");
936
+ const error = new Error(`Session ${state}`);
937
+ this.contextGeneration += 1;
938
+ this.replacementContext.clear(error);
939
+ this.rejectPendingCommands(error);
940
+ }
847
941
  this.notifyListeners(this.stateListeners, state);
848
942
  }
849
943
  bindRoomEvents() {
850
944
  this.room.on(RoomEvent.ParticipantDisconnected, (participant) => {
851
945
  if (participant.isAgent && !this.disconnectMode) {
946
+ this.agentDisconnected = true;
947
+ if (this.terminalSessionError)
948
+ return;
949
+ if (this.sessionEnded) {
950
+ this.finishPendingEnd();
951
+ return;
952
+ }
852
953
  this.startAutoRejoin(new Error("Agent disconnected from the room"), { trigger: "agent_disconnected" });
853
954
  }
854
955
  });
@@ -861,6 +962,8 @@ export class RealtimeSession {
861
962
  this.handleLiveKitDisconnected(reason);
862
963
  });
863
964
  this.room.on(RoomEvent.ConnectionStateChanged, (state) => {
965
+ if (this.sessionEnded || this.terminalSessionError)
966
+ return;
864
967
  if (state === LiveKitConnectionState.Connected) {
865
968
  const recovering = this.activeRecovery !== null || this.stateValue === "resuming";
866
969
  this.setState(this.agentReady
@@ -989,7 +1092,7 @@ export class RealtimeSession {
989
1092
  await this.waitForRemoteAudioTrack(deadline);
990
1093
  if (this.sessionEnded || generation !== this.contextGeneration)
991
1094
  return;
992
- await this.replacementContext.resume(true);
1095
+ this.resumeContextDelivery(true);
993
1096
  if (this.sessionEnded || generation !== this.contextGeneration)
994
1097
  return;
995
1098
  this.setState("connected");
@@ -1029,6 +1132,8 @@ export class RealtimeSession {
1029
1132
  this.setState("disconnected");
1030
1133
  return;
1031
1134
  }
1135
+ if (this.terminalSessionError)
1136
+ return;
1032
1137
  if (this.shouldAutoRejoin(reason)) {
1033
1138
  this.startAutoRejoin(new Error(`LiveKit disconnected (${disconnectReasonName(reason)})`), { trigger: "livekit_disconnected" });
1034
1139
  return;
@@ -1048,6 +1153,37 @@ export class RealtimeSession {
1048
1153
  this.pendingEndEvent = null;
1049
1154
  this.notifyEnded({ ...ending, type: "session.ended" });
1050
1155
  }
1156
+ finishPendingEnd() {
1157
+ this.deliverPendingEndEvent();
1158
+ // The agent can leave while the browser remains in the room. Close that
1159
+ // transport ourselves, after notifying the application of the true reason.
1160
+ void this.disconnect().catch(() => { });
1161
+ }
1162
+ async recoverSessionEnd(apiBase, signal) {
1163
+ const controller = new AbortController();
1164
+ const cancel = () => controller.abort(signal?.reason);
1165
+ signal?.addEventListener("abort", cancel, { once: true });
1166
+ if (signal?.aborted)
1167
+ cancel();
1168
+ const timeout = globalThis.setTimeout(() => controller.abort(), 5_000);
1169
+ try {
1170
+ // A later live event may have advanced the cursor past a missed ending.
1171
+ // Replay from the start; event IDs suppress already delivered messages.
1172
+ await this.fetchReplay(apiBase, 0, { signal: controller.signal, terminalOnly: true });
1173
+ }
1174
+ catch {
1175
+ // A 409 also covers failed sessions. Without an authoritative ending,
1176
+ // retain the original HTTP failure rather than invent a normal end.
1177
+ }
1178
+ finally {
1179
+ globalThis.clearTimeout(timeout);
1180
+ signal?.removeEventListener("abort", cancel);
1181
+ }
1182
+ if (!this.sessionEnded || this.terminalSessionError)
1183
+ return false;
1184
+ this.finishPendingEnd();
1185
+ return true;
1186
+ }
1051
1187
  notifyEnded(event) {
1052
1188
  if (this.endNotificationDelivered)
1053
1189
  return;
@@ -1156,6 +1292,8 @@ export class RealtimeSession {
1156
1292
  throwIfAborted(signal);
1157
1293
  this.beginRecovery("rejoin", attempt, details);
1158
1294
  await this.performRejoin(this.apiBase, { signal });
1295
+ if (this.sessionEnded)
1296
+ return;
1159
1297
  throwIfAborted(signal);
1160
1298
  this.reconnectStartedAt = null;
1161
1299
  this.completeRecovery();
@@ -1173,9 +1311,11 @@ export class RealtimeSession {
1173
1311
  this.replacementContext.clear(lastError);
1174
1312
  this.setState("failed");
1175
1313
  }
1176
- cancelAutoRejoin() {
1314
+ cancelRecovery() {
1177
1315
  this.autoRejoinController?.abort();
1178
1316
  this.autoRejoinController = null;
1317
+ this.manualRejoinController?.abort();
1318
+ this.manualRejoinController = null;
1179
1319
  this.autoRejoinPromise = null;
1180
1320
  this.activeRecovery = null;
1181
1321
  }
@@ -1242,44 +1382,76 @@ export class RealtimeSession {
1242
1382
  this.accessToken = refreshed.access_token;
1243
1383
  this.credentials = refreshed.connection;
1244
1384
  }
1385
+ async finishSessionFailure(error) {
1386
+ this.sessionEnded = true;
1387
+ this.contextGeneration += 1;
1388
+ this.rejectPendingCommands(error);
1389
+ this.replacementContext.clear(error);
1390
+ this.failRecovery(error);
1391
+ this.cancelRecovery();
1392
+ this.disconnectMode = "connect_failure";
1393
+ try {
1394
+ await this.room.disconnect();
1395
+ }
1396
+ finally {
1397
+ this.disconnectMode = null;
1398
+ this.setState("failed");
1399
+ }
1400
+ }
1245
1401
  observeConvenienceEvents(event) {
1246
1402
  if (event.type === "session.error") {
1403
+ // Teardown errors must not overwrite an already announced normal ending.
1404
+ if (this.sessionEnded)
1405
+ return;
1247
1406
  const error = new SessionErrorEventError(event);
1248
- if (error.retryable) {
1249
- this.startupAttemptController?.abort(error);
1407
+ // Retryability applies to an active startup attempt. Once connected,
1408
+ // a durable session.error means the server has failed the session.
1409
+ if (error.retryable && this.startupAttemptController) {
1410
+ this.startupAttemptController.abort(error);
1250
1411
  }
1251
1412
  else {
1413
+ this.terminalSessionError = error;
1252
1414
  this.abortStartupConnect(error);
1415
+ // Startup owns its aborted attempt's cleanup. An established session
1416
+ // needs to release media and settle recovery immediately on failure.
1417
+ if (!this.startupConnectController) {
1418
+ void this.finishSessionFailure(error).catch(() => { });
1419
+ }
1253
1420
  }
1254
1421
  }
1255
1422
  if (event.type === "session.ending" || event.type === "session.ended") {
1423
+ // A worker shutdown event can follow a durable session.error. The server
1424
+ // retains FAILED in that case, so it must not become a normal completion.
1425
+ if (this.terminalSessionError)
1426
+ return;
1256
1427
  this.abortStartupConnect(new Error("Session ended during startup"));
1257
1428
  this.contextGeneration += 1;
1258
1429
  this.rejectPendingCommands(new Error("Session ended"));
1430
+ this.failRecovery(new Error("Session ended during recovery"));
1259
1431
  }
1260
1432
  if (event.type === "session.ending") {
1261
1433
  // The server announced an intentional hang-up before tearing down the
1262
1434
  // room, so the coming disconnect must not fail recovery or auto-rejoin.
1263
1435
  this.sessionEnded = true;
1264
- this.cancelAutoRejoin();
1436
+ this.cancelRecovery();
1265
1437
  this.replacementContext.clear(new Error("Session ended"));
1266
- this.pendingEndEvent = event;
1438
+ // Match the server's first terminal outcome, even if teardown announces
1439
+ // another ending before the agent has left the room.
1440
+ this.pendingEndEvent ??= event;
1441
+ if (!this.hasConnectedValue
1442
+ || this.agentDisconnected
1443
+ || this.room.state === LiveKitConnectionState.Disconnected) {
1444
+ this.finishPendingEnd();
1445
+ }
1267
1446
  }
1268
1447
  if (event.type === "session.ended") {
1448
+ const ending = this.pendingEndEvent;
1269
1449
  this.pendingEndEvent = null;
1270
- const recoveryWasActive = (this.autoRejoinPromise !== null
1271
- || this.activeRecovery !== null
1272
- || this.stateValue === "reconnecting"
1273
- || this.stateValue === "resuming");
1274
1450
  this.sessionEnded = true;
1275
1451
  this.replacementContext.clear(new Error("Session ended"));
1276
- if (this.activeRecovery !== null) {
1277
- this.failRecovery(new Error("Session ended during recovery"));
1278
- }
1279
- this.cancelAutoRejoin();
1280
- this.notifyEnded(event);
1281
- if (recoveryWasActive)
1282
- this.setState("disconnected");
1452
+ this.cancelRecovery();
1453
+ this.notifyEnded(ending ? { ...ending, type: "session.ended" } : event);
1454
+ void this.disconnect().catch(() => { });
1283
1455
  }
1284
1456
  if (event.type.endsWith(".error"))
1285
1457
  this.notifyListeners(this.errorListeners, event);
@@ -1344,10 +1516,13 @@ export class RealtimeSession {
1344
1516
  const timeout = globalThis.setTimeout(() => {
1345
1517
  const pending = this.pendingCommands.get(message.message_id);
1346
1518
  if (pending)
1347
- globalThis.clearInterval(pending.retry);
1519
+ globalThis.clearTimeout(pending.retry);
1348
1520
  pending?.markSettled();
1349
1521
  this.pendingCommands.delete(message.message_id);
1350
- const error = new Error(`Command ${message.type} timed out after ${timeoutMs}ms`);
1522
+ const detail = `Command ${message.type} timed out after ${timeoutMs}ms`;
1523
+ const error = message.type === "context.update"
1524
+ ? new ContextDeliveryError(detail, { retryAfter: pending?.transferSettled })
1525
+ : new Error(detail);
1351
1526
  this.recordDiagnostic({
1352
1527
  operation: "command_send",
1353
1528
  status: "timed_out",
@@ -1366,21 +1541,7 @@ export class RealtimeSession {
1366
1541
  });
1367
1542
  this.flushDiagnostics("command_timeout");
1368
1543
  reject(error);
1369
- if (message.type === "context.update") {
1370
- this.startAutoRejoin(error, {
1371
- trigger: "context_command_timeout",
1372
- operation: "command_send",
1373
- messageId: message.message_id,
1374
- contextKey: contextKeyFromMessage(message),
1375
- timeoutMs,
1376
- });
1377
- }
1378
1544
  }, timeoutMs);
1379
- const retry = globalThis.setInterval(() => {
1380
- void this.publishCommandAttempt(message, true).catch(() => {
1381
- // A reconnect may temporarily reject writes; retry until timeout.
1382
- });
1383
- }, 500);
1384
1545
  this.pendingCommands.set(message.message_id, {
1385
1546
  commandType: message.type,
1386
1547
  message,
@@ -1392,25 +1553,58 @@ export class RealtimeSession {
1392
1553
  resolve,
1393
1554
  reject,
1394
1555
  timeout,
1395
- retry,
1396
1556
  settled,
1397
1557
  markSettled,
1398
- retryTransfer: retryTransfer
1399
- ? async () => {
1400
- await retryTransfer();
1401
- await this.publishCommandAttempt(message, true);
1402
- }
1403
- : undefined,
1558
+ retryTransfer,
1404
1559
  transferRetrying: false,
1560
+ transferRetryRequested: false,
1405
1561
  });
1562
+ this.scheduleCommandRetry(message);
1406
1563
  });
1407
1564
  void this.publishCommandAttempt(message, false).catch(() => {
1408
1565
  // The reliable data channel may still be warming immediately after a
1409
- // reconnect. Keep the command pending so the retry interval can publish
1566
+ // reconnect. Keep the command pending so the retry timer can publish
1410
1567
  // it once the transport accepts writes, or reject at the normal timeout.
1411
1568
  });
1412
1569
  return response;
1413
1570
  }
1571
+ scheduleCommandRetry(message) {
1572
+ const pending = this.pendingCommands.get(message.message_id);
1573
+ if (!pending)
1574
+ return;
1575
+ const delayMs = message.type === "context.update"
1576
+ ? contextRetryDelay(pending.retryCount + 1)
1577
+ : 500;
1578
+ pending.retry = globalThis.setTimeout(() => {
1579
+ void this.retryCommand(message).finally(() => this.scheduleCommandRetry(message));
1580
+ }, delayMs);
1581
+ }
1582
+ async retryCommand(message) {
1583
+ const pending = this.pendingCommands.get(message.message_id);
1584
+ if (!pending)
1585
+ return;
1586
+ if (message.type === "context.update" && (this.sessionEnded || ["reconnecting", "resuming", "disconnected", "failed"].includes(this.stateValue)))
1587
+ return;
1588
+ try {
1589
+ if (pending.transferRetryRequested && pending.retryTransfer) {
1590
+ pending.transferRetryRequested = false;
1591
+ pending.transferRetrying = true;
1592
+ const transfer = pending.retryTransfer();
1593
+ pending.transferSettled = transfer.then(() => { }, () => { });
1594
+ if (pending.contextKey) {
1595
+ this.replacementContext.blockUntilTransferSettles(pending.contextKey, pending.transferSettled);
1596
+ }
1597
+ await Promise.race([transfer, pending.settled]);
1598
+ }
1599
+ await this.publishCommandAttempt(message, true);
1600
+ }
1601
+ catch {
1602
+ // Failed writes retain their original deadline and retry with backoff.
1603
+ }
1604
+ finally {
1605
+ pending.transferRetrying = false;
1606
+ }
1607
+ }
1414
1608
  publishCommandAttempt(message, retrying) {
1415
1609
  const pending = this.pendingCommands.get(message.message_id);
1416
1610
  if (!pending || pending.publishQueued)
@@ -1520,8 +1714,8 @@ export class RealtimeSession {
1520
1714
  }, this.diagnosticReportTimeoutMs);
1521
1715
  });
1522
1716
  try {
1523
- const response = await Promise.race([
1524
- this.fetcher(`${this.apiBase}/sessions/${this.id}/diagnostics`, {
1717
+ const upload = async () => {
1718
+ const response = await this.fetcher(`${this.apiBase}/sessions/${this.id}/diagnostics`, {
1525
1719
  method: "POST",
1526
1720
  headers: {
1527
1721
  Authorization: `Bearer ${this.accessToken}`,
@@ -1530,11 +1724,22 @@ export class RealtimeSession {
1530
1724
  credentials: this.lifecycleCredentials,
1531
1725
  body: JSON.stringify(report),
1532
1726
  signal: controller.signal,
1533
- }),
1534
- timeoutExpired,
1535
- ]);
1536
- if (!response.ok)
1537
- throw new Error(`Diagnostic report failed (${response.status})`);
1727
+ keepalive: true,
1728
+ });
1729
+ const body = await response.json().catch(() => null);
1730
+ if (!response.ok) {
1731
+ const retryable = response.status === 408 || response.status === 429 || response.status >= 500;
1732
+ const headerDelay = Number(response.headers.get("Retry-After")) * 1000;
1733
+ const retryAfterMs = typeof body?.retry_after_ms === "number"
1734
+ ? body.retry_after_ms : headerDelay;
1735
+ throw new DiagnosticDeliveryError(`Diagnostic report failed (${response.status})`, retryable, Number.isFinite(retryAfterMs) ? Math.max(retryAfterMs, 0) : 0);
1736
+ }
1737
+ if (body?.report_id !== report.report_id) {
1738
+ throw new DiagnosticDeliveryError("Diagnostic report was not acknowledged");
1739
+ }
1740
+ };
1741
+ // The receipt body is part of the timeout too, not just the response headers.
1742
+ await Promise.race([upload(), timeoutExpired]);
1538
1743
  }
1539
1744
  finally {
1540
1745
  if (timeout !== undefined)
@@ -1608,10 +1813,11 @@ export class RealtimeSession {
1608
1813
  return;
1609
1814
  }
1610
1815
  this.agentStreamId = event.stream_id;
1816
+ this.agentDisconnected = false;
1611
1817
  const wasReady = this.agentReady;
1612
1818
  this.agentReady = true;
1613
- // connect/reconnect owns the transition after hello, audio and context
1614
- // restoration all finish. Readiness alone is not successful recovery.
1819
+ // connect/reconnect owns the transition after hello and audio are ready.
1820
+ // Context restoration proceeds independently in the background.
1615
1821
  this.readyResolve?.();
1616
1822
  if (!wasReady)
1617
1823
  this.notifyListeners(this.readyListeners, event);
@@ -1628,7 +1834,7 @@ export class RealtimeSession {
1628
1834
  if (event.type === "command.ack" && isCommandAckPayload(event.payload)) {
1629
1835
  this.recordCommandResult(pending, event.type, "acknowledged");
1630
1836
  globalThis.clearTimeout(pending.timeout);
1631
- globalThis.clearInterval(pending.retry);
1837
+ globalThis.clearTimeout(pending.retry);
1632
1838
  pending.markSettled();
1633
1839
  this.pendingCommands.delete(event.payload.message_id);
1634
1840
  pending.resolve(event.payload);
@@ -1637,7 +1843,7 @@ export class RealtimeSession {
1637
1843
  if (event.type === "context.updated" && isContextUpdatedPayload(event.payload)) {
1638
1844
  this.recordCommandResult(pending, event.type, "acknowledged");
1639
1845
  globalThis.clearTimeout(pending.timeout);
1640
- globalThis.clearInterval(pending.retry);
1846
+ globalThis.clearTimeout(pending.retry);
1641
1847
  pending.markSettled();
1642
1848
  this.pendingCommands.delete(event.payload.message_id);
1643
1849
  pending.resolve(event.payload);
@@ -1648,18 +1854,13 @@ export class RealtimeSession {
1648
1854
  this.recordCommandResult(pending, event.type, "rejected", event.payload.code);
1649
1855
  this.flushDiagnostics("command_rejected");
1650
1856
  if (event.type === "context.rejected" && event.payload.retryable) {
1651
- if (pending.retryTransfer && !pending.transferRetrying) {
1652
- pending.transferRetrying = true;
1653
- void pending.retryTransfer()
1654
- .catch(() => {
1655
- // The command timeout remains authoritative while reconnecting.
1656
- })
1657
- .finally(() => { pending.transferRetrying = false; });
1857
+ if (event.payload.code === "stream_unavailable" && pending.retryTransfer && !pending.transferRetrying) {
1858
+ pending.transferRetryRequested = true;
1658
1859
  }
1659
1860
  return;
1660
1861
  }
1661
1862
  globalThis.clearTimeout(pending.timeout);
1662
- globalThis.clearInterval(pending.retry);
1863
+ globalThis.clearTimeout(pending.retry);
1663
1864
  pending.markSettled();
1664
1865
  this.pendingCommands.delete(event.payload.message_id);
1665
1866
  pending.reject(new CommandError(event.payload));
@@ -1671,9 +1872,11 @@ export class RealtimeSession {
1671
1872
  continue;
1672
1873
  this.recordCommandResult(pending, pending.commandType === "context.update" ? "context.rejected" : "command.error", "failed", "pending_command_cancelled");
1673
1874
  globalThis.clearTimeout(pending.timeout);
1674
- globalThis.clearInterval(pending.retry);
1875
+ globalThis.clearTimeout(pending.retry);
1675
1876
  pending.markSettled();
1676
- pending.reject(error);
1877
+ pending.reject(pending.commandType === "context.update"
1878
+ ? new ContextDeliveryError(error.message, { cause: error, retryAfter: pending.transferSettled })
1879
+ : error);
1677
1880
  this.pendingCommands.delete(messageId);
1678
1881
  }
1679
1882
  }
@@ -1695,23 +1898,31 @@ export class RealtimeSession {
1695
1898
  if (durationMs > 2_000)
1696
1899
  this.flushDiagnostics("slow_operation");
1697
1900
  }
1698
- async fetchReplay(apiBase, afterSeq) {
1901
+ async fetchReplay(apiBase, afterSeq, options = {}) {
1902
+ const { signal, terminalOnly = false } = options;
1699
1903
  let nextAfter = afterSeq;
1700
1904
  while (true) {
1701
- let response = await this.fetcher(`${apiBase}/sessions/${this.id}/events?after_seq=${nextAfter}&limit=200`, {
1905
+ throwIfAborted(signal);
1906
+ const url = `${apiBase}/sessions/${this.id}/events?after_seq=${nextAfter}&limit=200`
1907
+ + (terminalOnly ? "&terminal_only=true" : "");
1908
+ let response = await raceWithAbort(this.fetcher(url, {
1702
1909
  headers: { Authorization: `Bearer ${this.accessToken}` },
1703
1910
  credentials: this.lifecycleCredentials,
1704
- });
1911
+ signal,
1912
+ }), signal);
1705
1913
  if (response.status === 401 && this.refreshCredentialsCallback) {
1706
- await this.refreshCredentials();
1707
- response = await this.fetcher(`${apiBase}/sessions/${this.id}/events?after_seq=${nextAfter}&limit=200`, {
1914
+ await raceWithAbort(this.refreshCredentials(), signal);
1915
+ response = await raceWithAbort(this.fetcher(url, {
1708
1916
  headers: { Authorization: `Bearer ${this.accessToken}` },
1709
1917
  credentials: this.lifecycleCredentials,
1710
- });
1918
+ signal,
1919
+ }), signal);
1711
1920
  }
1712
- if (!response.ok)
1713
- throw await httpError(response, "Could not replay session events");
1714
- const page = (await response.json());
1921
+ if (!response.ok) {
1922
+ throw await raceWithAbort(httpError(response, "Could not replay session events"), signal);
1923
+ }
1924
+ const page = (await raceWithAbort(response.json(), signal));
1925
+ throwIfAborted(signal);
1715
1926
  for (const stored of page.items) {
1716
1927
  nextAfter = Math.max(nextAfter, stored.server_seq);
1717
1928
  const event = {