@openclaw/voice-call 2026.7.2-beta.7 → 2026.8.1-beta.3

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,9 +1,10 @@
1
- import { o as canonicalizeVoiceCallMediaBase64 } from "./runtime-entry-D0UJiLfi.js";
1
+ import { T as REALTIME_VOICE_END_CALL_TOOL_NAME, o as canonicalizeVoiceCallMediaBase64, x as resolveVoiceCallPublicPathPrefix } from "./runtime-entry-DK8kPCGC.js";
2
2
  import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
3
- import { isFutureDateTimestampMs, resolveExpiresAtMsFromDurationMs } from "openclaw/plugin-sdk/number-runtime";
3
+ import { asNullableRecord, asOptionalObjectRecord } from "openclaw/plugin-sdk/string-coerce-runtime";
4
4
  import { createSubsystemLogger } from "openclaw/plugin-sdk/runtime-env";
5
5
  import { REALTIME_VOICE_AGENT_CONSULT_TOOL_NAME, buildRealtimeVoiceAgentConsultWorkingResponse, calculateMulawRms, createRealtimeVoiceSessionHarness, createSpeechThresholdGate, readRealtimeVoiceConsultQuestion, readSpeakableRealtimeVoiceToolResult } from "openclaw/plugin-sdk/realtime-voice";
6
6
  import { normalizeWebhookPath } from "openclaw/plugin-sdk/webhook-ingress";
7
+ import { isFutureDateTimestampMs, resolveExpiresAtMsFromDurationMs } from "openclaw/plugin-sdk/number-runtime";
7
8
  import { randomUUID } from "node:crypto";
8
9
  import { sliceUtf16Safe, truncateUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime";
9
10
  import "node:http";
@@ -11,16 +12,19 @@ import WebSocket$1, { WebSocketServer } from "ws";
11
12
  //#region extensions/voice-call/src/webhook/realtime-audio-pacer.ts
12
13
  const TELEPHONY_SAMPLE_RATE = 8e3;
13
14
  const TELEPHONY_CHUNK_BYTES = 160;
14
- const TELEPHONY_CHUNK_MS = 20;
15
+ const LEAD_MS = 160;
15
16
  const DEFAULT_MAX_QUEUED_AUDIO_BYTES = TELEPHONY_SAMPLE_RATE * 120;
17
+ const QUEUE_COMPACT_HEAD_THRESHOLD = 256;
16
18
  /** Paces outgoing mulaw audio frames at telephony cadence. */
17
19
  var RealtimeAudioPacer = class {
18
20
  constructor(params) {
19
21
  this.params = params;
20
22
  this.queue = [];
23
+ this.queueHead = 0;
21
24
  this.timer = null;
22
25
  this.queuedAudioBytes = 0;
23
26
  this.closed = false;
27
+ this.streamClockMs = null;
24
28
  }
25
29
  /** Queue mulaw audio and split it into 20ms-ish telephony chunks. */
26
30
  sendAudio(muLaw) {
@@ -35,7 +39,7 @@ var RealtimeAudioPacer = class {
35
39
  this.queue.push({
36
40
  type: "audio",
37
41
  chunk,
38
- durationMs: Math.max(1, Math.round(chunk.length / TELEPHONY_SAMPLE_RATE * 1e3))
42
+ durationMs: chunk.length / 8
39
43
  });
40
44
  this.queuedAudioBytes += chunk.length;
41
45
  }
@@ -55,8 +59,9 @@ var RealtimeAudioPacer = class {
55
59
  if (this.closed) return 0;
56
60
  const clearedAudioBytes = this.queuedAudioBytes;
57
61
  this.clearTimer();
58
- this.queue = [];
62
+ this.resetQueue();
59
63
  this.queuedAudioBytes = 0;
64
+ this.streamClockMs = null;
60
65
  this.params.send(this.params.serializer.clear());
61
66
  return clearedAudioBytes;
62
67
  }
@@ -68,8 +73,9 @@ var RealtimeAudioPacer = class {
68
73
  close() {
69
74
  this.closed = true;
70
75
  this.clearTimer();
71
- this.queue = [];
76
+ this.resetQueue();
72
77
  this.queuedAudioBytes = 0;
78
+ this.streamClockMs = null;
73
79
  }
74
80
  /** Clear the scheduled pump timer. */
75
81
  clearTimer() {
@@ -86,25 +92,56 @@ var RealtimeAudioPacer = class {
86
92
  this.close();
87
93
  this.params.onBackpressure?.();
88
94
  }
89
- /** Send one queued item and schedule the next send based on audio duration. */
95
+ get pendingQueueSize() {
96
+ return Math.max(0, this.queue.length - this.queueHead);
97
+ }
98
+ /** Take one queued item without shifting the remaining paced-audio backlog. */
99
+ takeNextItem() {
100
+ if (this.queueHead >= this.queue.length) {
101
+ this.resetQueue();
102
+ return;
103
+ }
104
+ const item = this.queue[this.queueHead];
105
+ this.queueHead += 1;
106
+ if (this.queueHead >= this.queue.length) this.resetQueue();
107
+ else if (this.queueHead > QUEUE_COMPACT_HEAD_THRESHOLD && this.queueHead * 2 > this.queue.length) {
108
+ this.queue.splice(0, this.queueHead);
109
+ this.queueHead = 0;
110
+ }
111
+ return item;
112
+ }
113
+ resetQueue() {
114
+ this.queue.length = 0;
115
+ this.queueHead = 0;
116
+ }
117
+ /** Fill the provider playout cushion, then wake at the next timeline boundary. */
90
118
  pump() {
91
119
  this.timer = null;
92
120
  if (this.closed) return;
93
- const item = this.queue.shift();
94
- if (!item) return;
95
- let delayMs = 0;
96
- let sent;
97
- if (item.type === "audio") {
98
- this.queuedAudioBytes = Math.max(0, this.queuedAudioBytes - item.chunk.length);
99
- sent = this.params.send(this.params.serializer.media(item.chunk.toString("base64")));
100
- delayMs = item.durationMs || TELEPHONY_CHUNK_MS;
101
- } else sent = this.params.send(this.params.serializer.mark(item.name));
102
- if (!sent) {
103
- this.queue = [];
104
- this.queuedAudioBytes = 0;
121
+ const now = performance.now();
122
+ this.streamClockMs ??= now;
123
+ while (this.pendingQueueSize > 0 && this.streamClockMs < now + LEAD_MS) {
124
+ const item = this.takeNextItem();
125
+ if (!item) break;
126
+ if (!(item.type === "audio" ? this.sendAudioItem(item) : this.params.send(this.params.serializer.mark(item.name)))) {
127
+ this.resetQueue();
128
+ this.queuedAudioBytes = 0;
129
+ this.streamClockMs = null;
130
+ return;
131
+ }
132
+ }
133
+ if (this.pendingQueueSize === 0) {
134
+ this.streamClockMs = null;
105
135
  return;
106
136
  }
107
- if (this.queue.length > 0) this.timer = setTimeout(() => this.pump(), delayMs);
137
+ const delayMs = Math.max(1, this.streamClockMs - LEAD_MS - performance.now());
138
+ this.timer = setTimeout(() => this.pump(), delayMs);
139
+ }
140
+ sendAudioItem(item) {
141
+ this.queuedAudioBytes = Math.max(0, this.queuedAudioBytes - item.chunk.length);
142
+ const sent = this.params.send(this.params.serializer.media(item.chunk.toString("base64")));
143
+ this.streamClockMs = (this.streamClockMs ?? performance.now()) + item.durationMs;
144
+ return sent;
108
145
  }
109
146
  };
110
147
  //#endregion
@@ -120,15 +157,13 @@ function parseTimestampMs(value) {
120
157
  /** Parse a JSON object frame, returning null for invalid or non-object payloads. */
121
158
  function tryParseJson(rawMessage) {
122
159
  try {
123
- const parsed = JSON.parse(rawMessage);
124
- if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) return parsed;
160
+ return asNullableRecord(JSON.parse(rawMessage));
125
161
  } catch {}
126
162
  return null;
127
163
  }
128
164
  /** Read an object-valued field from a parsed frame. */
129
165
  function readRecordField(record, field) {
130
- const value = record[field];
131
- return typeof value === "object" && value !== null ? value : void 0;
166
+ return asOptionalObjectRecord(record[field]);
132
167
  }
133
168
  /** Parse a common provider media frame. */
134
169
  function parseMediaFrame(msg) {
@@ -273,6 +308,8 @@ const STREAM_TOKEN_TTL_MS = 3e4;
273
308
  const DEFAULT_HOST = "localhost:8443";
274
309
  const MAX_REALTIME_MESSAGE_BYTES = 256 * 1024;
275
310
  const MAX_REALTIME_WS_BUFFERED_BYTES = 1024 * 1024;
311
+ const REALTIME_MEDIA_INACTIVITY_TIMEOUT_MS = 3e4;
312
+ const REALTIME_DISCONNECT_HANGUP_GRACE_MS = 2e3;
276
313
  const FORCED_CONSULT_FALLBACK_DELAY_MS = 200;
277
314
  const FORCED_CONSULT_NATIVE_DEDUPE_MS = 2e3;
278
315
  const FORCED_CONSULT_RESULT_MAX_CHARS = 1800;
@@ -377,6 +414,12 @@ function buildForcedConsultSpeechPrompt(result) {
377
414
  trimmed.length <= FORCED_CONSULT_RESULT_MAX_CHARS ? trimmed : `${truncateUtf16Safe(trimmed, FORCED_CONSULT_RESULT_MAX_CHARS - 16).trimEnd()} [truncated]`
378
415
  ].join("\n");
379
416
  }
417
+ async function waitForNativeConsult(state) {
418
+ return await Promise.race([state.promise.then((result) => ({
419
+ kind: "completed",
420
+ result
421
+ })), state.cancellation.then(() => ({ kind: "cancelled" }))]);
422
+ }
380
423
  function appendRecentTalkEventMetadata(call, event) {
381
424
  if (!call) return;
382
425
  const metadata = call.metadata ?? {};
@@ -399,26 +442,26 @@ function appendRecentTalkEventMetadata(call, event) {
399
442
  call.metadata = metadata;
400
443
  }
401
444
  var RealtimeCallHandler = class {
402
- constructor(config, manager, provider, realtimeProvider, providerConfig, servePath, coreConfig, resolveInstructions) {
445
+ constructor(config, manager, resolveCallRegistration, servePath, streamDisconnectLifecycle, coreConfig) {
403
446
  this.config = config;
404
447
  this.manager = manager;
405
- this.provider = provider;
406
- this.realtimeProvider = realtimeProvider;
407
- this.providerConfig = providerConfig;
448
+ this.resolveCallRegistration = resolveCallRegistration;
408
449
  this.servePath = servePath;
450
+ this.streamDisconnectLifecycle = streamDisconnectLifecycle;
409
451
  this.coreConfig = coreConfig;
410
- this.resolveInstructions = resolveInstructions;
411
452
  this.toolHandlers = /* @__PURE__ */ new Map();
412
453
  this.pendingStreamTokens = /* @__PURE__ */ new Map();
454
+ this.activeSockets = /* @__PURE__ */ new Set();
455
+ this.serverClosingSockets = /* @__PURE__ */ new WeakSet();
413
456
  this.activeBridgesByCallId = /* @__PURE__ */ new Map();
414
- 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();
457
+ this.activeTelephonyBindingsByCallId = /* @__PURE__ */ new Map();
458
+ this.userTranscriptStatesByCallId = /* @__PURE__ */ new Map();
420
459
  this.forcedConsultsByCallId = /* @__PURE__ */ new Map();
460
+ this.consultSessionsByCallId = /* @__PURE__ */ new Map();
421
461
  this.nativeConsultsInFlightByCallId = /* @__PURE__ */ new Map();
462
+ this.terminationAttempts = /* @__PURE__ */ new Set();
463
+ this.closePromise = null;
464
+ this.closing = false;
422
465
  this.publicOrigin = null;
423
466
  this.publicPathPrefix = "";
424
467
  }
@@ -426,10 +469,7 @@ var RealtimeCallHandler = class {
426
469
  try {
427
470
  const parsed = new URL(url);
428
471
  this.publicOrigin = parsed.host;
429
- const normalizedServePath = normalizeWebhookPath(this.servePath);
430
- const normalizedPublicPath = normalizeWebhookPath(parsed.pathname);
431
- const idx = normalizedPublicPath.indexOf(normalizedServePath);
432
- this.publicPathPrefix = idx > 0 ? normalizedPublicPath.slice(0, idx) : "";
472
+ this.publicPathPrefix = resolveVoiceCallPublicPathPrefix(parsed.pathname, this.servePath);
433
473
  } catch {
434
474
  this.publicOrigin = null;
435
475
  this.publicPathPrefix = "";
@@ -464,6 +504,11 @@ var RealtimeCallHandler = class {
464
504
  }
465
505
  }
466
506
  handleWebSocketUpgrade(request, socket, head) {
507
+ if (this.closing) {
508
+ socket.write("HTTP/1.1 503 Service Unavailable\r\nConnection: close\r\n\r\n");
509
+ socket.destroy();
510
+ return;
511
+ }
467
512
  const token = new URL(request.url ?? "/", "wss://localhost").pathname.split("/").pop() ?? null;
468
513
  const callerMeta = token ? this.consumeStreamToken(token) : null;
469
514
  if (!callerMeta) {
@@ -476,10 +521,11 @@ var RealtimeCallHandler = class {
476
521
  noServer: true,
477
522
  maxPayload: MAX_REALTIME_MESSAGE_BYTES
478
523
  }).handleUpgrade(request, socket, head, (ws) => {
479
- let bridge = null;
524
+ this.activeSockets.add(ws);
525
+ let telephonyBinding = null;
480
526
  let initialized = false;
481
527
  let activeCallSid = "unknown";
482
- let stopReceived = false;
528
+ let activeStreamSid = "unknown";
483
529
  let lastMediaTimestamp;
484
530
  let lastMediaGapWarnAt = 0;
485
531
  ws.on("message", (data) => {
@@ -490,15 +536,18 @@ var RealtimeCallHandler = class {
490
536
  if (initialized) return;
491
537
  initialized = true;
492
538
  activeCallSid = frame.providerCallId;
493
- const nextBridge = this.handleCall(frame.streamId, frame.providerCallId, ws, callerMeta, adapter);
494
- if (!nextBridge) return;
495
- bridge = nextBridge;
539
+ activeStreamSid = frame.streamId;
540
+ const nextBinding = this.handleCall(frame.streamId, frame.providerCallId, ws, callerMeta, adapter);
541
+ if (!nextBinding) return;
542
+ telephonyBinding = nextBinding;
543
+ this.streamDisconnectLifecycle.connect(activeCallSid, activeStreamSid);
496
544
  return;
497
545
  }
498
- if (!bridge) return;
546
+ if (!telephonyBinding) return;
499
547
  if (frame.kind === "media") {
500
548
  const audio = Buffer.from(frame.payloadBase64, "base64");
501
- bridge.sendAudio(audio);
549
+ telephonyBinding.noteMediaActivity();
550
+ telephonyBinding.bridge.sendAudio(audio);
502
551
  if (frame.timestampMs !== void 0) {
503
552
  if (lastMediaTimestamp !== void 0) {
504
553
  const gapMs = frame.timestampMs - lastMediaTimestamp;
@@ -509,35 +558,60 @@ var RealtimeCallHandler = class {
509
558
  }
510
559
  }
511
560
  lastMediaTimestamp = frame.timestampMs;
512
- bridge.setMediaTimestamp(frame.timestampMs);
561
+ telephonyBinding.bridge.setMediaTimestamp(frame.timestampMs);
513
562
  }
514
563
  return;
515
564
  }
516
565
  if (frame.kind === "mark") {
517
- bridge.acknowledgeMark();
566
+ telephonyBinding.bridge.acknowledgeMark();
518
567
  return;
519
568
  }
520
569
  if (frame.kind === "error") {
521
570
  console.error(`[voice-call] realtime WS error frame providerCallId=${activeCallSid} code=${frame.code ?? "?"} title=${frame.title ?? ""} detail=${frame.detail ?? ""}`);
522
571
  return;
523
572
  }
524
- if (frame.kind === "stop") {
525
- stopReceived = true;
526
- this.closeTelephonyBridge(activeCallSid, bridge, "completed");
527
- }
573
+ if (frame.kind === "stop") telephonyBinding.close("disconnect");
528
574
  } catch (error) {
529
575
  console.error("[voice-call] realtime WS parse failed:", error);
530
576
  }
531
577
  });
532
- ws.on("close", (code) => {
533
- const reason = stopReceived || code === 1e3 || code === 1005 ? "completed" : "error";
534
- this.closeTelephonyBridge(activeCallSid, bridge, reason);
578
+ ws.on("close", () => {
579
+ this.activeSockets.delete(ws);
580
+ const reason = this.serverClosingSockets.has(ws) ? "shutdown" : "disconnect";
581
+ if (telephonyBinding) telephonyBinding.close(reason);
535
582
  });
536
583
  ws.on("error", (error) => {
537
584
  console.error("[voice-call] realtime WS error:", error);
585
+ ws.terminate();
538
586
  });
587
+ if (this.closing) {
588
+ this.serverClosingSockets.add(ws);
589
+ ws.terminate();
590
+ }
539
591
  });
540
592
  }
593
+ close(shutdownBarrier = Promise.resolve()) {
594
+ if (this.closePromise) return this.closePromise;
595
+ this.closing = true;
596
+ this.pendingStreamTokens.clear();
597
+ const sockets = [...this.activeSockets];
598
+ this.closePromise = Promise.all([shutdownBarrier, ...sockets.map((ws) => new Promise((resolve) => {
599
+ if (ws.readyState === WebSocket$1.CLOSED) {
600
+ resolve();
601
+ return;
602
+ }
603
+ this.serverClosingSockets.add(ws);
604
+ ws.once("close", () => resolve());
605
+ ws.terminate();
606
+ }))]).then(async () => {
607
+ await Promise.all(this.terminationAttempts);
608
+ this.pendingStreamTokens.clear();
609
+ }).finally(() => {
610
+ this.closing = false;
611
+ this.closePromise = null;
612
+ });
613
+ return this.closePromise;
614
+ }
541
615
  registerToolHandler(name, fn) {
542
616
  this.toolHandlers.set(name, fn);
543
617
  }
@@ -572,13 +646,24 @@ var RealtimeCallHandler = class {
572
646
  }
573
647
  issueStreamToken(meta = {}) {
574
648
  const token = randomUUID();
575
- const now = Date.now();
576
- const expiry = resolveExpiresAtMsFromDurationMs(STREAM_TOKEN_TTL_MS, { nowMs: now });
577
- if (expiry !== void 0) this.pendingStreamTokens.set(token, {
578
- expiry,
579
- ...meta
580
- });
581
- for (const [candidate, entry] of this.pendingStreamTokens) if (!isFutureDateTimestampMs(entry.expiry, { nowMs: now })) this.pendingStreamTokens.delete(candidate);
649
+ const expiry = resolveExpiresAtMsFromDurationMs(STREAM_TOKEN_TTL_MS, { nowMs: Date.now() });
650
+ if (expiry !== void 0) {
651
+ this.pendingStreamTokens.set(token, {
652
+ expiry,
653
+ ...meta
654
+ });
655
+ const host = this.publicOrigin || DEFAULT_HOST;
656
+ const streamPathPattern = this.getStreamPathPattern();
657
+ setTimeout(() => {
658
+ if (!this.pendingStreamTokens.has(token)) return;
659
+ this.pendingStreamTokens.delete(token);
660
+ if (this.closing) return;
661
+ const call = meta.callId ? ` for call ${meta.callId}` : "";
662
+ const endpoints = [meta.from ? `from ${meta.from}` : "", meta.to ? `to ${meta.to}` : ""].filter(Boolean).join(" ");
663
+ const participants = endpoints ? ` (${endpoints})` : "";
664
+ console.warn(`[voice-call] Realtime stream WebSocket never connected within ${STREAM_TOKEN_TTL_MS / 1e3}s${call}${participants} — the provider could not reach wss://${host}${streamPathPattern}/<token>. Verify the stream path is exposed (tailscale serve/funnel --set-path).`);
665
+ }, STREAM_TOKEN_TTL_MS).unref?.();
666
+ }
582
667
  return token;
583
668
  }
584
669
  consumeStreamToken(token) {
@@ -595,20 +680,58 @@ var RealtimeCallHandler = class {
595
680
  };
596
681
  }
597
682
  handleCall(streamSid, callSid, ws, callerMeta, adapter) {
598
- const registration = this.registerCallInManager(callSid, callerMeta);
599
- if (!registration) {
683
+ const preparedCall = this.prepareCallInManager(callSid, callerMeta);
684
+ if (!preparedCall) {
600
685
  ws.close(1008, "Caller rejected by policy");
601
686
  return null;
602
687
  }
603
- const { callId, instructions, initialGreetingInstructions } = registration;
604
- const callRecord = this.manager.getCallByProviderCallId(callSid);
688
+ const { callRecord } = preparedCall;
689
+ const callId = callRecord.callId;
690
+ const hadPredecessorOnAdmission = this.activeBridgesByCallId.has(callId);
691
+ const previousTelephonyBinding = this.activeTelephonyBindingsByCallId.get(callId);
692
+ let callEndPromise;
693
+ const emitCallEnd = (cause) => {
694
+ if (callEndPromise) return callEndPromise;
695
+ const reason = cause === "error" ? "error" : cause === "inactivity" ? "timeout" : "completed";
696
+ const attempt = this.manager.endCall(callId, { reason }).then((result) => {
697
+ if (!result.success) {
698
+ console.warn(`[voice-call] Failed to end realtime call callId=${callId} providerCallId=${callSid} reason=${reason}: ${result.error ?? "unknown error"}; call remains active`);
699
+ return;
700
+ }
701
+ console.log(`[voice-call] Realtime call ended callId=${callId} providerCallId=${callSid} reason=${cause}`);
702
+ });
703
+ callEndPromise = attempt;
704
+ this.terminationAttempts.add(attempt);
705
+ const release = () => this.terminationAttempts.delete(attempt);
706
+ attempt.then(release, release);
707
+ return attempt;
708
+ };
709
+ let registration;
710
+ try {
711
+ registration = this.resolveCallRegistration(callRecord);
712
+ } catch (error) {
713
+ console.error(`[voice-call] Failed to resolve realtime call registration callId=${callId} providerCallId=${callSid}: ${formatErrorMessage(error)}`);
714
+ if (!hadPredecessorOnAdmission) emitCallEnd("error");
715
+ ws.close(1011, "Check realtime configuration for routed agent");
716
+ return null;
717
+ }
718
+ const { baseFields, initialGreeting } = preparedCall;
719
+ if (callRecord.metadata) delete callRecord.metadata.initialMessage;
720
+ this.manager.processEvent({
721
+ id: `realtime-answered-${callSid}`,
722
+ callId,
723
+ type: "call.answered",
724
+ ...baseFields
725
+ });
726
+ const { agentId, instructions, provider: realtimeProvider, providerConfig } = registration;
727
+ const initialGreetingInstructions = buildGreetingInstructions(instructions, initialGreeting);
605
728
  const harness = createRealtimeVoiceSessionHarness({
606
729
  talk: {
607
730
  sessionId: `voice-call:${callId}:realtime`,
608
731
  mode: "realtime",
609
732
  transport: "gateway-relay",
610
733
  brain: "agent-consult",
611
- provider: this.realtimeProvider.id
734
+ provider: realtimeProvider.id
612
735
  },
613
736
  talkPayloads: {
614
737
  turnStarted: () => ({
@@ -634,7 +757,7 @@ var RealtimeCallHandler = class {
634
757
  },
635
758
  onTalkEvent: (event) => appendRecentTalkEventMetadata(callRecord, event)
636
759
  });
637
- let providerHandlesInputAudioBargeIn = this.realtimeProvider.capabilities?.handlesInputAudioBargeIn === true;
760
+ let providerHandlesInputAudioBargeIn = realtimeProvider.capabilities?.handlesInputAudioBargeIn === true;
638
761
  const cancelOutputAudioForBargeIn = (source, interruptProvider, clearedAudioBytes = 0) => {
639
762
  const outputAudioActive = harness.talk.outputAudioActive;
640
763
  const pendingTelephonyAudio = audioPacer.hasPendingAudio();
@@ -664,12 +787,6 @@ var RealtimeCallHandler = class {
664
787
  }
665
788
  });
666
789
  console.log(`[voice-call] Realtime bridge starting for call ${callId} (providerCallId=${callSid}, initialGreeting=${initialGreetingInstructions ? "queued" : "absent"})`);
667
- let callEndEmitted = false;
668
- const emitCallEnd = (reason) => {
669
- if (callEndEmitted) return;
670
- callEndEmitted = true;
671
- this.endCallInManager(callSid, callId, reason);
672
- };
673
790
  const sendString = (message) => {
674
791
  if (ws.readyState !== WebSocket$1.OPEN) return false;
675
792
  if (ws.bufferedAmount > MAX_REALTIME_WS_BUFFERED_BYTES) {
@@ -702,11 +819,15 @@ var RealtimeCallHandler = class {
702
819
  speechFrames: BARGE_IN_REQUIRED_LOUD_CHUNKS,
703
820
  silenceFrames: 12
704
821
  });
705
- const interruptResponseOnInputAudio = typeof this.providerConfig.interruptResponseOnInputAudio === "boolean" ? this.providerConfig.interruptResponseOnInputAudio : void 0;
706
- const session = harness.createBridge({
707
- provider: this.realtimeProvider,
822
+ const interruptResponseOnInputAudio = typeof providerConfig.interruptResponseOnInputAudio === "boolean" ? providerConfig.interruptResponseOnInputAudio : void 0;
823
+ const nativeConsultOwner = {};
824
+ const userTranscriptAdoption = this.beginUserTranscriptOwnerAdoption(callId);
825
+ const userTranscriptOwner = userTranscriptAdoption.owner;
826
+ const bridgeParams = {
827
+ provider: realtimeProvider,
708
828
  cfg: this.coreConfig,
709
- providerConfig: this.providerConfig,
829
+ agentId,
830
+ providerConfig,
710
831
  interruptResponseOnInputAudio,
711
832
  instructions,
712
833
  tools: this.config.tools,
@@ -734,6 +855,8 @@ var RealtimeCallHandler = class {
734
855
  }
735
856
  },
736
857
  onTranscript: (role, text, isFinal) => {
858
+ const owner = nativeConsultOwner.current;
859
+ if (!this.getUserTranscriptState(callId, userTranscriptOwner) || owner && !this.isActiveBridgeOwner(callId, owner)) return;
737
860
  const turnId = harness.ensureTurn();
738
861
  const eventType = role === "assistant" ? isFinal ? "output.text.done" : "output.text.delta" : isFinal ? "transcript.done" : "transcript.delta";
739
862
  const payload = role === "assistant" ? { text } : {
@@ -757,19 +880,22 @@ var RealtimeCallHandler = class {
757
880
  });
758
881
  if (!isFinal) {
759
882
  if (role === "user" && text.trim()) {
760
- const transcript = this.recordPartialUserTranscript(callId, text);
883
+ const transcript = this.recordPartialUserTranscript(callId, userTranscriptOwner, text);
884
+ if (!transcript) return;
761
885
  console.log(`[voice-call] realtime input transcript callId=${callId} providerCallId=${callSid} final=false chars=${text.trim().length} aggregateChars=${transcript.length}`);
762
886
  }
763
887
  return;
764
888
  }
765
889
  if (role === "user") {
890
+ const state = this.getUserTranscriptState(callId, userTranscriptOwner);
891
+ if (!state) return;
766
892
  const transcript = resolveFinalTranscriptText({
767
- partial: this.partialUserTranscriptsByCallId.get(callId),
768
- rawPartial: this.rawPartialUserTranscriptsByCallId.get(callId),
893
+ partial: state.partial,
894
+ rawPartial: state.rawPartial,
769
895
  final: text
770
896
  });
771
- this.clearPartialUserTranscript(callId);
772
- this.setRecentFinalUserTranscript(callId, transcript);
897
+ this.clearPartialUserTranscript(callId, userTranscriptOwner);
898
+ this.setRecentFinalUserTranscript(callId, userTranscriptOwner, transcript);
773
899
  console.log(`[voice-call] realtime input transcript callId=${callId} providerCallId=${callSid} final=true chars=${text.trim().length} aggregateChars=${transcript.length}`);
774
900
  const event = {
775
901
  id: `realtime-speech-${callSid}-${Date.now()}`,
@@ -787,6 +913,7 @@ var RealtimeCallHandler = class {
787
913
  callId,
788
914
  callSid,
789
915
  transcript,
916
+ userTranscriptOwner,
790
917
  clearAudio: () => {
791
918
  const clearedBytes = audioPacer.clearAudio();
792
919
  console.log(`[voice-call] realtime forced consult cleared outbound audio callId=${callId} providerCallId=${callSid} queuedBytes=${clearedBytes}`);
@@ -816,9 +943,30 @@ var RealtimeCallHandler = class {
816
943
  }
817
944
  });
818
945
  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);
946
+ return this.executeToolCall(sessionLocal, callId, toolEvent.callId || toolEvent.itemId, toolEvent.name, toolEvent.args, turnId, harness, userTranscriptOwner);
820
947
  },
821
948
  onEvent: (event) => {
949
+ if (event.direction === "client" && event.type === "session.continuity.reset") {
950
+ const turnId = harness.talk.activeTurnId;
951
+ const owner = nativeConsultOwner.current;
952
+ if (owner && this.isActiveBridgeOwner(callId, owner)) {
953
+ this.resetUserTranscriptState(callId, userTranscriptOwner);
954
+ this.resetConsultSessionForContinuity(callId, owner);
955
+ }
956
+ harness.flushOutput(() => {
957
+ audioPacer.clearAudio();
958
+ harness.finishOutputAudio(event.type);
959
+ });
960
+ if (turnId) harness.talk.cancelTurn({
961
+ turnId,
962
+ payload: {
963
+ callId,
964
+ providerCallId: callSid,
965
+ reason: event.type
966
+ }
967
+ });
968
+ return;
969
+ }
822
970
  if (event.type === "input_audio_buffer.speech_started") {
823
971
  harness.ensureTurn();
824
972
  return;
@@ -838,17 +986,15 @@ var RealtimeCallHandler = class {
838
986
  });
839
987
  return;
840
988
  }
841
- if (event.type === "response.done") {
842
- harness.finishOutputAudio("response.done");
843
- harness.endTurn("response.done");
844
- return;
845
- }
846
989
  if (event.type === "error") harness.emit({
847
990
  type: "session.error",
848
991
  payload: { message: event.detail ?? "Realtime provider error" },
849
992
  final: true
850
993
  });
851
994
  },
995
+ onResponseDone: (outcome) => {
996
+ if (outcome.status === "failed" || outcome.status === "incomplete") console.warn(`[voice-call] realtime response ${outcome.status}: ${outcome.message}`);
997
+ },
852
998
  onReady: () => {
853
999
  harness.emit({
854
1000
  type: "session.ready",
@@ -867,11 +1013,13 @@ var RealtimeCallHandler = class {
867
1013
  });
868
1014
  },
869
1015
  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);
1016
+ const owner = nativeConsultOwner.current;
1017
+ const ownsCallState = owner ? this.isActiveBridgeOwner(callId, owner) : false;
1018
+ if (owner) {
1019
+ this.clearActiveBridgeMappings(callId, callSid, owner);
1020
+ this.cancelConsultSession(callId, owner);
1021
+ }
1022
+ if (ownsCallState) this.clearUserTranscriptState(callId, userTranscriptOwner);
875
1023
  harness.finishOutputAudio(reason);
876
1024
  harness.emit({
877
1025
  type: "session.closed",
@@ -879,29 +1027,33 @@ var RealtimeCallHandler = class {
879
1027
  final: true
880
1028
  });
881
1029
  if (reason !== "error") return;
882
- emitCallEnd("error");
1030
+ this.streamDisconnectLifecycle.retire(callSid, streamSid);
883
1031
  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
- });
891
- }
892
- });
893
- providerHandlesInputAudioBargeIn = session.bridge.handlesInputAudioBargeIn ?? providerHandlesInputAudioBargeIn;
894
- const closeTelephony = (reason) => {
895
- try {
896
- session.close();
897
- } finally {
898
- emitCallEnd(reason);
1032
+ if (owner && !ownsCallState || !owner && hadPredecessorOnAdmission && this.activeBridgesByCallId.has(callId)) return;
1033
+ emitCallEnd("error");
899
1034
  }
900
1035
  };
901
- this.activeBridgesByCallId.set(callId, session);
902
- this.activeBridgesByCallId.set(callSid, session);
903
- this.activeTelephonyClosersByCallId.set(callId, closeTelephony);
904
- this.activeTelephonyClosersByCallId.set(callSid, closeTelephony);
1036
+ let session;
1037
+ try {
1038
+ session = harness.createBridge(bridgeParams);
1039
+ } catch (error) {
1040
+ this.rollbackUserTranscriptOwnerAdoption(callId, userTranscriptAdoption);
1041
+ harness.close();
1042
+ audioPacer.close();
1043
+ if (!hadPredecessorOnAdmission || !this.activeBridgesByCallId.has(callId)) emitCallEnd("error");
1044
+ if (ws.readyState === WebSocket$1.OPEN) ws.close(1011, "Failed to create realtime bridge");
1045
+ console.error("[voice-call] Failed to create realtime bridge:", error);
1046
+ return null;
1047
+ }
1048
+ this.commitUserTranscriptOwnerAdoption(callId, userTranscriptAdoption);
1049
+ nativeConsultOwner.current = session;
1050
+ providerHandlesInputAudioBargeIn = session.bridge.handlesInputAudioBargeIn ?? providerHandlesInputAudioBargeIn;
1051
+ const previousConsultSession = this.consultSessionsByCallId.get(callId);
1052
+ if (previousConsultSession && previousConsultSession.owner !== session) this.cancelConsultSession(callId, previousConsultSession.owner);
1053
+ this.consultSessionsByCallId.set(callId, {
1054
+ owner: session,
1055
+ coordinator: harness.forcedConsults
1056
+ });
905
1057
  const sendAudioToSession = session.sendAudio.bind(session);
906
1058
  session.sendAudio = (audio) => {
907
1059
  if (speechDetector.accept({
@@ -924,86 +1076,239 @@ var RealtimeCallHandler = class {
924
1076
  try {
925
1077
  closeSession();
926
1078
  } 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);
1079
+ this.clearActiveBridgeMappings(callId, callSid, session);
1080
+ this.cancelConsultSession(callId, session);
1081
+ this.clearUserTranscriptState(callId, userTranscriptOwner);
933
1082
  harness.close();
934
1083
  audioPacer.close();
935
1084
  }
936
1085
  };
1086
+ let livenessTimer;
1087
+ const clearLivenessTimer = () => {
1088
+ if (livenessTimer) {
1089
+ clearTimeout(livenessTimer);
1090
+ livenessTimer = void 0;
1091
+ }
1092
+ };
1093
+ let bindingClosed = false;
1094
+ const closeBinding = (binding, cause) => {
1095
+ if (bindingClosed) return callEndPromise ?? Promise.resolve();
1096
+ bindingClosed = true;
1097
+ clearLivenessTimer();
1098
+ const ownsCall = this.activeTelephonyBindingsByCallId.get(callId) === binding;
1099
+ let termination = Promise.resolve();
1100
+ try {
1101
+ session.close();
1102
+ } catch (error) {
1103
+ console.warn(`[voice-call] Failed to close realtime bridge ${callSid}: ${formatErrorMessage(error)}`);
1104
+ } finally {
1105
+ this.clearActiveTelephonyBinding(callId, binding);
1106
+ if (cause === "disconnect") this.streamDisconnectLifecycle.disconnect(callSid, streamSid);
1107
+ else this.streamDisconnectLifecycle.retire(callSid, streamSid);
1108
+ if (ownsCall && cause && cause !== "disconnect") termination = emitCallEnd(cause);
1109
+ }
1110
+ return termination;
1111
+ };
1112
+ const telephonyBinding = {
1113
+ bridge: session,
1114
+ close: (cause) => closeBinding(telephonyBinding, cause),
1115
+ endCall: () => {
1116
+ closeBinding(telephonyBinding);
1117
+ if (ws.readyState === WebSocket$1.OPEN) ws.close(1e3, "Call ended");
1118
+ },
1119
+ noteMediaActivity: () => {
1120
+ if (bindingClosed || this.activeTelephonyBindingsByCallId.get(callId) !== telephonyBinding) return;
1121
+ clearLivenessTimer();
1122
+ livenessTimer = setTimeout(() => {
1123
+ console.warn(`[voice-call] Realtime media inactive callId=${callId} providerCallId=${callSid} timeoutMs=${REALTIME_MEDIA_INACTIVITY_TIMEOUT_MS} graceMs=${REALTIME_DISCONNECT_HANGUP_GRACE_MS}`);
1124
+ livenessTimer = setTimeout(() => {
1125
+ telephonyBinding.close("inactivity");
1126
+ if (ws.readyState === WebSocket$1.OPEN) ws.close(1e3, "Media inactivity");
1127
+ }, REALTIME_DISCONNECT_HANGUP_GRACE_MS);
1128
+ livenessTimer.unref?.();
1129
+ }, REALTIME_MEDIA_INACTIVITY_TIMEOUT_MS);
1130
+ livenessTimer.unref?.();
1131
+ },
1132
+ retire: () => {
1133
+ closeBinding(telephonyBinding);
1134
+ }
1135
+ };
1136
+ this.activeBridgesByCallId.set(callId, session);
1137
+ this.activeBridgesByCallId.set(callSid, session);
1138
+ this.activeTelephonyBindingsByCallId.set(callId, telephonyBinding);
1139
+ telephonyBinding.noteMediaActivity();
1140
+ if (previousTelephonyBinding && previousTelephonyBinding !== telephonyBinding) previousTelephonyBinding.retire();
937
1141
  session.connect().catch((error) => {
938
1142
  console.error("[voice-call] Failed to connect realtime bridge:", error);
939
- session.close();
940
- emitCallEnd("error");
941
- ws.close(1011, "Failed to connect");
1143
+ const ownsCallState = this.isActiveBridgeOwner(callId, session);
1144
+ this.streamDisconnectLifecycle.retire(callSid, streamSid);
1145
+ try {
1146
+ session.close();
1147
+ } catch (closeError) {
1148
+ console.warn(`[voice-call] Failed to close realtime bridge ${callSid}: ${formatErrorMessage(closeError)}`);
1149
+ } finally {
1150
+ if (ownsCallState) emitCallEnd("error");
1151
+ ws.close(1011, "Failed to connect");
1152
+ }
942
1153
  });
943
- return session;
944
- }
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());
1154
+ return telephonyBinding;
1155
+ }
1156
+ beginUserTranscriptOwnerAdoption(callId) {
1157
+ const adoption = {
1158
+ owner: {},
1159
+ previous: this.userTranscriptStatesByCallId.get(callId)
1160
+ };
1161
+ this.userTranscriptStatesByCallId.set(callId, adoption.owner);
1162
+ return adoption;
1163
+ }
1164
+ commitUserTranscriptOwnerAdoption(callId, adoption) {
1165
+ if (this.userTranscriptStatesByCallId.get(callId) !== adoption.owner) return;
1166
+ if (adoption.previous?.recentFinalTimer) {
1167
+ clearTimeout(adoption.previous.recentFinalTimer);
1168
+ adoption.previous.recentFinalTimer = void 0;
1169
+ }
1170
+ }
1171
+ rollbackUserTranscriptOwnerAdoption(callId, adoption) {
1172
+ if (this.userTranscriptStatesByCallId.get(callId) !== adoption.owner) return;
1173
+ if (adoption.owner.recentFinalTimer) {
1174
+ clearTimeout(adoption.owner.recentFinalTimer);
1175
+ adoption.owner.recentFinalTimer = void 0;
1176
+ }
1177
+ if (adoption.previous) {
1178
+ this.userTranscriptStatesByCallId.set(callId, adoption.previous);
1179
+ return;
1180
+ }
1181
+ this.userTranscriptStatesByCallId.delete(callId);
1182
+ }
1183
+ getUserTranscriptState(callId, owner) {
1184
+ const state = this.userTranscriptStatesByCallId.get(callId);
1185
+ return state === owner ? state : void 0;
1186
+ }
1187
+ recordPartialUserTranscript(callId, owner, text) {
1188
+ const state = this.getUserTranscriptState(callId, owner);
1189
+ if (!state) return;
1190
+ const next = limitPartialUserTranscript(appendTranscriptText(state.partial, text));
1191
+ const raw = limitPartialUserTranscript(`${state.rawPartial ?? ""}${text}`);
1192
+ state.partial = next;
1193
+ state.rawPartial = raw;
1194
+ state.partialUpdatedAt = Date.now();
951
1195
  return next;
952
1196
  }
953
- clearPartialUserTranscript(callId) {
954
- this.partialUserTranscriptsByCallId.delete(callId);
955
- this.rawPartialUserTranscriptsByCallId.delete(callId);
956
- this.partialUserTranscriptUpdatedAtByCallId.delete(callId);
1197
+ clearPartialUserTranscript(callId, owner) {
1198
+ const state = this.getUserTranscriptState(callId, owner);
1199
+ if (!state) return;
1200
+ state.partial = void 0;
1201
+ state.rawPartial = void 0;
1202
+ state.partialUpdatedAt = void 0;
957
1203
  }
958
- setRecentFinalUserTranscript(callId, text) {
959
- this.clearRecentFinalUserTranscript(callId);
960
- this.recentFinalUserTranscriptsByCallId.set(callId, text);
1204
+ setRecentFinalUserTranscript(callId, owner, text) {
1205
+ const state = this.getUserTranscriptState(callId, owner);
1206
+ if (!state) return;
1207
+ this.clearRecentFinalUserTranscript(callId, owner);
1208
+ state.recentFinal = text;
961
1209
  const timer = setTimeout(() => {
962
- if (this.recentFinalUserTranscriptsByCallId.get(callId) === text) this.recentFinalUserTranscriptsByCallId.delete(callId);
963
- this.recentFinalUserTranscriptTimersByCallId.delete(callId);
1210
+ if (this.userTranscriptStatesByCallId.get(callId) !== state) return;
1211
+ if (state.recentFinal === text) state.recentFinal = void 0;
1212
+ if (state.recentFinalTimer === timer) state.recentFinalTimer = void 0;
964
1213
  }, RECENT_FINAL_USER_TRANSCRIPT_TTL_MS);
965
1214
  timer.unref?.();
966
- this.recentFinalUserTranscriptTimersByCallId.set(callId, timer);
1215
+ state.recentFinalTimer = timer;
1216
+ }
1217
+ clearRecentFinalUserTranscript(callId, owner) {
1218
+ const state = this.getUserTranscriptState(callId, owner);
1219
+ if (!state) return;
1220
+ if (state.recentFinalTimer) {
1221
+ clearTimeout(state.recentFinalTimer);
1222
+ state.recentFinalTimer = void 0;
1223
+ }
1224
+ state.recentFinal = void 0;
1225
+ }
1226
+ resetUserTranscriptState(callId, owner) {
1227
+ this.clearPartialUserTranscript(callId, owner);
1228
+ this.clearRecentFinalUserTranscript(callId, owner);
1229
+ }
1230
+ clearUserTranscriptState(callId, owner) {
1231
+ const state = this.getUserTranscriptState(callId, owner);
1232
+ if (!state) return;
1233
+ if (state.recentFinalTimer) clearTimeout(state.recentFinalTimer);
1234
+ if (this.userTranscriptStatesByCallId.get(callId) === state) this.userTranscriptStatesByCallId.delete(callId);
967
1235
  }
968
- clearRecentFinalUserTranscript(callId) {
969
- const timer = this.recentFinalUserTranscriptTimersByCallId.get(callId);
970
- if (timer) {
971
- clearTimeout(timer);
972
- this.recentFinalUserTranscriptTimersByCallId.delete(callId);
1236
+ cancelNativeConsult(callId, owner) {
1237
+ const state = this.nativeConsultsInFlightByCallId.get(callId);
1238
+ if (!state || state.owner !== owner) return;
1239
+ state.cancelled = true;
1240
+ this.nativeConsultsInFlightByCallId.delete(callId);
1241
+ state.cancel();
1242
+ }
1243
+ cancelForcedConsult(callId, owner) {
1244
+ const state = this.forcedConsultsByCallId.get(callId);
1245
+ if (!state || state.owner !== owner) return;
1246
+ state.cancelled = true;
1247
+ state.sendSpeechPrompt = false;
1248
+ state.cancel();
1249
+ this.forcedConsultsByCallId.delete(callId);
1250
+ }
1251
+ resetConsultSession(callId, owner) {
1252
+ const session = this.consultSessionsByCallId.get(callId);
1253
+ if (!session || session.owner !== owner) return false;
1254
+ session.coordinator.clearPending();
1255
+ this.cancelForcedConsult(callId, owner);
1256
+ this.cancelNativeConsult(callId, owner);
1257
+ return true;
1258
+ }
1259
+ resetConsultSessionForContinuity(callId, owner) {
1260
+ const session = this.consultSessionsByCallId.get(callId);
1261
+ if (!session || session.owner !== owner) return false;
1262
+ this.cancelForcedConsult(callId, owner);
1263
+ this.cancelNativeConsult(callId, owner);
1264
+ session.coordinator.clear();
1265
+ return true;
1266
+ }
1267
+ cancelConsultSession(callId, owner) {
1268
+ if (!owner || !this.resetConsultSession(callId, owner)) return;
1269
+ this.consultSessionsByCallId.delete(callId);
1270
+ }
1271
+ isActiveBridgeOwner(callId, owner) {
1272
+ return this.activeBridgesByCallId.get(callId) === owner;
1273
+ }
1274
+ clearActiveBridgeMappings(callId, callSid, owner) {
1275
+ for (const key of [callId, callSid]) {
1276
+ if (this.activeBridgesByCallId.get(key) !== owner) continue;
1277
+ this.activeBridgesByCallId.delete(key);
973
1278
  }
974
- this.recentFinalUserTranscriptsByCallId.delete(callId);
975
1279
  }
976
- clearUserTranscriptState(callId) {
977
- this.clearPartialUserTranscript(callId);
978
- this.clearRecentFinalUserTranscript(callId);
1280
+ clearActiveTelephonyBinding(callId, binding) {
1281
+ if (this.activeTelephonyBindingsByCallId.get(callId) === binding) this.activeTelephonyBindingsByCallId.delete(callId);
979
1282
  }
980
- resolveUserTranscriptContext(callId) {
981
- return this.partialUserTranscriptsByCallId.get(callId) ?? this.recentFinalUserTranscriptsByCallId.get(callId);
1283
+ resolveUserTranscriptContext(callId, owner) {
1284
+ const state = this.getUserTranscriptState(callId, owner);
1285
+ return state?.partial ?? state?.recentFinal;
982
1286
  }
983
- consumePartialUserTranscript(callId, consumed) {
1287
+ consumePartialUserTranscript(callId, owner, consumed) {
984
1288
  const text = consumed?.trim();
985
1289
  if (!text) return;
986
- const current = this.partialUserTranscriptsByCallId.get(callId);
1290
+ const state = this.getUserTranscriptState(callId, owner);
1291
+ const current = state?.partial;
987
1292
  if (!current) return;
988
1293
  if (current === text) {
989
- this.clearPartialUserTranscript(callId);
1294
+ this.clearPartialUserTranscript(callId, owner);
990
1295
  return;
991
1296
  }
992
1297
  if (current.toLowerCase().startsWith(text.toLowerCase())) {
993
1298
  const remaining = current.slice(text.length).trimStart();
994
1299
  if (remaining) {
995
- this.partialUserTranscriptsByCallId.set(callId, remaining);
996
- this.rawPartialUserTranscriptsByCallId.set(callId, remaining);
997
- } else this.clearPartialUserTranscript(callId);
1300
+ state.partial = remaining;
1301
+ state.rawPartial = remaining;
1302
+ } else this.clearPartialUserTranscript(callId, owner);
998
1303
  }
999
- const recent = this.recentFinalUserTranscriptsByCallId.get(callId);
1304
+ const recent = state.recentFinal;
1000
1305
  if (!recent) return;
1001
- if (recent === text || recent.toLowerCase().startsWith(text.toLowerCase())) this.clearRecentFinalUserTranscript(callId);
1306
+ if (recent === text || recent.toLowerCase().startsWith(text.toLowerCase())) this.clearRecentFinalUserTranscript(callId, owner);
1002
1307
  }
1003
- async waitForConsultTranscriptSettle(callId, startedAt) {
1308
+ async waitForConsultTranscriptSettle(callId, owner, startedAt) {
1004
1309
  const deadline = startedAt + CONSULT_TRANSCRIPT_SETTLE_MAX_MS;
1005
1310
  while (true) {
1006
- const updatedAt = this.partialUserTranscriptUpdatedAtByCallId.get(callId);
1311
+ const updatedAt = this.getUserTranscriptState(callId, owner)?.partialUpdatedAt;
1007
1312
  if (!updatedAt) return;
1008
1313
  const now = Date.now();
1009
1314
  const quietFor = now - updatedAt;
@@ -1013,16 +1318,8 @@ var RealtimeCallHandler = class {
1013
1318
  });
1014
1319
  }
1015
1320
  }
1016
- closeTelephonyBridge(callIdOrSid, bridge, reason) {
1017
- const closer = this.activeTelephonyClosersByCallId.get(callIdOrSid);
1018
- if (closer) {
1019
- closer(reason);
1020
- return;
1021
- }
1022
- bridge?.close();
1023
- }
1024
1321
  scheduleForcedAgentConsult(params) {
1025
- if (this.config.consultPolicy !== "always") return;
1322
+ if (this.config.consultPolicy !== "always" || this.activeBridgesByCallId.get(params.callId) !== params.session) return;
1026
1323
  const question = params.transcript.trim();
1027
1324
  if (!question) return;
1028
1325
  const handler = this.toolHandlers.get(REALTIME_VOICE_AGENT_CONSULT_TOOL_NAME);
@@ -1051,13 +1348,24 @@ var RealtimeCallHandler = class {
1051
1348
  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
1349
  console.log(`[voice-call] realtime forced agent consult starting callId=${params.callId} providerCallId=${params.callSid} chars=${params.handle.question.length}`);
1053
1350
  params.clearAudio();
1351
+ const abortController = new AbortController();
1054
1352
  const state = {
1353
+ owner: params.session,
1055
1354
  sendSpeechPrompt: true,
1056
- promise: Promise.resolve().then(() => params.handler({ question: params.handle.question }, params.callId, {}))
1355
+ cancelled: false,
1356
+ cancel: () => {
1357
+ abortController.abort(/* @__PURE__ */ new Error("Realtime forced consult owner was cancelled."));
1358
+ coordinator.markCancelled(params.handle);
1359
+ },
1360
+ promise: Promise.resolve().then(() => {
1361
+ abortController.signal.throwIfAborted();
1362
+ return params.handler({ question: params.handle.question }, params.callId, { abortSignal: abortController.signal });
1363
+ })
1057
1364
  };
1058
1365
  this.forcedConsultsByCallId.set(params.callId, state);
1059
1366
  try {
1060
1367
  const result = await state.promise;
1368
+ if (state.cancelled || this.forcedConsultsByCallId.get(params.callId) !== state) return;
1061
1369
  state.completedAt = Date.now();
1062
1370
  coordinator.markDelivered(params.handle);
1063
1371
  const text = readSpeakableRealtimeVoiceToolResult(result, {
@@ -1073,11 +1381,12 @@ var RealtimeCallHandler = class {
1073
1381
  params.session.sendUserMessage(buildForcedConsultSpeechPrompt(text));
1074
1382
  }
1075
1383
  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);
1384
+ this.consumePartialUserTranscript(params.callId, params.userTranscriptOwner, params.handle.question);
1077
1385
  } catch (error) {
1078
- console.warn(`[voice-call] realtime forced agent consult failed callId=${params.callId} providerCallId=${params.callSid} error=${formatErrorMessage(error)}`);
1386
+ if (!state.cancelled) console.warn(`[voice-call] realtime forced agent consult failed callId=${params.callId} providerCallId=${params.callSid} error=${formatErrorMessage(error)}`);
1079
1387
  } finally {
1080
- setTimeout(() => {
1388
+ if (!state.cancelled) if (this.forcedConsultsByCallId.get(params.callId) !== state) coordinator.remove(params.handle);
1389
+ else setTimeout(() => {
1081
1390
  if (this.forcedConsultsByCallId.get(params.callId) === state) {
1082
1391
  this.forcedConsultsByCallId.delete(params.callId);
1083
1392
  coordinator.remove(params.handle);
@@ -1085,7 +1394,7 @@ var RealtimeCallHandler = class {
1085
1394
  }, FORCED_CONSULT_NATIVE_DEDUPE_MS).unref?.();
1086
1395
  }
1087
1396
  }
1088
- registerCallInManager(callSid, callerMeta = {}) {
1397
+ prepareCallInManager(callSid, callerMeta = {}) {
1089
1398
  const baseFields = {
1090
1399
  providerCallId: callSid,
1091
1400
  timestamp: Date.now(),
@@ -1097,18 +1406,10 @@ var RealtimeCallHandler = class {
1097
1406
  if (!callRecord) return null;
1098
1407
  const initialGreeting = this.extractInitialGreeting(callRecord);
1099
1408
  console.log(`[voice-call] Realtime call ${callRecord.callId} initial greeting ${initialGreeting ? "queued" : "absent"}`);
1100
- if (callRecord.metadata) delete callRecord.metadata.initialMessage;
1101
- this.manager.processEvent({
1102
- id: `realtime-answered-${callSid}`,
1103
- callId: callRecord.callId,
1104
- type: "call.answered",
1105
- ...baseFields
1106
- });
1107
- const instructions = this.resolveInstructions?.(callRecord) ?? this.config.instructions;
1108
1409
  return {
1109
- callId: callRecord.callId,
1110
- instructions,
1111
- initialGreetingInstructions: buildGreetingInstructions(instructions, initialGreeting)
1410
+ callRecord,
1411
+ baseFields,
1412
+ initialGreeting
1112
1413
  };
1113
1414
  }
1114
1415
  resolveRealtimeCall(callSid, callerMeta, baseFields) {
@@ -1127,17 +1428,57 @@ var RealtimeCallHandler = class {
1127
1428
  extractInitialGreeting(call) {
1128
1429
  return typeof call.metadata?.initialMessage === "string" ? call.metadata.initialMessage : void 0;
1129
1430
  }
1130
- endCallInManager(callSid, callId, reason) {
1131
- this.manager.processEvent({
1132
- id: `realtime-ended-${callSid}-${Date.now()}`,
1133
- type: "call.ended",
1134
- callId,
1135
- providerCallId: callSid,
1136
- timestamp: Date.now(),
1137
- reason
1431
+ async executeEndCallTool(params) {
1432
+ const binding = this.activeTelephonyBindingsByCallId.get(params.callId);
1433
+ if (!binding || binding.bridge !== params.bridge || !this.isActiveBridgeOwner(params.callId, params.bridge)) return;
1434
+ let result;
1435
+ try {
1436
+ result = await this.manager.endCall(params.callId);
1437
+ } catch (error) {
1438
+ result = {
1439
+ success: false,
1440
+ error: formatErrorMessage(error)
1441
+ };
1442
+ }
1443
+ if (this.activeTelephonyBindingsByCallId.get(params.callId) !== binding || !this.isActiveBridgeOwner(params.callId, params.bridge)) return;
1444
+ if (!result.success) {
1445
+ const toolResult = { error: `Could not end the current phone call: ${result.error?.trim() || "the telephony provider returned no reason"}. Tell the caller the call could not be ended and they can hang up or ask you to try again.` };
1446
+ await params.bridge.submitToolResult(params.bridgeCallId, toolResult);
1447
+ params.harness.emit({
1448
+ type: "tool.error",
1449
+ turnId: params.turnId,
1450
+ callId: params.bridgeCallId,
1451
+ payload: {
1452
+ name: REALTIME_VOICE_END_CALL_TOOL_NAME,
1453
+ result: toolResult
1454
+ },
1455
+ final: true
1456
+ });
1457
+ return;
1458
+ }
1459
+ params.harness.emit({
1460
+ type: "tool.result",
1461
+ turnId: params.turnId,
1462
+ callId: params.bridgeCallId,
1463
+ payload: {
1464
+ name: REALTIME_VOICE_END_CALL_TOOL_NAME,
1465
+ result: { success: true }
1466
+ },
1467
+ final: true
1138
1468
  });
1469
+ binding.endCall();
1139
1470
  }
1140
- async executeToolCall(bridge, callId, bridgeCallId, name, args, turnId, harness) {
1471
+ async executeToolCall(bridge, callId, bridgeCallId, name, args, turnId, harness, userTranscriptOwner) {
1472
+ if (name === "openclaw_end_call") {
1473
+ await this.executeEndCallTool({
1474
+ bridge,
1475
+ callId,
1476
+ bridgeCallId,
1477
+ turnId,
1478
+ harness
1479
+ });
1480
+ return;
1481
+ }
1141
1482
  const handler = this.toolHandlers.get(name);
1142
1483
  const startedAt = Date.now();
1143
1484
  const hasResultError = (result) => {
@@ -1174,13 +1515,15 @@ var RealtimeCallHandler = class {
1174
1515
  }
1175
1516
  };
1176
1517
  if (name === REALTIME_VOICE_AGENT_CONSULT_TOOL_NAME) {
1518
+ if (this.activeBridgesByCallId.get(callId) !== bridge) return;
1177
1519
  const coordinator = harness.forcedConsults;
1178
1520
  const forcedMatch = coordinator.recordNativeConsult(args, bridgeCallId);
1179
1521
  if (forcedMatch.kind === "none") {
1180
1522
  const pending = coordinator.consumePending();
1181
1523
  if (pending) coordinator.remove(pending);
1182
1524
  }
1183
- const forcedConsult = this.forcedConsultsByCallId.get(callId);
1525
+ const forcedConsultState = this.forcedConsultsByCallId.get(callId);
1526
+ const forcedConsult = forcedConsultState?.owner === bridge && !forcedConsultState.cancelled ? forcedConsultState : void 0;
1184
1527
  if (forcedMatch.kind === "already_delivered" && coordinator.isCancelled(forcedMatch.handle)) {
1185
1528
  if (forcedConsult) forcedConsult.sendSpeechPrompt = false;
1186
1529
  await submitFinalToolResult({
@@ -1198,24 +1541,50 @@ var RealtimeCallHandler = class {
1198
1541
  return;
1199
1542
  }
1200
1543
  forcedConsult.sendSpeechPrompt = false;
1201
- await submitFinalToolResult(await forcedConsult.promise.catch((error) => ({ error: formatErrorMessage(error) })));
1544
+ const result = await forcedConsult.promise.catch((error) => ({ error: formatErrorMessage(error) }));
1545
+ if (forcedConsult.cancelled || forcedConsult.owner !== bridge || this.forcedConsultsByCallId.get(callId) !== forcedConsult) return;
1546
+ await submitFinalToolResult(result);
1202
1547
  return;
1203
1548
  }
1204
1549
  const existingNativeConsult = this.nativeConsultsInFlightByCallId.get(callId);
1205
1550
  if (existingNativeConsult) {
1206
1551
  console.log(`[voice-call] realtime tool call sharing in-flight agent consult callId=${callId} ageMs=${Date.now() - existingNativeConsult.startedAt}`);
1207
1552
  await submitWorkingResponse();
1208
- await submitFinalToolResult(await existingNativeConsult.promise);
1553
+ const outcome = await waitForNativeConsult(existingNativeConsult);
1554
+ if (outcome.kind === "cancelled") return;
1555
+ await submitFinalToolResult(outcome.result);
1209
1556
  return;
1210
1557
  }
1558
+ const abortController = new AbortController();
1559
+ let releaseCancellation = () => {};
1560
+ const cancellation = new Promise((resolve) => {
1561
+ releaseCancellation = resolve;
1562
+ });
1563
+ let completeConsult = (_result) => {};
1211
1564
  const state = {
1565
+ owner: bridge,
1212
1566
  startedAt,
1213
- promise: Promise.resolve()
1567
+ promise: new Promise((resolve) => {
1568
+ completeConsult = resolve;
1569
+ }),
1570
+ cancellation,
1571
+ cancelled: false,
1572
+ cancel: () => {
1573
+ abortController.abort(/* @__PURE__ */ new Error("Realtime native consult owner was cancelled."));
1574
+ releaseCancellation();
1575
+ }
1214
1576
  };
1215
- state.promise = submitWorkingResponse().then(async () => {
1577
+ this.nativeConsultsInFlightByCallId.set(callId, state);
1578
+ (async () => {
1216
1579
  try {
1217
- await this.waitForConsultTranscriptSettle(callId, startedAt);
1218
- const context = { partialUserTranscript: this.resolveUserTranscriptContext(callId) };
1580
+ await submitWorkingResponse();
1581
+ if (state.cancelled) return;
1582
+ await Promise.race([this.waitForConsultTranscriptSettle(callId, userTranscriptOwner, startedAt), state.cancellation]);
1583
+ if (state.cancelled) return;
1584
+ const context = {
1585
+ partialUserTranscript: this.resolveUserTranscriptContext(callId, userTranscriptOwner),
1586
+ abortSignal: abortController.signal
1587
+ };
1219
1588
  state.partialUserTranscript = context.partialUserTranscript;
1220
1589
  const handlerArgs = withFallbackConsultQuestion(args, context.partialUserTranscript);
1221
1590
  console.log(`[voice-call] realtime tool call executing callId=${callId} tool=${name} hasHandler=${Boolean(handler)}`);
@@ -1223,29 +1592,30 @@ var RealtimeCallHandler = class {
1223
1592
  } catch (error) {
1224
1593
  return { error: formatErrorMessage(error) };
1225
1594
  }
1226
- });
1227
- this.nativeConsultsInFlightByCallId.set(callId, state);
1595
+ })().then(completeConsult);
1228
1596
  try {
1229
- const result = await state.promise;
1597
+ const outcome = await waitForNativeConsult(state);
1598
+ if (outcome.kind === "cancelled") return;
1599
+ const result = outcome.result;
1230
1600
  const status = result && typeof result === "object" && !Array.isArray(result) && "error" in result ? "error" : "ok";
1231
1601
  const error = status === "error" && result && typeof result === "object" && !Array.isArray(result) ? formatErrorMessage(result.error ?? "unknown") : void 0;
1232
1602
  console.log(`[voice-call] realtime tool call completed callId=${callId} tool=${name} status=${status} elapsedMs=${Date.now() - startedAt}${error ? ` error=${error}` : ""}`);
1233
1603
  await submitFinalToolResult(result);
1234
- if (status === "ok") this.consumePartialUserTranscript(callId, state.partialUserTranscript);
1604
+ if (status === "ok") this.consumePartialUserTranscript(callId, userTranscriptOwner, state.partialUserTranscript);
1235
1605
  } finally {
1236
1606
  if (this.nativeConsultsInFlightByCallId.get(callId) === state) this.nativeConsultsInFlightByCallId.delete(callId);
1237
1607
  }
1238
1608
  return;
1239
1609
  }
1240
1610
  console.log(`[voice-call] realtime tool call executing callId=${callId} tool=${name} hasHandler=${Boolean(handler)}`);
1241
- const context = { partialUserTranscript: this.resolveUserTranscriptContext(callId) };
1611
+ const context = { partialUserTranscript: this.resolveUserTranscriptContext(callId, userTranscriptOwner) };
1242
1612
  const handlerArgs = name === REALTIME_VOICE_AGENT_CONSULT_TOOL_NAME ? withFallbackConsultQuestion(args, context.partialUserTranscript) : args;
1243
1613
  const result = !handler ? { error: `Tool "${name}" not available` } : await handler(handlerArgs, callId, context).catch((error) => ({ error: formatErrorMessage(error) }));
1244
1614
  const status = result && typeof result === "object" && !Array.isArray(result) && "error" in result ? "error" : "ok";
1245
1615
  const error = status === "error" && result && typeof result === "object" && !Array.isArray(result) ? formatErrorMessage(result.error ?? "unknown") : void 0;
1246
1616
  console.log(`[voice-call] realtime tool call completed callId=${callId} tool=${name} status=${status} elapsedMs=${Date.now() - startedAt}${error ? ` error=${error}` : ""}`);
1247
1617
  await submitFinalToolResult(result);
1248
- if (name === REALTIME_VOICE_AGENT_CONSULT_TOOL_NAME && status === "ok") this.consumePartialUserTranscript(callId, context.partialUserTranscript);
1618
+ if (name === REALTIME_VOICE_AGENT_CONSULT_TOOL_NAME && status === "ok") this.consumePartialUserTranscript(callId, userTranscriptOwner, context.partialUserTranscript);
1249
1619
  }
1250
1620
  };
1251
1621
  //#endregion