@openclaw/voice-call 2026.7.2-beta.5 → 2026.8.1-beta.2

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.
@@ -1,10 +1,10 @@
1
- import { o as canonicalizeVoiceCallMediaBase64 } from "./runtime-entry-CQtitvRd.js";
1
+ import { o as canonicalizeVoiceCallMediaBase64 } from "./runtime-entry-F13eJ4pp.js";
2
2
  import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
3
- import { isFutureDateTimestampMs, resolveExpiresAtMsFromDurationMs } from "openclaw/plugin-sdk/number-runtime";
4
3
  import { createSubsystemLogger } from "openclaw/plugin-sdk/runtime-env";
5
4
  import { REALTIME_VOICE_AGENT_CONSULT_TOOL_NAME, buildRealtimeVoiceAgentConsultWorkingResponse, calculateMulawRms, createRealtimeVoiceSessionHarness, createSpeechThresholdGate, readRealtimeVoiceConsultQuestion, readSpeakableRealtimeVoiceToolResult } from "openclaw/plugin-sdk/realtime-voice";
6
5
  import { normalizeWebhookPath } from "openclaw/plugin-sdk/webhook-ingress";
7
6
  import { randomUUID } from "node:crypto";
7
+ import { isFutureDateTimestampMs, resolveExpiresAtMsFromDurationMs } from "openclaw/plugin-sdk/number-runtime";
8
8
  import { sliceUtf16Safe, truncateUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime";
9
9
  import "node:http";
10
10
  import WebSocket$1, { WebSocketServer } from "ws";
@@ -13,11 +13,13 @@ const TELEPHONY_SAMPLE_RATE = 8e3;
13
13
  const TELEPHONY_CHUNK_BYTES = 160;
14
14
  const TELEPHONY_CHUNK_MS = 20;
15
15
  const DEFAULT_MAX_QUEUED_AUDIO_BYTES = TELEPHONY_SAMPLE_RATE * 120;
16
+ const QUEUE_COMPACT_HEAD_THRESHOLD = 256;
16
17
  /** Paces outgoing mulaw audio frames at telephony cadence. */
17
18
  var RealtimeAudioPacer = class {
18
19
  constructor(params) {
19
20
  this.params = params;
20
21
  this.queue = [];
22
+ this.queueHead = 0;
21
23
  this.timer = null;
22
24
  this.queuedAudioBytes = 0;
23
25
  this.closed = false;
@@ -55,7 +57,7 @@ var RealtimeAudioPacer = class {
55
57
  if (this.closed) return 0;
56
58
  const clearedAudioBytes = this.queuedAudioBytes;
57
59
  this.clearTimer();
58
- this.queue = [];
60
+ this.resetQueue();
59
61
  this.queuedAudioBytes = 0;
60
62
  this.params.send(this.params.serializer.clear());
61
63
  return clearedAudioBytes;
@@ -68,7 +70,7 @@ var RealtimeAudioPacer = class {
68
70
  close() {
69
71
  this.closed = true;
70
72
  this.clearTimer();
71
- this.queue = [];
73
+ this.resetQueue();
72
74
  this.queuedAudioBytes = 0;
73
75
  }
74
76
  /** Clear the scheduled pump timer. */
@@ -86,11 +88,33 @@ var RealtimeAudioPacer = class {
86
88
  this.close();
87
89
  this.params.onBackpressure?.();
88
90
  }
91
+ get pendingQueueSize() {
92
+ return Math.max(0, this.queue.length - this.queueHead);
93
+ }
94
+ /** Take one queued item without shifting the remaining paced-audio backlog. */
95
+ takeNextItem() {
96
+ if (this.queueHead >= this.queue.length) {
97
+ this.resetQueue();
98
+ return;
99
+ }
100
+ const item = this.queue[this.queueHead];
101
+ this.queueHead += 1;
102
+ if (this.queueHead >= this.queue.length) this.resetQueue();
103
+ else if (this.queueHead > QUEUE_COMPACT_HEAD_THRESHOLD && this.queueHead * 2 > this.queue.length) {
104
+ this.queue.splice(0, this.queueHead);
105
+ this.queueHead = 0;
106
+ }
107
+ return item;
108
+ }
109
+ resetQueue() {
110
+ this.queue.length = 0;
111
+ this.queueHead = 0;
112
+ }
89
113
  /** Send one queued item and schedule the next send based on audio duration. */
90
114
  pump() {
91
115
  this.timer = null;
92
116
  if (this.closed) return;
93
- const item = this.queue.shift();
117
+ const item = this.takeNextItem();
94
118
  if (!item) return;
95
119
  let delayMs = 0;
96
120
  let sent;
@@ -100,11 +124,11 @@ var RealtimeAudioPacer = class {
100
124
  delayMs = item.durationMs || TELEPHONY_CHUNK_MS;
101
125
  } else sent = this.params.send(this.params.serializer.mark(item.name));
102
126
  if (!sent) {
103
- this.queue = [];
127
+ this.resetQueue();
104
128
  this.queuedAudioBytes = 0;
105
129
  return;
106
130
  }
107
- if (this.queue.length > 0) this.timer = setTimeout(() => this.pump(), delayMs);
131
+ if (this.pendingQueueSize > 0) this.timer = setTimeout(() => this.pump(), delayMs);
108
132
  }
109
133
  };
110
134
  //#endregion
@@ -377,6 +401,12 @@ function buildForcedConsultSpeechPrompt(result) {
377
401
  trimmed.length <= FORCED_CONSULT_RESULT_MAX_CHARS ? trimmed : `${truncateUtf16Safe(trimmed, FORCED_CONSULT_RESULT_MAX_CHARS - 16).trimEnd()} [truncated]`
378
402
  ].join("\n");
379
403
  }
404
+ async function waitForNativeConsult(state) {
405
+ return await Promise.race([state.promise.then((result) => ({
406
+ kind: "completed",
407
+ result
408
+ })), state.cancellation.then(() => ({ kind: "cancelled" }))]);
409
+ }
380
410
  function appendRecentTalkEventMetadata(call, event) {
381
411
  if (!call) return;
382
412
  const metadata = call.metadata ?? {};
@@ -410,15 +440,16 @@ var RealtimeCallHandler = class {
410
440
  this.resolveInstructions = resolveInstructions;
411
441
  this.toolHandlers = /* @__PURE__ */ new Map();
412
442
  this.pendingStreamTokens = /* @__PURE__ */ new Map();
443
+ this.activeSockets = /* @__PURE__ */ new Set();
444
+ this.serverClosingSockets = /* @__PURE__ */ new WeakSet();
413
445
  this.activeBridgesByCallId = /* @__PURE__ */ new Map();
414
446
  this.activeTelephonyClosersByCallId = /* @__PURE__ */ new Map();
415
- this.partialUserTranscriptsByCallId = /* @__PURE__ */ new Map();
416
- this.rawPartialUserTranscriptsByCallId = /* @__PURE__ */ new Map();
417
- this.partialUserTranscriptUpdatedAtByCallId = /* @__PURE__ */ new Map();
418
- this.recentFinalUserTranscriptsByCallId = /* @__PURE__ */ new Map();
419
- this.recentFinalUserTranscriptTimersByCallId = /* @__PURE__ */ new Map();
447
+ this.userTranscriptStatesByCallId = /* @__PURE__ */ new Map();
420
448
  this.forcedConsultsByCallId = /* @__PURE__ */ new Map();
449
+ this.consultSessionsByCallId = /* @__PURE__ */ new Map();
421
450
  this.nativeConsultsInFlightByCallId = /* @__PURE__ */ new Map();
451
+ this.closePromise = null;
452
+ this.closing = false;
422
453
  this.publicOrigin = null;
423
454
  this.publicPathPrefix = "";
424
455
  }
@@ -464,6 +495,11 @@ var RealtimeCallHandler = class {
464
495
  }
465
496
  }
466
497
  handleWebSocketUpgrade(request, socket, head) {
498
+ if (this.closing) {
499
+ socket.write("HTTP/1.1 503 Service Unavailable\r\nConnection: close\r\n\r\n");
500
+ socket.destroy();
501
+ return;
502
+ }
467
503
  const token = new URL(request.url ?? "/", "wss://localhost").pathname.split("/").pop() ?? null;
468
504
  const callerMeta = token ? this.consumeStreamToken(token) : null;
469
505
  if (!callerMeta) {
@@ -476,6 +512,7 @@ var RealtimeCallHandler = class {
476
512
  noServer: true,
477
513
  maxPayload: MAX_REALTIME_MESSAGE_BYTES
478
514
  }).handleUpgrade(request, socket, head, (ws) => {
515
+ this.activeSockets.add(ws);
479
516
  let bridge = null;
480
517
  let initialized = false;
481
518
  let activeCallSid = "unknown";
@@ -530,13 +567,39 @@ var RealtimeCallHandler = class {
530
567
  }
531
568
  });
532
569
  ws.on("close", (code) => {
533
- const reason = stopReceived || code === 1e3 || code === 1005 ? "completed" : "error";
570
+ this.activeSockets.delete(ws);
571
+ const reason = this.serverClosingSockets.has(ws) || stopReceived || code === 1e3 || code === 1005 ? "completed" : "error";
534
572
  this.closeTelephonyBridge(activeCallSid, bridge, reason);
535
573
  });
536
574
  ws.on("error", (error) => {
537
575
  console.error("[voice-call] realtime WS error:", error);
538
576
  });
577
+ if (this.closing) {
578
+ this.serverClosingSockets.add(ws);
579
+ ws.terminate();
580
+ }
581
+ });
582
+ }
583
+ close(shutdownBarrier = Promise.resolve()) {
584
+ if (this.closePromise) return this.closePromise;
585
+ this.closing = true;
586
+ this.pendingStreamTokens.clear();
587
+ const sockets = [...this.activeSockets];
588
+ this.closePromise = Promise.all([shutdownBarrier, ...sockets.map((ws) => new Promise((resolve) => {
589
+ if (ws.readyState === WebSocket$1.CLOSED) {
590
+ resolve();
591
+ return;
592
+ }
593
+ this.serverClosingSockets.add(ws);
594
+ ws.once("close", () => resolve());
595
+ ws.terminate();
596
+ }))]).then(() => {
597
+ this.pendingStreamTokens.clear();
598
+ }).finally(() => {
599
+ this.closing = false;
600
+ this.closePromise = null;
539
601
  });
602
+ return this.closePromise;
540
603
  }
541
604
  registerToolHandler(name, fn) {
542
605
  this.toolHandlers.set(name, fn);
@@ -634,7 +697,7 @@ var RealtimeCallHandler = class {
634
697
  },
635
698
  onTalkEvent: (event) => appendRecentTalkEventMetadata(callRecord, event)
636
699
  });
637
- const providerHandlesInputAudioBargeIn = this.realtimeProvider.capabilities?.handlesInputAudioBargeIn === true;
700
+ let providerHandlesInputAudioBargeIn = this.realtimeProvider.capabilities?.handlesInputAudioBargeIn === true;
638
701
  const cancelOutputAudioForBargeIn = (source, interruptProvider, clearedAudioBytes = 0) => {
639
702
  const outputAudioActive = harness.talk.outputAudioActive;
640
703
  const pendingTelephonyAudio = audioPacer.hasPendingAudio();
@@ -669,6 +732,14 @@ var RealtimeCallHandler = class {
669
732
  if (callEndEmitted) return;
670
733
  callEndEmitted = true;
671
734
  this.endCallInManager(callSid, callId, reason);
735
+ if (reason !== "error") return;
736
+ Promise.resolve(this.provider.hangupCall({
737
+ callId,
738
+ providerCallId: callSid,
739
+ reason
740
+ })).catch((error) => {
741
+ console.warn(`[voice-call] Failed to hang up realtime call ${callSid}: ${formatErrorMessage(error)}`);
742
+ });
672
743
  };
673
744
  const sendString = (message) => {
674
745
  if (ws.readyState !== WebSocket$1.OPEN) return false;
@@ -703,7 +774,11 @@ var RealtimeCallHandler = class {
703
774
  silenceFrames: 12
704
775
  });
705
776
  const interruptResponseOnInputAudio = typeof this.providerConfig.interruptResponseOnInputAudio === "boolean" ? this.providerConfig.interruptResponseOnInputAudio : void 0;
706
- const session = harness.createBridge({
777
+ const hadPredecessorOnAdmission = this.activeBridgesByCallId.has(callId);
778
+ const nativeConsultOwner = {};
779
+ const userTranscriptAdoption = this.beginUserTranscriptOwnerAdoption(callId);
780
+ const userTranscriptOwner = userTranscriptAdoption.owner;
781
+ const bridgeParams = {
707
782
  provider: this.realtimeProvider,
708
783
  cfg: this.coreConfig,
709
784
  providerConfig: this.providerConfig,
@@ -734,6 +809,8 @@ var RealtimeCallHandler = class {
734
809
  }
735
810
  },
736
811
  onTranscript: (role, text, isFinal) => {
812
+ const owner = nativeConsultOwner.current;
813
+ if (!this.getUserTranscriptState(callId, userTranscriptOwner) || owner && !this.isActiveBridgeOwner(callId, owner)) return;
737
814
  const turnId = harness.ensureTurn();
738
815
  const eventType = role === "assistant" ? isFinal ? "output.text.done" : "output.text.delta" : isFinal ? "transcript.done" : "transcript.delta";
739
816
  const payload = role === "assistant" ? { text } : {
@@ -757,19 +834,22 @@ var RealtimeCallHandler = class {
757
834
  });
758
835
  if (!isFinal) {
759
836
  if (role === "user" && text.trim()) {
760
- const transcript = this.recordPartialUserTranscript(callId, text);
837
+ const transcript = this.recordPartialUserTranscript(callId, userTranscriptOwner, text);
838
+ if (!transcript) return;
761
839
  console.log(`[voice-call] realtime input transcript callId=${callId} providerCallId=${callSid} final=false chars=${text.trim().length} aggregateChars=${transcript.length}`);
762
840
  }
763
841
  return;
764
842
  }
765
843
  if (role === "user") {
844
+ const state = this.getUserTranscriptState(callId, userTranscriptOwner);
845
+ if (!state) return;
766
846
  const transcript = resolveFinalTranscriptText({
767
- partial: this.partialUserTranscriptsByCallId.get(callId),
768
- rawPartial: this.rawPartialUserTranscriptsByCallId.get(callId),
847
+ partial: state.partial,
848
+ rawPartial: state.rawPartial,
769
849
  final: text
770
850
  });
771
- this.clearPartialUserTranscript(callId);
772
- this.setRecentFinalUserTranscript(callId, transcript);
851
+ this.clearPartialUserTranscript(callId, userTranscriptOwner);
852
+ this.setRecentFinalUserTranscript(callId, userTranscriptOwner, transcript);
773
853
  console.log(`[voice-call] realtime input transcript callId=${callId} providerCallId=${callSid} final=true chars=${text.trim().length} aggregateChars=${transcript.length}`);
774
854
  const event = {
775
855
  id: `realtime-speech-${callSid}-${Date.now()}`,
@@ -787,6 +867,7 @@ var RealtimeCallHandler = class {
787
867
  callId,
788
868
  callSid,
789
869
  transcript,
870
+ userTranscriptOwner,
790
871
  clearAudio: () => {
791
872
  const clearedBytes = audioPacer.clearAudio();
792
873
  console.log(`[voice-call] realtime forced consult cleared outbound audio callId=${callId} providerCallId=${callSid} queuedBytes=${clearedBytes}`);
@@ -816,9 +897,30 @@ var RealtimeCallHandler = class {
816
897
  }
817
898
  });
818
899
  console.log(`[voice-call] realtime tool call received callId=${callId} providerCallId=${callSid} tool=${toolEvent.name}`);
819
- return this.executeToolCall(sessionLocal, callId, toolEvent.callId || toolEvent.itemId, toolEvent.name, toolEvent.args, turnId, harness);
900
+ return this.executeToolCall(sessionLocal, callId, toolEvent.callId || toolEvent.itemId, toolEvent.name, toolEvent.args, turnId, harness, userTranscriptOwner);
820
901
  },
821
902
  onEvent: (event) => {
903
+ if (event.direction === "client" && event.type === "session.continuity.reset") {
904
+ const turnId = harness.talk.activeTurnId;
905
+ const owner = nativeConsultOwner.current;
906
+ if (owner && this.isActiveBridgeOwner(callId, owner)) {
907
+ this.resetUserTranscriptState(callId, userTranscriptOwner);
908
+ this.resetConsultSessionForContinuity(callId, owner);
909
+ }
910
+ harness.flushOutput(() => {
911
+ audioPacer.clearAudio();
912
+ harness.finishOutputAudio(event.type);
913
+ });
914
+ if (turnId) harness.talk.cancelTurn({
915
+ turnId,
916
+ payload: {
917
+ callId,
918
+ providerCallId: callSid,
919
+ reason: event.type
920
+ }
921
+ });
922
+ return;
923
+ }
822
924
  if (event.type === "input_audio_buffer.speech_started") {
823
925
  harness.ensureTurn();
824
926
  return;
@@ -838,17 +940,15 @@ var RealtimeCallHandler = class {
838
940
  });
839
941
  return;
840
942
  }
841
- if (event.type === "response.done") {
842
- harness.finishOutputAudio("response.done");
843
- harness.endTurn("response.done");
844
- return;
845
- }
846
943
  if (event.type === "error") harness.emit({
847
944
  type: "session.error",
848
945
  payload: { message: event.detail ?? "Realtime provider error" },
849
946
  final: true
850
947
  });
851
948
  },
949
+ onResponseDone: (outcome) => {
950
+ if (outcome.status === "failed" || outcome.status === "incomplete") console.warn(`[voice-call] realtime response ${outcome.status}: ${outcome.message}`);
951
+ },
852
952
  onReady: () => {
853
953
  harness.emit({
854
954
  type: "session.ready",
@@ -867,11 +967,13 @@ var RealtimeCallHandler = class {
867
967
  });
868
968
  },
869
969
  onClose: (reason) => {
870
- this.activeBridgesByCallId.delete(callId);
871
- this.activeBridgesByCallId.delete(callSid);
872
- this.activeTelephonyClosersByCallId.delete(callId);
873
- this.activeTelephonyClosersByCallId.delete(callSid);
874
- this.clearUserTranscriptState(callId);
970
+ const owner = nativeConsultOwner.current;
971
+ const ownsCallState = owner ? this.isActiveBridgeOwner(callId, owner) : false;
972
+ if (owner) {
973
+ this.clearActiveBridgeMappings(callId, callSid, owner);
974
+ this.cancelConsultSession(callId, owner);
975
+ }
976
+ if (ownsCallState) this.clearUserTranscriptState(callId, userTranscriptOwner);
875
977
  harness.finishOutputAudio(reason);
876
978
  harness.emit({
877
979
  type: "session.closed",
@@ -879,17 +981,26 @@ var RealtimeCallHandler = class {
879
981
  final: true
880
982
  });
881
983
  if (reason !== "error") return;
882
- emitCallEnd("error");
883
984
  if (ws.readyState === WebSocket$1.OPEN) ws.close(1011, "Bridge disconnected");
884
- this.provider.hangupCall({
885
- callId,
886
- providerCallId: callSid,
887
- reason: "error"
888
- }).catch((error) => {
889
- console.warn(`[voice-call] Failed to hang up realtime call ${callSid}: ${formatErrorMessage(error)}`);
890
- });
985
+ if (owner && !ownsCallState || !owner && hadPredecessorOnAdmission && this.activeBridgesByCallId.has(callId)) return;
986
+ emitCallEnd("error");
891
987
  }
892
- });
988
+ };
989
+ let session;
990
+ try {
991
+ session = harness.createBridge(bridgeParams);
992
+ } catch (error) {
993
+ this.rollbackUserTranscriptOwnerAdoption(callId, userTranscriptAdoption);
994
+ harness.close();
995
+ audioPacer.close();
996
+ if (!hadPredecessorOnAdmission || !this.activeBridgesByCallId.has(callId)) emitCallEnd("error");
997
+ if (ws.readyState === WebSocket$1.OPEN) ws.close(1011, "Failed to create realtime bridge");
998
+ console.error("[voice-call] Failed to create realtime bridge:", error);
999
+ return null;
1000
+ }
1001
+ this.commitUserTranscriptOwnerAdoption(callId, userTranscriptAdoption);
1002
+ nativeConsultOwner.current = session;
1003
+ providerHandlesInputAudioBargeIn = session.bridge.handlesInputAudioBargeIn ?? providerHandlesInputAudioBargeIn;
893
1004
  const closeTelephony = (reason) => {
894
1005
  try {
895
1006
  session.close();
@@ -897,10 +1008,20 @@ var RealtimeCallHandler = class {
897
1008
  emitCallEnd(reason);
898
1009
  }
899
1010
  };
1011
+ const previousConsultSession = this.consultSessionsByCallId.get(callId);
1012
+ if (previousConsultSession && previousConsultSession.owner !== session) this.cancelConsultSession(callId, previousConsultSession.owner);
1013
+ this.consultSessionsByCallId.set(callId, {
1014
+ owner: session,
1015
+ coordinator: harness.forcedConsults
1016
+ });
900
1017
  this.activeBridgesByCallId.set(callId, session);
901
1018
  this.activeBridgesByCallId.set(callSid, session);
902
- this.activeTelephonyClosersByCallId.set(callId, closeTelephony);
903
- this.activeTelephonyClosersByCallId.set(callSid, closeTelephony);
1019
+ const telephonyCloser = {
1020
+ owner: session,
1021
+ close: closeTelephony
1022
+ };
1023
+ this.activeTelephonyClosersByCallId.set(callId, telephonyCloser);
1024
+ this.activeTelephonyClosersByCallId.set(callSid, telephonyCloser);
904
1025
  const sendAudioToSession = session.sendAudio.bind(session);
905
1026
  session.sendAudio = (audio) => {
906
1027
  if (speechDetector.accept({
@@ -923,86 +1044,181 @@ var RealtimeCallHandler = class {
923
1044
  try {
924
1045
  closeSession();
925
1046
  } finally {
926
- this.activeBridgesByCallId.delete(callId);
927
- this.activeBridgesByCallId.delete(callSid);
928
- this.activeTelephonyClosersByCallId.delete(callId);
929
- this.activeTelephonyClosersByCallId.delete(callSid);
930
- this.clearUserTranscriptState(callId);
931
- this.forcedConsultsByCallId.delete(callId);
1047
+ this.clearActiveBridgeMappings(callId, callSid, session);
1048
+ this.cancelConsultSession(callId, session);
1049
+ this.clearUserTranscriptState(callId, userTranscriptOwner);
932
1050
  harness.close();
933
1051
  audioPacer.close();
934
1052
  }
935
1053
  };
936
1054
  session.connect().catch((error) => {
937
1055
  console.error("[voice-call] Failed to connect realtime bridge:", error);
938
- session.close();
939
- emitCallEnd("error");
940
- ws.close(1011, "Failed to connect");
1056
+ const ownsCallState = this.isActiveBridgeOwner(callId, session);
1057
+ try {
1058
+ session.close();
1059
+ } catch (closeError) {
1060
+ console.warn(`[voice-call] Failed to close realtime bridge ${callSid}: ${formatErrorMessage(closeError)}`);
1061
+ } finally {
1062
+ if (ownsCallState) emitCallEnd("error");
1063
+ ws.close(1011, "Failed to connect");
1064
+ }
941
1065
  });
942
1066
  return session;
943
1067
  }
944
- recordPartialUserTranscript(callId, text) {
945
- const next = limitPartialUserTranscript(appendTranscriptText(this.partialUserTranscriptsByCallId.get(callId), text));
946
- const raw = limitPartialUserTranscript(`${this.rawPartialUserTranscriptsByCallId.get(callId) ?? ""}${text}`);
947
- this.partialUserTranscriptsByCallId.set(callId, next);
948
- this.rawPartialUserTranscriptsByCallId.set(callId, raw);
949
- this.partialUserTranscriptUpdatedAtByCallId.set(callId, Date.now());
1068
+ beginUserTranscriptOwnerAdoption(callId) {
1069
+ const adoption = {
1070
+ owner: {},
1071
+ previous: this.userTranscriptStatesByCallId.get(callId)
1072
+ };
1073
+ this.userTranscriptStatesByCallId.set(callId, adoption.owner);
1074
+ return adoption;
1075
+ }
1076
+ commitUserTranscriptOwnerAdoption(callId, adoption) {
1077
+ if (this.userTranscriptStatesByCallId.get(callId) !== adoption.owner) return;
1078
+ if (adoption.previous?.recentFinalTimer) {
1079
+ clearTimeout(adoption.previous.recentFinalTimer);
1080
+ adoption.previous.recentFinalTimer = void 0;
1081
+ }
1082
+ }
1083
+ rollbackUserTranscriptOwnerAdoption(callId, adoption) {
1084
+ if (this.userTranscriptStatesByCallId.get(callId) !== adoption.owner) return;
1085
+ if (adoption.owner.recentFinalTimer) {
1086
+ clearTimeout(adoption.owner.recentFinalTimer);
1087
+ adoption.owner.recentFinalTimer = void 0;
1088
+ }
1089
+ if (adoption.previous) {
1090
+ this.userTranscriptStatesByCallId.set(callId, adoption.previous);
1091
+ return;
1092
+ }
1093
+ this.userTranscriptStatesByCallId.delete(callId);
1094
+ }
1095
+ getUserTranscriptState(callId, owner) {
1096
+ const state = this.userTranscriptStatesByCallId.get(callId);
1097
+ return state === owner ? state : void 0;
1098
+ }
1099
+ recordPartialUserTranscript(callId, owner, text) {
1100
+ const state = this.getUserTranscriptState(callId, owner);
1101
+ if (!state) return;
1102
+ const next = limitPartialUserTranscript(appendTranscriptText(state.partial, text));
1103
+ const raw = limitPartialUserTranscript(`${state.rawPartial ?? ""}${text}`);
1104
+ state.partial = next;
1105
+ state.rawPartial = raw;
1106
+ state.partialUpdatedAt = Date.now();
950
1107
  return next;
951
1108
  }
952
- clearPartialUserTranscript(callId) {
953
- this.partialUserTranscriptsByCallId.delete(callId);
954
- this.rawPartialUserTranscriptsByCallId.delete(callId);
955
- this.partialUserTranscriptUpdatedAtByCallId.delete(callId);
1109
+ clearPartialUserTranscript(callId, owner) {
1110
+ const state = this.getUserTranscriptState(callId, owner);
1111
+ if (!state) return;
1112
+ state.partial = void 0;
1113
+ state.rawPartial = void 0;
1114
+ state.partialUpdatedAt = void 0;
956
1115
  }
957
- setRecentFinalUserTranscript(callId, text) {
958
- this.clearRecentFinalUserTranscript(callId);
959
- this.recentFinalUserTranscriptsByCallId.set(callId, text);
1116
+ setRecentFinalUserTranscript(callId, owner, text) {
1117
+ const state = this.getUserTranscriptState(callId, owner);
1118
+ if (!state) return;
1119
+ this.clearRecentFinalUserTranscript(callId, owner);
1120
+ state.recentFinal = text;
960
1121
  const timer = setTimeout(() => {
961
- if (this.recentFinalUserTranscriptsByCallId.get(callId) === text) this.recentFinalUserTranscriptsByCallId.delete(callId);
962
- this.recentFinalUserTranscriptTimersByCallId.delete(callId);
1122
+ if (this.userTranscriptStatesByCallId.get(callId) !== state) return;
1123
+ if (state.recentFinal === text) state.recentFinal = void 0;
1124
+ if (state.recentFinalTimer === timer) state.recentFinalTimer = void 0;
963
1125
  }, RECENT_FINAL_USER_TRANSCRIPT_TTL_MS);
964
1126
  timer.unref?.();
965
- this.recentFinalUserTranscriptTimersByCallId.set(callId, timer);
1127
+ state.recentFinalTimer = timer;
966
1128
  }
967
- clearRecentFinalUserTranscript(callId) {
968
- const timer = this.recentFinalUserTranscriptTimersByCallId.get(callId);
969
- if (timer) {
970
- clearTimeout(timer);
971
- this.recentFinalUserTranscriptTimersByCallId.delete(callId);
1129
+ clearRecentFinalUserTranscript(callId, owner) {
1130
+ const state = this.getUserTranscriptState(callId, owner);
1131
+ if (!state) return;
1132
+ if (state.recentFinalTimer) {
1133
+ clearTimeout(state.recentFinalTimer);
1134
+ state.recentFinalTimer = void 0;
972
1135
  }
973
- this.recentFinalUserTranscriptsByCallId.delete(callId);
1136
+ state.recentFinal = void 0;
1137
+ }
1138
+ resetUserTranscriptState(callId, owner) {
1139
+ this.clearPartialUserTranscript(callId, owner);
1140
+ this.clearRecentFinalUserTranscript(callId, owner);
1141
+ }
1142
+ clearUserTranscriptState(callId, owner) {
1143
+ const state = this.getUserTranscriptState(callId, owner);
1144
+ if (!state) return;
1145
+ if (state.recentFinalTimer) clearTimeout(state.recentFinalTimer);
1146
+ if (this.userTranscriptStatesByCallId.get(callId) === state) this.userTranscriptStatesByCallId.delete(callId);
1147
+ }
1148
+ cancelNativeConsult(callId, owner) {
1149
+ const state = this.nativeConsultsInFlightByCallId.get(callId);
1150
+ if (!state || state.owner !== owner) return;
1151
+ state.cancelled = true;
1152
+ this.nativeConsultsInFlightByCallId.delete(callId);
1153
+ state.cancel();
1154
+ }
1155
+ cancelForcedConsult(callId, owner) {
1156
+ const state = this.forcedConsultsByCallId.get(callId);
1157
+ if (!state || state.owner !== owner) return;
1158
+ state.cancelled = true;
1159
+ state.sendSpeechPrompt = false;
1160
+ state.cancel();
1161
+ this.forcedConsultsByCallId.delete(callId);
1162
+ }
1163
+ resetConsultSession(callId, owner) {
1164
+ const session = this.consultSessionsByCallId.get(callId);
1165
+ if (!session || session.owner !== owner) return false;
1166
+ session.coordinator.clearPending();
1167
+ this.cancelForcedConsult(callId, owner);
1168
+ this.cancelNativeConsult(callId, owner);
1169
+ return true;
1170
+ }
1171
+ resetConsultSessionForContinuity(callId, owner) {
1172
+ const session = this.consultSessionsByCallId.get(callId);
1173
+ if (!session || session.owner !== owner) return false;
1174
+ this.cancelForcedConsult(callId, owner);
1175
+ this.cancelNativeConsult(callId, owner);
1176
+ session.coordinator.clear();
1177
+ return true;
1178
+ }
1179
+ cancelConsultSession(callId, owner) {
1180
+ if (!owner || !this.resetConsultSession(callId, owner)) return;
1181
+ this.consultSessionsByCallId.delete(callId);
1182
+ }
1183
+ isActiveBridgeOwner(callId, owner) {
1184
+ return this.activeBridgesByCallId.get(callId) === owner;
974
1185
  }
975
- clearUserTranscriptState(callId) {
976
- this.clearPartialUserTranscript(callId);
977
- this.clearRecentFinalUserTranscript(callId);
1186
+ clearActiveBridgeMappings(callId, callSid, owner) {
1187
+ for (const key of [callId, callSid]) {
1188
+ if (this.activeBridgesByCallId.get(key) !== owner) continue;
1189
+ this.activeBridgesByCallId.delete(key);
1190
+ this.activeTelephonyClosersByCallId.delete(key);
1191
+ }
978
1192
  }
979
- resolveUserTranscriptContext(callId) {
980
- return this.partialUserTranscriptsByCallId.get(callId) ?? this.recentFinalUserTranscriptsByCallId.get(callId);
1193
+ resolveUserTranscriptContext(callId, owner) {
1194
+ const state = this.getUserTranscriptState(callId, owner);
1195
+ return state?.partial ?? state?.recentFinal;
981
1196
  }
982
- consumePartialUserTranscript(callId, consumed) {
1197
+ consumePartialUserTranscript(callId, owner, consumed) {
983
1198
  const text = consumed?.trim();
984
1199
  if (!text) return;
985
- const current = this.partialUserTranscriptsByCallId.get(callId);
1200
+ const state = this.getUserTranscriptState(callId, owner);
1201
+ const current = state?.partial;
986
1202
  if (!current) return;
987
1203
  if (current === text) {
988
- this.clearPartialUserTranscript(callId);
1204
+ this.clearPartialUserTranscript(callId, owner);
989
1205
  return;
990
1206
  }
991
1207
  if (current.toLowerCase().startsWith(text.toLowerCase())) {
992
1208
  const remaining = current.slice(text.length).trimStart();
993
1209
  if (remaining) {
994
- this.partialUserTranscriptsByCallId.set(callId, remaining);
995
- this.rawPartialUserTranscriptsByCallId.set(callId, remaining);
996
- } else this.clearPartialUserTranscript(callId);
1210
+ state.partial = remaining;
1211
+ state.rawPartial = remaining;
1212
+ } else this.clearPartialUserTranscript(callId, owner);
997
1213
  }
998
- const recent = this.recentFinalUserTranscriptsByCallId.get(callId);
1214
+ const recent = state.recentFinal;
999
1215
  if (!recent) return;
1000
- if (recent === text || recent.toLowerCase().startsWith(text.toLowerCase())) this.clearRecentFinalUserTranscript(callId);
1216
+ if (recent === text || recent.toLowerCase().startsWith(text.toLowerCase())) this.clearRecentFinalUserTranscript(callId, owner);
1001
1217
  }
1002
- async waitForConsultTranscriptSettle(callId, startedAt) {
1218
+ async waitForConsultTranscriptSettle(callId, owner, startedAt) {
1003
1219
  const deadline = startedAt + CONSULT_TRANSCRIPT_SETTLE_MAX_MS;
1004
1220
  while (true) {
1005
- const updatedAt = this.partialUserTranscriptUpdatedAtByCallId.get(callId);
1221
+ const updatedAt = this.getUserTranscriptState(callId, owner)?.partialUpdatedAt;
1006
1222
  if (!updatedAt) return;
1007
1223
  const now = Date.now();
1008
1224
  const quietFor = now - updatedAt;
@@ -1014,14 +1230,14 @@ var RealtimeCallHandler = class {
1014
1230
  }
1015
1231
  closeTelephonyBridge(callIdOrSid, bridge, reason) {
1016
1232
  const closer = this.activeTelephonyClosersByCallId.get(callIdOrSid);
1017
- if (closer) {
1018
- closer(reason);
1233
+ if (closer && closer.owner === bridge) {
1234
+ closer.close(reason);
1019
1235
  return;
1020
1236
  }
1021
1237
  bridge?.close();
1022
1238
  }
1023
1239
  scheduleForcedAgentConsult(params) {
1024
- if (this.config.consultPolicy !== "always") return;
1240
+ if (this.config.consultPolicy !== "always" || this.activeBridgesByCallId.get(params.callId) !== params.session) return;
1025
1241
  const question = params.transcript.trim();
1026
1242
  if (!question) return;
1027
1243
  const handler = this.toolHandlers.get(REALTIME_VOICE_AGENT_CONSULT_TOOL_NAME);
@@ -1050,13 +1266,24 @@ var RealtimeCallHandler = class {
1050
1266
  logger.debug(`[voice-call] realtime forced agent consult reason=${FORCED_CONSULT_REASON} consultPolicy=always callId=${params.callId} providerCallId=${params.callSid} chars=${params.handle.question.length}`);
1051
1267
  console.log(`[voice-call] realtime forced agent consult starting callId=${params.callId} providerCallId=${params.callSid} chars=${params.handle.question.length}`);
1052
1268
  params.clearAudio();
1269
+ const abortController = new AbortController();
1053
1270
  const state = {
1271
+ owner: params.session,
1054
1272
  sendSpeechPrompt: true,
1055
- promise: Promise.resolve().then(() => params.handler({ question: params.handle.question }, params.callId, {}))
1273
+ cancelled: false,
1274
+ cancel: () => {
1275
+ abortController.abort(/* @__PURE__ */ new Error("Realtime forced consult owner was cancelled."));
1276
+ coordinator.markCancelled(params.handle);
1277
+ },
1278
+ promise: Promise.resolve().then(() => {
1279
+ abortController.signal.throwIfAborted();
1280
+ return params.handler({ question: params.handle.question }, params.callId, { abortSignal: abortController.signal });
1281
+ })
1056
1282
  };
1057
1283
  this.forcedConsultsByCallId.set(params.callId, state);
1058
1284
  try {
1059
1285
  const result = await state.promise;
1286
+ if (state.cancelled || this.forcedConsultsByCallId.get(params.callId) !== state) return;
1060
1287
  state.completedAt = Date.now();
1061
1288
  coordinator.markDelivered(params.handle);
1062
1289
  const text = readSpeakableRealtimeVoiceToolResult(result, {
@@ -1072,11 +1299,12 @@ var RealtimeCallHandler = class {
1072
1299
  params.session.sendUserMessage(buildForcedConsultSpeechPrompt(text));
1073
1300
  }
1074
1301
  console.log(`[voice-call] realtime forced agent consult completed callId=${params.callId} providerCallId=${params.callSid} elapsedMs=${Date.now() - startedAt}`);
1075
- this.consumePartialUserTranscript(params.callId, params.handle.question);
1302
+ this.consumePartialUserTranscript(params.callId, params.userTranscriptOwner, params.handle.question);
1076
1303
  } catch (error) {
1077
- console.warn(`[voice-call] realtime forced agent consult failed callId=${params.callId} providerCallId=${params.callSid} error=${formatErrorMessage(error)}`);
1304
+ if (!state.cancelled) console.warn(`[voice-call] realtime forced agent consult failed callId=${params.callId} providerCallId=${params.callSid} error=${formatErrorMessage(error)}`);
1078
1305
  } finally {
1079
- setTimeout(() => {
1306
+ if (!state.cancelled) if (this.forcedConsultsByCallId.get(params.callId) !== state) coordinator.remove(params.handle);
1307
+ else setTimeout(() => {
1080
1308
  if (this.forcedConsultsByCallId.get(params.callId) === state) {
1081
1309
  this.forcedConsultsByCallId.delete(params.callId);
1082
1310
  coordinator.remove(params.handle);
@@ -1136,7 +1364,7 @@ var RealtimeCallHandler = class {
1136
1364
  reason
1137
1365
  });
1138
1366
  }
1139
- async executeToolCall(bridge, callId, bridgeCallId, name, args, turnId, harness) {
1367
+ async executeToolCall(bridge, callId, bridgeCallId, name, args, turnId, harness, userTranscriptOwner) {
1140
1368
  const handler = this.toolHandlers.get(name);
1141
1369
  const startedAt = Date.now();
1142
1370
  const hasResultError = (result) => {
@@ -1173,13 +1401,15 @@ var RealtimeCallHandler = class {
1173
1401
  }
1174
1402
  };
1175
1403
  if (name === REALTIME_VOICE_AGENT_CONSULT_TOOL_NAME) {
1404
+ if (this.activeBridgesByCallId.get(callId) !== bridge) return;
1176
1405
  const coordinator = harness.forcedConsults;
1177
1406
  const forcedMatch = coordinator.recordNativeConsult(args, bridgeCallId);
1178
1407
  if (forcedMatch.kind === "none") {
1179
1408
  const pending = coordinator.consumePending();
1180
1409
  if (pending) coordinator.remove(pending);
1181
1410
  }
1182
- const forcedConsult = this.forcedConsultsByCallId.get(callId);
1411
+ const forcedConsultState = this.forcedConsultsByCallId.get(callId);
1412
+ const forcedConsult = forcedConsultState?.owner === bridge && !forcedConsultState.cancelled ? forcedConsultState : void 0;
1183
1413
  if (forcedMatch.kind === "already_delivered" && coordinator.isCancelled(forcedMatch.handle)) {
1184
1414
  if (forcedConsult) forcedConsult.sendSpeechPrompt = false;
1185
1415
  await submitFinalToolResult({
@@ -1197,24 +1427,50 @@ var RealtimeCallHandler = class {
1197
1427
  return;
1198
1428
  }
1199
1429
  forcedConsult.sendSpeechPrompt = false;
1200
- await submitFinalToolResult(await forcedConsult.promise.catch((error) => ({ error: formatErrorMessage(error) })));
1430
+ const result = await forcedConsult.promise.catch((error) => ({ error: formatErrorMessage(error) }));
1431
+ if (forcedConsult.cancelled || forcedConsult.owner !== bridge || this.forcedConsultsByCallId.get(callId) !== forcedConsult) return;
1432
+ await submitFinalToolResult(result);
1201
1433
  return;
1202
1434
  }
1203
1435
  const existingNativeConsult = this.nativeConsultsInFlightByCallId.get(callId);
1204
1436
  if (existingNativeConsult) {
1205
1437
  console.log(`[voice-call] realtime tool call sharing in-flight agent consult callId=${callId} ageMs=${Date.now() - existingNativeConsult.startedAt}`);
1206
1438
  await submitWorkingResponse();
1207
- await submitFinalToolResult(await existingNativeConsult.promise);
1439
+ const outcome = await waitForNativeConsult(existingNativeConsult);
1440
+ if (outcome.kind === "cancelled") return;
1441
+ await submitFinalToolResult(outcome.result);
1208
1442
  return;
1209
1443
  }
1444
+ const abortController = new AbortController();
1445
+ let releaseCancellation = () => {};
1446
+ const cancellation = new Promise((resolve) => {
1447
+ releaseCancellation = resolve;
1448
+ });
1449
+ let completeConsult = (_result) => {};
1210
1450
  const state = {
1451
+ owner: bridge,
1211
1452
  startedAt,
1212
- promise: Promise.resolve()
1453
+ promise: new Promise((resolve) => {
1454
+ completeConsult = resolve;
1455
+ }),
1456
+ cancellation,
1457
+ cancelled: false,
1458
+ cancel: () => {
1459
+ abortController.abort(/* @__PURE__ */ new Error("Realtime native consult owner was cancelled."));
1460
+ releaseCancellation();
1461
+ }
1213
1462
  };
1214
- state.promise = submitWorkingResponse().then(async () => {
1463
+ this.nativeConsultsInFlightByCallId.set(callId, state);
1464
+ (async () => {
1215
1465
  try {
1216
- await this.waitForConsultTranscriptSettle(callId, startedAt);
1217
- const context = { partialUserTranscript: this.resolveUserTranscriptContext(callId) };
1466
+ await submitWorkingResponse();
1467
+ if (state.cancelled) return;
1468
+ await Promise.race([this.waitForConsultTranscriptSettle(callId, userTranscriptOwner, startedAt), state.cancellation]);
1469
+ if (state.cancelled) return;
1470
+ const context = {
1471
+ partialUserTranscript: this.resolveUserTranscriptContext(callId, userTranscriptOwner),
1472
+ abortSignal: abortController.signal
1473
+ };
1218
1474
  state.partialUserTranscript = context.partialUserTranscript;
1219
1475
  const handlerArgs = withFallbackConsultQuestion(args, context.partialUserTranscript);
1220
1476
  console.log(`[voice-call] realtime tool call executing callId=${callId} tool=${name} hasHandler=${Boolean(handler)}`);
@@ -1222,29 +1478,30 @@ var RealtimeCallHandler = class {
1222
1478
  } catch (error) {
1223
1479
  return { error: formatErrorMessage(error) };
1224
1480
  }
1225
- });
1226
- this.nativeConsultsInFlightByCallId.set(callId, state);
1481
+ })().then(completeConsult);
1227
1482
  try {
1228
- const result = await state.promise;
1483
+ const outcome = await waitForNativeConsult(state);
1484
+ if (outcome.kind === "cancelled") return;
1485
+ const result = outcome.result;
1229
1486
  const status = result && typeof result === "object" && !Array.isArray(result) && "error" in result ? "error" : "ok";
1230
1487
  const error = status === "error" && result && typeof result === "object" && !Array.isArray(result) ? formatErrorMessage(result.error ?? "unknown") : void 0;
1231
1488
  console.log(`[voice-call] realtime tool call completed callId=${callId} tool=${name} status=${status} elapsedMs=${Date.now() - startedAt}${error ? ` error=${error}` : ""}`);
1232
1489
  await submitFinalToolResult(result);
1233
- if (status === "ok") this.consumePartialUserTranscript(callId, state.partialUserTranscript);
1490
+ if (status === "ok") this.consumePartialUserTranscript(callId, userTranscriptOwner, state.partialUserTranscript);
1234
1491
  } finally {
1235
1492
  if (this.nativeConsultsInFlightByCallId.get(callId) === state) this.nativeConsultsInFlightByCallId.delete(callId);
1236
1493
  }
1237
1494
  return;
1238
1495
  }
1239
1496
  console.log(`[voice-call] realtime tool call executing callId=${callId} tool=${name} hasHandler=${Boolean(handler)}`);
1240
- const context = { partialUserTranscript: this.resolveUserTranscriptContext(callId) };
1497
+ const context = { partialUserTranscript: this.resolveUserTranscriptContext(callId, userTranscriptOwner) };
1241
1498
  const handlerArgs = name === REALTIME_VOICE_AGENT_CONSULT_TOOL_NAME ? withFallbackConsultQuestion(args, context.partialUserTranscript) : args;
1242
1499
  const result = !handler ? { error: `Tool "${name}" not available` } : await handler(handlerArgs, callId, context).catch((error) => ({ error: formatErrorMessage(error) }));
1243
1500
  const status = result && typeof result === "object" && !Array.isArray(result) && "error" in result ? "error" : "ok";
1244
1501
  const error = status === "error" && result && typeof result === "object" && !Array.isArray(result) ? formatErrorMessage(result.error ?? "unknown") : void 0;
1245
1502
  console.log(`[voice-call] realtime tool call completed callId=${callId} tool=${name} status=${status} elapsedMs=${Date.now() - startedAt}${error ? ` error=${error}` : ""}`);
1246
1503
  await submitFinalToolResult(result);
1247
- if (name === REALTIME_VOICE_AGENT_CONSULT_TOOL_NAME && status === "ok") this.consumePartialUserTranscript(callId, context.partialUserTranscript);
1504
+ if (name === REALTIME_VOICE_AGENT_CONSULT_TOOL_NAME && status === "ok") this.consumePartialUserTranscript(callId, userTranscriptOwner, context.partialUserTranscript);
1248
1505
  }
1249
1506
  };
1250
1507
  //#endregion