@voxevolv/client 0.3.9 → 0.3.11
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 +7 -0
- 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 +10 -1
- package/dist/diagnostics.d.ts.map +1 -1
- package/dist/diagnostics.js +26 -0
- package/dist/diagnostics.js.map +1 -1
- package/dist/index.d.ts +8 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2222 -2212
- package/dist/index.js.map +7 -1
- package/dist/vendor/livekit-client.LICENSE +175 -0
- package/dist/vendor/livekit-client.esm.mjs +33222 -0
- package/package.json +2 -2
package/dist/index.js
CHANGED
|
@@ -1,2293 +1,2303 @@
|
|
|
1
|
-
|
|
2
|
-
import {
|
|
1
|
+
// src/index.ts
|
|
2
|
+
import {
|
|
3
|
+
ConnectionState as LiveKitConnectionState,
|
|
4
|
+
DataPacket_Kind,
|
|
5
|
+
DisconnectReason,
|
|
6
|
+
LocalAudioTrack,
|
|
7
|
+
Room,
|
|
8
|
+
RoomEvent,
|
|
9
|
+
RpcError,
|
|
10
|
+
Track
|
|
11
|
+
} from "./vendor/livekit-client.esm.mjs";
|
|
12
|
+
import {
|
|
13
|
+
createClientMessage,
|
|
14
|
+
CommandError,
|
|
15
|
+
EventCursor,
|
|
16
|
+
isCommandAckPayload,
|
|
17
|
+
isCommandErrorPayload,
|
|
18
|
+
isContextRejectedPayload,
|
|
19
|
+
isContextUpdatePayload,
|
|
20
|
+
isContextUpdatedPayload,
|
|
21
|
+
isRealtimeEvent
|
|
22
|
+
} from "./protocol.js";
|
|
3
23
|
import { ContextDeliveryError, contextRetryDelay, ReplacementContextQueue } from "./context-queue.js";
|
|
4
|
-
import {
|
|
24
|
+
import {
|
|
25
|
+
ClientDiagnosticBuffer,
|
|
26
|
+
DiagnosticDeliveryError
|
|
27
|
+
} from "./diagnostics.js";
|
|
5
28
|
import { TranscriptStore } from "./transcripts.js";
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
29
|
+
import { TranscriptStore as TranscriptStore2 } from "./transcripts.js";
|
|
30
|
+
import {
|
|
31
|
+
CommandError as CommandError2,
|
|
32
|
+
EventCursor as EventCursor2,
|
|
33
|
+
createClientMessage as createClientMessage2,
|
|
34
|
+
isCommandAckPayload as isCommandAckPayload2,
|
|
35
|
+
isCommandErrorPayload as isCommandErrorPayload2,
|
|
36
|
+
isContextRejectedPayload as isContextRejectedPayload2,
|
|
37
|
+
isContextUpdatePayload as isContextUpdatePayload2,
|
|
38
|
+
isContextUpdatedPayload as isContextUpdatedPayload2,
|
|
39
|
+
isRealtimeEvent as isRealtimeEvent2
|
|
40
|
+
} from "./protocol.js";
|
|
41
|
+
import {
|
|
42
|
+
decodeAgentSnapshotPayload,
|
|
43
|
+
decodeClientHelloPayload,
|
|
44
|
+
decodeClientMessage,
|
|
45
|
+
decodeCommandAckPayload,
|
|
46
|
+
decodeCommandErrorPayload,
|
|
47
|
+
decodeContextRejectedPayload,
|
|
48
|
+
decodeContextUpdatePayload,
|
|
49
|
+
decodeContextUpdatedPayload,
|
|
50
|
+
decodeRealtimeEvent
|
|
51
|
+
} from "./generated/protocol-codecs.js";
|
|
52
|
+
var SDK_VERSION = "0.3.11";
|
|
53
|
+
var CLIENT_HELLO_TIMEOUT_MS = 3e4;
|
|
54
|
+
var DIAGNOSTIC_REPORT_TIMEOUT_MS = 5e3;
|
|
55
|
+
var DEFAULT_STARTUP_ATTEMPTS = [
|
|
56
|
+
{ delayBeforeMs: 0, readyTimeoutMs: 15e3 },
|
|
57
|
+
{ delayBeforeMs: 1e3, readyTimeoutMs: 2e4 },
|
|
58
|
+
{ delayBeforeMs: 3e3, readyTimeoutMs: 5e4 }
|
|
16
59
|
];
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
60
|
+
var DEFAULT_STARTUP_TIMEOUT_MS = 9e4;
|
|
61
|
+
var RealtimeSessionHttpError = class extends Error {
|
|
62
|
+
status;
|
|
63
|
+
code;
|
|
64
|
+
requestId;
|
|
65
|
+
details;
|
|
66
|
+
constructor(message, options) {
|
|
67
|
+
super(message);
|
|
68
|
+
this.name = "RealtimeSessionHttpError";
|
|
69
|
+
this.status = options.status;
|
|
70
|
+
this.code = options.code ?? null;
|
|
71
|
+
this.requestId = options.requestId ?? null;
|
|
72
|
+
this.details = options.details ?? [];
|
|
73
|
+
}
|
|
74
|
+
};
|
|
75
|
+
var SessionConfigurationError = class extends TypeError {
|
|
76
|
+
};
|
|
77
|
+
var SessionErrorEventError = class extends Error {
|
|
78
|
+
retryable;
|
|
79
|
+
constructor(event) {
|
|
80
|
+
const payload = event.payload;
|
|
81
|
+
const message = typeof payload.message === "string" ? payload.message : "VoxEvolv reported a session error";
|
|
82
|
+
super(message);
|
|
83
|
+
this.name = "SessionErrorEventError";
|
|
84
|
+
this.retryable = payload.retryable === true;
|
|
85
|
+
}
|
|
86
|
+
};
|
|
87
|
+
var RealtimeSession = class _RealtimeSession {
|
|
88
|
+
id;
|
|
89
|
+
room;
|
|
90
|
+
accessToken;
|
|
91
|
+
credentials;
|
|
92
|
+
stateValue = "idle";
|
|
93
|
+
listeners = /* @__PURE__ */ new Set();
|
|
94
|
+
stateListeners = /* @__PURE__ */ new Set();
|
|
95
|
+
gapListeners = /* @__PURE__ */ new Set();
|
|
96
|
+
connectionQualityListeners = /* @__PURE__ */ new Set();
|
|
97
|
+
recoveryListeners = /* @__PURE__ */ new Set();
|
|
98
|
+
startupAttemptListeners = /* @__PURE__ */ new Set();
|
|
99
|
+
remoteAudioTrackListeners = /* @__PURE__ */ new Set();
|
|
100
|
+
remoteAudioTrackUnsubscribedListeners = /* @__PURE__ */ new Set();
|
|
101
|
+
readyListeners = /* @__PURE__ */ new Set();
|
|
102
|
+
userTranscriptListeners = /* @__PURE__ */ new Set();
|
|
103
|
+
agentTranscriptListeners = /* @__PURE__ */ new Set();
|
|
104
|
+
transcriptListeners = /* @__PURE__ */ new Set();
|
|
105
|
+
transcriptStore = new TranscriptStore();
|
|
106
|
+
errorListeners = /* @__PURE__ */ new Set();
|
|
107
|
+
endedListeners = /* @__PURE__ */ new Set();
|
|
108
|
+
toolCallListeners = /* @__PURE__ */ new Set();
|
|
109
|
+
audioPlaybackBlockedListeners = /* @__PURE__ */ new Set();
|
|
110
|
+
remoteAudioStreams = /* @__PURE__ */ new WeakMap();
|
|
111
|
+
outputMediaStream = null;
|
|
112
|
+
remoteAudioTrackResolve = null;
|
|
113
|
+
cursor = new EventCursor();
|
|
114
|
+
connectionId = `conn_${Math.random().toString(36).slice(2)}_${Date.now().toString(36)}`;
|
|
115
|
+
clientSeq = 0;
|
|
116
|
+
lastHelloMessageId = null;
|
|
117
|
+
reconnectStartedAt = null;
|
|
118
|
+
pendingReconnect = null;
|
|
119
|
+
agentReady = false;
|
|
120
|
+
agentStreamId = null;
|
|
121
|
+
contextGeneration = 0;
|
|
122
|
+
replacementContext = new ReplacementContextQueue(
|
|
123
|
+
(update, timeoutMs) => this.sendContextUpdate(update, timeoutMs)
|
|
124
|
+
);
|
|
125
|
+
readyResolve = null;
|
|
126
|
+
replayPromise = null;
|
|
127
|
+
pendingCommands = /* @__PURE__ */ new Map();
|
|
128
|
+
commandPublishTail = Promise.resolve();
|
|
129
|
+
toolHandlers = /* @__PURE__ */ new Map();
|
|
130
|
+
apiBase = "/v1";
|
|
131
|
+
hasConnectedValue = false;
|
|
132
|
+
sessionEnded = false;
|
|
133
|
+
pendingEndEvent = null;
|
|
134
|
+
agentDisconnected = false;
|
|
135
|
+
terminalSessionError = null;
|
|
136
|
+
endNotificationDelivered = false;
|
|
137
|
+
disconnectMode = null;
|
|
138
|
+
activeRecovery = null;
|
|
139
|
+
autoRejoinPromise = null;
|
|
140
|
+
autoRejoinController = null;
|
|
141
|
+
manualRejoinController = null;
|
|
142
|
+
startupConnectController = null;
|
|
143
|
+
startupAttemptController = null;
|
|
144
|
+
useManagedMicrophone = true;
|
|
145
|
+
microphoneEnabled = true;
|
|
146
|
+
applicationAudioTrack = null;
|
|
147
|
+
localApplicationAudioTrack = null;
|
|
148
|
+
connectDefaults;
|
|
149
|
+
recoveryReadiness;
|
|
150
|
+
credentialRefreshGeneration = 0;
|
|
151
|
+
fetcher;
|
|
152
|
+
endEndpoint;
|
|
153
|
+
refreshCredentialsCallback;
|
|
154
|
+
autoRejoinOptions;
|
|
155
|
+
startupRetryOptions;
|
|
156
|
+
lifecycleCredentials;
|
|
157
|
+
diagnostics = new ClientDiagnosticBuffer({ sdkVersion: SDK_VERSION });
|
|
158
|
+
activeContextTransfers = 0;
|
|
159
|
+
activeContextBytes = 0;
|
|
160
|
+
diagnosticLoopTimer;
|
|
161
|
+
diagnosticLoopExpectedAt = 0;
|
|
162
|
+
diagnosticLoopDelayMs;
|
|
163
|
+
diagnosticReportTimeoutMs = DIAGNOSTIC_REPORT_TIMEOUT_MS;
|
|
164
|
+
onDiagnosticVisibilityChange = () => {
|
|
165
|
+
if (globalThis.document?.visibilityState === "hidden") this.flushDiagnostics("page_hidden");
|
|
166
|
+
};
|
|
167
|
+
constructor(credentials, options = {}) {
|
|
168
|
+
validateSessionCredentials(credentials);
|
|
169
|
+
this.id = credentials.id;
|
|
170
|
+
this.accessToken = credentials.access_token;
|
|
171
|
+
this.credentials = credentials.connection;
|
|
172
|
+
this.connectDefaults = options.connect?.apiBase === void 0 ? options.connect ?? {} : { ...options.connect, apiBase: normalizeApiBase(options.connect.apiBase) };
|
|
173
|
+
this.apiBase = this.connectDefaults.apiBase ?? this.apiBase;
|
|
174
|
+
this.recoveryReadiness = {
|
|
175
|
+
readyTimeoutMs: this.connectDefaults.readyTimeoutMs ?? 15e3,
|
|
176
|
+
waitForRemoteAudio: this.connectDefaults.waitForRemoteAudio ?? true
|
|
119
177
|
};
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
const
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
const
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
}
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
this.readyListeners.add(listener);
|
|
293
|
-
return () => this.readyListeners.delete(listener);
|
|
294
|
-
}
|
|
295
|
-
onUserTranscript(listener) {
|
|
296
|
-
this.userTranscriptListeners.add(listener);
|
|
297
|
-
return () => this.userTranscriptListeners.delete(listener);
|
|
298
|
-
}
|
|
299
|
-
onAgentTranscript(listener) {
|
|
300
|
-
this.agentTranscriptListeners.add(listener);
|
|
301
|
-
return () => this.agentTranscriptListeners.delete(listener);
|
|
302
|
-
}
|
|
303
|
-
/** Latest message snapshots, retained across transport recovery and replay. */
|
|
304
|
-
get transcript() {
|
|
305
|
-
return this.transcriptStore.snapshot;
|
|
306
|
-
}
|
|
307
|
-
onTranscriptUpdated(listener) {
|
|
308
|
-
this.transcriptListeners.add(listener);
|
|
309
|
-
return () => this.transcriptListeners.delete(listener);
|
|
310
|
-
}
|
|
311
|
-
onError(listener) {
|
|
312
|
-
this.errorListeners.add(listener);
|
|
313
|
-
return () => this.errorListeners.delete(listener);
|
|
314
|
-
}
|
|
315
|
-
onEnded(listener) {
|
|
316
|
-
this.endedListeners.add(listener);
|
|
317
|
-
return () => this.endedListeners.delete(listener);
|
|
318
|
-
}
|
|
319
|
-
onToolCall(listener) {
|
|
320
|
-
this.toolCallListeners.add(listener);
|
|
321
|
-
return () => this.toolCallListeners.delete(listener);
|
|
178
|
+
this.fetcher = options.fetch ?? globalThis.fetch.bind(globalThis);
|
|
179
|
+
this.endEndpoint = options.endEndpoint ?? null;
|
|
180
|
+
this.refreshCredentialsCallback = options.refreshCredentials ?? null;
|
|
181
|
+
this.autoRejoinOptions = resolveAutoRejoinOptions(options.autoRejoin);
|
|
182
|
+
this.startupRetryOptions = resolveStartupRetryOptions(options.startupRetry);
|
|
183
|
+
this.lifecycleCredentials = options.lifecycleCredentials ?? "same-origin";
|
|
184
|
+
this.room = new Room({ adaptiveStream: true, dynacast: true });
|
|
185
|
+
for (const [name, handler] of Object.entries(options.tools ?? {})) {
|
|
186
|
+
this.registerTool(name, handler);
|
|
187
|
+
}
|
|
188
|
+
this.room.registerRpcMethod("voxevolv.tool.invoke", (invocation) => this.invokeBrowserTool(invocation));
|
|
189
|
+
this.room.registerRpcMethod("voxevolv.diagnostics.flush", async (invocation) => {
|
|
190
|
+
const payload = JSON.parse(invocation.payload);
|
|
191
|
+
if (typeof payload?.call_id !== "string" || payload.call_id.length > 200) {
|
|
192
|
+
throw new RpcError(1500, "Invalid diagnostic request");
|
|
193
|
+
}
|
|
194
|
+
this.recordDiagnostic({
|
|
195
|
+
operation: "tool_rpc",
|
|
196
|
+
status: "started",
|
|
197
|
+
stage: "flush_requested",
|
|
198
|
+
call_id: payload.call_id,
|
|
199
|
+
rpc_request_id: invocation.requestId
|
|
200
|
+
});
|
|
201
|
+
this.flushDiagnostics("tool_rpc_worker_request");
|
|
202
|
+
return "{}";
|
|
203
|
+
});
|
|
204
|
+
this.bindRoomEvents();
|
|
205
|
+
}
|
|
206
|
+
async invokeBrowserTool(invocation) {
|
|
207
|
+
const receivedAt = performance.now();
|
|
208
|
+
let callId;
|
|
209
|
+
let toolName;
|
|
210
|
+
let slowTimer;
|
|
211
|
+
const record = (entry) => this.recordDiagnostic({
|
|
212
|
+
operation: "tool_rpc",
|
|
213
|
+
status: "started",
|
|
214
|
+
rpc_request_id: invocation.requestId,
|
|
215
|
+
call_id: callId,
|
|
216
|
+
tool_name: toolName,
|
|
217
|
+
duration_ms: performance.now() - receivedAt,
|
|
218
|
+
...entry
|
|
219
|
+
});
|
|
220
|
+
record({ stage: "received" });
|
|
221
|
+
try {
|
|
222
|
+
let payload;
|
|
223
|
+
try {
|
|
224
|
+
payload = JSON.parse(invocation.payload);
|
|
225
|
+
} catch (error) {
|
|
226
|
+
throw new RpcError(1500, "Tool invocation payload was not valid JSON", void 0, { cause: error });
|
|
227
|
+
}
|
|
228
|
+
if (typeof payload?.name !== "string") {
|
|
229
|
+
throw new RpcError(1500, "Tool invocation did not include a valid name");
|
|
230
|
+
}
|
|
231
|
+
toolName = payload.name;
|
|
232
|
+
callId = typeof payload.call_id === "string" ? payload.call_id : invocation.requestId;
|
|
233
|
+
const handler = this.toolHandlers.get(toolName);
|
|
234
|
+
if (!handler) throw new RpcError(1500, `No browser handler is registered for ${toolName}`);
|
|
235
|
+
if (!payload.arguments || typeof payload.arguments !== "object" || Array.isArray(payload.arguments)) {
|
|
236
|
+
throw new RpcError(1500, "Tool invocation arguments must be an object");
|
|
237
|
+
}
|
|
238
|
+
const arguments_ = payload.arguments;
|
|
239
|
+
this.notifyListeners(this.toolCallListeners, { name: toolName, callId, arguments: arguments_ });
|
|
240
|
+
record({ stage: "handler_started" });
|
|
241
|
+
this.flushDiagnostics("tool_rpc_received");
|
|
242
|
+
slowTimer = globalThis.setTimeout(() => {
|
|
243
|
+
record({ stage: "handler_started", reason: "handler_slow" });
|
|
244
|
+
this.flushDiagnostics("tool_rpc_slow");
|
|
245
|
+
}, 2e3);
|
|
246
|
+
let response;
|
|
247
|
+
try {
|
|
248
|
+
const result = await handler(arguments_, { callId });
|
|
249
|
+
response = JSON.stringify(result ?? null);
|
|
250
|
+
} catch (error) {
|
|
251
|
+
const message = error instanceof Error ? error.message : "Browser tool handler failed";
|
|
252
|
+
throw new RpcError(1500, message, void 0, { cause: error });
|
|
253
|
+
}
|
|
254
|
+
record({ stage: "response_ready", status: "succeeded" });
|
|
255
|
+
return response;
|
|
256
|
+
} catch (error) {
|
|
257
|
+
record({ status: "failed", rpc_error_code: error instanceof RpcError ? error.code : void 0 });
|
|
258
|
+
throw error;
|
|
259
|
+
} finally {
|
|
260
|
+
globalThis.clearTimeout(slowTimer);
|
|
261
|
+
this.flushDiagnostics("tool_rpc_finished");
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
static async create(options) {
|
|
265
|
+
if ("connect" in options) {
|
|
266
|
+
throw new TypeError(
|
|
267
|
+
"RealtimeSession.create() accepts apiBase and media options at the top level, not in connect"
|
|
268
|
+
);
|
|
269
|
+
}
|
|
270
|
+
const {
|
|
271
|
+
endpoint,
|
|
272
|
+
request,
|
|
273
|
+
microphone,
|
|
274
|
+
audioTrack,
|
|
275
|
+
readyTimeoutMs,
|
|
276
|
+
waitForRemoteAudio,
|
|
277
|
+
apiBase,
|
|
278
|
+
...sessionOptions
|
|
279
|
+
} = options;
|
|
280
|
+
if (!endpoint) throw new TypeError("endpoint is required");
|
|
281
|
+
if (!apiBase) {
|
|
282
|
+
throw new TypeError(
|
|
283
|
+
"apiBase is required so rejoin, replay, and end use the intended VoxEvolv API or application proxy"
|
|
284
|
+
);
|
|
285
|
+
}
|
|
286
|
+
const normalizedApiBase = normalizeApiBase(apiBase);
|
|
287
|
+
const fetcher = sessionOptions.fetch ?? globalThis.fetch.bind(globalThis);
|
|
288
|
+
const response = await fetcher(endpoint, { ...request, method: "POST" });
|
|
289
|
+
if (!response.ok) throw await httpError(response, "Could not create realtime session");
|
|
290
|
+
const credentials = await response.json();
|
|
291
|
+
return new _RealtimeSession(credentials, {
|
|
292
|
+
...sessionOptions,
|
|
293
|
+
fetch: fetcher,
|
|
294
|
+
connect: { microphone, audioTrack, readyTimeoutMs, waitForRemoteAudio, apiBase: normalizedApiBase }
|
|
295
|
+
});
|
|
296
|
+
}
|
|
297
|
+
get state() {
|
|
298
|
+
return this.stateValue;
|
|
299
|
+
}
|
|
300
|
+
get hasConnectedBefore() {
|
|
301
|
+
return this.hasConnectedValue;
|
|
302
|
+
}
|
|
303
|
+
/**
|
|
304
|
+
* Stable stream holding the agent's current remote audio tracks. The stream
|
|
305
|
+
* object never changes across track arrival, reconnects, or rejoin; tracks
|
|
306
|
+
* are added and removed as the room subscribes and unsubscribes them.
|
|
307
|
+
*/
|
|
308
|
+
get outputStream() {
|
|
309
|
+
this.outputMediaStream ??= new MediaStream();
|
|
310
|
+
return this.outputMediaStream;
|
|
311
|
+
}
|
|
312
|
+
onEvent(listener) {
|
|
313
|
+
this.listeners.add(listener);
|
|
314
|
+
return () => this.listeners.delete(listener);
|
|
315
|
+
}
|
|
316
|
+
onStateChange(listener) {
|
|
317
|
+
this.stateListeners.add(listener);
|
|
318
|
+
this.notifyListeners(/* @__PURE__ */ new Set([listener]), this.stateValue);
|
|
319
|
+
return () => this.stateListeners.delete(listener);
|
|
320
|
+
}
|
|
321
|
+
onProtocolGap(listener) {
|
|
322
|
+
this.gapListeners.add(listener);
|
|
323
|
+
return () => this.gapListeners.delete(listener);
|
|
324
|
+
}
|
|
325
|
+
onConnectionQuality(listener) {
|
|
326
|
+
this.connectionQualityListeners.add(listener);
|
|
327
|
+
this.notifyListeners(
|
|
328
|
+
/* @__PURE__ */ new Set([listener]),
|
|
329
|
+
this.room.localParticipant.connectionQuality
|
|
330
|
+
);
|
|
331
|
+
return () => this.connectionQualityListeners.delete(listener);
|
|
332
|
+
}
|
|
333
|
+
onRecovery(listener) {
|
|
334
|
+
this.recoveryListeners.add(listener);
|
|
335
|
+
return () => this.recoveryListeners.delete(listener);
|
|
336
|
+
}
|
|
337
|
+
onStartupAttempt(listener) {
|
|
338
|
+
this.startupAttemptListeners.add(listener);
|
|
339
|
+
return () => this.startupAttemptListeners.delete(listener);
|
|
340
|
+
}
|
|
341
|
+
waitForState(states, timeoutMs, options = {}) {
|
|
342
|
+
if (!states.length) throw new TypeError("states must contain at least one connection state");
|
|
343
|
+
if (!Number.isFinite(timeoutMs) || timeoutMs < 0) {
|
|
344
|
+
throw new TypeError("timeoutMs must be a non-negative finite number");
|
|
345
|
+
}
|
|
346
|
+
const targets = new Set(states);
|
|
347
|
+
if (targets.has(this.stateValue)) return Promise.resolve(this.stateValue);
|
|
348
|
+
if (options.signal?.aborted) {
|
|
349
|
+
return Promise.reject(options.signal.reason ?? new DOMException("Aborted", "AbortError"));
|
|
322
350
|
}
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
351
|
+
return new Promise((resolve, reject) => {
|
|
352
|
+
let settled = false;
|
|
353
|
+
const finish = (state, error) => {
|
|
354
|
+
if (settled) return;
|
|
355
|
+
settled = true;
|
|
356
|
+
globalThis.clearTimeout(timeout);
|
|
357
|
+
this.stateListeners.delete(onState);
|
|
358
|
+
options.signal?.removeEventListener("abort", onAbort);
|
|
359
|
+
if (error !== void 0) {
|
|
360
|
+
reject(error);
|
|
361
|
+
} else if (state !== null) {
|
|
362
|
+
resolve(state);
|
|
363
|
+
}
|
|
364
|
+
};
|
|
365
|
+
const onState = (state) => {
|
|
366
|
+
if (targets.has(state)) finish(state);
|
|
367
|
+
};
|
|
368
|
+
const onAbort = () => {
|
|
369
|
+
finish(null, options.signal?.reason ?? new DOMException("Aborted", "AbortError"));
|
|
370
|
+
};
|
|
371
|
+
const timeout = globalThis.setTimeout(() => {
|
|
372
|
+
finish(
|
|
373
|
+
null,
|
|
374
|
+
new Error(`Session did not reach ${states.join(" or ")} within ${timeoutMs}ms`)
|
|
375
|
+
);
|
|
376
|
+
}, timeoutMs);
|
|
377
|
+
this.stateListeners.add(onState);
|
|
378
|
+
options.signal?.addEventListener("abort", onAbort, { once: true });
|
|
379
|
+
});
|
|
380
|
+
}
|
|
381
|
+
onRemoteAudioTrack(listener) {
|
|
382
|
+
this.remoteAudioTrackListeners.add(listener);
|
|
383
|
+
return () => this.remoteAudioTrackListeners.delete(listener);
|
|
384
|
+
}
|
|
385
|
+
onRemoteAudioTrackUnsubscribed(listener) {
|
|
386
|
+
this.remoteAudioTrackUnsubscribedListeners.add(listener);
|
|
387
|
+
return () => this.remoteAudioTrackUnsubscribedListeners.delete(listener);
|
|
388
|
+
}
|
|
389
|
+
onReady(listener) {
|
|
390
|
+
this.readyListeners.add(listener);
|
|
391
|
+
return () => this.readyListeners.delete(listener);
|
|
392
|
+
}
|
|
393
|
+
onUserTranscript(listener) {
|
|
394
|
+
this.userTranscriptListeners.add(listener);
|
|
395
|
+
return () => this.userTranscriptListeners.delete(listener);
|
|
396
|
+
}
|
|
397
|
+
onAgentTranscript(listener) {
|
|
398
|
+
this.agentTranscriptListeners.add(listener);
|
|
399
|
+
return () => this.agentTranscriptListeners.delete(listener);
|
|
400
|
+
}
|
|
401
|
+
/** Latest message snapshots, retained across transport recovery and replay. */
|
|
402
|
+
get transcript() {
|
|
403
|
+
return this.transcriptStore.snapshot;
|
|
404
|
+
}
|
|
405
|
+
onTranscriptUpdated(listener) {
|
|
406
|
+
this.transcriptListeners.add(listener);
|
|
407
|
+
return () => this.transcriptListeners.delete(listener);
|
|
408
|
+
}
|
|
409
|
+
onError(listener) {
|
|
410
|
+
this.errorListeners.add(listener);
|
|
411
|
+
return () => this.errorListeners.delete(listener);
|
|
412
|
+
}
|
|
413
|
+
onEnded(listener) {
|
|
414
|
+
this.endedListeners.add(listener);
|
|
415
|
+
return () => this.endedListeners.delete(listener);
|
|
416
|
+
}
|
|
417
|
+
onToolCall(listener) {
|
|
418
|
+
this.toolCallListeners.add(listener);
|
|
419
|
+
return () => this.toolCallListeners.delete(listener);
|
|
420
|
+
}
|
|
421
|
+
onAudioPlaybackBlocked(listener) {
|
|
422
|
+
this.audioPlaybackBlockedListeners.add(listener);
|
|
423
|
+
return () => this.audioPlaybackBlockedListeners.delete(listener);
|
|
424
|
+
}
|
|
425
|
+
registerTool(name, handler) {
|
|
426
|
+
if (["connecting", "connected_not_ready", "connected", "reconnecting", "resuming"].includes(this.stateValue)) {
|
|
427
|
+
throw new Error("Browser tools must be registered before connect()");
|
|
428
|
+
}
|
|
429
|
+
if (!/^[a-z][a-z0-9_]{0,79}$/.test(name)) {
|
|
430
|
+
throw new TypeError("Tool name must be lowercase letters, numbers, and underscores");
|
|
431
|
+
}
|
|
432
|
+
if (this.toolHandlers.has(name)) throw new TypeError(`Tool ${name} is already registered`);
|
|
433
|
+
this.toolHandlers.set(name, handler);
|
|
434
|
+
return () => {
|
|
435
|
+
if (this.toolHandlers.get(name) === handler) this.toolHandlers.delete(name);
|
|
436
|
+
};
|
|
437
|
+
}
|
|
438
|
+
async connect(options = {}) {
|
|
439
|
+
if (this.startupConnectController || this.manualRejoinController) {
|
|
440
|
+
throw new Error("A session connection attempt is already in progress");
|
|
326
441
|
}
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
.includes(this.stateValue)) {
|
|
330
|
-
throw new Error("Browser tools must be registered before connect()");
|
|
331
|
-
}
|
|
332
|
-
if (!/^[a-z][a-z0-9_]{0,79}$/.test(name)) {
|
|
333
|
-
throw new TypeError("Tool name must be lowercase letters, numbers, and underscores");
|
|
334
|
-
}
|
|
335
|
-
if (this.toolHandlers.has(name))
|
|
336
|
-
throw new TypeError(`Tool ${name} is already registered`);
|
|
337
|
-
this.toolHandlers.set(name, handler);
|
|
338
|
-
return () => {
|
|
339
|
-
if (this.toolHandlers.get(name) === handler)
|
|
340
|
-
this.toolHandlers.delete(name);
|
|
341
|
-
};
|
|
442
|
+
if (["connecting", "connected_not_ready", "connected", "reconnecting", "resuming"].includes(this.stateValue)) {
|
|
443
|
+
throw new Error(`Cannot connect while session is ${this.stateValue}`);
|
|
342
444
|
}
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
this.setState("
|
|
445
|
+
this.cancelRecovery();
|
|
446
|
+
const controller = new AbortController();
|
|
447
|
+
this.startupConnectController = controller;
|
|
448
|
+
this.credentialRefreshGeneration += 1;
|
|
449
|
+
const resolved = { ...this.connectDefaults, ...options };
|
|
450
|
+
this.recoveryReadiness = {
|
|
451
|
+
readyTimeoutMs: resolved.readyTimeoutMs ?? 15e3,
|
|
452
|
+
waitForRemoteAudio: resolved.waitForRemoteAudio ?? true
|
|
453
|
+
};
|
|
454
|
+
this.microphoneEnabled = resolved.audioTrack ? resolved.audioTrack.enabled !== false : resolved.microphone ?? true;
|
|
455
|
+
this.setState("connecting");
|
|
456
|
+
try {
|
|
457
|
+
if (this.startupRetryOptions) {
|
|
458
|
+
await this.connectWithStartupRetry(options, controller);
|
|
459
|
+
} else {
|
|
460
|
+
this.startupAttemptController = controller;
|
|
461
|
+
await this.connectWithMode(options, false, controller.signal, false);
|
|
462
|
+
}
|
|
463
|
+
} catch (error) {
|
|
464
|
+
if (this.startupConnectController === controller && this.terminalSessionError) {
|
|
465
|
+
this.sessionEnded = true;
|
|
466
|
+
this.setState("failed");
|
|
467
|
+
} else if (this.startupConnectController === controller && !this.sessionEnded) {
|
|
468
|
+
this.setState("failed");
|
|
469
|
+
}
|
|
470
|
+
throw error;
|
|
471
|
+
} finally {
|
|
472
|
+
if (this.startupAttemptController === controller) {
|
|
473
|
+
this.startupAttemptController = null;
|
|
474
|
+
}
|
|
475
|
+
if (this.startupConnectController === controller) {
|
|
476
|
+
this.startupConnectController = null;
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
async connectWithStartupRetry(options, controller) {
|
|
481
|
+
const startupOptions = this.startupRetryOptions;
|
|
482
|
+
if (!startupOptions) return;
|
|
483
|
+
const startupStartedAt = performance.now();
|
|
484
|
+
const timeout = globalThis.setTimeout(() => {
|
|
485
|
+
controller.abort(new Error(
|
|
486
|
+
`Session startup did not complete within ${startupOptions.timeoutMs}ms`
|
|
487
|
+
));
|
|
488
|
+
}, startupOptions.timeoutMs);
|
|
489
|
+
let lastError = new Error("Session startup failed");
|
|
490
|
+
let startupRegistered = this.hasConnectedValue;
|
|
491
|
+
this.apiBase = normalizeApiBase(options.apiBase ?? this.connectDefaults.apiBase ?? this.apiBase);
|
|
492
|
+
try {
|
|
493
|
+
for (const [index, attempt] of startupOptions.attempts.entries()) {
|
|
494
|
+
const attemptNumber = index + 1;
|
|
495
|
+
if (attempt.delayBeforeMs > 0) {
|
|
496
|
+
this.setState("connecting");
|
|
497
|
+
await abortableDelay(attempt.delayBeforeMs, controller.signal);
|
|
498
|
+
}
|
|
499
|
+
throwIfAborted(controller.signal);
|
|
500
|
+
const attemptController = createLinkedAbortController(controller.signal);
|
|
501
|
+
this.startupAttemptController = attemptController;
|
|
502
|
+
this.notifyStartupAttempt("started", attemptNumber, attempt.readyTimeoutMs);
|
|
365
503
|
try {
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
504
|
+
if (!startupRegistered) {
|
|
505
|
+
const remainingMs = startupOptions.timeoutMs - (performance.now() - startupStartedAt);
|
|
506
|
+
await this.registerStartupRecovery(remainingMs, attemptController.signal);
|
|
507
|
+
startupRegistered = true;
|
|
508
|
+
}
|
|
509
|
+
throwIfAborted(attemptController.signal);
|
|
510
|
+
if (index === 0) {
|
|
511
|
+
await this.connectWithMode(
|
|
512
|
+
{ ...options, readyTimeoutMs: attempt.readyTimeoutMs },
|
|
513
|
+
false,
|
|
514
|
+
attemptController.signal,
|
|
515
|
+
false
|
|
516
|
+
);
|
|
517
|
+
} else {
|
|
518
|
+
await this.performRejoin(this.apiBase, {
|
|
519
|
+
signal: attemptController.signal,
|
|
520
|
+
recovering: false,
|
|
521
|
+
connect: { ...options, readyTimeoutMs: attempt.readyTimeoutMs },
|
|
522
|
+
markFailureTerminal: false
|
|
523
|
+
});
|
|
524
|
+
}
|
|
525
|
+
throwIfAborted(controller.signal);
|
|
526
|
+
this.notifyStartupAttempt("succeeded", attemptNumber, attempt.readyTimeoutMs);
|
|
527
|
+
return;
|
|
528
|
+
} catch (error) {
|
|
529
|
+
lastError = toError(error);
|
|
530
|
+
this.notifyStartupAttempt("failed", attemptNumber, attempt.readyTimeoutMs, lastError);
|
|
531
|
+
if (controller.signal.aborted) throw abortError(controller.signal);
|
|
532
|
+
if (isTerminalStartupError(error) || attemptNumber === startupOptions.attempts.length) {
|
|
382
533
|
throw error;
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
}
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
534
|
+
}
|
|
535
|
+
this.setState("connecting");
|
|
536
|
+
} finally {
|
|
537
|
+
if (this.startupAttemptController === attemptController) {
|
|
538
|
+
this.startupAttemptController = null;
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
throw lastError;
|
|
543
|
+
} finally {
|
|
544
|
+
globalThis.clearTimeout(timeout);
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
async registerStartupRecovery(timeoutMs, signal) {
|
|
548
|
+
const reserveMs = Math.min(5e3, timeoutMs / 10);
|
|
549
|
+
const controller = createLinkedAbortController(signal);
|
|
550
|
+
const requestTimeout = globalThis.setTimeout(() => {
|
|
551
|
+
controller.abort(new Error("Startup registration timed out"));
|
|
552
|
+
}, Math.max(1, reserveMs));
|
|
553
|
+
try {
|
|
554
|
+
const send = () => raceWithAbort(this.fetcher(`${this.apiBase}/sessions/${this.id}/startup`, {
|
|
555
|
+
method: "POST",
|
|
556
|
+
headers: { Authorization: `Bearer ${this.accessToken}`, "Content-Type": "application/json" },
|
|
557
|
+
credentials: this.lifecycleCredentials,
|
|
558
|
+
body: JSON.stringify({ timeout_ms: Math.max(1, Math.floor(Math.min(timeoutMs - reserveMs, 85e3))) }),
|
|
559
|
+
signal: controller.signal
|
|
560
|
+
}), controller.signal);
|
|
561
|
+
let response = await send();
|
|
562
|
+
if (response.status === 401 && this.refreshCredentialsCallback) {
|
|
563
|
+
await this.refreshCredentials(controller.signal);
|
|
564
|
+
response = await send();
|
|
565
|
+
}
|
|
566
|
+
if (!response.ok) throw await httpError(response, "Could not register session startup");
|
|
567
|
+
const body = await raceWithAbort(response.json(), controller.signal);
|
|
568
|
+
if (typeof body.remaining_ms !== "number" || body.remaining_ms <= 0) {
|
|
569
|
+
throw new Error("Session startup window has expired");
|
|
570
|
+
}
|
|
571
|
+
} finally {
|
|
572
|
+
globalThis.clearTimeout(requestTimeout);
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
notifyStartupAttempt(status, attempt, readyTimeoutMs, error) {
|
|
576
|
+
const maxAttempts = this.startupRetryOptions?.attempts.length ?? 1;
|
|
577
|
+
this.notifyListeners(this.startupAttemptListeners, {
|
|
578
|
+
status,
|
|
579
|
+
attempt,
|
|
580
|
+
maxAttempts,
|
|
581
|
+
readyTimeoutMs,
|
|
582
|
+
...error ? { error } : {}
|
|
583
|
+
});
|
|
584
|
+
}
|
|
585
|
+
async connectWithMode(options, recovering, signal, markFailureTerminal = true) {
|
|
586
|
+
throwIfAborted(signal);
|
|
587
|
+
const blockedStates = recovering ? ["connecting", "connected_not_ready", "connected", "reconnecting"] : ["connecting", "connected_not_ready", "connected", "reconnecting", "resuming"];
|
|
588
|
+
const continuingStartupRetry = !recovering && !markFailureTerminal && this.stateValue === "connecting";
|
|
589
|
+
if (blockedStates.includes(this.stateValue) && !continuingStartupRetry) {
|
|
590
|
+
throw new Error(`Cannot connect while session is ${this.stateValue}`);
|
|
591
|
+
}
|
|
592
|
+
const generation = this.contextGeneration;
|
|
593
|
+
const resolved = { ...this.connectDefaults, ...options };
|
|
594
|
+
const audioTrack = resolved.audioTrack ?? null;
|
|
595
|
+
const microphone = resolved.microphone ?? audioTrack === null;
|
|
596
|
+
const readyTimeoutMs = resolved.readyTimeoutMs ?? 15e3;
|
|
597
|
+
const waitForRemoteAudio = resolved.waitForRemoteAudio ?? true;
|
|
598
|
+
const apiBase = normalizeApiBase(resolved.apiBase ?? this.apiBase);
|
|
599
|
+
this.validateAudioOptions(microphone, audioTrack);
|
|
600
|
+
this.useManagedMicrophone = microphone;
|
|
601
|
+
this.applicationAudioTrack = audioTrack;
|
|
602
|
+
if (!recovering) {
|
|
603
|
+
this.sessionEnded = false;
|
|
604
|
+
this.agentStreamId = null;
|
|
605
|
+
this.reconnectStartedAt = null;
|
|
606
|
+
this.pendingEndEvent = null;
|
|
607
|
+
this.agentDisconnected = false;
|
|
608
|
+
this.terminalSessionError = null;
|
|
609
|
+
this.endNotificationDelivered = false;
|
|
610
|
+
this.setState("connecting");
|
|
611
|
+
} else {
|
|
612
|
+
this.setState("resuming");
|
|
613
|
+
}
|
|
614
|
+
this.apiBase = apiBase;
|
|
615
|
+
this.agentReady = false;
|
|
616
|
+
try {
|
|
617
|
+
await this.connectRoom(signal, recovering);
|
|
618
|
+
throwIfAborted(signal);
|
|
619
|
+
if (audioTrack) {
|
|
620
|
+
await raceWithAbort(this.publishApplicationAudioTrack(audioTrack), signal);
|
|
621
|
+
} else if (microphone) {
|
|
622
|
+
await raceWithAbort(
|
|
623
|
+
this.room.localParticipant.setMicrophoneEnabled(this.microphoneEnabled),
|
|
624
|
+
signal
|
|
625
|
+
);
|
|
626
|
+
}
|
|
627
|
+
throwIfAborted(signal);
|
|
628
|
+
this.setState(recovering ? "resuming" : "connected_not_ready");
|
|
629
|
+
await raceWithAbort(this.sendHello(), signal);
|
|
630
|
+
const readyDeadline = Date.now() + readyTimeoutMs;
|
|
631
|
+
await this.waitForAgentReady(readyTimeoutMs, signal);
|
|
632
|
+
if (waitForRemoteAudio) await this.waitForRemoteAudioTrack(readyDeadline, signal);
|
|
633
|
+
throwIfAborted(signal);
|
|
634
|
+
if (this.sessionEnded || generation !== this.contextGeneration) {
|
|
635
|
+
throw new Error("Connection restoration interrupted");
|
|
636
|
+
}
|
|
637
|
+
this.resumeContextDelivery(recovering);
|
|
638
|
+
throwIfAborted(signal);
|
|
639
|
+
if (this.sessionEnded || generation !== this.contextGeneration) {
|
|
640
|
+
throw new Error("Connection restoration interrupted");
|
|
641
|
+
}
|
|
642
|
+
this.hasConnectedValue = true;
|
|
643
|
+
this.setState("connected");
|
|
644
|
+
} catch (error) {
|
|
645
|
+
if (signal?.aborted) this.rejectPendingCommands(toError(abortError(signal)));
|
|
646
|
+
this.disconnectMode = recovering ? "rejoin" : "connect_failure";
|
|
647
|
+
try {
|
|
648
|
+
await this.room.disconnect();
|
|
649
|
+
} catch {
|
|
650
|
+
} finally {
|
|
651
|
+
this.disconnectMode = null;
|
|
652
|
+
}
|
|
653
|
+
if (!recovering && markFailureTerminal) this.setState("failed");
|
|
654
|
+
throw error;
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
connectRoom(signal, recovering) {
|
|
658
|
+
throwIfAborted(signal);
|
|
659
|
+
const connection = this.room.connect(this.credentials.url, this.credentials.token);
|
|
660
|
+
if (!signal) return connection;
|
|
661
|
+
return new Promise((resolve, reject) => {
|
|
662
|
+
let settled = false;
|
|
663
|
+
const resolveOnce = () => {
|
|
664
|
+
if (settled) return;
|
|
665
|
+
settled = true;
|
|
666
|
+
signal.removeEventListener("abort", onAbort);
|
|
667
|
+
resolve();
|
|
668
|
+
};
|
|
669
|
+
const rejectOnce = (error) => {
|
|
670
|
+
if (settled) return;
|
|
671
|
+
settled = true;
|
|
672
|
+
signal.removeEventListener("abort", onAbort);
|
|
673
|
+
reject(error);
|
|
674
|
+
};
|
|
675
|
+
const onAbort = () => {
|
|
676
|
+
this.disconnectMode = recovering ? "rejoin" : "connect_failure";
|
|
677
|
+
void this.room.disconnect().catch(() => {
|
|
499
678
|
});
|
|
679
|
+
rejectOnce(abortError(signal));
|
|
680
|
+
};
|
|
681
|
+
if (signal.aborted) {
|
|
682
|
+
onAbort();
|
|
683
|
+
} else {
|
|
684
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
685
|
+
}
|
|
686
|
+
connection.then(resolveOnce, rejectOnce);
|
|
687
|
+
});
|
|
688
|
+
}
|
|
689
|
+
async rejoin(apiBase = this.apiBase) {
|
|
690
|
+
if (this.startupConnectController || this.manualRejoinController) {
|
|
691
|
+
throw new Error("A session connection attempt is already in progress");
|
|
500
692
|
}
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
const blockedStates = recovering
|
|
504
|
-
? ["connecting", "connected_not_ready", "connected", "reconnecting"]
|
|
505
|
-
: ["connecting", "connected_not_ready", "connected", "reconnecting", "resuming"];
|
|
506
|
-
const continuingStartupRetry = (!recovering
|
|
507
|
-
&& !markFailureTerminal
|
|
508
|
-
&& this.stateValue === "connecting");
|
|
509
|
-
if (blockedStates.includes(this.stateValue) && !continuingStartupRetry) {
|
|
510
|
-
throw new Error(`Cannot connect while session is ${this.stateValue}`);
|
|
511
|
-
}
|
|
512
|
-
const generation = this.contextGeneration;
|
|
513
|
-
const resolved = { ...this.connectDefaults, ...options };
|
|
514
|
-
const audioTrack = resolved.audioTrack ?? null;
|
|
515
|
-
const microphone = resolved.microphone ?? audioTrack === null;
|
|
516
|
-
const readyTimeoutMs = resolved.readyTimeoutMs ?? 15_000;
|
|
517
|
-
const waitForRemoteAudio = resolved.waitForRemoteAudio ?? true;
|
|
518
|
-
const apiBase = normalizeApiBase(resolved.apiBase ?? this.apiBase);
|
|
519
|
-
this.validateAudioOptions(microphone, audioTrack);
|
|
520
|
-
this.useManagedMicrophone = microphone;
|
|
521
|
-
this.applicationAudioTrack = audioTrack;
|
|
522
|
-
if (!recovering) {
|
|
523
|
-
this.sessionEnded = false;
|
|
524
|
-
this.agentStreamId = null;
|
|
525
|
-
this.reconnectStartedAt = null;
|
|
526
|
-
this.pendingEndEvent = null;
|
|
527
|
-
this.agentDisconnected = false;
|
|
528
|
-
this.terminalSessionError = null;
|
|
529
|
-
this.endNotificationDelivered = false;
|
|
530
|
-
this.setState("connecting");
|
|
531
|
-
}
|
|
532
|
-
else {
|
|
533
|
-
this.setState("resuming");
|
|
534
|
-
}
|
|
535
|
-
this.apiBase = apiBase;
|
|
536
|
-
this.agentReady = false;
|
|
537
|
-
try {
|
|
538
|
-
await this.connectRoom(signal, recovering);
|
|
539
|
-
throwIfAborted(signal);
|
|
540
|
-
if (audioTrack) {
|
|
541
|
-
await raceWithAbort(this.publishApplicationAudioTrack(audioTrack), signal);
|
|
542
|
-
}
|
|
543
|
-
else if (microphone) {
|
|
544
|
-
await raceWithAbort(this.room.localParticipant.setMicrophoneEnabled(this.microphoneEnabled), signal);
|
|
545
|
-
}
|
|
546
|
-
throwIfAborted(signal);
|
|
547
|
-
this.setState(recovering ? "resuming" : "connected_not_ready");
|
|
548
|
-
await raceWithAbort(this.sendHello(), signal);
|
|
549
|
-
const readyDeadline = Date.now() + readyTimeoutMs;
|
|
550
|
-
await this.waitForAgentReady(readyTimeoutMs, signal);
|
|
551
|
-
if (waitForRemoteAudio)
|
|
552
|
-
await this.waitForRemoteAudioTrack(readyDeadline, signal);
|
|
553
|
-
throwIfAborted(signal);
|
|
554
|
-
if (this.sessionEnded || generation !== this.contextGeneration) {
|
|
555
|
-
throw new Error("Connection restoration interrupted");
|
|
556
|
-
}
|
|
557
|
-
this.resumeContextDelivery(recovering);
|
|
558
|
-
throwIfAborted(signal);
|
|
559
|
-
if (this.sessionEnded || generation !== this.contextGeneration) {
|
|
560
|
-
throw new Error("Connection restoration interrupted");
|
|
561
|
-
}
|
|
562
|
-
this.hasConnectedValue = true;
|
|
563
|
-
this.setState("connected");
|
|
564
|
-
}
|
|
565
|
-
catch (error) {
|
|
566
|
-
if (signal?.aborted)
|
|
567
|
-
this.rejectPendingCommands(toError(abortError(signal)));
|
|
568
|
-
this.disconnectMode = recovering ? "rejoin" : "connect_failure";
|
|
569
|
-
try {
|
|
570
|
-
await this.room.disconnect();
|
|
571
|
-
}
|
|
572
|
-
catch {
|
|
573
|
-
// Preserve the original connection/readiness failure.
|
|
574
|
-
}
|
|
575
|
-
finally {
|
|
576
|
-
this.disconnectMode = null;
|
|
577
|
-
}
|
|
578
|
-
if (!recovering && markFailureTerminal)
|
|
579
|
-
this.setState("failed");
|
|
580
|
-
throw error;
|
|
581
|
-
}
|
|
582
|
-
}
|
|
583
|
-
connectRoom(signal, recovering) {
|
|
584
|
-
throwIfAborted(signal);
|
|
585
|
-
const connection = this.room.connect(this.credentials.url, this.credentials.token);
|
|
586
|
-
if (!signal)
|
|
587
|
-
return connection;
|
|
588
|
-
return new Promise((resolve, reject) => {
|
|
589
|
-
let settled = false;
|
|
590
|
-
const resolveOnce = () => {
|
|
591
|
-
if (settled)
|
|
592
|
-
return;
|
|
593
|
-
settled = true;
|
|
594
|
-
signal.removeEventListener("abort", onAbort);
|
|
595
|
-
resolve();
|
|
596
|
-
};
|
|
597
|
-
const rejectOnce = (error) => {
|
|
598
|
-
if (settled)
|
|
599
|
-
return;
|
|
600
|
-
settled = true;
|
|
601
|
-
signal.removeEventListener("abort", onAbort);
|
|
602
|
-
reject(error);
|
|
603
|
-
};
|
|
604
|
-
const onAbort = () => {
|
|
605
|
-
this.disconnectMode = recovering ? "rejoin" : "connect_failure";
|
|
606
|
-
void this.room.disconnect().catch(() => { });
|
|
607
|
-
rejectOnce(abortError(signal));
|
|
608
|
-
};
|
|
609
|
-
if (signal.aborted) {
|
|
610
|
-
onAbort();
|
|
611
|
-
}
|
|
612
|
-
else {
|
|
613
|
-
signal.addEventListener("abort", onAbort, { once: true });
|
|
614
|
-
}
|
|
615
|
-
connection.then(resolveOnce, rejectOnce);
|
|
616
|
-
});
|
|
693
|
+
if (this.sessionEnded) {
|
|
694
|
+
throw this.terminalSessionError ?? new Error("Cannot rejoin a session that has ended");
|
|
617
695
|
}
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
throw new Error("A session connection attempt is already in progress");
|
|
621
|
-
}
|
|
622
|
-
if (this.sessionEnded) {
|
|
623
|
-
throw this.terminalSessionError ?? new Error("Cannot rejoin a session that has ended");
|
|
624
|
-
}
|
|
625
|
-
if (["connecting", "connected_not_ready", "reconnecting", "resuming"].includes(this.stateValue)) {
|
|
626
|
-
throw new Error(`Cannot rejoin while session is ${this.stateValue}`);
|
|
627
|
-
}
|
|
628
|
-
this.cancelRecovery();
|
|
629
|
-
const normalizedApiBase = normalizeApiBase(apiBase);
|
|
630
|
-
const recovering = this.hasConnectedValue;
|
|
631
|
-
const controller = new AbortController();
|
|
632
|
-
this.manualRejoinController = controller;
|
|
633
|
-
if (recovering) {
|
|
634
|
-
this.beginRecovery("rejoin", 1, { trigger: "manual_rejoin" });
|
|
635
|
-
this.setState("resuming");
|
|
636
|
-
}
|
|
637
|
-
try {
|
|
638
|
-
await this.performRejoin(normalizedApiBase, { recovering, signal: controller.signal });
|
|
639
|
-
if (this.sessionEnded)
|
|
640
|
-
return;
|
|
641
|
-
if (recovering)
|
|
642
|
-
this.completeRecovery();
|
|
643
|
-
}
|
|
644
|
-
catch (error) {
|
|
645
|
-
if (controller.signal.aborted && !this.terminalSessionError)
|
|
646
|
-
return;
|
|
647
|
-
const normalizedError = toError(error);
|
|
648
|
-
if (recovering)
|
|
649
|
-
this.failRecovery(normalizedError);
|
|
650
|
-
this.setState("failed");
|
|
651
|
-
throw error;
|
|
652
|
-
}
|
|
653
|
-
finally {
|
|
654
|
-
if (this.manualRejoinController === controller) {
|
|
655
|
-
this.manualRejoinController = null;
|
|
656
|
-
}
|
|
657
|
-
}
|
|
696
|
+
if (["connecting", "connected_not_ready", "reconnecting", "resuming"].includes(this.stateValue)) {
|
|
697
|
+
throw new Error(`Cannot rejoin while session is ${this.stateValue}`);
|
|
658
698
|
}
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
throwIfAborted(signal);
|
|
668
|
-
response = await this.fetchRoomToken(apiBase, signal);
|
|
669
|
-
}
|
|
670
|
-
// A live ending may arrive while token renewal is in flight.
|
|
671
|
-
if (this.sessionEnded) {
|
|
672
|
-
if (this.terminalSessionError)
|
|
673
|
-
throw this.terminalSessionError;
|
|
674
|
-
return;
|
|
675
|
-
}
|
|
676
|
-
if (!response.ok) {
|
|
677
|
-
if (response.status === 409 && await this.recoverSessionEnd(apiBase, signal))
|
|
678
|
-
return;
|
|
679
|
-
throw await raceWithAbort(httpError(response, "Could not renew room token"), signal);
|
|
680
|
-
}
|
|
681
|
-
const payload = (await raceWithAbort(response.json(), signal));
|
|
682
|
-
throwIfAborted(signal);
|
|
683
|
-
this.credentials = payload.connection;
|
|
684
|
-
if (this.room.state !== LiveKitConnectionState.Disconnected) {
|
|
685
|
-
this.disconnectMode = "rejoin";
|
|
686
|
-
try {
|
|
687
|
-
await raceWithAbort(this.room.disconnect(), signal);
|
|
688
|
-
}
|
|
689
|
-
finally {
|
|
690
|
-
this.disconnectMode = null;
|
|
691
|
-
}
|
|
692
|
-
}
|
|
693
|
-
throwIfAborted(signal);
|
|
694
|
-
if (recovering && this.reconnectStartedAt !== null) {
|
|
695
|
-
this.pendingReconnect = {
|
|
696
|
-
started_at: new Date(this.reconnectStartedAt).toISOString(),
|
|
697
|
-
recovered_at: new Date().toISOString(),
|
|
698
|
-
duration_ms: Date.now() - this.reconnectStartedAt,
|
|
699
|
-
};
|
|
700
|
-
}
|
|
701
|
-
// Startup retries resolve the original caller options with constructor defaults.
|
|
702
|
-
// A later rejoin restores the audio settings saved by the previous connection.
|
|
703
|
-
const connectionOptions = connect ?? {
|
|
704
|
-
...this.recoveryReadiness,
|
|
705
|
-
microphone: this.useManagedMicrophone,
|
|
706
|
-
...(this.applicationAudioTrack ? { audioTrack: this.applicationAudioTrack } : {}),
|
|
707
|
-
};
|
|
708
|
-
await this.connectWithMode({ ...connectionOptions, apiBase }, recovering, signal, markFailureTerminal);
|
|
709
|
-
}
|
|
710
|
-
async setMicrophoneEnabled(enabled) {
|
|
711
|
-
// Recovery must see the user's latest intent while the media operation waits.
|
|
712
|
-
this.microphoneEnabled = enabled;
|
|
713
|
-
if (this.applicationAudioTrack) {
|
|
714
|
-
// The room may not know this track yet while publication is pending.
|
|
715
|
-
const track = this.localApplicationAudioTrack;
|
|
716
|
-
if (enabled)
|
|
717
|
-
await track?.unmute();
|
|
718
|
-
else
|
|
719
|
-
await track?.mute();
|
|
720
|
-
return;
|
|
721
|
-
}
|
|
722
|
-
await this.room.localParticipant.setMicrophoneEnabled(enabled);
|
|
699
|
+
this.cancelRecovery();
|
|
700
|
+
const normalizedApiBase = normalizeApiBase(apiBase);
|
|
701
|
+
const recovering = this.hasConnectedValue;
|
|
702
|
+
const controller = new AbortController();
|
|
703
|
+
this.manualRejoinController = controller;
|
|
704
|
+
if (recovering) {
|
|
705
|
+
this.beginRecovery("rejoin", 1, { trigger: "manual_rejoin" });
|
|
706
|
+
this.setState("resuming");
|
|
723
707
|
}
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
708
|
+
try {
|
|
709
|
+
await this.performRejoin(normalizedApiBase, { recovering, signal: controller.signal });
|
|
710
|
+
if (this.sessionEnded) return;
|
|
711
|
+
if (recovering) this.completeRecovery();
|
|
712
|
+
} catch (error) {
|
|
713
|
+
if (controller.signal.aborted && !this.terminalSessionError) return;
|
|
714
|
+
const normalizedError = toError(error);
|
|
715
|
+
if (recovering) this.failRecovery(normalizedError);
|
|
716
|
+
this.setState("failed");
|
|
717
|
+
throw error;
|
|
718
|
+
} finally {
|
|
719
|
+
if (this.manualRejoinController === controller) {
|
|
720
|
+
this.manualRejoinController = null;
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
async performRejoin(apiBase, options = {}) {
|
|
725
|
+
const {
|
|
726
|
+
signal,
|
|
727
|
+
recovering = true,
|
|
728
|
+
connect,
|
|
729
|
+
markFailureTerminal = true
|
|
730
|
+
} = options;
|
|
731
|
+
this.contextGeneration += 1;
|
|
732
|
+
this.replacementContext.pause();
|
|
733
|
+
this.rejectPendingCommands(new Error("Restoring the session"));
|
|
734
|
+
let response = await this.fetchRoomToken(apiBase, signal);
|
|
735
|
+
if (response.status === 401 && this.refreshCredentialsCallback) {
|
|
736
|
+
await this.refreshCredentials(signal);
|
|
737
|
+
throwIfAborted(signal);
|
|
738
|
+
response = await this.fetchRoomToken(apiBase, signal);
|
|
739
|
+
}
|
|
740
|
+
if (this.sessionEnded) {
|
|
741
|
+
if (this.terminalSessionError) throw this.terminalSessionError;
|
|
742
|
+
return;
|
|
743
|
+
}
|
|
744
|
+
if (!response.ok) {
|
|
745
|
+
if (response.status === 409 && await this.recoverSessionEnd(apiBase, signal)) return;
|
|
746
|
+
throw await raceWithAbort(httpError(response, "Could not renew room token"), signal);
|
|
747
|
+
}
|
|
748
|
+
const payload = await raceWithAbort(response.json(), signal);
|
|
749
|
+
throwIfAborted(signal);
|
|
750
|
+
this.credentials = payload.connection;
|
|
751
|
+
if (this.room.state !== LiveKitConnectionState.Disconnected) {
|
|
752
|
+
this.disconnectMode = "rejoin";
|
|
753
|
+
try {
|
|
754
|
+
await raceWithAbort(this.room.disconnect(), signal);
|
|
755
|
+
} finally {
|
|
756
|
+
this.disconnectMode = null;
|
|
757
|
+
}
|
|
727
758
|
}
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
759
|
+
throwIfAborted(signal);
|
|
760
|
+
if (recovering && this.reconnectStartedAt !== null) {
|
|
761
|
+
this.pendingReconnect = {
|
|
762
|
+
started_at: new Date(this.reconnectStartedAt).toISOString(),
|
|
763
|
+
recovered_at: (/* @__PURE__ */ new Date()).toISOString(),
|
|
764
|
+
duration_ms: Date.now() - this.reconnectStartedAt
|
|
765
|
+
};
|
|
766
|
+
}
|
|
767
|
+
const connectionOptions = connect ?? {
|
|
768
|
+
...this.recoveryReadiness,
|
|
769
|
+
microphone: this.useManagedMicrophone,
|
|
770
|
+
...this.applicationAudioTrack ? { audioTrack: this.applicationAudioTrack } : {}
|
|
771
|
+
};
|
|
772
|
+
await this.connectWithMode({ ...connectionOptions, apiBase }, recovering, signal, markFailureTerminal);
|
|
773
|
+
}
|
|
774
|
+
async setMicrophoneEnabled(enabled) {
|
|
775
|
+
this.microphoneEnabled = enabled;
|
|
776
|
+
if (this.applicationAudioTrack) {
|
|
777
|
+
const track = this.localApplicationAudioTrack;
|
|
778
|
+
if (enabled) await track?.unmute();
|
|
779
|
+
else await track?.mute();
|
|
780
|
+
return;
|
|
781
|
+
}
|
|
782
|
+
await this.room.localParticipant.setMicrophoneEnabled(enabled);
|
|
783
|
+
}
|
|
784
|
+
async sendCommand(type, payload, timeoutMs = 1e4) {
|
|
785
|
+
const message = createClientMessage(
|
|
786
|
+
this.connectionId,
|
|
787
|
+
++this.clientSeq,
|
|
788
|
+
type,
|
|
789
|
+
payload
|
|
790
|
+
);
|
|
791
|
+
return this.sendTrackedCommand(message, timeoutMs);
|
|
792
|
+
}
|
|
793
|
+
async updateContext(update, timeoutMs = 2e4) {
|
|
794
|
+
if (this.sessionEnded || ["failed", "disconnected"].includes(this.stateValue)) {
|
|
795
|
+
throw new Error("Cannot update context while the session is inactive");
|
|
796
|
+
}
|
|
797
|
+
if (update.mode === "replace") return this.replacementContext.submit(update, timeoutMs);
|
|
798
|
+
if (this.activeRecovery) throw new Error("Cannot append context during recovery");
|
|
799
|
+
return this.sendContextUpdate(update, timeoutMs);
|
|
800
|
+
}
|
|
801
|
+
async sendContextUpdate(update, timeoutMs) {
|
|
802
|
+
const generation = this.contextGeneration;
|
|
803
|
+
const payloadBytes = contextUpdateBytes(update);
|
|
804
|
+
const transferStartedAt = Date.now();
|
|
805
|
+
let transferTimedOut = false;
|
|
806
|
+
this.recordDiagnostic({
|
|
807
|
+
operation: "context_transfer",
|
|
808
|
+
status: "started",
|
|
809
|
+
context_key: update.key,
|
|
810
|
+
payload_bytes: payloadBytes
|
|
811
|
+
});
|
|
812
|
+
this.activeContextTransfers += 1;
|
|
813
|
+
this.activeContextBytes += payloadBytes;
|
|
814
|
+
const uploading = ("image" in update ? this.uploadImageContext(update) : this.uploadTextContext(update)).finally(() => {
|
|
815
|
+
this.activeContextTransfers -= 1;
|
|
816
|
+
this.activeContextBytes -= payloadBytes;
|
|
817
|
+
});
|
|
818
|
+
const transferSettled = uploading.then(() => {
|
|
819
|
+
}, () => {
|
|
820
|
+
});
|
|
821
|
+
let transferTimeout;
|
|
822
|
+
const transferExpired = new Promise((_resolve, reject) => {
|
|
823
|
+
transferTimeout = globalThis.setTimeout(() => {
|
|
824
|
+
transferTimedOut = true;
|
|
825
|
+
const error = new ContextDeliveryError(`Context transfer timed out after ${timeoutMs}ms`, {
|
|
826
|
+
retryAfter: transferSettled
|
|
770
827
|
});
|
|
771
|
-
let transfer;
|
|
772
|
-
try {
|
|
773
|
-
transfer = await Promise.race([
|
|
774
|
-
uploading,
|
|
775
|
-
transferExpired,
|
|
776
|
-
]);
|
|
777
|
-
}
|
|
778
|
-
catch (error) {
|
|
779
|
-
if (!transferTimedOut) {
|
|
780
|
-
this.recordDiagnostic({
|
|
781
|
-
operation: "context_transfer",
|
|
782
|
-
status: "failed",
|
|
783
|
-
reason: "context_transfer_failed",
|
|
784
|
-
context_key: update.key,
|
|
785
|
-
payload_bytes: payloadBytes,
|
|
786
|
-
duration_ms: Date.now() - transferStartedAt,
|
|
787
|
-
});
|
|
788
|
-
this.flushDiagnostics("command_failed");
|
|
789
|
-
}
|
|
790
|
-
if (error instanceof TypeError || error instanceof ContextDeliveryError)
|
|
791
|
-
throw error;
|
|
792
|
-
throw new ContextDeliveryError(toError(error).message, { cause: error });
|
|
793
|
-
}
|
|
794
|
-
finally {
|
|
795
|
-
globalThis.clearTimeout(transferTimeout);
|
|
796
|
-
}
|
|
797
|
-
const transferDurationMs = Date.now() - transferStartedAt;
|
|
798
828
|
this.recordDiagnostic({
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
this.flushDiagnostics("slow_operation");
|
|
807
|
-
if (generation !== this.contextGeneration)
|
|
808
|
-
throw new Error("Context transfer interrupted by recovery");
|
|
809
|
-
const { payload } = transfer;
|
|
810
|
-
if (!isContextUpdatePayload(payload)) {
|
|
811
|
-
throw new TypeError("Invalid context update payload");
|
|
812
|
-
}
|
|
813
|
-
const message = createClientMessage(this.connectionId, ++this.clientSeq, "context.update", { ...payload });
|
|
814
|
-
return this.sendTrackedCommand(message, timeoutMs, transfer.resend);
|
|
815
|
-
}
|
|
816
|
-
resumeContextDelivery(restore) {
|
|
817
|
-
// Audio readiness owns connection recovery. Context delivery has its own
|
|
818
|
-
// deadlines and retries, and may be interrupted by a later disconnect.
|
|
819
|
-
void this.replacementContext.resume(restore).catch(() => { });
|
|
820
|
-
}
|
|
821
|
-
async uploadTextContext(update) {
|
|
822
|
-
const data = new TextEncoder().encode(update.text);
|
|
823
|
-
if (data.byteLength === 0)
|
|
824
|
-
throw new TypeError("Context text cannot be empty");
|
|
825
|
-
if (data.byteLength > 262_144) {
|
|
826
|
-
throw new TypeError("Context text must be at most 262144 UTF-8 bytes");
|
|
827
|
-
}
|
|
828
|
-
const base = {
|
|
829
|
-
key: update.key,
|
|
830
|
-
mode: update.mode,
|
|
831
|
-
label: update.label ?? "",
|
|
832
|
-
respond: update.respond ?? false,
|
|
833
|
-
};
|
|
834
|
-
if (data.byteLength <= 32_768)
|
|
835
|
-
return { payload: { ...base, text: update.text } };
|
|
836
|
-
const sha256 = await sha256Hex(data);
|
|
837
|
-
const streamId = contextStreamId();
|
|
838
|
-
const send = async () => {
|
|
839
|
-
const writer = await this.room.localParticipant.streamText({
|
|
840
|
-
streamId,
|
|
841
|
-
topic: "voxevolv.context.text",
|
|
842
|
-
totalSize: data.byteLength,
|
|
843
|
-
attributes: { sha256 },
|
|
844
|
-
});
|
|
845
|
-
await writer.write(update.text);
|
|
846
|
-
await writer.close();
|
|
847
|
-
};
|
|
848
|
-
await send();
|
|
849
|
-
return {
|
|
850
|
-
payload: {
|
|
851
|
-
...base,
|
|
852
|
-
text_stream: { stream_id: streamId, bytes: data.byteLength, sha256 },
|
|
853
|
-
},
|
|
854
|
-
resend: send,
|
|
855
|
-
};
|
|
856
|
-
}
|
|
857
|
-
async uploadImageContext(update) {
|
|
858
|
-
const allowed = new Set(["image/jpeg", "image/png", "image/webp"]);
|
|
859
|
-
if (!allowed.has(update.image.type)) {
|
|
860
|
-
throw new TypeError("Image context must be JPEG, PNG, or WebP");
|
|
861
|
-
}
|
|
862
|
-
const data = new Uint8Array(await update.image.arrayBuffer());
|
|
863
|
-
if (data.byteLength === 0 || data.byteLength > 5_242_880) {
|
|
864
|
-
throw new TypeError("Image context must be between 1 byte and 5 MiB");
|
|
865
|
-
}
|
|
866
|
-
const sha256 = await sha256Hex(data);
|
|
867
|
-
const streamId = contextStreamId();
|
|
868
|
-
const send = async () => {
|
|
869
|
-
const writer = await this.room.localParticipant.streamBytes({
|
|
870
|
-
streamId,
|
|
871
|
-
topic: "voxevolv.context.image",
|
|
872
|
-
name: "context-image",
|
|
873
|
-
mimeType: update.image.type,
|
|
874
|
-
totalSize: data.byteLength,
|
|
875
|
-
attributes: { sha256 },
|
|
876
|
-
});
|
|
877
|
-
await writer.write(data);
|
|
878
|
-
await writer.close();
|
|
879
|
-
};
|
|
880
|
-
await send();
|
|
881
|
-
return {
|
|
882
|
-
payload: {
|
|
883
|
-
key: update.key,
|
|
884
|
-
mode: "replace",
|
|
885
|
-
label: update.label ?? "",
|
|
886
|
-
image: {
|
|
887
|
-
stream_id: streamId,
|
|
888
|
-
mime: update.image.type,
|
|
889
|
-
bytes: data.byteLength,
|
|
890
|
-
sha256,
|
|
891
|
-
...(update.width === undefined ? {} : { width: update.width }),
|
|
892
|
-
...(update.height === undefined ? {} : { height: update.height }),
|
|
893
|
-
detail: update.detail ?? "low",
|
|
894
|
-
},
|
|
895
|
-
respond: update.respond ?? false,
|
|
896
|
-
},
|
|
897
|
-
resend: send,
|
|
898
|
-
};
|
|
899
|
-
}
|
|
900
|
-
async disconnect() {
|
|
901
|
-
this.abortStartupConnect(new Error("Session disconnected"));
|
|
902
|
-
this.startupConnectController = null;
|
|
903
|
-
this.failRecovery(new Error("Session disconnected during recovery"));
|
|
904
|
-
this.cancelRecovery();
|
|
905
|
-
this.contextGeneration += 1;
|
|
906
|
-
this.localApplicationAudioTrack = null;
|
|
907
|
-
this.replacementContext.clear(new Error("Session disconnected"));
|
|
908
|
-
if (this.stateValue === "disconnected")
|
|
909
|
-
return;
|
|
910
|
-
this.rejectPendingCommands(new Error("Session disconnected before command completion"));
|
|
911
|
-
this.disconnectMode = "intentional";
|
|
912
|
-
try {
|
|
913
|
-
await this.room.disconnect();
|
|
914
|
-
}
|
|
915
|
-
finally {
|
|
916
|
-
this.disconnectMode = null;
|
|
917
|
-
this.activeRecovery = null;
|
|
918
|
-
this.setState("disconnected");
|
|
919
|
-
}
|
|
920
|
-
}
|
|
921
|
-
async end(options = {}) {
|
|
922
|
-
this.credentialRefreshGeneration += 1;
|
|
923
|
-
this.abortStartupConnect(new Error("Session ended"));
|
|
924
|
-
this.startupConnectController = null;
|
|
925
|
-
this.failRecovery(new Error("Session ended during recovery"));
|
|
926
|
-
this.cancelRecovery();
|
|
927
|
-
this.sessionEnded = true;
|
|
928
|
-
const endpoint = options.endpoint ?? this.endEndpoint ?? `${this.apiBase}/sessions/${this.id}`;
|
|
929
|
-
const request = options.request ?? {};
|
|
930
|
-
const headers = new Headers(request.headers);
|
|
931
|
-
const usesScopedSessionEndpoint = !options.endpoint && !this.endEndpoint;
|
|
932
|
-
if (!headers.has("Authorization") && usesScopedSessionEndpoint) {
|
|
933
|
-
headers.set("Authorization", `Bearer ${this.accessToken}`);
|
|
934
|
-
}
|
|
935
|
-
let response = await this.fetcher(endpoint, {
|
|
936
|
-
...request,
|
|
937
|
-
method: request.method ?? "DELETE",
|
|
938
|
-
headers,
|
|
939
|
-
credentials: request.credentials ?? this.lifecycleCredentials,
|
|
940
|
-
});
|
|
941
|
-
if (response.status === 401 && usesScopedSessionEndpoint && this.refreshCredentialsCallback) {
|
|
942
|
-
await this.refreshCredentials(request.signal ?? undefined);
|
|
943
|
-
headers.set("Authorization", `Bearer ${this.accessToken}`);
|
|
944
|
-
response = await this.fetcher(endpoint, {
|
|
945
|
-
...request,
|
|
946
|
-
method: request.method ?? "DELETE",
|
|
947
|
-
headers,
|
|
948
|
-
credentials: request.credentials ?? this.lifecycleCredentials,
|
|
949
|
-
});
|
|
950
|
-
}
|
|
951
|
-
if (!response.ok)
|
|
952
|
-
throw await httpError(response, "Could not end realtime session");
|
|
953
|
-
if (options.disconnect ?? true)
|
|
954
|
-
await this.disconnect();
|
|
955
|
-
}
|
|
956
|
-
async replay(apiBase = this.apiBase, afterSeq = this.cursor.lastServerSeq) {
|
|
957
|
-
if (this.replayPromise)
|
|
958
|
-
return this.replayPromise;
|
|
959
|
-
const normalizedApiBase = normalizeApiBase(apiBase);
|
|
960
|
-
this.replayPromise = this.fetchReplay(normalizedApiBase, afterSeq).finally(() => {
|
|
961
|
-
this.replayPromise = null;
|
|
962
|
-
});
|
|
963
|
-
return this.replayPromise;
|
|
964
|
-
}
|
|
965
|
-
setState(state) {
|
|
966
|
-
if (this.stateValue === state)
|
|
967
|
-
return;
|
|
968
|
-
this.stateValue = state;
|
|
969
|
-
if (state === "connecting" || state === "resuming") {
|
|
970
|
-
globalThis.document?.addEventListener?.("visibilitychange", this.onDiagnosticVisibilityChange);
|
|
971
|
-
}
|
|
972
|
-
if (state === "disconnected" || state === "failed") {
|
|
973
|
-
globalThis.document?.removeEventListener?.("visibilitychange", this.onDiagnosticVisibilityChange);
|
|
974
|
-
this.flushDiagnostics("session_finished");
|
|
975
|
-
const error = new Error(`Session ${state}`);
|
|
976
|
-
this.contextGeneration += 1;
|
|
977
|
-
this.replacementContext.clear(error);
|
|
978
|
-
this.rejectPendingCommands(error);
|
|
979
|
-
}
|
|
980
|
-
this.notifyListeners(this.stateListeners, state);
|
|
981
|
-
}
|
|
982
|
-
bindRoomEvents() {
|
|
983
|
-
this.room.on(RoomEvent.ParticipantDisconnected, (participant) => {
|
|
984
|
-
if (participant.isAgent && !this.disconnectMode) {
|
|
985
|
-
this.agentDisconnected = true;
|
|
986
|
-
if (this.terminalSessionError)
|
|
987
|
-
return;
|
|
988
|
-
if (this.sessionEnded) {
|
|
989
|
-
this.finishPendingEnd();
|
|
990
|
-
return;
|
|
991
|
-
}
|
|
992
|
-
this.startAutoRejoin(new Error("Agent disconnected from the room"), { trigger: "agent_disconnected" });
|
|
993
|
-
}
|
|
994
|
-
});
|
|
995
|
-
this.room.on(RoomEvent.SignalReconnecting, () => this.handleLiveKitReconnecting());
|
|
996
|
-
this.room.on(RoomEvent.Reconnecting, () => this.handleLiveKitReconnecting());
|
|
997
|
-
this.room.on(RoomEvent.Reconnected, () => {
|
|
998
|
-
void this.handleLiveKitReconnected();
|
|
999
|
-
});
|
|
1000
|
-
this.room.on(RoomEvent.Disconnected, (reason) => {
|
|
1001
|
-
this.handleLiveKitDisconnected(reason);
|
|
1002
|
-
});
|
|
1003
|
-
this.room.on(RoomEvent.ConnectionStateChanged, (state) => {
|
|
1004
|
-
if (this.sessionEnded || this.terminalSessionError)
|
|
1005
|
-
return;
|
|
1006
|
-
if (state === LiveKitConnectionState.Connected) {
|
|
1007
|
-
const recovering = this.activeRecovery !== null || this.stateValue === "resuming";
|
|
1008
|
-
this.setState(this.agentReady
|
|
1009
|
-
? "connected"
|
|
1010
|
-
: recovering
|
|
1011
|
-
? "resuming"
|
|
1012
|
-
: "connected_not_ready");
|
|
1013
|
-
}
|
|
1014
|
-
});
|
|
1015
|
-
this.room.on(RoomEvent.ConnectionQualityChanged, (quality, participant) => {
|
|
1016
|
-
if (!participant.isLocal)
|
|
1017
|
-
return;
|
|
1018
|
-
this.notifyListeners(this.connectionQualityListeners, quality);
|
|
1019
|
-
});
|
|
1020
|
-
this.room.on(RoomEvent.TrackSubscribed, (track) => {
|
|
1021
|
-
if (track.kind !== Track.Kind.Audio)
|
|
1022
|
-
return;
|
|
1023
|
-
const element = track.attach();
|
|
1024
|
-
element.dataset.voxevolvRemoteAudio = "true";
|
|
1025
|
-
element.style.display = "none";
|
|
1026
|
-
document.body.appendChild(element);
|
|
1027
|
-
try {
|
|
1028
|
-
const playback = element.play();
|
|
1029
|
-
void playback.catch((error) => {
|
|
1030
|
-
this.notifyListeners(this.audioPlaybackBlockedListeners, {
|
|
1031
|
-
error,
|
|
1032
|
-
element,
|
|
1033
|
-
track: track.mediaStreamTrack,
|
|
1034
|
-
});
|
|
1035
|
-
});
|
|
1036
|
-
}
|
|
1037
|
-
catch (error) {
|
|
1038
|
-
this.notifyListeners(this.audioPlaybackBlockedListeners, {
|
|
1039
|
-
error,
|
|
1040
|
-
element,
|
|
1041
|
-
track: track.mediaStreamTrack,
|
|
1042
|
-
});
|
|
1043
|
-
}
|
|
1044
|
-
const stream = new MediaStream([track.mediaStreamTrack]);
|
|
1045
|
-
this.remoteAudioStreams.set(track, stream);
|
|
1046
|
-
this.outputStream.addTrack(track.mediaStreamTrack);
|
|
1047
|
-
this.notifyRemoteAudioListeners(this.remoteAudioTrackListeners, track.mediaStreamTrack, stream);
|
|
1048
|
-
this.remoteAudioTrackResolve?.();
|
|
1049
|
-
});
|
|
1050
|
-
this.room.on(RoomEvent.TrackUnsubscribed, (track) => {
|
|
1051
|
-
if (track.kind !== Track.Kind.Audio)
|
|
1052
|
-
return;
|
|
1053
|
-
const stream = this.remoteAudioStreams.get(track) ?? new MediaStream([track.mediaStreamTrack]);
|
|
1054
|
-
this.notifyRemoteAudioListeners(this.remoteAudioTrackUnsubscribedListeners, track.mediaStreamTrack, stream);
|
|
1055
|
-
this.remoteAudioStreams.delete(track);
|
|
1056
|
-
this.outputMediaStream?.removeTrack(track.mediaStreamTrack);
|
|
1057
|
-
track.detach().forEach((element) => element.remove());
|
|
1058
|
-
});
|
|
1059
|
-
this.room.on(RoomEvent.DataReceived, (data, _participant, kind, topic) => {
|
|
1060
|
-
if (topic !== "voxevolv.events" || kind !== DataPacket_Kind.RELIABLE)
|
|
1061
|
-
return;
|
|
1062
|
-
let value;
|
|
1063
|
-
try {
|
|
1064
|
-
value = JSON.parse(new TextDecoder().decode(data));
|
|
1065
|
-
}
|
|
1066
|
-
catch {
|
|
1067
|
-
// Ignore data from other room participants that is not a VoxEvolv envelope.
|
|
1068
|
-
return;
|
|
1069
|
-
}
|
|
1070
|
-
if (!isRealtimeEvent(value))
|
|
1071
|
-
return;
|
|
1072
|
-
const result = this.cursor.accept(value);
|
|
1073
|
-
if (result.gap) {
|
|
1074
|
-
this.notifyListeners(this.gapListeners, result.gap);
|
|
1075
|
-
void this.replay(this.apiBase, result.gap.after_server_seq);
|
|
1076
|
-
}
|
|
1077
|
-
if (!result.deliver)
|
|
1078
|
-
return;
|
|
1079
|
-
this.observeReadiness(value);
|
|
1080
|
-
this.observeCommandResult(value);
|
|
1081
|
-
this.observeConvenienceEvents(value);
|
|
1082
|
-
this.notifyListeners(this.listeners, value);
|
|
1083
|
-
});
|
|
1084
|
-
}
|
|
1085
|
-
handleLiveKitReconnecting() {
|
|
1086
|
-
if (this.sessionEnded || this.disconnectMode === "intentional")
|
|
1087
|
-
return;
|
|
1088
|
-
this.agentReady = false;
|
|
1089
|
-
// Once full rejoin owns recovery, late LiveKit reconnect notifications
|
|
1090
|
-
// from the old transport must not move the wrapper back to reconnecting.
|
|
1091
|
-
// Doing so makes connectWithMode() reject the replacement connection.
|
|
1092
|
-
if (this.autoRejoinPromise !== null
|
|
1093
|
-
|| this.activeRecovery?.strategy === "rejoin"
|
|
1094
|
-
|| this.disconnectMode === "rejoin") {
|
|
1095
|
-
this.setState("resuming");
|
|
1096
|
-
return;
|
|
1097
|
-
}
|
|
1098
|
-
this.contextGeneration += 1;
|
|
1099
|
-
this.replacementContext.pause();
|
|
1100
|
-
this.reconnectStartedAt ??= Date.now();
|
|
1101
|
-
this.rejectPendingCommands(new Error("Connection interrupted before command completion"), true);
|
|
1102
|
-
if (this.activeRecovery === null) {
|
|
1103
|
-
this.beginRecovery("livekit", 1, { trigger: "livekit_reconnecting" });
|
|
1104
|
-
}
|
|
1105
|
-
this.setState("reconnecting");
|
|
1106
|
-
}
|
|
1107
|
-
async handleLiveKitReconnected() {
|
|
1108
|
-
if (this.sessionEnded || this.disconnectMode || this.autoRejoinPromise)
|
|
1109
|
-
return;
|
|
1110
|
-
const generation = this.contextGeneration;
|
|
1111
|
-
const recoveredAt = Date.now();
|
|
1112
|
-
if (this.reconnectStartedAt !== null) {
|
|
1113
|
-
this.pendingReconnect = {
|
|
1114
|
-
started_at: new Date(this.reconnectStartedAt).toISOString(),
|
|
1115
|
-
recovered_at: new Date(recoveredAt).toISOString(),
|
|
1116
|
-
duration_ms: Math.max(recoveredAt - this.reconnectStartedAt, 0),
|
|
1117
|
-
};
|
|
1118
|
-
this.reconnectStartedAt = null;
|
|
1119
|
-
}
|
|
1120
|
-
if (this.hasConnectedValue && this.activeRecovery === null) {
|
|
1121
|
-
this.beginRecovery("livekit", 1, { trigger: "livekit_reconnecting" });
|
|
1122
|
-
}
|
|
1123
|
-
this.connectionId = `conn_${Math.random().toString(36).slice(2)}_${Date.now().toString(36)}`;
|
|
1124
|
-
this.clientSeq = 0;
|
|
1125
|
-
this.setState(this.hasConnectedValue ? "resuming" : "connected_not_ready");
|
|
1126
|
-
try {
|
|
1127
|
-
await this.sendHello();
|
|
1128
|
-
const deadline = Date.now() + this.recoveryReadiness.readyTimeoutMs;
|
|
1129
|
-
await this.waitForAgentReady(Math.max(deadline - Date.now(), 0));
|
|
1130
|
-
if (this.recoveryReadiness.waitForRemoteAudio)
|
|
1131
|
-
await this.waitForRemoteAudioTrack(deadline);
|
|
1132
|
-
if (this.sessionEnded || generation !== this.contextGeneration)
|
|
1133
|
-
return;
|
|
1134
|
-
this.resumeContextDelivery(true);
|
|
1135
|
-
if (this.sessionEnded || generation !== this.contextGeneration)
|
|
1136
|
-
return;
|
|
1137
|
-
this.setState("connected");
|
|
1138
|
-
this.completeRecovery();
|
|
1139
|
-
}
|
|
1140
|
-
catch (error) {
|
|
1141
|
-
if (this.sessionEnded || generation !== this.contextGeneration)
|
|
1142
|
-
return;
|
|
1143
|
-
const normalizedError = toError(error);
|
|
1144
|
-
if (this.shouldAutoRejoin()) {
|
|
1145
|
-
this.startAutoRejoin(normalizedError, { trigger: "livekit_resume_failed" });
|
|
1146
|
-
}
|
|
1147
|
-
else {
|
|
1148
|
-
this.failRecovery(normalizedError);
|
|
1149
|
-
this.setState("failed");
|
|
1150
|
-
}
|
|
1151
|
-
}
|
|
1152
|
-
}
|
|
1153
|
-
handleLiveKitDisconnected(reason) {
|
|
1154
|
-
this.agentReady = false;
|
|
1155
|
-
if (this.disconnectMode !== "rejoin") {
|
|
1156
|
-
this.pendingReconnect = null;
|
|
1157
|
-
}
|
|
1158
|
-
// Drop any remaining remote tracks so a stale, ended track cannot
|
|
1159
|
-
// satisfy the next connect()'s wait for agent audio.
|
|
1160
|
-
this.outputMediaStream?.getTracks().forEach((track) => {
|
|
1161
|
-
this.outputMediaStream?.removeTrack(track);
|
|
829
|
+
operation: "context_transfer",
|
|
830
|
+
status: "timed_out",
|
|
831
|
+
reason: "context_transfer_timeout",
|
|
832
|
+
context_key: update.key,
|
|
833
|
+
payload_bytes: payloadBytes,
|
|
834
|
+
duration_ms: Date.now() - transferStartedAt,
|
|
835
|
+
timeout_ms: timeoutMs
|
|
1162
836
|
});
|
|
1163
|
-
this.
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
return;
|
|
1176
|
-
if (this.shouldAutoRejoin(reason)) {
|
|
1177
|
-
this.startAutoRejoin(new Error(`LiveKit disconnected (${disconnectReasonName(reason)})`), { trigger: "livekit_disconnected" });
|
|
1178
|
-
return;
|
|
1179
|
-
}
|
|
1180
|
-
if (this.activeRecovery !== null) {
|
|
1181
|
-
this.failRecovery(new Error(`LiveKit disconnected (${disconnectReasonName(reason)})`));
|
|
1182
|
-
}
|
|
1183
|
-
this.deliverPendingEndEvent();
|
|
1184
|
-
this.setState("disconnected");
|
|
1185
|
-
}
|
|
1186
|
-
// The terminal session.ended event is recorded durably after room teardown,
|
|
1187
|
-
// so turn the earlier live announcement into the browser completion signal.
|
|
1188
|
-
deliverPendingEndEvent() {
|
|
1189
|
-
const ending = this.pendingEndEvent;
|
|
1190
|
-
if (!ending)
|
|
1191
|
-
return;
|
|
1192
|
-
this.pendingEndEvent = null;
|
|
1193
|
-
this.notifyEnded({ ...ending, type: "session.ended" });
|
|
1194
|
-
}
|
|
1195
|
-
finishPendingEnd() {
|
|
1196
|
-
this.deliverPendingEndEvent();
|
|
1197
|
-
// The agent can leave while the browser remains in the room. Close that
|
|
1198
|
-
// transport ourselves, after notifying the application of the true reason.
|
|
1199
|
-
void this.disconnect().catch(() => { });
|
|
1200
|
-
}
|
|
1201
|
-
async recoverSessionEnd(apiBase, signal) {
|
|
1202
|
-
const controller = new AbortController();
|
|
1203
|
-
const cancel = () => controller.abort(signal?.reason);
|
|
1204
|
-
signal?.addEventListener("abort", cancel, { once: true });
|
|
1205
|
-
if (signal?.aborted)
|
|
1206
|
-
cancel();
|
|
1207
|
-
const timeout = globalThis.setTimeout(() => controller.abort(), 5_000);
|
|
1208
|
-
try {
|
|
1209
|
-
// A later live event may have advanced the cursor past a missed ending.
|
|
1210
|
-
// Replay from the start; event IDs suppress already delivered messages.
|
|
1211
|
-
await this.fetchReplay(apiBase, 0, { signal: controller.signal, terminalOnly: true });
|
|
1212
|
-
}
|
|
1213
|
-
catch {
|
|
1214
|
-
// A 409 also covers failed sessions. Without an authoritative ending,
|
|
1215
|
-
// retain the original HTTP failure rather than invent a normal end.
|
|
1216
|
-
}
|
|
1217
|
-
finally {
|
|
1218
|
-
globalThis.clearTimeout(timeout);
|
|
1219
|
-
signal?.removeEventListener("abort", cancel);
|
|
1220
|
-
}
|
|
1221
|
-
if (!this.sessionEnded || this.terminalSessionError)
|
|
1222
|
-
return false;
|
|
1223
|
-
this.finishPendingEnd();
|
|
1224
|
-
return true;
|
|
1225
|
-
}
|
|
1226
|
-
notifyEnded(event) {
|
|
1227
|
-
if (this.endNotificationDelivered)
|
|
1228
|
-
return;
|
|
1229
|
-
this.endNotificationDelivered = true;
|
|
1230
|
-
this.notifyListeners(this.endedListeners, event);
|
|
1231
|
-
}
|
|
1232
|
-
beginRecovery(strategy, attempt, details) {
|
|
1233
|
-
this.activeRecovery = { strategy, attempt, details };
|
|
837
|
+
this.flushDiagnostics("context_transfer_timeout");
|
|
838
|
+
reject(error);
|
|
839
|
+
}, timeoutMs);
|
|
840
|
+
});
|
|
841
|
+
let transfer;
|
|
842
|
+
try {
|
|
843
|
+
transfer = await Promise.race([
|
|
844
|
+
uploading,
|
|
845
|
+
transferExpired
|
|
846
|
+
]);
|
|
847
|
+
} catch (error) {
|
|
848
|
+
if (!transferTimedOut) {
|
|
1234
849
|
this.recordDiagnostic({
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
});
|
|
1242
|
-
this.flushDiagnostics("recovery_started");
|
|
1243
|
-
this.notifyListeners(this.recoveryListeners, {
|
|
1244
|
-
status: "started",
|
|
1245
|
-
strategy,
|
|
1246
|
-
attempt,
|
|
1247
|
-
...details,
|
|
1248
|
-
});
|
|
1249
|
-
}
|
|
1250
|
-
completeRecovery() {
|
|
1251
|
-
const recovery = this.activeRecovery;
|
|
1252
|
-
if (!recovery)
|
|
1253
|
-
return;
|
|
1254
|
-
this.activeRecovery = null;
|
|
1255
|
-
this.notifyListeners(this.recoveryListeners, {
|
|
1256
|
-
status: "succeeded",
|
|
1257
|
-
strategy: recovery.strategy,
|
|
1258
|
-
attempt: recovery.attempt,
|
|
1259
|
-
...recovery.details,
|
|
850
|
+
operation: "context_transfer",
|
|
851
|
+
status: "failed",
|
|
852
|
+
reason: "context_transfer_failed",
|
|
853
|
+
context_key: update.key,
|
|
854
|
+
payload_bytes: payloadBytes,
|
|
855
|
+
duration_ms: Date.now() - transferStartedAt
|
|
1260
856
|
});
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
857
|
+
this.flushDiagnostics("command_failed");
|
|
858
|
+
}
|
|
859
|
+
if (error instanceof TypeError || error instanceof ContextDeliveryError) throw error;
|
|
860
|
+
throw new ContextDeliveryError(toError(error).message, { cause: error });
|
|
861
|
+
} finally {
|
|
862
|
+
globalThis.clearTimeout(transferTimeout);
|
|
863
|
+
}
|
|
864
|
+
const transferDurationMs = Date.now() - transferStartedAt;
|
|
865
|
+
this.recordDiagnostic({
|
|
866
|
+
operation: "context_transfer",
|
|
867
|
+
status: "succeeded",
|
|
868
|
+
context_key: update.key,
|
|
869
|
+
payload_bytes: payloadBytes,
|
|
870
|
+
duration_ms: transferDurationMs
|
|
871
|
+
});
|
|
872
|
+
if (transferDurationMs > 2e3) this.flushDiagnostics("slow_operation");
|
|
873
|
+
if (generation !== this.contextGeneration) throw new Error("Context transfer interrupted by recovery");
|
|
874
|
+
const { payload } = transfer;
|
|
875
|
+
if (!isContextUpdatePayload(payload)) {
|
|
876
|
+
throw new TypeError("Invalid context update payload");
|
|
877
|
+
}
|
|
878
|
+
const message = createClientMessage(
|
|
879
|
+
this.connectionId,
|
|
880
|
+
++this.clientSeq,
|
|
881
|
+
"context.update",
|
|
882
|
+
{ ...payload }
|
|
883
|
+
);
|
|
884
|
+
return this.sendTrackedCommand(
|
|
885
|
+
message,
|
|
886
|
+
timeoutMs,
|
|
887
|
+
transfer.resend
|
|
888
|
+
);
|
|
889
|
+
}
|
|
890
|
+
resumeContextDelivery(restore) {
|
|
891
|
+
void this.replacementContext.resume(restore).catch(() => {
|
|
892
|
+
});
|
|
893
|
+
}
|
|
894
|
+
async uploadTextContext(update) {
|
|
895
|
+
const data = new TextEncoder().encode(update.text);
|
|
896
|
+
if (data.byteLength === 0) throw new TypeError("Context text cannot be empty");
|
|
897
|
+
if (data.byteLength > 262144) {
|
|
898
|
+
throw new TypeError("Context text must be at most 262144 UTF-8 bytes");
|
|
899
|
+
}
|
|
900
|
+
const base = {
|
|
901
|
+
key: update.key,
|
|
902
|
+
mode: update.mode,
|
|
903
|
+
label: update.label ?? "",
|
|
904
|
+
respond: update.respond ?? false
|
|
905
|
+
};
|
|
906
|
+
if (data.byteLength <= 32768) return { payload: { ...base, text: update.text } };
|
|
907
|
+
const sha256 = await sha256Hex(data);
|
|
908
|
+
const streamId = contextStreamId();
|
|
909
|
+
const send = async () => {
|
|
910
|
+
const writer = await this.room.localParticipant.streamText({
|
|
911
|
+
streamId,
|
|
912
|
+
topic: "voxevolv.context.text",
|
|
913
|
+
totalSize: data.byteLength,
|
|
914
|
+
attributes: { sha256 }
|
|
915
|
+
});
|
|
916
|
+
await writer.write(update.text);
|
|
917
|
+
await writer.close();
|
|
918
|
+
};
|
|
919
|
+
await send();
|
|
920
|
+
return {
|
|
921
|
+
payload: {
|
|
922
|
+
...base,
|
|
923
|
+
text_stream: { stream_id: streamId, bytes: data.byteLength, sha256 }
|
|
924
|
+
},
|
|
925
|
+
resend: send
|
|
926
|
+
};
|
|
927
|
+
}
|
|
928
|
+
async uploadImageContext(update) {
|
|
929
|
+
const allowed = /* @__PURE__ */ new Set(["image/jpeg", "image/png", "image/webp"]);
|
|
930
|
+
if (!allowed.has(update.image.type)) {
|
|
931
|
+
throw new TypeError("Image context must be JPEG, PNG, or WebP");
|
|
932
|
+
}
|
|
933
|
+
const data = new Uint8Array(await update.image.arrayBuffer());
|
|
934
|
+
if (data.byteLength === 0 || data.byteLength > 5242880) {
|
|
935
|
+
throw new TypeError("Image context must be between 1 byte and 5 MiB");
|
|
936
|
+
}
|
|
937
|
+
const sha256 = await sha256Hex(data);
|
|
938
|
+
const streamId = contextStreamId();
|
|
939
|
+
const send = async () => {
|
|
940
|
+
const writer = await this.room.localParticipant.streamBytes({
|
|
941
|
+
streamId,
|
|
942
|
+
topic: "voxevolv.context.image",
|
|
943
|
+
name: "context-image",
|
|
944
|
+
mimeType: update.image.type,
|
|
945
|
+
totalSize: data.byteLength,
|
|
946
|
+
attributes: { sha256 }
|
|
947
|
+
});
|
|
948
|
+
await writer.write(data);
|
|
949
|
+
await writer.close();
|
|
950
|
+
};
|
|
951
|
+
await send();
|
|
952
|
+
return {
|
|
953
|
+
payload: {
|
|
954
|
+
key: update.key,
|
|
955
|
+
mode: "replace",
|
|
956
|
+
label: update.label ?? "",
|
|
957
|
+
image: {
|
|
958
|
+
stream_id: streamId,
|
|
959
|
+
mime: update.image.type,
|
|
960
|
+
bytes: data.byteLength,
|
|
961
|
+
sha256,
|
|
962
|
+
...update.width === void 0 ? {} : { width: update.width },
|
|
963
|
+
...update.height === void 0 ? {} : { height: update.height },
|
|
964
|
+
detail: update.detail ?? "low"
|
|
965
|
+
},
|
|
966
|
+
respond: update.respond ?? false
|
|
967
|
+
},
|
|
968
|
+
resend: send
|
|
969
|
+
};
|
|
970
|
+
}
|
|
971
|
+
async disconnect() {
|
|
972
|
+
this.abortStartupConnect(new Error("Session disconnected"));
|
|
973
|
+
this.startupConnectController = null;
|
|
974
|
+
this.failRecovery(new Error("Session disconnected during recovery"));
|
|
975
|
+
this.cancelRecovery();
|
|
976
|
+
this.contextGeneration += 1;
|
|
977
|
+
this.localApplicationAudioTrack = null;
|
|
978
|
+
this.replacementContext.clear(new Error("Session disconnected"));
|
|
979
|
+
if (this.stateValue === "disconnected") return;
|
|
980
|
+
this.rejectPendingCommands(new Error("Session disconnected before command completion"));
|
|
981
|
+
this.disconnectMode = "intentional";
|
|
982
|
+
try {
|
|
983
|
+
await this.room.disconnect();
|
|
984
|
+
} finally {
|
|
985
|
+
this.disconnectMode = null;
|
|
986
|
+
this.activeRecovery = null;
|
|
987
|
+
this.setState("disconnected");
|
|
988
|
+
}
|
|
989
|
+
}
|
|
990
|
+
async end(options = {}) {
|
|
991
|
+
this.credentialRefreshGeneration += 1;
|
|
992
|
+
this.abortStartupConnect(new Error("Session ended"));
|
|
993
|
+
this.startupConnectController = null;
|
|
994
|
+
this.failRecovery(new Error("Session ended during recovery"));
|
|
995
|
+
this.cancelRecovery();
|
|
996
|
+
this.sessionEnded = true;
|
|
997
|
+
const endpoint = options.endpoint ?? this.endEndpoint ?? `${this.apiBase}/sessions/${this.id}`;
|
|
998
|
+
const request = options.request ?? {};
|
|
999
|
+
const headers = new Headers(request.headers);
|
|
1000
|
+
const usesScopedSessionEndpoint = !options.endpoint && !this.endEndpoint;
|
|
1001
|
+
if (!headers.has("Authorization") && usesScopedSessionEndpoint) {
|
|
1002
|
+
headers.set("Authorization", `Bearer ${this.accessToken}`);
|
|
1003
|
+
}
|
|
1004
|
+
let response = await this.fetcher(endpoint, {
|
|
1005
|
+
...request,
|
|
1006
|
+
method: request.method ?? "DELETE",
|
|
1007
|
+
headers,
|
|
1008
|
+
credentials: request.credentials ?? this.lifecycleCredentials
|
|
1009
|
+
});
|
|
1010
|
+
if (response.status === 401 && usesScopedSessionEndpoint && this.refreshCredentialsCallback) {
|
|
1011
|
+
await this.refreshCredentials(request.signal ?? void 0);
|
|
1012
|
+
headers.set("Authorization", `Bearer ${this.accessToken}`);
|
|
1013
|
+
response = await this.fetcher(endpoint, {
|
|
1014
|
+
...request,
|
|
1015
|
+
method: request.method ?? "DELETE",
|
|
1016
|
+
headers,
|
|
1017
|
+
credentials: request.credentials ?? this.lifecycleCredentials
|
|
1018
|
+
});
|
|
1019
|
+
}
|
|
1020
|
+
if (!response.ok) throw await httpError(response, "Could not end realtime session");
|
|
1021
|
+
if (options.disconnect ?? true) await this.disconnect();
|
|
1022
|
+
}
|
|
1023
|
+
async replay(apiBase = this.apiBase, afterSeq = this.cursor.lastServerSeq) {
|
|
1024
|
+
if (this.replayPromise) return this.replayPromise;
|
|
1025
|
+
const normalizedApiBase = normalizeApiBase(apiBase);
|
|
1026
|
+
this.replayPromise = this.fetchReplay(normalizedApiBase, afterSeq).finally(() => {
|
|
1027
|
+
this.replayPromise = null;
|
|
1028
|
+
});
|
|
1029
|
+
return this.replayPromise;
|
|
1030
|
+
}
|
|
1031
|
+
setState(state) {
|
|
1032
|
+
if (this.stateValue === state) return;
|
|
1033
|
+
this.stateValue = state;
|
|
1034
|
+
if (state === "connecting" || state === "resuming") {
|
|
1035
|
+
this.startDiagnosticLoopMonitor();
|
|
1036
|
+
globalThis.document?.addEventListener?.("visibilitychange", this.onDiagnosticVisibilityChange);
|
|
1037
|
+
}
|
|
1038
|
+
if (state === "disconnected" || state === "failed") {
|
|
1039
|
+
globalThis.clearInterval(this.diagnosticLoopTimer);
|
|
1040
|
+
this.diagnosticLoopTimer = void 0;
|
|
1041
|
+
this.diagnosticLoopDelayMs = void 0;
|
|
1042
|
+
globalThis.document?.removeEventListener?.("visibilitychange", this.onDiagnosticVisibilityChange);
|
|
1043
|
+
this.flushDiagnostics("session_finished");
|
|
1044
|
+
const error = new Error(`Session ${state}`);
|
|
1045
|
+
this.contextGeneration += 1;
|
|
1046
|
+
this.replacementContext.clear(error);
|
|
1047
|
+
this.rejectPendingCommands(error);
|
|
1048
|
+
}
|
|
1049
|
+
this.notifyListeners(this.stateListeners, state);
|
|
1050
|
+
}
|
|
1051
|
+
bindRoomEvents() {
|
|
1052
|
+
this.room.on(RoomEvent.ParticipantDisconnected, (participant) => {
|
|
1053
|
+
if (participant.isAgent && !this.disconnectMode) {
|
|
1054
|
+
this.agentDisconnected = true;
|
|
1055
|
+
if (this.terminalSessionError) return;
|
|
1056
|
+
if (this.sessionEnded) {
|
|
1057
|
+
this.finishPendingEnd();
|
|
1058
|
+
return;
|
|
1059
|
+
}
|
|
1060
|
+
this.startAutoRejoin(
|
|
1061
|
+
new Error("Agent disconnected from the room"),
|
|
1062
|
+
{ trigger: "agent_disconnected" }
|
|
1063
|
+
);
|
|
1064
|
+
}
|
|
1065
|
+
});
|
|
1066
|
+
this.room.on(RoomEvent.SignalReconnecting, () => this.handleLiveKitReconnecting());
|
|
1067
|
+
this.room.on(RoomEvent.Reconnecting, () => this.handleLiveKitReconnecting());
|
|
1068
|
+
this.room.on(RoomEvent.Reconnected, () => {
|
|
1069
|
+
void this.handleLiveKitReconnected();
|
|
1070
|
+
});
|
|
1071
|
+
this.room.on(RoomEvent.Disconnected, (reason) => {
|
|
1072
|
+
this.handleLiveKitDisconnected(reason);
|
|
1073
|
+
});
|
|
1074
|
+
this.room.on(RoomEvent.ConnectionStateChanged, (state) => {
|
|
1075
|
+
if (this.sessionEnded || this.terminalSessionError) return;
|
|
1076
|
+
if (state === LiveKitConnectionState.Connected) {
|
|
1077
|
+
const recovering = this.activeRecovery !== null || this.stateValue === "resuming";
|
|
1078
|
+
this.setState(
|
|
1079
|
+
this.agentReady ? "connected" : recovering ? "resuming" : "connected_not_ready"
|
|
1080
|
+
);
|
|
1081
|
+
}
|
|
1082
|
+
});
|
|
1083
|
+
this.room.on(RoomEvent.ConnectionQualityChanged, (quality, participant) => {
|
|
1084
|
+
if (!participant.isLocal) return;
|
|
1085
|
+
this.notifyListeners(
|
|
1086
|
+
this.connectionQualityListeners,
|
|
1087
|
+
quality
|
|
1088
|
+
);
|
|
1089
|
+
});
|
|
1090
|
+
this.room.on(RoomEvent.TrackSubscribed, (track) => {
|
|
1091
|
+
if (track.kind !== Track.Kind.Audio) return;
|
|
1092
|
+
const element = track.attach();
|
|
1093
|
+
element.dataset.voxevolvRemoteAudio = "true";
|
|
1094
|
+
element.style.display = "none";
|
|
1095
|
+
document.body.appendChild(element);
|
|
1096
|
+
try {
|
|
1097
|
+
const playback = element.play();
|
|
1098
|
+
void playback.catch((error) => {
|
|
1099
|
+
this.notifyListeners(this.audioPlaybackBlockedListeners, {
|
|
1272
1100
|
error,
|
|
1101
|
+
element,
|
|
1102
|
+
track: track.mediaStreamTrack
|
|
1103
|
+
});
|
|
1273
1104
|
});
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
&& !["disconnected", "failed"].includes(this.stateValue)
|
|
1280
|
-
&& !isTerminalDisconnectReason(reason));
|
|
1281
|
-
}
|
|
1282
|
-
startAutoRejoin(initialError, details) {
|
|
1283
|
-
if (this.autoRejoinPromise || !this.shouldAutoRejoin())
|
|
1284
|
-
return;
|
|
1285
|
-
this.agentReady = false;
|
|
1286
|
-
this.contextGeneration += 1;
|
|
1287
|
-
this.replacementContext.pause();
|
|
1288
|
-
this.rejectPendingCommands(initialError);
|
|
1289
|
-
this.reconnectStartedAt ??= Date.now();
|
|
1290
|
-
const controller = new AbortController();
|
|
1291
|
-
this.autoRejoinController = controller;
|
|
1292
|
-
const timeout = globalThis.setTimeout(() => {
|
|
1293
|
-
if (controller.signal.aborted)
|
|
1294
|
-
return;
|
|
1295
|
-
const error = new Error("Session recovery timed out");
|
|
1296
|
-
controller.abort(error);
|
|
1297
|
-
this.contextGeneration += 1;
|
|
1298
|
-
this.rejectPendingCommands(error);
|
|
1299
|
-
this.replacementContext.clear(error);
|
|
1300
|
-
this.failRecovery(error);
|
|
1301
|
-
this.setState("failed");
|
|
1302
|
-
}, this.autoRejoinOptions.timeoutMs);
|
|
1303
|
-
const promise = this.runAutoRejoin(initialError, controller.signal, details)
|
|
1304
|
-
.finally(() => {
|
|
1305
|
-
globalThis.clearTimeout(timeout);
|
|
1306
|
-
if (this.autoRejoinController === controller) {
|
|
1307
|
-
this.autoRejoinController = null;
|
|
1308
|
-
this.autoRejoinPromise = null;
|
|
1309
|
-
}
|
|
1105
|
+
} catch (error) {
|
|
1106
|
+
this.notifyListeners(this.audioPlaybackBlockedListeners, {
|
|
1107
|
+
error,
|
|
1108
|
+
element,
|
|
1109
|
+
track: track.mediaStreamTrack
|
|
1310
1110
|
});
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
this.
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1111
|
+
}
|
|
1112
|
+
const stream = new MediaStream([track.mediaStreamTrack]);
|
|
1113
|
+
this.remoteAudioStreams.set(track, stream);
|
|
1114
|
+
this.outputStream.addTrack(track.mediaStreamTrack);
|
|
1115
|
+
this.notifyRemoteAudioListeners(this.remoteAudioTrackListeners, track.mediaStreamTrack, stream);
|
|
1116
|
+
this.remoteAudioTrackResolve?.();
|
|
1117
|
+
});
|
|
1118
|
+
this.room.on(RoomEvent.TrackUnsubscribed, (track) => {
|
|
1119
|
+
if (track.kind !== Track.Kind.Audio) return;
|
|
1120
|
+
const stream = this.remoteAudioStreams.get(track) ?? new MediaStream([track.mediaStreamTrack]);
|
|
1121
|
+
this.notifyRemoteAudioListeners(
|
|
1122
|
+
this.remoteAudioTrackUnsubscribedListeners,
|
|
1123
|
+
track.mediaStreamTrack,
|
|
1124
|
+
stream
|
|
1125
|
+
);
|
|
1126
|
+
this.remoteAudioStreams.delete(track);
|
|
1127
|
+
this.outputMediaStream?.removeTrack(track.mediaStreamTrack);
|
|
1128
|
+
track.detach().forEach((element) => element.remove());
|
|
1129
|
+
});
|
|
1130
|
+
this.room.on(RoomEvent.DataReceived, (data, _participant, kind, topic) => {
|
|
1131
|
+
if (topic !== "voxevolv.events" || kind !== DataPacket_Kind.RELIABLE) return;
|
|
1132
|
+
let value;
|
|
1133
|
+
try {
|
|
1134
|
+
value = JSON.parse(new TextDecoder().decode(data));
|
|
1135
|
+
} catch {
|
|
1136
|
+
return;
|
|
1137
|
+
}
|
|
1138
|
+
if (!isRealtimeEvent(value)) return;
|
|
1139
|
+
const result = this.cursor.accept(value);
|
|
1140
|
+
if (result.gap) {
|
|
1141
|
+
this.notifyListeners(this.gapListeners, result.gap);
|
|
1142
|
+
void this.replay(this.apiBase, result.gap.after_server_seq);
|
|
1143
|
+
}
|
|
1144
|
+
if (!result.deliver) return;
|
|
1145
|
+
this.observeReadiness(value);
|
|
1146
|
+
this.observeCommandResult(value);
|
|
1147
|
+
this.observeConvenienceEvents(value);
|
|
1148
|
+
this.notifyListeners(this.listeners, value);
|
|
1149
|
+
});
|
|
1150
|
+
}
|
|
1151
|
+
handleLiveKitReconnecting() {
|
|
1152
|
+
if (this.sessionEnded || this.disconnectMode === "intentional") return;
|
|
1153
|
+
this.agentReady = false;
|
|
1154
|
+
if (this.autoRejoinPromise !== null || this.activeRecovery?.strategy === "rejoin" || this.disconnectMode === "rejoin") {
|
|
1155
|
+
this.setState("resuming");
|
|
1156
|
+
return;
|
|
1157
|
+
}
|
|
1158
|
+
this.contextGeneration += 1;
|
|
1159
|
+
this.replacementContext.pause();
|
|
1160
|
+
this.reconnectStartedAt ??= Date.now();
|
|
1161
|
+
this.rejectPendingCommands(
|
|
1162
|
+
new Error("Connection interrupted before command completion"),
|
|
1163
|
+
true
|
|
1164
|
+
);
|
|
1165
|
+
if (this.activeRecovery === null) {
|
|
1166
|
+
this.beginRecovery("livekit", 1, { trigger: "livekit_reconnecting" });
|
|
1167
|
+
}
|
|
1168
|
+
this.setState("reconnecting");
|
|
1169
|
+
}
|
|
1170
|
+
async handleLiveKitReconnected() {
|
|
1171
|
+
if (this.sessionEnded || this.disconnectMode || this.autoRejoinPromise) return;
|
|
1172
|
+
const generation = this.contextGeneration;
|
|
1173
|
+
const recoveredAt = Date.now();
|
|
1174
|
+
if (this.reconnectStartedAt !== null) {
|
|
1175
|
+
this.pendingReconnect = {
|
|
1176
|
+
started_at: new Date(this.reconnectStartedAt).toISOString(),
|
|
1177
|
+
recovered_at: new Date(recoveredAt).toISOString(),
|
|
1178
|
+
duration_ms: Math.max(recoveredAt - this.reconnectStartedAt, 0)
|
|
1179
|
+
};
|
|
1180
|
+
this.reconnectStartedAt = null;
|
|
1181
|
+
}
|
|
1182
|
+
if (this.hasConnectedValue && this.activeRecovery === null) {
|
|
1183
|
+
this.beginRecovery("livekit", 1, { trigger: "livekit_reconnecting" });
|
|
1184
|
+
}
|
|
1185
|
+
this.connectionId = `conn_${Math.random().toString(36).slice(2)}_${Date.now().toString(36)}`;
|
|
1186
|
+
this.clientSeq = 0;
|
|
1187
|
+
this.setState(this.hasConnectedValue ? "resuming" : "connected_not_ready");
|
|
1188
|
+
try {
|
|
1189
|
+
await this.sendHello();
|
|
1190
|
+
const deadline = Date.now() + this.recoveryReadiness.readyTimeoutMs;
|
|
1191
|
+
await this.waitForAgentReady(Math.max(deadline - Date.now(), 0));
|
|
1192
|
+
if (this.recoveryReadiness.waitForRemoteAudio) await this.waitForRemoteAudioTrack(deadline);
|
|
1193
|
+
if (this.sessionEnded || generation !== this.contextGeneration) return;
|
|
1194
|
+
this.resumeContextDelivery(true);
|
|
1195
|
+
if (this.sessionEnded || generation !== this.contextGeneration) return;
|
|
1196
|
+
this.setState("connected");
|
|
1197
|
+
this.completeRecovery();
|
|
1198
|
+
} catch (error) {
|
|
1199
|
+
if (this.sessionEnded || generation !== this.contextGeneration) return;
|
|
1200
|
+
const normalizedError = toError(error);
|
|
1201
|
+
if (this.shouldAutoRejoin()) {
|
|
1202
|
+
this.startAutoRejoin(normalizedError, { trigger: "livekit_resume_failed" });
|
|
1203
|
+
} else {
|
|
1204
|
+
this.failRecovery(normalizedError);
|
|
1351
1205
|
this.setState("failed");
|
|
1206
|
+
}
|
|
1207
|
+
}
|
|
1208
|
+
}
|
|
1209
|
+
handleLiveKitDisconnected(reason) {
|
|
1210
|
+
this.agentReady = false;
|
|
1211
|
+
if (this.disconnectMode !== "rejoin") {
|
|
1212
|
+
this.pendingReconnect = null;
|
|
1352
1213
|
}
|
|
1353
|
-
|
|
1354
|
-
|
|
1214
|
+
this.outputMediaStream?.getTracks().forEach((track) => {
|
|
1215
|
+
this.outputMediaStream?.removeTrack(track);
|
|
1216
|
+
});
|
|
1217
|
+
this.rejectPendingCommands(new Error("Session disconnected before command completion"));
|
|
1218
|
+
if (this.disconnectMode === "rejoin") return;
|
|
1219
|
+
if (this.disconnectMode === "connect_failure") return;
|
|
1220
|
+
if (this.disconnectMode === "intentional") {
|
|
1221
|
+
this.activeRecovery = null;
|
|
1222
|
+
this.deliverPendingEndEvent();
|
|
1223
|
+
this.setState("disconnected");
|
|
1224
|
+
return;
|
|
1225
|
+
}
|
|
1226
|
+
if (this.terminalSessionError) return;
|
|
1227
|
+
if (this.shouldAutoRejoin(reason)) {
|
|
1228
|
+
this.startAutoRejoin(
|
|
1229
|
+
new Error(`LiveKit disconnected (${disconnectReasonName(reason)})`),
|
|
1230
|
+
{ trigger: "livekit_disconnected" }
|
|
1231
|
+
);
|
|
1232
|
+
return;
|
|
1233
|
+
}
|
|
1234
|
+
if (this.activeRecovery !== null) {
|
|
1235
|
+
this.failRecovery(
|
|
1236
|
+
new Error(`LiveKit disconnected (${disconnectReasonName(reason)})`)
|
|
1237
|
+
);
|
|
1238
|
+
}
|
|
1239
|
+
this.deliverPendingEndEvent();
|
|
1240
|
+
this.setState("disconnected");
|
|
1241
|
+
}
|
|
1242
|
+
// The terminal session.ended event is recorded durably after room teardown,
|
|
1243
|
+
// so turn the earlier live announcement into the browser completion signal.
|
|
1244
|
+
deliverPendingEndEvent() {
|
|
1245
|
+
const ending = this.pendingEndEvent;
|
|
1246
|
+
if (!ending) return;
|
|
1247
|
+
this.pendingEndEvent = null;
|
|
1248
|
+
this.notifyEnded({ ...ending, type: "session.ended" });
|
|
1249
|
+
}
|
|
1250
|
+
finishPendingEnd() {
|
|
1251
|
+
this.deliverPendingEndEvent();
|
|
1252
|
+
void this.disconnect().catch(() => {
|
|
1253
|
+
});
|
|
1254
|
+
}
|
|
1255
|
+
async recoverSessionEnd(apiBase, signal) {
|
|
1256
|
+
const controller = new AbortController();
|
|
1257
|
+
const cancel = () => controller.abort(signal?.reason);
|
|
1258
|
+
signal?.addEventListener("abort", cancel, { once: true });
|
|
1259
|
+
if (signal?.aborted) cancel();
|
|
1260
|
+
const timeout = globalThis.setTimeout(() => controller.abort(), 5e3);
|
|
1261
|
+
try {
|
|
1262
|
+
await this.fetchReplay(apiBase, 0, { signal: controller.signal, terminalOnly: true });
|
|
1263
|
+
} catch {
|
|
1264
|
+
} finally {
|
|
1265
|
+
globalThis.clearTimeout(timeout);
|
|
1266
|
+
signal?.removeEventListener("abort", cancel);
|
|
1267
|
+
}
|
|
1268
|
+
if (!this.sessionEnded || this.terminalSessionError) return false;
|
|
1269
|
+
this.finishPendingEnd();
|
|
1270
|
+
return true;
|
|
1271
|
+
}
|
|
1272
|
+
notifyEnded(event) {
|
|
1273
|
+
if (this.endNotificationDelivered) return;
|
|
1274
|
+
this.endNotificationDelivered = true;
|
|
1275
|
+
this.notifyListeners(this.endedListeners, event);
|
|
1276
|
+
}
|
|
1277
|
+
beginRecovery(strategy, attempt, details) {
|
|
1278
|
+
this.activeRecovery = { strategy, attempt, details };
|
|
1279
|
+
this.recordDiagnostic({
|
|
1280
|
+
operation: "connection_recovery",
|
|
1281
|
+
status: "started",
|
|
1282
|
+
reason: details.trigger,
|
|
1283
|
+
message_id: details.messageId,
|
|
1284
|
+
context_key: details.contextKey,
|
|
1285
|
+
timeout_ms: details.timeoutMs
|
|
1286
|
+
});
|
|
1287
|
+
this.flushDiagnostics("recovery_started");
|
|
1288
|
+
this.notifyListeners(this.recoveryListeners, {
|
|
1289
|
+
status: "started",
|
|
1290
|
+
strategy,
|
|
1291
|
+
attempt,
|
|
1292
|
+
...details
|
|
1293
|
+
});
|
|
1294
|
+
}
|
|
1295
|
+
completeRecovery() {
|
|
1296
|
+
const recovery = this.activeRecovery;
|
|
1297
|
+
if (!recovery) return;
|
|
1298
|
+
this.activeRecovery = null;
|
|
1299
|
+
this.notifyListeners(this.recoveryListeners, {
|
|
1300
|
+
status: "succeeded",
|
|
1301
|
+
strategy: recovery.strategy,
|
|
1302
|
+
attempt: recovery.attempt,
|
|
1303
|
+
...recovery.details
|
|
1304
|
+
});
|
|
1305
|
+
}
|
|
1306
|
+
failRecovery(error) {
|
|
1307
|
+
const recovery = this.activeRecovery;
|
|
1308
|
+
if (!recovery) return;
|
|
1309
|
+
this.activeRecovery = null;
|
|
1310
|
+
this.notifyListeners(this.recoveryListeners, {
|
|
1311
|
+
status: "failed",
|
|
1312
|
+
strategy: recovery.strategy,
|
|
1313
|
+
attempt: recovery.attempt,
|
|
1314
|
+
...recovery.details,
|
|
1315
|
+
error
|
|
1316
|
+
});
|
|
1317
|
+
}
|
|
1318
|
+
shouldAutoRejoin(reason) {
|
|
1319
|
+
return this.autoRejoinOptions !== null && this.hasConnectedValue && !this.sessionEnded && !["disconnected", "failed"].includes(this.stateValue) && !isTerminalDisconnectReason(reason);
|
|
1320
|
+
}
|
|
1321
|
+
startAutoRejoin(initialError, details) {
|
|
1322
|
+
if (this.autoRejoinPromise || !this.shouldAutoRejoin()) return;
|
|
1323
|
+
this.agentReady = false;
|
|
1324
|
+
this.contextGeneration += 1;
|
|
1325
|
+
this.replacementContext.pause();
|
|
1326
|
+
this.rejectPendingCommands(initialError);
|
|
1327
|
+
this.reconnectStartedAt ??= Date.now();
|
|
1328
|
+
const controller = new AbortController();
|
|
1329
|
+
this.autoRejoinController = controller;
|
|
1330
|
+
const timeout = globalThis.setTimeout(() => {
|
|
1331
|
+
if (controller.signal.aborted) return;
|
|
1332
|
+
const error = new Error("Session recovery timed out");
|
|
1333
|
+
controller.abort(error);
|
|
1334
|
+
this.contextGeneration += 1;
|
|
1335
|
+
this.rejectPendingCommands(error);
|
|
1336
|
+
this.replacementContext.clear(error);
|
|
1337
|
+
this.failRecovery(error);
|
|
1338
|
+
this.setState("failed");
|
|
1339
|
+
}, this.autoRejoinOptions.timeoutMs);
|
|
1340
|
+
const promise = this.runAutoRejoin(initialError, controller.signal, details).finally(() => {
|
|
1341
|
+
globalThis.clearTimeout(timeout);
|
|
1342
|
+
if (this.autoRejoinController === controller) {
|
|
1355
1343
|
this.autoRejoinController = null;
|
|
1356
|
-
this.manualRejoinController?.abort();
|
|
1357
|
-
this.manualRejoinController = null;
|
|
1358
1344
|
this.autoRejoinPromise = null;
|
|
1359
|
-
|
|
1360
|
-
}
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
}
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
}
|
|
1380
|
-
}
|
|
1381
|
-
validateAudioOptions(microphone, audioTrack) {
|
|
1382
|
-
if (microphone && audioTrack) {
|
|
1383
|
-
throw new SessionConfigurationError("microphone and audioTrack cannot both be enabled");
|
|
1384
|
-
}
|
|
1385
|
-
if (!audioTrack)
|
|
1386
|
-
return;
|
|
1387
|
-
if (audioTrack.kind !== "audio") {
|
|
1388
|
-
throw new SessionConfigurationError("audioTrack must be an audio MediaStreamTrack");
|
|
1389
|
-
}
|
|
1390
|
-
if (audioTrack.readyState === "ended") {
|
|
1391
|
-
throw new SessionConfigurationError("audioTrack has already ended");
|
|
1392
|
-
}
|
|
1393
|
-
}
|
|
1394
|
-
async fetchRoomToken(apiBase, signal) {
|
|
1395
|
-
const controller = new AbortController();
|
|
1396
|
-
const cancel = () => controller.abort(signal?.reason);
|
|
1397
|
-
signal?.addEventListener("abort", cancel, { once: true });
|
|
1398
|
-
const timeout = globalThis.setTimeout(() => controller.abort(), 10_000);
|
|
1399
|
-
try {
|
|
1400
|
-
throwIfAborted(signal);
|
|
1401
|
-
return await raceWithAbort(this.fetcher(`${apiBase}/sessions/${this.id}/room-tokens`, {
|
|
1402
|
-
method: "POST",
|
|
1403
|
-
headers: { Authorization: `Bearer ${this.accessToken}` },
|
|
1404
|
-
credentials: this.lifecycleCredentials,
|
|
1405
|
-
signal: controller.signal,
|
|
1406
|
-
}), controller.signal);
|
|
1407
|
-
}
|
|
1408
|
-
finally {
|
|
1409
|
-
globalThis.clearTimeout(timeout);
|
|
1410
|
-
signal?.removeEventListener("abort", cancel);
|
|
1345
|
+
}
|
|
1346
|
+
});
|
|
1347
|
+
this.autoRejoinPromise = promise;
|
|
1348
|
+
void promise;
|
|
1349
|
+
}
|
|
1350
|
+
async runAutoRejoin(initialError, signal, details) {
|
|
1351
|
+
const options = this.autoRejoinOptions;
|
|
1352
|
+
if (!options) return;
|
|
1353
|
+
let lastError = initialError;
|
|
1354
|
+
if (this.activeRecovery?.strategy === "livekit") {
|
|
1355
|
+
this.failRecovery(initialError);
|
|
1356
|
+
}
|
|
1357
|
+
this.setState("resuming");
|
|
1358
|
+
for (let attempt = 1; attempt <= options.maxAttempts; attempt += 1) {
|
|
1359
|
+
try {
|
|
1360
|
+
await waitUntilOnline(signal);
|
|
1361
|
+
if (attempt > 1) {
|
|
1362
|
+
const exponentialDelay = options.baseDelayMs * 2 ** (attempt - 2);
|
|
1363
|
+
const delayMs = Math.min(exponentialDelay, options.maxDelayMs);
|
|
1364
|
+
await abortableDelay(delayMs + Math.random() * Math.min(delayMs, 500), signal);
|
|
1411
1365
|
}
|
|
1412
|
-
}
|
|
1413
|
-
async refreshCredentials(signal) {
|
|
1414
|
-
if (!this.refreshCredentialsCallback)
|
|
1415
|
-
return;
|
|
1416
1366
|
throwIfAborted(signal);
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
1367
|
+
this.beginRecovery("rejoin", attempt, details);
|
|
1368
|
+
await this.performRejoin(this.apiBase, { signal });
|
|
1369
|
+
if (this.sessionEnded) return;
|
|
1420
1370
|
throwIfAborted(signal);
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
|
|
1428
|
-
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
|
|
1476
|
-
|
|
1477
|
-
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
|
|
1481
|
-
|
|
1482
|
-
|
|
1483
|
-
|
|
1484
|
-
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
|
|
1489
|
-
|
|
1490
|
-
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
|
|
1498
|
-
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
|
|
1502
|
-
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
|
|
1506
|
-
|
|
1507
|
-
|
|
1508
|
-
|
|
1509
|
-
|
|
1510
|
-
|
|
1511
|
-
|
|
1512
|
-
|
|
1513
|
-
|
|
1514
|
-
|
|
1515
|
-
|
|
1516
|
-
|
|
1517
|
-
}
|
|
1518
|
-
|
|
1519
|
-
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
|
|
1532
|
-
|
|
1533
|
-
|
|
1534
|
-
|
|
1535
|
-
|
|
1536
|
-
|
|
1537
|
-
|
|
1538
|
-
|
|
1539
|
-
|
|
1540
|
-
|
|
1541
|
-
|
|
1542
|
-
|
|
1543
|
-
|
|
1544
|
-
|
|
1545
|
-
|
|
1546
|
-
|
|
1547
|
-
|
|
1548
|
-
|
|
1549
|
-
|
|
1550
|
-
this.
|
|
1551
|
-
|
|
1552
|
-
|
|
1553
|
-
|
|
1554
|
-
|
|
1555
|
-
|
|
1556
|
-
|
|
1557
|
-
|
|
1558
|
-
|
|
1559
|
-
|
|
1560
|
-
|
|
1561
|
-
|
|
1562
|
-
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
|
|
1567
|
-
|
|
1568
|
-
|
|
1569
|
-
|
|
1570
|
-
|
|
1571
|
-
|
|
1572
|
-
|
|
1573
|
-
|
|
1574
|
-
|
|
1575
|
-
|
|
1576
|
-
|
|
1577
|
-
|
|
1578
|
-
|
|
1579
|
-
|
|
1580
|
-
|
|
1581
|
-
|
|
1582
|
-
|
|
1583
|
-
|
|
1584
|
-
|
|
1585
|
-
|
|
1586
|
-
|
|
1587
|
-
|
|
1588
|
-
|
|
1589
|
-
|
|
1590
|
-
|
|
1591
|
-
|
|
1592
|
-
|
|
1593
|
-
|
|
1594
|
-
|
|
1595
|
-
|
|
1596
|
-
|
|
1597
|
-
|
|
1598
|
-
|
|
1599
|
-
|
|
1600
|
-
|
|
1601
|
-
|
|
1602
|
-
|
|
1603
|
-
|
|
1604
|
-
|
|
1605
|
-
|
|
1606
|
-
|
|
1607
|
-
|
|
1608
|
-
|
|
1609
|
-
|
|
1610
|
-
|
|
1611
|
-
|
|
1612
|
-
|
|
1613
|
-
|
|
1614
|
-
|
|
1615
|
-
|
|
1616
|
-
|
|
1617
|
-
|
|
1618
|
-
|
|
1619
|
-
|
|
1620
|
-
|
|
1621
|
-
|
|
1622
|
-
|
|
1623
|
-
|
|
1624
|
-
|
|
1625
|
-
|
|
1626
|
-
|
|
1627
|
-
|
|
1628
|
-
|
|
1629
|
-
|
|
1630
|
-
|
|
1631
|
-
|
|
1632
|
-
|
|
1633
|
-
|
|
1634
|
-
|
|
1635
|
-
|
|
1636
|
-
|
|
1637
|
-
|
|
1638
|
-
|
|
1639
|
-
}
|
|
1640
|
-
async retryCommand(message) {
|
|
1641
|
-
const pending = this.pendingCommands.get(message.message_id);
|
|
1642
|
-
if (!pending)
|
|
1643
|
-
return;
|
|
1644
|
-
if (message.type === "context.update" && (this.sessionEnded || ["reconnecting", "resuming", "disconnected", "failed"].includes(this.stateValue)))
|
|
1645
|
-
return;
|
|
1646
|
-
try {
|
|
1647
|
-
if (pending.transferRetryRequested && pending.retryTransfer) {
|
|
1648
|
-
pending.transferRetryRequested = false;
|
|
1649
|
-
pending.transferRetrying = true;
|
|
1650
|
-
const transfer = pending.retryTransfer();
|
|
1651
|
-
pending.transferSettled = transfer.then(() => { }, () => { });
|
|
1652
|
-
if (pending.contextKey) {
|
|
1653
|
-
this.replacementContext.blockUntilTransferSettles(pending.contextKey, pending.transferSettled);
|
|
1654
|
-
}
|
|
1655
|
-
await Promise.race([transfer, pending.settled]);
|
|
1656
|
-
}
|
|
1657
|
-
await this.publishCommandAttempt(message, true);
|
|
1658
|
-
}
|
|
1659
|
-
catch {
|
|
1660
|
-
// Failed writes retain their original deadline and retry with backoff.
|
|
1661
|
-
}
|
|
1662
|
-
finally {
|
|
1663
|
-
pending.transferRetrying = false;
|
|
1664
|
-
}
|
|
1665
|
-
}
|
|
1666
|
-
publishCommandAttempt(message, retrying) {
|
|
1667
|
-
const pending = this.pendingCommands.get(message.message_id);
|
|
1668
|
-
if (!pending || pending.publishQueued)
|
|
1669
|
-
return Promise.resolve();
|
|
1670
|
-
pending.publishQueued = true;
|
|
1671
|
-
// Context keys drain independently, but client_seq is global. Serialize the
|
|
1672
|
-
// asynchronous LiveKit publish boundary so a later sequence cannot overtake
|
|
1673
|
-
// an earlier one before the reliable data channel receives it.
|
|
1674
|
-
const attempt = this.commandPublishTail
|
|
1675
|
-
.then(() => this.publishCommandAttemptNow(message, retrying))
|
|
1676
|
-
.finally(() => {
|
|
1677
|
-
const current = this.pendingCommands.get(message.message_id);
|
|
1678
|
-
if (current === pending)
|
|
1679
|
-
current.publishQueued = false;
|
|
1680
|
-
});
|
|
1681
|
-
this.commandPublishTail = attempt.catch(() => { });
|
|
1682
|
-
return attempt;
|
|
1683
|
-
}
|
|
1684
|
-
async publishCommandAttemptNow(message, retrying) {
|
|
1371
|
+
this.reconnectStartedAt = null;
|
|
1372
|
+
this.completeRecovery();
|
|
1373
|
+
return;
|
|
1374
|
+
} catch (error) {
|
|
1375
|
+
if (signal.aborted) return;
|
|
1376
|
+
lastError = toError(error);
|
|
1377
|
+
if (isTerminalRejoinError(error)) break;
|
|
1378
|
+
}
|
|
1379
|
+
}
|
|
1380
|
+
this.failRecovery(lastError);
|
|
1381
|
+
this.replacementContext.clear(lastError);
|
|
1382
|
+
this.setState("failed");
|
|
1383
|
+
}
|
|
1384
|
+
cancelRecovery() {
|
|
1385
|
+
this.autoRejoinController?.abort();
|
|
1386
|
+
this.autoRejoinController = null;
|
|
1387
|
+
this.manualRejoinController?.abort();
|
|
1388
|
+
this.manualRejoinController = null;
|
|
1389
|
+
this.autoRejoinPromise = null;
|
|
1390
|
+
this.activeRecovery = null;
|
|
1391
|
+
}
|
|
1392
|
+
notifyListeners(listeners, value) {
|
|
1393
|
+
for (const listener of listeners) {
|
|
1394
|
+
try {
|
|
1395
|
+
listener(value);
|
|
1396
|
+
} catch (error) {
|
|
1397
|
+
console.error("VoxEvolv event listener failed", error);
|
|
1398
|
+
}
|
|
1399
|
+
}
|
|
1400
|
+
}
|
|
1401
|
+
notifyRemoteAudioListeners(listeners, track, stream) {
|
|
1402
|
+
for (const listener of listeners) {
|
|
1403
|
+
try {
|
|
1404
|
+
listener(track, stream);
|
|
1405
|
+
} catch (error) {
|
|
1406
|
+
console.error("VoxEvolv remote audio listener failed", error);
|
|
1407
|
+
}
|
|
1408
|
+
}
|
|
1409
|
+
}
|
|
1410
|
+
validateAudioOptions(microphone, audioTrack) {
|
|
1411
|
+
if (microphone && audioTrack) {
|
|
1412
|
+
throw new SessionConfigurationError("microphone and audioTrack cannot both be enabled");
|
|
1413
|
+
}
|
|
1414
|
+
if (!audioTrack) return;
|
|
1415
|
+
if (audioTrack.kind !== "audio") {
|
|
1416
|
+
throw new SessionConfigurationError("audioTrack must be an audio MediaStreamTrack");
|
|
1417
|
+
}
|
|
1418
|
+
if (audioTrack.readyState === "ended") {
|
|
1419
|
+
throw new SessionConfigurationError("audioTrack has already ended");
|
|
1420
|
+
}
|
|
1421
|
+
}
|
|
1422
|
+
async fetchRoomToken(apiBase, signal) {
|
|
1423
|
+
const controller = new AbortController();
|
|
1424
|
+
const cancel = () => controller.abort(signal?.reason);
|
|
1425
|
+
signal?.addEventListener("abort", cancel, { once: true });
|
|
1426
|
+
const timeout = globalThis.setTimeout(() => controller.abort(), 1e4);
|
|
1427
|
+
try {
|
|
1428
|
+
throwIfAborted(signal);
|
|
1429
|
+
return await raceWithAbort(this.fetcher(`${apiBase}/sessions/${this.id}/room-tokens`, {
|
|
1430
|
+
method: "POST",
|
|
1431
|
+
headers: { Authorization: `Bearer ${this.accessToken}` },
|
|
1432
|
+
credentials: this.lifecycleCredentials,
|
|
1433
|
+
signal: controller.signal
|
|
1434
|
+
}), controller.signal);
|
|
1435
|
+
} finally {
|
|
1436
|
+
globalThis.clearTimeout(timeout);
|
|
1437
|
+
signal?.removeEventListener("abort", cancel);
|
|
1438
|
+
}
|
|
1439
|
+
}
|
|
1440
|
+
async refreshCredentials(signal) {
|
|
1441
|
+
if (!this.refreshCredentialsCallback) return;
|
|
1442
|
+
throwIfAborted(signal);
|
|
1443
|
+
const connectionGeneration = this.contextGeneration;
|
|
1444
|
+
const refreshGeneration = ++this.credentialRefreshGeneration;
|
|
1445
|
+
const refreshed = await raceWithAbort(this.refreshCredentialsCallback(), signal);
|
|
1446
|
+
throwIfAborted(signal);
|
|
1447
|
+
if (connectionGeneration !== this.contextGeneration || refreshGeneration !== this.credentialRefreshGeneration) {
|
|
1448
|
+
throw new Error("Credential refresh was superseded");
|
|
1449
|
+
}
|
|
1450
|
+
validateSessionCredentials(refreshed);
|
|
1451
|
+
if (refreshed.id !== this.id) {
|
|
1452
|
+
throw new Error("Refreshed credentials belong to a different session");
|
|
1453
|
+
}
|
|
1454
|
+
this.accessToken = refreshed.access_token;
|
|
1455
|
+
this.credentials = refreshed.connection;
|
|
1456
|
+
}
|
|
1457
|
+
async finishSessionFailure(error) {
|
|
1458
|
+
this.sessionEnded = true;
|
|
1459
|
+
this.contextGeneration += 1;
|
|
1460
|
+
this.rejectPendingCommands(error);
|
|
1461
|
+
this.replacementContext.clear(error);
|
|
1462
|
+
this.failRecovery(error);
|
|
1463
|
+
this.cancelRecovery();
|
|
1464
|
+
this.disconnectMode = "connect_failure";
|
|
1465
|
+
try {
|
|
1466
|
+
await this.room.disconnect();
|
|
1467
|
+
} finally {
|
|
1468
|
+
this.disconnectMode = null;
|
|
1469
|
+
this.setState("failed");
|
|
1470
|
+
}
|
|
1471
|
+
}
|
|
1472
|
+
observeConvenienceEvents(event) {
|
|
1473
|
+
if (event.type === "session.error") {
|
|
1474
|
+
if (this.sessionEnded) return;
|
|
1475
|
+
const error = new SessionErrorEventError(event);
|
|
1476
|
+
if (error.retryable && this.startupAttemptController) {
|
|
1477
|
+
this.startupAttemptController.abort(error);
|
|
1478
|
+
} else {
|
|
1479
|
+
this.terminalSessionError = error;
|
|
1480
|
+
this.abortStartupConnect(error);
|
|
1481
|
+
if (!this.startupConnectController) {
|
|
1482
|
+
void this.finishSessionFailure(error).catch(() => {
|
|
1483
|
+
});
|
|
1484
|
+
}
|
|
1485
|
+
}
|
|
1486
|
+
}
|
|
1487
|
+
if (event.type === "session.ending" || event.type === "session.ended") {
|
|
1488
|
+
if (this.terminalSessionError) return;
|
|
1489
|
+
this.abortStartupConnect(new Error("Session ended during startup"));
|
|
1490
|
+
this.contextGeneration += 1;
|
|
1491
|
+
this.rejectPendingCommands(new Error("Session ended"));
|
|
1492
|
+
this.failRecovery(new Error("Session ended during recovery"));
|
|
1493
|
+
}
|
|
1494
|
+
if (event.type === "session.ending") {
|
|
1495
|
+
this.sessionEnded = true;
|
|
1496
|
+
this.cancelRecovery();
|
|
1497
|
+
this.replacementContext.clear(new Error("Session ended"));
|
|
1498
|
+
this.pendingEndEvent ??= event;
|
|
1499
|
+
if (!this.hasConnectedValue || this.agentDisconnected || this.room.state === LiveKitConnectionState.Disconnected) {
|
|
1500
|
+
this.finishPendingEnd();
|
|
1501
|
+
}
|
|
1502
|
+
}
|
|
1503
|
+
if (event.type === "session.ended") {
|
|
1504
|
+
const ending = this.pendingEndEvent;
|
|
1505
|
+
this.pendingEndEvent = null;
|
|
1506
|
+
this.sessionEnded = true;
|
|
1507
|
+
this.replacementContext.clear(new Error("Session ended"));
|
|
1508
|
+
this.cancelRecovery();
|
|
1509
|
+
this.notifyEnded(ending ? { ...ending, type: "session.ended" } : event);
|
|
1510
|
+
void this.disconnect().catch(() => {
|
|
1511
|
+
});
|
|
1512
|
+
}
|
|
1513
|
+
if (event.type.endsWith(".error")) this.notifyListeners(this.errorListeners, event);
|
|
1514
|
+
const message = this.transcriptStore.accept(event);
|
|
1515
|
+
if (message) this.notifyListeners(this.transcriptListeners, message);
|
|
1516
|
+
const transcript = transcriptEvent(event);
|
|
1517
|
+
if (!transcript) return;
|
|
1518
|
+
this.notifyListeners(
|
|
1519
|
+
transcript.role === "user" ? this.userTranscriptListeners : this.agentTranscriptListeners,
|
|
1520
|
+
transcript
|
|
1521
|
+
);
|
|
1522
|
+
}
|
|
1523
|
+
abortStartupConnect(error) {
|
|
1524
|
+
if (!this.startupConnectController?.signal.aborted) {
|
|
1525
|
+
this.startupConnectController?.abort(error);
|
|
1526
|
+
}
|
|
1527
|
+
}
|
|
1528
|
+
async publishApplicationAudioTrack(audioTrack) {
|
|
1529
|
+
const clonedTrack = audioTrack.clone();
|
|
1530
|
+
let publishedTrack = null;
|
|
1531
|
+
try {
|
|
1532
|
+
publishedTrack = new LocalAudioTrack(clonedTrack, void 0, true);
|
|
1533
|
+
this.localApplicationAudioTrack = publishedTrack;
|
|
1534
|
+
if (!this.microphoneEnabled) {
|
|
1535
|
+
await publishedTrack.mute();
|
|
1536
|
+
}
|
|
1537
|
+
await this.room.localParticipant.publishTrack(publishedTrack, {
|
|
1538
|
+
source: Track.Source.Microphone,
|
|
1539
|
+
name: "voxevolv-application-audio"
|
|
1540
|
+
});
|
|
1541
|
+
} catch (error) {
|
|
1542
|
+
if (this.localApplicationAudioTrack === publishedTrack) {
|
|
1543
|
+
this.localApplicationAudioTrack = null;
|
|
1544
|
+
}
|
|
1545
|
+
clonedTrack.stop();
|
|
1546
|
+
throw error;
|
|
1547
|
+
}
|
|
1548
|
+
}
|
|
1549
|
+
async sendHello() {
|
|
1550
|
+
const reconnect = this.pendingReconnect;
|
|
1551
|
+
const message = createClientMessage(
|
|
1552
|
+
this.connectionId,
|
|
1553
|
+
++this.clientSeq,
|
|
1554
|
+
"client.hello",
|
|
1555
|
+
{
|
|
1556
|
+
last_server_seq: this.cursor.lastServerSeq,
|
|
1557
|
+
sdk: `@voxevolv/client@${SDK_VERSION}`,
|
|
1558
|
+
tool_handlers: [...this.toolHandlers.keys()].sort(),
|
|
1559
|
+
...reconnect ? { reconnect } : {}
|
|
1560
|
+
}
|
|
1561
|
+
);
|
|
1562
|
+
this.lastHelloMessageId = message.message_id;
|
|
1563
|
+
await this.sendTrackedCommand(message, CLIENT_HELLO_TIMEOUT_MS);
|
|
1564
|
+
if (this.pendingReconnect === reconnect) this.pendingReconnect = null;
|
|
1565
|
+
}
|
|
1566
|
+
async sendTrackedCommand(message, timeoutMs, retryTransfer) {
|
|
1567
|
+
const contextKey = contextKeyFromMessage(message);
|
|
1568
|
+
const payloadBytes = contextPayloadBytes(message.payload);
|
|
1569
|
+
const startedAt = Date.now();
|
|
1570
|
+
this.recordDiagnostic({
|
|
1571
|
+
operation: "command_send",
|
|
1572
|
+
status: "started",
|
|
1573
|
+
message_id: message.message_id,
|
|
1574
|
+
connection_id: message.connection_id,
|
|
1575
|
+
client_seq: message.client_seq,
|
|
1576
|
+
context_key: contextKey,
|
|
1577
|
+
payload_bytes: payloadBytes,
|
|
1578
|
+
retry_count: 0,
|
|
1579
|
+
timeout_ms: timeoutMs,
|
|
1580
|
+
event_type: diagnosticEventType(message.type)
|
|
1581
|
+
});
|
|
1582
|
+
let markSettled = () => {
|
|
1583
|
+
};
|
|
1584
|
+
const settled = new Promise((resolve) => {
|
|
1585
|
+
markSettled = resolve;
|
|
1586
|
+
});
|
|
1587
|
+
const response = new Promise((resolve, reject) => {
|
|
1588
|
+
const timeout = globalThis.setTimeout(() => {
|
|
1685
1589
|
const pending = this.pendingCommands.get(message.message_id);
|
|
1686
|
-
if (
|
|
1687
|
-
|
|
1688
|
-
|
|
1689
|
-
|
|
1690
|
-
|
|
1691
|
-
operation: "command_send",
|
|
1692
|
-
status: "retrying",
|
|
1693
|
-
message_id: message.message_id,
|
|
1694
|
-
connection_id: message.connection_id,
|
|
1695
|
-
client_seq: message.client_seq,
|
|
1696
|
-
context_key: pending.contextKey,
|
|
1697
|
-
payload_bytes: pending.payloadBytes,
|
|
1698
|
-
retry_count: pending.retryCount,
|
|
1699
|
-
event_type: diagnosticEventType(message.type),
|
|
1700
|
-
});
|
|
1701
|
-
}
|
|
1702
|
-
const attemptStartedAt = Date.now();
|
|
1703
|
-
try {
|
|
1704
|
-
let publishFinished = false;
|
|
1705
|
-
const publish = this.publishCommand(message).then(() => { publishFinished = true; });
|
|
1706
|
-
await Promise.race([publish, pending.settled]);
|
|
1707
|
-
if (!publishFinished)
|
|
1708
|
-
return;
|
|
1709
|
-
}
|
|
1710
|
-
catch (error) {
|
|
1711
|
-
this.recordDiagnostic({
|
|
1712
|
-
operation: "command_send",
|
|
1713
|
-
status: "failed",
|
|
1714
|
-
reason: "command_publish_failed",
|
|
1715
|
-
message_id: message.message_id,
|
|
1716
|
-
connection_id: message.connection_id,
|
|
1717
|
-
client_seq: message.client_seq,
|
|
1718
|
-
context_key: pending.contextKey,
|
|
1719
|
-
payload_bytes: pending.payloadBytes,
|
|
1720
|
-
duration_ms: Date.now() - attemptStartedAt,
|
|
1721
|
-
retry_count: pending.retryCount,
|
|
1722
|
-
event_type: diagnosticEventType(message.type),
|
|
1723
|
-
});
|
|
1724
|
-
this.flushDiagnostics("command_failed");
|
|
1725
|
-
throw error;
|
|
1726
|
-
}
|
|
1727
|
-
const durationMs = Date.now() - attemptStartedAt;
|
|
1590
|
+
if (pending) globalThis.clearTimeout(pending.retry);
|
|
1591
|
+
pending?.markSettled();
|
|
1592
|
+
this.pendingCommands.delete(message.message_id);
|
|
1593
|
+
const detail = `Command ${message.type} timed out after ${timeoutMs}ms`;
|
|
1594
|
+
const error = message.type === "context.update" ? new ContextDeliveryError(detail, { retryAfter: pending?.transferSettled }) : new Error(detail);
|
|
1728
1595
|
this.recordDiagnostic({
|
|
1729
|
-
|
|
1730
|
-
|
|
1731
|
-
|
|
1732
|
-
|
|
1733
|
-
|
|
1734
|
-
|
|
1735
|
-
|
|
1736
|
-
|
|
1737
|
-
|
|
1738
|
-
|
|
1739
|
-
|
|
1740
|
-
|
|
1741
|
-
this.flushDiagnostics("slow_operation");
|
|
1742
|
-
}
|
|
1743
|
-
async publishCommand(message) {
|
|
1744
|
-
await this.room.localParticipant.publishData(new TextEncoder().encode(JSON.stringify(message)), { reliable: true, topic: "voxevolv.commands" });
|
|
1745
|
-
}
|
|
1746
|
-
recordDiagnostic(entry) {
|
|
1747
|
-
this.diagnostics.record({
|
|
1748
|
-
...entry,
|
|
1749
|
-
connection_state: this.stateValue,
|
|
1750
|
-
buffered_bytes: this.reliableDataChannelBufferedBytes(),
|
|
1596
|
+
operation: "command_send",
|
|
1597
|
+
status: "timed_out",
|
|
1598
|
+
reason: message.type === "context.update" ? "context_command_timeout" : "command_timeout",
|
|
1599
|
+
message_id: message.message_id,
|
|
1600
|
+
connection_id: message.connection_id,
|
|
1601
|
+
client_seq: message.client_seq,
|
|
1602
|
+
context_key: contextKey,
|
|
1603
|
+
payload_bytes: payloadBytes,
|
|
1604
|
+
duration_ms: Date.now() - startedAt,
|
|
1605
|
+
retry_count: pending?.retryCount ?? 0,
|
|
1606
|
+
timeout_ms: timeoutMs,
|
|
1607
|
+
event_type: diagnosticEventType(message.type)
|
|
1751
1608
|
});
|
|
1752
|
-
|
|
1753
|
-
|
|
1754
|
-
|
|
1755
|
-
|
|
1756
|
-
|
|
1757
|
-
|
|
1758
|
-
|
|
1759
|
-
|
|
1760
|
-
|
|
1761
|
-
|
|
1762
|
-
|
|
1763
|
-
|
|
1764
|
-
|
|
1765
|
-
|
|
1766
|
-
|
|
1767
|
-
|
|
1768
|
-
|
|
1769
|
-
|
|
1770
|
-
|
|
1771
|
-
|
|
1772
|
-
|
|
1773
|
-
}
|
|
1774
|
-
|
|
1775
|
-
|
|
1776
|
-
|
|
1777
|
-
|
|
1778
|
-
|
|
1779
|
-
|
|
1780
|
-
|
|
1781
|
-
|
|
1782
|
-
|
|
1783
|
-
|
|
1784
|
-
|
|
1785
|
-
|
|
1786
|
-
|
|
1787
|
-
|
|
1788
|
-
|
|
1789
|
-
|
|
1790
|
-
|
|
1791
|
-
|
|
1792
|
-
|
|
1793
|
-
|
|
1794
|
-
|
|
1795
|
-
|
|
1796
|
-
|
|
1797
|
-
|
|
1798
|
-
|
|
1799
|
-
|
|
1800
|
-
await Promise.race([upload(), timeoutExpired]);
|
|
1801
|
-
}
|
|
1802
|
-
finally {
|
|
1803
|
-
if (timeout !== undefined)
|
|
1804
|
-
globalThis.clearTimeout(timeout);
|
|
1805
|
-
}
|
|
1806
|
-
}
|
|
1807
|
-
waitForRemoteAudioTrack(deadline, signal) {
|
|
1808
|
-
if ((this.outputMediaStream?.getAudioTracks().length ?? 0) > 0)
|
|
1809
|
-
return Promise.resolve();
|
|
1810
|
-
throwIfAborted(signal);
|
|
1811
|
-
return new Promise((resolve, reject) => {
|
|
1812
|
-
const cleanup = () => {
|
|
1813
|
-
globalThis.clearTimeout(timeout);
|
|
1814
|
-
signal?.removeEventListener("abort", onAbort);
|
|
1815
|
-
this.remoteAudioTrackResolve = null;
|
|
1816
|
-
};
|
|
1817
|
-
const timeout = globalThis.setTimeout(() => {
|
|
1818
|
-
cleanup();
|
|
1819
|
-
reject(new Error("Agent audio track did not arrive within the ready timeout;"
|
|
1820
|
-
+ " pass waitForRemoteAudio: false to resolve connect() on signaling readiness alone"));
|
|
1821
|
-
}, Math.max(deadline - Date.now(), 0));
|
|
1822
|
-
const onAbort = () => {
|
|
1823
|
-
cleanup();
|
|
1824
|
-
reject(abortError(signal));
|
|
1825
|
-
};
|
|
1826
|
-
this.remoteAudioTrackResolve = () => {
|
|
1827
|
-
cleanup();
|
|
1828
|
-
resolve();
|
|
1829
|
-
};
|
|
1830
|
-
signal?.addEventListener("abort", onAbort, { once: true });
|
|
1831
|
-
});
|
|
1832
|
-
}
|
|
1833
|
-
waitForAgentReady(timeoutMs, signal) {
|
|
1834
|
-
if (this.agentReady)
|
|
1835
|
-
return Promise.resolve();
|
|
1836
|
-
throwIfAborted(signal);
|
|
1837
|
-
return new Promise((resolve, reject) => {
|
|
1838
|
-
const cleanup = () => {
|
|
1839
|
-
globalThis.clearTimeout(timeout);
|
|
1840
|
-
signal?.removeEventListener("abort", onAbort);
|
|
1841
|
-
this.readyResolve = null;
|
|
1842
|
-
};
|
|
1843
|
-
const timeout = globalThis.setTimeout(() => {
|
|
1844
|
-
cleanup();
|
|
1845
|
-
reject(new Error(`Agent did not become ready within ${timeoutMs}ms`));
|
|
1846
|
-
}, timeoutMs);
|
|
1847
|
-
const onAbort = () => {
|
|
1848
|
-
cleanup();
|
|
1849
|
-
reject(abortError(signal));
|
|
1850
|
-
};
|
|
1851
|
-
this.readyResolve = () => {
|
|
1852
|
-
cleanup();
|
|
1853
|
-
resolve();
|
|
1854
|
-
};
|
|
1855
|
-
signal?.addEventListener("abort", onAbort, { once: true });
|
|
1609
|
+
this.flushDiagnostics("command_timeout");
|
|
1610
|
+
reject(error);
|
|
1611
|
+
}, timeoutMs);
|
|
1612
|
+
this.pendingCommands.set(
|
|
1613
|
+
message.message_id,
|
|
1614
|
+
{
|
|
1615
|
+
commandType: message.type,
|
|
1616
|
+
message,
|
|
1617
|
+
startedAt,
|
|
1618
|
+
contextKey,
|
|
1619
|
+
payloadBytes,
|
|
1620
|
+
retryCount: 0,
|
|
1621
|
+
publishQueued: false,
|
|
1622
|
+
resolve,
|
|
1623
|
+
reject,
|
|
1624
|
+
timeout,
|
|
1625
|
+
settled,
|
|
1626
|
+
markSettled,
|
|
1627
|
+
retryTransfer,
|
|
1628
|
+
transferRetrying: false,
|
|
1629
|
+
transferRetryRequested: false
|
|
1630
|
+
}
|
|
1631
|
+
);
|
|
1632
|
+
this.scheduleCommandRetry(message);
|
|
1633
|
+
});
|
|
1634
|
+
void this.publishCommandAttempt(message, false).catch(() => {
|
|
1635
|
+
});
|
|
1636
|
+
return response;
|
|
1637
|
+
}
|
|
1638
|
+
scheduleCommandRetry(message) {
|
|
1639
|
+
const pending = this.pendingCommands.get(message.message_id);
|
|
1640
|
+
if (!pending) return;
|
|
1641
|
+
const delayMs = message.type === "context.update" ? contextRetryDelay(pending.retryCount + 1) : 500;
|
|
1642
|
+
pending.retry = globalThis.setTimeout(() => {
|
|
1643
|
+
void this.retryCommand(message).finally(() => this.scheduleCommandRetry(message));
|
|
1644
|
+
}, delayMs);
|
|
1645
|
+
}
|
|
1646
|
+
async retryCommand(message) {
|
|
1647
|
+
const pending = this.pendingCommands.get(message.message_id);
|
|
1648
|
+
if (!pending) return;
|
|
1649
|
+
if (message.type === "context.update" && (this.sessionEnded || ["reconnecting", "resuming", "disconnected", "failed"].includes(this.stateValue))) return;
|
|
1650
|
+
try {
|
|
1651
|
+
if (pending.transferRetryRequested && pending.retryTransfer) {
|
|
1652
|
+
pending.transferRetryRequested = false;
|
|
1653
|
+
pending.transferRetrying = true;
|
|
1654
|
+
const transfer = pending.retryTransfer();
|
|
1655
|
+
pending.transferSettled = transfer.then(() => {
|
|
1656
|
+
}, () => {
|
|
1856
1657
|
});
|
|
1857
|
-
|
|
1858
|
-
|
|
1859
|
-
|
|
1860
|
-
|
|
1861
|
-
|
|
1862
|
-
|
|
1863
|
-
|
|
1864
|
-
|
|
1865
|
-
|
|
1866
|
-
|
|
1867
|
-
|
|
1868
|
-
|
|
1869
|
-
|
|
1870
|
-
|
|
1871
|
-
|
|
1872
|
-
|
|
1873
|
-
|
|
1874
|
-
|
|
1875
|
-
|
|
1876
|
-
|
|
1877
|
-
|
|
1878
|
-
|
|
1879
|
-
|
|
1880
|
-
|
|
1881
|
-
|
|
1882
|
-
|
|
1883
|
-
|
|
1884
|
-
|
|
1885
|
-
|
|
1886
|
-
|
|
1887
|
-
|
|
1888
|
-
|
|
1889
|
-
|
|
1890
|
-
|
|
1891
|
-
|
|
1892
|
-
|
|
1893
|
-
|
|
1894
|
-
|
|
1895
|
-
|
|
1896
|
-
|
|
1897
|
-
|
|
1898
|
-
|
|
1899
|
-
|
|
1900
|
-
|
|
1901
|
-
|
|
1902
|
-
|
|
1903
|
-
|
|
1904
|
-
|
|
1905
|
-
|
|
1906
|
-
|
|
1907
|
-
|
|
1908
|
-
|
|
1909
|
-
|
|
1910
|
-
|
|
1911
|
-
|
|
1912
|
-
|
|
1913
|
-
|
|
1914
|
-
|
|
1915
|
-
|
|
1916
|
-
|
|
1917
|
-
|
|
1918
|
-
|
|
1919
|
-
|
|
1920
|
-
|
|
1921
|
-
|
|
1922
|
-
|
|
1923
|
-
|
|
1924
|
-
|
|
1925
|
-
|
|
1926
|
-
|
|
1927
|
-
|
|
1928
|
-
|
|
1929
|
-
|
|
1930
|
-
|
|
1931
|
-
|
|
1932
|
-
|
|
1933
|
-
|
|
1934
|
-
|
|
1935
|
-
|
|
1936
|
-
|
|
1937
|
-
|
|
1938
|
-
|
|
1939
|
-
|
|
1940
|
-
|
|
1941
|
-
|
|
1942
|
-
|
|
1943
|
-
|
|
1944
|
-
|
|
1945
|
-
|
|
1946
|
-
|
|
1947
|
-
|
|
1948
|
-
|
|
1949
|
-
|
|
1950
|
-
|
|
1951
|
-
|
|
1952
|
-
|
|
1953
|
-
|
|
1954
|
-
|
|
1658
|
+
if (pending.contextKey) {
|
|
1659
|
+
this.replacementContext.blockUntilTransferSettles(pending.contextKey, pending.transferSettled);
|
|
1660
|
+
}
|
|
1661
|
+
await Promise.race([transfer, pending.settled]);
|
|
1662
|
+
}
|
|
1663
|
+
await this.publishCommandAttempt(message, true);
|
|
1664
|
+
} catch {
|
|
1665
|
+
} finally {
|
|
1666
|
+
pending.transferRetrying = false;
|
|
1667
|
+
}
|
|
1668
|
+
}
|
|
1669
|
+
publishCommandAttempt(message, retrying) {
|
|
1670
|
+
const pending = this.pendingCommands.get(message.message_id);
|
|
1671
|
+
if (!pending || pending.publishQueued) return Promise.resolve();
|
|
1672
|
+
pending.publishQueued = true;
|
|
1673
|
+
const attempt = this.commandPublishTail.then(() => this.publishCommandAttemptNow(message, retrying)).finally(() => {
|
|
1674
|
+
const current = this.pendingCommands.get(message.message_id);
|
|
1675
|
+
if (current === pending) current.publishQueued = false;
|
|
1676
|
+
});
|
|
1677
|
+
this.commandPublishTail = attempt.catch(() => {
|
|
1678
|
+
});
|
|
1679
|
+
return attempt;
|
|
1680
|
+
}
|
|
1681
|
+
async publishCommandAttemptNow(message, retrying) {
|
|
1682
|
+
const pending = this.pendingCommands.get(message.message_id);
|
|
1683
|
+
if (!pending) return;
|
|
1684
|
+
if (retrying) {
|
|
1685
|
+
pending.retryCount += 1;
|
|
1686
|
+
this.recordDiagnostic({
|
|
1687
|
+
operation: "command_send",
|
|
1688
|
+
status: "retrying",
|
|
1689
|
+
message_id: message.message_id,
|
|
1690
|
+
connection_id: message.connection_id,
|
|
1691
|
+
client_seq: message.client_seq,
|
|
1692
|
+
context_key: pending.contextKey,
|
|
1693
|
+
payload_bytes: pending.payloadBytes,
|
|
1694
|
+
retry_count: pending.retryCount,
|
|
1695
|
+
event_type: diagnosticEventType(message.type)
|
|
1696
|
+
});
|
|
1697
|
+
}
|
|
1698
|
+
const attemptStartedAt = Date.now();
|
|
1699
|
+
try {
|
|
1700
|
+
let publishFinished = false;
|
|
1701
|
+
const publish = this.publishCommand(message).then(() => {
|
|
1702
|
+
publishFinished = true;
|
|
1703
|
+
});
|
|
1704
|
+
await Promise.race([publish, pending.settled]);
|
|
1705
|
+
if (!publishFinished) return;
|
|
1706
|
+
} catch (error) {
|
|
1707
|
+
this.recordDiagnostic({
|
|
1708
|
+
operation: "command_send",
|
|
1709
|
+
status: "failed",
|
|
1710
|
+
reason: "command_publish_failed",
|
|
1711
|
+
message_id: message.message_id,
|
|
1712
|
+
connection_id: message.connection_id,
|
|
1713
|
+
client_seq: message.client_seq,
|
|
1714
|
+
context_key: pending.contextKey,
|
|
1715
|
+
payload_bytes: pending.payloadBytes,
|
|
1716
|
+
duration_ms: Date.now() - attemptStartedAt,
|
|
1717
|
+
retry_count: pending.retryCount,
|
|
1718
|
+
event_type: diagnosticEventType(message.type)
|
|
1719
|
+
});
|
|
1720
|
+
this.flushDiagnostics("command_failed");
|
|
1721
|
+
throw error;
|
|
1722
|
+
}
|
|
1723
|
+
const durationMs = Date.now() - attemptStartedAt;
|
|
1724
|
+
this.recordDiagnostic({
|
|
1725
|
+
operation: "command_send",
|
|
1726
|
+
status: "succeeded",
|
|
1727
|
+
message_id: message.message_id,
|
|
1728
|
+
connection_id: message.connection_id,
|
|
1729
|
+
client_seq: message.client_seq,
|
|
1730
|
+
context_key: pending.contextKey,
|
|
1731
|
+
payload_bytes: pending.payloadBytes,
|
|
1732
|
+
duration_ms: durationMs,
|
|
1733
|
+
retry_count: pending.retryCount,
|
|
1734
|
+
event_type: diagnosticEventType(message.type)
|
|
1735
|
+
});
|
|
1736
|
+
if (durationMs > 2e3) this.flushDiagnostics("slow_operation");
|
|
1737
|
+
}
|
|
1738
|
+
async publishCommand(message) {
|
|
1739
|
+
await this.room.localParticipant.publishData(
|
|
1740
|
+
new TextEncoder().encode(JSON.stringify(message)),
|
|
1741
|
+
{ reliable: true, topic: "voxevolv.commands" }
|
|
1742
|
+
);
|
|
1743
|
+
}
|
|
1744
|
+
startDiagnosticLoopMonitor() {
|
|
1745
|
+
if (this.diagnosticLoopTimer !== void 0) return;
|
|
1746
|
+
this.diagnosticLoopExpectedAt = performance.now() + 1e3;
|
|
1747
|
+
this.diagnosticLoopTimer = globalThis.setInterval(() => {
|
|
1748
|
+
const now = performance.now();
|
|
1749
|
+
this.diagnosticLoopDelayMs = Math.max(0, now - this.diagnosticLoopExpectedAt);
|
|
1750
|
+
this.diagnosticLoopExpectedAt = now + 1e3;
|
|
1751
|
+
}, 1e3);
|
|
1752
|
+
this.diagnosticLoopTimer.unref?.();
|
|
1753
|
+
}
|
|
1754
|
+
recordDiagnostic(entry) {
|
|
1755
|
+
const snapshot = {
|
|
1756
|
+
...entry,
|
|
1757
|
+
connection_state: this.stateValue,
|
|
1758
|
+
buffered_bytes: this.reliableDataChannelBufferedBytes()
|
|
1759
|
+
};
|
|
1760
|
+
if (entry.operation === "tool_rpc") {
|
|
1761
|
+
snapshot.page_visibility = globalThis.document?.visibilityState;
|
|
1762
|
+
snapshot.active_context_transfers = this.activeContextTransfers;
|
|
1763
|
+
snapshot.active_context_bytes = this.activeContextBytes;
|
|
1764
|
+
if (this.diagnosticLoopTimer !== void 0) {
|
|
1765
|
+
snapshot.event_loop_delay_ms = Math.max(
|
|
1766
|
+
this.diagnosticLoopDelayMs ?? 0,
|
|
1767
|
+
performance.now() - this.diagnosticLoopExpectedAt
|
|
1768
|
+
);
|
|
1769
|
+
}
|
|
1770
|
+
}
|
|
1771
|
+
this.diagnostics.record(snapshot);
|
|
1772
|
+
}
|
|
1773
|
+
reliableDataChannelBufferedBytes() {
|
|
1774
|
+
try {
|
|
1775
|
+
const bufferedAmount = this.room.engine.dataChannelForKind(0)?.bufferedAmount;
|
|
1776
|
+
return typeof bufferedAmount === "number" ? bufferedAmount : void 0;
|
|
1777
|
+
} catch {
|
|
1778
|
+
return void 0;
|
|
1779
|
+
}
|
|
1780
|
+
}
|
|
1781
|
+
flushDiagnostics(trigger) {
|
|
1782
|
+
void this.diagnostics.flush(trigger, (report) => this.postDiagnostics(report));
|
|
1783
|
+
}
|
|
1784
|
+
async postDiagnostics(report) {
|
|
1785
|
+
const controller = new AbortController();
|
|
1786
|
+
let timeout;
|
|
1787
|
+
const timeoutExpired = new Promise((_resolve, reject) => {
|
|
1788
|
+
timeout = globalThis.setTimeout(() => {
|
|
1789
|
+
controller.abort();
|
|
1790
|
+
reject(new Error(
|
|
1791
|
+
`Diagnostic report timed out after ${this.diagnosticReportTimeoutMs}ms`
|
|
1792
|
+
));
|
|
1793
|
+
}, this.diagnosticReportTimeoutMs);
|
|
1794
|
+
});
|
|
1795
|
+
try {
|
|
1796
|
+
const upload = async () => {
|
|
1797
|
+
const response = await this.fetcher(`${this.apiBase}/sessions/${this.id}/diagnostics`, {
|
|
1798
|
+
method: "POST",
|
|
1799
|
+
headers: {
|
|
1800
|
+
Authorization: `Bearer ${this.accessToken}`,
|
|
1801
|
+
"Content-Type": "application/json"
|
|
1802
|
+
},
|
|
1803
|
+
credentials: this.lifecycleCredentials,
|
|
1804
|
+
body: JSON.stringify(report),
|
|
1805
|
+
signal: controller.signal,
|
|
1806
|
+
keepalive: true
|
|
1955
1807
|
});
|
|
1956
|
-
|
|
1957
|
-
|
|
1958
|
-
|
|
1959
|
-
|
|
1960
|
-
|
|
1961
|
-
|
|
1962
|
-
|
|
1963
|
-
|
|
1964
|
-
|
|
1965
|
-
|
|
1966
|
-
|
|
1967
|
-
|
|
1968
|
-
|
|
1969
|
-
|
|
1970
|
-
|
|
1971
|
-
|
|
1972
|
-
|
|
1973
|
-
|
|
1974
|
-
|
|
1975
|
-
|
|
1976
|
-
|
|
1977
|
-
|
|
1978
|
-
|
|
1979
|
-
|
|
1980
|
-
|
|
1981
|
-
|
|
1982
|
-
|
|
1983
|
-
|
|
1984
|
-
|
|
1985
|
-
|
|
1986
|
-
|
|
1987
|
-
|
|
1988
|
-
|
|
1989
|
-
|
|
1990
|
-
|
|
1991
|
-
|
|
1992
|
-
|
|
1993
|
-
|
|
1994
|
-
|
|
1995
|
-
|
|
1996
|
-
|
|
1997
|
-
|
|
1998
|
-
|
|
1999
|
-
|
|
2000
|
-
|
|
2001
|
-
|
|
2002
|
-
|
|
2003
|
-
|
|
2004
|
-
|
|
2005
|
-
|
|
2006
|
-
|
|
2007
|
-
|
|
1808
|
+
const body = await response.json().catch(() => null);
|
|
1809
|
+
if (!response.ok) {
|
|
1810
|
+
const retryable = response.status === 408 || response.status === 429 || response.status >= 500;
|
|
1811
|
+
const headerDelay = Number(response.headers.get("Retry-After")) * 1e3;
|
|
1812
|
+
const retryAfterMs = typeof body?.retry_after_ms === "number" ? body.retry_after_ms : headerDelay;
|
|
1813
|
+
throw new DiagnosticDeliveryError(
|
|
1814
|
+
`Diagnostic report failed (${response.status})`,
|
|
1815
|
+
retryable,
|
|
1816
|
+
Number.isFinite(retryAfterMs) ? Math.max(retryAfterMs, 0) : 0
|
|
1817
|
+
);
|
|
1818
|
+
}
|
|
1819
|
+
if (body?.report_id !== report.report_id) {
|
|
1820
|
+
throw new DiagnosticDeliveryError("Diagnostic report was not acknowledged");
|
|
1821
|
+
}
|
|
1822
|
+
};
|
|
1823
|
+
await Promise.race([upload(), timeoutExpired]);
|
|
1824
|
+
} finally {
|
|
1825
|
+
if (timeout !== void 0) globalThis.clearTimeout(timeout);
|
|
1826
|
+
}
|
|
1827
|
+
}
|
|
1828
|
+
waitForRemoteAudioTrack(deadline, signal) {
|
|
1829
|
+
if ((this.outputMediaStream?.getAudioTracks().length ?? 0) > 0) return Promise.resolve();
|
|
1830
|
+
throwIfAborted(signal);
|
|
1831
|
+
return new Promise((resolve, reject) => {
|
|
1832
|
+
const cleanup = () => {
|
|
1833
|
+
globalThis.clearTimeout(timeout);
|
|
1834
|
+
signal?.removeEventListener("abort", onAbort);
|
|
1835
|
+
this.remoteAudioTrackResolve = null;
|
|
1836
|
+
};
|
|
1837
|
+
const timeout = globalThis.setTimeout(() => {
|
|
1838
|
+
cleanup();
|
|
1839
|
+
reject(new Error(
|
|
1840
|
+
"Agent audio track did not arrive within the ready timeout; pass waitForRemoteAudio: false to resolve connect() on signaling readiness alone"
|
|
1841
|
+
));
|
|
1842
|
+
}, Math.max(deadline - Date.now(), 0));
|
|
1843
|
+
const onAbort = () => {
|
|
1844
|
+
cleanup();
|
|
1845
|
+
reject(abortError(signal));
|
|
1846
|
+
};
|
|
1847
|
+
this.remoteAudioTrackResolve = () => {
|
|
1848
|
+
cleanup();
|
|
1849
|
+
resolve();
|
|
1850
|
+
};
|
|
1851
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
1852
|
+
});
|
|
1853
|
+
}
|
|
1854
|
+
waitForAgentReady(timeoutMs, signal) {
|
|
1855
|
+
if (this.agentReady) return Promise.resolve();
|
|
1856
|
+
throwIfAborted(signal);
|
|
1857
|
+
return new Promise((resolve, reject) => {
|
|
1858
|
+
const cleanup = () => {
|
|
1859
|
+
globalThis.clearTimeout(timeout);
|
|
1860
|
+
signal?.removeEventListener("abort", onAbort);
|
|
1861
|
+
this.readyResolve = null;
|
|
1862
|
+
};
|
|
1863
|
+
const timeout = globalThis.setTimeout(() => {
|
|
1864
|
+
cleanup();
|
|
1865
|
+
reject(new Error(`Agent did not become ready within ${timeoutMs}ms`));
|
|
1866
|
+
}, timeoutMs);
|
|
1867
|
+
const onAbort = () => {
|
|
1868
|
+
cleanup();
|
|
1869
|
+
reject(abortError(signal));
|
|
1870
|
+
};
|
|
1871
|
+
this.readyResolve = () => {
|
|
1872
|
+
cleanup();
|
|
1873
|
+
resolve();
|
|
1874
|
+
};
|
|
1875
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
1876
|
+
});
|
|
1877
|
+
}
|
|
1878
|
+
observeReadiness(event) {
|
|
1879
|
+
if (event.stream_id === "durable-replay") return;
|
|
1880
|
+
const payload = event.payload;
|
|
1881
|
+
const snapshotReady = event.type === "agent.snapshot" && payload.status === "ready" && payload.connection_id === this.connectionId && payload.ack_message_id === this.lastHelloMessageId;
|
|
1882
|
+
if (event.type !== "agent.ready" && !snapshotReady) return;
|
|
1883
|
+
if (this.agentStreamId && this.agentStreamId !== event.stream_id && !this.activeRecovery) {
|
|
1884
|
+
this.startAutoRejoin(
|
|
1885
|
+
new Error("Agent worker was replaced"),
|
|
1886
|
+
{ trigger: "worker_replaced" }
|
|
1887
|
+
);
|
|
1888
|
+
return;
|
|
1889
|
+
}
|
|
1890
|
+
this.agentStreamId = event.stream_id;
|
|
1891
|
+
this.agentDisconnected = false;
|
|
1892
|
+
const wasReady = this.agentReady;
|
|
1893
|
+
this.agentReady = true;
|
|
1894
|
+
this.readyResolve?.();
|
|
1895
|
+
if (!wasReady) this.notifyListeners(this.readyListeners, event);
|
|
1896
|
+
}
|
|
1897
|
+
observeCommandResult(event) {
|
|
1898
|
+
if (event.type !== "command.ack" && event.type !== "command.error" && event.type !== "context.updated" && event.type !== "context.rejected") return;
|
|
1899
|
+
const pending = this.pendingCommands.get(String(event.payload.message_id ?? ""));
|
|
1900
|
+
if (!pending) return;
|
|
1901
|
+
if (event.type === "command.ack" && isCommandAckPayload(event.payload)) {
|
|
1902
|
+
this.recordCommandResult(pending, event.type, "acknowledged");
|
|
1903
|
+
globalThis.clearTimeout(pending.timeout);
|
|
1904
|
+
globalThis.clearTimeout(pending.retry);
|
|
1905
|
+
pending.markSettled();
|
|
1906
|
+
this.pendingCommands.delete(event.payload.message_id);
|
|
1907
|
+
pending.resolve(event.payload);
|
|
1908
|
+
return;
|
|
1909
|
+
}
|
|
1910
|
+
if (event.type === "context.updated" && isContextUpdatedPayload(event.payload)) {
|
|
1911
|
+
this.recordCommandResult(pending, event.type, "acknowledged");
|
|
1912
|
+
globalThis.clearTimeout(pending.timeout);
|
|
1913
|
+
globalThis.clearTimeout(pending.retry);
|
|
1914
|
+
pending.markSettled();
|
|
1915
|
+
this.pendingCommands.delete(event.payload.message_id);
|
|
1916
|
+
pending.resolve(event.payload);
|
|
1917
|
+
return;
|
|
1918
|
+
}
|
|
1919
|
+
if (event.type === "command.error" && isCommandErrorPayload(event.payload) || event.type === "context.rejected" && isContextRejectedPayload(event.payload)) {
|
|
1920
|
+
this.recordCommandResult(pending, event.type, "rejected", event.payload.code);
|
|
1921
|
+
this.flushDiagnostics("command_rejected");
|
|
1922
|
+
if (event.type === "context.rejected" && event.payload.retryable) {
|
|
1923
|
+
if (event.payload.code === "stream_unavailable" && pending.retryTransfer && !pending.transferRetrying) {
|
|
1924
|
+
pending.transferRetryRequested = true;
|
|
1925
|
+
}
|
|
1926
|
+
return;
|
|
1927
|
+
}
|
|
1928
|
+
globalThis.clearTimeout(pending.timeout);
|
|
1929
|
+
globalThis.clearTimeout(pending.retry);
|
|
1930
|
+
pending.markSettled();
|
|
1931
|
+
this.pendingCommands.delete(event.payload.message_id);
|
|
1932
|
+
pending.reject(new CommandError(event.payload));
|
|
1933
|
+
}
|
|
1934
|
+
}
|
|
1935
|
+
rejectPendingCommands(error, preserveContext = false) {
|
|
1936
|
+
for (const [messageId, pending] of this.pendingCommands) {
|
|
1937
|
+
if (preserveContext && pending.commandType === "context.update") continue;
|
|
1938
|
+
this.recordCommandResult(
|
|
1939
|
+
pending,
|
|
1940
|
+
pending.commandType === "context.update" ? "context.rejected" : "command.error",
|
|
1941
|
+
"failed",
|
|
1942
|
+
"pending_command_cancelled"
|
|
1943
|
+
);
|
|
1944
|
+
globalThis.clearTimeout(pending.timeout);
|
|
1945
|
+
globalThis.clearTimeout(pending.retry);
|
|
1946
|
+
pending.markSettled();
|
|
1947
|
+
pending.reject(pending.commandType === "context.update" ? new ContextDeliveryError(error.message, { cause: error, retryAfter: pending.transferSettled }) : error);
|
|
1948
|
+
this.pendingCommands.delete(messageId);
|
|
1949
|
+
}
|
|
1950
|
+
}
|
|
1951
|
+
recordCommandResult(pending, eventType, status, reason) {
|
|
1952
|
+
const durationMs = Date.now() - pending.startedAt;
|
|
1953
|
+
this.recordDiagnostic({
|
|
1954
|
+
operation: "command_result",
|
|
1955
|
+
status,
|
|
1956
|
+
reason,
|
|
1957
|
+
message_id: pending.message.message_id,
|
|
1958
|
+
connection_id: pending.message.connection_id,
|
|
1959
|
+
client_seq: pending.message.client_seq,
|
|
1960
|
+
context_key: pending.contextKey,
|
|
1961
|
+
payload_bytes: pending.payloadBytes,
|
|
1962
|
+
duration_ms: durationMs,
|
|
1963
|
+
retry_count: pending.retryCount,
|
|
1964
|
+
event_type: eventType
|
|
1965
|
+
});
|
|
1966
|
+
if (durationMs > 2e3) this.flushDiagnostics("slow_operation");
|
|
1967
|
+
}
|
|
1968
|
+
async fetchReplay(apiBase, afterSeq, options = {}) {
|
|
1969
|
+
const { signal, terminalOnly = false } = options;
|
|
1970
|
+
let nextAfter = afterSeq;
|
|
1971
|
+
while (true) {
|
|
1972
|
+
throwIfAborted(signal);
|
|
1973
|
+
const url = `${apiBase}/sessions/${this.id}/events?after_seq=${nextAfter}&limit=200` + (terminalOnly ? "&terminal_only=true" : "");
|
|
1974
|
+
let response = await raceWithAbort(this.fetcher(
|
|
1975
|
+
url,
|
|
1976
|
+
{
|
|
1977
|
+
headers: { Authorization: `Bearer ${this.accessToken}` },
|
|
1978
|
+
credentials: this.lifecycleCredentials,
|
|
1979
|
+
signal
|
|
1980
|
+
}
|
|
1981
|
+
), signal);
|
|
1982
|
+
if (response.status === 401 && this.refreshCredentialsCallback) {
|
|
1983
|
+
await this.refreshCredentials(signal);
|
|
1984
|
+
response = await raceWithAbort(this.fetcher(
|
|
1985
|
+
url,
|
|
1986
|
+
{
|
|
1987
|
+
headers: { Authorization: `Bearer ${this.accessToken}` },
|
|
1988
|
+
credentials: this.lifecycleCredentials,
|
|
1989
|
+
signal
|
|
1990
|
+
}
|
|
1991
|
+
), signal);
|
|
1992
|
+
}
|
|
1993
|
+
if (!response.ok) {
|
|
1994
|
+
throw await raceWithAbort(httpError(response, "Could not replay session events"), signal);
|
|
1995
|
+
}
|
|
1996
|
+
const page = await raceWithAbort(response.json(), signal);
|
|
1997
|
+
throwIfAborted(signal);
|
|
1998
|
+
for (const stored of page.items) {
|
|
1999
|
+
nextAfter = Math.max(nextAfter, stored.server_seq);
|
|
2000
|
+
const event = {
|
|
2001
|
+
protocol_version: 1,
|
|
2002
|
+
event_id: stored.event_id,
|
|
2003
|
+
stream_id: "durable-replay",
|
|
2004
|
+
stream_seq: Math.max(this.cursor.lastStreamSeq, 1),
|
|
2005
|
+
server_seq: stored.server_seq,
|
|
2006
|
+
ts: stored.created_at,
|
|
2007
|
+
type: stored.type,
|
|
2008
|
+
payload: stored.payload
|
|
2009
|
+
};
|
|
2010
|
+
if (!this.cursor.acceptReplay(event)) continue;
|
|
2011
|
+
this.observeReadiness(event);
|
|
2012
|
+
this.observeConvenienceEvents(event);
|
|
2013
|
+
this.notifyListeners(this.listeners, event);
|
|
2014
|
+
}
|
|
2015
|
+
nextAfter = Math.max(nextAfter, page.next_after_seq);
|
|
2016
|
+
if (!page.has_more) return;
|
|
2017
|
+
}
|
|
2018
|
+
}
|
|
2019
|
+
};
|
|
2008
2020
|
function resolveAutoRejoinOptions(value) {
|
|
2009
|
-
|
|
2010
|
-
|
|
2011
|
-
|
|
2012
|
-
|
|
2013
|
-
|
|
2014
|
-
|
|
2015
|
-
|
|
2016
|
-
|
|
2017
|
-
|
|
2018
|
-
|
|
2019
|
-
|
|
2020
|
-
|
|
2021
|
-
|
|
2022
|
-
|
|
2023
|
-
|
|
2024
|
-
|
|
2025
|
-
|
|
2026
|
-
|
|
2027
|
-
|
|
2028
|
-
|
|
2029
|
-
|
|
2030
|
-
return resolved;
|
|
2021
|
+
if (!value) return null;
|
|
2022
|
+
const options = value === true ? {} : value;
|
|
2023
|
+
const resolved = {
|
|
2024
|
+
maxAttempts: options.maxAttempts ?? Number.MAX_SAFE_INTEGER,
|
|
2025
|
+
baseDelayMs: options.baseDelayMs ?? 750,
|
|
2026
|
+
maxDelayMs: options.maxDelayMs ?? 5e3,
|
|
2027
|
+
timeoutMs: options.timeoutMs ?? 18e4
|
|
2028
|
+
};
|
|
2029
|
+
if (!Number.isFinite(resolved.timeoutMs) || resolved.timeoutMs <= 0) {
|
|
2030
|
+
throw new TypeError("autoRejoin.timeoutMs must be a positive finite number");
|
|
2031
|
+
}
|
|
2032
|
+
if (!Number.isInteger(resolved.maxAttempts) || resolved.maxAttempts < 1) {
|
|
2033
|
+
throw new TypeError("autoRejoin.maxAttempts must be a positive integer");
|
|
2034
|
+
}
|
|
2035
|
+
if (!Number.isFinite(resolved.baseDelayMs) || resolved.baseDelayMs < 0) {
|
|
2036
|
+
throw new TypeError("autoRejoin.baseDelayMs must be a non-negative finite number");
|
|
2037
|
+
}
|
|
2038
|
+
if (!Number.isFinite(resolved.maxDelayMs) || resolved.maxDelayMs < 0) {
|
|
2039
|
+
throw new TypeError("autoRejoin.maxDelayMs must be a non-negative finite number");
|
|
2040
|
+
}
|
|
2041
|
+
return resolved;
|
|
2031
2042
|
}
|
|
2032
2043
|
function resolveStartupRetryOptions(value) {
|
|
2033
|
-
|
|
2034
|
-
|
|
2035
|
-
|
|
2036
|
-
|
|
2037
|
-
|
|
2038
|
-
|
|
2039
|
-
|
|
2040
|
-
|
|
2041
|
-
|
|
2042
|
-
|
|
2043
|
-
|
|
2044
|
-
|
|
2045
|
-
|
|
2046
|
-
|
|
2047
|
-
|
|
2048
|
-
|
|
2049
|
-
|
|
2050
|
-
|
|
2051
|
-
|
|
2052
|
-
|
|
2053
|
-
|
|
2054
|
-
|
|
2055
|
-
|
|
2044
|
+
if (value === false) return null;
|
|
2045
|
+
const options = typeof value === "object" ? value : {};
|
|
2046
|
+
const attempts = (options.attempts ?? DEFAULT_STARTUP_ATTEMPTS).map((attempt) => ({
|
|
2047
|
+
readyTimeoutMs: attempt.readyTimeoutMs,
|
|
2048
|
+
delayBeforeMs: attempt.delayBeforeMs ?? 0
|
|
2049
|
+
}));
|
|
2050
|
+
const timeoutMs = options.timeoutMs ?? DEFAULT_STARTUP_TIMEOUT_MS;
|
|
2051
|
+
if (attempts.length === 0) {
|
|
2052
|
+
throw new TypeError("startupRetry.attempts must contain at least one attempt");
|
|
2053
|
+
}
|
|
2054
|
+
for (const attempt of attempts) {
|
|
2055
|
+
if (!Number.isFinite(attempt.readyTimeoutMs) || attempt.readyTimeoutMs <= 0) {
|
|
2056
|
+
throw new TypeError(
|
|
2057
|
+
"startupRetry.attempts readyTimeoutMs must be a positive finite number"
|
|
2058
|
+
);
|
|
2059
|
+
}
|
|
2060
|
+
if (!Number.isFinite(attempt.delayBeforeMs) || attempt.delayBeforeMs < 0) {
|
|
2061
|
+
throw new TypeError(
|
|
2062
|
+
"startupRetry.attempts delayBeforeMs must be a non-negative finite number"
|
|
2063
|
+
);
|
|
2064
|
+
}
|
|
2065
|
+
}
|
|
2066
|
+
if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) {
|
|
2067
|
+
throw new TypeError("startupRetry.timeoutMs must be a positive finite number");
|
|
2068
|
+
}
|
|
2069
|
+
return { attempts, timeoutMs };
|
|
2056
2070
|
}
|
|
2057
2071
|
function isTerminalDisconnectReason(reason) {
|
|
2058
|
-
|
|
2059
|
-
|
|
2060
|
-
|
|
2061
|
-
|
|
2062
|
-
|
|
2063
|
-
|
|
2064
|
-
|
|
2065
|
-
|
|
2066
|
-
|
|
2072
|
+
return reason !== void 0 && [
|
|
2073
|
+
DisconnectReason.CLIENT_INITIATED,
|
|
2074
|
+
DisconnectReason.DUPLICATE_IDENTITY,
|
|
2075
|
+
DisconnectReason.PARTICIPANT_REMOVED,
|
|
2076
|
+
DisconnectReason.ROOM_DELETED,
|
|
2077
|
+
DisconnectReason.ROOM_CLOSED,
|
|
2078
|
+
DisconnectReason.USER_UNAVAILABLE,
|
|
2079
|
+
DisconnectReason.USER_REJECTED
|
|
2080
|
+
].includes(reason);
|
|
2067
2081
|
}
|
|
2068
2082
|
function disconnectReasonName(reason) {
|
|
2069
|
-
|
|
2070
|
-
|
|
2071
|
-
return DisconnectReason[reason] ?? String(reason);
|
|
2083
|
+
if (reason === void 0) return "unknown";
|
|
2084
|
+
return DisconnectReason[reason] ?? String(reason);
|
|
2072
2085
|
}
|
|
2073
2086
|
function isTerminalRejoinError(error) {
|
|
2074
|
-
|
|
2075
|
-
&& [401, 403, 409].includes(error.status);
|
|
2087
|
+
return error instanceof RealtimeSessionHttpError && [401, 403, 409].includes(error.status);
|
|
2076
2088
|
}
|
|
2077
2089
|
function isTerminalStartupError(error) {
|
|
2078
|
-
|
|
2079
|
-
|
|
2080
|
-
|
|
2081
|
-
|
|
2082
|
-
|
|
2083
|
-
|
|
2084
|
-
|
|
2085
|
-
|
|
2086
|
-
|
|
2087
|
-
|
|
2088
|
-
|
|
2089
|
-
|
|
2090
|
-
|
|
2091
|
-
|
|
2092
|
-
|
|
2093
|
-
|
|
2094
|
-
|
|
2095
|
-
"NotReadableError",
|
|
2096
|
-
"NotSupportedError",
|
|
2097
|
-
"OverconstrainedError",
|
|
2098
|
-
"SecurityError",
|
|
2099
|
-
"TrackStartError",
|
|
2100
|
-
].includes(error.name);
|
|
2090
|
+
if (error instanceof SessionErrorEventError) return !error.retryable;
|
|
2091
|
+
if (error instanceof RealtimeSessionHttpError) {
|
|
2092
|
+
return error.status >= 400 && error.status < 500 && ![408, 425, 429].includes(error.status);
|
|
2093
|
+
}
|
|
2094
|
+
if (error instanceof SessionConfigurationError) return true;
|
|
2095
|
+
return error instanceof Error && [
|
|
2096
|
+
"ConstraintNotSatisfiedError",
|
|
2097
|
+
"DevicesNotFoundError",
|
|
2098
|
+
"InvalidStateError",
|
|
2099
|
+
"NotAllowedError",
|
|
2100
|
+
"NotFoundError",
|
|
2101
|
+
"NotReadableError",
|
|
2102
|
+
"NotSupportedError",
|
|
2103
|
+
"OverconstrainedError",
|
|
2104
|
+
"SecurityError",
|
|
2105
|
+
"TrackStartError"
|
|
2106
|
+
].includes(error.name);
|
|
2101
2107
|
}
|
|
2102
2108
|
function toError(error) {
|
|
2103
|
-
|
|
2109
|
+
return error instanceof Error ? error : new Error(String(error));
|
|
2104
2110
|
}
|
|
2105
2111
|
function abortError(signal) {
|
|
2106
|
-
|
|
2112
|
+
return signal.reason ?? new DOMException("Aborted", "AbortError");
|
|
2107
2113
|
}
|
|
2108
2114
|
function createLinkedAbortController(parentSignal) {
|
|
2109
|
-
|
|
2110
|
-
|
|
2111
|
-
|
|
2112
|
-
|
|
2113
|
-
|
|
2114
|
-
|
|
2115
|
-
|
|
2116
|
-
|
|
2115
|
+
const controller = new AbortController();
|
|
2116
|
+
if (parentSignal.aborted) {
|
|
2117
|
+
controller.abort(abortError(parentSignal));
|
|
2118
|
+
} else {
|
|
2119
|
+
parentSignal.addEventListener(
|
|
2120
|
+
"abort",
|
|
2121
|
+
() => controller.abort(abortError(parentSignal)),
|
|
2122
|
+
{ once: true }
|
|
2123
|
+
);
|
|
2124
|
+
}
|
|
2125
|
+
return controller;
|
|
2117
2126
|
}
|
|
2118
2127
|
function throwIfAborted(signal) {
|
|
2119
|
-
|
|
2120
|
-
throw abortError(signal);
|
|
2128
|
+
if (signal?.aborted) throw abortError(signal);
|
|
2121
2129
|
}
|
|
2122
2130
|
function raceWithAbort(promise, signal) {
|
|
2123
|
-
|
|
2124
|
-
|
|
2125
|
-
|
|
2126
|
-
|
|
2127
|
-
|
|
2128
|
-
|
|
2129
|
-
|
|
2130
|
-
|
|
2131
|
-
|
|
2132
|
-
|
|
2133
|
-
|
|
2134
|
-
|
|
2135
|
-
|
|
2136
|
-
|
|
2137
|
-
|
|
2138
|
-
|
|
2139
|
-
|
|
2140
|
-
|
|
2141
|
-
|
|
2142
|
-
|
|
2143
|
-
|
|
2144
|
-
|
|
2145
|
-
|
|
2146
|
-
signal.addEventListener("abort", onAbort, { once: true });
|
|
2147
|
-
}
|
|
2148
|
-
// Always observe the operation, including when it aborted the signal before
|
|
2149
|
-
// returning its promise, so a later rejection cannot escape to the caller.
|
|
2150
|
-
promise.then(resolveOnce, rejectOnce);
|
|
2151
|
-
});
|
|
2131
|
+
if (!signal) return promise;
|
|
2132
|
+
return new Promise((resolve, reject) => {
|
|
2133
|
+
let settled = false;
|
|
2134
|
+
const resolveOnce = (value) => {
|
|
2135
|
+
if (settled) return;
|
|
2136
|
+
settled = true;
|
|
2137
|
+
signal.removeEventListener("abort", onAbort);
|
|
2138
|
+
resolve(value);
|
|
2139
|
+
};
|
|
2140
|
+
const rejectOnce = (error) => {
|
|
2141
|
+
if (settled) return;
|
|
2142
|
+
settled = true;
|
|
2143
|
+
signal.removeEventListener("abort", onAbort);
|
|
2144
|
+
reject(error);
|
|
2145
|
+
};
|
|
2146
|
+
const onAbort = () => rejectOnce(abortError(signal));
|
|
2147
|
+
if (signal.aborted) {
|
|
2148
|
+
onAbort();
|
|
2149
|
+
} else {
|
|
2150
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
2151
|
+
}
|
|
2152
|
+
promise.then(resolveOnce, rejectOnce);
|
|
2153
|
+
});
|
|
2152
2154
|
}
|
|
2153
2155
|
function abortableDelay(delayMs, signal) {
|
|
2154
|
-
|
|
2155
|
-
|
|
2156
|
-
|
|
2157
|
-
|
|
2158
|
-
|
|
2159
|
-
|
|
2160
|
-
|
|
2161
|
-
|
|
2162
|
-
|
|
2163
|
-
|
|
2164
|
-
|
|
2165
|
-
|
|
2166
|
-
|
|
2167
|
-
});
|
|
2156
|
+
throwIfAborted(signal);
|
|
2157
|
+
if (delayMs <= 0) return Promise.resolve();
|
|
2158
|
+
return new Promise((resolve, reject) => {
|
|
2159
|
+
const timeout = globalThis.setTimeout(() => {
|
|
2160
|
+
signal.removeEventListener("abort", onAbort);
|
|
2161
|
+
resolve();
|
|
2162
|
+
}, delayMs);
|
|
2163
|
+
const onAbort = () => {
|
|
2164
|
+
globalThis.clearTimeout(timeout);
|
|
2165
|
+
reject(abortError(signal));
|
|
2166
|
+
};
|
|
2167
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
2168
|
+
});
|
|
2168
2169
|
}
|
|
2169
2170
|
function waitUntilOnline(signal) {
|
|
2170
|
-
|
|
2171
|
-
|
|
2172
|
-
|
|
2173
|
-
|
|
2174
|
-
|
|
2175
|
-
|
|
2176
|
-
|
|
2177
|
-
|
|
2178
|
-
|
|
2179
|
-
|
|
2180
|
-
|
|
2181
|
-
|
|
2182
|
-
|
|
2183
|
-
|
|
2184
|
-
|
|
2185
|
-
|
|
2186
|
-
|
|
2187
|
-
|
|
2188
|
-
|
|
2189
|
-
|
|
2190
|
-
|
|
2191
|
-
});
|
|
2171
|
+
throwIfAborted(signal);
|
|
2172
|
+
if (typeof navigator === "undefined" || navigator.onLine !== false) {
|
|
2173
|
+
return Promise.resolve();
|
|
2174
|
+
}
|
|
2175
|
+
if (typeof globalThis.addEventListener !== "function") return Promise.resolve();
|
|
2176
|
+
return new Promise((resolve, reject) => {
|
|
2177
|
+
const cleanup = () => {
|
|
2178
|
+
globalThis.removeEventListener("online", onOnline);
|
|
2179
|
+
signal.removeEventListener("abort", onAbort);
|
|
2180
|
+
};
|
|
2181
|
+
const onOnline = () => {
|
|
2182
|
+
cleanup();
|
|
2183
|
+
resolve();
|
|
2184
|
+
};
|
|
2185
|
+
const onAbort = () => {
|
|
2186
|
+
cleanup();
|
|
2187
|
+
reject(abortError(signal));
|
|
2188
|
+
};
|
|
2189
|
+
globalThis.addEventListener("online", onOnline, { once: true });
|
|
2190
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
2191
|
+
});
|
|
2192
2192
|
}
|
|
2193
2193
|
async function sha256Hex(data) {
|
|
2194
|
-
|
|
2195
|
-
|
|
2196
|
-
|
|
2197
|
-
return [...new Uint8Array(digest)]
|
|
2198
|
-
.map((byte) => byte.toString(16).padStart(2, "0"))
|
|
2199
|
-
.join("");
|
|
2194
|
+
if (!globalThis.crypto?.subtle) throw new Error("Web Crypto SHA-256 is unavailable");
|
|
2195
|
+
const digest = await globalThis.crypto.subtle.digest("SHA-256", Uint8Array.from(data).buffer);
|
|
2196
|
+
return [...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
2200
2197
|
}
|
|
2201
2198
|
function contextStreamId() {
|
|
2202
|
-
|
|
2203
|
-
|
|
2204
|
-
return `ctx_${Date.now().toString(36)}_${Math.random().toString(36).slice(2)}`;
|
|
2199
|
+
if (globalThis.crypto?.randomUUID) return globalThis.crypto.randomUUID();
|
|
2200
|
+
return `ctx_${Date.now().toString(36)}_${Math.random().toString(36).slice(2)}`;
|
|
2205
2201
|
}
|
|
2206
2202
|
function contextUpdateBytes(update) {
|
|
2207
|
-
|
|
2208
|
-
? update.image.size
|
|
2209
|
-
: new TextEncoder().encode(update.text).byteLength;
|
|
2203
|
+
return "image" in update ? update.image.size : new TextEncoder().encode(update.text).byteLength;
|
|
2210
2204
|
}
|
|
2211
2205
|
function contextPayloadBytes(payload) {
|
|
2212
|
-
|
|
2213
|
-
|
|
2214
|
-
|
|
2215
|
-
|
|
2216
|
-
|
|
2217
|
-
|
|
2218
|
-
|
|
2219
|
-
|
|
2220
|
-
|
|
2221
|
-
|
|
2222
|
-
|
|
2223
|
-
|
|
2224
|
-
|
|
2225
|
-
|
|
2226
|
-
|
|
2227
|
-
|
|
2228
|
-
|
|
2229
|
-
|
|
2230
|
-
catch {
|
|
2231
|
-
return undefined;
|
|
2232
|
-
}
|
|
2206
|
+
const image = payload.image;
|
|
2207
|
+
if (image && typeof image === "object" && "bytes" in image) {
|
|
2208
|
+
const bytes = image.bytes;
|
|
2209
|
+
if (typeof bytes === "number") return bytes;
|
|
2210
|
+
}
|
|
2211
|
+
const textStream = payload.text_stream;
|
|
2212
|
+
if (textStream && typeof textStream === "object" && "bytes" in textStream) {
|
|
2213
|
+
const bytes = textStream.bytes;
|
|
2214
|
+
if (typeof bytes === "number") return bytes;
|
|
2215
|
+
}
|
|
2216
|
+
if (typeof payload.text === "string") {
|
|
2217
|
+
return new TextEncoder().encode(payload.text).byteLength;
|
|
2218
|
+
}
|
|
2219
|
+
try {
|
|
2220
|
+
return new TextEncoder().encode(JSON.stringify(payload)).byteLength;
|
|
2221
|
+
} catch {
|
|
2222
|
+
return void 0;
|
|
2223
|
+
}
|
|
2233
2224
|
}
|
|
2234
2225
|
function contextKeyFromMessage(message) {
|
|
2235
|
-
|
|
2236
|
-
|
|
2237
|
-
|
|
2238
|
-
return typeof key === "string" ? key : undefined;
|
|
2226
|
+
if (message.type !== "context.update") return void 0;
|
|
2227
|
+
const key = message.payload.key;
|
|
2228
|
+
return typeof key === "string" ? key : void 0;
|
|
2239
2229
|
}
|
|
2240
2230
|
function diagnosticEventType(type) {
|
|
2241
|
-
|
|
2231
|
+
return type === "client.hello" || type === "context.update" ? type : void 0;
|
|
2242
2232
|
}
|
|
2243
2233
|
function validateSessionCredentials(value) {
|
|
2244
|
-
|
|
2245
|
-
|
|
2246
|
-
|
|
2247
|
-
|
|
2248
|
-
|
|
2249
|
-
|
|
2250
|
-
|
|
2251
|
-
|
|
2252
|
-
|
|
2253
|
-
|| !value.connection.room_name) {
|
|
2254
|
-
throw new SessionConfigurationError("Session connection credentials are incomplete");
|
|
2255
|
-
}
|
|
2234
|
+
if (!value || typeof value !== "object") {
|
|
2235
|
+
throw new SessionConfigurationError("Session credentials must be an object");
|
|
2236
|
+
}
|
|
2237
|
+
if (!value.id || !value.access_token || !value.access_token_expires_at) {
|
|
2238
|
+
throw new SessionConfigurationError("Session credentials are incomplete");
|
|
2239
|
+
}
|
|
2240
|
+
if (!value.connection || !value.connection.url || !value.connection.token || !value.connection.room_name) {
|
|
2241
|
+
throw new SessionConfigurationError("Session connection credentials are incomplete");
|
|
2242
|
+
}
|
|
2256
2243
|
}
|
|
2257
2244
|
function normalizeApiBase(value) {
|
|
2258
|
-
|
|
2259
|
-
|
|
2260
|
-
|
|
2261
|
-
|
|
2262
|
-
|
|
2245
|
+
const normalized = value.trim().replace(/\/+$/, "");
|
|
2246
|
+
if (!normalized) {
|
|
2247
|
+
throw new SessionConfigurationError("apiBase must be a non-root relative path or an absolute URL");
|
|
2248
|
+
}
|
|
2249
|
+
return normalized;
|
|
2263
2250
|
}
|
|
2264
2251
|
function transcriptEvent(event) {
|
|
2265
|
-
|
|
2266
|
-
|
|
2267
|
-
|
|
2268
|
-
|
|
2269
|
-
|
|
2270
|
-
|
|
2271
|
-
|
|
2272
|
-
|
|
2273
|
-
|
|
2274
|
-
|
|
2275
|
-
event,
|
|
2276
|
-
};
|
|
2252
|
+
const match = /^transcript\.(user|agent)\.(interim|final)$/.exec(event.type);
|
|
2253
|
+
if (!match) return null;
|
|
2254
|
+
const text = event.payload.text;
|
|
2255
|
+
if (typeof text !== "string") return null;
|
|
2256
|
+
return {
|
|
2257
|
+
role: match[1],
|
|
2258
|
+
text,
|
|
2259
|
+
final: match[2] === "final",
|
|
2260
|
+
event
|
|
2261
|
+
};
|
|
2277
2262
|
}
|
|
2278
2263
|
async function httpError(response, fallback) {
|
|
2279
|
-
|
|
2280
|
-
|
|
2281
|
-
|
|
2282
|
-
|
|
2283
|
-
|
|
2284
|
-
|
|
2285
|
-
}
|
|
2286
|
-
|
|
2287
|
-
|
|
2288
|
-
|
|
2289
|
-
|
|
2290
|
-
|
|
2291
|
-
}
|
|
2264
|
+
let payload = {};
|
|
2265
|
+
try {
|
|
2266
|
+
payload = await response.json();
|
|
2267
|
+
} catch {
|
|
2268
|
+
}
|
|
2269
|
+
return new RealtimeSessionHttpError(
|
|
2270
|
+
typeof payload.message === "string" ? payload.message : `${fallback} (${response.status})`,
|
|
2271
|
+
{
|
|
2272
|
+
status: response.status,
|
|
2273
|
+
code: typeof payload.error === "string" ? payload.error : null,
|
|
2274
|
+
requestId: typeof payload.request_id === "string" ? payload.request_id : null,
|
|
2275
|
+
details: Array.isArray(payload.details) ? payload.details : []
|
|
2276
|
+
}
|
|
2277
|
+
);
|
|
2292
2278
|
}
|
|
2293
|
-
|
|
2279
|
+
export {
|
|
2280
|
+
CommandError2 as CommandError,
|
|
2281
|
+
EventCursor2 as EventCursor,
|
|
2282
|
+
RealtimeSession,
|
|
2283
|
+
RealtimeSessionHttpError,
|
|
2284
|
+
SDK_VERSION,
|
|
2285
|
+
TranscriptStore2 as TranscriptStore,
|
|
2286
|
+
createClientMessage2 as createClientMessage,
|
|
2287
|
+
decodeAgentSnapshotPayload,
|
|
2288
|
+
decodeClientHelloPayload,
|
|
2289
|
+
decodeClientMessage,
|
|
2290
|
+
decodeCommandAckPayload,
|
|
2291
|
+
decodeCommandErrorPayload,
|
|
2292
|
+
decodeContextRejectedPayload,
|
|
2293
|
+
decodeContextUpdatePayload,
|
|
2294
|
+
decodeContextUpdatedPayload,
|
|
2295
|
+
decodeRealtimeEvent,
|
|
2296
|
+
isCommandAckPayload2 as isCommandAckPayload,
|
|
2297
|
+
isCommandErrorPayload2 as isCommandErrorPayload,
|
|
2298
|
+
isContextRejectedPayload2 as isContextRejectedPayload,
|
|
2299
|
+
isContextUpdatePayload2 as isContextUpdatePayload,
|
|
2300
|
+
isContextUpdatedPayload2 as isContextUpdatedPayload,
|
|
2301
|
+
isRealtimeEvent2 as isRealtimeEvent
|
|
2302
|
+
};
|
|
2303
|
+
//# sourceMappingURL=index.js.map
|