From b95ed67607310b64c449e086ce5bd9467475b469 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 14 Aug 2026 18:33:03 +0000 Subject: [PATCH] fix: handle rejected updateInputSettings so Krisp failures emit 'error' Enabling noise cancellation calls `this.call.updateInputSettings(...)` inside a synchronous try/catch, without awaiting it and without a rejection handler. A synchronous catch cannot observe a promise rejection, so when Daily's remotely loaded call machine rejects - `KrispInitError: Canceled`, seen since Daily SDK 0.91.0 landed on 2026-08-07 - the catch block never runs. Its emission of the SDK's own 'error' event never happens either, and the rejection escapes to the page as an unhandled rejection instead of reaching the listeners consumers register. dashboard.vapi.ai has taken 101 of these across 93 users, all handled=false, still firing about 14 a day. Attach a rejection handler at all four sites - the noise-cancellation setup and the 'audio-processor-error' recovery chain, each duplicated in start() and in reconnect() - routing the failure through one new private helper, emitAudioProcessingError(), which emits the same 'error' payload the dead catch block intended. Audio processing stays non-critical: the call is unaffected, the failure is now observable. Covered by four new cases in __tests__/vapi.test.ts that drive the real start() and reconnect() paths with an updateInputSettings() that rejects. They fail against unmodified vapi.ts (no rejection handler is attached, no 'error' event is emitted) and pass with this change. Fixes VAP-15826 --- __tests__/vapi.test.ts | 232 +++++++++++++++++++++++++++++++++++++++++ vapi.ts | 64 +++++++++--- 2 files changed, 282 insertions(+), 14 deletions(-) diff --git a/__tests__/vapi.test.ts b/__tests__/vapi.test.ts index 63822586a..5385e2360 100644 --- a/__tests__/vapi.test.ts +++ b/__tests__/vapi.test.ts @@ -1,5 +1,100 @@ import Vapi from "../vapi"; // Adjust the import path based on your project structure +// The DailyCall object the mocked daily-js factory hands back. It is read when +// createCallObject() is called, so each test installs its own fake first. +let mockDailyCall: any = null; + +jest.mock("@daily-co/daily-js", () => { + return { + __esModule: true, + default: { + createCallObject: () => { + return mockDailyCall; + }, + }, + }; +}); + +jest.mock("../client", () => { + return { + client: { + baseUrl: "", + setSecurityData: () => {}, + call: { + callControllerCreateWebCall: () => { + return Promise.resolve({ + data: { + id: "call_test", + webCallUrl: "https://example.daily.co/test", + }, + }); + }, + }, + }, + }; +}); + +// A DailyCall stand-in with only the members start()/reconnect() touch. The +// registered event handlers are kept so tests can fire Daily's events. +function createMockDailyCall(updateInputSettings: jest.Mock) { + const handlers: Record void> = {}; + return { + handlers, + updateInputSettings, + on: jest.fn((event: string, handler: (event: any) => void) => { + handlers[event] = handler; + }), + join: jest.fn().mockResolvedValue(undefined), + destroy: jest.fn().mockResolvedValue(undefined), + iframe: jest.fn().mockReturnValue(null), + setLocalAudio: jest.fn(), + localAudio: jest.fn(), + startRemoteParticipantsAudioLevelObserver: jest.fn(), + startLocalAudioLevelObserver: jest.fn(), + }; +} + +type RejectionTracker = { handled: boolean }; + +// Jest runs test code in a vm sandbox that never delivers process-level +// "unhandledRejection" events, so an escaping rejection is asserted at its +// source instead: this wrapper records whether the SDK attaches a rejection +// handler anywhere on the chain it builds from the promise updateInputSettings() +// hands back. Without one, the rejection reaches the page unhandled. +function trackRejectionHandling( + promise: Promise, + tracker: RejectionTracker +): any { + return { + then(onFulfilled?: any, onRejected?: any) { + if (onRejected) { + tracker.handled = true; + } + return trackRejectionHandling( + promise.then(onFulfilled, onRejected), + tracker + ); + }, + catch(onRejected?: any) { + tracker.handled = true; + return trackRejectionHandling(promise.catch(onRejected), tracker); + }, + finally(onFinally?: any) { + return trackRejectionHandling(promise.finally(onFinally), tracker); + }, + }; +} + +// Let the rejection handlers the SDK attached run before asserting on them. +async function flushRejections() { + await new Promise((resolve) => { + setImmediate(resolve); + }); + await new Promise((resolve) => { + setImmediate(resolve); + }); +} + describe("Vapi", () => { let vapi: Vapi; let mockCall: any; @@ -54,3 +149,140 @@ describe("Vapi", () => { }); }); }); + +// Daily applies input settings through its remotely loaded call machine, so a +// failure (e.g. Krisp's "KrispInitError: Canceled") arrives as a rejected +// promise. It must not escape to the page as an unhandled rejection, and it must +// stay observable on the SDK's own "error" event. +describe("Vapi audio processing failures", () => { + const webCall = { + id: "call_test", + webCallUrl: "https://example.daily.co/test", + }; + let tracker: RejectionTracker; + let errors: any[]; + + beforeEach(() => { + tracker = { handled: false }; + errors = []; + }); + + afterEach(() => { + mockDailyCall = null; + }); + + it("reports a rejected noise cancellation setup during start()", async () => { + const updateInputSettings = jest.fn(() => { + return trackRejectionHandling( + Promise.reject(new Error("Canceled")), + tracker + ); + }); + mockDailyCall = createMockDailyCall(updateInputSettings as jest.Mock); + const vapi = new Vapi("dummy_token"); + vapi.on("error", (error) => { + errors.push(error); + }); + + const call = await vapi.start("dummy_assistant_id"); + await flushRejections(); + + expect(updateInputSettings).toHaveBeenCalledWith({ + audio: { processor: { type: "noise-cancellation" } }, + }); + // Non-fatal: the call still starts. + expect(call).not.toBeNull(); + expect(tracker.handled).toBe(true); + const emitted = errors.find((error) => { + return error?.type === "audio-processing-setup-error"; + }); + expect(emitted?.error?.message).toBe("Canceled"); + }); + + it("reports a rejected processor reset during start()", async () => { + const updateInputSettings = jest.fn((settings: any) => { + if (settings?.audio?.processor?.type === "none") { + return trackRejectionHandling( + Promise.reject(new Error("Canceled")), + tracker + ); + } + return Promise.resolve(); + }); + mockDailyCall = createMockDailyCall(updateInputSettings as jest.Mock); + const vapi = new Vapi("dummy_token"); + vapi.on("error", (error) => { + errors.push(error); + }); + + await vapi.start("dummy_assistant_id"); + mockDailyCall.handlers["nonfatal-error"]({ type: "audio-processor-error" }); + await flushRejections(); + + expect(updateInputSettings).toHaveBeenCalledWith({ + audio: { processor: { type: "none" } }, + }); + expect(tracker.handled).toBe(true); + const emitted = errors.find((error) => { + return error?.type === "audio-processor-recovery-error"; + }); + expect(emitted?.error?.message).toBe("Canceled"); + }); + + it("reports a rejected noise cancellation setup during reconnect()", async () => { + const updateInputSettings = jest.fn(() => { + return trackRejectionHandling( + Promise.reject(new Error("Canceled")), + tracker + ); + }); + mockDailyCall = createMockDailyCall(updateInputSettings as jest.Mock); + const vapi = new Vapi("dummy_token"); + vapi.on("error", (error) => { + errors.push(error); + }); + + // Non-fatal: reconnect() resolves rather than rethrowing. + await expect(vapi.reconnect(webCall)).resolves.toBeUndefined(); + await flushRejections(); + + expect(updateInputSettings).toHaveBeenCalledWith({ + audio: { processor: { type: "noise-cancellation" } }, + }); + expect(tracker.handled).toBe(true); + const emitted = errors.find((error) => { + return error?.type === "audio-processing-setup-error"; + }); + expect(emitted?.error?.message).toBe("Canceled"); + }); + + it("reports a rejected processor reset during reconnect()", async () => { + const updateInputSettings = jest.fn((settings: any) => { + if (settings?.audio?.processor?.type === "none") { + return trackRejectionHandling( + Promise.reject(new Error("Canceled")), + tracker + ); + } + return Promise.resolve(); + }); + mockDailyCall = createMockDailyCall(updateInputSettings as jest.Mock); + const vapi = new Vapi("dummy_token"); + vapi.on("error", (error) => { + errors.push(error); + }); + + await vapi.reconnect(webCall); + mockDailyCall.handlers["nonfatal-error"]({ type: "audio-processor-error" }); + await flushRejections(); + + expect(updateInputSettings).toHaveBeenCalledWith({ + audio: { processor: { type: "none" } }, + }); + expect(tracker.handled).toBe(true); + const emitted = errors.find((error) => { + return error?.type === "audio-processor-recovery-error"; + }); + expect(emitted?.error?.message).toBe("Canceled"); + }); +}); diff --git a/vapi.ts b/vapi.ts index f34de5b5b..aba01b8ab 100644 --- a/vapi.ts +++ b/vapi.ts @@ -368,6 +368,28 @@ export default class Vapi extends VapiEventEmitter { return new Promise((resolve) => setTimeout(resolve, ms)); } + /** + * Reports a failed audio processing update on the SDK's `error` event. + * + * Daily applies input settings inside its remotely loaded call machine, so a + * failure - Krisp's `KrispInitError: Canceled`, for example - arrives as a + * rejected promise, which a synchronous try/catch around + * `updateInputSettings()` cannot see. Without this the rejection escapes to + * the page as an unhandled rejection instead of reaching the listeners + * consumers register. Audio processing is non-critical, so the call continues. + */ + private emitAudioProcessingError( + stage: 'audio-processing-setup' | 'audio-processor-recovery', + error: unknown, + ) { + this.emit('error', { + type: `${stage}-error`, + stage, + error: serializeError(error), + timestamp: new Date().toISOString(), + }); + } + async start( assistant?: CreateAssistantDTO | string, assistantOverrides?: AssistantOverrides, @@ -828,6 +850,9 @@ export default class Vapi extends VapiEventEmitter { }) .then(() => { safeSetLocalAudio(this.call, true); + }) + .catch((error) => { + this.emitAudioProcessingError('audio-processor-recovery', error); }); } }); @@ -842,14 +867,18 @@ export default class Vapi extends VapiEventEmitter { const audioProcessingStartTime = Date.now(); try { - this.call.updateInputSettings({ - audio: { - processor: { - type: 'noise-cancellation', + this.call + .updateInputSettings({ + audio: { + processor: { + type: 'noise-cancellation', + }, }, - }, - }); - + }) + .catch((error) => { + this.emitAudioProcessingError('audio-processing-setup', error); + }); + const audioProcessingDuration = Date.now() - audioProcessingStartTime; this.emit('call-start-progress', { stage: 'audio-processing-setup', @@ -1352,6 +1381,9 @@ export default class Vapi extends VapiEventEmitter { }) .then(() => { safeSetLocalAudio(this.call, true); + }) + .catch((error) => { + this.emitAudioProcessingError('audio-processor-recovery', error); }); } }); @@ -1513,14 +1545,18 @@ export default class Vapi extends VapiEventEmitter { const audioProcessingStartTime = Date.now(); try { - this.call.updateInputSettings({ - audio: { - processor: { - type: 'noise-cancellation', + this.call + .updateInputSettings({ + audio: { + processor: { + type: 'noise-cancellation', + }, }, - }, - }); - + }) + .catch((error) => { + this.emitAudioProcessingError('audio-processing-setup', error); + }); + const audioProcessingDuration = Date.now() - audioProcessingStartTime; this.emit('call-start-progress', { stage: 'audio-processing-setup',