@absolutejs/voice 0.0.22-beta.653 → 0.0.22-beta.654

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.
@@ -455,8 +455,11 @@ var serverMessageToAction = (message) => {
455
455
  case "error":
456
456
  return {
457
457
  message: normalizeErrorMessage(message.message),
458
+ recoverable: message.recoverable,
458
459
  type: "error"
459
460
  };
461
+ case "call_control_ack":
462
+ return null;
460
463
  case "final":
461
464
  return {
462
465
  transcript: message.transcript,
@@ -584,14 +587,17 @@ var WS_FORBIDDEN = 4403;
584
587
  var DEFAULT_MAX_RECONNECT_ATTEMPTS = 15;
585
588
  var DEFAULT_PING_INTERVAL = 30000;
586
589
  var DEFAULT_RECONNECT_RESET_AFTER_MS = 30000;
590
+ var DEFAULT_CALL_CONTROL_ACK_TIMEOUT_MS = 5000;
591
+ var MAX_PENDING_CONTROL_MESSAGES = 100;
587
592
  var RECONNECT_BASE_DELAY_MS = 500;
588
593
  var DEFAULT_RECONNECT_MAX_DELAY_MS = 8000;
589
594
  var computeVoiceReconnectDelayMs = (attempt, baseMs, maxDelayMs) => Math.min(maxDelayMs, baseMs * 2 ** (Math.max(1, attempt) - 1));
590
595
  var DEFAULT_SCENARIO_QUERY_PARAM = "scenarioId";
591
596
  var noop = () => {};
597
+ var noopAsync = () => Promise.resolve();
592
598
  var noopUnsubscribe = () => noop;
593
599
  var NOOP_CONNECTION = {
594
- callControl: noop,
600
+ callControl: noopAsync,
595
601
  close: noop,
596
602
  disconnect: noop,
597
603
  endTurn: noop,
@@ -629,6 +635,7 @@ var isVoiceServerMessage = (value) => {
629
635
  case "audio":
630
636
  case "assistant":
631
637
  case "call_lifecycle":
638
+ case "call_control_ack":
632
639
  case "complete":
633
640
  case "connection":
634
641
  case "error":
@@ -659,6 +666,7 @@ var createVoiceConnection = (path, options = {}) => {
659
666
  return NOOP_CONNECTION;
660
667
  }
661
668
  const listeners = new Set;
669
+ const pendingCallControls = new Map;
662
670
  const shouldReconnect = options.reconnect !== false;
663
671
  const maxReconnectAttempts = options.maxReconnectAttempts ?? DEFAULT_MAX_RECONNECT_ATTEMPTS;
664
672
  const reconnectMaxDelayMs = options.reconnectMaxDelayMs ?? DEFAULT_RECONNECT_MAX_DELAY_MS;
@@ -704,6 +712,13 @@ var createVoiceConnection = (path, options = {}) => {
704
712
  }
705
713
  }
706
714
  };
715
+ const rejectPendingCallControls = (message) => {
716
+ for (const pending of pendingCallControls.values()) {
717
+ clearTimeout(pending.timeout);
718
+ pending.reject(new Error(message));
719
+ }
720
+ pendingCallControls.clear();
721
+ };
707
722
  const scheduleReconnect = () => {
708
723
  state.reconnectAttempts += 1;
709
724
  const delayMs = computeReconnectDelayMs(state.reconnectAttempts);
@@ -718,7 +733,7 @@ var createVoiceConnection = (path, options = {}) => {
718
733
  },
719
734
  type: "connection"
720
735
  });
721
- state.reconnectTimeout = setTimeout(() => {
736
+ state.reconnectTimeout = setTimeout(async () => {
722
737
  if (state.reconnectAttempts > maxReconnectAttempts) {
723
738
  emitConnection({
724
739
  reconnect: {
@@ -730,6 +745,28 @@ var createVoiceConnection = (path, options = {}) => {
730
745
  });
731
746
  return;
732
747
  }
748
+ try {
749
+ await options.prepareReconnect?.({
750
+ attempt: state.reconnectAttempts,
751
+ path,
752
+ scenarioId: state.scenarioId,
753
+ sessionId: state.sessionId
754
+ });
755
+ } catch {
756
+ if (state.reconnectAttempts < maxReconnectAttempts) {
757
+ scheduleReconnect();
758
+ } else {
759
+ emitConnection({
760
+ reconnect: {
761
+ attempts: state.reconnectAttempts,
762
+ maxAttempts: maxReconnectAttempts,
763
+ status: "exhausted"
764
+ },
765
+ type: "connection"
766
+ });
767
+ }
768
+ return;
769
+ }
733
770
  connect();
734
771
  }, delayMs);
735
772
  };
@@ -784,6 +821,18 @@ var createVoiceConnection = (path, options = {}) => {
784
821
  state.sessionId = parsed.sessionId;
785
822
  state.scenarioId = parsed.scenarioId ?? state.scenarioId;
786
823
  }
824
+ if (parsed.type === "call_control_ack") {
825
+ const pending = pendingCallControls.get(parsed.requestId);
826
+ if (pending) {
827
+ clearTimeout(pending.timeout);
828
+ pendingCallControls.delete(parsed.requestId);
829
+ if (parsed.ok) {
830
+ pending.resolve();
831
+ } else {
832
+ pending.reject(new Error(parsed.message ?? `${parsed.action} was not applied`));
833
+ }
834
+ }
835
+ }
787
836
  listeners.forEach((listener) => listener(parsed));
788
837
  };
789
838
  ws.onclose = (event) => {
@@ -793,6 +842,14 @@ var createVoiceConnection = (path, options = {}) => {
793
842
  state.isConnected = false;
794
843
  clearTimers();
795
844
  const reconnectable = shouldReconnect && event.code !== WS_NORMAL_CLOSURE && event.code !== WS_UNAUTHORIZED && event.code !== WS_FORBIDDEN && state.reconnectAttempts < maxReconnectAttempts;
845
+ const authorizationFailure = event.code === WS_UNAUTHORIZED || event.code === WS_FORBIDDEN;
846
+ if (authorizationFailure) {
847
+ listeners.forEach((listener) => listener({
848
+ message: event.code === WS_UNAUTHORIZED ? "Voice authorization expired or was rejected." : "Voice authorization was forbidden.",
849
+ recoverable: false,
850
+ type: "error"
851
+ }));
852
+ }
796
853
  if (reconnectable) {
797
854
  scheduleReconnect();
798
855
  } else if (shouldReconnect && event.code !== WS_NORMAL_CLOSURE) {
@@ -814,6 +871,12 @@ var createVoiceConnection = (path, options = {}) => {
814
871
  state.ws.send(value);
815
872
  return;
816
873
  }
874
+ if (typeof value !== "string") {
875
+ return;
876
+ }
877
+ if (state.pendingMessages.length >= MAX_PENDING_CONTROL_MESSAGES) {
878
+ state.pendingMessages.shift();
879
+ }
817
880
  state.pendingMessages.push(value);
818
881
  };
819
882
  const send = (message) => {
@@ -839,13 +902,23 @@ var createVoiceConnection = (path, options = {}) => {
839
902
  send({ type: "end_turn" });
840
903
  };
841
904
  const callControl = (message) => {
905
+ const requestId = crypto.randomUUID();
906
+ const { promise, reject, resolve } = Promise.withResolvers();
907
+ const timeout = setTimeout(() => {
908
+ pendingCallControls.delete(requestId);
909
+ reject(new Error(`${message.action} acknowledgement timed out`));
910
+ }, DEFAULT_CALL_CONTROL_ACK_TIMEOUT_MS);
911
+ pendingCallControls.set(requestId, { reject, resolve, timeout });
842
912
  send({
843
913
  ...message,
914
+ requestId,
844
915
  type: "call_control"
845
916
  });
917
+ return promise;
846
918
  };
847
919
  const close = (reason = "client-close") => {
848
920
  clearTimers();
921
+ rejectPendingCallControls("Voice connection closed");
849
922
  if (state.ws) {
850
923
  const ws = state.ws;
851
924
  state.ws = null;
@@ -859,6 +932,7 @@ var createVoiceConnection = (path, options = {}) => {
859
932
  };
860
933
  const disconnect = () => {
861
934
  clearTimers();
935
+ rejectPendingCallControls("Voice connection disconnected");
862
936
  if (state.ws) {
863
937
  const ws = state.ws;
864
938
  state.ws = null;
@@ -1021,7 +1095,8 @@ var createVoiceStreamStore = () => {
1021
1095
  case "error":
1022
1096
  state = {
1023
1097
  ...state,
1024
- error: action.message
1098
+ error: action.message,
1099
+ ...action.recoverable === false ? { isConnected: false, status: "failed" } : {}
1025
1100
  };
1026
1101
  break;
1027
1102
  case "final":
@@ -1161,7 +1236,7 @@ var createVoiceStream = (path, options = {}) => {
1161
1236
  return store.getSnapshot().call;
1162
1237
  },
1163
1238
  callControl(message) {
1164
- connection.callControl(message);
1239
+ return connection.callControl(message);
1165
1240
  },
1166
1241
  close(reason) {
1167
1242
  unsubscribeConnection();
@@ -10,7 +10,7 @@ export declare class VoiceStreamService {
10
10
  assistantStreamingText: import("@angular/core").Signal<string>;
11
11
  assistantTexts: import("@angular/core").Signal<string[]>;
12
12
  call: import("@angular/core").Signal<import("..").VoiceCallLifecycleState | null>;
13
- callControl: (message: Parameters<(message: Omit<import("..").VoiceClientCallControlMessage, "type">) => void>[0]) => void;
13
+ callControl: (message: Parameters<(message: Omit<import("..").VoiceClientCallControlMessage, "requestId" | "type">) => Promise<void>>[0]) => Promise<void>;
14
14
  close: () => void;
15
15
  endTurn: () => void;
16
16
  error: import("@angular/core").Signal<string | null>;
@@ -11,6 +11,7 @@ export declare const serverMessageToAction: <TResult = unknown>(message: VoiceSe
11
11
  reconnect?: undefined;
12
12
  event?: undefined;
13
13
  message?: undefined;
14
+ recoverable?: undefined;
14
15
  transcript?: undefined;
15
16
  assistantTexts?: undefined;
16
17
  call?: undefined;
@@ -32,6 +33,7 @@ export declare const serverMessageToAction: <TResult = unknown>(message: VoiceSe
32
33
  reconnect?: undefined;
33
34
  event?: undefined;
34
35
  message?: undefined;
36
+ recoverable?: undefined;
35
37
  transcript?: undefined;
36
38
  assistantTexts?: undefined;
37
39
  call?: undefined;
@@ -53,6 +55,7 @@ export declare const serverMessageToAction: <TResult = unknown>(message: VoiceSe
53
55
  reconnect?: undefined;
54
56
  event?: undefined;
55
57
  message?: undefined;
58
+ recoverable?: undefined;
56
59
  transcript?: undefined;
57
60
  assistantTexts?: undefined;
58
61
  call?: undefined;
@@ -74,6 +77,7 @@ export declare const serverMessageToAction: <TResult = unknown>(message: VoiceSe
74
77
  reconnect?: undefined;
75
78
  event?: undefined;
76
79
  message?: undefined;
80
+ recoverable?: undefined;
77
81
  transcript?: undefined;
78
82
  assistantTexts?: undefined;
79
83
  call?: undefined;
@@ -95,6 +99,7 @@ export declare const serverMessageToAction: <TResult = unknown>(message: VoiceSe
95
99
  sessionId?: undefined;
96
100
  event?: undefined;
97
101
  message?: undefined;
102
+ recoverable?: undefined;
98
103
  transcript?: undefined;
99
104
  assistantTexts?: undefined;
100
105
  call?: undefined;
@@ -116,6 +121,7 @@ export declare const serverMessageToAction: <TResult = unknown>(message: VoiceSe
116
121
  delta?: undefined;
117
122
  reconnect?: undefined;
118
123
  message?: undefined;
124
+ recoverable?: undefined;
119
125
  transcript?: undefined;
120
126
  assistantTexts?: undefined;
121
127
  call?: undefined;
@@ -127,6 +133,7 @@ export declare const serverMessageToAction: <TResult = unknown>(message: VoiceSe
127
133
  turn?: undefined;
128
134
  } | {
129
135
  message: string;
136
+ recoverable: boolean | undefined;
130
137
  type: "error";
131
138
  chunk?: undefined;
132
139
  format?: undefined;
@@ -159,6 +166,7 @@ export declare const serverMessageToAction: <TResult = unknown>(message: VoiceSe
159
166
  reconnect?: undefined;
160
167
  event?: undefined;
161
168
  message?: undefined;
169
+ recoverable?: undefined;
162
170
  assistantTexts?: undefined;
163
171
  call?: undefined;
164
172
  partial?: undefined;
@@ -180,6 +188,7 @@ export declare const serverMessageToAction: <TResult = unknown>(message: VoiceSe
180
188
  reconnect?: undefined;
181
189
  event?: undefined;
182
190
  message?: undefined;
191
+ recoverable?: undefined;
183
192
  assistantTexts?: undefined;
184
193
  call?: undefined;
185
194
  partial?: undefined;
@@ -207,6 +216,7 @@ export declare const serverMessageToAction: <TResult = unknown>(message: VoiceSe
207
216
  reconnect?: undefined;
208
217
  event?: undefined;
209
218
  message?: undefined;
219
+ recoverable?: undefined;
210
220
  transcript?: undefined;
211
221
  turn?: undefined;
212
222
  } | {
@@ -224,6 +234,7 @@ export declare const serverMessageToAction: <TResult = unknown>(message: VoiceSe
224
234
  reconnect?: undefined;
225
235
  event?: undefined;
226
236
  message?: undefined;
237
+ recoverable?: undefined;
227
238
  transcript?: undefined;
228
239
  assistantTexts?: undefined;
229
240
  call?: undefined;
@@ -243,6 +254,7 @@ export declare const serverMessageToAction: <TResult = unknown>(message: VoiceSe
243
254
  reconnect?: undefined;
244
255
  event?: undefined;
245
256
  message?: undefined;
257
+ recoverable?: undefined;
246
258
  transcript?: undefined;
247
259
  assistantTexts?: undefined;
248
260
  call?: undefined;
@@ -6,7 +6,7 @@ export declare const computeVoiceReconnectDelayMs: (attempt: number, baseMs: num
6
6
  type VoiceConnectionHandle = {
7
7
  callControl: (message: Omit<VoiceClientMessage & {
8
8
  type: "call_control";
9
- }, "type">) => void;
9
+ }, "requestId" | "type">) => Promise<void>;
10
10
  start: (input?: {
11
11
  sessionId?: string;
12
12
  scenarioId?: string;
@@ -223,8 +223,11 @@ var serverMessageToAction = (message) => {
223
223
  case "error":
224
224
  return {
225
225
  message: normalizeErrorMessage(message.message),
226
+ recoverable: message.recoverable,
226
227
  type: "error"
227
228
  };
229
+ case "call_control_ack":
230
+ return null;
228
231
  case "final":
229
232
  return {
230
233
  transcript: message.transcript,
@@ -501,14 +504,17 @@ var WS_FORBIDDEN = 4403;
501
504
  var DEFAULT_MAX_RECONNECT_ATTEMPTS = 15;
502
505
  var DEFAULT_PING_INTERVAL = 30000;
503
506
  var DEFAULT_RECONNECT_RESET_AFTER_MS = 30000;
507
+ var DEFAULT_CALL_CONTROL_ACK_TIMEOUT_MS = 5000;
508
+ var MAX_PENDING_CONTROL_MESSAGES = 100;
504
509
  var RECONNECT_BASE_DELAY_MS = 500;
505
510
  var DEFAULT_RECONNECT_MAX_DELAY_MS = 8000;
506
511
  var computeVoiceReconnectDelayMs = (attempt, baseMs, maxDelayMs) => Math.min(maxDelayMs, baseMs * 2 ** (Math.max(1, attempt) - 1));
507
512
  var DEFAULT_SCENARIO_QUERY_PARAM = "scenarioId";
508
513
  var noop = () => {};
514
+ var noopAsync = () => Promise.resolve();
509
515
  var noopUnsubscribe = () => noop;
510
516
  var NOOP_CONNECTION = {
511
- callControl: noop,
517
+ callControl: noopAsync,
512
518
  close: noop,
513
519
  disconnect: noop,
514
520
  endTurn: noop,
@@ -546,6 +552,7 @@ var isVoiceServerMessage = (value) => {
546
552
  case "audio":
547
553
  case "assistant":
548
554
  case "call_lifecycle":
555
+ case "call_control_ack":
549
556
  case "complete":
550
557
  case "connection":
551
558
  case "error":
@@ -576,6 +583,7 @@ var createVoiceConnection = (path, options = {}) => {
576
583
  return NOOP_CONNECTION;
577
584
  }
578
585
  const listeners = new Set;
586
+ const pendingCallControls = new Map;
579
587
  const shouldReconnect = options.reconnect !== false;
580
588
  const maxReconnectAttempts = options.maxReconnectAttempts ?? DEFAULT_MAX_RECONNECT_ATTEMPTS;
581
589
  const reconnectMaxDelayMs = options.reconnectMaxDelayMs ?? DEFAULT_RECONNECT_MAX_DELAY_MS;
@@ -621,6 +629,13 @@ var createVoiceConnection = (path, options = {}) => {
621
629
  }
622
630
  }
623
631
  };
632
+ const rejectPendingCallControls = (message) => {
633
+ for (const pending of pendingCallControls.values()) {
634
+ clearTimeout(pending.timeout);
635
+ pending.reject(new Error(message));
636
+ }
637
+ pendingCallControls.clear();
638
+ };
624
639
  const scheduleReconnect = () => {
625
640
  state.reconnectAttempts += 1;
626
641
  const delayMs = computeReconnectDelayMs(state.reconnectAttempts);
@@ -635,7 +650,7 @@ var createVoiceConnection = (path, options = {}) => {
635
650
  },
636
651
  type: "connection"
637
652
  });
638
- state.reconnectTimeout = setTimeout(() => {
653
+ state.reconnectTimeout = setTimeout(async () => {
639
654
  if (state.reconnectAttempts > maxReconnectAttempts) {
640
655
  emitConnection({
641
656
  reconnect: {
@@ -647,6 +662,28 @@ var createVoiceConnection = (path, options = {}) => {
647
662
  });
648
663
  return;
649
664
  }
665
+ try {
666
+ await options.prepareReconnect?.({
667
+ attempt: state.reconnectAttempts,
668
+ path,
669
+ scenarioId: state.scenarioId,
670
+ sessionId: state.sessionId
671
+ });
672
+ } catch {
673
+ if (state.reconnectAttempts < maxReconnectAttempts) {
674
+ scheduleReconnect();
675
+ } else {
676
+ emitConnection({
677
+ reconnect: {
678
+ attempts: state.reconnectAttempts,
679
+ maxAttempts: maxReconnectAttempts,
680
+ status: "exhausted"
681
+ },
682
+ type: "connection"
683
+ });
684
+ }
685
+ return;
686
+ }
650
687
  connect();
651
688
  }, delayMs);
652
689
  };
@@ -701,6 +738,18 @@ var createVoiceConnection = (path, options = {}) => {
701
738
  state.sessionId = parsed.sessionId;
702
739
  state.scenarioId = parsed.scenarioId ?? state.scenarioId;
703
740
  }
741
+ if (parsed.type === "call_control_ack") {
742
+ const pending = pendingCallControls.get(parsed.requestId);
743
+ if (pending) {
744
+ clearTimeout(pending.timeout);
745
+ pendingCallControls.delete(parsed.requestId);
746
+ if (parsed.ok) {
747
+ pending.resolve();
748
+ } else {
749
+ pending.reject(new Error(parsed.message ?? `${parsed.action} was not applied`));
750
+ }
751
+ }
752
+ }
704
753
  listeners.forEach((listener) => listener(parsed));
705
754
  };
706
755
  ws.onclose = (event) => {
@@ -710,6 +759,14 @@ var createVoiceConnection = (path, options = {}) => {
710
759
  state.isConnected = false;
711
760
  clearTimers();
712
761
  const reconnectable = shouldReconnect && event.code !== WS_NORMAL_CLOSURE && event.code !== WS_UNAUTHORIZED && event.code !== WS_FORBIDDEN && state.reconnectAttempts < maxReconnectAttempts;
762
+ const authorizationFailure = event.code === WS_UNAUTHORIZED || event.code === WS_FORBIDDEN;
763
+ if (authorizationFailure) {
764
+ listeners.forEach((listener) => listener({
765
+ message: event.code === WS_UNAUTHORIZED ? "Voice authorization expired or was rejected." : "Voice authorization was forbidden.",
766
+ recoverable: false,
767
+ type: "error"
768
+ }));
769
+ }
713
770
  if (reconnectable) {
714
771
  scheduleReconnect();
715
772
  } else if (shouldReconnect && event.code !== WS_NORMAL_CLOSURE) {
@@ -731,6 +788,12 @@ var createVoiceConnection = (path, options = {}) => {
731
788
  state.ws.send(value);
732
789
  return;
733
790
  }
791
+ if (typeof value !== "string") {
792
+ return;
793
+ }
794
+ if (state.pendingMessages.length >= MAX_PENDING_CONTROL_MESSAGES) {
795
+ state.pendingMessages.shift();
796
+ }
734
797
  state.pendingMessages.push(value);
735
798
  };
736
799
  const send = (message) => {
@@ -756,13 +819,23 @@ var createVoiceConnection = (path, options = {}) => {
756
819
  send({ type: "end_turn" });
757
820
  };
758
821
  const callControl = (message) => {
822
+ const requestId = crypto.randomUUID();
823
+ const { promise, reject, resolve } = Promise.withResolvers();
824
+ const timeout = setTimeout(() => {
825
+ pendingCallControls.delete(requestId);
826
+ reject(new Error(`${message.action} acknowledgement timed out`));
827
+ }, DEFAULT_CALL_CONTROL_ACK_TIMEOUT_MS);
828
+ pendingCallControls.set(requestId, { reject, resolve, timeout });
759
829
  send({
760
830
  ...message,
831
+ requestId,
761
832
  type: "call_control"
762
833
  });
834
+ return promise;
763
835
  };
764
836
  const close = (reason = "client-close") => {
765
837
  clearTimers();
838
+ rejectPendingCallControls("Voice connection closed");
766
839
  if (state.ws) {
767
840
  const ws = state.ws;
768
841
  state.ws = null;
@@ -776,6 +849,7 @@ var createVoiceConnection = (path, options = {}) => {
776
849
  };
777
850
  const disconnect = () => {
778
851
  clearTimers();
852
+ rejectPendingCallControls("Voice connection disconnected");
779
853
  if (state.ws) {
780
854
  const ws = state.ws;
781
855
  state.ws = null;
@@ -938,7 +1012,8 @@ var createVoiceStreamStore = () => {
938
1012
  case "error":
939
1013
  state = {
940
1014
  ...state,
941
- error: action.message
1015
+ error: action.message,
1016
+ ...action.recoverable === false ? { isConnected: false, status: "failed" } : {}
942
1017
  };
943
1018
  break;
944
1019
  case "final":
@@ -1078,7 +1153,7 @@ var createVoiceStream = (path, options = {}) => {
1078
1153
  return store.getSnapshot().call;
1079
1154
  },
1080
1155
  callControl(message) {
1081
- connection.callControl(message);
1156
+ return connection.callControl(message);
1082
1157
  },
1083
1158
  close(reason) {
1084
1159
  unsubscribeConnection();
@@ -109,14 +109,17 @@ var WS_FORBIDDEN = 4403;
109
109
  var DEFAULT_MAX_RECONNECT_ATTEMPTS = 15;
110
110
  var DEFAULT_PING_INTERVAL = 30000;
111
111
  var DEFAULT_RECONNECT_RESET_AFTER_MS = 30000;
112
+ var DEFAULT_CALL_CONTROL_ACK_TIMEOUT_MS = 5000;
113
+ var MAX_PENDING_CONTROL_MESSAGES = 100;
112
114
  var RECONNECT_BASE_DELAY_MS = 500;
113
115
  var DEFAULT_RECONNECT_MAX_DELAY_MS = 8000;
114
116
  var computeVoiceReconnectDelayMs = (attempt, baseMs, maxDelayMs) => Math.min(maxDelayMs, baseMs * 2 ** (Math.max(1, attempt) - 1));
115
117
  var DEFAULT_SCENARIO_QUERY_PARAM = "scenarioId";
116
118
  var noop = () => {};
119
+ var noopAsync = () => Promise.resolve();
117
120
  var noopUnsubscribe = () => noop;
118
121
  var NOOP_CONNECTION = {
119
- callControl: noop,
122
+ callControl: noopAsync,
120
123
  close: noop,
121
124
  disconnect: noop,
122
125
  endTurn: noop,
@@ -154,6 +157,7 @@ var isVoiceServerMessage = (value) => {
154
157
  case "audio":
155
158
  case "assistant":
156
159
  case "call_lifecycle":
160
+ case "call_control_ack":
157
161
  case "complete":
158
162
  case "connection":
159
163
  case "error":
@@ -184,6 +188,7 @@ var createVoiceConnection = (path, options = {}) => {
184
188
  return NOOP_CONNECTION;
185
189
  }
186
190
  const listeners = new Set;
191
+ const pendingCallControls = new Map;
187
192
  const shouldReconnect = options.reconnect !== false;
188
193
  const maxReconnectAttempts = options.maxReconnectAttempts ?? DEFAULT_MAX_RECONNECT_ATTEMPTS;
189
194
  const reconnectMaxDelayMs = options.reconnectMaxDelayMs ?? DEFAULT_RECONNECT_MAX_DELAY_MS;
@@ -229,6 +234,13 @@ var createVoiceConnection = (path, options = {}) => {
229
234
  }
230
235
  }
231
236
  };
237
+ const rejectPendingCallControls = (message) => {
238
+ for (const pending of pendingCallControls.values()) {
239
+ clearTimeout(pending.timeout);
240
+ pending.reject(new Error(message));
241
+ }
242
+ pendingCallControls.clear();
243
+ };
232
244
  const scheduleReconnect = () => {
233
245
  state.reconnectAttempts += 1;
234
246
  const delayMs = computeReconnectDelayMs(state.reconnectAttempts);
@@ -243,7 +255,7 @@ var createVoiceConnection = (path, options = {}) => {
243
255
  },
244
256
  type: "connection"
245
257
  });
246
- state.reconnectTimeout = setTimeout(() => {
258
+ state.reconnectTimeout = setTimeout(async () => {
247
259
  if (state.reconnectAttempts > maxReconnectAttempts) {
248
260
  emitConnection({
249
261
  reconnect: {
@@ -255,6 +267,28 @@ var createVoiceConnection = (path, options = {}) => {
255
267
  });
256
268
  return;
257
269
  }
270
+ try {
271
+ await options.prepareReconnect?.({
272
+ attempt: state.reconnectAttempts,
273
+ path,
274
+ scenarioId: state.scenarioId,
275
+ sessionId: state.sessionId
276
+ });
277
+ } catch {
278
+ if (state.reconnectAttempts < maxReconnectAttempts) {
279
+ scheduleReconnect();
280
+ } else {
281
+ emitConnection({
282
+ reconnect: {
283
+ attempts: state.reconnectAttempts,
284
+ maxAttempts: maxReconnectAttempts,
285
+ status: "exhausted"
286
+ },
287
+ type: "connection"
288
+ });
289
+ }
290
+ return;
291
+ }
258
292
  connect();
259
293
  }, delayMs);
260
294
  };
@@ -309,6 +343,18 @@ var createVoiceConnection = (path, options = {}) => {
309
343
  state.sessionId = parsed.sessionId;
310
344
  state.scenarioId = parsed.scenarioId ?? state.scenarioId;
311
345
  }
346
+ if (parsed.type === "call_control_ack") {
347
+ const pending = pendingCallControls.get(parsed.requestId);
348
+ if (pending) {
349
+ clearTimeout(pending.timeout);
350
+ pendingCallControls.delete(parsed.requestId);
351
+ if (parsed.ok) {
352
+ pending.resolve();
353
+ } else {
354
+ pending.reject(new Error(parsed.message ?? `${parsed.action} was not applied`));
355
+ }
356
+ }
357
+ }
312
358
  listeners.forEach((listener) => listener(parsed));
313
359
  };
314
360
  ws.onclose = (event) => {
@@ -318,6 +364,14 @@ var createVoiceConnection = (path, options = {}) => {
318
364
  state.isConnected = false;
319
365
  clearTimers();
320
366
  const reconnectable = shouldReconnect && event.code !== WS_NORMAL_CLOSURE && event.code !== WS_UNAUTHORIZED && event.code !== WS_FORBIDDEN && state.reconnectAttempts < maxReconnectAttempts;
367
+ const authorizationFailure = event.code === WS_UNAUTHORIZED || event.code === WS_FORBIDDEN;
368
+ if (authorizationFailure) {
369
+ listeners.forEach((listener) => listener({
370
+ message: event.code === WS_UNAUTHORIZED ? "Voice authorization expired or was rejected." : "Voice authorization was forbidden.",
371
+ recoverable: false,
372
+ type: "error"
373
+ }));
374
+ }
321
375
  if (reconnectable) {
322
376
  scheduleReconnect();
323
377
  } else if (shouldReconnect && event.code !== WS_NORMAL_CLOSURE) {
@@ -339,6 +393,12 @@ var createVoiceConnection = (path, options = {}) => {
339
393
  state.ws.send(value);
340
394
  return;
341
395
  }
396
+ if (typeof value !== "string") {
397
+ return;
398
+ }
399
+ if (state.pendingMessages.length >= MAX_PENDING_CONTROL_MESSAGES) {
400
+ state.pendingMessages.shift();
401
+ }
342
402
  state.pendingMessages.push(value);
343
403
  };
344
404
  const send = (message) => {
@@ -364,13 +424,23 @@ var createVoiceConnection = (path, options = {}) => {
364
424
  send({ type: "end_turn" });
365
425
  };
366
426
  const callControl = (message) => {
427
+ const requestId = crypto.randomUUID();
428
+ const { promise, reject, resolve } = Promise.withResolvers();
429
+ const timeout = setTimeout(() => {
430
+ pendingCallControls.delete(requestId);
431
+ reject(new Error(`${message.action} acknowledgement timed out`));
432
+ }, DEFAULT_CALL_CONTROL_ACK_TIMEOUT_MS);
433
+ pendingCallControls.set(requestId, { reject, resolve, timeout });
367
434
  send({
368
435
  ...message,
436
+ requestId,
369
437
  type: "call_control"
370
438
  });
439
+ return promise;
371
440
  };
372
441
  const close = (reason = "client-close") => {
373
442
  clearTimers();
443
+ rejectPendingCallControls("Voice connection closed");
374
444
  if (state.ws) {
375
445
  const ws = state.ws;
376
446
  state.ws = null;
@@ -384,6 +454,7 @@ var createVoiceConnection = (path, options = {}) => {
384
454
  };
385
455
  const disconnect = () => {
386
456
  clearTimers();
457
+ rejectPendingCallControls("Voice connection disconnected");
387
458
  if (state.ws) {
388
459
  const ws = state.ws;
389
460
  state.ws = null;
@@ -1219,8 +1290,11 @@ var serverMessageToAction = (message) => {
1219
1290
  case "error":
1220
1291
  return {
1221
1292
  message: normalizeErrorMessage(message.message),
1293
+ recoverable: message.recoverable,
1222
1294
  type: "error"
1223
1295
  };
1296
+ case "call_control_ack":
1297
+ return null;
1224
1298
  case "final":
1225
1299
  return {
1226
1300
  transcript: message.transcript,
@@ -1465,7 +1539,8 @@ var createVoiceStreamStore = () => {
1465
1539
  case "error":
1466
1540
  state = {
1467
1541
  ...state,
1468
- error: action.message
1542
+ error: action.message,
1543
+ ...action.recoverable === false ? { isConnected: false, status: "failed" } : {}
1469
1544
  };
1470
1545
  break;
1471
1546
  case "final":
@@ -1605,7 +1680,7 @@ var createVoiceStream = (path, options = {}) => {
1605
1680
  return store.getSnapshot().call;
1606
1681
  },
1607
1682
  callControl(message) {
1608
- connection.callControl(message);
1683
+ return connection.callControl(message);
1609
1684
  },
1610
1685
  close(reason) {
1611
1686
  unsubscribeConnection();