@voxevolv/client 0.3.7 → 0.3.9

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
- import { ConnectionState as LiveKitConnectionState, DataPacket_Kind, DisconnectReason, Room, RoomEvent, RpcError, Track, } from "livekit-client";
1
+ import { ConnectionState as LiveKitConnectionState, DataPacket_Kind, DisconnectReason, LocalAudioTrack, 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.7";
9
+ export const SDK_VERSION = "0.3.9";
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;
@@ -99,9 +99,12 @@ export class RealtimeSession {
99
99
  startupConnectController = null;
100
100
  startupAttemptController = null;
101
101
  useManagedMicrophone = true;
102
- managedMicrophoneEnabled = true;
102
+ microphoneEnabled = true;
103
103
  applicationAudioTrack = null;
104
+ localApplicationAudioTrack = null;
104
105
  connectDefaults;
106
+ recoveryReadiness;
107
+ credentialRefreshGeneration = 0;
105
108
  fetcher;
106
109
  endEndpoint;
107
110
  refreshCredentialsCallback;
@@ -110,6 +113,10 @@ export class RealtimeSession {
110
113
  lifecycleCredentials;
111
114
  diagnostics = new ClientDiagnosticBuffer({ sdkVersion: SDK_VERSION });
112
115
  diagnosticReportTimeoutMs = DIAGNOSTIC_REPORT_TIMEOUT_MS;
116
+ onDiagnosticVisibilityChange = () => {
117
+ if (globalThis.document?.visibilityState === "hidden")
118
+ this.flushDiagnostics("page_hidden");
119
+ };
113
120
  constructor(credentials, options = {}) {
114
121
  validateSessionCredentials(credentials);
115
122
  this.id = credentials.id;
@@ -119,6 +126,10 @@ export class RealtimeSession {
119
126
  ? (options.connect ?? {})
120
127
  : { ...options.connect, apiBase: normalizeApiBase(options.connect.apiBase) };
121
128
  this.apiBase = this.connectDefaults.apiBase ?? this.apiBase;
129
+ this.recoveryReadiness = {
130
+ readyTimeoutMs: this.connectDefaults.readyTimeoutMs ?? 15_000,
131
+ waitForRemoteAudio: this.connectDefaults.waitForRemoteAudio ?? true,
132
+ };
122
133
  this.fetcher = options.fetch ?? globalThis.fetch.bind(globalThis);
123
134
  this.endEndpoint = options.endEndpoint ?? null;
124
135
  this.refreshCredentialsCallback = options.refreshCredentials ?? null;
@@ -330,6 +341,9 @@ export class RealtimeSession {
330
341
  };
331
342
  }
332
343
  async connect(options = {}) {
344
+ if (this.startupConnectController || this.manualRejoinController) {
345
+ throw new Error("A session connection attempt is already in progress");
346
+ }
333
347
  if (["connecting", "connected_not_ready", "connected", "reconnecting", "resuming"]
334
348
  .includes(this.stateValue)) {
335
349
  throw new Error(`Cannot connect while session is ${this.stateValue}`);
@@ -337,6 +351,17 @@ export class RealtimeSession {
337
351
  this.cancelRecovery();
338
352
  const controller = new AbortController();
339
353
  this.startupConnectController = controller;
354
+ this.credentialRefreshGeneration += 1;
355
+ const resolved = { ...this.connectDefaults, ...options };
356
+ // Keep caller preferences separate from temporary startup-attempt budgets.
357
+ this.recoveryReadiness = {
358
+ readyTimeoutMs: resolved.readyTimeoutMs ?? 15_000,
359
+ waitForRemoteAudio: resolved.waitForRemoteAudio ?? true,
360
+ };
361
+ this.microphoneEnabled = resolved.audioTrack
362
+ ? resolved.audioTrack.enabled !== false
363
+ : resolved.microphone ?? true;
364
+ this.setState("connecting");
340
365
  try {
341
366
  if (this.startupRetryOptions) {
342
367
  await this.connectWithStartupRetry(options, controller);
@@ -369,10 +394,13 @@ export class RealtimeSession {
369
394
  const startupOptions = this.startupRetryOptions;
370
395
  if (!startupOptions)
371
396
  return;
397
+ const startupStartedAt = performance.now();
372
398
  const timeout = globalThis.setTimeout(() => {
373
399
  controller.abort(new Error(`Session startup did not complete within ${startupOptions.timeoutMs}ms`));
374
400
  }, startupOptions.timeoutMs);
375
401
  let lastError = new Error("Session startup failed");
402
+ let startupRegistered = this.hasConnectedValue;
403
+ this.apiBase = normalizeApiBase(options.apiBase ?? this.connectDefaults.apiBase ?? this.apiBase);
376
404
  try {
377
405
  for (const [index, attempt] of startupOptions.attempts.entries()) {
378
406
  const attemptNumber = index + 1;
@@ -385,6 +413,12 @@ export class RealtimeSession {
385
413
  this.startupAttemptController = attemptController;
386
414
  this.notifyStartupAttempt("started", attemptNumber, attempt.readyTimeoutMs);
387
415
  try {
416
+ if (!startupRegistered) {
417
+ const remainingMs = startupOptions.timeoutMs - (performance.now() - startupStartedAt);
418
+ await this.registerStartupRecovery(remainingMs, attemptController.signal);
419
+ startupRegistered = true;
420
+ }
421
+ throwIfAborted(attemptController.signal);
388
422
  if (index === 0) {
389
423
  await this.connectWithMode({ ...options, readyTimeoutMs: attempt.readyTimeoutMs }, false, attemptController.signal, false);
390
424
  }
@@ -422,6 +456,38 @@ export class RealtimeSession {
422
456
  globalThis.clearTimeout(timeout);
423
457
  }
424
458
  }
459
+ async registerStartupRecovery(timeoutMs, signal) {
460
+ const reserveMs = Math.min(5_000, timeoutMs / 10);
461
+ const controller = createLinkedAbortController(signal);
462
+ const requestTimeout = globalThis.setTimeout(() => {
463
+ controller.abort(new Error("Startup registration timed out"));
464
+ }, Math.max(1, reserveMs));
465
+ try {
466
+ // Leave room for registration latency and final readiness before the
467
+ // browser's existing overall deadline. Rejoins never renew this window.
468
+ const send = () => raceWithAbort(this.fetcher(`${this.apiBase}/sessions/${this.id}/startup`, {
469
+ method: "POST",
470
+ headers: { Authorization: `Bearer ${this.accessToken}`, "Content-Type": "application/json" },
471
+ credentials: this.lifecycleCredentials,
472
+ body: JSON.stringify({ timeout_ms: Math.max(1, Math.floor(Math.min(timeoutMs - reserveMs, 85_000))) }),
473
+ signal: controller.signal,
474
+ }), controller.signal);
475
+ let response = await send();
476
+ if (response.status === 401 && this.refreshCredentialsCallback) {
477
+ await this.refreshCredentials(controller.signal);
478
+ response = await send();
479
+ }
480
+ if (!response.ok)
481
+ throw await httpError(response, "Could not register session startup");
482
+ const body = await raceWithAbort(response.json(), controller.signal);
483
+ if (typeof body.remaining_ms !== "number" || body.remaining_ms <= 0) {
484
+ throw new Error("Session startup window has expired");
485
+ }
486
+ }
487
+ finally {
488
+ globalThis.clearTimeout(requestTimeout);
489
+ }
490
+ }
425
491
  notifyStartupAttempt(status, attempt, readyTimeoutMs, error) {
426
492
  const maxAttempts = this.startupRetryOptions?.attempts.length ?? 1;
427
493
  this.notifyListeners(this.startupAttemptListeners, {
@@ -433,6 +499,7 @@ export class RealtimeSession {
433
499
  });
434
500
  }
435
501
  async connectWithMode(options, recovering, signal, markFailureTerminal = true) {
502
+ throwIfAborted(signal);
436
503
  const blockedStates = recovering
437
504
  ? ["connecting", "connected_not_ready", "connected", "reconnecting"]
438
505
  : ["connecting", "connected_not_ready", "connected", "reconnecting", "resuming"];
@@ -451,8 +518,6 @@ export class RealtimeSession {
451
518
  const apiBase = normalizeApiBase(resolved.apiBase ?? this.apiBase);
452
519
  this.validateAudioOptions(microphone, audioTrack);
453
520
  this.useManagedMicrophone = microphone;
454
- if (!recovering)
455
- this.managedMicrophoneEnabled = microphone;
456
521
  this.applicationAudioTrack = audioTrack;
457
522
  if (!recovering) {
458
523
  this.sessionEnded = false;
@@ -476,7 +541,7 @@ export class RealtimeSession {
476
541
  await raceWithAbort(this.publishApplicationAudioTrack(audioTrack), signal);
477
542
  }
478
543
  else if (microphone) {
479
- await raceWithAbort(this.room.localParticipant.setMicrophoneEnabled(this.managedMicrophoneEnabled), signal);
544
+ await raceWithAbort(this.room.localParticipant.setMicrophoneEnabled(this.microphoneEnabled), signal);
480
545
  }
481
546
  throwIfAborted(signal);
482
547
  this.setState(recovering ? "resuming" : "connected_not_ready");
@@ -489,7 +554,7 @@ export class RealtimeSession {
489
554
  if (this.sessionEnded || generation !== this.contextGeneration) {
490
555
  throw new Error("Connection restoration interrupted");
491
556
  }
492
- await raceWithAbort(this.replacementContext.resume(recovering), signal);
557
+ this.resumeContextDelivery(recovering);
493
558
  throwIfAborted(signal);
494
559
  if (this.sessionEnded || generation !== this.contextGeneration) {
495
560
  throw new Error("Connection restoration interrupted");
@@ -551,10 +616,13 @@ export class RealtimeSession {
551
616
  });
552
617
  }
553
618
  async rejoin(apiBase = this.apiBase) {
619
+ if (this.startupConnectController || this.manualRejoinController) {
620
+ throw new Error("A session connection attempt is already in progress");
621
+ }
554
622
  if (this.sessionEnded) {
555
623
  throw this.terminalSessionError ?? new Error("Cannot rejoin a session that has ended");
556
624
  }
557
- if (["connecting", "reconnecting", "resuming"].includes(this.stateValue)) {
625
+ if (["connecting", "connected_not_ready", "reconnecting", "resuming"].includes(this.stateValue)) {
558
626
  throw new Error(`Cannot rejoin while session is ${this.stateValue}`);
559
627
  }
560
628
  this.cancelRecovery();
@@ -589,13 +657,13 @@ export class RealtimeSession {
589
657
  }
590
658
  }
591
659
  async performRejoin(apiBase, options = {}) {
592
- const { signal, recovering = true, connect = {}, markFailureTerminal = true, } = options;
660
+ const { signal, recovering = true, connect, markFailureTerminal = true, } = options;
593
661
  this.contextGeneration += 1;
594
662
  this.replacementContext.pause();
595
663
  this.rejectPendingCommands(new Error("Restoring the session"));
596
664
  let response = await this.fetchRoomToken(apiBase, signal);
597
665
  if (response.status === 401 && this.refreshCredentialsCallback) {
598
- await raceWithAbort(this.refreshCredentials(), signal);
666
+ await this.refreshCredentials(signal);
599
667
  throwIfAborted(signal);
600
668
  response = await this.fetchRoomToken(apiBase, signal);
601
669
  }
@@ -611,6 +679,7 @@ export class RealtimeSession {
611
679
  throw await raceWithAbort(httpError(response, "Could not renew room token"), signal);
612
680
  }
613
681
  const payload = (await raceWithAbort(response.json(), signal));
682
+ throwIfAborted(signal);
614
683
  this.credentials = payload.connection;
615
684
  if (this.room.state !== LiveKitConnectionState.Disconnected) {
616
685
  this.disconnectMode = "rejoin";
@@ -629,16 +698,28 @@ export class RealtimeSession {
629
698
  duration_ms: Date.now() - this.reconnectStartedAt,
630
699
  };
631
700
  }
632
- await this.connectWithMode({
633
- ...connect,
634
- apiBase,
701
+ // Startup retries resolve the original caller options with constructor defaults.
702
+ // A later rejoin restores the audio settings saved by the previous connection.
703
+ const connectionOptions = connect ?? {
704
+ ...this.recoveryReadiness,
635
705
  microphone: this.useManagedMicrophone,
636
706
  ...(this.applicationAudioTrack ? { audioTrack: this.applicationAudioTrack } : {}),
637
- }, recovering, signal, markFailureTerminal);
707
+ };
708
+ await this.connectWithMode({ ...connectionOptions, apiBase }, recovering, signal, markFailureTerminal);
638
709
  }
639
710
  async setMicrophoneEnabled(enabled) {
711
+ // Recovery must see the user's latest intent while the media operation waits.
712
+ this.microphoneEnabled = enabled;
713
+ if (this.applicationAudioTrack) {
714
+ // The room may not know this track yet while publication is pending.
715
+ const track = this.localApplicationAudioTrack;
716
+ if (enabled)
717
+ await track?.unmute();
718
+ else
719
+ await track?.mute();
720
+ return;
721
+ }
640
722
  await this.room.localParticipant.setMicrophoneEnabled(enabled);
641
- this.managedMicrophoneEnabled = enabled;
642
723
  }
643
724
  async sendCommand(type, payload, timeoutMs = 10_000) {
644
725
  const message = createClientMessage(this.connectionId, ++this.clientSeq, type, payload);
@@ -665,11 +746,15 @@ export class RealtimeSession {
665
746
  context_key: update.key,
666
747
  payload_bytes: payloadBytes,
667
748
  });
749
+ const uploading = "image" in update ? this.uploadImageContext(update) : this.uploadTextContext(update);
750
+ const transferSettled = uploading.then(() => { }, () => { });
668
751
  let transferTimeout;
669
752
  const transferExpired = new Promise((_resolve, reject) => {
670
753
  transferTimeout = globalThis.setTimeout(() => {
671
754
  transferTimedOut = true;
672
- const error = new Error(`Context transfer timed out after ${timeoutMs}ms`);
755
+ const error = new ContextDeliveryError(`Context transfer timed out after ${timeoutMs}ms`, {
756
+ retryAfter: transferSettled,
757
+ });
673
758
  this.recordDiagnostic({
674
759
  operation: "context_transfer",
675
760
  status: "timed_out",
@@ -680,19 +765,13 @@ export class RealtimeSession {
680
765
  timeout_ms: timeoutMs,
681
766
  });
682
767
  this.flushDiagnostics("context_transfer_timeout");
683
- this.startAutoRejoin(error, {
684
- trigger: "context_transfer_timeout",
685
- operation: "context_transfer",
686
- contextKey: update.key,
687
- timeoutMs,
688
- });
689
768
  reject(error);
690
769
  }, timeoutMs);
691
770
  });
692
771
  let transfer;
693
772
  try {
694
773
  transfer = await Promise.race([
695
- "image" in update ? this.uploadImageContext(update) : this.uploadTextContext(update),
774
+ uploading,
696
775
  transferExpired,
697
776
  ]);
698
777
  }
@@ -708,7 +787,9 @@ export class RealtimeSession {
708
787
  });
709
788
  this.flushDiagnostics("command_failed");
710
789
  }
711
- throw error;
790
+ if (error instanceof TypeError || error instanceof ContextDeliveryError)
791
+ throw error;
792
+ throw new ContextDeliveryError(toError(error).message, { cause: error });
712
793
  }
713
794
  finally {
714
795
  globalThis.clearTimeout(transferTimeout);
@@ -732,6 +813,11 @@ export class RealtimeSession {
732
813
  const message = createClientMessage(this.connectionId, ++this.clientSeq, "context.update", { ...payload });
733
814
  return this.sendTrackedCommand(message, timeoutMs, transfer.resend);
734
815
  }
816
+ resumeContextDelivery(restore) {
817
+ // Audio readiness owns connection recovery. Context delivery has its own
818
+ // deadlines and retries, and may be interrupted by a later disconnect.
819
+ void this.replacementContext.resume(restore).catch(() => { });
820
+ }
735
821
  async uploadTextContext(update) {
736
822
  const data = new TextEncoder().encode(update.text);
737
823
  if (data.byteLength === 0)
@@ -817,6 +903,7 @@ export class RealtimeSession {
817
903
  this.failRecovery(new Error("Session disconnected during recovery"));
818
904
  this.cancelRecovery();
819
905
  this.contextGeneration += 1;
906
+ this.localApplicationAudioTrack = null;
820
907
  this.replacementContext.clear(new Error("Session disconnected"));
821
908
  if (this.stateValue === "disconnected")
822
909
  return;
@@ -832,6 +919,7 @@ export class RealtimeSession {
832
919
  }
833
920
  }
834
921
  async end(options = {}) {
922
+ this.credentialRefreshGeneration += 1;
835
923
  this.abortStartupConnect(new Error("Session ended"));
836
924
  this.startupConnectController = null;
837
925
  this.failRecovery(new Error("Session ended during recovery"));
@@ -851,7 +939,7 @@ export class RealtimeSession {
851
939
  credentials: request.credentials ?? this.lifecycleCredentials,
852
940
  });
853
941
  if (response.status === 401 && usesScopedSessionEndpoint && this.refreshCredentialsCallback) {
854
- await this.refreshCredentials();
942
+ await this.refreshCredentials(request.signal ?? undefined);
855
943
  headers.set("Authorization", `Bearer ${this.accessToken}`);
856
944
  response = await this.fetcher(endpoint, {
857
945
  ...request,
@@ -878,6 +966,17 @@ export class RealtimeSession {
878
966
  if (this.stateValue === state)
879
967
  return;
880
968
  this.stateValue = state;
969
+ if (state === "connecting" || state === "resuming") {
970
+ globalThis.document?.addEventListener?.("visibilitychange", this.onDiagnosticVisibilityChange);
971
+ }
972
+ if (state === "disconnected" || state === "failed") {
973
+ globalThis.document?.removeEventListener?.("visibilitychange", this.onDiagnosticVisibilityChange);
974
+ this.flushDiagnostics("session_finished");
975
+ const error = new Error(`Session ${state}`);
976
+ this.contextGeneration += 1;
977
+ this.replacementContext.clear(error);
978
+ this.rejectPendingCommands(error);
979
+ }
881
980
  this.notifyListeners(this.stateListeners, state);
882
981
  }
883
982
  bindRoomEvents() {
@@ -1026,13 +1125,13 @@ export class RealtimeSession {
1026
1125
  this.setState(this.hasConnectedValue ? "resuming" : "connected_not_ready");
1027
1126
  try {
1028
1127
  await this.sendHello();
1029
- const deadline = Date.now() + (this.connectDefaults.readyTimeoutMs ?? 15_000);
1128
+ const deadline = Date.now() + this.recoveryReadiness.readyTimeoutMs;
1030
1129
  await this.waitForAgentReady(Math.max(deadline - Date.now(), 0));
1031
- if (this.connectDefaults.waitForRemoteAudio !== false)
1130
+ if (this.recoveryReadiness.waitForRemoteAudio)
1032
1131
  await this.waitForRemoteAudioTrack(deadline);
1033
1132
  if (this.sessionEnded || generation !== this.contextGeneration)
1034
1133
  return;
1035
- await this.replacementContext.resume(true);
1134
+ this.resumeContextDelivery(true);
1036
1135
  if (this.sessionEnded || generation !== this.contextGeneration)
1037
1136
  return;
1038
1137
  this.setState("connected");
@@ -1311,10 +1410,18 @@ export class RealtimeSession {
1311
1410
  signal?.removeEventListener("abort", cancel);
1312
1411
  }
1313
1412
  }
1314
- async refreshCredentials() {
1413
+ async refreshCredentials(signal) {
1315
1414
  if (!this.refreshCredentialsCallback)
1316
1415
  return;
1317
- const refreshed = await this.refreshCredentialsCallback();
1416
+ throwIfAborted(signal);
1417
+ const connectionGeneration = this.contextGeneration;
1418
+ const refreshGeneration = ++this.credentialRefreshGeneration;
1419
+ const refreshed = await raceWithAbort(this.refreshCredentialsCallback(), signal);
1420
+ throwIfAborted(signal);
1421
+ if (connectionGeneration !== this.contextGeneration
1422
+ || refreshGeneration !== this.credentialRefreshGeneration) {
1423
+ throw new Error("Credential refresh was superseded");
1424
+ }
1318
1425
  validateSessionCredentials(refreshed);
1319
1426
  if (refreshed.id !== this.id) {
1320
1427
  throw new Error("Refreshed credentials belong to a different session");
@@ -1409,15 +1516,26 @@ export class RealtimeSession {
1409
1516
  }
1410
1517
  }
1411
1518
  async publishApplicationAudioTrack(audioTrack) {
1412
- const publishedTrack = audioTrack.clone();
1519
+ const clonedTrack = audioTrack.clone();
1520
+ let publishedTrack = null;
1413
1521
  try {
1522
+ publishedTrack = new LocalAudioTrack(clonedTrack, undefined, true);
1523
+ this.localApplicationAudioTrack = publishedTrack;
1524
+ if (!this.microphoneEnabled) {
1525
+ // LiveKit can re-enable a raw track when wrapping it. Mute the wrapper
1526
+ // before publication so recovery cannot briefly transmit muted input.
1527
+ await publishedTrack.mute();
1528
+ }
1414
1529
  await this.room.localParticipant.publishTrack(publishedTrack, {
1415
1530
  source: Track.Source.Microphone,
1416
1531
  name: "voxevolv-application-audio",
1417
1532
  });
1418
1533
  }
1419
1534
  catch (error) {
1420
- publishedTrack.stop();
1535
+ if (this.localApplicationAudioTrack === publishedTrack) {
1536
+ this.localApplicationAudioTrack = null;
1537
+ }
1538
+ clonedTrack.stop();
1421
1539
  throw error;
1422
1540
  }
1423
1541
  }
@@ -1456,10 +1574,13 @@ export class RealtimeSession {
1456
1574
  const timeout = globalThis.setTimeout(() => {
1457
1575
  const pending = this.pendingCommands.get(message.message_id);
1458
1576
  if (pending)
1459
- globalThis.clearInterval(pending.retry);
1577
+ globalThis.clearTimeout(pending.retry);
1460
1578
  pending?.markSettled();
1461
1579
  this.pendingCommands.delete(message.message_id);
1462
- const error = new Error(`Command ${message.type} timed out after ${timeoutMs}ms`);
1580
+ const detail = `Command ${message.type} timed out after ${timeoutMs}ms`;
1581
+ const error = message.type === "context.update"
1582
+ ? new ContextDeliveryError(detail, { retryAfter: pending?.transferSettled })
1583
+ : new Error(detail);
1463
1584
  this.recordDiagnostic({
1464
1585
  operation: "command_send",
1465
1586
  status: "timed_out",
@@ -1478,21 +1599,7 @@ export class RealtimeSession {
1478
1599
  });
1479
1600
  this.flushDiagnostics("command_timeout");
1480
1601
  reject(error);
1481
- if (message.type === "context.update") {
1482
- this.startAutoRejoin(error, {
1483
- trigger: "context_command_timeout",
1484
- operation: "command_send",
1485
- messageId: message.message_id,
1486
- contextKey: contextKeyFromMessage(message),
1487
- timeoutMs,
1488
- });
1489
- }
1490
1602
  }, timeoutMs);
1491
- const retry = globalThis.setInterval(() => {
1492
- void this.publishCommandAttempt(message, true).catch(() => {
1493
- // A reconnect may temporarily reject writes; retry until timeout.
1494
- });
1495
- }, 500);
1496
1603
  this.pendingCommands.set(message.message_id, {
1497
1604
  commandType: message.type,
1498
1605
  message,
@@ -1504,25 +1611,58 @@ export class RealtimeSession {
1504
1611
  resolve,
1505
1612
  reject,
1506
1613
  timeout,
1507
- retry,
1508
1614
  settled,
1509
1615
  markSettled,
1510
- retryTransfer: retryTransfer
1511
- ? async () => {
1512
- await retryTransfer();
1513
- await this.publishCommandAttempt(message, true);
1514
- }
1515
- : undefined,
1616
+ retryTransfer,
1516
1617
  transferRetrying: false,
1618
+ transferRetryRequested: false,
1517
1619
  });
1620
+ this.scheduleCommandRetry(message);
1518
1621
  });
1519
1622
  void this.publishCommandAttempt(message, false).catch(() => {
1520
1623
  // The reliable data channel may still be warming immediately after a
1521
- // reconnect. Keep the command pending so the retry interval can publish
1624
+ // reconnect. Keep the command pending so the retry timer can publish
1522
1625
  // it once the transport accepts writes, or reject at the normal timeout.
1523
1626
  });
1524
1627
  return response;
1525
1628
  }
1629
+ scheduleCommandRetry(message) {
1630
+ const pending = this.pendingCommands.get(message.message_id);
1631
+ if (!pending)
1632
+ return;
1633
+ const delayMs = message.type === "context.update"
1634
+ ? contextRetryDelay(pending.retryCount + 1)
1635
+ : 500;
1636
+ pending.retry = globalThis.setTimeout(() => {
1637
+ void this.retryCommand(message).finally(() => this.scheduleCommandRetry(message));
1638
+ }, delayMs);
1639
+ }
1640
+ async retryCommand(message) {
1641
+ const pending = this.pendingCommands.get(message.message_id);
1642
+ if (!pending)
1643
+ return;
1644
+ if (message.type === "context.update" && (this.sessionEnded || ["reconnecting", "resuming", "disconnected", "failed"].includes(this.stateValue)))
1645
+ return;
1646
+ try {
1647
+ if (pending.transferRetryRequested && pending.retryTransfer) {
1648
+ pending.transferRetryRequested = false;
1649
+ pending.transferRetrying = true;
1650
+ const transfer = pending.retryTransfer();
1651
+ pending.transferSettled = transfer.then(() => { }, () => { });
1652
+ if (pending.contextKey) {
1653
+ this.replacementContext.blockUntilTransferSettles(pending.contextKey, pending.transferSettled);
1654
+ }
1655
+ await Promise.race([transfer, pending.settled]);
1656
+ }
1657
+ await this.publishCommandAttempt(message, true);
1658
+ }
1659
+ catch {
1660
+ // Failed writes retain their original deadline and retry with backoff.
1661
+ }
1662
+ finally {
1663
+ pending.transferRetrying = false;
1664
+ }
1665
+ }
1526
1666
  publishCommandAttempt(message, retrying) {
1527
1667
  const pending = this.pendingCommands.get(message.message_id);
1528
1668
  if (!pending || pending.publishQueued)
@@ -1632,8 +1772,8 @@ export class RealtimeSession {
1632
1772
  }, this.diagnosticReportTimeoutMs);
1633
1773
  });
1634
1774
  try {
1635
- const response = await Promise.race([
1636
- this.fetcher(`${this.apiBase}/sessions/${this.id}/diagnostics`, {
1775
+ const upload = async () => {
1776
+ const response = await this.fetcher(`${this.apiBase}/sessions/${this.id}/diagnostics`, {
1637
1777
  method: "POST",
1638
1778
  headers: {
1639
1779
  Authorization: `Bearer ${this.accessToken}`,
@@ -1642,11 +1782,22 @@ export class RealtimeSession {
1642
1782
  credentials: this.lifecycleCredentials,
1643
1783
  body: JSON.stringify(report),
1644
1784
  signal: controller.signal,
1645
- }),
1646
- timeoutExpired,
1647
- ]);
1648
- if (!response.ok)
1649
- throw new Error(`Diagnostic report failed (${response.status})`);
1785
+ keepalive: true,
1786
+ });
1787
+ const body = await response.json().catch(() => null);
1788
+ if (!response.ok) {
1789
+ const retryable = response.status === 408 || response.status === 429 || response.status >= 500;
1790
+ const headerDelay = Number(response.headers.get("Retry-After")) * 1000;
1791
+ const retryAfterMs = typeof body?.retry_after_ms === "number"
1792
+ ? body.retry_after_ms : headerDelay;
1793
+ throw new DiagnosticDeliveryError(`Diagnostic report failed (${response.status})`, retryable, Number.isFinite(retryAfterMs) ? Math.max(retryAfterMs, 0) : 0);
1794
+ }
1795
+ if (body?.report_id !== report.report_id) {
1796
+ throw new DiagnosticDeliveryError("Diagnostic report was not acknowledged");
1797
+ }
1798
+ };
1799
+ // The receipt body is part of the timeout too, not just the response headers.
1800
+ await Promise.race([upload(), timeoutExpired]);
1650
1801
  }
1651
1802
  finally {
1652
1803
  if (timeout !== undefined)
@@ -1723,8 +1874,8 @@ export class RealtimeSession {
1723
1874
  this.agentDisconnected = false;
1724
1875
  const wasReady = this.agentReady;
1725
1876
  this.agentReady = true;
1726
- // connect/reconnect owns the transition after hello, audio and context
1727
- // restoration all finish. Readiness alone is not successful recovery.
1877
+ // connect/reconnect owns the transition after hello and audio are ready.
1878
+ // Context restoration proceeds independently in the background.
1728
1879
  this.readyResolve?.();
1729
1880
  if (!wasReady)
1730
1881
  this.notifyListeners(this.readyListeners, event);
@@ -1741,7 +1892,7 @@ export class RealtimeSession {
1741
1892
  if (event.type === "command.ack" && isCommandAckPayload(event.payload)) {
1742
1893
  this.recordCommandResult(pending, event.type, "acknowledged");
1743
1894
  globalThis.clearTimeout(pending.timeout);
1744
- globalThis.clearInterval(pending.retry);
1895
+ globalThis.clearTimeout(pending.retry);
1745
1896
  pending.markSettled();
1746
1897
  this.pendingCommands.delete(event.payload.message_id);
1747
1898
  pending.resolve(event.payload);
@@ -1750,7 +1901,7 @@ export class RealtimeSession {
1750
1901
  if (event.type === "context.updated" && isContextUpdatedPayload(event.payload)) {
1751
1902
  this.recordCommandResult(pending, event.type, "acknowledged");
1752
1903
  globalThis.clearTimeout(pending.timeout);
1753
- globalThis.clearInterval(pending.retry);
1904
+ globalThis.clearTimeout(pending.retry);
1754
1905
  pending.markSettled();
1755
1906
  this.pendingCommands.delete(event.payload.message_id);
1756
1907
  pending.resolve(event.payload);
@@ -1761,18 +1912,13 @@ export class RealtimeSession {
1761
1912
  this.recordCommandResult(pending, event.type, "rejected", event.payload.code);
1762
1913
  this.flushDiagnostics("command_rejected");
1763
1914
  if (event.type === "context.rejected" && event.payload.retryable) {
1764
- if (pending.retryTransfer && !pending.transferRetrying) {
1765
- pending.transferRetrying = true;
1766
- void pending.retryTransfer()
1767
- .catch(() => {
1768
- // The command timeout remains authoritative while reconnecting.
1769
- })
1770
- .finally(() => { pending.transferRetrying = false; });
1915
+ if (event.payload.code === "stream_unavailable" && pending.retryTransfer && !pending.transferRetrying) {
1916
+ pending.transferRetryRequested = true;
1771
1917
  }
1772
1918
  return;
1773
1919
  }
1774
1920
  globalThis.clearTimeout(pending.timeout);
1775
- globalThis.clearInterval(pending.retry);
1921
+ globalThis.clearTimeout(pending.retry);
1776
1922
  pending.markSettled();
1777
1923
  this.pendingCommands.delete(event.payload.message_id);
1778
1924
  pending.reject(new CommandError(event.payload));
@@ -1784,9 +1930,11 @@ export class RealtimeSession {
1784
1930
  continue;
1785
1931
  this.recordCommandResult(pending, pending.commandType === "context.update" ? "context.rejected" : "command.error", "failed", "pending_command_cancelled");
1786
1932
  globalThis.clearTimeout(pending.timeout);
1787
- globalThis.clearInterval(pending.retry);
1933
+ globalThis.clearTimeout(pending.retry);
1788
1934
  pending.markSettled();
1789
- pending.reject(error);
1935
+ pending.reject(pending.commandType === "context.update"
1936
+ ? new ContextDeliveryError(error.message, { cause: error, retryAfter: pending.transferSettled })
1937
+ : error);
1790
1938
  this.pendingCommands.delete(messageId);
1791
1939
  }
1792
1940
  }
@@ -1821,7 +1969,7 @@ export class RealtimeSession {
1821
1969
  signal,
1822
1970
  }), signal);
1823
1971
  if (response.status === 401 && this.refreshCredentialsCallback) {
1824
- await raceWithAbort(this.refreshCredentials(), signal);
1972
+ await this.refreshCredentials(signal);
1825
1973
  response = await raceWithAbort(this.fetcher(url, {
1826
1974
  headers: { Authorization: `Bearer ${this.accessToken}` },
1827
1975
  credentials: this.lifecycleCredentials,