From 63ac6ccd201288061723ecbe70b00db545d569a9 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 18 Jan 2026 16:34:18 +0000 Subject: [PATCH] fix: resolve TwiML/status callbacks (#1180) (thanks @andrew-kurin) --- CHANGELOG.md | 2 + .../voice-call/src/providers/twilio.test.ts | 92 +++++++++++++++++++ extensions/voice-call/src/providers/twilio.ts | 40 +++++--- src/cli/exec-approvals-cli.ts | 6 +- src/infra/exec-approvals.ts | 2 +- src/infra/skills-remote.ts | 6 +- 6 files changed, 131 insertions(+), 17 deletions(-) create mode 100644 extensions/voice-call/src/providers/twilio.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index e68511a6ce4..f0c1c993105 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,6 +30,7 @@ Docs: https://docs.clawd.bot - macOS: avoid touching launchd in Remote over SSH so quitting the app no longer disables the remote gateway. (#1105) - Memory: index atomically so failed reindex preserves the previous memory database. (#1151) - Memory: avoid sqlite-vec unique constraint failures when reindexing duplicate chunk ids. (#1151) +- Voice call: separate TwiML fetches from status callbacks to avoid stuck calls. (#1180) — thanks @andrew-kurin. ## 2026.1.18-5 @@ -47,6 +48,7 @@ Docs: https://docs.clawd.bot - macOS: avoid touching launchd in Remote over SSH so quitting the app no longer disables the remote gateway. (#1105) - Memory: index atomically so failed reindex preserves the previous memory database. (#1151) - Memory: avoid sqlite-vec unique constraint failures when reindexing duplicate chunk ids. (#1151) +- Voice call: separate TwiML fetches from status callbacks to avoid stuck calls. (#1180) — thanks @andrew-kurin. ## 2026.1.18-5 diff --git a/extensions/voice-call/src/providers/twilio.test.ts b/extensions/voice-call/src/providers/twilio.test.ts new file mode 100644 index 00000000000..8374be4127f --- /dev/null +++ b/extensions/voice-call/src/providers/twilio.test.ts @@ -0,0 +1,92 @@ +import { describe, expect, it } from "vitest"; + +import { TwilioProvider } from "./twilio.js"; + +const mockTwilioConfig = { + accountSid: "AC00000000000000000000000000000000", + authToken: "test-token", +}; + +const callId = "internal-call-id"; +const twimlPayload = + "Hi"; + +describe("TwilioProvider", () => { + it("serves stored TwiML on twiml requests without emitting events", async () => { + const provider = new TwilioProvider(mockTwilioConfig); + (provider as unknown as { apiRequest: (endpoint: string, params: Record) => Promise }).apiRequest = + async () => ({ + sid: "CA00000000000000000000000000000000", + status: "queued", + direction: "outbound-api", + from: "+15550000000", + to: "+15550000001", + uri: "/Calls/CA00000000000000000000000000000000.json", + }); + + await provider.initiateCall({ + callId, + to: "+15550000000", + from: "+15550000001", + webhookUrl: "https://example.com/voice/webhook?provider=twilio", + inlineTwiml: twimlPayload, + }); + + const result = provider.parseWebhookEvent({ + headers: { host: "example.com" }, + rawBody: + "CallSid=CA00000000000000000000000000000000&CallStatus=initiated&Direction=outbound-api", + url: `https://example.com/voice/webhook?provider=twilio&callId=${callId}&type=twiml`, + method: "POST", + query: { provider: "twilio", callId, type: "twiml" }, + }); + + expect(result.events).toHaveLength(0); + expect(result.providerResponseBody).toBe(twimlPayload); + }); + + it("does not consume stored TwiML on status callbacks", async () => { + const provider = new TwilioProvider(mockTwilioConfig); + (provider as unknown as { apiRequest: (endpoint: string, params: Record) => Promise }).apiRequest = + async () => ({ + sid: "CA00000000000000000000000000000000", + status: "queued", + direction: "outbound-api", + from: "+15550000000", + to: "+15550000001", + uri: "/Calls/CA00000000000000000000000000000000.json", + }); + + await provider.initiateCall({ + callId, + to: "+15550000000", + from: "+15550000001", + webhookUrl: "https://example.com/voice/webhook?provider=twilio", + inlineTwiml: twimlPayload, + }); + + const statusResult = provider.parseWebhookEvent({ + headers: { host: "example.com" }, + rawBody: + "CallSid=CA00000000000000000000000000000000&CallStatus=initiated&Direction=outbound-api&From=%2B15550000000&To=%2B15550000001", + url: `https://example.com/voice/webhook?provider=twilio&callId=${callId}&type=status`, + method: "POST", + query: { provider: "twilio", callId, type: "status" }, + }); + + expect(statusResult.events).toHaveLength(1); + expect(statusResult.events[0]?.type).toBe("call.initiated"); + expect(statusResult.providerResponseBody).not.toBe(twimlPayload); + + const twimlResult = provider.parseWebhookEvent({ + headers: { host: "example.com" }, + rawBody: + "CallSid=CA00000000000000000000000000000000&CallStatus=initiated&Direction=outbound-api", + url: `https://example.com/voice/webhook?provider=twilio&callId=${callId}&type=twiml`, + method: "POST", + query: { provider: "twilio", callId, type: "twiml" }, + }); + + expect(twimlResult.providerResponseBody).toBe(twimlPayload); + }); +}); diff --git a/extensions/voice-call/src/providers/twilio.ts b/extensions/voice-call/src/providers/twilio.ts index 62a65eb4c1e..e5a3a88b55d 100644 --- a/extensions/voice-call/src/providers/twilio.ts +++ b/extensions/voice-call/src/providers/twilio.ts @@ -84,10 +84,13 @@ export class TwilioProvider implements VoiceCallProvider { const webhookUrl = this.callWebhookUrls.get(providerCallId); if (!webhookUrl) return; - const callIdMatch = webhookUrl.match(/callId=([^&]+)/); - if (!callIdMatch) return; - - this.deleteStoredTwiml(callIdMatch[1]); + try { + const callId = new URL(webhookUrl).searchParams.get("callId"); + if (!callId) return; + this.deleteStoredTwiml(callId); + } catch { + // Ignore malformed URLs; best-effort cleanup only. + } } constructor(config: TwilioConfig, options: TwilioProviderOptions = {}) { @@ -177,7 +180,14 @@ export class TwilioProvider implements VoiceCallProvider { typeof ctx.query?.callId === "string" && ctx.query.callId.trim() ? ctx.query.callId.trim() : undefined; - const event = this.normalizeEvent(params, callIdFromQuery); + const requestType = + typeof ctx.query?.type === "string" && ctx.query.type.trim() + ? ctx.query.type.trim() + : undefined; + const isTwimlRequest = requestType === "twiml"; + const event = isTwimlRequest + ? null + : this.normalizeEvent(params, callIdFromQuery); // For Twilio, we must return TwiML. Most actions are driven by Calls API updates, // so the webhook response is typically a pause to keep the call alive. @@ -292,12 +302,16 @@ export class TwilioProvider implements VoiceCallProvider { typeof ctx.query?.callId === "string" && ctx.query.callId.trim() ? ctx.query.callId.trim() : undefined; + const requestType = + typeof ctx.query?.type === "string" && ctx.query.type.trim() + ? ctx.query.type.trim() + : undefined; // Avoid logging webhook params/TwiML (may contain PII). // Handle initial TwiML request (when Twilio first initiates the call) // Check if we have stored TwiML for this call (notify mode) - if (callIdFromQuery) { + if (callIdFromQuery && requestType !== "status") { const storedTwiml = this.twimlStorage.get(callIdFromQuery); if (storedTwiml) { // Clean up after serving (one-time use) @@ -373,12 +387,14 @@ export class TwilioProvider implements VoiceCallProvider { * Otherwise, uses webhook URL for dynamic TwiML. */ async initiateCall(input: InitiateCallInput): Promise { - const url = new URL(input.webhookUrl); - url.searchParams.set("callId", input.callId); + const webhookUrl = new URL(input.webhookUrl); + webhookUrl.searchParams.set("callId", input.callId); + + const twimlUrl = new URL(webhookUrl); + twimlUrl.searchParams.set("type", "twiml"); // Create separate URL for status callbacks (required by Twilio) - const statusUrl = new URL(input.webhookUrl); - statusUrl.searchParams.set("callId", input.callId); + const statusUrl = new URL(webhookUrl); statusUrl.searchParams.set("type", "status"); // Differentiate from TwiML requests // Store TwiML content if provided (for notify mode) @@ -392,7 +408,7 @@ export class TwilioProvider implements VoiceCallProvider { const params: Record = { To: input.to, From: input.from, - Url: url.toString(), // TwiML serving endpoint + Url: twimlUrl.toString(), // TwiML serving endpoint StatusCallback: statusUrl.toString(), // Separate status callback endpoint StatusCallbackEvent: "initiated ringing answered completed", Timeout: "30", @@ -403,7 +419,7 @@ export class TwilioProvider implements VoiceCallProvider { params, ); - this.callWebhookUrls.set(result.sid, url.toString()); + this.callWebhookUrls.set(result.sid, webhookUrl.toString()); return { providerCallId: result.sid, diff --git a/src/cli/exec-approvals-cli.ts b/src/cli/exec-approvals-cli.ts index 64e932969e6..b25e80d6a3e 100644 --- a/src/cli/exec-approvals-cli.ts +++ b/src/cli/exec-approvals-cli.ts @@ -185,7 +185,7 @@ export function registerExecApprovalsCli(program: Command) { } allowlistEntries.push({ pattern: trimmed, lastUsedAt: Date.now() }); agent.allowlist = allowlistEntries; - file.agents = { ...(file.agents ?? {}), [agentKey]: agent }; + file.agents = { ...file.agents, [agentKey]: agent }; const next = await saveSnapshot(opts, nodeId, file, snapshot.hash); const payload = opts.json ? JSON.stringify(next) : JSON.stringify(next, null, 2); defaultRuntime.log(payload); @@ -229,11 +229,11 @@ export function registerExecApprovalsCli(program: Command) { agent.allowlist = nextEntries; } if (isEmptyAgent(agent)) { - const agents = { ...(file.agents ?? {}) }; + const agents = { ...file.agents }; delete agents[agentKey]; file.agents = Object.keys(agents).length > 0 ? agents : undefined; } else { - file.agents = { ...(file.agents ?? {}), [agentKey]: agent }; + file.agents = { ...file.agents, [agentKey]: agent }; } const next = await saveSnapshot(opts, nodeId, file, snapshot.hash); const payload = opts.json ? JSON.stringify(next) : JSON.stringify(next, null, 2); diff --git a/src/infra/exec-approvals.ts b/src/infra/exec-approvals.ts index 213ce5a7c4c..3a7d8989054 100644 --- a/src/infra/exec-approvals.ts +++ b/src/infra/exec-approvals.ts @@ -242,7 +242,7 @@ function parseFirstToken(command: string): string | null { if (end > 1) return trimmed.slice(1, end); return trimmed.slice(1); } - const match = /^[^\\s]+/.exec(trimmed); + const match = /^\S+/.exec(trimmed); return match ? match[0] : null; } diff --git a/src/infra/skills-remote.ts b/src/infra/skills-remote.ts index cf81b765769..0f0c923f7f0 100644 --- a/src/infra/skills-remote.ts +++ b/src/infra/skills-remote.ts @@ -36,7 +36,11 @@ function extractErrorMessage(err: unknown): string | undefined { if (typeof err === "object" && "message" in err && typeof err.message === "string") { return err.message; } - return String(err); + try { + return JSON.stringify(err); + } catch { + return undefined; + } } function logRemoteBinProbeFailure(nodeId: string, err: unknown) {