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