@voxevolv/client 0.3.2 → 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 +16 -0
- package/README.md +29 -6
- 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 +23 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +319 -24
- package/dist/index.js.map +1 -1
- package/dist/transcripts.d.ts +21 -0
- package/dist/transcripts.d.ts.map +1 -0
- package/dist/transcripts.js +45 -0
- package/dist/transcripts.js.map +1 -0
- package/package.json +2 -2
package/dist/index.js
CHANGED
|
@@ -1,10 +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";
|
|
5
|
+
import { TranscriptStore } from "./transcripts.js";
|
|
6
|
+
export { TranscriptStore } from "./transcripts.js";
|
|
4
7
|
export { CommandError, EventCursor, createClientMessage, isCommandAckPayload, isCommandErrorPayload, isContextRejectedPayload, isContextUpdatePayload, isContextUpdatedPayload, isRealtimeEvent, } from "./protocol.js";
|
|
5
8
|
export { decodeAgentSnapshotPayload, decodeClientHelloPayload, decodeClientMessage, decodeCommandAckPayload, decodeCommandErrorPayload, decodeContextRejectedPayload, decodeContextUpdatePayload, decodeContextUpdatedPayload, decodeRealtimeEvent, } from "./generated/protocol-codecs.js";
|
|
6
|
-
export const SDK_VERSION = "0.3.
|
|
9
|
+
export const SDK_VERSION = "0.3.4";
|
|
7
10
|
const CLIENT_HELLO_TIMEOUT_MS = 30_000;
|
|
11
|
+
const DIAGNOSTIC_REPORT_TIMEOUT_MS = 5_000;
|
|
8
12
|
export class RealtimeSessionHttpError extends Error {
|
|
9
13
|
status;
|
|
10
14
|
code;
|
|
@@ -35,6 +39,8 @@ export class RealtimeSession {
|
|
|
35
39
|
readyListeners = new Set();
|
|
36
40
|
userTranscriptListeners = new Set();
|
|
37
41
|
agentTranscriptListeners = new Set();
|
|
42
|
+
transcriptListeners = new Set();
|
|
43
|
+
transcriptStore = new TranscriptStore();
|
|
38
44
|
errorListeners = new Set();
|
|
39
45
|
endedListeners = new Set();
|
|
40
46
|
toolCallListeners = new Set();
|
|
@@ -74,6 +80,8 @@ export class RealtimeSession {
|
|
|
74
80
|
refreshCredentialsCallback;
|
|
75
81
|
autoRejoinOptions;
|
|
76
82
|
lifecycleCredentials;
|
|
83
|
+
diagnostics = new ClientDiagnosticBuffer({ sdkVersion: SDK_VERSION });
|
|
84
|
+
diagnosticReportTimeoutMs = DIAGNOSTIC_REPORT_TIMEOUT_MS;
|
|
77
85
|
constructor(credentials, options = {}) {
|
|
78
86
|
validateSessionCredentials(credentials);
|
|
79
87
|
this.id = credentials.id;
|
|
@@ -248,6 +256,14 @@ export class RealtimeSession {
|
|
|
248
256
|
this.agentTranscriptListeners.add(listener);
|
|
249
257
|
return () => this.agentTranscriptListeners.delete(listener);
|
|
250
258
|
}
|
|
259
|
+
/** Latest message snapshots, retained across transport recovery and replay. */
|
|
260
|
+
get transcript() {
|
|
261
|
+
return this.transcriptStore.snapshot;
|
|
262
|
+
}
|
|
263
|
+
onTranscriptUpdated(listener) {
|
|
264
|
+
this.transcriptListeners.add(listener);
|
|
265
|
+
return () => this.transcriptListeners.delete(listener);
|
|
266
|
+
}
|
|
251
267
|
onError(listener) {
|
|
252
268
|
this.errorListeners.add(listener);
|
|
253
269
|
return () => this.errorListeners.delete(listener);
|
|
@@ -372,7 +388,7 @@ export class RealtimeSession {
|
|
|
372
388
|
const normalizedApiBase = normalizeApiBase(apiBase);
|
|
373
389
|
const recovering = this.hasConnectedValue;
|
|
374
390
|
if (recovering) {
|
|
375
|
-
this.beginRecovery("rejoin", 1);
|
|
391
|
+
this.beginRecovery("rejoin", 1, { trigger: "manual_rejoin" });
|
|
376
392
|
this.setState("resuming");
|
|
377
393
|
}
|
|
378
394
|
try {
|
|
@@ -445,18 +461,73 @@ export class RealtimeSession {
|
|
|
445
461
|
}
|
|
446
462
|
async sendContextUpdate(update, timeoutMs) {
|
|
447
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
|
+
});
|
|
448
473
|
let transferTimeout;
|
|
449
474
|
const transferExpired = new Promise((_resolve, reject) => {
|
|
450
475
|
transferTimeout = globalThis.setTimeout(() => {
|
|
476
|
+
transferTimedOut = true;
|
|
451
477
|
const error = new Error(`Context transfer timed out after ${timeoutMs}ms`);
|
|
452
|
-
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
|
+
});
|
|
453
494
|
reject(error);
|
|
454
495
|
}, timeoutMs);
|
|
455
496
|
});
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
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");
|
|
460
531
|
if (generation !== this.contextGeneration)
|
|
461
532
|
throw new Error("Context transfer interrupted by recovery");
|
|
462
533
|
const { payload } = transfer;
|
|
@@ -611,7 +682,7 @@ export class RealtimeSession {
|
|
|
611
682
|
bindRoomEvents() {
|
|
612
683
|
this.room.on(RoomEvent.ParticipantDisconnected, (participant) => {
|
|
613
684
|
if (participant.isAgent && !this.disconnectMode) {
|
|
614
|
-
this.startAutoRejoin(new Error("Agent disconnected from the room"));
|
|
685
|
+
this.startAutoRejoin(new Error("Agent disconnected from the room"), { trigger: "agent_disconnected" });
|
|
615
686
|
}
|
|
616
687
|
});
|
|
617
688
|
this.room.on(RoomEvent.SignalReconnecting, () => this.handleLiveKitReconnecting());
|
|
@@ -720,7 +791,7 @@ export class RealtimeSession {
|
|
|
720
791
|
this.reconnectStartedAt ??= Date.now();
|
|
721
792
|
this.rejectPendingCommands(new Error("Connection interrupted before command completion"), true);
|
|
722
793
|
if (this.activeRecovery === null) {
|
|
723
|
-
this.beginRecovery("livekit", 1);
|
|
794
|
+
this.beginRecovery("livekit", 1, { trigger: "livekit_reconnecting" });
|
|
724
795
|
}
|
|
725
796
|
this.setState("reconnecting");
|
|
726
797
|
}
|
|
@@ -738,7 +809,7 @@ export class RealtimeSession {
|
|
|
738
809
|
this.reconnectStartedAt = null;
|
|
739
810
|
}
|
|
740
811
|
if (this.hasConnectedValue && this.activeRecovery === null) {
|
|
741
|
-
this.beginRecovery("livekit", 1);
|
|
812
|
+
this.beginRecovery("livekit", 1, { trigger: "livekit_reconnecting" });
|
|
742
813
|
}
|
|
743
814
|
this.connectionId = `conn_${Math.random().toString(36).slice(2)}_${Date.now().toString(36)}`;
|
|
744
815
|
this.clientSeq = 0;
|
|
@@ -762,7 +833,7 @@ export class RealtimeSession {
|
|
|
762
833
|
return;
|
|
763
834
|
const normalizedError = toError(error);
|
|
764
835
|
if (this.shouldAutoRejoin()) {
|
|
765
|
-
this.startAutoRejoin(normalizedError);
|
|
836
|
+
this.startAutoRejoin(normalizedError, { trigger: "livekit_resume_failed" });
|
|
766
837
|
}
|
|
767
838
|
else {
|
|
768
839
|
this.failRecovery(normalizedError);
|
|
@@ -792,7 +863,7 @@ export class RealtimeSession {
|
|
|
792
863
|
return;
|
|
793
864
|
}
|
|
794
865
|
if (this.shouldAutoRejoin(reason)) {
|
|
795
|
-
this.startAutoRejoin(new Error(`LiveKit disconnected (${disconnectReasonName(reason)})`));
|
|
866
|
+
this.startAutoRejoin(new Error(`LiveKit disconnected (${disconnectReasonName(reason)})`), { trigger: "livekit_disconnected" });
|
|
796
867
|
return;
|
|
797
868
|
}
|
|
798
869
|
if (this.activeRecovery !== null) {
|
|
@@ -816,12 +887,22 @@ export class RealtimeSession {
|
|
|
816
887
|
this.endNotificationDelivered = true;
|
|
817
888
|
this.notifyListeners(this.endedListeners, event);
|
|
818
889
|
}
|
|
819
|
-
beginRecovery(strategy, attempt) {
|
|
820
|
-
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");
|
|
821
901
|
this.notifyListeners(this.recoveryListeners, {
|
|
822
902
|
status: "started",
|
|
823
903
|
strategy,
|
|
824
904
|
attempt,
|
|
905
|
+
...details,
|
|
825
906
|
});
|
|
826
907
|
}
|
|
827
908
|
completeRecovery() {
|
|
@@ -833,6 +914,7 @@ export class RealtimeSession {
|
|
|
833
914
|
status: "succeeded",
|
|
834
915
|
strategy: recovery.strategy,
|
|
835
916
|
attempt: recovery.attempt,
|
|
917
|
+
...recovery.details,
|
|
836
918
|
});
|
|
837
919
|
}
|
|
838
920
|
failRecovery(error) {
|
|
@@ -844,6 +926,7 @@ export class RealtimeSession {
|
|
|
844
926
|
status: "failed",
|
|
845
927
|
strategy: recovery.strategy,
|
|
846
928
|
attempt: recovery.attempt,
|
|
929
|
+
...recovery.details,
|
|
847
930
|
error,
|
|
848
931
|
});
|
|
849
932
|
}
|
|
@@ -854,7 +937,7 @@ export class RealtimeSession {
|
|
|
854
937
|
&& !["disconnected", "failed"].includes(this.stateValue)
|
|
855
938
|
&& !isTerminalDisconnectReason(reason));
|
|
856
939
|
}
|
|
857
|
-
startAutoRejoin(initialError) {
|
|
940
|
+
startAutoRejoin(initialError, details) {
|
|
858
941
|
if (this.autoRejoinPromise || !this.shouldAutoRejoin())
|
|
859
942
|
return;
|
|
860
943
|
this.agentReady = false;
|
|
@@ -875,7 +958,7 @@ export class RealtimeSession {
|
|
|
875
958
|
this.failRecovery(error);
|
|
876
959
|
this.setState("failed");
|
|
877
960
|
}, this.autoRejoinOptions.timeoutMs);
|
|
878
|
-
const promise = this.runAutoRejoin(initialError, controller.signal)
|
|
961
|
+
const promise = this.runAutoRejoin(initialError, controller.signal, details)
|
|
879
962
|
.finally(() => {
|
|
880
963
|
globalThis.clearTimeout(timeout);
|
|
881
964
|
if (this.autoRejoinController === controller) {
|
|
@@ -886,7 +969,7 @@ export class RealtimeSession {
|
|
|
886
969
|
this.autoRejoinPromise = promise;
|
|
887
970
|
void promise;
|
|
888
971
|
}
|
|
889
|
-
async runAutoRejoin(initialError, signal) {
|
|
972
|
+
async runAutoRejoin(initialError, signal, details) {
|
|
890
973
|
const options = this.autoRejoinOptions;
|
|
891
974
|
if (!options)
|
|
892
975
|
return;
|
|
@@ -904,7 +987,7 @@ export class RealtimeSession {
|
|
|
904
987
|
await abortableDelay(delayMs + Math.random() * Math.min(delayMs, 500), signal);
|
|
905
988
|
}
|
|
906
989
|
throwIfAborted(signal);
|
|
907
|
-
this.beginRecovery("rejoin", attempt);
|
|
990
|
+
this.beginRecovery("rejoin", attempt, details);
|
|
908
991
|
await this.performRejoin(this.apiBase, signal);
|
|
909
992
|
throwIfAborted(signal);
|
|
910
993
|
this.reconnectStartedAt = null;
|
|
@@ -1023,6 +1106,9 @@ export class RealtimeSession {
|
|
|
1023
1106
|
}
|
|
1024
1107
|
if (event.type.endsWith(".error"))
|
|
1025
1108
|
this.notifyListeners(this.errorListeners, event);
|
|
1109
|
+
const message = this.transcriptStore.accept(event);
|
|
1110
|
+
if (message)
|
|
1111
|
+
this.notifyListeners(this.transcriptListeners, message);
|
|
1026
1112
|
const transcript = transcriptEvent(event);
|
|
1027
1113
|
if (!transcript)
|
|
1028
1114
|
return;
|
|
@@ -1055,6 +1141,21 @@ export class RealtimeSession {
|
|
|
1055
1141
|
this.pendingReconnect = null;
|
|
1056
1142
|
}
|
|
1057
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
|
+
});
|
|
1058
1159
|
const response = new Promise((resolve, reject) => {
|
|
1059
1160
|
const timeout = globalThis.setTimeout(() => {
|
|
1060
1161
|
const pending = this.pendingCommands.get(message.message_id);
|
|
@@ -1062,17 +1163,46 @@ export class RealtimeSession {
|
|
|
1062
1163
|
globalThis.clearInterval(pending.retry);
|
|
1063
1164
|
this.pendingCommands.delete(message.message_id);
|
|
1064
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");
|
|
1065
1183
|
reject(error);
|
|
1066
|
-
if (message.type === "context.update")
|
|
1067
|
-
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
|
+
}
|
|
1068
1193
|
}, timeoutMs);
|
|
1069
1194
|
const retry = globalThis.setInterval(() => {
|
|
1070
|
-
void this.
|
|
1195
|
+
void this.publishCommandAttempt(message, true).catch(() => {
|
|
1071
1196
|
// A reconnect may temporarily reject writes; retry until timeout.
|
|
1072
1197
|
});
|
|
1073
1198
|
}, 500);
|
|
1074
1199
|
this.pendingCommands.set(message.message_id, {
|
|
1075
1200
|
commandType: message.type,
|
|
1201
|
+
message,
|
|
1202
|
+
startedAt,
|
|
1203
|
+
contextKey,
|
|
1204
|
+
payloadBytes,
|
|
1205
|
+
retryCount: 0,
|
|
1076
1206
|
resolve,
|
|
1077
1207
|
reject,
|
|
1078
1208
|
timeout,
|
|
@@ -1080,14 +1210,14 @@ export class RealtimeSession {
|
|
|
1080
1210
|
retryTransfer: retryTransfer
|
|
1081
1211
|
? async () => {
|
|
1082
1212
|
await retryTransfer();
|
|
1083
|
-
await this.
|
|
1213
|
+
await this.publishCommandAttempt(message, true);
|
|
1084
1214
|
}
|
|
1085
1215
|
: undefined,
|
|
1086
1216
|
transferRetrying: false,
|
|
1087
1217
|
});
|
|
1088
1218
|
});
|
|
1089
1219
|
try {
|
|
1090
|
-
await this.
|
|
1220
|
+
await this.publishCommandAttempt(message, false);
|
|
1091
1221
|
}
|
|
1092
1222
|
catch {
|
|
1093
1223
|
// The reliable data channel may still be warming immediately after a
|
|
@@ -1096,9 +1226,114 @@ export class RealtimeSession {
|
|
|
1096
1226
|
}
|
|
1097
1227
|
return response;
|
|
1098
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
|
+
}
|
|
1099
1284
|
async publishCommand(message) {
|
|
1100
1285
|
await this.room.localParticipant.publishData(new TextEncoder().encode(JSON.stringify(message)), { reliable: true, topic: "voxevolv.commands" });
|
|
1101
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
|
+
}
|
|
1102
1337
|
waitForRemoteAudioTrack(deadline, signal) {
|
|
1103
1338
|
if ((this.outputMediaStream?.getAudioTracks().length ?? 0) > 0)
|
|
1104
1339
|
return Promise.resolve();
|
|
@@ -1162,7 +1397,7 @@ export class RealtimeSession {
|
|
|
1162
1397
|
if (event.type !== "agent.ready" && !snapshotReady)
|
|
1163
1398
|
return;
|
|
1164
1399
|
if (this.agentStreamId && this.agentStreamId !== event.stream_id && !this.activeRecovery) {
|
|
1165
|
-
this.startAutoRejoin(new Error("Agent worker was replaced"));
|
|
1400
|
+
this.startAutoRejoin(new Error("Agent worker was replaced"), { trigger: "worker_replaced" });
|
|
1166
1401
|
return;
|
|
1167
1402
|
}
|
|
1168
1403
|
this.agentStreamId = event.stream_id;
|
|
@@ -1184,6 +1419,7 @@ export class RealtimeSession {
|
|
|
1184
1419
|
if (!pending)
|
|
1185
1420
|
return;
|
|
1186
1421
|
if (event.type === "command.ack" && isCommandAckPayload(event.payload)) {
|
|
1422
|
+
this.recordCommandResult(pending, event.type, "acknowledged");
|
|
1187
1423
|
globalThis.clearTimeout(pending.timeout);
|
|
1188
1424
|
globalThis.clearInterval(pending.retry);
|
|
1189
1425
|
this.pendingCommands.delete(event.payload.message_id);
|
|
@@ -1191,6 +1427,7 @@ export class RealtimeSession {
|
|
|
1191
1427
|
return;
|
|
1192
1428
|
}
|
|
1193
1429
|
if (event.type === "context.updated" && isContextUpdatedPayload(event.payload)) {
|
|
1430
|
+
this.recordCommandResult(pending, event.type, "acknowledged");
|
|
1194
1431
|
globalThis.clearTimeout(pending.timeout);
|
|
1195
1432
|
globalThis.clearInterval(pending.retry);
|
|
1196
1433
|
this.pendingCommands.delete(event.payload.message_id);
|
|
@@ -1199,6 +1436,8 @@ export class RealtimeSession {
|
|
|
1199
1436
|
}
|
|
1200
1437
|
if ((event.type === "command.error" && isCommandErrorPayload(event.payload))
|
|
1201
1438
|
|| (event.type === "context.rejected" && isContextRejectedPayload(event.payload))) {
|
|
1439
|
+
this.recordCommandResult(pending, event.type, "rejected", event.payload.code);
|
|
1440
|
+
this.flushDiagnostics("command_rejected");
|
|
1202
1441
|
if (event.type === "context.rejected" && event.payload.retryable) {
|
|
1203
1442
|
if (pending.retryTransfer && !pending.transferRetrying) {
|
|
1204
1443
|
pending.transferRetrying = true;
|
|
@@ -1220,12 +1459,31 @@ export class RealtimeSession {
|
|
|
1220
1459
|
for (const [messageId, pending] of this.pendingCommands) {
|
|
1221
1460
|
if (preserveContext && pending.commandType === "context.update")
|
|
1222
1461
|
continue;
|
|
1462
|
+
this.recordCommandResult(pending, pending.commandType === "context.update" ? "context.rejected" : "command.error", "failed", "pending_command_cancelled");
|
|
1223
1463
|
globalThis.clearTimeout(pending.timeout);
|
|
1224
1464
|
globalThis.clearInterval(pending.retry);
|
|
1225
1465
|
pending.reject(error);
|
|
1226
1466
|
this.pendingCommands.delete(messageId);
|
|
1227
1467
|
}
|
|
1228
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
|
+
}
|
|
1229
1487
|
async fetchReplay(apiBase, afterSeq) {
|
|
1230
1488
|
let nextAfter = afterSeq;
|
|
1231
1489
|
while (true) {
|
|
@@ -1374,6 +1632,43 @@ function contextStreamId() {
|
|
|
1374
1632
|
return globalThis.crypto.randomUUID();
|
|
1375
1633
|
return `ctx_${Date.now().toString(36)}_${Math.random().toString(36).slice(2)}`;
|
|
1376
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
|
+
}
|
|
1377
1672
|
function validateSessionCredentials(value) {
|
|
1378
1673
|
if (!value || typeof value !== "object") {
|
|
1379
1674
|
throw new TypeError("Session credentials must be an object");
|