@openclaw/voice-call 2026.5.2 → 2026.5.3-beta.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (126) hide show
  1. package/dist/api.js +2 -0
  2. package/dist/call-status-CXldV5o8.js +32 -0
  3. package/dist/cli-metadata.js +12 -0
  4. package/dist/config-7w04YpHh.js +548 -0
  5. package/dist/config-compat-B0me39_4.js +129 -0
  6. package/dist/guarded-json-api-Btx5EE4w.js +591 -0
  7. package/dist/http-headers-BrnxBasF.js +10 -0
  8. package/dist/index.js +1284 -0
  9. package/dist/mock-CeKvfVEd.js +135 -0
  10. package/dist/plivo-B-a7KFoT.js +393 -0
  11. package/dist/realtime-handler-B63CIDP2.js +325 -0
  12. package/dist/realtime-transcription.runtime-B2h70y2W.js +2 -0
  13. package/dist/realtime-voice.runtime-Bkh4nvLn.js +2 -0
  14. package/dist/response-generator-BrcmwDZU.js +182 -0
  15. package/dist/response-model-CyF5K80p.js +12 -0
  16. package/dist/runtime-api.js +6 -0
  17. package/dist/runtime-entry-88ytYAQa.js +3119 -0
  18. package/dist/runtime-entry.js +2 -0
  19. package/dist/setup-api.js +37 -0
  20. package/dist/telnyx-jjBE8boz.js +260 -0
  21. package/dist/twilio-1OqbcXLL.js +676 -0
  22. package/dist/voice-mapping-BYDGdWGx.js +40 -0
  23. package/package.json +14 -6
  24. package/api.ts +0 -16
  25. package/cli-metadata.ts +0 -10
  26. package/config-api.ts +0 -12
  27. package/index.test.ts +0 -943
  28. package/index.ts +0 -794
  29. package/runtime-api.ts +0 -20
  30. package/runtime-entry.ts +0 -1
  31. package/setup-api.ts +0 -47
  32. package/src/allowlist.test.ts +0 -18
  33. package/src/allowlist.ts +0 -19
  34. package/src/cli.ts +0 -845
  35. package/src/config-compat.test.ts +0 -120
  36. package/src/config-compat.ts +0 -227
  37. package/src/config.test.ts +0 -479
  38. package/src/config.ts +0 -808
  39. package/src/core-bridge.ts +0 -14
  40. package/src/deep-merge.test.ts +0 -40
  41. package/src/deep-merge.ts +0 -23
  42. package/src/gateway-continue-operation.ts +0 -200
  43. package/src/http-headers.test.ts +0 -16
  44. package/src/http-headers.ts +0 -15
  45. package/src/manager/context.ts +0 -42
  46. package/src/manager/events.test.ts +0 -581
  47. package/src/manager/events.ts +0 -288
  48. package/src/manager/lifecycle.ts +0 -53
  49. package/src/manager/lookup.test.ts +0 -52
  50. package/src/manager/lookup.ts +0 -35
  51. package/src/manager/outbound.test.ts +0 -528
  52. package/src/manager/outbound.ts +0 -486
  53. package/src/manager/state.ts +0 -48
  54. package/src/manager/store.ts +0 -106
  55. package/src/manager/timers.test.ts +0 -129
  56. package/src/manager/timers.ts +0 -113
  57. package/src/manager/twiml.test.ts +0 -13
  58. package/src/manager/twiml.ts +0 -17
  59. package/src/manager.closed-loop.test.ts +0 -236
  60. package/src/manager.inbound-allowlist.test.ts +0 -188
  61. package/src/manager.notify.test.ts +0 -377
  62. package/src/manager.restore.test.ts +0 -183
  63. package/src/manager.test-harness.ts +0 -127
  64. package/src/manager.ts +0 -392
  65. package/src/media-stream.test.ts +0 -768
  66. package/src/media-stream.ts +0 -708
  67. package/src/providers/base.ts +0 -97
  68. package/src/providers/mock.test.ts +0 -78
  69. package/src/providers/mock.ts +0 -185
  70. package/src/providers/plivo.test.ts +0 -93
  71. package/src/providers/plivo.ts +0 -601
  72. package/src/providers/shared/call-status.test.ts +0 -24
  73. package/src/providers/shared/call-status.ts +0 -24
  74. package/src/providers/shared/guarded-json-api.test.ts +0 -106
  75. package/src/providers/shared/guarded-json-api.ts +0 -42
  76. package/src/providers/telnyx.test.ts +0 -340
  77. package/src/providers/telnyx.ts +0 -394
  78. package/src/providers/twilio/api.test.ts +0 -145
  79. package/src/providers/twilio/api.ts +0 -93
  80. package/src/providers/twilio/twiml-policy.test.ts +0 -84
  81. package/src/providers/twilio/twiml-policy.ts +0 -87
  82. package/src/providers/twilio/webhook.ts +0 -34
  83. package/src/providers/twilio.test.ts +0 -591
  84. package/src/providers/twilio.ts +0 -861
  85. package/src/providers/twilio.types.ts +0 -17
  86. package/src/realtime-defaults.ts +0 -3
  87. package/src/realtime-fast-context.test.ts +0 -88
  88. package/src/realtime-fast-context.ts +0 -165
  89. package/src/realtime-transcription.runtime.ts +0 -4
  90. package/src/realtime-voice.runtime.ts +0 -5
  91. package/src/response-generator.test.ts +0 -321
  92. package/src/response-generator.ts +0 -318
  93. package/src/response-model.test.ts +0 -71
  94. package/src/response-model.ts +0 -23
  95. package/src/runtime.test.ts +0 -536
  96. package/src/runtime.ts +0 -510
  97. package/src/telephony-audio.test.ts +0 -61
  98. package/src/telephony-audio.ts +0 -12
  99. package/src/telephony-tts.test.ts +0 -196
  100. package/src/telephony-tts.ts +0 -235
  101. package/src/test-fixtures.ts +0 -73
  102. package/src/tts-provider-voice.test.ts +0 -34
  103. package/src/tts-provider-voice.ts +0 -21
  104. package/src/tunnel.test.ts +0 -166
  105. package/src/tunnel.ts +0 -314
  106. package/src/types.ts +0 -291
  107. package/src/utils.test.ts +0 -17
  108. package/src/utils.ts +0 -14
  109. package/src/voice-mapping.test.ts +0 -34
  110. package/src/voice-mapping.ts +0 -68
  111. package/src/webhook/realtime-handler.test.ts +0 -598
  112. package/src/webhook/realtime-handler.ts +0 -485
  113. package/src/webhook/stale-call-reaper.test.ts +0 -88
  114. package/src/webhook/stale-call-reaper.ts +0 -38
  115. package/src/webhook/tailscale.test.ts +0 -214
  116. package/src/webhook/tailscale.ts +0 -129
  117. package/src/webhook-exposure.test.ts +0 -33
  118. package/src/webhook-exposure.ts +0 -84
  119. package/src/webhook-security.test.ts +0 -770
  120. package/src/webhook-security.ts +0 -994
  121. package/src/webhook.hangup-once.lifecycle.test.ts +0 -135
  122. package/src/webhook.test.ts +0 -1470
  123. package/src/webhook.ts +0 -908
  124. package/src/webhook.types.ts +0 -5
  125. package/src/websocket-test-support.ts +0 -72
  126. package/tsconfig.json +0 -16
@@ -1,598 +0,0 @@
1
- import http from "node:http";
2
- import type {
3
- RealtimeVoiceBridge,
4
- RealtimeVoiceProviderPlugin,
5
- RealtimeVoiceToolCallEvent,
6
- } from "openclaw/plugin-sdk/realtime-voice";
7
- import { describe, expect, it, vi } from "vitest";
8
- import { WebSocket } from "ws";
9
- import type { VoiceCallRealtimeConfig } from "../config.js";
10
- import type { CallManager } from "../manager.js";
11
- import type { VoiceCallProvider } from "../providers/base.js";
12
- import type { CallRecord } from "../types.js";
13
- import { connectWs, startUpgradeWsServer, waitForClose } from "../websocket-test-support.js";
14
- import { RealtimeCallHandler } from "./realtime-handler.js";
15
-
16
- function makeRequest(url: string, host = "gateway.ts.net"): http.IncomingMessage {
17
- const req = new http.IncomingMessage(null as never);
18
- req.url = url;
19
- req.method = "POST";
20
- req.headers = host ? { host } : {};
21
- return req;
22
- }
23
-
24
- function makeBridge(overrides: Partial<RealtimeVoiceBridge> = {}): RealtimeVoiceBridge {
25
- return {
26
- connect: async () => {},
27
- sendAudio: () => {},
28
- setMediaTimestamp: () => {},
29
- submitToolResult: vi.fn(),
30
- acknowledgeMark: () => {},
31
- close: () => {},
32
- isConnected: () => true,
33
- triggerGreeting: () => {},
34
- ...overrides,
35
- };
36
- }
37
-
38
- function makeRealtimeProvider(
39
- createBridge: RealtimeVoiceProviderPlugin["createBridge"],
40
- ): RealtimeVoiceProviderPlugin {
41
- return {
42
- id: "openai",
43
- label: "OpenAI",
44
- isConfigured: () => true,
45
- createBridge,
46
- };
47
- }
48
-
49
- function makeHandler(
50
- overrides?: Partial<VoiceCallRealtimeConfig>,
51
- deps?: {
52
- manager?: Partial<CallManager>;
53
- provider?: Partial<VoiceCallProvider>;
54
- realtimeProvider?: RealtimeVoiceProviderPlugin;
55
- },
56
- ) {
57
- const config: VoiceCallRealtimeConfig = {
58
- enabled: true,
59
- streamPath: overrides?.streamPath ?? "/voice/stream/realtime",
60
- instructions: overrides?.instructions ?? "Be helpful.",
61
- toolPolicy: overrides?.toolPolicy ?? "safe-read-only",
62
- tools: overrides?.tools ?? [],
63
- fastContext: overrides?.fastContext ?? {
64
- enabled: false,
65
- timeoutMs: 800,
66
- maxResults: 3,
67
- sources: ["memory", "sessions"],
68
- fallbackToConsult: false,
69
- },
70
- providers: overrides?.providers ?? {},
71
- ...(overrides?.provider ? { provider: overrides.provider } : {}),
72
- };
73
- return new RealtimeCallHandler(
74
- config,
75
- {
76
- processEvent: vi.fn(),
77
- getCallByProviderCallId: vi.fn(),
78
- ...deps?.manager,
79
- } as unknown as CallManager,
80
- {
81
- name: "twilio",
82
- verifyWebhook: vi.fn(),
83
- parseWebhookEvent: vi.fn(),
84
- initiateCall: vi.fn(),
85
- hangupCall: vi.fn(),
86
- playTts: vi.fn(),
87
- startListening: vi.fn(),
88
- stopListening: vi.fn(),
89
- getCallStatus: vi.fn(),
90
- ...deps?.provider,
91
- } as unknown as VoiceCallProvider,
92
- deps?.realtimeProvider ?? makeRealtimeProvider(() => makeBridge()),
93
- { apiKey: "test-key" },
94
- "/voice/webhook",
95
- );
96
- }
97
-
98
- const startRealtimeServer = async (
99
- handler: RealtimeCallHandler,
100
- ): Promise<{
101
- url: string;
102
- close: () => Promise<void>;
103
- }> => {
104
- const payload = handler.buildTwiMLPayload(makeRequest("/voice/webhook"));
105
- const match = payload.body.match(/wss:\/\/[^/]+(\/[^"]+)/);
106
- if (!match) {
107
- throw new Error("Failed to extract realtime stream path");
108
- }
109
-
110
- return await startUpgradeWsServer({
111
- urlPath: match[1],
112
- onUpgrade: (request, socket, head) => {
113
- handler.handleWebSocketUpgrade(request, socket, head);
114
- },
115
- });
116
- };
117
-
118
- describe("RealtimeCallHandler path routing", () => {
119
- it("uses the request host and stream path in TwiML", () => {
120
- const handler = makeHandler();
121
- const payload = handler.buildTwiMLPayload(makeRequest("/voice/webhook", "gateway.ts.net"));
122
-
123
- expect(payload.statusCode).toBe(200);
124
- expect(payload.body).toMatch(
125
- /wss:\/\/gateway\.ts\.net\/voice\/stream\/realtime\/[0-9a-f-]{36}/,
126
- );
127
- });
128
-
129
- it("preserves a public path prefix ahead of serve.path", () => {
130
- const handler = makeHandler({ streamPath: "/custom/stream/realtime" });
131
- handler.setPublicUrl("https://public.example/api/voice/webhook");
132
- const payload = handler.buildTwiMLPayload(makeRequest("/voice/webhook", "127.0.0.1:3334"));
133
-
134
- expect(handler.getStreamPathPattern()).toBe("/api/custom/stream/realtime");
135
- expect(payload.body).toMatch(
136
- /wss:\/\/public\.example\/api\/custom\/stream\/realtime\/[0-9a-f-]{36}/,
137
- );
138
- });
139
-
140
- it("normalizes Twilio outbound realtime directions", async () => {
141
- let callbacks:
142
- | {
143
- onReady?: () => void;
144
- }
145
- | undefined;
146
- const createBridge = vi.fn(
147
- (request: Parameters<RealtimeVoiceProviderPlugin["createBridge"]>[0]) => {
148
- callbacks = request;
149
- return makeBridge();
150
- },
151
- );
152
- const processEvent = vi.fn();
153
- const getCallByProviderCallId = vi.fn(
154
- (): CallRecord => ({
155
- callId: "call-1",
156
- providerCallId: "CA-outbound",
157
- provider: "twilio",
158
- direction: "outbound",
159
- state: "ringing",
160
- from: "+15550001234",
161
- to: "+15550009999",
162
- startedAt: Date.now(),
163
- transcript: [],
164
- processedEventIds: [],
165
- metadata: {},
166
- }),
167
- );
168
- const handler = makeHandler(undefined, {
169
- manager: {
170
- processEvent,
171
- getCallByProviderCallId,
172
- },
173
- realtimeProvider: makeRealtimeProvider(createBridge),
174
- });
175
- const payload = handler.buildTwiMLPayload(
176
- makeRequest("/voice/webhook"),
177
- new URLSearchParams({
178
- Direction: "outbound-dial",
179
- From: "+15550001234",
180
- To: "+15550009999",
181
- }),
182
- );
183
- const match = payload.body.match(/wss:\/\/[^/]+(\/[^"]+)/);
184
- if (!match) {
185
- throw new Error("Failed to extract realtime stream path");
186
- }
187
- const server = await startUpgradeWsServer({
188
- urlPath: match[1],
189
- onUpgrade: (request, socket, head) => {
190
- handler.handleWebSocketUpgrade(request, socket, head);
191
- },
192
- });
193
-
194
- try {
195
- const ws = await connectWs(server.url);
196
- try {
197
- ws.send(
198
- JSON.stringify({
199
- event: "start",
200
- start: { streamSid: "MZ-outbound", callSid: "CA-outbound" },
201
- }),
202
- );
203
- await vi.waitFor(() => {
204
- expect(createBridge).toHaveBeenCalled();
205
- });
206
- callbacks?.onReady?.();
207
- expect(processEvent).toHaveBeenCalledWith(
208
- expect.objectContaining({
209
- type: "call.initiated",
210
- direction: "outbound",
211
- from: "+15550001234",
212
- to: "+15550009999",
213
- }),
214
- );
215
- } finally {
216
- if (ws.readyState !== WebSocket.CLOSED && ws.readyState !== WebSocket.CLOSING) {
217
- ws.close();
218
- }
219
- }
220
- } finally {
221
- await server.close();
222
- }
223
- });
224
-
225
- it("does not emit an outbound realtime greeting without an initial message", async () => {
226
- let callbacks:
227
- | {
228
- onReady?: () => void;
229
- }
230
- | undefined;
231
- const triggerGreeting = vi.fn();
232
- const createBridge = vi.fn(
233
- (request: Parameters<RealtimeVoiceProviderPlugin["createBridge"]>[0]) => {
234
- callbacks = request;
235
- return makeBridge({ triggerGreeting });
236
- },
237
- );
238
- const getCallByProviderCallId = vi.fn(
239
- (): CallRecord => ({
240
- callId: "call-1",
241
- providerCallId: "CA-silent",
242
- provider: "twilio",
243
- direction: "outbound",
244
- state: "ringing",
245
- from: "+15550001234",
246
- to: "+15550009999",
247
- startedAt: Date.now(),
248
- transcript: [],
249
- processedEventIds: [],
250
- metadata: {},
251
- }),
252
- );
253
- const handler = makeHandler(undefined, {
254
- manager: {
255
- getCallByProviderCallId,
256
- },
257
- realtimeProvider: makeRealtimeProvider(createBridge),
258
- });
259
- const server = await startRealtimeServer(handler);
260
-
261
- try {
262
- const ws = await connectWs(server.url);
263
- try {
264
- ws.send(
265
- JSON.stringify({
266
- event: "start",
267
- start: { streamSid: "MZ-silent", callSid: "CA-silent" },
268
- }),
269
- );
270
- await vi.waitFor(() => {
271
- expect(createBridge).toHaveBeenCalled();
272
- });
273
-
274
- callbacks?.onReady?.();
275
-
276
- expect(triggerGreeting).not.toHaveBeenCalled();
277
- } finally {
278
- if (ws.readyState !== WebSocket.CLOSED && ws.readyState !== WebSocket.CLOSING) {
279
- ws.close();
280
- }
281
- }
282
- } finally {
283
- await server.close();
284
- }
285
- });
286
-
287
- it("speaks through the active outbound realtime bridge by call id", async () => {
288
- const triggerGreeting = vi.fn();
289
- const createBridge = vi.fn(() => makeBridge({ triggerGreeting }));
290
- const getCallByProviderCallId = vi.fn(
291
- (): CallRecord => ({
292
- callId: "call-1",
293
- providerCallId: "CA-speak",
294
- provider: "twilio",
295
- direction: "outbound",
296
- state: "ringing",
297
- from: "+15550001234",
298
- to: "+15550009999",
299
- startedAt: Date.now(),
300
- transcript: [],
301
- processedEventIds: [],
302
- metadata: {},
303
- }),
304
- );
305
- const handler = makeHandler(undefined, {
306
- manager: {
307
- getCallByProviderCallId,
308
- },
309
- realtimeProvider: makeRealtimeProvider(createBridge),
310
- });
311
- const server = await startRealtimeServer(handler);
312
-
313
- try {
314
- const ws = await connectWs(server.url);
315
- try {
316
- ws.send(
317
- JSON.stringify({
318
- event: "start",
319
- start: { streamSid: "MZ-speak", callSid: "CA-speak" },
320
- }),
321
- );
322
- await vi.waitFor(() => {
323
- expect(createBridge).toHaveBeenCalled();
324
- });
325
-
326
- expect(handler.speak("call-1", "Say exactly: hello from Meet.")).toEqual({
327
- success: true,
328
- });
329
- expect(triggerGreeting).toHaveBeenCalledWith("Say exactly: hello from Meet.");
330
- } finally {
331
- if (ws.readyState !== WebSocket.CLOSED && ws.readyState !== WebSocket.CLOSING) {
332
- ws.close();
333
- }
334
- }
335
- } finally {
336
- await server.close();
337
- }
338
- });
339
-
340
- it("submits continuing responses only for realtime agent consult calls", async () => {
341
- let callbacks:
342
- | {
343
- onToolCall?: (event: {
344
- itemId: string;
345
- callId: string;
346
- name: string;
347
- args: unknown;
348
- }) => void;
349
- onTranscript?: (role: "user" | "assistant", text: string, isFinal: boolean) => void;
350
- }
351
- | undefined;
352
- let resolveConsult: ((value: unknown) => void) | undefined;
353
- let receivedPartialTranscript: string | undefined;
354
- const submitToolResult = vi.fn();
355
- const bridge = makeBridge({
356
- supportsToolResultContinuation: true,
357
- submitToolResult,
358
- });
359
- const createBridge = vi.fn(
360
- (request: Parameters<RealtimeVoiceProviderPlugin["createBridge"]>[0]) => {
361
- callbacks = request;
362
- return bridge;
363
- },
364
- );
365
- const getCallByProviderCallId = vi.fn(
366
- (): CallRecord => ({
367
- callId: "call-1",
368
- providerCallId: "CA-tool",
369
- provider: "twilio",
370
- direction: "inbound",
371
- state: "ringing",
372
- from: "+15550001234",
373
- to: "+15550009999",
374
- startedAt: Date.now(),
375
- transcript: [],
376
- processedEventIds: [],
377
- metadata: {},
378
- }),
379
- );
380
- const handler = makeHandler(undefined, {
381
- manager: {
382
- getCallByProviderCallId,
383
- },
384
- realtimeProvider: makeRealtimeProvider(createBridge),
385
- });
386
- handler.registerToolHandler("openclaw_agent_consult", (_args, _callId, context) => {
387
- receivedPartialTranscript = context.partialUserTranscript;
388
- return new Promise((resolve) => {
389
- resolveConsult = resolve;
390
- });
391
- });
392
- handler.registerToolHandler("custom_lookup", async () => ({ ok: true }));
393
- const server = await startRealtimeServer(handler);
394
-
395
- try {
396
- const ws = await connectWs(server.url);
397
- try {
398
- ws.send(
399
- JSON.stringify({
400
- event: "start",
401
- start: { streamSid: "MZ-tool", callSid: "CA-tool" },
402
- }),
403
- );
404
- await vi.waitFor(() => {
405
- expect(createBridge).toHaveBeenCalled();
406
- });
407
-
408
- callbacks?.onTranscript?.("user", "Are the basement", false);
409
- callbacks?.onToolCall?.({
410
- itemId: "item-1",
411
- callId: "consult-call",
412
- name: "openclaw_agent_consult",
413
- args: { question: "Are the basement lights on?" },
414
- });
415
- expect(receivedPartialTranscript).toBe("Are the basement");
416
-
417
- await vi.waitFor(() => {
418
- expect(submitToolResult).toHaveBeenCalledWith(
419
- "consult-call",
420
- expect.objectContaining({
421
- status: "working",
422
- tool: "openclaw_agent_consult",
423
- }),
424
- { willContinue: true },
425
- );
426
- });
427
- expect(submitToolResult).toHaveBeenCalledTimes(1);
428
-
429
- resolveConsult?.({ text: "The basement lights are on." });
430
-
431
- await vi.waitFor(() => {
432
- expect(submitToolResult).toHaveBeenLastCalledWith(
433
- "consult-call",
434
- {
435
- text: "The basement lights are on.",
436
- },
437
- undefined,
438
- );
439
- });
440
-
441
- submitToolResult.mockClear();
442
- callbacks?.onToolCall?.({
443
- itemId: "item-2",
444
- callId: "custom-call",
445
- name: "custom_lookup",
446
- args: {},
447
- });
448
-
449
- await vi.waitFor(() => {
450
- expect(submitToolResult).toHaveBeenCalledWith("custom-call", { ok: true }, undefined);
451
- });
452
- expect(submitToolResult).not.toHaveBeenCalledWith("custom-call", expect.anything(), {
453
- willContinue: true,
454
- });
455
- } finally {
456
- if (ws.readyState !== WebSocket.CLOSED && ws.readyState !== WebSocket.CLOSING) {
457
- ws.close();
458
- }
459
- }
460
- } finally {
461
- await server.close();
462
- }
463
- });
464
-
465
- it("does not submit an interim checking result when fast context is enabled", async () => {
466
- let callbacks:
467
- | {
468
- onToolCall?: (event: RealtimeVoiceToolCallEvent) => void;
469
- }
470
- | undefined;
471
- const submitToolResult = vi.fn();
472
- const bridge = makeBridge({
473
- supportsToolResultContinuation: true,
474
- submitToolResult,
475
- });
476
- const createBridge = vi.fn(
477
- (request: Parameters<RealtimeVoiceProviderPlugin["createBridge"]>[0]) => {
478
- callbacks = request;
479
- return bridge;
480
- },
481
- );
482
- const handler = makeHandler(
483
- {
484
- fastContext: {
485
- enabled: true,
486
- timeoutMs: 800,
487
- maxResults: 3,
488
- sources: ["memory", "sessions"],
489
- fallbackToConsult: false,
490
- },
491
- },
492
- {
493
- manager: {
494
- getCallByProviderCallId: vi.fn(
495
- (): CallRecord => ({
496
- callId: "call-1",
497
- providerCallId: "CA-fast",
498
- provider: "twilio",
499
- direction: "inbound",
500
- state: "ringing",
501
- from: "+15550001234",
502
- to: "+15550009999",
503
- startedAt: Date.now(),
504
- transcript: [],
505
- processedEventIds: [],
506
- metadata: {},
507
- }),
508
- ),
509
- },
510
- realtimeProvider: makeRealtimeProvider(createBridge),
511
- },
512
- );
513
- handler.registerToolHandler("openclaw_agent_consult", async () => ({ text: "Fast context." }));
514
- const server = await startRealtimeServer(handler);
515
-
516
- try {
517
- const ws = await connectWs(server.url);
518
- try {
519
- ws.send(
520
- JSON.stringify({
521
- event: "start",
522
- start: { streamSid: "MZ-fast", callSid: "CA-fast" },
523
- }),
524
- );
525
- await vi.waitFor(() => {
526
- expect(createBridge).toHaveBeenCalled();
527
- });
528
-
529
- callbacks?.onToolCall?.({
530
- itemId: "item-1",
531
- callId: "consult-call",
532
- name: "openclaw_agent_consult",
533
- args: { question: "What do you remember?" },
534
- });
535
-
536
- await vi.waitFor(() => {
537
- expect(submitToolResult).toHaveBeenCalledWith(
538
- "consult-call",
539
- { text: "Fast context." },
540
- undefined,
541
- );
542
- });
543
- expect(submitToolResult).toHaveBeenCalledTimes(1);
544
- } finally {
545
- if (ws.readyState !== WebSocket.CLOSED && ws.readyState !== WebSocket.CLOSING) {
546
- ws.close();
547
- }
548
- }
549
- } finally {
550
- await server.close();
551
- }
552
- });
553
- });
554
-
555
- describe("RealtimeCallHandler websocket hardening", () => {
556
- it("rejects oversized pre-start frames before bridge setup", async () => {
557
- const createBridge = vi.fn(() => makeBridge());
558
- const processEvent = vi.fn();
559
- const getCallByProviderCallId = vi.fn();
560
- const handler = makeHandler(undefined, {
561
- manager: {
562
- processEvent,
563
- getCallByProviderCallId,
564
- },
565
- realtimeProvider: makeRealtimeProvider(createBridge),
566
- });
567
- const server = await startRealtimeServer(handler);
568
-
569
- try {
570
- const ws = await connectWs(server.url);
571
- try {
572
- ws.send(
573
- JSON.stringify({
574
- event: "start",
575
- start: {
576
- streamSid: "MZ-oversized",
577
- callSid: "CA-oversized",
578
- padding: "A".repeat(300 * 1024),
579
- },
580
- }),
581
- );
582
-
583
- const closed = await waitForClose(ws);
584
-
585
- expect(closed.code).toBe(1009);
586
- expect(createBridge).not.toHaveBeenCalled();
587
- expect(processEvent).not.toHaveBeenCalled();
588
- expect(getCallByProviderCallId).not.toHaveBeenCalled();
589
- } finally {
590
- if (ws.readyState !== WebSocket.CLOSED && ws.readyState !== WebSocket.CLOSING) {
591
- ws.close();
592
- }
593
- }
594
- } finally {
595
- await server.close();
596
- }
597
- });
598
- });