@voxevolv/client 0.3.8 → 0.3.10

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,4 +1,4 @@
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
3
  import { ContextDeliveryError, contextRetryDelay, ReplacementContextQueue } from "./context-queue.js";
4
4
  import { ClientDiagnosticBuffer, DiagnosticDeliveryError, } from "./diagnostics.js";
@@ -6,7 +6,7 @@ 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.8";
9
+ export const SDK_VERSION = "0.3.10";
10
10
  const CLIENT_HELLO_TIMEOUT_MS = 30_000;
11
11
  const DIAGNOSTIC_REPORT_TIMEOUT_MS = 5_000;
12
12
  const DEFAULT_STARTUP_ATTEMPTS = [
@@ -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;
@@ -109,6 +112,11 @@ export class RealtimeSession {
109
112
  startupRetryOptions;
110
113
  lifecycleCredentials;
111
114
  diagnostics = new ClientDiagnosticBuffer({ sdkVersion: SDK_VERSION });
115
+ activeContextTransfers = 0;
116
+ activeContextBytes = 0;
117
+ diagnosticLoopTimer;
118
+ diagnosticLoopExpectedAt = 0;
119
+ diagnosticLoopDelayMs;
112
120
  diagnosticReportTimeoutMs = DIAGNOSTIC_REPORT_TIMEOUT_MS;
113
121
  onDiagnosticVisibilityChange = () => {
114
122
  if (globalThis.document?.visibilityState === "hidden")
@@ -123,6 +131,10 @@ export class RealtimeSession {
123
131
  ? (options.connect ?? {})
124
132
  : { ...options.connect, apiBase: normalizeApiBase(options.connect.apiBase) };
125
133
  this.apiBase = this.connectDefaults.apiBase ?? this.apiBase;
134
+ this.recoveryReadiness = {
135
+ readyTimeoutMs: this.connectDefaults.readyTimeoutMs ?? 15_000,
136
+ waitForRemoteAudio: this.connectDefaults.waitForRemoteAudio ?? true,
137
+ };
126
138
  this.fetcher = options.fetch ?? globalThis.fetch.bind(globalThis);
127
139
  this.endEndpoint = options.endEndpoint ?? null;
128
140
  this.refreshCredentialsCallback = options.refreshCredentials ?? null;
@@ -133,7 +145,34 @@ export class RealtimeSession {
133
145
  for (const [name, handler] of Object.entries(options.tools ?? {})) {
134
146
  this.registerTool(name, handler);
135
147
  }
136
- this.room.registerRpcMethod("voxevolv.tool.invoke", async (invocation) => {
148
+ this.room.registerRpcMethod("voxevolv.tool.invoke", (invocation) => this.invokeBrowserTool(invocation));
149
+ this.room.registerRpcMethod("voxevolv.diagnostics.flush", async (invocation) => {
150
+ const payload = JSON.parse(invocation.payload);
151
+ if (typeof payload?.call_id !== "string" || payload.call_id.length > 200) {
152
+ throw new RpcError(1500, "Invalid diagnostic request");
153
+ }
154
+ this.recordDiagnostic({
155
+ operation: "tool_rpc", status: "started", stage: "flush_requested",
156
+ call_id: payload.call_id, rpc_request_id: invocation.requestId,
157
+ });
158
+ // HTTP delivery has its own retry/receipt handling; never await it on the RPC channel.
159
+ this.flushDiagnostics("tool_rpc_worker_request");
160
+ return "{}";
161
+ });
162
+ this.bindRoomEvents();
163
+ }
164
+ async invokeBrowserTool(invocation) {
165
+ const receivedAt = performance.now();
166
+ let callId;
167
+ let toolName;
168
+ let slowTimer;
169
+ const record = (entry) => this.recordDiagnostic({
170
+ operation: "tool_rpc", status: "started", rpc_request_id: invocation.requestId,
171
+ call_id: callId, tool_name: toolName,
172
+ duration_ms: performance.now() - receivedAt, ...entry,
173
+ });
174
+ record({ stage: "received" });
175
+ try {
137
176
  let payload;
138
177
  try {
139
178
  payload = JSON.parse(invocation.payload);
@@ -141,32 +180,47 @@ export class RealtimeSession {
141
180
  catch (error) {
142
181
  throw new RpcError(1500, "Tool invocation payload was not valid JSON", undefined, { cause: error });
143
182
  }
144
- if (typeof payload.name !== "string") {
183
+ if (typeof payload?.name !== "string") {
145
184
  throw new RpcError(1500, "Tool invocation did not include a valid name");
146
185
  }
147
- const handler = this.toolHandlers.get(payload.name);
186
+ toolName = payload.name;
187
+ callId = typeof payload.call_id === "string" ? payload.call_id : invocation.requestId;
188
+ const handler = this.toolHandlers.get(toolName);
148
189
  if (!handler)
149
- throw new RpcError(1500, `No browser handler is registered for ${payload.name}`);
190
+ throw new RpcError(1500, `No browser handler is registered for ${toolName}`);
150
191
  if (!payload.arguments || typeof payload.arguments !== "object" || Array.isArray(payload.arguments)) {
151
192
  throw new RpcError(1500, "Tool invocation arguments must be an object");
152
193
  }
153
- const callId = typeof payload.call_id === "string" ? payload.call_id : invocation.requestId;
154
194
  const arguments_ = payload.arguments;
155
- this.notifyListeners(this.toolCallListeners, {
156
- name: payload.name,
157
- callId,
158
- arguments: arguments_,
159
- });
195
+ this.notifyListeners(this.toolCallListeners, { name: toolName, callId, arguments: arguments_ });
196
+ record({ stage: "handler_started" });
197
+ this.flushDiagnostics("tool_rpc_received");
198
+ slowTimer = globalThis.setTimeout(() => {
199
+ record({ stage: "handler_started", reason: "handler_slow" });
200
+ this.flushDiagnostics("tool_rpc_slow");
201
+ }, 2_000);
202
+ let response;
160
203
  try {
161
204
  const result = await handler(arguments_, { callId });
162
- return JSON.stringify(result ?? null);
205
+ response = JSON.stringify(result ?? null);
163
206
  }
164
207
  catch (error) {
165
208
  const message = error instanceof Error ? error.message : "Browser tool handler failed";
166
209
  throw new RpcError(1500, message, undefined, { cause: error });
167
210
  }
168
- });
169
- this.bindRoomEvents();
211
+ // LiveKit sends the response after this callback returns. This is readiness,
212
+ // not proof of a network send or acknowledgement by the worker.
213
+ record({ stage: "response_ready", status: "succeeded" });
214
+ return response;
215
+ }
216
+ catch (error) {
217
+ record({ status: "failed", rpc_error_code: error instanceof RpcError ? error.code : undefined });
218
+ throw error;
219
+ }
220
+ finally {
221
+ globalThis.clearTimeout(slowTimer);
222
+ this.flushDiagnostics("tool_rpc_finished");
223
+ }
170
224
  }
171
225
  static async create(options) {
172
226
  if ("connect" in options) {
@@ -334,6 +388,9 @@ export class RealtimeSession {
334
388
  };
335
389
  }
336
390
  async connect(options = {}) {
391
+ if (this.startupConnectController || this.manualRejoinController) {
392
+ throw new Error("A session connection attempt is already in progress");
393
+ }
337
394
  if (["connecting", "connected_not_ready", "connected", "reconnecting", "resuming"]
338
395
  .includes(this.stateValue)) {
339
396
  throw new Error(`Cannot connect while session is ${this.stateValue}`);
@@ -341,6 +398,17 @@ export class RealtimeSession {
341
398
  this.cancelRecovery();
342
399
  const controller = new AbortController();
343
400
  this.startupConnectController = controller;
401
+ this.credentialRefreshGeneration += 1;
402
+ const resolved = { ...this.connectDefaults, ...options };
403
+ // Keep caller preferences separate from temporary startup-attempt budgets.
404
+ this.recoveryReadiness = {
405
+ readyTimeoutMs: resolved.readyTimeoutMs ?? 15_000,
406
+ waitForRemoteAudio: resolved.waitForRemoteAudio ?? true,
407
+ };
408
+ this.microphoneEnabled = resolved.audioTrack
409
+ ? resolved.audioTrack.enabled !== false
410
+ : resolved.microphone ?? true;
411
+ this.setState("connecting");
344
412
  try {
345
413
  if (this.startupRetryOptions) {
346
414
  await this.connectWithStartupRetry(options, controller);
@@ -397,6 +465,7 @@ export class RealtimeSession {
397
465
  await this.registerStartupRecovery(remainingMs, attemptController.signal);
398
466
  startupRegistered = true;
399
467
  }
468
+ throwIfAborted(attemptController.signal);
400
469
  if (index === 0) {
401
470
  await this.connectWithMode({ ...options, readyTimeoutMs: attempt.readyTimeoutMs }, false, attemptController.signal, false);
402
471
  }
@@ -439,7 +508,7 @@ export class RealtimeSession {
439
508
  const controller = createLinkedAbortController(signal);
440
509
  const requestTimeout = globalThis.setTimeout(() => {
441
510
  controller.abort(new Error("Startup registration timed out"));
442
- }, Math.max(1, Math.min(3_000, reserveMs)));
511
+ }, Math.max(1, reserveMs));
443
512
  try {
444
513
  // Leave room for registration latency and final readiness before the
445
514
  // browser's existing overall deadline. Rejoins never renew this window.
@@ -452,7 +521,7 @@ export class RealtimeSession {
452
521
  }), controller.signal);
453
522
  let response = await send();
454
523
  if (response.status === 401 && this.refreshCredentialsCallback) {
455
- await raceWithAbort(this.refreshCredentials(), controller.signal);
524
+ await this.refreshCredentials(controller.signal);
456
525
  response = await send();
457
526
  }
458
527
  if (!response.ok)
@@ -477,6 +546,7 @@ export class RealtimeSession {
477
546
  });
478
547
  }
479
548
  async connectWithMode(options, recovering, signal, markFailureTerminal = true) {
549
+ throwIfAborted(signal);
480
550
  const blockedStates = recovering
481
551
  ? ["connecting", "connected_not_ready", "connected", "reconnecting"]
482
552
  : ["connecting", "connected_not_ready", "connected", "reconnecting", "resuming"];
@@ -495,8 +565,6 @@ export class RealtimeSession {
495
565
  const apiBase = normalizeApiBase(resolved.apiBase ?? this.apiBase);
496
566
  this.validateAudioOptions(microphone, audioTrack);
497
567
  this.useManagedMicrophone = microphone;
498
- if (!recovering)
499
- this.managedMicrophoneEnabled = microphone;
500
568
  this.applicationAudioTrack = audioTrack;
501
569
  if (!recovering) {
502
570
  this.sessionEnded = false;
@@ -520,7 +588,7 @@ export class RealtimeSession {
520
588
  await raceWithAbort(this.publishApplicationAudioTrack(audioTrack), signal);
521
589
  }
522
590
  else if (microphone) {
523
- await raceWithAbort(this.room.localParticipant.setMicrophoneEnabled(this.managedMicrophoneEnabled), signal);
591
+ await raceWithAbort(this.room.localParticipant.setMicrophoneEnabled(this.microphoneEnabled), signal);
524
592
  }
525
593
  throwIfAborted(signal);
526
594
  this.setState(recovering ? "resuming" : "connected_not_ready");
@@ -595,10 +663,13 @@ export class RealtimeSession {
595
663
  });
596
664
  }
597
665
  async rejoin(apiBase = this.apiBase) {
666
+ if (this.startupConnectController || this.manualRejoinController) {
667
+ throw new Error("A session connection attempt is already in progress");
668
+ }
598
669
  if (this.sessionEnded) {
599
670
  throw this.terminalSessionError ?? new Error("Cannot rejoin a session that has ended");
600
671
  }
601
- if (["connecting", "reconnecting", "resuming"].includes(this.stateValue)) {
672
+ if (["connecting", "connected_not_ready", "reconnecting", "resuming"].includes(this.stateValue)) {
602
673
  throw new Error(`Cannot rejoin while session is ${this.stateValue}`);
603
674
  }
604
675
  this.cancelRecovery();
@@ -633,13 +704,13 @@ export class RealtimeSession {
633
704
  }
634
705
  }
635
706
  async performRejoin(apiBase, options = {}) {
636
- const { signal, recovering = true, connect = {}, markFailureTerminal = true, } = options;
707
+ const { signal, recovering = true, connect, markFailureTerminal = true, } = options;
637
708
  this.contextGeneration += 1;
638
709
  this.replacementContext.pause();
639
710
  this.rejectPendingCommands(new Error("Restoring the session"));
640
711
  let response = await this.fetchRoomToken(apiBase, signal);
641
712
  if (response.status === 401 && this.refreshCredentialsCallback) {
642
- await raceWithAbort(this.refreshCredentials(), signal);
713
+ await this.refreshCredentials(signal);
643
714
  throwIfAborted(signal);
644
715
  response = await this.fetchRoomToken(apiBase, signal);
645
716
  }
@@ -655,6 +726,7 @@ export class RealtimeSession {
655
726
  throw await raceWithAbort(httpError(response, "Could not renew room token"), signal);
656
727
  }
657
728
  const payload = (await raceWithAbort(response.json(), signal));
729
+ throwIfAborted(signal);
658
730
  this.credentials = payload.connection;
659
731
  if (this.room.state !== LiveKitConnectionState.Disconnected) {
660
732
  this.disconnectMode = "rejoin";
@@ -673,16 +745,28 @@ export class RealtimeSession {
673
745
  duration_ms: Date.now() - this.reconnectStartedAt,
674
746
  };
675
747
  }
676
- await this.connectWithMode({
677
- ...connect,
678
- apiBase,
748
+ // Startup retries resolve the original caller options with constructor defaults.
749
+ // A later rejoin restores the audio settings saved by the previous connection.
750
+ const connectionOptions = connect ?? {
751
+ ...this.recoveryReadiness,
679
752
  microphone: this.useManagedMicrophone,
680
753
  ...(this.applicationAudioTrack ? { audioTrack: this.applicationAudioTrack } : {}),
681
- }, recovering, signal, markFailureTerminal);
754
+ };
755
+ await this.connectWithMode({ ...connectionOptions, apiBase }, recovering, signal, markFailureTerminal);
682
756
  }
683
757
  async setMicrophoneEnabled(enabled) {
758
+ // Recovery must see the user's latest intent while the media operation waits.
759
+ this.microphoneEnabled = enabled;
760
+ if (this.applicationAudioTrack) {
761
+ // The room may not know this track yet while publication is pending.
762
+ const track = this.localApplicationAudioTrack;
763
+ if (enabled)
764
+ await track?.unmute();
765
+ else
766
+ await track?.mute();
767
+ return;
768
+ }
684
769
  await this.room.localParticipant.setMicrophoneEnabled(enabled);
685
- this.managedMicrophoneEnabled = enabled;
686
770
  }
687
771
  async sendCommand(type, payload, timeoutMs = 10_000) {
688
772
  const message = createClientMessage(this.connectionId, ++this.clientSeq, type, payload);
@@ -709,7 +793,13 @@ export class RealtimeSession {
709
793
  context_key: update.key,
710
794
  payload_bytes: payloadBytes,
711
795
  });
712
- const uploading = "image" in update ? this.uploadImageContext(update) : this.uploadTextContext(update);
796
+ this.activeContextTransfers += 1;
797
+ this.activeContextBytes += payloadBytes;
798
+ const uploading = ("image" in update ? this.uploadImageContext(update) : this.uploadTextContext(update))
799
+ .finally(() => {
800
+ this.activeContextTransfers -= 1;
801
+ this.activeContextBytes -= payloadBytes;
802
+ });
713
803
  const transferSettled = uploading.then(() => { }, () => { });
714
804
  let transferTimeout;
715
805
  const transferExpired = new Promise((_resolve, reject) => {
@@ -866,6 +956,7 @@ export class RealtimeSession {
866
956
  this.failRecovery(new Error("Session disconnected during recovery"));
867
957
  this.cancelRecovery();
868
958
  this.contextGeneration += 1;
959
+ this.localApplicationAudioTrack = null;
869
960
  this.replacementContext.clear(new Error("Session disconnected"));
870
961
  if (this.stateValue === "disconnected")
871
962
  return;
@@ -881,6 +972,7 @@ export class RealtimeSession {
881
972
  }
882
973
  }
883
974
  async end(options = {}) {
975
+ this.credentialRefreshGeneration += 1;
884
976
  this.abortStartupConnect(new Error("Session ended"));
885
977
  this.startupConnectController = null;
886
978
  this.failRecovery(new Error("Session ended during recovery"));
@@ -900,7 +992,7 @@ export class RealtimeSession {
900
992
  credentials: request.credentials ?? this.lifecycleCredentials,
901
993
  });
902
994
  if (response.status === 401 && usesScopedSessionEndpoint && this.refreshCredentialsCallback) {
903
- await this.refreshCredentials();
995
+ await this.refreshCredentials(request.signal ?? undefined);
904
996
  headers.set("Authorization", `Bearer ${this.accessToken}`);
905
997
  response = await this.fetcher(endpoint, {
906
998
  ...request,
@@ -928,9 +1020,13 @@ export class RealtimeSession {
928
1020
  return;
929
1021
  this.stateValue = state;
930
1022
  if (state === "connecting" || state === "resuming") {
1023
+ this.startDiagnosticLoopMonitor();
931
1024
  globalThis.document?.addEventListener?.("visibilitychange", this.onDiagnosticVisibilityChange);
932
1025
  }
933
1026
  if (state === "disconnected" || state === "failed") {
1027
+ globalThis.clearInterval(this.diagnosticLoopTimer);
1028
+ this.diagnosticLoopTimer = undefined;
1029
+ this.diagnosticLoopDelayMs = undefined;
934
1030
  globalThis.document?.removeEventListener?.("visibilitychange", this.onDiagnosticVisibilityChange);
935
1031
  this.flushDiagnostics("session_finished");
936
1032
  const error = new Error(`Session ${state}`);
@@ -1086,9 +1182,9 @@ export class RealtimeSession {
1086
1182
  this.setState(this.hasConnectedValue ? "resuming" : "connected_not_ready");
1087
1183
  try {
1088
1184
  await this.sendHello();
1089
- const deadline = Date.now() + (this.connectDefaults.readyTimeoutMs ?? 15_000);
1185
+ const deadline = Date.now() + this.recoveryReadiness.readyTimeoutMs;
1090
1186
  await this.waitForAgentReady(Math.max(deadline - Date.now(), 0));
1091
- if (this.connectDefaults.waitForRemoteAudio !== false)
1187
+ if (this.recoveryReadiness.waitForRemoteAudio)
1092
1188
  await this.waitForRemoteAudioTrack(deadline);
1093
1189
  if (this.sessionEnded || generation !== this.contextGeneration)
1094
1190
  return;
@@ -1371,10 +1467,18 @@ export class RealtimeSession {
1371
1467
  signal?.removeEventListener("abort", cancel);
1372
1468
  }
1373
1469
  }
1374
- async refreshCredentials() {
1470
+ async refreshCredentials(signal) {
1375
1471
  if (!this.refreshCredentialsCallback)
1376
1472
  return;
1377
- const refreshed = await this.refreshCredentialsCallback();
1473
+ throwIfAborted(signal);
1474
+ const connectionGeneration = this.contextGeneration;
1475
+ const refreshGeneration = ++this.credentialRefreshGeneration;
1476
+ const refreshed = await raceWithAbort(this.refreshCredentialsCallback(), signal);
1477
+ throwIfAborted(signal);
1478
+ if (connectionGeneration !== this.contextGeneration
1479
+ || refreshGeneration !== this.credentialRefreshGeneration) {
1480
+ throw new Error("Credential refresh was superseded");
1481
+ }
1378
1482
  validateSessionCredentials(refreshed);
1379
1483
  if (refreshed.id !== this.id) {
1380
1484
  throw new Error("Refreshed credentials belong to a different session");
@@ -1469,15 +1573,26 @@ export class RealtimeSession {
1469
1573
  }
1470
1574
  }
1471
1575
  async publishApplicationAudioTrack(audioTrack) {
1472
- const publishedTrack = audioTrack.clone();
1576
+ const clonedTrack = audioTrack.clone();
1577
+ let publishedTrack = null;
1473
1578
  try {
1579
+ publishedTrack = new LocalAudioTrack(clonedTrack, undefined, true);
1580
+ this.localApplicationAudioTrack = publishedTrack;
1581
+ if (!this.microphoneEnabled) {
1582
+ // LiveKit can re-enable a raw track when wrapping it. Mute the wrapper
1583
+ // before publication so recovery cannot briefly transmit muted input.
1584
+ await publishedTrack.mute();
1585
+ }
1474
1586
  await this.room.localParticipant.publishTrack(publishedTrack, {
1475
1587
  source: Track.Source.Microphone,
1476
1588
  name: "voxevolv-application-audio",
1477
1589
  });
1478
1590
  }
1479
1591
  catch (error) {
1480
- publishedTrack.stop();
1592
+ if (this.localApplicationAudioTrack === publishedTrack) {
1593
+ this.localApplicationAudioTrack = null;
1594
+ }
1595
+ clonedTrack.stop();
1481
1596
  throw error;
1482
1597
  }
1483
1598
  }
@@ -1685,12 +1800,32 @@ export class RealtimeSession {
1685
1800
  async publishCommand(message) {
1686
1801
  await this.room.localParticipant.publishData(new TextEncoder().encode(JSON.stringify(message)), { reliable: true, topic: "voxevolv.commands" });
1687
1802
  }
1803
+ startDiagnosticLoopMonitor() {
1804
+ if (this.diagnosticLoopTimer !== undefined)
1805
+ return;
1806
+ this.diagnosticLoopExpectedAt = performance.now() + 1_000;
1807
+ this.diagnosticLoopTimer = globalThis.setInterval(() => {
1808
+ const now = performance.now();
1809
+ this.diagnosticLoopDelayMs = Math.max(0, now - this.diagnosticLoopExpectedAt);
1810
+ this.diagnosticLoopExpectedAt = now + 1_000;
1811
+ }, 1_000);
1812
+ this.diagnosticLoopTimer.unref?.();
1813
+ }
1688
1814
  recordDiagnostic(entry) {
1689
- this.diagnostics.record({
1815
+ const snapshot = {
1690
1816
  ...entry,
1691
1817
  connection_state: this.stateValue,
1692
1818
  buffered_bytes: this.reliableDataChannelBufferedBytes(),
1693
- });
1819
+ };
1820
+ if (entry.operation === "tool_rpc") {
1821
+ snapshot.page_visibility = globalThis.document?.visibilityState;
1822
+ snapshot.active_context_transfers = this.activeContextTransfers;
1823
+ snapshot.active_context_bytes = this.activeContextBytes;
1824
+ if (this.diagnosticLoopTimer !== undefined) {
1825
+ snapshot.event_loop_delay_ms = Math.max(this.diagnosticLoopDelayMs ?? 0, performance.now() - this.diagnosticLoopExpectedAt);
1826
+ }
1827
+ }
1828
+ this.diagnostics.record(snapshot);
1694
1829
  }
1695
1830
  reliableDataChannelBufferedBytes() {
1696
1831
  try {
@@ -1911,7 +2046,7 @@ export class RealtimeSession {
1911
2046
  signal,
1912
2047
  }), signal);
1913
2048
  if (response.status === 401 && this.refreshCredentialsCallback) {
1914
- await raceWithAbort(this.refreshCredentials(), signal);
2049
+ await this.refreshCredentials(signal);
1915
2050
  response = await raceWithAbort(this.fetcher(url, {
1916
2051
  headers: { Authorization: `Bearer ${this.accessToken}` },
1917
2052
  credentials: this.lifecycleCredentials,