@alexkroman1/aai-ui 1.9.2 → 1.10.0

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.
Files changed (44) hide show
  1. package/dist/{_colors-DJUordGv.js → _colors-DYX7XRTr.js} +8 -0
  2. package/dist/{_utils-5cs73OrA.js → _utils-CN1yVgYS.js} +2 -5
  3. package/dist/audio.d.ts +6 -0
  4. package/dist/audio.js +62 -9
  5. package/dist/{chat-view-C1XbqDk0.js → chat-view-u3yBAlig.js} +16 -14
  6. package/dist/components/_colors.d.ts +0 -4
  7. package/dist/components/chat-view.js +1 -1
  8. package/dist/components/controls.d.ts +2 -2
  9. package/dist/components/controls.js +1 -1
  10. package/dist/components/message-list.d.ts +2 -2
  11. package/dist/components/message-list.js +70 -64
  12. package/dist/components/start-screen.js +1 -1
  13. package/dist/components/text-controls.d.ts +2 -2
  14. package/dist/components/tool-call-block.d.ts +6 -2
  15. package/dist/components/tool-call-block.js +1 -1
  16. package/dist/context.d.ts +3 -11
  17. package/dist/context.js +9 -22
  18. package/dist/{controls-BngrPbOC.js → controls-B2EPUJDU.js} +12 -9
  19. package/dist/default-client/assets/audio-COt0_Zvp.js +1 -0
  20. package/dist/default-client/assets/{capture-processor-C19oBn4L.js → capture-processor-UlKEKyIW.js} +4 -1
  21. package/dist/default-client/assets/index-B2bISVbm.css +2 -0
  22. package/dist/default-client/assets/index-DtsWxxc_.js +94 -0
  23. package/dist/default-client/assets/{playback-processor-BtlzAH78.js → playback-processor-C5HVRVbu.js} +11 -4
  24. package/dist/default-client/index.html +2 -2
  25. package/dist/define-client.js +3 -3
  26. package/dist/hooks.js +5 -2
  27. package/dist/index.d.ts +1 -0
  28. package/dist/index.js +5 -5
  29. package/dist/{session-core-D3NDaySY.js → session-core-Cfz7PPhN.js} +248 -111
  30. package/dist/session-core-audio-setup.d.ts +36 -0
  31. package/dist/session-core-messages.d.ts +12 -0
  32. package/dist/session-core-reconnect.d.ts +19 -0
  33. package/dist/session-core-url.d.ts +2 -0
  34. package/dist/session-core.js +1 -1
  35. package/dist/{tool-call-block-7f1GTG-P.js → tool-call-block-DIxpG8GM.js} +11 -6
  36. package/dist/types.d.ts +11 -3
  37. package/dist/worklets/capture-processor.d.ts +1 -1
  38. package/dist/worklets/capture-processor.js +4 -1
  39. package/dist/worklets/playback-processor.d.ts +1 -1
  40. package/dist/worklets/playback-processor.js +11 -4
  41. package/package.json +8 -5
  42. package/dist/default-client/assets/audio-Cs-6t_Wd.js +0 -1
  43. package/dist/default-client/assets/index-Bf4ZTNcx.js +0 -73
  44. package/dist/default-client/assets/index-Bzlh9i7w.css +0 -2
@@ -46,8 +46,11 @@ class PlaybackProcessor extends AudioWorkletProcessor {
46
46
  // End the current turn: notify the host and rearm for the next reply.
47
47
  // Must NOT return false from process() — a processor that stops is dead
48
48
  // for good, forcing a new node (and buffer) per reply.
49
- stopTurn() {
50
- this.port.postMessage({ event: 'stop' });
49
+ // \`reason\` ('interrupt' | 'done') tells the host which turn boundary this
50
+ // stop belongs to: interrupt-stops are dropped host-side (flush() already
51
+ // settled that turn), so they can never resolve a later turn's done() early.
52
+ stopTurn(reason) {
53
+ this.port.postMessage({ event: 'stop', reason });
51
54
  this.resetTurn();
52
55
  }
53
56
 
@@ -105,10 +108,14 @@ class PlaybackProcessor extends AudioWorkletProcessor {
105
108
  }
106
109
 
107
110
  process(inputs, outputs) {
111
+ // No output wired up yet — nothing to render this quantum. Throwing here
112
+ // would permanently kill the processor (the node is persistent per
113
+ // session), so guard like the capture processor does.
114
+ if (!outputs[0] || !outputs[0][0]) return true;
108
115
  const out = outputs[0][0];
109
116
  if (this.interrupted) {
110
117
  out.fill(0);
111
- this.stopTurn();
118
+ this.stopTurn('interrupt');
112
119
  return true;
113
120
  }
114
121
 
@@ -139,7 +146,7 @@ class PlaybackProcessor extends AudioWorkletProcessor {
139
146
  // No data: output silence, end the turn only when done
140
147
  out.fill(0);
141
148
  if (this.isDone) {
142
- this.stopTurn();
149
+ this.stopTurn('done');
143
150
  }
144
151
  return true;
145
152
  }
@@ -6,8 +6,8 @@
6
6
  <title>aai</title>
7
7
  <link rel="icon" href="data:," />
8
8
  <style>html, body { background: #FBF8F2; margin: 0; }</style>
9
- <script type="module" crossorigin src="./assets/index-Bf4ZTNcx.js"></script>
10
- <link rel="stylesheet" crossorigin href="./assets/index-Bzlh9i7w.css">
9
+ <script type="module" crossorigin src="./assets/index-DtsWxxc_.js"></script>
10
+ <link rel="stylesheet" crossorigin href="./assets/index-B2bISVbm.css">
11
11
  </head>
12
12
  <body>
13
13
  <main id="app"></main>
@@ -1,9 +1,9 @@
1
1
  import { SessionProvider, ThemeProvider } from "./context.js";
2
- import { n as ToolConfigContext } from "./tool-call-block-7f1GTG-P.js";
3
- import { t as ChatView } from "./chat-view-C1XbqDk0.js";
2
+ import { n as ToolConfigContext } from "./tool-call-block-DIxpG8GM.js";
3
+ import { t as ChatView } from "./chat-view-u3yBAlig.js";
4
4
  import { SidebarLayout } from "./components/sidebar-layout.js";
5
5
  import { StartScreen } from "./components/start-screen.js";
6
- import { t as createSessionCore } from "./session-core-D3NDaySY.js";
6
+ import { t as createSessionCore } from "./session-core-Cfz7PPhN.js";
7
7
  import { createElement } from "react";
8
8
  import { jsx } from "react/jsx-runtime";
9
9
  import { flushSync } from "react-dom";
package/dist/hooks.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { useSessionSelector } from "./context.js";
2
- import { n as tryParseJSON } from "./_utils-5cs73OrA.js";
2
+ import { n as tryParseJSON } from "./_utils-CN1yVgYS.js";
3
3
  import { useEffect, useRef } from "react";
4
4
  //#region hooks.ts
5
5
  /** Index of the first item whose sequence number is above the watermark (tail scan from the end). */
@@ -51,13 +51,16 @@ function useToolCallEffect(status, args, fire) {
51
51
  seq: 0,
52
52
  fired: /* @__PURE__ */ new Set()
53
53
  });
54
+ const mountedRef = useRef(false);
54
55
  const callbackRef = useRef(callback);
55
56
  callbackRef.current = callback;
56
57
  const fireRef = useRef(fire);
57
58
  fireRef.current = fire;
58
59
  useEffect(() => {
60
+ const firstRun = !mountedRef.current;
61
+ mountedRef.current = true;
59
62
  processToolCallTail(toolCalls, cursorRef.current, (tc) => status === "pending" || tc.status === status, (tc) => {
60
- if (tc.status !== status) return;
63
+ if (tc.status !== status && firstRun) return;
61
64
  if (filterName && tc.name !== filterName) return;
62
65
  fireRef.current(callbackRef.current, tc, filterName !== null);
63
66
  });
package/dist/index.d.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  export { Button } from "./components/button.tsx";
2
2
  export { ChatView } from "./components/chat-view.tsx";
3
+ export { Controls } from "./components/controls.tsx";
3
4
  export { MessageList } from "./components/message-list.tsx";
4
5
  export { SidebarLayout } from "./components/sidebar-layout.tsx";
5
6
  export { StartScreen } from "./components/start-screen.tsx";
package/dist/index.js CHANGED
@@ -1,12 +1,12 @@
1
1
  import { SessionProvider, ThemeProvider, useSession, useSessionSelector, useTheme } from "./context.js";
2
2
  import { Button } from "./components/button.js";
3
- import { i as UiUrlChip, n as ApiUrlChip, r as SessionUrlChips } from "./controls-BngrPbOC.js";
4
- import { n as ToolConfigContext } from "./tool-call-block-7f1GTG-P.js";
3
+ import { i as UiUrlChip, n as ApiUrlChip, r as SessionUrlChips, t as Controls } from "./controls-B2EPUJDU.js";
4
+ import { n as ToolConfigContext } from "./tool-call-block-DIxpG8GM.js";
5
5
  import { MessageList } from "./components/message-list.js";
6
- import { n as TextControls, t as ChatView } from "./chat-view-C1XbqDk0.js";
6
+ import { n as TextControls, t as ChatView } from "./chat-view-u3yBAlig.js";
7
7
  import { SidebarLayout } from "./components/sidebar-layout.js";
8
8
  import { StartScreen } from "./components/start-screen.js";
9
- import { t as createSessionCore } from "./session-core-D3NDaySY.js";
9
+ import { t as createSessionCore } from "./session-core-Cfz7PPhN.js";
10
10
  import { client } from "./define-client.js";
11
11
  import { useEvent, useToolCallStart, useToolResult } from "./hooks.js";
12
- export { ApiUrlChip, Button, ChatView, MessageList, SessionProvider, SessionUrlChips, SidebarLayout, StartScreen, TextControls, ThemeProvider, ToolConfigContext, UiUrlChip, client, createSessionCore, useEvent, useSession, useSessionSelector, useTheme, useToolCallStart, useToolResult };
12
+ export { ApiUrlChip, Button, ChatView, Controls, MessageList, SessionProvider, SessionUrlChips, SidebarLayout, StartScreen, TextControls, ThemeProvider, ToolConfigContext, UiUrlChip, client, createSessionCore, useEvent, useSession, useSessionSelector, useTheme, useToolCallStart, useToolResult };
@@ -1,7 +1,108 @@
1
1
  import "./types.js";
2
2
  import { DEFAULT_MAX_HISTORY, FILE_UPLOAD_CHUNK_BYTES, WS_OPEN, errorMessage, safeJsonParse } from "@alexkroman1/aai";
3
3
  import { ServerMessageSchema, lenientParse } from "@alexkroman1/aai/protocol";
4
+ import ReconnectingWebSocket from "partysocket/ws";
4
5
  import { MAX_SYNC_AUDIO_SECONDS } from "@alexkroman1/aai/stt";
6
+ //#region session-core-audio-setup.ts
7
+ /**
8
+ * Audio-path initialization for the voice session core.
9
+ *
10
+ * Split out of `session-core.ts`: this module owns the async
11
+ * mic-permission → worklet-registration → `VoiceIO` bring-up (and its
12
+ * staleness/failure handling), while `session-core.ts` owns the state store
13
+ * and connection lifecycle.
14
+ */
15
+ /**
16
+ * Initialize audio capture and playback after the server sends a ready config.
17
+ *
18
+ * Lifecycle: dynamically import audio modules -> request microphone access ->
19
+ * register AudioWorklet processors -> create a `VoiceIO` instance -> send
20
+ * `audio_ready` to the server -> transition state to `"listening"`.
21
+ *
22
+ * Uses the connection `generation` counter to detect if `connect()` was called
23
+ * (or a reconnect happened) while awaiting async operations; if so, the stale
24
+ * VoiceIO is closed immediately to prevent it from being assigned to a newer
25
+ * connection.
26
+ *
27
+ * `fatal` selects the failure mode: voice sessions (`config` handshake) can't
28
+ * function without the mic, so failure there sets the error state and ends
29
+ * the session; a text-only session's opt-in record button must not brick an
30
+ * otherwise healthy session, so failure there is a banner (`error` set,
31
+ * state kept) and the session stays interactive.
32
+ */
33
+ async function initAudioCapture(conn, msg, deps, fatal) {
34
+ if (conn.audioSetupInFlight) return;
35
+ conn.audioSetupInFlight = true;
36
+ const gen = conn.generation;
37
+ const stale = () => conn.generation !== gen || !conn.ws || conn.ws.readyState !== WS_OPEN;
38
+ const reportAudioFailure = (message) => {
39
+ if (fatal) deps.updateState({
40
+ state: "error",
41
+ error: {
42
+ code: "audio",
43
+ message
44
+ },
45
+ running: false,
46
+ recording: false
47
+ });
48
+ else {
49
+ deps.cleanupAudio();
50
+ deps.updateState({
51
+ error: {
52
+ code: "audio",
53
+ message
54
+ },
55
+ recording: false
56
+ });
57
+ }
58
+ };
59
+ try {
60
+ const [{ createVoiceIO }, captureWorklet, playbackWorklet] = await Promise.all([
61
+ import("./audio.js"),
62
+ import("./worklets/capture-processor.js").then((m) => m.default),
63
+ import("./worklets/playback-processor.js").then((m) => m.default)
64
+ ]);
65
+ const io = await createVoiceIO({
66
+ sttSampleRate: msg.sampleRate,
67
+ ttsSampleRate: msg.ttsSampleRate,
68
+ captureWorkletSrc: captureWorklet,
69
+ playbackWorkletSrc: playbackWorklet,
70
+ onMicData: (pcm16) => {
71
+ try {
72
+ deps.sendAudio(new Uint8Array(pcm16));
73
+ } catch {
74
+ console.debug("[aai-ui] sendAudio dropped: connection closed");
75
+ }
76
+ },
77
+ onError: (err) => {
78
+ if (conn.generation !== gen) return;
79
+ reportAudioFailure(err.message);
80
+ }
81
+ });
82
+ if (stale()) {
83
+ io.close().catch(() => {});
84
+ return;
85
+ }
86
+ conn.voiceIO?.close().catch(() => {});
87
+ conn.voiceIO = io;
88
+ if (conn.preInitAudio.length > 0) {
89
+ for (const chunk of conn.preInitAudio) io.enqueue(chunk.buffer);
90
+ conn.preInitAudio = [];
91
+ }
92
+ deps.sendJson({ type: "audio_ready" });
93
+ deps.updateState({ recording: true });
94
+ if (conn.preInitDone) {
95
+ conn.preInitDone = false;
96
+ deps.settleWhenAudioDrained(io);
97
+ } else deps.updateState({ state: "listening" });
98
+ } catch (err) {
99
+ if (stale()) return;
100
+ reportAudioFailure(`Microphone access failed: ${errorMessage(err)}`);
101
+ } finally {
102
+ if (conn.generation === gen) conn.audioSetupInFlight = false;
103
+ }
104
+ }
105
+ //#endregion
5
106
  //#region session-core-messages.ts
6
107
  /**
7
108
  * Incoming-message handling for the voice session core.
@@ -48,7 +149,7 @@ function appendCapped(list, item, cap) {
48
149
  * dedup) that previously lived as closure locals in `createSessionCore`.
49
150
  */
50
151
  function createMessageHandlers(deps) {
51
- const { getSnapshot, updateState, conn } = deps;
152
+ const { getSnapshot, updateState, conn, discardUpload, cleanupAudio } = deps;
52
153
  /** Incremented on each turn boundary -- stale async callbacks compare against this. */
53
154
  let handlerGeneration = 0;
54
155
  /** Monotonically increasing counter for custom events -- used by useEvent to deduplicate. */
@@ -93,7 +194,7 @@ function createMessageHandlers(deps) {
93
194
  function clearRecoveredError() {
94
195
  const snap = getSnapshot();
95
196
  if (snap.state === "error") updateState({
96
- state: "disconnected",
197
+ state: "listening",
97
198
  error: null
98
199
  });
99
200
  else if (snap.error !== null) updateState({ error: null });
@@ -104,14 +205,18 @@ function createMessageHandlers(deps) {
104
205
  code: e.code,
105
206
  message: e.message
106
207
  } });
107
- else updateState({
108
- state: "error",
109
- error: {
110
- code: e.code,
111
- message: e.message
112
- },
113
- running: false
114
- });
208
+ else {
209
+ cleanupAudio();
210
+ updateState({
211
+ state: "error",
212
+ error: {
213
+ code: e.code,
214
+ message: e.message
215
+ },
216
+ running: false,
217
+ recording: false
218
+ });
219
+ }
115
220
  }
116
221
  /** Single entry point for all server->client session events. */
117
222
  function handleEvent(e) {
@@ -169,6 +274,7 @@ function createMessageHandlers(deps) {
169
274
  break;
170
275
  case "reset":
171
276
  handlerGeneration++;
277
+ discardUpload();
172
278
  conn.voiceIO?.flush();
173
279
  updateState({
174
280
  ...CLEARED_SESSION_STATE,
@@ -188,25 +294,31 @@ function createMessageHandlers(deps) {
188
294
  /** Enqueue a PCM16 audio chunk for playback. Transitions state to `"speaking"` on the first chunk. */
189
295
  function playAudioChunk(chunk) {
190
296
  const snap = getSnapshot();
191
- if (snap.state === "disconnected" && snap.error !== null) return;
297
+ if (snap.state === "error" || snap.state === "disconnected" && snap.error !== null) return;
192
298
  if (snap.state !== "speaking") updateState({ state: "speaking" });
193
299
  if (conn.voiceIO) conn.voiceIO.enqueue(chunk.buffer);
194
300
  else if (conn.preInitAudio.length < MAX_PREINIT_AUDIO_CHUNKS) conn.preInitAudio.push(chunk);
195
301
  }
302
+ /** See {@link MessageHandlers.settleWhenAudioDrained}. Captures
303
+ * `handlerGeneration` so a completion (or failure) that lands after a turn
304
+ * boundary is discarded instead of overwriting the newer turn's state. */
305
+ function settleWhenAudioDrained(io) {
306
+ const gen = handlerGeneration;
307
+ io.done().then(() => {
308
+ if (handlerGeneration !== gen) return;
309
+ updateState({ state: "listening" });
310
+ }).catch((err) => {
311
+ console.warn("Audio playback done failed:", err);
312
+ });
313
+ }
196
314
  /**
197
315
  * Signal that the server has finished sending audio for this turn.
198
316
  * Waits for the audio queue to drain, then transitions state to `"listening"`.
199
317
  * Uses the `handlerGeneration` counter to discard stale completions from interrupted turns.
200
318
  */
201
319
  function playAudioDone() {
202
- const gen = handlerGeneration;
203
320
  const io = conn.voiceIO;
204
- if (io) io.done().then(() => {
205
- if (handlerGeneration !== gen) return;
206
- updateState({ state: "listening" });
207
- }).catch((err) => {
208
- console.warn("Audio playback done failed:", err);
209
- });
321
+ if (io) settleWhenAudioDrained(io);
210
322
  else {
211
323
  conn.preInitDone = true;
212
324
  updateState({ state: "listening" });
@@ -244,7 +356,46 @@ function createMessageHandlers(deps) {
244
356
  }
245
357
  handleEvent(msg);
246
358
  }
247
- return { handleMessage };
359
+ return {
360
+ handleMessage,
361
+ settleWhenAudioDrained
362
+ };
363
+ }
364
+ //#endregion
365
+ //#region session-core-reconnect.ts
366
+ /**
367
+ * Automatic reconnection for the browser session socket, built on
368
+ * partysocket's `ReconnectingWebSocket`. Kept out of `session-core.ts` so
369
+ * the state machine there reads as protocol logic, not socket plumbing.
370
+ */
371
+ /**
372
+ * Backoff for automatic reconnects after an unexpected close (partysocket):
373
+ * exponential from 1s, capped at 15s, giving up after 10 attempts. Applies
374
+ * only to the default socket implementation — an injected
375
+ * `options.WebSocket` (tests) never reconnects on its own.
376
+ */
377
+ const RECONNECT_OPTIONS = {
378
+ minReconnectionDelay: 1e3,
379
+ maxReconnectionDelay: 15e3,
380
+ reconnectionDelayGrowFactor: 2,
381
+ maxRetries: 10
382
+ };
383
+ /**
384
+ * Open partysocket's reconnecting WebSocket. The URL is a *provider*,
385
+ * re-evaluated on every attempt, so each retry picks up the current resume
386
+ * URL rather than the one the session started with.
387
+ */
388
+ function openReconnectingSocket(urlProvider) {
389
+ return new ReconnectingWebSocket(urlProvider, void 0, RECONNECT_OPTIONS);
390
+ }
391
+ /**
392
+ * True while `socket` is a reconnecting socket that will retry after the
393
+ * close event currently being handled. partysocket schedules the retry
394
+ * *before* dispatching `close`, so `retryCount` already names the attempt
395
+ * just scheduled — at `maxRetries` it has given up.
396
+ */
397
+ function reconnectPending(socket) {
398
+ return socket instanceof ReconnectingWebSocket && socket.shouldReconnect && socket.retryCount < RECONNECT_OPTIONS.maxRetries;
248
399
  }
249
400
  //#endregion
250
401
  //#region session-core-upload.ts
@@ -325,6 +476,16 @@ function createUploadSender(deps) {
325
476
  };
326
477
  }
327
478
  //#endregion
479
+ //#region session-core-url.ts
480
+ /** Build the session WebSocket URL from the platform URL and resume state. */
481
+ function buildWsUrl(platformUrl, resume, sessionId) {
482
+ const wsUrl = new URL("websocket", platformUrl.endsWith("/") ? platformUrl : `${platformUrl}/`);
483
+ wsUrl.protocol = wsUrl.protocol === "https:" ? "wss:" : "ws:";
484
+ if (sessionId) wsUrl.searchParams.set("sessionId", sessionId);
485
+ else if (resume) wsUrl.searchParams.set("resume", "1");
486
+ return wsUrl;
487
+ }
488
+ //#endregion
328
489
  //#region session-core.ts
329
490
  /**
330
491
  * Framework-agnostic voice session core.
@@ -339,82 +500,6 @@ function createUploadSender(deps) {
339
500
  * No dependency on React, Preact, or any UI framework.
340
501
  */
341
502
  /**
342
- * Initialize audio capture and playback after the server sends a ready config.
343
- *
344
- * Lifecycle: dynamically import audio modules -> request microphone access ->
345
- * register AudioWorklet processors -> create a `VoiceIO` instance -> send
346
- * `audio_ready` to the server -> transition state to `"listening"`.
347
- *
348
- * Uses the connection `generation` counter to detect if `connect()` was called
349
- * while awaiting async operations; if so, the stale VoiceIO is closed immediately
350
- * to prevent it from being assigned to a newer connection.
351
- *
352
- * On failure (e.g. microphone permission denied, WebSocket closed mid-setup),
353
- * sets the error state and transitions to `"disconnected"`.
354
- */
355
- async function initAudioCapture(conn, msg, deps) {
356
- if (conn.audioSetupInFlight) return;
357
- conn.audioSetupInFlight = true;
358
- const gen = conn.generation;
359
- try {
360
- const [{ createVoiceIO }, captureWorklet, playbackWorklet] = await Promise.all([
361
- import("./audio.js"),
362
- import("./worklets/capture-processor.js").then((m) => m.default),
363
- import("./worklets/playback-processor.js").then((m) => m.default)
364
- ]);
365
- const io = await createVoiceIO({
366
- sttSampleRate: msg.sampleRate,
367
- ttsSampleRate: msg.ttsSampleRate,
368
- captureWorkletSrc: captureWorklet,
369
- playbackWorkletSrc: playbackWorklet,
370
- onMicData: (pcm16) => {
371
- try {
372
- deps.sendAudio(new Uint8Array(pcm16));
373
- } catch {
374
- console.debug("[aai-ui] sendAudio dropped: connection closed");
375
- }
376
- }
377
- });
378
- if (conn.generation !== gen || !conn.ws || conn.ws.readyState !== WS_OPEN) {
379
- io.close();
380
- return;
381
- }
382
- conn.voiceIO = io;
383
- if (conn.preInitAudio.length > 0) {
384
- for (const chunk of conn.preInitAudio) io.enqueue(chunk.buffer);
385
- conn.preInitAudio = [];
386
- }
387
- deps.sendJson({ type: "audio_ready" });
388
- deps.updateState({ recording: true });
389
- if (conn.preInitDone) {
390
- conn.preInitDone = false;
391
- io.done().then(() => {
392
- if (conn.generation !== gen) return;
393
- deps.updateState({ state: "listening" });
394
- }).catch(() => deps.updateState({ state: "listening" }));
395
- } else deps.updateState({ state: "listening" });
396
- } catch (err) {
397
- if (conn.generation !== gen || !conn.ws || conn.ws.readyState !== WS_OPEN) return;
398
- deps.updateState({
399
- state: "error",
400
- error: {
401
- code: "audio",
402
- message: `Microphone access failed: ${errorMessage(err)}`
403
- },
404
- running: false
405
- });
406
- } finally {
407
- conn.audioSetupInFlight = false;
408
- }
409
- }
410
- function buildWsUrl(platformUrl, resume, sessionId) {
411
- const wsUrl = new URL("websocket", platformUrl.endsWith("/") ? platformUrl : `${platformUrl}/`);
412
- wsUrl.protocol = wsUrl.protocol === "https:" ? "wss:" : "ws:";
413
- if (sessionId) wsUrl.searchParams.set("sessionId", sessionId);
414
- else if (resume) wsUrl.searchParams.set("resume", "1");
415
- return wsUrl;
416
- }
417
- /**
418
503
  * Create a framework-agnostic voice session core that connects to an AAI
419
504
  * server via WebSocket.
420
505
  *
@@ -427,7 +512,6 @@ function buildWsUrl(platformUrl, resume, sessionId) {
427
512
  * @public
428
513
  */
429
514
  function createSessionCore(options) {
430
- const WS = options.WebSocket ?? WebSocket;
431
515
  let currentSnapshot = {
432
516
  ...CLEARED_SESSION_STATE,
433
517
  state: "disconnected",
@@ -483,7 +567,7 @@ function createSessionCore(options) {
483
567
  function cleanupAudio() {
484
568
  upload.discard();
485
569
  conn.audioSetupInFlight = false;
486
- conn.voiceIO?.close();
570
+ conn.voiceIO?.close().catch(() => {});
487
571
  conn.voiceIO = null;
488
572
  conn.preInitAudio = [];
489
573
  conn.preInitDone = false;
@@ -499,21 +583,25 @@ function createSessionCore(options) {
499
583
  if (conn.ws.bufferedAmount > 65536) return;
500
584
  conn.ws.send(bytes);
501
585
  }
502
- const audioDeps = {
503
- sendJson,
504
- sendAudio,
505
- updateState
506
- };
507
586
  const upload = createUploadSender({
508
587
  conn,
509
588
  getSnapshot,
510
589
  sendJson
511
590
  });
512
- const { handleMessage } = createMessageHandlers({
591
+ const { handleMessage, settleWhenAudioDrained } = createMessageHandlers({
513
592
  getSnapshot,
514
593
  updateState,
515
- conn
594
+ conn,
595
+ discardUpload: upload.discard,
596
+ cleanupAudio
516
597
  });
598
+ const audioDeps = {
599
+ sendJson,
600
+ sendAudio,
601
+ updateState,
602
+ settleWhenAudioDrained,
603
+ cleanupAudio
604
+ };
517
605
  /** Abort the in-flight connection and release audio + WebSocket resources. */
518
606
  function teardownConnection() {
519
607
  connectionController?.abort();
@@ -534,7 +622,7 @@ function createSessionCore(options) {
534
622
  };
535
623
  const audioOut = config.audioOut !== false;
536
624
  updateState({ audioOut });
537
- if (audioOut) initAudioCapture(conn, config, audioDeps);
625
+ if (audioOut) initAudioCapture(conn, config, audioDeps, true);
538
626
  else {
539
627
  sendJson({ type: "audio_ready" });
540
628
  updateState({ state: "listening" });
@@ -547,7 +635,27 @@ function createSessionCore(options) {
547
635
  }))
548
636
  });
549
637
  }
638
+ /**
639
+ * The WebSocket URL for the *next* connection attempt. Evaluated per
640
+ * attempt (partysocket takes it as a URL provider), so once the first
641
+ * `config` arrives, every reconnect — automatic or explicit — carries
642
+ * `resume=1` and the session resumes instead of starting over.
643
+ */
644
+ function currentWsUrl() {
645
+ const resumeId = !hasConnected ? options.resumeSessionId : void 0;
646
+ return buildWsUrl(options.platformUrl, hasConnected, resumeId).toString();
647
+ }
648
+ /** Open a socket: an injected constructor as-is (tests), or partysocket's
649
+ * reconnecting WebSocket — same interface, plus reconnect-on-close. */
650
+ function openSocket() {
651
+ if (options.WebSocket) return new options.WebSocket(currentWsUrl());
652
+ return openReconnectingSocket(currentWsUrl);
653
+ }
550
654
  function connect(opts) {
655
+ if (opts?.signal?.aborted) {
656
+ disconnect();
657
+ return;
658
+ }
551
659
  updateState({
552
660
  state: "connecting",
553
661
  error: null
@@ -558,11 +666,10 @@ function createSessionCore(options) {
558
666
  connectionController = controller;
559
667
  const { signal: sig } = controller;
560
668
  if (opts?.signal) opts.signal.addEventListener("abort", () => disconnect(), { signal: sig });
561
- const resumeId = !hasConnected ? options.resumeSessionId : void 0;
562
- const wsUrl = buildWsUrl(options.platformUrl, hasConnected, resumeId);
563
- const socket = new WS(wsUrl.toString());
669
+ const socket = openSocket();
564
670
  socket.binaryType = "arraybuffer";
565
671
  conn.ws = socket;
672
+ let socketErrored = false;
566
673
  socket.addEventListener("open", () => {
567
674
  updateState({ state: "ready" });
568
675
  }, { signal: sig });
@@ -570,18 +677,47 @@ function createSessionCore(options) {
570
677
  const config = handleMessage(event.data);
571
678
  if (config) onServerConfig(config);
572
679
  }, { signal: sig });
680
+ socket.addEventListener("error", () => {
681
+ socketErrored = true;
682
+ }, { signal: sig });
573
683
  socket.addEventListener("close", () => {
574
684
  if (sig.aborted) return;
575
- controller.abort();
576
685
  cleanupAudio();
577
- updateState({
686
+ if (reconnectPending(socket)) {
687
+ conn.generation++;
688
+ socketErrored = false;
689
+ updateState({
690
+ state: "connecting",
691
+ recording: false
692
+ });
693
+ return;
694
+ }
695
+ controller.abort();
696
+ socket.close();
697
+ conn.ws = null;
698
+ if (socketErrored) updateState({
699
+ state: "error",
700
+ error: {
701
+ code: "connection",
702
+ message: "WebSocket connection error"
703
+ },
704
+ running: false,
705
+ recording: false
706
+ });
707
+ else if (currentSnapshot.state === "error") updateState({
708
+ running: false,
709
+ recording: false
710
+ });
711
+ else updateState({
578
712
  state: "disconnected",
713
+ error: null,
579
714
  running: false,
580
715
  recording: false
581
716
  });
582
717
  }, { signal: sig });
583
718
  }
584
719
  function cancel() {
720
+ if (!conn.ws || conn.ws.readyState !== WS_OPEN) return;
585
721
  conn.voiceIO?.flush();
586
722
  updateState({ state: "listening" });
587
723
  sendJson({ type: "cancel" });
@@ -595,6 +731,7 @@ function createSessionCore(options) {
595
731
  }
596
732
  resetState();
597
733
  disconnect();
734
+ updateState({ running: true });
598
735
  connect();
599
736
  }
600
737
  function disconnect() {
@@ -609,7 +746,7 @@ function createSessionCore(options) {
609
746
  if (currentSnapshot.audioOut || currentSnapshot.recording || conn.audioSetupInFlight || upload.inFlight()) return;
610
747
  const cfg = conn.readyConfig;
611
748
  if (!(cfg && conn.ws) || conn.ws.readyState !== WS_OPEN) return;
612
- initAudioCapture(conn, cfg, audioDeps);
749
+ initAudioCapture(conn, cfg, audioDeps, false);
613
750
  }
614
751
  function stopRecording() {
615
752
  if (currentSnapshot.audioOut || !currentSnapshot.recording) return;
@@ -0,0 +1,36 @@
1
+ import type { ClientMessage } from "@alexkroman1/aai/protocol";
2
+ import type { VoiceIO } from "./audio.ts";
3
+ import type { ConnState, SessionSnapshot } from "./session-core-types.ts";
4
+ /** Dependencies `initAudioCapture` needs from the owning session core. */
5
+ export type AudioSetupDeps = {
6
+ sendJson: (msg: ClientMessage) => void;
7
+ sendAudio: (bytes: Uint8Array) => void;
8
+ updateState: (partial: Partial<SessionSnapshot>) => void;
9
+ /** Turn-boundary-guarded drain from the message handlers — replays a
10
+ * buffered `audio_done` without stomping a barge-in's state. */
11
+ settleWhenAudioDrained: (io: VoiceIO) => void;
12
+ /** Release the mic/VoiceIO (used when the audio path dies non-fatally). */
13
+ cleanupAudio: () => void;
14
+ };
15
+ /**
16
+ * Initialize audio capture and playback after the server sends a ready config.
17
+ *
18
+ * Lifecycle: dynamically import audio modules -> request microphone access ->
19
+ * register AudioWorklet processors -> create a `VoiceIO` instance -> send
20
+ * `audio_ready` to the server -> transition state to `"listening"`.
21
+ *
22
+ * Uses the connection `generation` counter to detect if `connect()` was called
23
+ * (or a reconnect happened) while awaiting async operations; if so, the stale
24
+ * VoiceIO is closed immediately to prevent it from being assigned to a newer
25
+ * connection.
26
+ *
27
+ * `fatal` selects the failure mode: voice sessions (`config` handshake) can't
28
+ * function without the mic, so failure there sets the error state and ends
29
+ * the session; a text-only session's opt-in record button must not brick an
30
+ * otherwise healthy session, so failure there is a banner (`error` set,
31
+ * state kept) and the session stays interactive.
32
+ */
33
+ export declare function initAudioCapture(conn: ConnState, msg: {
34
+ sampleRate: number;
35
+ ttsSampleRate: number;
36
+ }, deps: AudioSetupDeps, fatal: boolean): Promise<void>;