@openclaw/voice-call 2026.3.12 → 2026.5.1-beta.1

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.
Files changed (103) hide show
  1. package/README.md +30 -48
  2. package/api.ts +16 -0
  3. package/cli-metadata.ts +10 -0
  4. package/config-api.ts +12 -0
  5. package/index.test.ts +866 -0
  6. package/index.ts +374 -147
  7. package/openclaw.plugin.json +336 -157
  8. package/package.json +33 -5
  9. package/runtime-api.ts +20 -0
  10. package/runtime-entry.ts +1 -0
  11. package/setup-api.ts +47 -0
  12. package/src/allowlist.test.ts +18 -0
  13. package/src/cli.ts +533 -68
  14. package/src/config-compat.test.ts +120 -0
  15. package/src/config-compat.ts +227 -0
  16. package/src/config.test.ts +160 -12
  17. package/src/config.ts +243 -74
  18. package/src/core-bridge.ts +2 -147
  19. package/src/deep-merge.test.ts +40 -0
  20. package/src/gateway-continue-operation.ts +200 -0
  21. package/src/http-headers.ts +6 -3
  22. package/src/manager/context.ts +6 -5
  23. package/src/manager/events.test.ts +179 -19
  24. package/src/manager/events.ts +48 -30
  25. package/src/manager/lifecycle.ts +53 -0
  26. package/src/manager/lookup.test.ts +52 -0
  27. package/src/manager/outbound.test.ts +464 -0
  28. package/src/manager/outbound.ts +148 -55
  29. package/src/manager/store.ts +18 -6
  30. package/src/manager/timers.test.ts +129 -0
  31. package/src/manager/timers.ts +4 -3
  32. package/src/manager/twiml.test.ts +13 -0
  33. package/src/manager/twiml.ts +8 -0
  34. package/src/manager.closed-loop.test.ts +30 -12
  35. package/src/manager.inbound-allowlist.test.ts +77 -10
  36. package/src/manager.notify.test.ts +344 -20
  37. package/src/manager.restore.test.ts +118 -65
  38. package/src/manager.test-harness.ts +8 -6
  39. package/src/manager.ts +79 -5
  40. package/src/media-stream.test.ts +578 -81
  41. package/src/media-stream.ts +235 -54
  42. package/src/providers/base.ts +19 -0
  43. package/src/providers/mock.ts +7 -1
  44. package/src/providers/plivo.test.ts +50 -6
  45. package/src/providers/plivo.ts +14 -6
  46. package/src/providers/shared/call-status.ts +2 -1
  47. package/src/providers/shared/guarded-json-api.test.ts +106 -0
  48. package/src/providers/shared/guarded-json-api.ts +1 -1
  49. package/src/providers/telnyx.test.ts +207 -33
  50. package/src/providers/telnyx.ts +40 -3
  51. package/src/providers/twilio/api.test.ts +145 -0
  52. package/src/providers/twilio/api.ts +67 -16
  53. package/src/providers/twilio/twiml-policy.ts +6 -10
  54. package/src/providers/twilio/webhook.ts +1 -1
  55. package/src/providers/twilio.test.ts +431 -27
  56. package/src/providers/twilio.ts +230 -77
  57. package/src/providers/twilio.types.ts +17 -0
  58. package/src/realtime-defaults.ts +3 -0
  59. package/src/realtime-fast-context.test.ts +88 -0
  60. package/src/realtime-fast-context.ts +165 -0
  61. package/src/realtime-transcription.runtime.ts +4 -0
  62. package/src/realtime-voice.runtime.ts +5 -0
  63. package/src/response-generator.test.ts +277 -0
  64. package/src/response-generator.ts +186 -40
  65. package/src/response-model.test.ts +71 -0
  66. package/src/response-model.ts +23 -0
  67. package/src/runtime.test.ts +351 -0
  68. package/src/runtime.ts +254 -24
  69. package/src/telephony-audio.test.ts +61 -0
  70. package/src/telephony-audio.ts +1 -79
  71. package/src/telephony-tts.test.ts +133 -12
  72. package/src/telephony-tts.ts +155 -2
  73. package/src/test-fixtures.ts +26 -7
  74. package/src/tts-provider-voice.test.ts +34 -0
  75. package/src/tts-provider-voice.ts +21 -0
  76. package/src/tunnel.test.ts +166 -0
  77. package/src/tunnel.ts +1 -1
  78. package/src/types.ts +24 -37
  79. package/src/utils.test.ts +17 -0
  80. package/src/voice-mapping.test.ts +34 -0
  81. package/src/voice-mapping.ts +3 -2
  82. package/src/webhook/realtime-handler.test.ts +598 -0
  83. package/src/webhook/realtime-handler.ts +485 -0
  84. package/src/webhook/stale-call-reaper.test.ts +88 -0
  85. package/src/webhook/stale-call-reaper.ts +5 -0
  86. package/src/webhook/tailscale.test.ts +214 -0
  87. package/src/webhook/tailscale.ts +19 -5
  88. package/src/webhook-exposure.test.ts +33 -0
  89. package/src/webhook-exposure.ts +84 -0
  90. package/src/webhook-security.test.ts +245 -126
  91. package/src/webhook-security.ts +43 -29
  92. package/src/webhook.hangup-once.lifecycle.test.ts +135 -0
  93. package/src/webhook.test.ts +1174 -52
  94. package/src/webhook.ts +513 -100
  95. package/src/webhook.types.ts +5 -0
  96. package/src/websocket-test-support.ts +72 -0
  97. package/tsconfig.json +16 -0
  98. package/CHANGELOG.md +0 -115
  99. package/src/providers/index.ts +0 -10
  100. package/src/providers/stt-openai-realtime.test.ts +0 -42
  101. package/src/providers/stt-openai-realtime.ts +0 -311
  102. package/src/providers/tts-openai.test.ts +0 -43
  103. package/src/providers/tts-openai.ts +0 -221
package/index.test.ts ADDED
@@ -0,0 +1,866 @@
1
+ import fs from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import { Command } from "commander";
5
+ import { createTestPluginApi } from "openclaw/plugin-sdk/plugin-test-api";
6
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
7
+ import type { OpenClawPluginApi } from "./api.js";
8
+ import type { VoiceCallRuntime } from "./runtime-entry.js";
9
+
10
+ let runtimeStub: VoiceCallRuntime;
11
+
12
+ vi.mock("./runtime-entry.js", () => ({
13
+ createVoiceCallRuntime: vi.fn(async () => runtimeStub),
14
+ }));
15
+
16
+ import plugin from "./index.js";
17
+ import { createVoiceCallRuntime } from "./runtime-entry.js";
18
+ import { __testing as voiceCallCliTesting } from "./src/cli.js";
19
+
20
+ const noopLogger = {
21
+ info: vi.fn(),
22
+ warn: vi.fn(),
23
+ error: vi.fn(),
24
+ debug: vi.fn(),
25
+ };
26
+
27
+ const callGatewayFromCliMock = vi.fn();
28
+
29
+ type Registered = {
30
+ methods: Map<string, unknown>;
31
+ tools: unknown[];
32
+ service?: Parameters<OpenClawPluginApi["registerService"]>[0];
33
+ };
34
+ type RegisterVoiceCall = (api: Record<string, unknown>) => void;
35
+ type RegisterCliContext = {
36
+ program: Command;
37
+ config: Record<string, unknown>;
38
+ workspaceDir?: string;
39
+ logger: typeof noopLogger;
40
+ };
41
+
42
+ function captureStdout() {
43
+ let output = "";
44
+ const writeSpy = vi.spyOn(process.stdout, "write").mockImplementation(((chunk: unknown) => {
45
+ output += String(chunk);
46
+ return true;
47
+ }) as typeof process.stdout.write);
48
+ return {
49
+ output: () => output,
50
+ restore: () => writeSpy.mockRestore(),
51
+ };
52
+ }
53
+
54
+ function createRuntimeStub(callId = "call-1"): VoiceCallRuntime {
55
+ return {
56
+ config: { toNumber: "+15550001234" } as VoiceCallRuntime["config"],
57
+ provider: {} as VoiceCallRuntime["provider"],
58
+ manager: {
59
+ initiateCall: vi.fn(async () => ({ callId, success: true })),
60
+ continueCall: vi.fn(async () => ({
61
+ success: true,
62
+ transcript: "hello",
63
+ })),
64
+ speak: vi.fn(async () => ({ success: true })),
65
+ sendDtmf: vi.fn(async () => ({ success: true })),
66
+ endCall: vi.fn(async () => ({ success: true })),
67
+ getCall: vi.fn((id: string) => (id === callId ? { callId } : undefined)),
68
+ getCallByProviderCallId: vi.fn(() => undefined),
69
+ getActiveCalls: vi.fn(() => [{ callId }]),
70
+ } as unknown as VoiceCallRuntime["manager"],
71
+ webhookServer: {} as VoiceCallRuntime["webhookServer"],
72
+ webhookUrl: "http://127.0.0.1:3334/voice/webhook",
73
+ publicUrl: null,
74
+ stop: vi.fn(async () => {}),
75
+ };
76
+ }
77
+
78
+ function createServiceContext(): Parameters<NonNullable<Registered["service"]>["start"]>[0] {
79
+ return {
80
+ config: {},
81
+ stateDir: os.tmpdir(),
82
+ logger: noopLogger,
83
+ } as Parameters<NonNullable<Registered["service"]>["start"]>[0];
84
+ }
85
+
86
+ function setup(config: Record<string, unknown>): Registered {
87
+ const methods = new Map<string, unknown>();
88
+ const tools: unknown[] = [];
89
+ let service: Registered["service"];
90
+ const api = createTestPluginApi({
91
+ id: "voice-call",
92
+ name: "Voice Call",
93
+ description: "test",
94
+ version: "0",
95
+ source: "test",
96
+ config: {},
97
+ pluginConfig: config,
98
+ runtime: { tts: { textToSpeechTelephony: vi.fn() } } as unknown as OpenClawPluginApi["runtime"],
99
+ logger: noopLogger,
100
+ registerGatewayMethod: (method: string, handler: unknown) => methods.set(method, handler),
101
+ registerTool: (tool: unknown) => tools.push(tool),
102
+ registerCli: () => {},
103
+ registerService: (registeredService) => {
104
+ service = registeredService;
105
+ },
106
+ resolvePath: (p: string) => p,
107
+ });
108
+ plugin.register(api);
109
+ return { methods, tools, service };
110
+ }
111
+
112
+ function envRef(id: string) {
113
+ return { source: "env" as const, provider: "default", id };
114
+ }
115
+
116
+ async function registerVoiceCallCli(
117
+ program: Command,
118
+ pluginConfig: Record<string, unknown> = { provider: "mock" },
119
+ ) {
120
+ const { register } = plugin as unknown as {
121
+ register: RegisterVoiceCall;
122
+ };
123
+ register({
124
+ id: "voice-call",
125
+ name: "Voice Call",
126
+ description: "test",
127
+ version: "0",
128
+ source: "test",
129
+ config: {},
130
+ pluginConfig,
131
+ runtime: { tts: { textToSpeechTelephony: vi.fn() } },
132
+ logger: noopLogger,
133
+ registerGatewayMethod: () => {},
134
+ registerTool: () => {},
135
+ registerCli: (fn: (ctx: RegisterCliContext) => void) =>
136
+ fn({
137
+ program,
138
+ config: {},
139
+ workspaceDir: undefined,
140
+ logger: noopLogger,
141
+ }),
142
+ registerService: () => {},
143
+ resolvePath: (p: string) => p,
144
+ });
145
+ }
146
+
147
+ describe("voice-call plugin", () => {
148
+ beforeEach(() => {
149
+ noopLogger.info.mockClear();
150
+ noopLogger.warn.mockClear();
151
+ noopLogger.error.mockClear();
152
+ noopLogger.debug.mockClear();
153
+ runtimeStub = createRuntimeStub();
154
+ callGatewayFromCliMock.mockReset();
155
+ callGatewayFromCliMock.mockRejectedValue(new Error("connect ECONNREFUSED 127.0.0.1:18789"));
156
+ voiceCallCliTesting.setCallGatewayFromCliForTests(callGatewayFromCliMock);
157
+ vi.mocked(createVoiceCallRuntime).mockReset();
158
+ vi.mocked(createVoiceCallRuntime).mockImplementation(async () => runtimeStub);
159
+ });
160
+
161
+ afterEach(() => {
162
+ voiceCallCliTesting.setCallGatewayFromCliForTests();
163
+ vi.restoreAllMocks();
164
+ vi.unstubAllEnvs();
165
+ delete (globalThis as Record<PropertyKey, unknown>)[Symbol.for("openclaw.voice-call.runtime")];
166
+ delete (globalThis as Record<PropertyKey, unknown>)[
167
+ Symbol.for("openclaw.voice-call.runtimePromise")
168
+ ];
169
+ delete (globalThis as Record<PropertyKey, unknown>)[
170
+ Symbol.for("openclaw.voice-call.runtimeStopPromise")
171
+ ];
172
+ });
173
+
174
+ it("reuses a started runtime across plugin registration contexts", async () => {
175
+ const first = setup({ provider: "mock" });
176
+ const second = setup({ provider: "mock" });
177
+
178
+ await first.service?.start(createServiceContext());
179
+ const handler = second.methods.get("voicecall.initiate") as
180
+ | ((ctx: {
181
+ params: Record<string, unknown>;
182
+ respond: ReturnType<typeof vi.fn>;
183
+ }) => Promise<void>)
184
+ | undefined;
185
+ const respond = vi.fn();
186
+ await handler?.({ params: { message: "Hi" }, respond });
187
+
188
+ expect(createVoiceCallRuntime).toHaveBeenCalledTimes(1);
189
+ expect(runtimeStub.manager.initiateCall).toHaveBeenCalledTimes(1);
190
+ expect(respond).toHaveBeenCalledWith(true, { callId: "call-1", initiated: true });
191
+ });
192
+
193
+ it("does not block service startup while runtime exposure initializes", async () => {
194
+ let resolveRuntime: ((runtime: VoiceCallRuntime) => void) | undefined;
195
+ vi.mocked(createVoiceCallRuntime).mockReturnValueOnce(
196
+ new Promise<VoiceCallRuntime>((resolve) => {
197
+ resolveRuntime = resolve;
198
+ }),
199
+ );
200
+ const { service, methods } = setup({ provider: "mock" });
201
+
202
+ expect(service).toBeDefined();
203
+ expect(service!.start(createServiceContext())).toBeUndefined();
204
+ expect(createVoiceCallRuntime).toHaveBeenCalledTimes(1);
205
+
206
+ resolveRuntime?.(runtimeStub);
207
+ const handler = methods.get("voicecall.initiate") as
208
+ | ((ctx: {
209
+ params: Record<string, unknown>;
210
+ respond: ReturnType<typeof vi.fn>;
211
+ }) => Promise<void>)
212
+ | undefined;
213
+ const respond = vi.fn();
214
+ await handler?.({ params: { message: "Hi" }, respond });
215
+
216
+ expect(respond).toHaveBeenCalledWith(true, { callId: "call-1", initiated: true });
217
+ });
218
+
219
+ it("does not start the webhook runtime for CLI-only plugin loading", async () => {
220
+ vi.stubEnv("OPENCLAW_CLI", "1");
221
+ const { service } = setup({ provider: "mock" });
222
+
223
+ await service?.start(createServiceContext());
224
+
225
+ expect(createVoiceCallRuntime).not.toHaveBeenCalled();
226
+ });
227
+
228
+ it("still starts the webhook runtime for gateway CLI processes", async () => {
229
+ const previousArgv = process.argv;
230
+ vi.stubEnv("OPENCLAW_CLI", "1");
231
+ process.argv = ["node", "openclaw", "gateway", "run"];
232
+ const { service } = setup({ provider: "mock" });
233
+
234
+ try {
235
+ await service?.start(createServiceContext());
236
+ expect(createVoiceCallRuntime).toHaveBeenCalledTimes(1);
237
+ } finally {
238
+ process.argv = previousArgv;
239
+ }
240
+ });
241
+
242
+ it("creates a fresh shared runtime after service stop", async () => {
243
+ const first = setup({ provider: "mock" });
244
+ await first.service?.start(createServiceContext());
245
+ await first.service?.stop?.(createServiceContext());
246
+
247
+ runtimeStub = createRuntimeStub("call-2");
248
+ const second = setup({ provider: "mock" });
249
+ const handler = second.methods.get("voicecall.initiate") as
250
+ | ((ctx: {
251
+ params: Record<string, unknown>;
252
+ respond: ReturnType<typeof vi.fn>;
253
+ }) => Promise<void>)
254
+ | undefined;
255
+ const respond = vi.fn();
256
+ await handler?.({ params: { message: "Hi" }, respond });
257
+
258
+ expect(createVoiceCallRuntime).toHaveBeenCalledTimes(2);
259
+ expect(respond).toHaveBeenCalledWith(true, { callId: "call-2", initiated: true });
260
+ });
261
+
262
+ it("does not log a startup error when provider setup is incomplete", async () => {
263
+ vi.stubEnv("TWILIO_ACCOUNT_SID", "");
264
+ vi.stubEnv("TWILIO_AUTH_TOKEN", "");
265
+ vi.stubEnv("TWILIO_FROM_NUMBER", "");
266
+ const { service } = setup({ provider: "twilio" });
267
+
268
+ await service?.start(createServiceContext());
269
+
270
+ expect(createVoiceCallRuntime).not.toHaveBeenCalled();
271
+ expect(
272
+ noopLogger.error.mock.calls.some(([message]) =>
273
+ String(message).includes("Failed to start runtime"),
274
+ ),
275
+ ).toBe(false);
276
+ expect(noopLogger.warn).toHaveBeenCalledWith(
277
+ expect.stringContaining("Runtime not started; setup incomplete"),
278
+ );
279
+ expect(noopLogger.warn).toHaveBeenCalledWith(expect.stringContaining("TWILIO_ACCOUNT_SID"));
280
+ });
281
+
282
+ it("registers Twilio configs with SecretRef auth tokens", async () => {
283
+ const authToken = envRef("TWILIO_AUTH_TOKEN");
284
+ const { service } = setup({
285
+ enabled: true,
286
+ provider: "twilio",
287
+ fromNumber: "+15550001234",
288
+ twilio: {
289
+ accountSid: "AC123",
290
+ authToken,
291
+ },
292
+ });
293
+
294
+ await service?.start(createServiceContext());
295
+
296
+ expect(createVoiceCallRuntime).toHaveBeenCalledTimes(1);
297
+ expect(vi.mocked(createVoiceCallRuntime).mock.calls[0]?.[0]?.config.twilio?.authToken).toEqual(
298
+ authToken,
299
+ );
300
+ });
301
+
302
+ it("still reports missing provider setup when a command needs the runtime", async () => {
303
+ vi.stubEnv("TWILIO_ACCOUNT_SID", "");
304
+ vi.stubEnv("TWILIO_AUTH_TOKEN", "");
305
+ vi.stubEnv("TWILIO_FROM_NUMBER", "");
306
+ const { methods } = setup({ provider: "twilio" });
307
+ const handler = methods.get("voicecall.initiate") as
308
+ | ((ctx: {
309
+ params: Record<string, unknown>;
310
+ respond: ReturnType<typeof vi.fn>;
311
+ }) => Promise<void>)
312
+ | undefined;
313
+ const respond = vi.fn();
314
+
315
+ await handler?.({ params: { message: "Hi", to: "+15550001234" }, respond });
316
+
317
+ expect(createVoiceCallRuntime).not.toHaveBeenCalled();
318
+ expect(respond).toHaveBeenCalledWith(
319
+ false,
320
+ undefined,
321
+ expect.objectContaining({
322
+ message: expect.stringContaining("TWILIO_ACCOUNT_SID"),
323
+ }),
324
+ );
325
+ });
326
+
327
+ it("initiates a call via voicecall.initiate", async () => {
328
+ const { methods } = setup({ provider: "mock" });
329
+ const handler = methods.get("voicecall.initiate") as
330
+ | ((ctx: {
331
+ params: Record<string, unknown>;
332
+ respond: ReturnType<typeof vi.fn>;
333
+ }) => Promise<void>)
334
+ | undefined;
335
+ const respond = vi.fn();
336
+ await handler?.({ params: { message: "Hi" }, respond });
337
+ expect(runtimeStub.manager.initiateCall).toHaveBeenCalled();
338
+ const [ok, payload] = respond.mock.calls[0];
339
+ expect(ok).toBe(true);
340
+ expect(payload.callId).toBe("call-1");
341
+ });
342
+
343
+ it("preserves mode on legacy voicecall.start", async () => {
344
+ const { methods } = setup({ provider: "mock" });
345
+ const handler = methods.get("voicecall.start") as
346
+ | ((ctx: {
347
+ params: Record<string, unknown>;
348
+ respond: ReturnType<typeof vi.fn>;
349
+ }) => Promise<void>)
350
+ | undefined;
351
+ const respond = vi.fn();
352
+ await handler?.({
353
+ params: {
354
+ dtmfSequence: "ww123456#",
355
+ message: "Hi",
356
+ mode: "conversation",
357
+ to: "+15550001234",
358
+ },
359
+ respond,
360
+ });
361
+ expect(runtimeStub.manager.initiateCall).toHaveBeenCalledWith("+15550001234", undefined, {
362
+ dtmfSequence: "ww123456#",
363
+ message: "Hi",
364
+ mode: "conversation",
365
+ });
366
+ expect(respond.mock.calls[0]?.[0]).toBe(true);
367
+ });
368
+
369
+ it("returns call status", async () => {
370
+ const { methods } = setup({ provider: "mock" });
371
+ const handler = methods.get("voicecall.status") as
372
+ | ((ctx: {
373
+ params: Record<string, unknown>;
374
+ respond: ReturnType<typeof vi.fn>;
375
+ }) => Promise<void>)
376
+ | undefined;
377
+ const respond = vi.fn();
378
+ await handler?.({ params: { callId: "call-1" }, respond });
379
+ const [ok, payload] = respond.mock.calls[0];
380
+ expect(ok).toBe(true);
381
+ expect(payload.found).toBe(true);
382
+ });
383
+
384
+ it("sends DTMF via voicecall.dtmf", async () => {
385
+ const { methods } = setup({ provider: "mock" });
386
+ const handler = methods.get("voicecall.dtmf") as
387
+ | ((ctx: {
388
+ params: Record<string, unknown>;
389
+ respond: ReturnType<typeof vi.fn>;
390
+ }) => Promise<void>)
391
+ | undefined;
392
+ const respond = vi.fn();
393
+
394
+ await handler?.({ params: { callId: "call-1", digits: "ww123#" }, respond });
395
+
396
+ expect(runtimeStub.manager.sendDtmf).toHaveBeenCalledWith("call-1", "ww123#");
397
+ expect(respond.mock.calls[0]).toEqual([true, { success: true }]);
398
+ });
399
+
400
+ it("normalizes legacy config through runtime creation and warns to run doctor", async () => {
401
+ const { methods } = setup({
402
+ enabled: true,
403
+ provider: "log",
404
+ twilio: {
405
+ from: "+15550001234",
406
+ },
407
+ streaming: {
408
+ enabled: true,
409
+ sttProvider: "openai",
410
+ openaiApiKey: "sk-test", // pragma: allowlist secret
411
+ },
412
+ });
413
+ const handler = methods.get("voicecall.status") as
414
+ | ((ctx: {
415
+ params: Record<string, unknown>;
416
+ respond: ReturnType<typeof vi.fn>;
417
+ }) => Promise<void>)
418
+ | undefined;
419
+ const respond = vi.fn();
420
+
421
+ await handler?.({ params: { callId: "call-1" }, respond });
422
+
423
+ expect(vi.mocked(createVoiceCallRuntime)).toHaveBeenCalledTimes(1);
424
+ expect(vi.mocked(createVoiceCallRuntime).mock.calls[0]?.[0]?.config).toMatchObject({
425
+ enabled: true,
426
+ provider: "mock",
427
+ fromNumber: "+15550001234",
428
+ streaming: {
429
+ enabled: true,
430
+ provider: "openai",
431
+ providers: {
432
+ openai: {
433
+ apiKey: "sk-test",
434
+ },
435
+ },
436
+ },
437
+ });
438
+ expect(noopLogger.warn).toHaveBeenCalledWith(
439
+ expect.stringContaining('Run "openclaw doctor --fix"'),
440
+ );
441
+ });
442
+
443
+ it("tool get_status returns json payload", async () => {
444
+ const { tools } = setup({ provider: "mock" });
445
+ const tool = tools[0] as {
446
+ execute: (id: string, params: unknown) => Promise<unknown>;
447
+ };
448
+ const result = (await tool.execute("id", {
449
+ action: "get_status",
450
+ callId: "call-1",
451
+ })) as { details: { found?: boolean } };
452
+ expect(result.details.found).toBe(true);
453
+ });
454
+
455
+ it("tool send_dtmf returns json payload", async () => {
456
+ const { tools } = setup({ provider: "mock" });
457
+ const tool = tools[0] as {
458
+ execute: (id: string, params: unknown) => Promise<unknown>;
459
+ };
460
+ const result = (await tool.execute("id", {
461
+ action: "send_dtmf",
462
+ callId: "call-1",
463
+ digits: "ww123#",
464
+ })) as { details: { success?: boolean } };
465
+ expect(runtimeStub.manager.sendDtmf).toHaveBeenCalledWith("call-1", "ww123#");
466
+ expect(result.details.success).toBe(true);
467
+ });
468
+
469
+ it("legacy tool status without sid returns error payload", async () => {
470
+ const { tools } = setup({ provider: "mock" });
471
+ const tool = tools[0] as {
472
+ execute: (id: string, params: unknown) => Promise<unknown>;
473
+ };
474
+ const result = (await tool.execute("id", { mode: "status" })) as {
475
+ details: { error?: unknown };
476
+ };
477
+ expect(String(result.details.error)).toContain("sid required");
478
+ });
479
+
480
+ it("CLI latency summarizes turn metrics from JSONL", async () => {
481
+ const program = new Command();
482
+ const tmpFile = path.join(os.tmpdir(), `voicecall-latency-${Date.now()}.jsonl`);
483
+ fs.writeFileSync(
484
+ tmpFile,
485
+ [
486
+ JSON.stringify({ metadata: { lastTurnLatencyMs: 100, lastTurnListenWaitMs: 70 } }),
487
+ JSON.stringify({ metadata: { lastTurnLatencyMs: 200, lastTurnListenWaitMs: 110 } }),
488
+ ].join("\n") + "\n",
489
+ "utf8",
490
+ );
491
+
492
+ const stdout = captureStdout();
493
+
494
+ try {
495
+ await registerVoiceCallCli(program);
496
+
497
+ await program.parseAsync(["voicecall", "latency", "--file", tmpFile, "--last", "10"], {
498
+ from: "user",
499
+ });
500
+
501
+ const printed = stdout.output();
502
+ expect(printed).toContain('"recordsScanned": 2');
503
+ expect(printed).toContain('"p50Ms": 100');
504
+ expect(printed).toContain('"p95Ms": 200');
505
+ } finally {
506
+ stdout.restore();
507
+ fs.unlinkSync(tmpFile);
508
+ }
509
+ });
510
+
511
+ it("CLI start prints JSON", async () => {
512
+ const program = new Command();
513
+ const stdout = captureStdout();
514
+ await registerVoiceCallCli(program);
515
+
516
+ try {
517
+ await program.parseAsync(["voicecall", "start", "--to", "+1", "--message", "Hello"], {
518
+ from: "user",
519
+ });
520
+ expect(stdout.output()).toContain('"callId": "call-1"');
521
+ } finally {
522
+ stdout.restore();
523
+ }
524
+ });
525
+
526
+ it("CLI start delegates to the running gateway runtime", async () => {
527
+ callGatewayFromCliMock.mockResolvedValueOnce({ callId: "gateway-call", initiated: true });
528
+ const program = new Command();
529
+ const stdout = captureStdout();
530
+ await registerVoiceCallCli(program);
531
+
532
+ try {
533
+ await program.parseAsync(["voicecall", "start", "--to", "+1", "--message", "Hello"], {
534
+ from: "user",
535
+ });
536
+ expect(callGatewayFromCliMock).toHaveBeenCalledWith(
537
+ "voicecall.start",
538
+ { json: true, timeout: "35000" },
539
+ { to: "+1", message: "Hello", mode: "conversation" },
540
+ { progress: false },
541
+ );
542
+ expect(createVoiceCallRuntime).not.toHaveBeenCalled();
543
+ expect(stdout.output()).toContain('"callId": "gateway-call"');
544
+ } finally {
545
+ stdout.restore();
546
+ }
547
+ });
548
+
549
+ it("responds with protocol errors for delegated gateway failures", async () => {
550
+ const { methods } = setup({ provider: "mock" });
551
+ const handler = methods.get("voicecall.start") as
552
+ | ((ctx: {
553
+ params: Record<string, unknown>;
554
+ respond: ReturnType<typeof vi.fn>;
555
+ }) => Promise<void>)
556
+ | undefined;
557
+ const respond = vi.fn();
558
+
559
+ await handler?.({ params: {}, respond });
560
+
561
+ expect(respond).toHaveBeenCalledWith(
562
+ false,
563
+ undefined,
564
+ expect.objectContaining({
565
+ code: "INVALID_REQUEST",
566
+ message: "to required",
567
+ }),
568
+ );
569
+ });
570
+
571
+ it("starts and polls delegated gateway continue operations", async () => {
572
+ callGatewayFromCliMock
573
+ .mockResolvedValueOnce({
574
+ operationId: "op-1",
575
+ status: "pending",
576
+ pollTimeoutMs: 180000,
577
+ })
578
+ .mockResolvedValueOnce({
579
+ operationId: "op-1",
580
+ status: "completed",
581
+ result: { success: true, transcript: "gateway hello" },
582
+ });
583
+ const program = new Command();
584
+ const stdout = captureStdout();
585
+ await registerVoiceCallCli(program, {
586
+ provider: "mock",
587
+ transcriptTimeoutMs: 120000,
588
+ tts: { timeoutMs: 30000 },
589
+ });
590
+
591
+ try {
592
+ await program.parseAsync(
593
+ ["voicecall", "continue", "--call-id", "call-1", "--message", "Hello"],
594
+ {
595
+ from: "user",
596
+ },
597
+ );
598
+ expect(callGatewayFromCliMock).toHaveBeenCalledWith(
599
+ "voicecall.continue.start",
600
+ { json: true, timeout: "35000" },
601
+ { callId: "call-1", message: "Hello" },
602
+ { progress: false },
603
+ );
604
+ expect(callGatewayFromCliMock).toHaveBeenCalledWith(
605
+ "voicecall.continue.result",
606
+ { json: true, timeout: "5000" },
607
+ { operationId: "op-1" },
608
+ { progress: false },
609
+ );
610
+ expect(createVoiceCallRuntime).not.toHaveBeenCalled();
611
+ expect(stdout.output()).toContain('"transcript": "gateway hello"');
612
+ } finally {
613
+ stdout.restore();
614
+ }
615
+ });
616
+
617
+ it("gateway continue operations return pending then completed results", async () => {
618
+ let finishContinue: ((value: { success: true; transcript: string }) => void) | undefined;
619
+ const continuePromise = new Promise<{ success: true; transcript: string }>((resolve) => {
620
+ finishContinue = resolve;
621
+ });
622
+ runtimeStub.manager.continueCall = vi.fn(
623
+ async () => await continuePromise,
624
+ ) as VoiceCallRuntime["manager"]["continueCall"];
625
+ const { methods } = setup({
626
+ provider: "mock",
627
+ transcriptTimeoutMs: 120000,
628
+ tts: { timeoutMs: 30000 },
629
+ });
630
+ const start = methods.get("voicecall.continue.start") as
631
+ | ((ctx: {
632
+ params: Record<string, unknown>;
633
+ respond: ReturnType<typeof vi.fn>;
634
+ }) => Promise<void>)
635
+ | undefined;
636
+ const result = methods.get("voicecall.continue.result") as
637
+ | ((ctx: {
638
+ params: Record<string, unknown>;
639
+ respond: ReturnType<typeof vi.fn>;
640
+ }) => Promise<void>)
641
+ | undefined;
642
+ const startRespond = vi.fn();
643
+
644
+ await start?.({
645
+ params: { callId: "call-1", message: "Hello" },
646
+ respond: startRespond,
647
+ });
648
+ const startPayload = startRespond.mock.calls[0]?.[1] as
649
+ | { operationId?: string; pollTimeoutMs?: number }
650
+ | undefined;
651
+ expect(startPayload).toEqual(
652
+ expect.objectContaining({
653
+ operationId: expect.any(String),
654
+ status: "pending",
655
+ pollTimeoutMs: 180000,
656
+ }),
657
+ );
658
+ expect(runtimeStub.manager.continueCall).toHaveBeenCalledWith("call-1", "Hello");
659
+
660
+ const pendingRespond = vi.fn();
661
+ await result?.({
662
+ params: { operationId: startPayload?.operationId },
663
+ respond: pendingRespond,
664
+ });
665
+ expect(pendingRespond).toHaveBeenCalledWith(
666
+ true,
667
+ expect.objectContaining({ status: "pending" }),
668
+ );
669
+
670
+ finishContinue?.({ success: true, transcript: "gateway hello" });
671
+ await continuePromise;
672
+ await Promise.resolve();
673
+
674
+ const completedRespond = vi.fn();
675
+ await result?.({
676
+ params: { operationId: startPayload?.operationId },
677
+ respond: completedRespond,
678
+ });
679
+ expect(completedRespond).toHaveBeenCalledWith(
680
+ true,
681
+ expect.objectContaining({
682
+ status: "completed",
683
+ result: { success: true, transcript: "gateway hello" },
684
+ }),
685
+ );
686
+ });
687
+
688
+ it("CLI setup prints human-readable checks by default", async () => {
689
+ const program = new Command();
690
+ const stdout = captureStdout();
691
+ await registerVoiceCallCli(program, {
692
+ provider: "twilio",
693
+ fromNumber: "+15550001234",
694
+ publicUrl: "https://voice.example.com/voice/webhook",
695
+ twilio: {
696
+ accountSid: "AC123",
697
+ authToken: "token",
698
+ },
699
+ });
700
+
701
+ try {
702
+ await program.parseAsync(["voicecall", "setup"], { from: "user" });
703
+ expect(stdout.output()).toContain("Voice Call setup: OK");
704
+ expect(stdout.output()).toContain("OK provider: Provider configured: twilio");
705
+ } finally {
706
+ stdout.restore();
707
+ }
708
+ });
709
+
710
+ it("CLI setup preserves JSON output with --json", async () => {
711
+ const program = new Command();
712
+ const stdout = captureStdout();
713
+ await registerVoiceCallCli(program, {
714
+ provider: "twilio",
715
+ fromNumber: "+15550001234",
716
+ twilio: {
717
+ accountSid: "AC123",
718
+ authToken: "token",
719
+ },
720
+ });
721
+
722
+ try {
723
+ await program.parseAsync(["voicecall", "setup", "--json"], { from: "user" });
724
+ const parsed = JSON.parse(stdout.output()) as {
725
+ ok?: boolean;
726
+ checks?: Array<{ id: string; ok: boolean }>;
727
+ };
728
+ expect(parsed.ok).toBe(false);
729
+ expect(parsed.checks).toContainEqual(
730
+ expect.objectContaining({ id: "webhook-exposure", ok: false }),
731
+ );
732
+ } finally {
733
+ stdout.restore();
734
+ }
735
+ });
736
+
737
+ it.each([
738
+ "http://127.0.0.1:3334/voice/webhook",
739
+ "http://[::1]:3334/voice/webhook",
740
+ "http://[fd00::1]/voice/webhook",
741
+ ])("CLI setup rejects local public webhook URL %s for Twilio", async (publicUrl) => {
742
+ const program = new Command();
743
+ const stdout = captureStdout();
744
+ await registerVoiceCallCli(program, {
745
+ provider: "twilio",
746
+ fromNumber: "+15550001234",
747
+ publicUrl,
748
+ twilio: {
749
+ accountSid: "AC123",
750
+ authToken: "token",
751
+ },
752
+ });
753
+
754
+ try {
755
+ await program.parseAsync(["voicecall", "setup", "--json"], { from: "user" });
756
+ const parsed = JSON.parse(stdout.output()) as {
757
+ ok?: boolean;
758
+ checks?: Array<{ id: string; ok: boolean; message: string }>;
759
+ };
760
+ expect(parsed.ok).toBe(false);
761
+ expect(parsed.checks).toContainEqual(
762
+ expect.objectContaining({
763
+ id: "webhook-exposure",
764
+ ok: false,
765
+ message: expect.stringContaining("local/private"),
766
+ }),
767
+ );
768
+ } finally {
769
+ stdout.restore();
770
+ }
771
+ });
772
+
773
+ it("CLI status lists active calls without a call id", async () => {
774
+ const program = new Command();
775
+ const stdout = captureStdout();
776
+ await registerVoiceCallCli(program);
777
+
778
+ try {
779
+ await program.parseAsync(["voicecall", "status", "--json"], { from: "user" });
780
+ const parsed = JSON.parse(stdout.output()) as {
781
+ calls?: Array<{ callId?: string }>;
782
+ };
783
+ expect(parsed.calls).toEqual([expect.objectContaining({ callId: "call-1" })]);
784
+ } finally {
785
+ stdout.restore();
786
+ }
787
+ });
788
+
789
+ it("CLI status lists active calls through the running gateway runtime", async () => {
790
+ callGatewayFromCliMock.mockResolvedValueOnce({
791
+ found: true,
792
+ calls: [{ callId: "gateway-call" }],
793
+ });
794
+ const program = new Command();
795
+ const stdout = captureStdout();
796
+ await registerVoiceCallCli(program);
797
+
798
+ try {
799
+ await program.parseAsync(["voicecall", "status", "--json"], { from: "user" });
800
+ const parsed = JSON.parse(stdout.output()) as {
801
+ calls?: Array<{ callId?: string }>;
802
+ };
803
+ expect(callGatewayFromCliMock).toHaveBeenCalledWith(
804
+ "voicecall.status",
805
+ { json: true, timeout: "5000" },
806
+ undefined,
807
+ { progress: false },
808
+ );
809
+ expect(createVoiceCallRuntime).not.toHaveBeenCalled();
810
+ expect(parsed.calls).toEqual([expect.objectContaining({ callId: "gateway-call" })]);
811
+ } finally {
812
+ stdout.restore();
813
+ }
814
+ });
815
+
816
+ it("CLI smoke dry-runs a live call unless --yes is passed", async () => {
817
+ const program = new Command();
818
+ const stdout = captureStdout();
819
+ await registerVoiceCallCli(program, {
820
+ provider: "twilio",
821
+ fromNumber: "+15550001234",
822
+ publicUrl: "https://voice.example.com/voice/webhook",
823
+ twilio: {
824
+ accountSid: "AC123",
825
+ authToken: "token",
826
+ },
827
+ });
828
+
829
+ try {
830
+ await program.parseAsync(["voicecall", "smoke", "--to", "+15550009999"], {
831
+ from: "user",
832
+ });
833
+ expect(stdout.output()).toContain("live-call: dry run for +15550009999");
834
+ expect(runtimeStub.manager.initiateCall).not.toHaveBeenCalled();
835
+ } finally {
836
+ stdout.restore();
837
+ }
838
+ });
839
+
840
+ it("CLI smoke can place a live notify call with --yes", async () => {
841
+ const program = new Command();
842
+ const stdout = captureStdout();
843
+ await registerVoiceCallCli(program, {
844
+ provider: "twilio",
845
+ fromNumber: "+15550001234",
846
+ publicUrl: "https://voice.example.com/voice/webhook",
847
+ twilio: {
848
+ accountSid: "AC123",
849
+ authToken: "token",
850
+ },
851
+ });
852
+
853
+ try {
854
+ await program.parseAsync(["voicecall", "smoke", "--to", "+15550009999", "--yes"], {
855
+ from: "user",
856
+ });
857
+ expect(runtimeStub.manager.initiateCall).toHaveBeenCalledWith("+15550009999", undefined, {
858
+ message: "OpenClaw voice call smoke test.",
859
+ mode: "notify",
860
+ });
861
+ expect(stdout.output()).toContain("live-call: started call-1");
862
+ } finally {
863
+ stdout.restore();
864
+ }
865
+ });
866
+ });