@lokutor/sdk 1.5.0 → 1.6.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.
package/src/client.ts CHANGED
@@ -50,6 +50,12 @@ function wsToHttp(url: string): string {
50
50
  return url.replace(/^wss:/, 'https:').replace(/^ws:/, 'http:');
51
51
  }
52
52
 
53
+ // The discovery routes live at the API's root (https://api.lokutor.com/voices). They were built from the
54
+ // agent socket's URL, so every helper requested /ws/agent/voices and got a 404.
55
+ function apiOrigin(base?: string): string {
56
+ return new URL(wsToHttp(base || DEFAULT_URLS.VOICE_AGENT)).origin;
57
+ }
58
+
53
59
  async function fetchJson<T>(url: string, timeoutMs = 10000): Promise<T> {
54
60
  const controller = new AbortController();
55
61
  const timer = setTimeout(() => controller.abort(), timeoutMs);
@@ -216,9 +222,13 @@ export class VoiceAgentClient {
216
222
  private serverInputRate: number = 16000;
217
223
 
218
224
  private serverUrl: string;
225
+ private authProvider?: () => string | Promise<string>;
226
+ private voiceExplicit = false;
227
+ private languageExplicit = false;
219
228
 
220
229
  constructor(config: LokutorConfig & {
221
- prompt: string,
230
+ /** Required unless agentId is set (a dashboard agent brings its own prompt). */
231
+ prompt?: string,
222
232
  voice?: VoiceId,
223
233
  language?: Language,
224
234
  visemes?: boolean,
@@ -227,9 +237,15 @@ export class VoiceAgentClient {
227
237
  tools?: ToolDefinition[],
228
238
  serverUrl?: string,
229
239
  }) {
230
- this.apiKey = config.apiKey;
240
+ this.apiKey = config.apiKey || "";
241
+ this.authProvider = config.auth;
231
242
  this.agentId = config.agentId || "";
232
- this.prompt = config.prompt;
243
+ this.prompt = config.prompt ?? "";
244
+ // With agentId, only what the caller set explicitly is sent: the server applies voice, language and
245
+ // prompt on top of the agent it loaded, and a default prompt replaced the agent's own (with its
246
+ // knowledge base and evaluation rules), F1/en replaced its voice and language.
247
+ this.voiceExplicit = config.voice !== undefined;
248
+ this.languageExplicit = config.language !== undefined;
233
249
  // Default stays the Versa 1.x name on purpose: the server resolves F1 to the voice of the
234
250
  // call's own language family (esca_f1 for Spanish, en_f1 for English), which adapts better
235
251
  // than pinning one explicit voice here would.
@@ -254,6 +270,15 @@ export class VoiceAgentClient {
254
270
  */
255
271
  public async connect(customAudioManager?: AudioManager): Promise<boolean> {
256
272
  this.isUserDisconnect = false;
273
+ if (this.authProvider) {
274
+ try {
275
+ this.apiKey = await this.authProvider();
276
+ } catch (err) {
277
+ const error = new LokutorError('auth.missing_key', 'The auth callback failed to return a credential', { original: err, retryable: true });
278
+ this.emit('error', error);
279
+ throw error;
280
+ }
281
+ }
257
282
 
258
283
  if (this.enableAudio || customAudioManager) {
259
284
  if (customAudioManager) {
@@ -280,11 +305,11 @@ export class VoiceAgentClient {
280
305
  let url = this.serverUrl;
281
306
  if (this.apiKey) {
282
307
  const separator = url.includes('?') ? '&' : '?';
283
- url += `${separator}api_key=${this.apiKey}`;
308
+ url += `${separator}api_key=${encodeURIComponent(this.apiKey)}`;
284
309
  }
285
310
  if (this.agentId) {
286
311
  const separator = url.includes('?') ? '&' : '?';
287
- url += `${separator}agent_id=${this.agentId}`;
312
+ url += `${separator}agent_id=${encodeURIComponent(this.agentId)}`;
288
313
  }
289
314
 
290
315
  const redactedUrl = url.replace(/api_key=[^&]+/, 'api_key=***');
@@ -314,11 +339,29 @@ export class VoiceAgentClient {
314
339
  this.sendConfig();
315
340
 
316
341
  if (this.audioManager && !this.micStarted) {
317
- await this.audioManager.startMicrophone((data) => {
318
- if (this.isConnected) {
319
- this.sendAudio(data);
320
- }
321
- });
342
+ try {
343
+ await this.audioManager.startMicrophone((data) => {
344
+ if (this.isConnected) {
345
+ this.sendAudio(data);
346
+ }
347
+ });
348
+ } catch (e) {
349
+ // No microphone, no call. Uncaught here, this left connect() pending forever while the session
350
+ // it had opened went on talking: the caller heard the agent, nothing they said reached it, and
351
+ // the page never learned why (iOS refusing capture with no prompt, 2026-09-28).
352
+ const name = (e as { name?: string } | null)?.name || 'Error';
353
+ const error = new LokutorError('audio.microphone_unavailable',
354
+ name === 'NotAllowedError' || name === 'SecurityError'
355
+ ? 'Microphone access was not allowed.'
356
+ : 'The microphone could not be started.',
357
+ { detail: `${name}: ${(e as Error)?.message ?? e}`, original: e, retryable: false });
358
+ sdkTrace('mic.failed', { name });
359
+ settle(() => reject(error));
360
+ this.isUserDisconnect = true;
361
+ try { this.ws?.close(); } catch (_) { /* already closing */ }
362
+ this.emit('error', error); // reaches onError too
363
+ return;
364
+ }
322
365
  this.micStarted = true;
323
366
  }
324
367
 
@@ -343,7 +386,7 @@ export class VoiceAgentClient {
343
386
  });
344
387
  console.error('❌ WebSocket error:', error.message);
345
388
  sdkTrace('ws.error', { code: error.code, message: error.message });
346
- if (this.onError) this.onError(error);
389
+ this.emit('error', error);
347
390
  if (!this.isConnected) {
348
391
  settle(() => reject(error));
349
392
  }
@@ -386,8 +429,8 @@ export class VoiceAgentClient {
386
429
  });
387
430
  console.log(`Session ended by the server (code ${event.code})`);
388
431
  this.emit('ended', { code: event.code, reason: event.reason || '' });
389
- if (this.onError) this.onError(ended);
390
- if (this.onStatus) this.onStatus('disconnected');
432
+ this.emit('error', ended);
433
+ this.emit('status', 'disconnected');
391
434
  return;
392
435
  }
393
436
 
@@ -398,7 +441,7 @@ export class VoiceAgentClient {
398
441
 
399
442
  console.warn(`Connection lost. Reconnecting in ${backoffDelay}ms (attempt ${this.reconnectAttempts}/${this.maxReconnectAttempts})`);
400
443
 
401
- if (this.onStatus) this.onStatus('reconnecting');
444
+ this.emit('status', 'reconnecting');
402
445
 
403
446
  if (this.reconnectTimer) clearTimeout(this.reconnectTimer);
404
447
  this.reconnectTimer = setTimeout(() => {
@@ -409,16 +452,16 @@ export class VoiceAgentClient {
409
452
  }, backoffDelay);
410
453
  } else if (this.isUserDisconnect) {
411
454
  console.log('Disconnected');
412
- if (this.onStatus) this.onStatus('disconnected');
455
+ this.emit('status', 'disconnected');
413
456
  } else {
414
457
  console.log('Max reconnection attempts reached');
415
- if (this.onStatus) this.onStatus('disconnected');
458
+ this.emit('status', 'disconnected');
416
459
  }
417
460
  };
418
461
 
419
462
  } catch (err) {
420
463
  const error = err instanceof LokutorError ? err : new LokutorError('internal.error', 'Failed to create WebSocket connection', { original: err });
421
- if (this.onError) this.onError(error);
464
+ this.emit('error', error);
422
465
  settle(() => reject(error));
423
466
  }
424
467
  });
@@ -453,13 +496,14 @@ export class VoiceAgentClient {
453
496
  this.configConfirmed = false;
454
497
 
455
498
  // Send feature/config flags — order matches the test agent for consistency.
456
- this.ws.send(JSON.stringify({ type: 'voice', data: this.voice }));
457
- this.ws.send(JSON.stringify({ type: 'language', data: this.language }));
499
+ const agentSession = Boolean(this.agentId);
500
+ if (!agentSession || this.voiceExplicit) this.ws.send(JSON.stringify({ type: 'voice', data: this.voice }));
501
+ if (!agentSession || this.languageExplicit) this.ws.send(JSON.stringify({ type: 'language', data: this.language }));
458
502
  if (this.tools && this.tools.length > 0) {
459
503
  this.ws.send(JSON.stringify({ type: 'tools', data: this.tools }));
460
504
  }
461
505
  this.ws.send(JSON.stringify({ type: 'visemes', data: this.wantVisemes }));
462
- this.ws.send(JSON.stringify({ type: 'prompt', data: this.prompt }));
506
+ if (!agentSession || this.prompt) this.ws.send(JSON.stringify({ type: 'prompt', data: this.prompt }));
463
507
 
464
508
  // Inform the server of our sample rates for echo cancellation.
465
509
  this.ws.send(JSON.stringify({ type: 'rates', playback: 44100, input: 16000 }));
@@ -527,6 +571,12 @@ export class VoiceAgentClient {
527
571
  sdkTrace('server_rates', { playback: this.serverPlaybackRate, input: this.serverInputRate });
528
572
  break;
529
573
  case 'transcript':
574
+ // Interim recognition while the caller is still speaking: its own event, never history. The
575
+ // final line follows without "partial" (it used to be stored and reported as final every time).
576
+ if (msg.partial) {
577
+ this.emit('transcription_partial', msg.data);
578
+ break;
579
+ }
530
580
  const role = msg.role === 'user' ? 'user' : 'agent';
531
581
  // Store in history
532
582
  this.messages.push({
@@ -536,11 +586,9 @@ export class VoiceAgentClient {
536
586
  });
537
587
 
538
588
  if (msg.role === 'user') {
539
- if (this.onTranscription) this.onTranscription(msg.data);
540
589
  this.emit('transcription', msg.data);
541
590
  console.log(`💬 You: ${msg.data}`);
542
591
  } else {
543
- if (this.onResponse) this.onResponse(msg.data);
544
592
  this.emit('response', msg.data);
545
593
  console.log(`🤖 Agent: ${msg.data}`);
546
594
  }
@@ -562,7 +610,6 @@ export class VoiceAgentClient {
562
610
  if (msg.data === 'interrupted' && this.audioManager) {
563
611
  this.audioManager.stopPlayback();
564
612
  }
565
- if (this.onStatus) this.onStatus(msg.data);
566
613
  this.emit('status', msg.data);
567
614
  const icons: Record<string, string> = {
568
615
  'interrupted': '⚡',
@@ -600,8 +647,7 @@ export class VoiceAgentClient {
600
647
  detail: backendDetail,
601
648
  retryable: backendRetryable,
602
649
  });
603
- if (this.onError) this.onError(error);
604
- this.emit('error', error);
650
+ this.emit('error', error); // reaches onError too (calling it here as well delivered every server error twice)
605
651
  console.error(`❌ Server error: [${error.code}] ${error.message}`);
606
652
  break;
607
653
  }
@@ -616,11 +662,14 @@ export class VoiceAgentClient {
616
662
  break;
617
663
  }
618
664
 
619
- case 'tool_call':
620
- this.emit('tool_call', { name: msg.name, arguments: msg.arguments });
665
+ case 'tool_call': {
666
+ // Once, with the call_id submitToolResult needs. It was emitted twice (the first time
667
+ // without call_id), so a handler ran every tool twice and its first result was dropped.
668
+ const call: ToolCall = { name: msg.name, arguments: msg.arguments, call_id: msg.call_id };
621
669
  console.log(`🛠️ Tool Call: ${msg.name}(${msg.arguments})`);
622
- this.emit('tool_call', msg);
670
+ this.emit('tool_call', call);
623
671
  break;
672
+ }
624
673
  }
625
674
  } catch (e) {
626
675
  sdkTrace('ws.recv.parse_error', { preview: text?.slice(0, 120) });
@@ -748,7 +797,7 @@ export class VoiceAgentClient {
748
797
  * No authentication required.
749
798
  */
750
799
  static async fetchVoices(baseUrl?: string): Promise<VoiceInfo[]> {
751
- const url = wsToHttp(baseUrl || DEFAULT_URLS.VOICE_AGENT);
800
+ const url = apiOrigin(baseUrl);
752
801
  const data = await fetchJson<{ voices: VoiceInfo[] }>(`${url}/voices`);
753
802
  return data.voices || [];
754
803
  }
@@ -758,7 +807,7 @@ export class VoiceAgentClient {
758
807
  * No authentication required.
759
808
  */
760
809
  static async fetchLanguages(baseUrl?: string): Promise<LanguageInfo[]> {
761
- const url = wsToHttp(baseUrl || DEFAULT_URLS.VOICE_AGENT);
810
+ const url = apiOrigin(baseUrl);
762
811
  const data = await fetchJson<{ languages: LanguageInfo[] }>(`${url}/languages`);
763
812
  return data.languages || [];
764
813
  }
@@ -768,7 +817,7 @@ export class VoiceAgentClient {
768
817
  * No authentication required.
769
818
  */
770
819
  static async fetchModels(baseUrl?: string): Promise<ModelInfo[]> {
771
- const url = wsToHttp(baseUrl || DEFAULT_URLS.VOICE_AGENT);
820
+ const url = apiOrigin(baseUrl);
772
821
  const data = await fetchJson<{ models: ModelInfo[] }>(`${url}/models`);
773
822
  return data.models || [];
774
823
  }
@@ -778,7 +827,7 @@ export class VoiceAgentClient {
778
827
  * No authentication required.
779
828
  */
780
829
  static async fetchConfig(baseUrl?: string): Promise<ServerConfig> {
781
- const url = wsToHttp(baseUrl || DEFAULT_URLS.VOICE_AGENT);
830
+ const url = apiOrigin(baseUrl);
782
831
  return fetchJson<ServerConfig>(`${url}/config`);
783
832
  }
784
833
 
@@ -787,17 +836,17 @@ export class VoiceAgentClient {
787
836
  * No authentication required.
788
837
  */
789
838
  static async fetchStatus(baseUrl?: string): Promise<ServerStatus> {
790
- const url = wsToHttp(baseUrl || DEFAULT_URLS.VOICE_AGENT);
839
+ const url = apiOrigin(baseUrl);
791
840
  return fetchJson<ServerStatus>(`${url}/status`);
792
841
  }
793
842
 
794
843
  /**
795
- * Fetch health/liveness status from the server.
796
- * No authentication required.
844
+ * Fetch readiness from the server (GET /ready: 200 once it can serve).
845
+ * No authentication required. (/health is the operator dashboard's and needs its password.)
797
846
  */
798
847
  static async fetchHealth(baseUrl?: string): Promise<HealthStatus> {
799
- const url = wsToHttp(baseUrl || DEFAULT_URLS.VOICE_AGENT);
800
- return fetchJson<HealthStatus>(`${url}/health`);
848
+ const url = apiOrigin(baseUrl);
849
+ return fetchJson<HealthStatus>(`${url}/ready`);
801
850
  }
802
851
 
803
852
  /**
@@ -824,6 +873,7 @@ export class VoiceAgentClient {
824
873
  */
825
874
  public updateVoice(voice: VoiceId) {
826
875
  this.voice = voice;
876
+ this.voiceExplicit = true;
827
877
  if (this.ws && this.ws.readyState === WebSocket.OPEN && this.isConnected) {
828
878
  this.ws.send(JSON.stringify({ type: 'voice', data: voice }));
829
879
  console.log(`⚙️ Updated voice: ${voice}`);
@@ -835,6 +885,7 @@ export class VoiceAgentClient {
835
885
  */
836
886
  public updateLanguage(language: Language) {
837
887
  this.language = language;
888
+ this.languageExplicit = true;
838
889
  if (this.ws && this.ws.readyState === WebSocket.OPEN && this.isConnected) {
839
890
  this.ws.send(JSON.stringify({ type: 'language', data: language }));
840
891
  console.log(`⚙️ Updated language: ${language}`);
@@ -918,112 +969,133 @@ export class TTSClient {
918
969
  onTTFB?: (ms: number) => void;
919
970
  onError?: (error: any) => void;
920
971
  }): Promise<void> {
972
+ // Done when the server says so ("EOS"); a server error frame rejects. It used to resolve after 2 s
973
+ // without audio, so an error looked like success and a request whose first audio was queued for more
974
+ // than 2 s (the synthesis pool at its cap) was cut off empty.
921
975
  return new Promise((resolve, reject) => {
922
- let activityTimeout: any;
976
+ let timer: any;
923
977
  let ws: WebSocket;
924
978
  let startTime: number;
925
979
  let firstByteReceived = false;
926
-
927
- const refreshTimeout = () => {
928
- if (activityTimeout) clearTimeout(activityTimeout);
929
- activityTimeout = setTimeout(() => {
930
- console.log("⏱️ TTS synthesis reached inactivity timeout (2s) - resolving");
931
- if (ws) ws.close();
980
+ let finished = false;
981
+
982
+ const finish = (err?: unknown) => {
983
+ if (finished) return;
984
+ finished = true;
985
+ if (timer) clearTimeout(timer);
986
+ try { ws?.close(); } catch { /* already closed */ }
987
+ if (err) {
988
+ if (options.onError) options.onError(err);
989
+ reject(err);
990
+ } else {
932
991
  resolve();
933
- }, 2000);
992
+ }
993
+ };
994
+ // 30 s for the first audio, then 15 s of silence between chunks before giving up.
995
+ const arm = () => {
996
+ if (timer) clearTimeout(timer);
997
+ timer = setTimeout(() => finish(new LokutorError('internal.timeout' as ErrorCode,
998
+ firstByteReceived ? 'TTS stream stalled' : 'No audio within 30 s', { retryable: true })),
999
+ firstByteReceived ? 15000 : 30000);
934
1000
  };
935
1001
 
936
1002
  try {
937
1003
  let url = DEFAULT_URLS.TTS;
938
1004
  if (this.apiKey) {
939
1005
  const separator = url.includes('?') ? '&' : '?';
940
- url += `${separator}api_key=${this.apiKey}`;
1006
+ url += `${separator}api_key=${encodeURIComponent(this.apiKey)}`;
941
1007
  }
942
1008
 
943
1009
  ws = new WebSocket(url);
944
1010
  ws.binaryType = 'arraybuffer';
945
1011
 
946
1012
  ws.onopen = () => {
947
- refreshTimeout();
948
- const req = {
1013
+ arm();
1014
+ // speed and steps only when set: the server's defaults are what production is tuned for (the
1015
+ // SDK used to send speed 1.05 and steps 24 on every request).
1016
+ const req: Record<string, unknown> = {
949
1017
  text: options.text,
950
1018
  voice: options.voice || VoiceStyle.F1,
951
1019
  language: options.language || Language.ENGLISH,
952
- speed: options.speed || 1.05,
953
- steps: options.steps || 24,
954
1020
  visemes: options.visemes || false
955
1021
  };
1022
+ if (options.speed !== undefined) req.speed = options.speed;
1023
+ if (options.steps !== undefined) req.steps = options.steps;
956
1024
  ws.send(JSON.stringify(req));
957
1025
  startTime = nowMs();
958
1026
  };
959
1027
 
960
1028
  ws.onmessage = async (event) => {
961
- refreshTimeout();
962
1029
  if (event.data instanceof ArrayBuffer) {
963
1030
  if (!firstByteReceived) {
964
1031
  const ttfb = nowMs() - startTime;
965
1032
  if (options.onTTFB) options.onTTFB(ttfb);
966
1033
  firstByteReceived = true;
967
1034
  }
1035
+ arm();
968
1036
  if (options.onAudio) options.onAudio(new Uint8Array(event.data));
969
1037
  return;
970
1038
  }
971
1039
  const text = event.data.toString();
972
1040
  if (text === 'EOS') {
973
- if (activityTimeout) clearTimeout(activityTimeout);
974
- ws.close();
975
- resolve();
1041
+ finish();
976
1042
  return;
977
1043
  }
978
1044
 
1045
+ let msg: any;
979
1046
  try {
980
- const msg = JSON.parse(text);
981
-
982
- if (msg.type === 'audio' && msg.data) {
983
- const audioBuffer = base64ToUint8Array(msg.data);
984
- if (!firstByteReceived) {
985
- const ttfb = nowMs() - startTime;
986
- if (options.onTTFB) options.onTTFB(ttfb);
987
- firstByteReceived = true;
988
- }
989
- if (options.onAudio) options.onAudio(audioBuffer);
990
- return;
991
- }
1047
+ msg = JSON.parse(text);
1048
+ } catch {
1049
+ return;
1050
+ }
992
1051
 
993
- if (msg.type === 'visemes' && Array.isArray(msg.data) && options.onVisemes) {
994
- options.onVisemes(normalizeVisemes(msg.data));
995
- return;
996
- }
1052
+ if (msg.type === 'error') {
1053
+ const data = typeof msg.data === 'object' && msg.data ? msg.data : { message: msg.data };
1054
+ finish(new LokutorError((data.code ?? 'internal.error') as ErrorCode, data.message ?? 'TTS failed', {
1055
+ detail: data.detail,
1056
+ retryable: data.retryable ?? false,
1057
+ }));
1058
+ return;
1059
+ }
997
1060
 
998
- if (Array.isArray(msg) && options.onVisemes) {
999
- options.onVisemes(normalizeVisemes(msg));
1000
- return;
1001
- }
1002
-
1003
- if (msg.type === 'eos') {
1004
- if (activityTimeout) clearTimeout(activityTimeout);
1005
- ws.close();
1006
- resolve();
1061
+ if (msg.type === 'audio' && msg.data) {
1062
+ const audioBuffer = base64ToUint8Array(msg.data);
1063
+ if (!firstByteReceived) {
1064
+ const ttfb = nowMs() - startTime;
1065
+ if (options.onTTFB) options.onTTFB(ttfb);
1066
+ firstByteReceived = true;
1007
1067
  }
1008
- } catch (e) {
1068
+ arm();
1069
+ if (options.onAudio) options.onAudio(audioBuffer);
1070
+ return;
1009
1071
  }
1010
- };
1011
1072
 
1012
- ws.onerror = (err) => {
1013
- if (activityTimeout) clearTimeout(activityTimeout);
1014
- if (options.onError) options.onError(err);
1015
- reject(err);
1016
- };
1073
+ if (msg.type === 'visemes' && Array.isArray(msg.data) && options.onVisemes) {
1074
+ options.onVisemes(normalizeVisemes(msg.data));
1075
+ return;
1076
+ }
1017
1077
 
1018
- ws.onclose = () => {
1019
- if (activityTimeout) clearTimeout(activityTimeout);
1020
- resolve();
1078
+ if (Array.isArray(msg) && options.onVisemes) {
1079
+ options.onVisemes(normalizeVisemes(msg));
1080
+ return;
1081
+ }
1082
+
1083
+ if (msg.type === 'eos') {
1084
+ finish();
1085
+ }
1021
1086
  };
1022
1087
 
1088
+ ws.onerror = (err) => finish(err);
1089
+
1090
+ ws.onclose = (ev) => {
1091
+ // A close before EOS is an error unless audio already arrived and the server closed cleanly.
1092
+ if (!finished) {
1093
+ if (firstByteReceived && ev.code === 1000) finish();
1094
+ else finish(new LokutorError('internal.error' as ErrorCode, `TTS socket closed (${ev.code}${ev.reason ? `: ${ev.reason}` : ''})`, { retryable: true }));
1095
+ }
1096
+ };
1023
1097
  } catch (err) {
1024
- if (activityTimeout) clearTimeout(activityTimeout);
1025
- if (options.onError) options.onError(err);
1026
- reject(err);
1098
+ finish(err);
1027
1099
  }
1028
1100
  });
1029
1101
  }
@@ -1153,7 +1225,7 @@ export class SpeechToTextClient {
1153
1225
  try {
1154
1226
  let url = this.serverUrl;
1155
1227
  const separator = url.includes('?') ? '&' : '?';
1156
- url += `${separator}api_key=${this.apiKey}`;
1228
+ url += `${separator}api_key=${encodeURIComponent(this.apiKey)}`;
1157
1229
 
1158
1230
  this.ws = new WebSocket(url);
1159
1231
 
@@ -1162,11 +1234,22 @@ export class SpeechToTextClient {
1162
1234
  this.onStatusChange?.('connected');
1163
1235
  this.ws!.send(JSON.stringify({ language: this.language || Language.ENGLISH, vad: this.vad }));
1164
1236
 
1165
- await this.audioManager!.startMicrophone((data) => {
1166
- if (this.isConnected && this.ws?.readyState === WebSocket.OPEN) {
1167
- this.ws.send(data);
1168
- }
1169
- });
1237
+ // A denied or missing microphone rejects instead of leaving connect() pending forever (the
1238
+ // same hang 1.5.1 fixed in VoiceAgentClient).
1239
+ try {
1240
+ await this.audioManager!.startMicrophone((data) => {
1241
+ if (this.isConnected && this.ws?.readyState === WebSocket.OPEN) {
1242
+ this.ws.send(data);
1243
+ }
1244
+ });
1245
+ } catch (err) {
1246
+ const error = err instanceof LokutorError ? err
1247
+ : new LokutorError('audio.microphone_unavailable' as ErrorCode, 'Could not start the microphone', { original: err });
1248
+ this.onError?.(error);
1249
+ try { this.ws?.close(); } catch { /* already closed */ }
1250
+ settle(() => reject(error));
1251
+ return;
1252
+ }
1170
1253
 
1171
1254
  settle(() => resolve(true));
1172
1255
  };
@@ -1182,7 +1265,10 @@ export class SpeechToTextClient {
1182
1265
  this.onPartialTranscript?.(msg.data ?? '');
1183
1266
  }
1184
1267
  } else if (msg.type === 'error') {
1185
- this.onError?.(new LokutorError('internal.error', msg.data ?? 'STT error'));
1268
+ const data = typeof msg.data === 'object' && msg.data ? msg.data : { message: msg.data };
1269
+ this.onError?.(new LokutorError((data.code ?? 'internal.error') as ErrorCode, data.message ?? 'STT error', {
1270
+ detail: data.detail, retryable: data.retryable ?? false,
1271
+ }));
1186
1272
  }
1187
1273
  } catch {
1188
1274
  // ignore malformed frames