@voxevolv/client 0.3.3 → 0.3.4
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 +11 -0
- package/README.md +25 -4
- 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 +16 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +304 -24
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -1,12 +1,14 @@
|
|
|
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.4";
|
|
9
10
|
const CLIENT_HELLO_TIMEOUT_MS = 30_000;
|
|
11
|
+
const DIAGNOSTIC_REPORT_TIMEOUT_MS = 5_000;
|
|
10
12
|
export class RealtimeSessionHttpError extends Error {
|
|
11
13
|
status;
|
|
12
14
|
code;
|
|
@@ -78,6 +80,8 @@ export class RealtimeSession {
|
|
|
78
80
|
refreshCredentialsCallback;
|
|
79
81
|
autoRejoinOptions;
|
|
80
82
|
lifecycleCredentials;
|
|
83
|
+
diagnostics = new ClientDiagnosticBuffer({ sdkVersion: SDK_VERSION });
|
|
84
|
+
diagnosticReportTimeoutMs = DIAGNOSTIC_REPORT_TIMEOUT_MS;
|
|
81
85
|
constructor(credentials, options = {}) {
|
|
82
86
|
validateSessionCredentials(credentials);
|
|
83
87
|
this.id = credentials.id;
|
|
@@ -384,7 +388,7 @@ export class RealtimeSession {
|
|
|
384
388
|
const normalizedApiBase = normalizeApiBase(apiBase);
|
|
385
389
|
const recovering = this.hasConnectedValue;
|
|
386
390
|
if (recovering) {
|
|
387
|
-
this.beginRecovery("rejoin", 1);
|
|
391
|
+
this.beginRecovery("rejoin", 1, { trigger: "manual_rejoin" });
|
|
388
392
|
this.setState("resuming");
|
|
389
393
|
}
|
|
390
394
|
try {
|
|
@@ -457,18 +461,73 @@ export class RealtimeSession {
|
|
|
457
461
|
}
|
|
458
462
|
async sendContextUpdate(update, timeoutMs) {
|
|
459
463
|
const generation = this.contextGeneration;
|
|
464
|
+
const payloadBytes = contextUpdateBytes(update);
|
|
465
|
+
const transferStartedAt = Date.now();
|
|
466
|
+
let transferTimedOut = false;
|
|
467
|
+
this.recordDiagnostic({
|
|
468
|
+
operation: "context_transfer",
|
|
469
|
+
status: "started",
|
|
470
|
+
context_key: update.key,
|
|
471
|
+
payload_bytes: payloadBytes,
|
|
472
|
+
});
|
|
460
473
|
let transferTimeout;
|
|
461
474
|
const transferExpired = new Promise((_resolve, reject) => {
|
|
462
475
|
transferTimeout = globalThis.setTimeout(() => {
|
|
476
|
+
transferTimedOut = true;
|
|
463
477
|
const error = new Error(`Context transfer timed out after ${timeoutMs}ms`);
|
|
464
|
-
this.
|
|
478
|
+
this.recordDiagnostic({
|
|
479
|
+
operation: "context_transfer",
|
|
480
|
+
status: "timed_out",
|
|
481
|
+
reason: "context_transfer_timeout",
|
|
482
|
+
context_key: update.key,
|
|
483
|
+
payload_bytes: payloadBytes,
|
|
484
|
+
duration_ms: Date.now() - transferStartedAt,
|
|
485
|
+
timeout_ms: timeoutMs,
|
|
486
|
+
});
|
|
487
|
+
this.flushDiagnostics("context_transfer_timeout");
|
|
488
|
+
this.startAutoRejoin(error, {
|
|
489
|
+
trigger: "context_transfer_timeout",
|
|
490
|
+
operation: "context_transfer",
|
|
491
|
+
contextKey: update.key,
|
|
492
|
+
timeoutMs,
|
|
493
|
+
});
|
|
465
494
|
reject(error);
|
|
466
495
|
}, timeoutMs);
|
|
467
496
|
});
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
497
|
+
let transfer;
|
|
498
|
+
try {
|
|
499
|
+
transfer = await Promise.race([
|
|
500
|
+
"image" in update ? this.uploadImageContext(update) : this.uploadTextContext(update),
|
|
501
|
+
transferExpired,
|
|
502
|
+
]);
|
|
503
|
+
}
|
|
504
|
+
catch (error) {
|
|
505
|
+
if (!transferTimedOut) {
|
|
506
|
+
this.recordDiagnostic({
|
|
507
|
+
operation: "context_transfer",
|
|
508
|
+
status: "failed",
|
|
509
|
+
reason: "context_transfer_failed",
|
|
510
|
+
context_key: update.key,
|
|
511
|
+
payload_bytes: payloadBytes,
|
|
512
|
+
duration_ms: Date.now() - transferStartedAt,
|
|
513
|
+
});
|
|
514
|
+
this.flushDiagnostics("command_failed");
|
|
515
|
+
}
|
|
516
|
+
throw error;
|
|
517
|
+
}
|
|
518
|
+
finally {
|
|
519
|
+
globalThis.clearTimeout(transferTimeout);
|
|
520
|
+
}
|
|
521
|
+
const transferDurationMs = Date.now() - transferStartedAt;
|
|
522
|
+
this.recordDiagnostic({
|
|
523
|
+
operation: "context_transfer",
|
|
524
|
+
status: "succeeded",
|
|
525
|
+
context_key: update.key,
|
|
526
|
+
payload_bytes: payloadBytes,
|
|
527
|
+
duration_ms: transferDurationMs,
|
|
528
|
+
});
|
|
529
|
+
if (transferDurationMs > 2_000)
|
|
530
|
+
this.flushDiagnostics("slow_operation");
|
|
472
531
|
if (generation !== this.contextGeneration)
|
|
473
532
|
throw new Error("Context transfer interrupted by recovery");
|
|
474
533
|
const { payload } = transfer;
|
|
@@ -623,7 +682,7 @@ export class RealtimeSession {
|
|
|
623
682
|
bindRoomEvents() {
|
|
624
683
|
this.room.on(RoomEvent.ParticipantDisconnected, (participant) => {
|
|
625
684
|
if (participant.isAgent && !this.disconnectMode) {
|
|
626
|
-
this.startAutoRejoin(new Error("Agent disconnected from the room"));
|
|
685
|
+
this.startAutoRejoin(new Error("Agent disconnected from the room"), { trigger: "agent_disconnected" });
|
|
627
686
|
}
|
|
628
687
|
});
|
|
629
688
|
this.room.on(RoomEvent.SignalReconnecting, () => this.handleLiveKitReconnecting());
|
|
@@ -732,7 +791,7 @@ export class RealtimeSession {
|
|
|
732
791
|
this.reconnectStartedAt ??= Date.now();
|
|
733
792
|
this.rejectPendingCommands(new Error("Connection interrupted before command completion"), true);
|
|
734
793
|
if (this.activeRecovery === null) {
|
|
735
|
-
this.beginRecovery("livekit", 1);
|
|
794
|
+
this.beginRecovery("livekit", 1, { trigger: "livekit_reconnecting" });
|
|
736
795
|
}
|
|
737
796
|
this.setState("reconnecting");
|
|
738
797
|
}
|
|
@@ -750,7 +809,7 @@ export class RealtimeSession {
|
|
|
750
809
|
this.reconnectStartedAt = null;
|
|
751
810
|
}
|
|
752
811
|
if (this.hasConnectedValue && this.activeRecovery === null) {
|
|
753
|
-
this.beginRecovery("livekit", 1);
|
|
812
|
+
this.beginRecovery("livekit", 1, { trigger: "livekit_reconnecting" });
|
|
754
813
|
}
|
|
755
814
|
this.connectionId = `conn_${Math.random().toString(36).slice(2)}_${Date.now().toString(36)}`;
|
|
756
815
|
this.clientSeq = 0;
|
|
@@ -774,7 +833,7 @@ export class RealtimeSession {
|
|
|
774
833
|
return;
|
|
775
834
|
const normalizedError = toError(error);
|
|
776
835
|
if (this.shouldAutoRejoin()) {
|
|
777
|
-
this.startAutoRejoin(normalizedError);
|
|
836
|
+
this.startAutoRejoin(normalizedError, { trigger: "livekit_resume_failed" });
|
|
778
837
|
}
|
|
779
838
|
else {
|
|
780
839
|
this.failRecovery(normalizedError);
|
|
@@ -804,7 +863,7 @@ export class RealtimeSession {
|
|
|
804
863
|
return;
|
|
805
864
|
}
|
|
806
865
|
if (this.shouldAutoRejoin(reason)) {
|
|
807
|
-
this.startAutoRejoin(new Error(`LiveKit disconnected (${disconnectReasonName(reason)})`));
|
|
866
|
+
this.startAutoRejoin(new Error(`LiveKit disconnected (${disconnectReasonName(reason)})`), { trigger: "livekit_disconnected" });
|
|
808
867
|
return;
|
|
809
868
|
}
|
|
810
869
|
if (this.activeRecovery !== null) {
|
|
@@ -828,12 +887,22 @@ export class RealtimeSession {
|
|
|
828
887
|
this.endNotificationDelivered = true;
|
|
829
888
|
this.notifyListeners(this.endedListeners, event);
|
|
830
889
|
}
|
|
831
|
-
beginRecovery(strategy, attempt) {
|
|
832
|
-
this.activeRecovery = { strategy, attempt };
|
|
890
|
+
beginRecovery(strategy, attempt, details) {
|
|
891
|
+
this.activeRecovery = { strategy, attempt, details };
|
|
892
|
+
this.recordDiagnostic({
|
|
893
|
+
operation: "connection_recovery",
|
|
894
|
+
status: "started",
|
|
895
|
+
reason: details.trigger,
|
|
896
|
+
message_id: details.messageId,
|
|
897
|
+
context_key: details.contextKey,
|
|
898
|
+
timeout_ms: details.timeoutMs,
|
|
899
|
+
});
|
|
900
|
+
this.flushDiagnostics("recovery_started");
|
|
833
901
|
this.notifyListeners(this.recoveryListeners, {
|
|
834
902
|
status: "started",
|
|
835
903
|
strategy,
|
|
836
904
|
attempt,
|
|
905
|
+
...details,
|
|
837
906
|
});
|
|
838
907
|
}
|
|
839
908
|
completeRecovery() {
|
|
@@ -845,6 +914,7 @@ export class RealtimeSession {
|
|
|
845
914
|
status: "succeeded",
|
|
846
915
|
strategy: recovery.strategy,
|
|
847
916
|
attempt: recovery.attempt,
|
|
917
|
+
...recovery.details,
|
|
848
918
|
});
|
|
849
919
|
}
|
|
850
920
|
failRecovery(error) {
|
|
@@ -856,6 +926,7 @@ export class RealtimeSession {
|
|
|
856
926
|
status: "failed",
|
|
857
927
|
strategy: recovery.strategy,
|
|
858
928
|
attempt: recovery.attempt,
|
|
929
|
+
...recovery.details,
|
|
859
930
|
error,
|
|
860
931
|
});
|
|
861
932
|
}
|
|
@@ -866,7 +937,7 @@ export class RealtimeSession {
|
|
|
866
937
|
&& !["disconnected", "failed"].includes(this.stateValue)
|
|
867
938
|
&& !isTerminalDisconnectReason(reason));
|
|
868
939
|
}
|
|
869
|
-
startAutoRejoin(initialError) {
|
|
940
|
+
startAutoRejoin(initialError, details) {
|
|
870
941
|
if (this.autoRejoinPromise || !this.shouldAutoRejoin())
|
|
871
942
|
return;
|
|
872
943
|
this.agentReady = false;
|
|
@@ -887,7 +958,7 @@ export class RealtimeSession {
|
|
|
887
958
|
this.failRecovery(error);
|
|
888
959
|
this.setState("failed");
|
|
889
960
|
}, this.autoRejoinOptions.timeoutMs);
|
|
890
|
-
const promise = this.runAutoRejoin(initialError, controller.signal)
|
|
961
|
+
const promise = this.runAutoRejoin(initialError, controller.signal, details)
|
|
891
962
|
.finally(() => {
|
|
892
963
|
globalThis.clearTimeout(timeout);
|
|
893
964
|
if (this.autoRejoinController === controller) {
|
|
@@ -898,7 +969,7 @@ export class RealtimeSession {
|
|
|
898
969
|
this.autoRejoinPromise = promise;
|
|
899
970
|
void promise;
|
|
900
971
|
}
|
|
901
|
-
async runAutoRejoin(initialError, signal) {
|
|
972
|
+
async runAutoRejoin(initialError, signal, details) {
|
|
902
973
|
const options = this.autoRejoinOptions;
|
|
903
974
|
if (!options)
|
|
904
975
|
return;
|
|
@@ -916,7 +987,7 @@ export class RealtimeSession {
|
|
|
916
987
|
await abortableDelay(delayMs + Math.random() * Math.min(delayMs, 500), signal);
|
|
917
988
|
}
|
|
918
989
|
throwIfAborted(signal);
|
|
919
|
-
this.beginRecovery("rejoin", attempt);
|
|
990
|
+
this.beginRecovery("rejoin", attempt, details);
|
|
920
991
|
await this.performRejoin(this.apiBase, signal);
|
|
921
992
|
throwIfAborted(signal);
|
|
922
993
|
this.reconnectStartedAt = null;
|
|
@@ -1070,6 +1141,21 @@ export class RealtimeSession {
|
|
|
1070
1141
|
this.pendingReconnect = null;
|
|
1071
1142
|
}
|
|
1072
1143
|
async sendTrackedCommand(message, timeoutMs, retryTransfer) {
|
|
1144
|
+
const contextKey = contextKeyFromMessage(message);
|
|
1145
|
+
const payloadBytes = contextPayloadBytes(message.payload);
|
|
1146
|
+
const startedAt = Date.now();
|
|
1147
|
+
this.recordDiagnostic({
|
|
1148
|
+
operation: "command_send",
|
|
1149
|
+
status: "started",
|
|
1150
|
+
message_id: message.message_id,
|
|
1151
|
+
connection_id: message.connection_id,
|
|
1152
|
+
client_seq: message.client_seq,
|
|
1153
|
+
context_key: contextKey,
|
|
1154
|
+
payload_bytes: payloadBytes,
|
|
1155
|
+
retry_count: 0,
|
|
1156
|
+
timeout_ms: timeoutMs,
|
|
1157
|
+
event_type: diagnosticEventType(message.type),
|
|
1158
|
+
});
|
|
1073
1159
|
const response = new Promise((resolve, reject) => {
|
|
1074
1160
|
const timeout = globalThis.setTimeout(() => {
|
|
1075
1161
|
const pending = this.pendingCommands.get(message.message_id);
|
|
@@ -1077,17 +1163,46 @@ export class RealtimeSession {
|
|
|
1077
1163
|
globalThis.clearInterval(pending.retry);
|
|
1078
1164
|
this.pendingCommands.delete(message.message_id);
|
|
1079
1165
|
const error = new Error(`Command ${message.type} timed out after ${timeoutMs}ms`);
|
|
1166
|
+
this.recordDiagnostic({
|
|
1167
|
+
operation: "command_send",
|
|
1168
|
+
status: "timed_out",
|
|
1169
|
+
reason: message.type === "context.update"
|
|
1170
|
+
? "context_command_timeout"
|
|
1171
|
+
: "command_timeout",
|
|
1172
|
+
message_id: message.message_id,
|
|
1173
|
+
connection_id: message.connection_id,
|
|
1174
|
+
client_seq: message.client_seq,
|
|
1175
|
+
context_key: contextKey,
|
|
1176
|
+
payload_bytes: payloadBytes,
|
|
1177
|
+
duration_ms: Date.now() - startedAt,
|
|
1178
|
+
retry_count: pending?.retryCount ?? 0,
|
|
1179
|
+
timeout_ms: timeoutMs,
|
|
1180
|
+
event_type: diagnosticEventType(message.type),
|
|
1181
|
+
});
|
|
1182
|
+
this.flushDiagnostics("command_timeout");
|
|
1080
1183
|
reject(error);
|
|
1081
|
-
if (message.type === "context.update")
|
|
1082
|
-
this.startAutoRejoin(error
|
|
1184
|
+
if (message.type === "context.update") {
|
|
1185
|
+
this.startAutoRejoin(error, {
|
|
1186
|
+
trigger: "context_command_timeout",
|
|
1187
|
+
operation: "command_send",
|
|
1188
|
+
messageId: message.message_id,
|
|
1189
|
+
contextKey: contextKeyFromMessage(message),
|
|
1190
|
+
timeoutMs,
|
|
1191
|
+
});
|
|
1192
|
+
}
|
|
1083
1193
|
}, timeoutMs);
|
|
1084
1194
|
const retry = globalThis.setInterval(() => {
|
|
1085
|
-
void this.
|
|
1195
|
+
void this.publishCommandAttempt(message, true).catch(() => {
|
|
1086
1196
|
// A reconnect may temporarily reject writes; retry until timeout.
|
|
1087
1197
|
});
|
|
1088
1198
|
}, 500);
|
|
1089
1199
|
this.pendingCommands.set(message.message_id, {
|
|
1090
1200
|
commandType: message.type,
|
|
1201
|
+
message,
|
|
1202
|
+
startedAt,
|
|
1203
|
+
contextKey,
|
|
1204
|
+
payloadBytes,
|
|
1205
|
+
retryCount: 0,
|
|
1091
1206
|
resolve,
|
|
1092
1207
|
reject,
|
|
1093
1208
|
timeout,
|
|
@@ -1095,14 +1210,14 @@ export class RealtimeSession {
|
|
|
1095
1210
|
retryTransfer: retryTransfer
|
|
1096
1211
|
? async () => {
|
|
1097
1212
|
await retryTransfer();
|
|
1098
|
-
await this.
|
|
1213
|
+
await this.publishCommandAttempt(message, true);
|
|
1099
1214
|
}
|
|
1100
1215
|
: undefined,
|
|
1101
1216
|
transferRetrying: false,
|
|
1102
1217
|
});
|
|
1103
1218
|
});
|
|
1104
1219
|
try {
|
|
1105
|
-
await this.
|
|
1220
|
+
await this.publishCommandAttempt(message, false);
|
|
1106
1221
|
}
|
|
1107
1222
|
catch {
|
|
1108
1223
|
// The reliable data channel may still be warming immediately after a
|
|
@@ -1111,9 +1226,114 @@ export class RealtimeSession {
|
|
|
1111
1226
|
}
|
|
1112
1227
|
return response;
|
|
1113
1228
|
}
|
|
1229
|
+
async publishCommandAttempt(message, retrying) {
|
|
1230
|
+
const pending = this.pendingCommands.get(message.message_id);
|
|
1231
|
+
if (!pending)
|
|
1232
|
+
return;
|
|
1233
|
+
if (retrying) {
|
|
1234
|
+
pending.retryCount += 1;
|
|
1235
|
+
this.recordDiagnostic({
|
|
1236
|
+
operation: "command_send",
|
|
1237
|
+
status: "retrying",
|
|
1238
|
+
message_id: message.message_id,
|
|
1239
|
+
connection_id: message.connection_id,
|
|
1240
|
+
client_seq: message.client_seq,
|
|
1241
|
+
context_key: pending.contextKey,
|
|
1242
|
+
payload_bytes: pending.payloadBytes,
|
|
1243
|
+
retry_count: pending.retryCount,
|
|
1244
|
+
event_type: diagnosticEventType(message.type),
|
|
1245
|
+
});
|
|
1246
|
+
}
|
|
1247
|
+
const attemptStartedAt = Date.now();
|
|
1248
|
+
try {
|
|
1249
|
+
await this.publishCommand(message);
|
|
1250
|
+
}
|
|
1251
|
+
catch (error) {
|
|
1252
|
+
this.recordDiagnostic({
|
|
1253
|
+
operation: "command_send",
|
|
1254
|
+
status: "failed",
|
|
1255
|
+
reason: "command_publish_failed",
|
|
1256
|
+
message_id: message.message_id,
|
|
1257
|
+
connection_id: message.connection_id,
|
|
1258
|
+
client_seq: message.client_seq,
|
|
1259
|
+
context_key: pending.contextKey,
|
|
1260
|
+
payload_bytes: pending.payloadBytes,
|
|
1261
|
+
duration_ms: Date.now() - attemptStartedAt,
|
|
1262
|
+
retry_count: pending.retryCount,
|
|
1263
|
+
event_type: diagnosticEventType(message.type),
|
|
1264
|
+
});
|
|
1265
|
+
this.flushDiagnostics("command_failed");
|
|
1266
|
+
throw error;
|
|
1267
|
+
}
|
|
1268
|
+
const durationMs = Date.now() - attemptStartedAt;
|
|
1269
|
+
this.recordDiagnostic({
|
|
1270
|
+
operation: "command_send",
|
|
1271
|
+
status: "succeeded",
|
|
1272
|
+
message_id: message.message_id,
|
|
1273
|
+
connection_id: message.connection_id,
|
|
1274
|
+
client_seq: message.client_seq,
|
|
1275
|
+
context_key: pending.contextKey,
|
|
1276
|
+
payload_bytes: pending.payloadBytes,
|
|
1277
|
+
duration_ms: durationMs,
|
|
1278
|
+
retry_count: pending.retryCount,
|
|
1279
|
+
event_type: diagnosticEventType(message.type),
|
|
1280
|
+
});
|
|
1281
|
+
if (durationMs > 2_000)
|
|
1282
|
+
this.flushDiagnostics("slow_operation");
|
|
1283
|
+
}
|
|
1114
1284
|
async publishCommand(message) {
|
|
1115
1285
|
await this.room.localParticipant.publishData(new TextEncoder().encode(JSON.stringify(message)), { reliable: true, topic: "voxevolv.commands" });
|
|
1116
1286
|
}
|
|
1287
|
+
recordDiagnostic(entry) {
|
|
1288
|
+
this.diagnostics.record({
|
|
1289
|
+
...entry,
|
|
1290
|
+
connection_state: this.stateValue,
|
|
1291
|
+
buffered_bytes: this.reliableDataChannelBufferedBytes(),
|
|
1292
|
+
});
|
|
1293
|
+
}
|
|
1294
|
+
reliableDataChannelBufferedBytes() {
|
|
1295
|
+
try {
|
|
1296
|
+
const bufferedAmount = this.room.engine.dataChannelForKind(0)?.bufferedAmount;
|
|
1297
|
+
return typeof bufferedAmount === "number" ? bufferedAmount : undefined;
|
|
1298
|
+
}
|
|
1299
|
+
catch {
|
|
1300
|
+
return undefined;
|
|
1301
|
+
}
|
|
1302
|
+
}
|
|
1303
|
+
flushDiagnostics(trigger) {
|
|
1304
|
+
void this.diagnostics.flush(trigger, (report) => this.postDiagnostics(report));
|
|
1305
|
+
}
|
|
1306
|
+
async postDiagnostics(report) {
|
|
1307
|
+
const controller = new AbortController();
|
|
1308
|
+
let timeout;
|
|
1309
|
+
const timeoutExpired = new Promise((_resolve, reject) => {
|
|
1310
|
+
timeout = globalThis.setTimeout(() => {
|
|
1311
|
+
controller.abort();
|
|
1312
|
+
reject(new Error(`Diagnostic report timed out after ${this.diagnosticReportTimeoutMs}ms`));
|
|
1313
|
+
}, this.diagnosticReportTimeoutMs);
|
|
1314
|
+
});
|
|
1315
|
+
try {
|
|
1316
|
+
const response = await Promise.race([
|
|
1317
|
+
this.fetcher(`${this.apiBase}/sessions/${this.id}/diagnostics`, {
|
|
1318
|
+
method: "POST",
|
|
1319
|
+
headers: {
|
|
1320
|
+
Authorization: `Bearer ${this.accessToken}`,
|
|
1321
|
+
"Content-Type": "application/json",
|
|
1322
|
+
},
|
|
1323
|
+
credentials: this.lifecycleCredentials,
|
|
1324
|
+
body: JSON.stringify(report),
|
|
1325
|
+
signal: controller.signal,
|
|
1326
|
+
}),
|
|
1327
|
+
timeoutExpired,
|
|
1328
|
+
]);
|
|
1329
|
+
if (!response.ok)
|
|
1330
|
+
throw new Error(`Diagnostic report failed (${response.status})`);
|
|
1331
|
+
}
|
|
1332
|
+
finally {
|
|
1333
|
+
if (timeout !== undefined)
|
|
1334
|
+
globalThis.clearTimeout(timeout);
|
|
1335
|
+
}
|
|
1336
|
+
}
|
|
1117
1337
|
waitForRemoteAudioTrack(deadline, signal) {
|
|
1118
1338
|
if ((this.outputMediaStream?.getAudioTracks().length ?? 0) > 0)
|
|
1119
1339
|
return Promise.resolve();
|
|
@@ -1177,7 +1397,7 @@ export class RealtimeSession {
|
|
|
1177
1397
|
if (event.type !== "agent.ready" && !snapshotReady)
|
|
1178
1398
|
return;
|
|
1179
1399
|
if (this.agentStreamId && this.agentStreamId !== event.stream_id && !this.activeRecovery) {
|
|
1180
|
-
this.startAutoRejoin(new Error("Agent worker was replaced"));
|
|
1400
|
+
this.startAutoRejoin(new Error("Agent worker was replaced"), { trigger: "worker_replaced" });
|
|
1181
1401
|
return;
|
|
1182
1402
|
}
|
|
1183
1403
|
this.agentStreamId = event.stream_id;
|
|
@@ -1199,6 +1419,7 @@ export class RealtimeSession {
|
|
|
1199
1419
|
if (!pending)
|
|
1200
1420
|
return;
|
|
1201
1421
|
if (event.type === "command.ack" && isCommandAckPayload(event.payload)) {
|
|
1422
|
+
this.recordCommandResult(pending, event.type, "acknowledged");
|
|
1202
1423
|
globalThis.clearTimeout(pending.timeout);
|
|
1203
1424
|
globalThis.clearInterval(pending.retry);
|
|
1204
1425
|
this.pendingCommands.delete(event.payload.message_id);
|
|
@@ -1206,6 +1427,7 @@ export class RealtimeSession {
|
|
|
1206
1427
|
return;
|
|
1207
1428
|
}
|
|
1208
1429
|
if (event.type === "context.updated" && isContextUpdatedPayload(event.payload)) {
|
|
1430
|
+
this.recordCommandResult(pending, event.type, "acknowledged");
|
|
1209
1431
|
globalThis.clearTimeout(pending.timeout);
|
|
1210
1432
|
globalThis.clearInterval(pending.retry);
|
|
1211
1433
|
this.pendingCommands.delete(event.payload.message_id);
|
|
@@ -1214,6 +1436,8 @@ export class RealtimeSession {
|
|
|
1214
1436
|
}
|
|
1215
1437
|
if ((event.type === "command.error" && isCommandErrorPayload(event.payload))
|
|
1216
1438
|
|| (event.type === "context.rejected" && isContextRejectedPayload(event.payload))) {
|
|
1439
|
+
this.recordCommandResult(pending, event.type, "rejected", event.payload.code);
|
|
1440
|
+
this.flushDiagnostics("command_rejected");
|
|
1217
1441
|
if (event.type === "context.rejected" && event.payload.retryable) {
|
|
1218
1442
|
if (pending.retryTransfer && !pending.transferRetrying) {
|
|
1219
1443
|
pending.transferRetrying = true;
|
|
@@ -1235,12 +1459,31 @@ export class RealtimeSession {
|
|
|
1235
1459
|
for (const [messageId, pending] of this.pendingCommands) {
|
|
1236
1460
|
if (preserveContext && pending.commandType === "context.update")
|
|
1237
1461
|
continue;
|
|
1462
|
+
this.recordCommandResult(pending, pending.commandType === "context.update" ? "context.rejected" : "command.error", "failed", "pending_command_cancelled");
|
|
1238
1463
|
globalThis.clearTimeout(pending.timeout);
|
|
1239
1464
|
globalThis.clearInterval(pending.retry);
|
|
1240
1465
|
pending.reject(error);
|
|
1241
1466
|
this.pendingCommands.delete(messageId);
|
|
1242
1467
|
}
|
|
1243
1468
|
}
|
|
1469
|
+
recordCommandResult(pending, eventType, status, reason) {
|
|
1470
|
+
const durationMs = Date.now() - pending.startedAt;
|
|
1471
|
+
this.recordDiagnostic({
|
|
1472
|
+
operation: "command_result",
|
|
1473
|
+
status,
|
|
1474
|
+
reason,
|
|
1475
|
+
message_id: pending.message.message_id,
|
|
1476
|
+
connection_id: pending.message.connection_id,
|
|
1477
|
+
client_seq: pending.message.client_seq,
|
|
1478
|
+
context_key: pending.contextKey,
|
|
1479
|
+
payload_bytes: pending.payloadBytes,
|
|
1480
|
+
duration_ms: durationMs,
|
|
1481
|
+
retry_count: pending.retryCount,
|
|
1482
|
+
event_type: eventType,
|
|
1483
|
+
});
|
|
1484
|
+
if (durationMs > 2_000)
|
|
1485
|
+
this.flushDiagnostics("slow_operation");
|
|
1486
|
+
}
|
|
1244
1487
|
async fetchReplay(apiBase, afterSeq) {
|
|
1245
1488
|
let nextAfter = afterSeq;
|
|
1246
1489
|
while (true) {
|
|
@@ -1389,6 +1632,43 @@ function contextStreamId() {
|
|
|
1389
1632
|
return globalThis.crypto.randomUUID();
|
|
1390
1633
|
return `ctx_${Date.now().toString(36)}_${Math.random().toString(36).slice(2)}`;
|
|
1391
1634
|
}
|
|
1635
|
+
function contextUpdateBytes(update) {
|
|
1636
|
+
return "image" in update
|
|
1637
|
+
? update.image.size
|
|
1638
|
+
: new TextEncoder().encode(update.text).byteLength;
|
|
1639
|
+
}
|
|
1640
|
+
function contextPayloadBytes(payload) {
|
|
1641
|
+
const image = payload.image;
|
|
1642
|
+
if (image && typeof image === "object" && "bytes" in image) {
|
|
1643
|
+
const bytes = image.bytes;
|
|
1644
|
+
if (typeof bytes === "number")
|
|
1645
|
+
return bytes;
|
|
1646
|
+
}
|
|
1647
|
+
const textStream = payload.text_stream;
|
|
1648
|
+
if (textStream && typeof textStream === "object" && "bytes" in textStream) {
|
|
1649
|
+
const bytes = textStream.bytes;
|
|
1650
|
+
if (typeof bytes === "number")
|
|
1651
|
+
return bytes;
|
|
1652
|
+
}
|
|
1653
|
+
if (typeof payload.text === "string") {
|
|
1654
|
+
return new TextEncoder().encode(payload.text).byteLength;
|
|
1655
|
+
}
|
|
1656
|
+
try {
|
|
1657
|
+
return new TextEncoder().encode(JSON.stringify(payload)).byteLength;
|
|
1658
|
+
}
|
|
1659
|
+
catch {
|
|
1660
|
+
return undefined;
|
|
1661
|
+
}
|
|
1662
|
+
}
|
|
1663
|
+
function contextKeyFromMessage(message) {
|
|
1664
|
+
if (message.type !== "context.update")
|
|
1665
|
+
return undefined;
|
|
1666
|
+
const key = message.payload.key;
|
|
1667
|
+
return typeof key === "string" ? key : undefined;
|
|
1668
|
+
}
|
|
1669
|
+
function diagnosticEventType(type) {
|
|
1670
|
+
return type === "client.hello" || type === "context.update" ? type : undefined;
|
|
1671
|
+
}
|
|
1392
1672
|
function validateSessionCredentials(value) {
|
|
1393
1673
|
if (!value || typeof value !== "object") {
|
|
1394
1674
|
throw new TypeError("Session credentials must be an object");
|