@openclaw/voice-call 2026.7.2-beta.7 → 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-D0UJiLfi.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);
@@ -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,25 @@ 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;
893
1003
  providerHandlesInputAudioBargeIn = session.bridge.handlesInputAudioBargeIn ?? providerHandlesInputAudioBargeIn;
894
1004
  const closeTelephony = (reason) => {
895
1005
  try {
@@ -898,10 +1008,20 @@ var RealtimeCallHandler = class {
898
1008
  emitCallEnd(reason);
899
1009
  }
900
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
+ });
901
1017
  this.activeBridgesByCallId.set(callId, session);
902
1018
  this.activeBridgesByCallId.set(callSid, session);
903
- this.activeTelephonyClosersByCallId.set(callId, closeTelephony);
904
- 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);
905
1025
  const sendAudioToSession = session.sendAudio.bind(session);
906
1026
  session.sendAudio = (audio) => {
907
1027
  if (speechDetector.accept({
@@ -924,86 +1044,181 @@ var RealtimeCallHandler = class {
924
1044
  try {
925
1045
  closeSession();
926
1046
  } finally {
927
- this.activeBridgesByCallId.delete(callId);
928
- this.activeBridgesByCallId.delete(callSid);
929
- this.activeTelephonyClosersByCallId.delete(callId);
930
- this.activeTelephonyClosersByCallId.delete(callSid);
931
- this.clearUserTranscriptState(callId);
932
- this.forcedConsultsByCallId.delete(callId);
1047
+ this.clearActiveBridgeMappings(callId, callSid, session);
1048
+ this.cancelConsultSession(callId, session);
1049
+ this.clearUserTranscriptState(callId, userTranscriptOwner);
933
1050
  harness.close();
934
1051
  audioPacer.close();
935
1052
  }
936
1053
  };
937
1054
  session.connect().catch((error) => {
938
1055
  console.error("[voice-call] Failed to connect realtime bridge:", error);
939
- session.close();
940
- emitCallEnd("error");
941
- 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
+ }
942
1065
  });
943
1066
  return session;
944
1067
  }
945
- recordPartialUserTranscript(callId, text) {
946
- const next = limitPartialUserTranscript(appendTranscriptText(this.partialUserTranscriptsByCallId.get(callId), text));
947
- const raw = limitPartialUserTranscript(`${this.rawPartialUserTranscriptsByCallId.get(callId) ?? ""}${text}`);
948
- this.partialUserTranscriptsByCallId.set(callId, next);
949
- this.rawPartialUserTranscriptsByCallId.set(callId, raw);
950
- 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();
951
1107
  return next;
952
1108
  }
953
- clearPartialUserTranscript(callId) {
954
- this.partialUserTranscriptsByCallId.delete(callId);
955
- this.rawPartialUserTranscriptsByCallId.delete(callId);
956
- 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;
957
1115
  }
958
- setRecentFinalUserTranscript(callId, text) {
959
- this.clearRecentFinalUserTranscript(callId);
960
- 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;
961
1121
  const timer = setTimeout(() => {
962
- if (this.recentFinalUserTranscriptsByCallId.get(callId) === text) this.recentFinalUserTranscriptsByCallId.delete(callId);
963
- 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;
964
1125
  }, RECENT_FINAL_USER_TRANSCRIPT_TTL_MS);
965
1126
  timer.unref?.();
966
- this.recentFinalUserTranscriptTimersByCallId.set(callId, timer);
1127
+ state.recentFinalTimer = timer;
967
1128
  }
968
- clearRecentFinalUserTranscript(callId) {
969
- const timer = this.recentFinalUserTranscriptTimersByCallId.get(callId);
970
- if (timer) {
971
- clearTimeout(timer);
972
- 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;
973
1135
  }
974
- 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;
975
1185
  }
976
- clearUserTranscriptState(callId) {
977
- this.clearPartialUserTranscript(callId);
978
- 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
+ }
979
1192
  }
980
- resolveUserTranscriptContext(callId) {
981
- 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;
982
1196
  }
983
- consumePartialUserTranscript(callId, consumed) {
1197
+ consumePartialUserTranscript(callId, owner, consumed) {
984
1198
  const text = consumed?.trim();
985
1199
  if (!text) return;
986
- const current = this.partialUserTranscriptsByCallId.get(callId);
1200
+ const state = this.getUserTranscriptState(callId, owner);
1201
+ const current = state?.partial;
987
1202
  if (!current) return;
988
1203
  if (current === text) {
989
- this.clearPartialUserTranscript(callId);
1204
+ this.clearPartialUserTranscript(callId, owner);
990
1205
  return;
991
1206
  }
992
1207
  if (current.toLowerCase().startsWith(text.toLowerCase())) {
993
1208
  const remaining = current.slice(text.length).trimStart();
994
1209
  if (remaining) {
995
- this.partialUserTranscriptsByCallId.set(callId, remaining);
996
- this.rawPartialUserTranscriptsByCallId.set(callId, remaining);
997
- } else this.clearPartialUserTranscript(callId);
1210
+ state.partial = remaining;
1211
+ state.rawPartial = remaining;
1212
+ } else this.clearPartialUserTranscript(callId, owner);
998
1213
  }
999
- const recent = this.recentFinalUserTranscriptsByCallId.get(callId);
1214
+ const recent = state.recentFinal;
1000
1215
  if (!recent) return;
1001
- if (recent === text || recent.toLowerCase().startsWith(text.toLowerCase())) this.clearRecentFinalUserTranscript(callId);
1216
+ if (recent === text || recent.toLowerCase().startsWith(text.toLowerCase())) this.clearRecentFinalUserTranscript(callId, owner);
1002
1217
  }
1003
- async waitForConsultTranscriptSettle(callId, startedAt) {
1218
+ async waitForConsultTranscriptSettle(callId, owner, startedAt) {
1004
1219
  const deadline = startedAt + CONSULT_TRANSCRIPT_SETTLE_MAX_MS;
1005
1220
  while (true) {
1006
- const updatedAt = this.partialUserTranscriptUpdatedAtByCallId.get(callId);
1221
+ const updatedAt = this.getUserTranscriptState(callId, owner)?.partialUpdatedAt;
1007
1222
  if (!updatedAt) return;
1008
1223
  const now = Date.now();
1009
1224
  const quietFor = now - updatedAt;
@@ -1015,14 +1230,14 @@ var RealtimeCallHandler = class {
1015
1230
  }
1016
1231
  closeTelephonyBridge(callIdOrSid, bridge, reason) {
1017
1232
  const closer = this.activeTelephonyClosersByCallId.get(callIdOrSid);
1018
- if (closer) {
1019
- closer(reason);
1233
+ if (closer && closer.owner === bridge) {
1234
+ closer.close(reason);
1020
1235
  return;
1021
1236
  }
1022
1237
  bridge?.close();
1023
1238
  }
1024
1239
  scheduleForcedAgentConsult(params) {
1025
- if (this.config.consultPolicy !== "always") return;
1240
+ if (this.config.consultPolicy !== "always" || this.activeBridgesByCallId.get(params.callId) !== params.session) return;
1026
1241
  const question = params.transcript.trim();
1027
1242
  if (!question) return;
1028
1243
  const handler = this.toolHandlers.get(REALTIME_VOICE_AGENT_CONSULT_TOOL_NAME);
@@ -1051,13 +1266,24 @@ var RealtimeCallHandler = class {
1051
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}`);
1052
1267
  console.log(`[voice-call] realtime forced agent consult starting callId=${params.callId} providerCallId=${params.callSid} chars=${params.handle.question.length}`);
1053
1268
  params.clearAudio();
1269
+ const abortController = new AbortController();
1054
1270
  const state = {
1271
+ owner: params.session,
1055
1272
  sendSpeechPrompt: true,
1056
- 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
+ })
1057
1282
  };
1058
1283
  this.forcedConsultsByCallId.set(params.callId, state);
1059
1284
  try {
1060
1285
  const result = await state.promise;
1286
+ if (state.cancelled || this.forcedConsultsByCallId.get(params.callId) !== state) return;
1061
1287
  state.completedAt = Date.now();
1062
1288
  coordinator.markDelivered(params.handle);
1063
1289
  const text = readSpeakableRealtimeVoiceToolResult(result, {
@@ -1073,11 +1299,12 @@ var RealtimeCallHandler = class {
1073
1299
  params.session.sendUserMessage(buildForcedConsultSpeechPrompt(text));
1074
1300
  }
1075
1301
  console.log(`[voice-call] realtime forced agent consult completed callId=${params.callId} providerCallId=${params.callSid} elapsedMs=${Date.now() - startedAt}`);
1076
- this.consumePartialUserTranscript(params.callId, params.handle.question);
1302
+ this.consumePartialUserTranscript(params.callId, params.userTranscriptOwner, params.handle.question);
1077
1303
  } catch (error) {
1078
- 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)}`);
1079
1305
  } finally {
1080
- setTimeout(() => {
1306
+ if (!state.cancelled) if (this.forcedConsultsByCallId.get(params.callId) !== state) coordinator.remove(params.handle);
1307
+ else setTimeout(() => {
1081
1308
  if (this.forcedConsultsByCallId.get(params.callId) === state) {
1082
1309
  this.forcedConsultsByCallId.delete(params.callId);
1083
1310
  coordinator.remove(params.handle);
@@ -1137,7 +1364,7 @@ var RealtimeCallHandler = class {
1137
1364
  reason
1138
1365
  });
1139
1366
  }
1140
- async executeToolCall(bridge, callId, bridgeCallId, name, args, turnId, harness) {
1367
+ async executeToolCall(bridge, callId, bridgeCallId, name, args, turnId, harness, userTranscriptOwner) {
1141
1368
  const handler = this.toolHandlers.get(name);
1142
1369
  const startedAt = Date.now();
1143
1370
  const hasResultError = (result) => {
@@ -1174,13 +1401,15 @@ var RealtimeCallHandler = class {
1174
1401
  }
1175
1402
  };
1176
1403
  if (name === REALTIME_VOICE_AGENT_CONSULT_TOOL_NAME) {
1404
+ if (this.activeBridgesByCallId.get(callId) !== bridge) return;
1177
1405
  const coordinator = harness.forcedConsults;
1178
1406
  const forcedMatch = coordinator.recordNativeConsult(args, bridgeCallId);
1179
1407
  if (forcedMatch.kind === "none") {
1180
1408
  const pending = coordinator.consumePending();
1181
1409
  if (pending) coordinator.remove(pending);
1182
1410
  }
1183
- const forcedConsult = this.forcedConsultsByCallId.get(callId);
1411
+ const forcedConsultState = this.forcedConsultsByCallId.get(callId);
1412
+ const forcedConsult = forcedConsultState?.owner === bridge && !forcedConsultState.cancelled ? forcedConsultState : void 0;
1184
1413
  if (forcedMatch.kind === "already_delivered" && coordinator.isCancelled(forcedMatch.handle)) {
1185
1414
  if (forcedConsult) forcedConsult.sendSpeechPrompt = false;
1186
1415
  await submitFinalToolResult({
@@ -1198,24 +1427,50 @@ var RealtimeCallHandler = class {
1198
1427
  return;
1199
1428
  }
1200
1429
  forcedConsult.sendSpeechPrompt = false;
1201
- 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);
1202
1433
  return;
1203
1434
  }
1204
1435
  const existingNativeConsult = this.nativeConsultsInFlightByCallId.get(callId);
1205
1436
  if (existingNativeConsult) {
1206
1437
  console.log(`[voice-call] realtime tool call sharing in-flight agent consult callId=${callId} ageMs=${Date.now() - existingNativeConsult.startedAt}`);
1207
1438
  await submitWorkingResponse();
1208
- await submitFinalToolResult(await existingNativeConsult.promise);
1439
+ const outcome = await waitForNativeConsult(existingNativeConsult);
1440
+ if (outcome.kind === "cancelled") return;
1441
+ await submitFinalToolResult(outcome.result);
1209
1442
  return;
1210
1443
  }
1444
+ const abortController = new AbortController();
1445
+ let releaseCancellation = () => {};
1446
+ const cancellation = new Promise((resolve) => {
1447
+ releaseCancellation = resolve;
1448
+ });
1449
+ let completeConsult = (_result) => {};
1211
1450
  const state = {
1451
+ owner: bridge,
1212
1452
  startedAt,
1213
- 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
+ }
1214
1462
  };
1215
- state.promise = submitWorkingResponse().then(async () => {
1463
+ this.nativeConsultsInFlightByCallId.set(callId, state);
1464
+ (async () => {
1216
1465
  try {
1217
- await this.waitForConsultTranscriptSettle(callId, startedAt);
1218
- 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
+ };
1219
1474
  state.partialUserTranscript = context.partialUserTranscript;
1220
1475
  const handlerArgs = withFallbackConsultQuestion(args, context.partialUserTranscript);
1221
1476
  console.log(`[voice-call] realtime tool call executing callId=${callId} tool=${name} hasHandler=${Boolean(handler)}`);
@@ -1223,29 +1478,30 @@ var RealtimeCallHandler = class {
1223
1478
  } catch (error) {
1224
1479
  return { error: formatErrorMessage(error) };
1225
1480
  }
1226
- });
1227
- this.nativeConsultsInFlightByCallId.set(callId, state);
1481
+ })().then(completeConsult);
1228
1482
  try {
1229
- const result = await state.promise;
1483
+ const outcome = await waitForNativeConsult(state);
1484
+ if (outcome.kind === "cancelled") return;
1485
+ const result = outcome.result;
1230
1486
  const status = result && typeof result === "object" && !Array.isArray(result) && "error" in result ? "error" : "ok";
1231
1487
  const error = status === "error" && result && typeof result === "object" && !Array.isArray(result) ? formatErrorMessage(result.error ?? "unknown") : void 0;
1232
1488
  console.log(`[voice-call] realtime tool call completed callId=${callId} tool=${name} status=${status} elapsedMs=${Date.now() - startedAt}${error ? ` error=${error}` : ""}`);
1233
1489
  await submitFinalToolResult(result);
1234
- if (status === "ok") this.consumePartialUserTranscript(callId, state.partialUserTranscript);
1490
+ if (status === "ok") this.consumePartialUserTranscript(callId, userTranscriptOwner, state.partialUserTranscript);
1235
1491
  } finally {
1236
1492
  if (this.nativeConsultsInFlightByCallId.get(callId) === state) this.nativeConsultsInFlightByCallId.delete(callId);
1237
1493
  }
1238
1494
  return;
1239
1495
  }
1240
1496
  console.log(`[voice-call] realtime tool call executing callId=${callId} tool=${name} hasHandler=${Boolean(handler)}`);
1241
- const context = { partialUserTranscript: this.resolveUserTranscriptContext(callId) };
1497
+ const context = { partialUserTranscript: this.resolveUserTranscriptContext(callId, userTranscriptOwner) };
1242
1498
  const handlerArgs = name === REALTIME_VOICE_AGENT_CONSULT_TOOL_NAME ? withFallbackConsultQuestion(args, context.partialUserTranscript) : args;
1243
1499
  const result = !handler ? { error: `Tool "${name}" not available` } : await handler(handlerArgs, callId, context).catch((error) => ({ error: formatErrorMessage(error) }));
1244
1500
  const status = result && typeof result === "object" && !Array.isArray(result) && "error" in result ? "error" : "ok";
1245
1501
  const error = status === "error" && result && typeof result === "object" && !Array.isArray(result) ? formatErrorMessage(result.error ?? "unknown") : void 0;
1246
1502
  console.log(`[voice-call] realtime tool call completed callId=${callId} tool=${name} status=${status} elapsedMs=${Date.now() - startedAt}${error ? ` error=${error}` : ""}`);
1247
1503
  await submitFinalToolResult(result);
1248
- 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);
1249
1505
  }
1250
1506
  };
1251
1507
  //#endregion