@voxevolv/client 0.3.3 → 0.3.5

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,12 +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
3
  import { ReplacementContextQueue } from "./context-queue.js";
4
+ import { ClientDiagnosticBuffer, } from "./diagnostics.js";
4
5
  import { TranscriptStore } from "./transcripts.js";
5
6
  export { TranscriptStore } from "./transcripts.js";
6
7
  export { CommandError, EventCursor, createClientMessage, isCommandAckPayload, isCommandErrorPayload, isContextRejectedPayload, isContextUpdatePayload, isContextUpdatedPayload, isRealtimeEvent, } from "./protocol.js";
7
8
  export { decodeAgentSnapshotPayload, decodeClientHelloPayload, decodeClientMessage, decodeCommandAckPayload, decodeCommandErrorPayload, decodeContextRejectedPayload, decodeContextUpdatePayload, decodeContextUpdatedPayload, decodeRealtimeEvent, } from "./generated/protocol-codecs.js";
8
- export const SDK_VERSION = "0.3.3";
9
+ export const SDK_VERSION = "0.3.5";
9
10
  const CLIENT_HELLO_TIMEOUT_MS = 30_000;
11
+ const DIAGNOSTIC_REPORT_TIMEOUT_MS = 5_000;
12
+ const DEFAULT_STARTUP_ATTEMPTS = [
13
+ { delayBeforeMs: 0, readyTimeoutMs: 15_000 },
14
+ { delayBeforeMs: 1_000, readyTimeoutMs: 20_000 },
15
+ { delayBeforeMs: 3_000, readyTimeoutMs: 25_000 },
16
+ ];
17
+ const DEFAULT_STARTUP_TIMEOUT_MS = 65_000;
10
18
  export class RealtimeSessionHttpError extends Error {
11
19
  status;
12
20
  code;
@@ -21,6 +29,20 @@ export class RealtimeSessionHttpError extends Error {
21
29
  this.details = options.details ?? [];
22
30
  }
23
31
  }
32
+ class SessionConfigurationError extends TypeError {
33
+ }
34
+ class SessionErrorEventError extends Error {
35
+ retryable;
36
+ constructor(event) {
37
+ const payload = event.payload;
38
+ const message = typeof payload.message === "string"
39
+ ? payload.message
40
+ : "VoxEvolv reported a session error";
41
+ super(message);
42
+ this.name = "SessionErrorEventError";
43
+ this.retryable = payload.retryable === true;
44
+ }
45
+ }
24
46
  export class RealtimeSession {
25
47
  id;
26
48
  room;
@@ -32,6 +54,7 @@ export class RealtimeSession {
32
54
  gapListeners = new Set();
33
55
  connectionQualityListeners = new Set();
34
56
  recoveryListeners = new Set();
57
+ startupAttemptListeners = new Set();
35
58
  remoteAudioTrackListeners = new Set();
36
59
  remoteAudioTrackUnsubscribedListeners = new Set();
37
60
  readyListeners = new Set();
@@ -59,6 +82,7 @@ export class RealtimeSession {
59
82
  readyResolve = null;
60
83
  replayPromise = null;
61
84
  pendingCommands = new Map();
85
+ commandPublishTail = Promise.resolve();
62
86
  toolHandlers = new Map();
63
87
  apiBase = "/v1";
64
88
  hasConnectedValue = false;
@@ -69,6 +93,8 @@ export class RealtimeSession {
69
93
  activeRecovery = null;
70
94
  autoRejoinPromise = null;
71
95
  autoRejoinController = null;
96
+ startupConnectController = null;
97
+ startupAttemptController = null;
72
98
  useManagedMicrophone = true;
73
99
  managedMicrophoneEnabled = true;
74
100
  applicationAudioTrack = null;
@@ -77,7 +103,10 @@ export class RealtimeSession {
77
103
  endEndpoint;
78
104
  refreshCredentialsCallback;
79
105
  autoRejoinOptions;
106
+ startupRetryOptions;
80
107
  lifecycleCredentials;
108
+ diagnostics = new ClientDiagnosticBuffer({ sdkVersion: SDK_VERSION });
109
+ diagnosticReportTimeoutMs = DIAGNOSTIC_REPORT_TIMEOUT_MS;
81
110
  constructor(credentials, options = {}) {
82
111
  validateSessionCredentials(credentials);
83
112
  this.id = credentials.id;
@@ -91,6 +120,7 @@ export class RealtimeSession {
91
120
  this.endEndpoint = options.endEndpoint ?? null;
92
121
  this.refreshCredentialsCallback = options.refreshCredentials ?? null;
93
122
  this.autoRejoinOptions = resolveAutoRejoinOptions(options.autoRejoin);
123
+ this.startupRetryOptions = resolveStartupRetryOptions(options.startupRetry);
94
124
  this.lifecycleCredentials = options.lifecycleCredentials ?? "same-origin";
95
125
  this.room = new Room({ adaptiveStream: true, dynacast: true });
96
126
  for (const [name, handler] of Object.entries(options.tools ?? {})) {
@@ -190,6 +220,10 @@ export class RealtimeSession {
190
220
  this.recoveryListeners.add(listener);
191
221
  return () => this.recoveryListeners.delete(listener);
192
222
  }
223
+ onStartupAttempt(listener) {
224
+ this.startupAttemptListeners.add(listener);
225
+ return () => this.startupAttemptListeners.delete(listener);
226
+ }
193
227
  waitForState(states, timeoutMs, options = {}) {
194
228
  if (!states.length)
195
229
  throw new TypeError("states must contain at least one connection state");
@@ -298,13 +332,106 @@ export class RealtimeSession {
298
332
  throw new Error(`Cannot connect while session is ${this.stateValue}`);
299
333
  }
300
334
  this.cancelAutoRejoin();
301
- await this.connectWithMode(options, false);
335
+ const controller = new AbortController();
336
+ this.startupConnectController = controller;
337
+ try {
338
+ if (this.startupRetryOptions) {
339
+ await this.connectWithStartupRetry(options, controller);
340
+ }
341
+ else {
342
+ this.startupAttemptController = controller;
343
+ await this.connectWithMode(options, false, controller.signal, false);
344
+ }
345
+ }
346
+ catch (error) {
347
+ if (this.startupConnectController === controller && !this.sessionEnded) {
348
+ this.setState("failed");
349
+ }
350
+ throw error;
351
+ }
352
+ finally {
353
+ if (this.startupAttemptController === controller) {
354
+ this.startupAttemptController = null;
355
+ }
356
+ if (this.startupConnectController === controller) {
357
+ this.startupConnectController = null;
358
+ }
359
+ }
302
360
  }
303
- async connectWithMode(options, recovering, signal) {
361
+ async connectWithStartupRetry(options, controller) {
362
+ const startupOptions = this.startupRetryOptions;
363
+ if (!startupOptions)
364
+ return;
365
+ const timeout = globalThis.setTimeout(() => {
366
+ controller.abort(new Error(`Session startup did not complete within ${startupOptions.timeoutMs}ms`));
367
+ }, startupOptions.timeoutMs);
368
+ let lastError = new Error("Session startup failed");
369
+ try {
370
+ for (const [index, attempt] of startupOptions.attempts.entries()) {
371
+ const attemptNumber = index + 1;
372
+ if (attempt.delayBeforeMs > 0) {
373
+ this.setState("connecting");
374
+ await abortableDelay(attempt.delayBeforeMs, controller.signal);
375
+ }
376
+ throwIfAborted(controller.signal);
377
+ const attemptController = createLinkedAbortController(controller.signal);
378
+ this.startupAttemptController = attemptController;
379
+ this.notifyStartupAttempt("started", attemptNumber, attempt.readyTimeoutMs);
380
+ try {
381
+ if (index === 0) {
382
+ await this.connectWithMode({ ...options, readyTimeoutMs: attempt.readyTimeoutMs }, false, attemptController.signal, false);
383
+ }
384
+ else {
385
+ await this.performRejoin(this.apiBase, {
386
+ signal: attemptController.signal,
387
+ recovering: false,
388
+ connect: { ...options, readyTimeoutMs: attempt.readyTimeoutMs },
389
+ markFailureTerminal: false,
390
+ });
391
+ }
392
+ this.notifyStartupAttempt("succeeded", attemptNumber, attempt.readyTimeoutMs);
393
+ return;
394
+ }
395
+ catch (error) {
396
+ lastError = toError(error);
397
+ this.notifyStartupAttempt("failed", attemptNumber, attempt.readyTimeoutMs, lastError);
398
+ if (controller.signal.aborted)
399
+ throw abortError(controller.signal);
400
+ if (isTerminalStartupError(error) || attemptNumber === startupOptions.attempts.length) {
401
+ throw error;
402
+ }
403
+ this.setState("connecting");
404
+ }
405
+ finally {
406
+ if (this.startupAttemptController === attemptController) {
407
+ this.startupAttemptController = null;
408
+ }
409
+ }
410
+ }
411
+ throw lastError;
412
+ }
413
+ finally {
414
+ globalThis.clearTimeout(timeout);
415
+ }
416
+ }
417
+ notifyStartupAttempt(status, attempt, readyTimeoutMs, error) {
418
+ const maxAttempts = this.startupRetryOptions?.attempts.length ?? 1;
419
+ this.notifyListeners(this.startupAttemptListeners, {
420
+ status,
421
+ attempt,
422
+ maxAttempts,
423
+ readyTimeoutMs,
424
+ ...(error ? { error } : {}),
425
+ });
426
+ }
427
+ async connectWithMode(options, recovering, signal, markFailureTerminal = true) {
304
428
  const blockedStates = recovering
305
429
  ? ["connecting", "connected_not_ready", "connected", "reconnecting"]
306
430
  : ["connecting", "connected_not_ready", "connected", "reconnecting", "resuming"];
307
- if (blockedStates.includes(this.stateValue)) {
431
+ const continuingStartupRetry = (!recovering
432
+ && !markFailureTerminal
433
+ && this.stateValue === "connecting");
434
+ if (blockedStates.includes(this.stateValue) && !continuingStartupRetry) {
308
435
  throw new Error(`Cannot connect while session is ${this.stateValue}`);
309
436
  }
310
437
  const generation = this.contextGeneration;
@@ -333,17 +460,17 @@ export class RealtimeSession {
333
460
  this.apiBase = apiBase;
334
461
  this.agentReady = false;
335
462
  try {
336
- await this.room.connect(this.credentials.url, this.credentials.token);
463
+ await this.connectRoom(signal, recovering);
337
464
  throwIfAborted(signal);
338
465
  if (audioTrack) {
339
- await this.publishApplicationAudioTrack(audioTrack);
466
+ await raceWithAbort(this.publishApplicationAudioTrack(audioTrack), signal);
340
467
  }
341
468
  else if (microphone) {
342
- await this.room.localParticipant.setMicrophoneEnabled(this.managedMicrophoneEnabled);
469
+ await raceWithAbort(this.room.localParticipant.setMicrophoneEnabled(this.managedMicrophoneEnabled), signal);
343
470
  }
344
471
  throwIfAborted(signal);
345
472
  this.setState(recovering ? "resuming" : "connected_not_ready");
346
- await this.sendHello();
473
+ await raceWithAbort(this.sendHello(), signal);
347
474
  const readyDeadline = Date.now() + readyTimeoutMs;
348
475
  await this.waitForAgentReady(readyTimeoutMs, signal);
349
476
  if (waitForRemoteAudio)
@@ -352,7 +479,7 @@ export class RealtimeSession {
352
479
  if (this.sessionEnded || generation !== this.contextGeneration) {
353
480
  throw new Error("Connection restoration interrupted");
354
481
  }
355
- await this.replacementContext.resume(recovering);
482
+ await raceWithAbort(this.replacementContext.resume(recovering), signal);
356
483
  throwIfAborted(signal);
357
484
  if (this.sessionEnded || generation !== this.contextGeneration) {
358
485
  throw new Error("Connection restoration interrupted");
@@ -361,6 +488,8 @@ export class RealtimeSession {
361
488
  this.setState("connected");
362
489
  }
363
490
  catch (error) {
491
+ if (signal?.aborted)
492
+ this.rejectPendingCommands(toError(abortError(signal)));
364
493
  this.disconnectMode = recovering ? "rejoin" : "connect_failure";
365
494
  try {
366
495
  await this.room.disconnect();
@@ -371,11 +500,46 @@ export class RealtimeSession {
371
500
  finally {
372
501
  this.disconnectMode = null;
373
502
  }
374
- if (!recovering)
503
+ if (!recovering && markFailureTerminal)
375
504
  this.setState("failed");
376
505
  throw error;
377
506
  }
378
507
  }
508
+ connectRoom(signal, recovering) {
509
+ throwIfAborted(signal);
510
+ const connection = this.room.connect(this.credentials.url, this.credentials.token);
511
+ if (!signal)
512
+ return connection;
513
+ return new Promise((resolve, reject) => {
514
+ let settled = false;
515
+ const resolveOnce = () => {
516
+ if (settled)
517
+ return;
518
+ settled = true;
519
+ signal.removeEventListener("abort", onAbort);
520
+ resolve();
521
+ };
522
+ const rejectOnce = (error) => {
523
+ if (settled)
524
+ return;
525
+ settled = true;
526
+ signal.removeEventListener("abort", onAbort);
527
+ reject(error);
528
+ };
529
+ const onAbort = () => {
530
+ this.disconnectMode = recovering ? "rejoin" : "connect_failure";
531
+ void this.room.disconnect().catch(() => { });
532
+ rejectOnce(abortError(signal));
533
+ };
534
+ if (signal.aborted) {
535
+ onAbort();
536
+ }
537
+ else {
538
+ signal.addEventListener("abort", onAbort, { once: true });
539
+ }
540
+ connection.then(resolveOnce, rejectOnce);
541
+ });
542
+ }
379
543
  async rejoin(apiBase = this.apiBase) {
380
544
  if (["connecting", "reconnecting", "resuming"].includes(this.stateValue)) {
381
545
  throw new Error(`Cannot rejoin while session is ${this.stateValue}`);
@@ -384,11 +548,11 @@ export class RealtimeSession {
384
548
  const normalizedApiBase = normalizeApiBase(apiBase);
385
549
  const recovering = this.hasConnectedValue;
386
550
  if (recovering) {
387
- this.beginRecovery("rejoin", 1);
551
+ this.beginRecovery("rejoin", 1, { trigger: "manual_rejoin" });
388
552
  this.setState("resuming");
389
553
  }
390
554
  try {
391
- await this.performRejoin(normalizedApiBase, undefined, recovering);
555
+ await this.performRejoin(normalizedApiBase, { recovering });
392
556
  if (recovering)
393
557
  this.completeRecovery();
394
558
  }
@@ -400,19 +564,21 @@ export class RealtimeSession {
400
564
  throw error;
401
565
  }
402
566
  }
403
- async performRejoin(apiBase, signal, recovering = true) {
567
+ async performRejoin(apiBase, options = {}) {
568
+ const { signal, recovering = true, connect = {}, markFailureTerminal = true, } = options;
404
569
  this.contextGeneration += 1;
405
570
  this.replacementContext.pause();
406
571
  this.rejectPendingCommands(new Error("Restoring the session"));
407
572
  let response = await this.fetchRoomToken(apiBase, signal);
408
573
  if (response.status === 401 && this.refreshCredentialsCallback) {
409
- await this.refreshCredentials();
574
+ await raceWithAbort(this.refreshCredentials(), signal);
410
575
  throwIfAborted(signal);
411
576
  response = await this.fetchRoomToken(apiBase, signal);
412
577
  }
413
- if (!response.ok)
414
- throw await httpError(response, "Could not renew room token");
415
- const payload = (await response.json());
578
+ if (!response.ok) {
579
+ throw await raceWithAbort(httpError(response, "Could not renew room token"), signal);
580
+ }
581
+ const payload = (await raceWithAbort(response.json(), signal));
416
582
  this.credentials = payload.connection;
417
583
  if (this.room.state !== LiveKitConnectionState.Disconnected) {
418
584
  this.disconnectMode = "rejoin";
@@ -432,10 +598,11 @@ export class RealtimeSession {
432
598
  };
433
599
  }
434
600
  await this.connectWithMode({
601
+ ...connect,
435
602
  apiBase,
436
603
  microphone: this.useManagedMicrophone,
437
604
  ...(this.applicationAudioTrack ? { audioTrack: this.applicationAudioTrack } : {}),
438
- }, recovering, signal);
605
+ }, recovering, signal, markFailureTerminal);
439
606
  }
440
607
  async setMicrophoneEnabled(enabled) {
441
608
  await this.room.localParticipant.setMicrophoneEnabled(enabled);
@@ -457,18 +624,73 @@ export class RealtimeSession {
457
624
  }
458
625
  async sendContextUpdate(update, timeoutMs) {
459
626
  const generation = this.contextGeneration;
627
+ const payloadBytes = contextUpdateBytes(update);
628
+ const transferStartedAt = Date.now();
629
+ let transferTimedOut = false;
630
+ this.recordDiagnostic({
631
+ operation: "context_transfer",
632
+ status: "started",
633
+ context_key: update.key,
634
+ payload_bytes: payloadBytes,
635
+ });
460
636
  let transferTimeout;
461
637
  const transferExpired = new Promise((_resolve, reject) => {
462
638
  transferTimeout = globalThis.setTimeout(() => {
639
+ transferTimedOut = true;
463
640
  const error = new Error(`Context transfer timed out after ${timeoutMs}ms`);
464
- this.startAutoRejoin(error);
641
+ this.recordDiagnostic({
642
+ operation: "context_transfer",
643
+ status: "timed_out",
644
+ reason: "context_transfer_timeout",
645
+ context_key: update.key,
646
+ payload_bytes: payloadBytes,
647
+ duration_ms: Date.now() - transferStartedAt,
648
+ timeout_ms: timeoutMs,
649
+ });
650
+ 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
+ });
465
657
  reject(error);
466
658
  }, timeoutMs);
467
659
  });
468
- const transfer = await Promise.race([
469
- "image" in update ? this.uploadImageContext(update) : this.uploadTextContext(update),
470
- transferExpired,
471
- ]).finally(() => globalThis.clearTimeout(transferTimeout));
660
+ let transfer;
661
+ try {
662
+ transfer = await Promise.race([
663
+ "image" in update ? this.uploadImageContext(update) : this.uploadTextContext(update),
664
+ transferExpired,
665
+ ]);
666
+ }
667
+ catch (error) {
668
+ if (!transferTimedOut) {
669
+ this.recordDiagnostic({
670
+ operation: "context_transfer",
671
+ status: "failed",
672
+ reason: "context_transfer_failed",
673
+ context_key: update.key,
674
+ payload_bytes: payloadBytes,
675
+ duration_ms: Date.now() - transferStartedAt,
676
+ });
677
+ this.flushDiagnostics("command_failed");
678
+ }
679
+ throw error;
680
+ }
681
+ finally {
682
+ globalThis.clearTimeout(transferTimeout);
683
+ }
684
+ const transferDurationMs = Date.now() - transferStartedAt;
685
+ this.recordDiagnostic({
686
+ operation: "context_transfer",
687
+ status: "succeeded",
688
+ context_key: update.key,
689
+ payload_bytes: payloadBytes,
690
+ duration_ms: transferDurationMs,
691
+ });
692
+ if (transferDurationMs > 2_000)
693
+ this.flushDiagnostics("slow_operation");
472
694
  if (generation !== this.contextGeneration)
473
695
  throw new Error("Context transfer interrupted by recovery");
474
696
  const { payload } = transfer;
@@ -558,6 +780,8 @@ export class RealtimeSession {
558
780
  };
559
781
  }
560
782
  async disconnect() {
783
+ this.abortStartupConnect(new Error("Session disconnected"));
784
+ this.startupConnectController = null;
561
785
  this.cancelAutoRejoin();
562
786
  this.contextGeneration += 1;
563
787
  this.replacementContext.clear(new Error("Session disconnected"));
@@ -575,6 +799,8 @@ export class RealtimeSession {
575
799
  }
576
800
  }
577
801
  async end(options = {}) {
802
+ this.abortStartupConnect(new Error("Session ended"));
803
+ this.startupConnectController = null;
578
804
  this.cancelAutoRejoin();
579
805
  this.sessionEnded = true;
580
806
  const endpoint = options.endpoint ?? this.endEndpoint ?? `${this.apiBase}/sessions/${this.id}`;
@@ -623,7 +849,7 @@ export class RealtimeSession {
623
849
  bindRoomEvents() {
624
850
  this.room.on(RoomEvent.ParticipantDisconnected, (participant) => {
625
851
  if (participant.isAgent && !this.disconnectMode) {
626
- this.startAutoRejoin(new Error("Agent disconnected from the room"));
852
+ this.startAutoRejoin(new Error("Agent disconnected from the room"), { trigger: "agent_disconnected" });
627
853
  }
628
854
  });
629
855
  this.room.on(RoomEvent.SignalReconnecting, () => this.handleLiveKitReconnecting());
@@ -732,7 +958,7 @@ export class RealtimeSession {
732
958
  this.reconnectStartedAt ??= Date.now();
733
959
  this.rejectPendingCommands(new Error("Connection interrupted before command completion"), true);
734
960
  if (this.activeRecovery === null) {
735
- this.beginRecovery("livekit", 1);
961
+ this.beginRecovery("livekit", 1, { trigger: "livekit_reconnecting" });
736
962
  }
737
963
  this.setState("reconnecting");
738
964
  }
@@ -750,7 +976,7 @@ export class RealtimeSession {
750
976
  this.reconnectStartedAt = null;
751
977
  }
752
978
  if (this.hasConnectedValue && this.activeRecovery === null) {
753
- this.beginRecovery("livekit", 1);
979
+ this.beginRecovery("livekit", 1, { trigger: "livekit_reconnecting" });
754
980
  }
755
981
  this.connectionId = `conn_${Math.random().toString(36).slice(2)}_${Date.now().toString(36)}`;
756
982
  this.clientSeq = 0;
@@ -774,7 +1000,7 @@ export class RealtimeSession {
774
1000
  return;
775
1001
  const normalizedError = toError(error);
776
1002
  if (this.shouldAutoRejoin()) {
777
- this.startAutoRejoin(normalizedError);
1003
+ this.startAutoRejoin(normalizedError, { trigger: "livekit_resume_failed" });
778
1004
  }
779
1005
  else {
780
1006
  this.failRecovery(normalizedError);
@@ -804,7 +1030,7 @@ export class RealtimeSession {
804
1030
  return;
805
1031
  }
806
1032
  if (this.shouldAutoRejoin(reason)) {
807
- this.startAutoRejoin(new Error(`LiveKit disconnected (${disconnectReasonName(reason)})`));
1033
+ this.startAutoRejoin(new Error(`LiveKit disconnected (${disconnectReasonName(reason)})`), { trigger: "livekit_disconnected" });
808
1034
  return;
809
1035
  }
810
1036
  if (this.activeRecovery !== null) {
@@ -828,12 +1054,22 @@ export class RealtimeSession {
828
1054
  this.endNotificationDelivered = true;
829
1055
  this.notifyListeners(this.endedListeners, event);
830
1056
  }
831
- beginRecovery(strategy, attempt) {
832
- this.activeRecovery = { strategy, attempt };
1057
+ beginRecovery(strategy, attempt, details) {
1058
+ this.activeRecovery = { strategy, attempt, details };
1059
+ this.recordDiagnostic({
1060
+ operation: "connection_recovery",
1061
+ status: "started",
1062
+ reason: details.trigger,
1063
+ message_id: details.messageId,
1064
+ context_key: details.contextKey,
1065
+ timeout_ms: details.timeoutMs,
1066
+ });
1067
+ this.flushDiagnostics("recovery_started");
833
1068
  this.notifyListeners(this.recoveryListeners, {
834
1069
  status: "started",
835
1070
  strategy,
836
1071
  attempt,
1072
+ ...details,
837
1073
  });
838
1074
  }
839
1075
  completeRecovery() {
@@ -845,6 +1081,7 @@ export class RealtimeSession {
845
1081
  status: "succeeded",
846
1082
  strategy: recovery.strategy,
847
1083
  attempt: recovery.attempt,
1084
+ ...recovery.details,
848
1085
  });
849
1086
  }
850
1087
  failRecovery(error) {
@@ -856,6 +1093,7 @@ export class RealtimeSession {
856
1093
  status: "failed",
857
1094
  strategy: recovery.strategy,
858
1095
  attempt: recovery.attempt,
1096
+ ...recovery.details,
859
1097
  error,
860
1098
  });
861
1099
  }
@@ -866,7 +1104,7 @@ export class RealtimeSession {
866
1104
  && !["disconnected", "failed"].includes(this.stateValue)
867
1105
  && !isTerminalDisconnectReason(reason));
868
1106
  }
869
- startAutoRejoin(initialError) {
1107
+ startAutoRejoin(initialError, details) {
870
1108
  if (this.autoRejoinPromise || !this.shouldAutoRejoin())
871
1109
  return;
872
1110
  this.agentReady = false;
@@ -887,7 +1125,7 @@ export class RealtimeSession {
887
1125
  this.failRecovery(error);
888
1126
  this.setState("failed");
889
1127
  }, this.autoRejoinOptions.timeoutMs);
890
- const promise = this.runAutoRejoin(initialError, controller.signal)
1128
+ const promise = this.runAutoRejoin(initialError, controller.signal, details)
891
1129
  .finally(() => {
892
1130
  globalThis.clearTimeout(timeout);
893
1131
  if (this.autoRejoinController === controller) {
@@ -898,7 +1136,7 @@ export class RealtimeSession {
898
1136
  this.autoRejoinPromise = promise;
899
1137
  void promise;
900
1138
  }
901
- async runAutoRejoin(initialError, signal) {
1139
+ async runAutoRejoin(initialError, signal, details) {
902
1140
  const options = this.autoRejoinOptions;
903
1141
  if (!options)
904
1142
  return;
@@ -916,8 +1154,8 @@ export class RealtimeSession {
916
1154
  await abortableDelay(delayMs + Math.random() * Math.min(delayMs, 500), signal);
917
1155
  }
918
1156
  throwIfAborted(signal);
919
- this.beginRecovery("rejoin", attempt);
920
- await this.performRejoin(this.apiBase, signal);
1157
+ this.beginRecovery("rejoin", attempt, details);
1158
+ await this.performRejoin(this.apiBase, { signal });
921
1159
  throwIfAborted(signal);
922
1160
  this.reconnectStartedAt = null;
923
1161
  this.completeRecovery();
@@ -963,15 +1201,15 @@ export class RealtimeSession {
963
1201
  }
964
1202
  validateAudioOptions(microphone, audioTrack) {
965
1203
  if (microphone && audioTrack) {
966
- throw new TypeError("microphone and audioTrack cannot both be enabled");
1204
+ throw new SessionConfigurationError("microphone and audioTrack cannot both be enabled");
967
1205
  }
968
1206
  if (!audioTrack)
969
1207
  return;
970
1208
  if (audioTrack.kind !== "audio") {
971
- throw new TypeError("audioTrack must be an audio MediaStreamTrack");
1209
+ throw new SessionConfigurationError("audioTrack must be an audio MediaStreamTrack");
972
1210
  }
973
1211
  if (audioTrack.readyState === "ended") {
974
- throw new TypeError("audioTrack has already ended");
1212
+ throw new SessionConfigurationError("audioTrack has already ended");
975
1213
  }
976
1214
  }
977
1215
  async fetchRoomToken(apiBase, signal) {
@@ -981,12 +1219,12 @@ export class RealtimeSession {
981
1219
  const timeout = globalThis.setTimeout(() => controller.abort(), 10_000);
982
1220
  try {
983
1221
  throwIfAborted(signal);
984
- return await this.fetcher(`${apiBase}/sessions/${this.id}/room-tokens`, {
1222
+ return await raceWithAbort(this.fetcher(`${apiBase}/sessions/${this.id}/room-tokens`, {
985
1223
  method: "POST",
986
1224
  headers: { Authorization: `Bearer ${this.accessToken}` },
987
1225
  credentials: this.lifecycleCredentials,
988
1226
  signal: controller.signal,
989
- });
1227
+ }), controller.signal);
990
1228
  }
991
1229
  finally {
992
1230
  globalThis.clearTimeout(timeout);
@@ -1005,7 +1243,17 @@ export class RealtimeSession {
1005
1243
  this.credentials = refreshed.connection;
1006
1244
  }
1007
1245
  observeConvenienceEvents(event) {
1246
+ if (event.type === "session.error") {
1247
+ const error = new SessionErrorEventError(event);
1248
+ if (error.retryable) {
1249
+ this.startupAttemptController?.abort(error);
1250
+ }
1251
+ else {
1252
+ this.abortStartupConnect(error);
1253
+ }
1254
+ }
1008
1255
  if (event.type === "session.ending" || event.type === "session.ended") {
1256
+ this.abortStartupConnect(new Error("Session ended during startup"));
1009
1257
  this.contextGeneration += 1;
1010
1258
  this.rejectPendingCommands(new Error("Session ended"));
1011
1259
  }
@@ -1043,6 +1291,11 @@ export class RealtimeSession {
1043
1291
  return;
1044
1292
  this.notifyListeners(transcript.role === "user" ? this.userTranscriptListeners : this.agentTranscriptListeners, transcript);
1045
1293
  }
1294
+ abortStartupConnect(error) {
1295
+ if (!this.startupConnectController?.signal.aborted) {
1296
+ this.startupConnectController?.abort(error);
1297
+ }
1298
+ }
1046
1299
  async publishApplicationAudioTrack(audioTrack) {
1047
1300
  const publishedTrack = audioTrack.clone();
1048
1301
  try {
@@ -1070,50 +1323,224 @@ export class RealtimeSession {
1070
1323
  this.pendingReconnect = null;
1071
1324
  }
1072
1325
  async sendTrackedCommand(message, timeoutMs, retryTransfer) {
1326
+ const contextKey = contextKeyFromMessage(message);
1327
+ const payloadBytes = contextPayloadBytes(message.payload);
1328
+ const startedAt = Date.now();
1329
+ this.recordDiagnostic({
1330
+ operation: "command_send",
1331
+ status: "started",
1332
+ message_id: message.message_id,
1333
+ connection_id: message.connection_id,
1334
+ client_seq: message.client_seq,
1335
+ context_key: contextKey,
1336
+ payload_bytes: payloadBytes,
1337
+ retry_count: 0,
1338
+ timeout_ms: timeoutMs,
1339
+ event_type: diagnosticEventType(message.type),
1340
+ });
1341
+ let markSettled = () => { };
1342
+ const settled = new Promise((resolve) => { markSettled = resolve; });
1073
1343
  const response = new Promise((resolve, reject) => {
1074
1344
  const timeout = globalThis.setTimeout(() => {
1075
1345
  const pending = this.pendingCommands.get(message.message_id);
1076
1346
  if (pending)
1077
1347
  globalThis.clearInterval(pending.retry);
1348
+ pending?.markSettled();
1078
1349
  this.pendingCommands.delete(message.message_id);
1079
1350
  const error = new Error(`Command ${message.type} timed out after ${timeoutMs}ms`);
1351
+ this.recordDiagnostic({
1352
+ operation: "command_send",
1353
+ status: "timed_out",
1354
+ reason: message.type === "context.update"
1355
+ ? "context_command_timeout"
1356
+ : "command_timeout",
1357
+ message_id: message.message_id,
1358
+ connection_id: message.connection_id,
1359
+ client_seq: message.client_seq,
1360
+ context_key: contextKey,
1361
+ payload_bytes: payloadBytes,
1362
+ duration_ms: Date.now() - startedAt,
1363
+ retry_count: pending?.retryCount ?? 0,
1364
+ timeout_ms: timeoutMs,
1365
+ event_type: diagnosticEventType(message.type),
1366
+ });
1367
+ this.flushDiagnostics("command_timeout");
1080
1368
  reject(error);
1081
- if (message.type === "context.update")
1082
- this.startAutoRejoin(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
+ }
1083
1378
  }, timeoutMs);
1084
1379
  const retry = globalThis.setInterval(() => {
1085
- void this.publishCommand(message).catch(() => {
1380
+ void this.publishCommandAttempt(message, true).catch(() => {
1086
1381
  // A reconnect may temporarily reject writes; retry until timeout.
1087
1382
  });
1088
1383
  }, 500);
1089
1384
  this.pendingCommands.set(message.message_id, {
1090
1385
  commandType: message.type,
1386
+ message,
1387
+ startedAt,
1388
+ contextKey,
1389
+ payloadBytes,
1390
+ retryCount: 0,
1391
+ publishQueued: false,
1091
1392
  resolve,
1092
1393
  reject,
1093
1394
  timeout,
1094
1395
  retry,
1396
+ settled,
1397
+ markSettled,
1095
1398
  retryTransfer: retryTransfer
1096
1399
  ? async () => {
1097
1400
  await retryTransfer();
1098
- await this.publishCommand(message);
1401
+ await this.publishCommandAttempt(message, true);
1099
1402
  }
1100
1403
  : undefined,
1101
1404
  transferRetrying: false,
1102
1405
  });
1103
1406
  });
1104
- try {
1105
- await this.publishCommand(message);
1106
- }
1107
- catch {
1407
+ void this.publishCommandAttempt(message, false).catch(() => {
1108
1408
  // The reliable data channel may still be warming immediately after a
1109
1409
  // reconnect. Keep the command pending so the retry interval can publish
1110
1410
  // it once the transport accepts writes, or reject at the normal timeout.
1111
- }
1411
+ });
1112
1412
  return response;
1113
1413
  }
1414
+ publishCommandAttempt(message, retrying) {
1415
+ const pending = this.pendingCommands.get(message.message_id);
1416
+ if (!pending || pending.publishQueued)
1417
+ return Promise.resolve();
1418
+ pending.publishQueued = true;
1419
+ // Context keys drain independently, but client_seq is global. Serialize the
1420
+ // asynchronous LiveKit publish boundary so a later sequence cannot overtake
1421
+ // an earlier one before the reliable data channel receives it.
1422
+ const attempt = this.commandPublishTail
1423
+ .then(() => this.publishCommandAttemptNow(message, retrying))
1424
+ .finally(() => {
1425
+ const current = this.pendingCommands.get(message.message_id);
1426
+ if (current === pending)
1427
+ current.publishQueued = false;
1428
+ });
1429
+ this.commandPublishTail = attempt.catch(() => { });
1430
+ return attempt;
1431
+ }
1432
+ async publishCommandAttemptNow(message, retrying) {
1433
+ const pending = this.pendingCommands.get(message.message_id);
1434
+ if (!pending)
1435
+ return;
1436
+ if (retrying) {
1437
+ pending.retryCount += 1;
1438
+ this.recordDiagnostic({
1439
+ operation: "command_send",
1440
+ status: "retrying",
1441
+ message_id: message.message_id,
1442
+ connection_id: message.connection_id,
1443
+ client_seq: message.client_seq,
1444
+ context_key: pending.contextKey,
1445
+ payload_bytes: pending.payloadBytes,
1446
+ retry_count: pending.retryCount,
1447
+ event_type: diagnosticEventType(message.type),
1448
+ });
1449
+ }
1450
+ const attemptStartedAt = Date.now();
1451
+ try {
1452
+ let publishFinished = false;
1453
+ const publish = this.publishCommand(message).then(() => { publishFinished = true; });
1454
+ await Promise.race([publish, pending.settled]);
1455
+ if (!publishFinished)
1456
+ return;
1457
+ }
1458
+ catch (error) {
1459
+ this.recordDiagnostic({
1460
+ operation: "command_send",
1461
+ status: "failed",
1462
+ reason: "command_publish_failed",
1463
+ message_id: message.message_id,
1464
+ connection_id: message.connection_id,
1465
+ client_seq: message.client_seq,
1466
+ context_key: pending.contextKey,
1467
+ payload_bytes: pending.payloadBytes,
1468
+ duration_ms: Date.now() - attemptStartedAt,
1469
+ retry_count: pending.retryCount,
1470
+ event_type: diagnosticEventType(message.type),
1471
+ });
1472
+ this.flushDiagnostics("command_failed");
1473
+ throw error;
1474
+ }
1475
+ const durationMs = Date.now() - attemptStartedAt;
1476
+ this.recordDiagnostic({
1477
+ operation: "command_send",
1478
+ status: "succeeded",
1479
+ message_id: message.message_id,
1480
+ connection_id: message.connection_id,
1481
+ client_seq: message.client_seq,
1482
+ context_key: pending.contextKey,
1483
+ payload_bytes: pending.payloadBytes,
1484
+ duration_ms: durationMs,
1485
+ retry_count: pending.retryCount,
1486
+ event_type: diagnosticEventType(message.type),
1487
+ });
1488
+ if (durationMs > 2_000)
1489
+ this.flushDiagnostics("slow_operation");
1490
+ }
1114
1491
  async publishCommand(message) {
1115
1492
  await this.room.localParticipant.publishData(new TextEncoder().encode(JSON.stringify(message)), { reliable: true, topic: "voxevolv.commands" });
1116
1493
  }
1494
+ recordDiagnostic(entry) {
1495
+ this.diagnostics.record({
1496
+ ...entry,
1497
+ connection_state: this.stateValue,
1498
+ buffered_bytes: this.reliableDataChannelBufferedBytes(),
1499
+ });
1500
+ }
1501
+ reliableDataChannelBufferedBytes() {
1502
+ try {
1503
+ const bufferedAmount = this.room.engine.dataChannelForKind(0)?.bufferedAmount;
1504
+ return typeof bufferedAmount === "number" ? bufferedAmount : undefined;
1505
+ }
1506
+ catch {
1507
+ return undefined;
1508
+ }
1509
+ }
1510
+ flushDiagnostics(trigger) {
1511
+ void this.diagnostics.flush(trigger, (report) => this.postDiagnostics(report));
1512
+ }
1513
+ async postDiagnostics(report) {
1514
+ const controller = new AbortController();
1515
+ let timeout;
1516
+ const timeoutExpired = new Promise((_resolve, reject) => {
1517
+ timeout = globalThis.setTimeout(() => {
1518
+ controller.abort();
1519
+ reject(new Error(`Diagnostic report timed out after ${this.diagnosticReportTimeoutMs}ms`));
1520
+ }, this.diagnosticReportTimeoutMs);
1521
+ });
1522
+ try {
1523
+ const response = await Promise.race([
1524
+ this.fetcher(`${this.apiBase}/sessions/${this.id}/diagnostics`, {
1525
+ method: "POST",
1526
+ headers: {
1527
+ Authorization: `Bearer ${this.accessToken}`,
1528
+ "Content-Type": "application/json",
1529
+ },
1530
+ credentials: this.lifecycleCredentials,
1531
+ body: JSON.stringify(report),
1532
+ signal: controller.signal,
1533
+ }),
1534
+ timeoutExpired,
1535
+ ]);
1536
+ if (!response.ok)
1537
+ throw new Error(`Diagnostic report failed (${response.status})`);
1538
+ }
1539
+ finally {
1540
+ if (timeout !== undefined)
1541
+ globalThis.clearTimeout(timeout);
1542
+ }
1543
+ }
1117
1544
  waitForRemoteAudioTrack(deadline, signal) {
1118
1545
  if ((this.outputMediaStream?.getAudioTracks().length ?? 0) > 0)
1119
1546
  return Promise.resolve();
@@ -1177,7 +1604,7 @@ export class RealtimeSession {
1177
1604
  if (event.type !== "agent.ready" && !snapshotReady)
1178
1605
  return;
1179
1606
  if (this.agentStreamId && this.agentStreamId !== event.stream_id && !this.activeRecovery) {
1180
- this.startAutoRejoin(new Error("Agent worker was replaced"));
1607
+ this.startAutoRejoin(new Error("Agent worker was replaced"), { trigger: "worker_replaced" });
1181
1608
  return;
1182
1609
  }
1183
1610
  this.agentStreamId = event.stream_id;
@@ -1199,21 +1626,27 @@ export class RealtimeSession {
1199
1626
  if (!pending)
1200
1627
  return;
1201
1628
  if (event.type === "command.ack" && isCommandAckPayload(event.payload)) {
1629
+ this.recordCommandResult(pending, event.type, "acknowledged");
1202
1630
  globalThis.clearTimeout(pending.timeout);
1203
1631
  globalThis.clearInterval(pending.retry);
1632
+ pending.markSettled();
1204
1633
  this.pendingCommands.delete(event.payload.message_id);
1205
1634
  pending.resolve(event.payload);
1206
1635
  return;
1207
1636
  }
1208
1637
  if (event.type === "context.updated" && isContextUpdatedPayload(event.payload)) {
1638
+ this.recordCommandResult(pending, event.type, "acknowledged");
1209
1639
  globalThis.clearTimeout(pending.timeout);
1210
1640
  globalThis.clearInterval(pending.retry);
1641
+ pending.markSettled();
1211
1642
  this.pendingCommands.delete(event.payload.message_id);
1212
1643
  pending.resolve(event.payload);
1213
1644
  return;
1214
1645
  }
1215
1646
  if ((event.type === "command.error" && isCommandErrorPayload(event.payload))
1216
1647
  || (event.type === "context.rejected" && isContextRejectedPayload(event.payload))) {
1648
+ this.recordCommandResult(pending, event.type, "rejected", event.payload.code);
1649
+ this.flushDiagnostics("command_rejected");
1217
1650
  if (event.type === "context.rejected" && event.payload.retryable) {
1218
1651
  if (pending.retryTransfer && !pending.transferRetrying) {
1219
1652
  pending.transferRetrying = true;
@@ -1227,6 +1660,7 @@ export class RealtimeSession {
1227
1660
  }
1228
1661
  globalThis.clearTimeout(pending.timeout);
1229
1662
  globalThis.clearInterval(pending.retry);
1663
+ pending.markSettled();
1230
1664
  this.pendingCommands.delete(event.payload.message_id);
1231
1665
  pending.reject(new CommandError(event.payload));
1232
1666
  }
@@ -1235,12 +1669,32 @@ export class RealtimeSession {
1235
1669
  for (const [messageId, pending] of this.pendingCommands) {
1236
1670
  if (preserveContext && pending.commandType === "context.update")
1237
1671
  continue;
1672
+ this.recordCommandResult(pending, pending.commandType === "context.update" ? "context.rejected" : "command.error", "failed", "pending_command_cancelled");
1238
1673
  globalThis.clearTimeout(pending.timeout);
1239
1674
  globalThis.clearInterval(pending.retry);
1675
+ pending.markSettled();
1240
1676
  pending.reject(error);
1241
1677
  this.pendingCommands.delete(messageId);
1242
1678
  }
1243
1679
  }
1680
+ recordCommandResult(pending, eventType, status, reason) {
1681
+ const durationMs = Date.now() - pending.startedAt;
1682
+ this.recordDiagnostic({
1683
+ operation: "command_result",
1684
+ status,
1685
+ reason,
1686
+ message_id: pending.message.message_id,
1687
+ connection_id: pending.message.connection_id,
1688
+ client_seq: pending.message.client_seq,
1689
+ context_key: pending.contextKey,
1690
+ payload_bytes: pending.payloadBytes,
1691
+ duration_ms: durationMs,
1692
+ retry_count: pending.retryCount,
1693
+ event_type: eventType,
1694
+ });
1695
+ if (durationMs > 2_000)
1696
+ this.flushDiagnostics("slow_operation");
1697
+ }
1244
1698
  async fetchReplay(apiBase, afterSeq) {
1245
1699
  let nextAfter = afterSeq;
1246
1700
  while (true) {
@@ -1306,6 +1760,31 @@ function resolveAutoRejoinOptions(value) {
1306
1760
  }
1307
1761
  return resolved;
1308
1762
  }
1763
+ function resolveStartupRetryOptions(value) {
1764
+ if (!value)
1765
+ return null;
1766
+ const options = value === true ? {} : value;
1767
+ const attempts = (options.attempts ?? DEFAULT_STARTUP_ATTEMPTS).map((attempt) => ({
1768
+ readyTimeoutMs: attempt.readyTimeoutMs,
1769
+ delayBeforeMs: attempt.delayBeforeMs ?? 0,
1770
+ }));
1771
+ const timeoutMs = options.timeoutMs ?? DEFAULT_STARTUP_TIMEOUT_MS;
1772
+ if (attempts.length === 0) {
1773
+ throw new TypeError("startupRetry.attempts must contain at least one attempt");
1774
+ }
1775
+ for (const attempt of attempts) {
1776
+ if (!Number.isFinite(attempt.readyTimeoutMs) || attempt.readyTimeoutMs <= 0) {
1777
+ throw new TypeError("startupRetry.attempts readyTimeoutMs must be a positive finite number");
1778
+ }
1779
+ if (!Number.isFinite(attempt.delayBeforeMs) || attempt.delayBeforeMs < 0) {
1780
+ throw new TypeError("startupRetry.attempts delayBeforeMs must be a non-negative finite number");
1781
+ }
1782
+ }
1783
+ if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) {
1784
+ throw new TypeError("startupRetry.timeoutMs must be a positive finite number");
1785
+ }
1786
+ return { attempts, timeoutMs };
1787
+ }
1309
1788
  function isTerminalDisconnectReason(reason) {
1310
1789
  return reason !== undefined && [
1311
1790
  DisconnectReason.CLIENT_INITIATED,
@@ -1326,16 +1805,82 @@ function isTerminalRejoinError(error) {
1326
1805
  return error instanceof RealtimeSessionHttpError
1327
1806
  && [401, 403, 409].includes(error.status);
1328
1807
  }
1808
+ function isTerminalStartupError(error) {
1809
+ if (error instanceof SessionErrorEventError)
1810
+ return !error.retryable;
1811
+ if (error instanceof RealtimeSessionHttpError) {
1812
+ return error.status >= 400
1813
+ && error.status < 500
1814
+ && ![408, 425, 429].includes(error.status);
1815
+ }
1816
+ // Browser fetch also rejects network failures with TypeError. Only our
1817
+ // explicit configuration errors are terminal before transport is established.
1818
+ if (error instanceof SessionConfigurationError)
1819
+ return true;
1820
+ return error instanceof Error && [
1821
+ "ConstraintNotSatisfiedError",
1822
+ "DevicesNotFoundError",
1823
+ "InvalidStateError",
1824
+ "NotAllowedError",
1825
+ "NotFoundError",
1826
+ "NotReadableError",
1827
+ "NotSupportedError",
1828
+ "OverconstrainedError",
1829
+ "SecurityError",
1830
+ "TrackStartError",
1831
+ ].includes(error.name);
1832
+ }
1329
1833
  function toError(error) {
1330
1834
  return error instanceof Error ? error : new Error(String(error));
1331
1835
  }
1332
1836
  function abortError(signal) {
1333
1837
  return signal.reason ?? new DOMException("Aborted", "AbortError");
1334
1838
  }
1839
+ function createLinkedAbortController(parentSignal) {
1840
+ const controller = new AbortController();
1841
+ if (parentSignal.aborted) {
1842
+ controller.abort(abortError(parentSignal));
1843
+ }
1844
+ else {
1845
+ parentSignal.addEventListener("abort", () => controller.abort(abortError(parentSignal)), { once: true });
1846
+ }
1847
+ return controller;
1848
+ }
1335
1849
  function throwIfAborted(signal) {
1336
1850
  if (signal?.aborted)
1337
1851
  throw abortError(signal);
1338
1852
  }
1853
+ function raceWithAbort(promise, signal) {
1854
+ if (!signal)
1855
+ return promise;
1856
+ return new Promise((resolve, reject) => {
1857
+ let settled = false;
1858
+ const resolveOnce = (value) => {
1859
+ if (settled)
1860
+ return;
1861
+ settled = true;
1862
+ signal.removeEventListener("abort", onAbort);
1863
+ resolve(value);
1864
+ };
1865
+ const rejectOnce = (error) => {
1866
+ if (settled)
1867
+ return;
1868
+ settled = true;
1869
+ signal.removeEventListener("abort", onAbort);
1870
+ reject(error);
1871
+ };
1872
+ const onAbort = () => rejectOnce(abortError(signal));
1873
+ if (signal.aborted) {
1874
+ onAbort();
1875
+ }
1876
+ else {
1877
+ signal.addEventListener("abort", onAbort, { once: true });
1878
+ }
1879
+ // Always observe the operation, including when it aborted the signal before
1880
+ // returning its promise, so a later rejection cannot escape to the caller.
1881
+ promise.then(resolveOnce, rejectOnce);
1882
+ });
1883
+ }
1339
1884
  function abortableDelay(delayMs, signal) {
1340
1885
  throwIfAborted(signal);
1341
1886
  if (delayMs <= 0)
@@ -1389,24 +1934,61 @@ function contextStreamId() {
1389
1934
  return globalThis.crypto.randomUUID();
1390
1935
  return `ctx_${Date.now().toString(36)}_${Math.random().toString(36).slice(2)}`;
1391
1936
  }
1937
+ function contextUpdateBytes(update) {
1938
+ return "image" in update
1939
+ ? update.image.size
1940
+ : new TextEncoder().encode(update.text).byteLength;
1941
+ }
1942
+ function contextPayloadBytes(payload) {
1943
+ const image = payload.image;
1944
+ if (image && typeof image === "object" && "bytes" in image) {
1945
+ const bytes = image.bytes;
1946
+ if (typeof bytes === "number")
1947
+ return bytes;
1948
+ }
1949
+ const textStream = payload.text_stream;
1950
+ if (textStream && typeof textStream === "object" && "bytes" in textStream) {
1951
+ const bytes = textStream.bytes;
1952
+ if (typeof bytes === "number")
1953
+ return bytes;
1954
+ }
1955
+ if (typeof payload.text === "string") {
1956
+ return new TextEncoder().encode(payload.text).byteLength;
1957
+ }
1958
+ try {
1959
+ return new TextEncoder().encode(JSON.stringify(payload)).byteLength;
1960
+ }
1961
+ catch {
1962
+ return undefined;
1963
+ }
1964
+ }
1965
+ function contextKeyFromMessage(message) {
1966
+ if (message.type !== "context.update")
1967
+ return undefined;
1968
+ const key = message.payload.key;
1969
+ return typeof key === "string" ? key : undefined;
1970
+ }
1971
+ function diagnosticEventType(type) {
1972
+ return type === "client.hello" || type === "context.update" ? type : undefined;
1973
+ }
1392
1974
  function validateSessionCredentials(value) {
1393
1975
  if (!value || typeof value !== "object") {
1394
- throw new TypeError("Session credentials must be an object");
1976
+ throw new SessionConfigurationError("Session credentials must be an object");
1395
1977
  }
1396
1978
  if (!value.id || !value.access_token || !value.access_token_expires_at) {
1397
- throw new TypeError("Session credentials are incomplete");
1979
+ throw new SessionConfigurationError("Session credentials are incomplete");
1398
1980
  }
1399
1981
  if (!value.connection
1400
1982
  || !value.connection.url
1401
1983
  || !value.connection.token
1402
1984
  || !value.connection.room_name) {
1403
- throw new TypeError("Session connection credentials are incomplete");
1985
+ throw new SessionConfigurationError("Session connection credentials are incomplete");
1404
1986
  }
1405
1987
  }
1406
1988
  function normalizeApiBase(value) {
1407
1989
  const normalized = value.trim().replace(/\/+$/, "");
1408
1990
  if (!normalized) {
1409
- throw new TypeError("apiBase must be a non-root relative path or an absolute URL");
1991
+ throw new SessionConfigurationError("apiBase must be a non-root relative path or an absolute URL");
1410
1992
  }
1411
1993
  return normalized;
1412
1994
  }