@openclaw/voice-call 2026.3.1 → 2026.3.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.
@@ -0,0 +1,91 @@
1
+ import type { WebhookContext } from "../../types.js";
2
+
3
+ export type TwimlResponseKind = "empty" | "pause" | "queue" | "stored" | "stream";
4
+
5
+ export type TwimlRequestView = {
6
+ callStatus: string | null;
7
+ direction: string | null;
8
+ isStatusCallback: boolean;
9
+ callSid?: string;
10
+ callIdFromQuery?: string;
11
+ };
12
+
13
+ export type TwimlPolicyInput = TwimlRequestView & {
14
+ hasStoredTwiml: boolean;
15
+ isNotifyCall: boolean;
16
+ hasActiveStreams: boolean;
17
+ canStream: boolean;
18
+ };
19
+
20
+ export type TwimlDecision =
21
+ | {
22
+ kind: "empty" | "pause" | "queue";
23
+ consumeStoredTwimlCallId?: string;
24
+ activateStreamCallSid?: string;
25
+ }
26
+ | {
27
+ kind: "stored";
28
+ consumeStoredTwimlCallId: string;
29
+ activateStreamCallSid?: string;
30
+ }
31
+ | {
32
+ kind: "stream";
33
+ consumeStoredTwimlCallId?: string;
34
+ activateStreamCallSid?: string;
35
+ };
36
+
37
+ function isOutboundDirection(direction: string | null): boolean {
38
+ return direction?.startsWith("outbound") ?? false;
39
+ }
40
+
41
+ export function readTwimlRequestView(ctx: WebhookContext): TwimlRequestView {
42
+ const params = new URLSearchParams(ctx.rawBody);
43
+ const type = typeof ctx.query?.type === "string" ? ctx.query.type.trim() : undefined;
44
+ const callIdFromQuery =
45
+ typeof ctx.query?.callId === "string" && ctx.query.callId.trim()
46
+ ? ctx.query.callId.trim()
47
+ : undefined;
48
+
49
+ return {
50
+ callStatus: params.get("CallStatus"),
51
+ direction: params.get("Direction"),
52
+ isStatusCallback: type === "status",
53
+ callSid: params.get("CallSid") || undefined,
54
+ callIdFromQuery,
55
+ };
56
+ }
57
+
58
+ export function decideTwimlResponse(input: TwimlPolicyInput): TwimlDecision {
59
+ if (input.callIdFromQuery && !input.isStatusCallback) {
60
+ if (input.hasStoredTwiml) {
61
+ return { kind: "stored", consumeStoredTwimlCallId: input.callIdFromQuery };
62
+ }
63
+ if (input.isNotifyCall) {
64
+ return { kind: "empty" };
65
+ }
66
+
67
+ if (isOutboundDirection(input.direction)) {
68
+ return input.canStream ? { kind: "stream" } : { kind: "pause" };
69
+ }
70
+ }
71
+
72
+ if (input.isStatusCallback) {
73
+ return { kind: "empty" };
74
+ }
75
+
76
+ if (input.direction === "inbound") {
77
+ if (input.hasActiveStreams) {
78
+ return { kind: "queue" };
79
+ }
80
+ if (input.canStream && input.callSid) {
81
+ return { kind: "stream", activateStreamCallSid: input.callSid };
82
+ }
83
+ return { kind: "pause" };
84
+ }
85
+
86
+ if (input.callStatus !== "in-progress") {
87
+ return { kind: "empty" };
88
+ }
89
+
90
+ return input.canStream ? { kind: "stream" } : { kind: "pause" };
91
+ }
@@ -60,6 +60,76 @@ describe("TwilioProvider", () => {
60
60
  expect(result.providerResponseBody).toContain("<Connect>");
61
61
  });
62
62
 
63
+ it("returns queue TwiML for second inbound call when first call is active", () => {
64
+ const provider = createProvider();
65
+ const firstInbound = createContext("CallStatus=ringing&Direction=inbound&CallSid=CA111");
66
+ const secondInbound = createContext("CallStatus=ringing&Direction=inbound&CallSid=CA222");
67
+
68
+ const firstResult = provider.parseWebhookEvent(firstInbound);
69
+ const secondResult = provider.parseWebhookEvent(secondInbound);
70
+
71
+ expect(firstResult.providerResponseBody).toContain("<Connect>");
72
+ expect(secondResult.providerResponseBody).toContain("Please hold while we connect you.");
73
+ expect(secondResult.providerResponseBody).toContain("<Enqueue");
74
+ expect(secondResult.providerResponseBody).toContain("hold-queue");
75
+ });
76
+
77
+ it("connects next inbound call after unregisterCallStream cleanup", () => {
78
+ const provider = createProvider();
79
+ const firstInbound = createContext("CallStatus=ringing&Direction=inbound&CallSid=CA311");
80
+ const secondInbound = createContext("CallStatus=ringing&Direction=inbound&CallSid=CA322");
81
+
82
+ provider.parseWebhookEvent(firstInbound);
83
+ provider.unregisterCallStream("CA311");
84
+ const secondResult = provider.parseWebhookEvent(secondInbound);
85
+
86
+ expect(secondResult.providerResponseBody).toContain("<Connect>");
87
+ expect(secondResult.providerResponseBody).not.toContain("hold-queue");
88
+ });
89
+
90
+ it("cleans up active inbound call on completed status callback", () => {
91
+ const provider = createProvider();
92
+ const firstInbound = createContext("CallStatus=ringing&Direction=inbound&CallSid=CA411");
93
+ const completed = createContext("CallStatus=completed&Direction=inbound&CallSid=CA411", {
94
+ type: "status",
95
+ });
96
+ const nextInbound = createContext("CallStatus=ringing&Direction=inbound&CallSid=CA422");
97
+
98
+ provider.parseWebhookEvent(firstInbound);
99
+ provider.parseWebhookEvent(completed);
100
+ const nextResult = provider.parseWebhookEvent(nextInbound);
101
+
102
+ expect(nextResult.providerResponseBody).toContain("<Connect>");
103
+ expect(nextResult.providerResponseBody).not.toContain("hold-queue");
104
+ });
105
+
106
+ it("cleans up active inbound call on canceled status callback", () => {
107
+ const provider = createProvider();
108
+ const firstInbound = createContext("CallStatus=ringing&Direction=inbound&CallSid=CA511");
109
+ const canceled = createContext("CallStatus=canceled&Direction=inbound&CallSid=CA511", {
110
+ type: "status",
111
+ });
112
+ const nextInbound = createContext("CallStatus=ringing&Direction=inbound&CallSid=CA522");
113
+
114
+ provider.parseWebhookEvent(firstInbound);
115
+ provider.parseWebhookEvent(canceled);
116
+ const nextResult = provider.parseWebhookEvent(nextInbound);
117
+
118
+ expect(nextResult.providerResponseBody).toContain("<Connect>");
119
+ expect(nextResult.providerResponseBody).not.toContain("hold-queue");
120
+ });
121
+
122
+ it("QUEUE_TWIML references /voice/hold-music waitUrl", () => {
123
+ const provider = createProvider();
124
+ const firstInbound = createContext("CallStatus=ringing&Direction=inbound&CallSid=CA611");
125
+ const secondInbound = createContext("CallStatus=ringing&Direction=inbound&CallSid=CA622");
126
+
127
+ provider.parseWebhookEvent(firstInbound);
128
+ const result = provider.parseWebhookEvent(secondInbound);
129
+
130
+ expect(result.providerResponseBody).toContain('waitUrl="/voice/hold-music"');
131
+ });
132
+
63
133
  it("uses a stable fallback dedupeKey for identical request payloads", () => {
64
134
  const provider = createProvider();
65
135
  const rawBody = "CallSid=CA789&Direction=inbound&SpeechResult=hello";
@@ -5,6 +5,8 @@ import type { MediaStreamHandler } from "../media-stream.js";
5
5
  import { chunkAudio } from "../telephony-audio.js";
6
6
  import type { TelephonyTtsProvider } from "../telephony-tts.js";
7
7
  import type {
8
+ GetCallStatusInput,
9
+ GetCallStatusResult,
8
10
  HangupCallInput,
9
11
  InitiateCallInput,
10
12
  InitiateCallResult,
@@ -19,7 +21,14 @@ import type {
19
21
  } from "../types.js";
20
22
  import { escapeXml, mapVoiceToPolly } from "../voice-mapping.js";
21
23
  import type { VoiceCallProvider } from "./base.js";
24
+ import {
25
+ isProviderStatusTerminal,
26
+ mapProviderStatusToEndReason,
27
+ normalizeProviderStatus,
28
+ } from "./shared/call-status.js";
29
+ import { guardedJsonApiRequest } from "./shared/guarded-json-api.js";
22
30
  import { twilioApiRequest } from "./twilio/api.js";
31
+ import { decideTwimlResponse, readTwimlRequestView } from "./twilio/twiml-policy.js";
23
32
  import { verifyTwilioProviderWebhook } from "./twilio/webhook.js";
24
33
 
25
34
  function createTwilioRequestDedupeKey(ctx: WebhookContext, verifiedRequestKey?: string): string {
@@ -92,6 +101,7 @@ export class TwilioProvider implements VoiceCallProvider {
92
101
  private readonly twimlStorage = new Map<string, string>();
93
102
  /** Track notify-mode calls to avoid streaming on follow-up callbacks */
94
103
  private readonly notifyCalls = new Set<string>();
104
+ private readonly activeStreamCalls = new Set<string>();
95
105
 
96
106
  /**
97
107
  * Delete stored TwiML for a given `callId`.
@@ -164,6 +174,7 @@ export class TwilioProvider implements VoiceCallProvider {
164
174
 
165
175
  unregisterCallStream(callSid: string): void {
166
176
  this.callStreamMap.delete(callSid);
177
+ this.activeStreamCalls.delete(callSid);
167
178
  }
168
179
 
169
180
  isValidStreamToken(callSid: string, token?: string): boolean {
@@ -322,32 +333,28 @@ export class TwilioProvider implements VoiceCallProvider {
322
333
  }
323
334
 
324
335
  // Handle call status changes
325
- const callStatus = params.get("CallStatus");
326
- switch (callStatus) {
327
- case "initiated":
328
- return { ...baseEvent, type: "call.initiated" };
329
- case "ringing":
330
- return { ...baseEvent, type: "call.ringing" };
331
- case "in-progress":
332
- return { ...baseEvent, type: "call.answered" };
333
- case "completed":
334
- case "busy":
335
- case "no-answer":
336
- case "failed":
337
- this.streamAuthTokens.delete(callSid);
338
- if (callIdOverride) {
339
- this.deleteStoredTwiml(callIdOverride);
340
- }
341
- return { ...baseEvent, type: "call.ended", reason: callStatus };
342
- case "canceled":
343
- this.streamAuthTokens.delete(callSid);
344
- if (callIdOverride) {
345
- this.deleteStoredTwiml(callIdOverride);
346
- }
347
- return { ...baseEvent, type: "call.ended", reason: "hangup-bot" };
348
- default:
349
- return null;
336
+ const callStatus = normalizeProviderStatus(params.get("CallStatus"));
337
+ if (callStatus === "initiated") {
338
+ return { ...baseEvent, type: "call.initiated" };
339
+ }
340
+ if (callStatus === "ringing") {
341
+ return { ...baseEvent, type: "call.ringing" };
342
+ }
343
+ if (callStatus === "in-progress") {
344
+ return { ...baseEvent, type: "call.answered" };
350
345
  }
346
+
347
+ const endReason = mapProviderStatusToEndReason(callStatus);
348
+ if (endReason) {
349
+ this.streamAuthTokens.delete(callSid);
350
+ this.activeStreamCalls.delete(callSid);
351
+ if (callIdOverride) {
352
+ this.deleteStoredTwiml(callIdOverride);
353
+ }
354
+ return { ...baseEvent, type: "call.ended", reason: endReason };
355
+ }
356
+
357
+ return null;
351
358
  }
352
359
 
353
360
  private static readonly EMPTY_TWIML =
@@ -358,6 +365,12 @@ export class TwilioProvider implements VoiceCallProvider {
358
365
  <Pause length="30"/>
359
366
  </Response>`;
360
367
 
368
+ private static readonly QUEUE_TWIML = `<?xml version="1.0" encoding="UTF-8"?>
369
+ <Response>
370
+ <Say voice="alice">Please hold while we connect you.</Say>
371
+ <Enqueue waitUrl="/voice/hold-music">hold-queue</Enqueue>
372
+ </Response>`;
373
+
361
374
  /**
362
375
  * Generate TwiML response for webhook.
363
376
  * When a call is answered, connects to media stream for bidirectional audio.
@@ -367,59 +380,40 @@ export class TwilioProvider implements VoiceCallProvider {
367
380
  return TwilioProvider.EMPTY_TWIML;
368
381
  }
369
382
 
370
- const params = new URLSearchParams(ctx.rawBody);
371
- const type = typeof ctx.query?.type === "string" ? ctx.query.type.trim() : undefined;
372
- const isStatusCallback = type === "status";
373
- const callStatus = params.get("CallStatus");
374
- const direction = params.get("Direction");
375
- const isOutbound = direction?.startsWith("outbound") ?? false;
376
- const callSid = params.get("CallSid") || undefined;
377
- const callIdFromQuery =
378
- typeof ctx.query?.callId === "string" && ctx.query.callId.trim()
379
- ? ctx.query.callId.trim()
380
- : undefined;
381
-
382
- // Avoid logging webhook params/TwiML (may contain PII).
383
-
384
- // Handle initial TwiML request (when Twilio first initiates the call)
385
- // Check if we have stored TwiML for this call (notify mode)
386
- if (callIdFromQuery && !isStatusCallback) {
387
- const storedTwiml = this.twimlStorage.get(callIdFromQuery);
388
- if (storedTwiml) {
389
- // Clean up after serving (one-time use)
390
- this.deleteStoredTwiml(callIdFromQuery);
391
- return storedTwiml;
392
- }
393
- if (this.notifyCalls.has(callIdFromQuery)) {
394
- return TwilioProvider.EMPTY_TWIML;
395
- }
396
-
397
- // Conversation mode: return streaming TwiML immediately for outbound calls.
398
- if (isOutbound) {
399
- const streamUrl = callSid ? this.getStreamUrlForCall(callSid) : null;
400
- return streamUrl ? this.getStreamConnectXml(streamUrl) : TwilioProvider.PAUSE_TWIML;
401
- }
402
- }
383
+ const view = readTwimlRequestView(ctx);
384
+ const storedTwiml = view.callIdFromQuery
385
+ ? this.twimlStorage.get(view.callIdFromQuery)
386
+ : undefined;
387
+ const decision = decideTwimlResponse({
388
+ ...view,
389
+ hasStoredTwiml: Boolean(storedTwiml),
390
+ isNotifyCall: view.callIdFromQuery ? this.notifyCalls.has(view.callIdFromQuery) : false,
391
+ hasActiveStreams: this.activeStreamCalls.size > 0,
392
+ canStream: Boolean(view.callSid && this.getStreamUrl()),
393
+ });
403
394
 
404
- // Status callbacks should not receive TwiML.
405
- if (isStatusCallback) {
406
- return TwilioProvider.EMPTY_TWIML;
395
+ if (decision.consumeStoredTwimlCallId) {
396
+ this.deleteStoredTwiml(decision.consumeStoredTwimlCallId);
407
397
  }
408
-
409
- // Handle subsequent webhook requests (status callbacks, etc.)
410
- // For inbound calls, answer immediately with stream
411
- if (direction === "inbound") {
412
- const streamUrl = callSid ? this.getStreamUrlForCall(callSid) : null;
413
- return streamUrl ? this.getStreamConnectXml(streamUrl) : TwilioProvider.PAUSE_TWIML;
398
+ if (decision.activateStreamCallSid) {
399
+ this.activeStreamCalls.add(decision.activateStreamCallSid);
414
400
  }
415
401
 
416
- // For outbound calls, only connect to stream when call is in-progress
417
- if (callStatus !== "in-progress") {
418
- return TwilioProvider.EMPTY_TWIML;
402
+ switch (decision.kind) {
403
+ case "stored":
404
+ return storedTwiml ?? TwilioProvider.EMPTY_TWIML;
405
+ case "queue":
406
+ return TwilioProvider.QUEUE_TWIML;
407
+ case "pause":
408
+ return TwilioProvider.PAUSE_TWIML;
409
+ case "stream": {
410
+ const streamUrl = view.callSid ? this.getStreamUrlForCall(view.callSid) : null;
411
+ return streamUrl ? this.getStreamConnectXml(streamUrl) : TwilioProvider.PAUSE_TWIML;
412
+ }
413
+ case "empty":
414
+ default:
415
+ return TwilioProvider.EMPTY_TWIML;
419
416
  }
420
-
421
- const streamUrl = callSid ? this.getStreamUrlForCall(callSid) : null;
422
- return streamUrl ? this.getStreamConnectXml(streamUrl) : TwilioProvider.PAUSE_TWIML;
423
417
  }
424
418
 
425
419
  /**
@@ -543,6 +537,7 @@ export class TwilioProvider implements VoiceCallProvider {
543
537
 
544
538
  this.callWebhookUrls.delete(input.providerCallId);
545
539
  this.streamAuthTokens.delete(input.providerCallId);
540
+ this.activeStreamCalls.delete(input.providerCallId);
546
541
 
547
542
  await this.apiRequest(
548
543
  `/Calls/${input.providerCallId}.json`,
@@ -671,6 +666,32 @@ export class TwilioProvider implements VoiceCallProvider {
671
666
  // Twilio's <Gather> automatically stops on speech end
672
667
  // No explicit action needed
673
668
  }
669
+
670
+ async getCallStatus(input: GetCallStatusInput): Promise<GetCallStatusResult> {
671
+ try {
672
+ const data = await guardedJsonApiRequest<{ status?: string }>({
673
+ url: `${this.baseUrl}/Calls/${input.providerCallId}.json`,
674
+ method: "GET",
675
+ headers: {
676
+ Authorization: `Basic ${Buffer.from(`${this.accountSid}:${this.authToken}`).toString("base64")}`,
677
+ },
678
+ allowNotFound: true,
679
+ allowedHostnames: ["api.twilio.com"],
680
+ auditContext: "twilio-get-call-status",
681
+ errorPrefix: "Twilio get call status error",
682
+ });
683
+
684
+ if (!data) {
685
+ return { status: "not-found", isTerminal: true };
686
+ }
687
+
688
+ const status = normalizeProviderStatus(data.status);
689
+ return { status, isTerminal: isProviderStatusTerminal(status) };
690
+ } catch {
691
+ // Transient error — keep the call and rely on timer fallback
692
+ return { status: "error", isTerminal: false, isUnknown: true };
693
+ }
694
+ }
674
695
  }
675
696
 
676
697
  // -----------------------------------------------------------------------------
@@ -0,0 +1,147 @@
1
+ import { beforeEach, describe, expect, it, vi } from "vitest";
2
+ import type { VoiceCallConfig } from "./config.js";
3
+ import type { CoreConfig } from "./core-bridge.js";
4
+
5
+ const mocks = vi.hoisted(() => ({
6
+ resolveVoiceCallConfig: vi.fn(),
7
+ validateProviderConfig: vi.fn(),
8
+ managerInitialize: vi.fn(),
9
+ webhookStart: vi.fn(),
10
+ webhookStop: vi.fn(),
11
+ webhookGetMediaStreamHandler: vi.fn(),
12
+ startTunnel: vi.fn(),
13
+ setupTailscaleExposure: vi.fn(),
14
+ cleanupTailscaleExposure: vi.fn(),
15
+ }));
16
+
17
+ vi.mock("./config.js", () => ({
18
+ resolveVoiceCallConfig: mocks.resolveVoiceCallConfig,
19
+ validateProviderConfig: mocks.validateProviderConfig,
20
+ }));
21
+
22
+ vi.mock("./manager.js", () => ({
23
+ CallManager: class {
24
+ initialize = mocks.managerInitialize;
25
+ },
26
+ }));
27
+
28
+ vi.mock("./webhook.js", () => ({
29
+ VoiceCallWebhookServer: class {
30
+ start = mocks.webhookStart;
31
+ stop = mocks.webhookStop;
32
+ getMediaStreamHandler = mocks.webhookGetMediaStreamHandler;
33
+ },
34
+ }));
35
+
36
+ vi.mock("./tunnel.js", () => ({
37
+ startTunnel: mocks.startTunnel,
38
+ }));
39
+
40
+ vi.mock("./webhook/tailscale.js", () => ({
41
+ setupTailscaleExposure: mocks.setupTailscaleExposure,
42
+ cleanupTailscaleExposure: mocks.cleanupTailscaleExposure,
43
+ }));
44
+
45
+ import { createVoiceCallRuntime } from "./runtime.js";
46
+
47
+ function createBaseConfig(): VoiceCallConfig {
48
+ return {
49
+ enabled: true,
50
+ provider: "mock",
51
+ fromNumber: "+15550001234",
52
+ inboundPolicy: "disabled",
53
+ allowFrom: [],
54
+ outbound: { defaultMode: "notify", notifyHangupDelaySec: 3 },
55
+ maxDurationSeconds: 300,
56
+ staleCallReaperSeconds: 600,
57
+ silenceTimeoutMs: 800,
58
+ transcriptTimeoutMs: 180000,
59
+ ringTimeoutMs: 30000,
60
+ maxConcurrentCalls: 1,
61
+ serve: { port: 3334, bind: "127.0.0.1", path: "/voice/webhook" },
62
+ tailscale: { mode: "off", path: "/voice/webhook" },
63
+ tunnel: { provider: "ngrok", allowNgrokFreeTierLoopbackBypass: false },
64
+ webhookSecurity: {
65
+ allowedHosts: [],
66
+ trustForwardingHeaders: false,
67
+ trustedProxyIPs: [],
68
+ },
69
+ streaming: {
70
+ enabled: false,
71
+ sttProvider: "openai-realtime",
72
+ sttModel: "gpt-4o-transcribe",
73
+ silenceDurationMs: 800,
74
+ vadThreshold: 0.5,
75
+ streamPath: "/voice/stream",
76
+ preStartTimeoutMs: 5000,
77
+ maxPendingConnections: 32,
78
+ maxPendingConnectionsPerIp: 4,
79
+ maxConnections: 128,
80
+ },
81
+ skipSignatureVerification: false,
82
+ stt: { provider: "openai", model: "whisper-1" },
83
+ tts: {
84
+ provider: "openai",
85
+ openai: { model: "gpt-4o-mini-tts", voice: "coral" },
86
+ },
87
+ responseModel: "openai/gpt-4o-mini",
88
+ responseTimeoutMs: 30000,
89
+ };
90
+ }
91
+
92
+ describe("createVoiceCallRuntime lifecycle", () => {
93
+ beforeEach(() => {
94
+ vi.clearAllMocks();
95
+ mocks.resolveVoiceCallConfig.mockImplementation((cfg: VoiceCallConfig) => cfg);
96
+ mocks.validateProviderConfig.mockReturnValue({ valid: true, errors: [] });
97
+ mocks.managerInitialize.mockResolvedValue(undefined);
98
+ mocks.webhookStart.mockResolvedValue("http://127.0.0.1:3334/voice/webhook");
99
+ mocks.webhookStop.mockResolvedValue(undefined);
100
+ mocks.webhookGetMediaStreamHandler.mockReturnValue(undefined);
101
+ mocks.startTunnel.mockResolvedValue(null);
102
+ mocks.setupTailscaleExposure.mockResolvedValue(null);
103
+ mocks.cleanupTailscaleExposure.mockResolvedValue(undefined);
104
+ });
105
+
106
+ it("cleans up tunnel, tailscale, and webhook server when init fails after start", async () => {
107
+ const tunnelStop = vi.fn().mockResolvedValue(undefined);
108
+ mocks.startTunnel.mockResolvedValue({
109
+ publicUrl: "https://public.example/voice/webhook",
110
+ provider: "ngrok",
111
+ stop: tunnelStop,
112
+ });
113
+ mocks.managerInitialize.mockRejectedValue(new Error("init failed"));
114
+
115
+ await expect(
116
+ createVoiceCallRuntime({
117
+ config: createBaseConfig(),
118
+ coreConfig: {},
119
+ }),
120
+ ).rejects.toThrow("init failed");
121
+
122
+ expect(tunnelStop).toHaveBeenCalledTimes(1);
123
+ expect(mocks.cleanupTailscaleExposure).toHaveBeenCalledTimes(1);
124
+ expect(mocks.webhookStop).toHaveBeenCalledTimes(1);
125
+ });
126
+
127
+ it("returns an idempotent stop handler", async () => {
128
+ const tunnelStop = vi.fn().mockResolvedValue(undefined);
129
+ mocks.startTunnel.mockResolvedValue({
130
+ publicUrl: "https://public.example/voice/webhook",
131
+ provider: "ngrok",
132
+ stop: tunnelStop,
133
+ });
134
+
135
+ const runtime = await createVoiceCallRuntime({
136
+ config: createBaseConfig(),
137
+ coreConfig: {} as CoreConfig,
138
+ });
139
+
140
+ await runtime.stop();
141
+ await runtime.stop();
142
+
143
+ expect(tunnelStop).toHaveBeenCalledTimes(1);
144
+ expect(mocks.cleanupTailscaleExposure).toHaveBeenCalledTimes(1);
145
+ expect(mocks.webhookStop).toHaveBeenCalledTimes(1);
146
+ });
147
+ });