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