diff --git a/.commitlintrc.json b/.commitlintrc.json index 179755c..defce9b 100644 --- a/.commitlintrc.json +++ b/.commitlintrc.json @@ -3,6 +3,8 @@ "rules": { "scope-enum": [2, "always", ["js", "react", ""]], "body-max-line-length": [0], - "footer-max-line-length": [0] + "footer-max-line-length": [0], + "header-case": [0], + "body-case": [0] } } diff --git a/client-js/client/client.ts b/client-js/client/client.ts index 52f1141..7c086a2 100644 --- a/client-js/client/client.ts +++ b/client-js/client/client.ts @@ -70,7 +70,7 @@ export type RTVIEventCallbacks = Partial<{ onBotStarted: (botResponse: unknown) => void; onBotConnected: (participant: Participant) => void; onBotReady: (botReadyData: BotReadyData) => void; - onBotDisconnected: (participant: Participant) => void; + onBotDisconnected: (participant?: Participant) => void; onMetrics: (data: PipecatMetricsData) => void; // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -158,6 +158,13 @@ export interface PipecatClientOptions { * Default to false */ enableScreenShare?: boolean; + + /** + * Disconnect when the bot disconnects. + * + * Default to true + */ + disconnectOnBotDisconnect?: boolean; } abstract class RTVIEventEmitter extends (EventEmitter as unknown as new () => TypedEmitter) {} @@ -167,6 +174,7 @@ export class PipecatClient extends RTVIEventEmitter { private _connectResolve: ((value: BotReadyData) => void) | undefined; protected _transport: Transport; protected _transportWrapper: TransportWrapper; + protected _disconnectOnBotDisconnect: boolean; declare protected _messageDispatcher: MessageDispatcher; protected _functionCallCallbacks: Record = {}; protected _abortController: AbortController | undefined; @@ -182,6 +190,8 @@ export class PipecatClient extends RTVIEventEmitter { this._transport = options.transport; this._transportWrapper = new TransportWrapper(this._transport); + this._disconnectOnBotDisconnect = options.disconnectOnBotDisconnect ?? true; + // Wrap transport callbacks with event triggers // This allows for either functional callbacks or .on / .off event listeners const wrappedCallbacks: RTVIEventCallbacks = { @@ -209,7 +219,7 @@ export class PipecatClient extends RTVIEventEmitter { const data = message.data as ErrorData; if (data?.fatal) { logger.error("Fatal error reported. Disconnecting..."); - this.disconnect(); + void this.disconnect(); } }, onConnected: () => { @@ -295,6 +305,10 @@ export class PipecatClient extends RTVIEventEmitter { onBotDisconnected: (p) => { options?.callbacks?.onBotDisconnected?.(p); this.emit(RTVIEvent.BotDisconnected, p); + if (this._disconnectOnBotDisconnect) { + logger.info("Bot disconnected. Disconnecting client..."); + void this.disconnect(); + } }, onUserStartedSpeaking: () => { options?.callbacks?.onUserStartedSpeaking?.(); @@ -480,7 +494,7 @@ export class PipecatClient extends RTVIEventEmitter { ); await this._transport.sendReadyMessage(); } catch (e) { - this.disconnect(); + void this.disconnect(); reject(e); return; } diff --git a/client-js/rtvi/events.ts b/client-js/rtvi/events.ts index e228b1c..166ccfd 100644 --- a/client-js/rtvi/events.ts +++ b/client-js/rtvi/events.ts @@ -110,7 +110,7 @@ export type RTVIEvents = Partial<{ botStarted: (botResponse: unknown) => void; botConnected: (participant: Participant) => void; botReady: (botData: BotReadyData) => void; - botDisconnected: (participant: Participant) => void; + botDisconnected: (participant?: Participant) => void; error: (message: RTVIMessage) => void; /** server messaging */ diff --git a/client-js/tests/client.spec.ts b/client-js/tests/client.spec.ts index 251dcea..385bad1 100644 --- a/client-js/tests/client.spec.ts +++ b/client-js/tests/client.spec.ts @@ -109,7 +109,9 @@ describe("PipecatClient Methods", () => { test("llm-function-call-started should trigger callback and emit event", async () => { let callbackTriggered = false; let eventTriggered = false; + // eslint-disable-next-line @typescript-eslint/no-explicit-any let callbackData: any = null; + // eslint-disable-next-line @typescript-eslint/no-explicit-any let eventData: any = null; const clientWithCallbacks = new PipecatClient({ @@ -147,7 +149,9 @@ describe("PipecatClient Methods", () => { test("llm-function-call-in-progress should trigger callback and emit event", async () => { let callbackTriggered = false; let eventTriggered = false; + // eslint-disable-next-line @typescript-eslint/no-explicit-any let callbackData: any = null; + // eslint-disable-next-line @typescript-eslint/no-explicit-any let eventData: any = null; const clientWithCallbacks = new PipecatClient({ @@ -191,7 +195,9 @@ describe("PipecatClient Methods", () => { test("llm-function-call-stopped should trigger callback and emit event", async () => { let callbackTriggered = false; let eventTriggered = false; + // eslint-disable-next-line @typescript-eslint/no-explicit-any let callbackData: any = null; + // eslint-disable-next-line @typescript-eslint/no-explicit-any let eventData: any = null; const clientWithCallbacks = new PipecatClient({ @@ -238,7 +244,9 @@ describe("PipecatClient Methods", () => { test("deprecated llm-function-call should trigger callback and emit event", async () => { let callbackTriggered = false; let eventTriggered = false; + // eslint-disable-next-line @typescript-eslint/no-explicit-any let callbackData: any = null; + // eslint-disable-next-line @typescript-eslint/no-explicit-any let eventData: any = null; const clientWithCallbacks = new PipecatClient({ @@ -344,6 +352,81 @@ describe("PipecatClient Methods", () => { client.enableScreenShare(true); expect(client.isSharingScreen).toBe(true); }); + + test("should auto-disconnect when bot disconnects (default behavior)", async () => { + await client.connect(); + expect(client.connected).toBe(true); + + const disconnectedPromise = new Promise((resolve) => { + client.on(RTVIEvent.TransportStateChanged, (state) => { + if (state === "disconnected") resolve(); + }); + }); + + (client.transport as TransportStub).simulateBotDisconnect(); + + await disconnectedPromise; + + expect(client.connected).toBe(false); + expect(client.state).toBe("disconnected"); + }); + + test("should NOT auto-disconnect when bot disconnects if disconnectOnBotDisconnect is false", async () => { + const clientNoBotDisconnect = new PipecatClient({ + transport: TransportStub.create(), + disconnectOnBotDisconnect: false, + }); + + await clientNoBotDisconnect.connect(); + expect(clientNoBotDisconnect.connected).toBe(true); + + let disconnectCalled = false; + clientNoBotDisconnect.on(RTVIEvent.Disconnected, () => { + disconnectCalled = true; + }); + + (clientNoBotDisconnect.transport as TransportStub).simulateBotDisconnect(); + + // Yield to allow any async disconnect to fire if it were going to + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(disconnectCalled).toBe(false); + expect(clientNoBotDisconnect.connected).toBe(true); + expect(clientNoBotDisconnect.state).toBe("ready"); + }); + + test("should invoke onBotDisconnected callback regardless of disconnectOnBotDisconnect setting", async () => { + let callbackCalledDefault = false; + let callbackCalledOptOut = false; + + const clientDefault = new PipecatClient({ + transport: TransportStub.create(), + callbacks: { + onBotDisconnected: () => { + callbackCalledDefault = true; + }, + }, + }); + + const clientOptOut = new PipecatClient({ + transport: TransportStub.create(), + disconnectOnBotDisconnect: false, + callbacks: { + onBotDisconnected: () => { + callbackCalledOptOut = true; + }, + }, + }); + + await clientDefault.connect(); + await clientOptOut.connect(); + + (clientDefault.transport as TransportStub).simulateBotDisconnect(); + (clientOptOut.transport as TransportStub).simulateBotDisconnect(); + + expect(callbackCalledDefault).toBe(true); + expect(callbackCalledOptOut).toBe(true); + }); }); describe("messageSizeWithinLimit utility function", () => { diff --git a/client-js/tests/stubs/transport.ts b/client-js/tests/stubs/transport.ts index 16bafdd..5a8fa14 100644 --- a/client-js/tests/stubs/transport.ts +++ b/client-js/tests/stubs/transport.ts @@ -5,7 +5,7 @@ */ import { PipecatClientOptions, Tracks, Transport } from "../../client"; -import { RTVIMessage, RTVIMessageType, TransportState } from "../../rtvi"; +import { Participant, RTVIMessage, RTVIMessageType, TransportState } from "../../rtvi"; class mockState { public isSharingScreen = false; @@ -151,6 +151,11 @@ export class TransportStub extends Transport { this._onMessage(message); } + // to simulate the bot disconnecting + public simulateBotDisconnect(participant?: Participant): void { + this._callbacks.onBotDisconnected?.(participant); + } + public get state(): TransportState { return this._state; } diff --git a/package-lock.json b/package-lock.json index 46f00ff..06c52b8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -26,7 +26,7 @@ }, "client-js": { "name": "@pipecat-ai/client-js", - "version": "1.6.0", + "version": "1.6.1", "license": "BSD-2-Clause", "dependencies": { "@types/events": "^3.0.3", @@ -52,7 +52,7 @@ }, "client-react": { "name": "@pipecat-ai/client-react", - "version": "1.1.0", + "version": "1.2.0", "license": "BSD-2-Clause", "dependencies": { "jotai": "^2.9.0" @@ -2245,9 +2245,9 @@ } }, "node_modules/@eslint/config-array/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", "dev": true, "license": "ISC", "dependencies": { @@ -2339,9 +2339,9 @@ } }, "node_modules/@eslint/eslintrc/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", "dev": true, "license": "ISC", "dependencies": { @@ -6484,9 +6484,9 @@ } }, "node_modules/ajv": { - "version": "6.12.6", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", - "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", + "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", "dev": true, "license": "MIT", "dependencies": { @@ -7687,9 +7687,9 @@ } }, "node_modules/eslint/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", "dev": true, "license": "ISC", "dependencies": { @@ -8225,9 +8225,9 @@ } }, "node_modules/glob/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", "dev": true, "license": "ISC", "dependencies": { @@ -10544,13 +10544,13 @@ } }, "node_modules/minimatch": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", - "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", "dev": true, "license": "ISC", "dependencies": { - "brace-expansion": "^2.0.1" + "brace-expansion": "^2.0.2" }, "engines": { "node": ">=16 || 14 >=14.17" @@ -11879,9 +11879,9 @@ } }, "node_modules/test-exclude/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", "dev": true, "license": "ISC", "dependencies": {