@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/CHANGELOG.md +29 -0
- package/README.md +85 -7
- package/dist/browser/index.js +6 -6
- package/dist/browser/index.js.map +4 -4
- package/dist/browser/index.js.sha384 +1 -1
- package/dist/browser/manifest.json +1 -1
- package/dist/diagnostics.d.ts +61 -0
- package/dist/diagnostics.d.ts.map +1 -0
- package/dist/diagnostics.js +176 -0
- package/dist/diagnostics.js.map +1 -0
- package/dist/index.d.ts +44 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +636 -54
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
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.
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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.
|
|
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,
|
|
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,
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
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
|
|
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
|
|
1209
|
+
throw new SessionConfigurationError("audioTrack must be an audio MediaStreamTrack");
|
|
972
1210
|
}
|
|
973
1211
|
if (audioTrack.readyState === "ended") {
|
|
974
|
-
throw new
|
|
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.
|
|
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.
|
|
1401
|
+
await this.publishCommandAttempt(message, true);
|
|
1099
1402
|
}
|
|
1100
1403
|
: undefined,
|
|
1101
1404
|
transferRetrying: false,
|
|
1102
1405
|
});
|
|
1103
1406
|
});
|
|
1104
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
1991
|
+
throw new SessionConfigurationError("apiBase must be a non-root relative path or an absolute URL");
|
|
1410
1992
|
}
|
|
1411
1993
|
return normalized;
|
|
1412
1994
|
}
|