@openclaw/voice-call 2026.3.13 → 2026.5.2-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 +27 -5
  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 +943 -0
  6. package/index.ts +379 -149
  7. package/openclaw.plugin.json +384 -157
  8. package/package.json +35 -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 +273 -12
  17. package/src/config.ts +355 -72
  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 +243 -19
  24. package/src/manager/events.ts +61 -31
  25. package/src/manager/lifecycle.ts +53 -0
  26. package/src/manager/lookup.test.ts +52 -0
  27. package/src/manager/outbound.test.ts +528 -0
  28. package/src/manager/outbound.ts +163 -57
  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 +95 -8
  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 +178 -6
  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 +425 -25
  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 +321 -0
  64. package/src/response-generator.ts +213 -53
  65. package/src/response-model.test.ts +71 -0
  66. package/src/response-model.ts +23 -0
  67. package/src/runtime.test.ts +429 -0
  68. package/src/runtime.ts +270 -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 +28 -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 +172 -21
  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 +1145 -27
  94. package/src/webhook.ts +523 -102
  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 -121
  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
@@ -0,0 +1,598 @@
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
+ });