diff --git a/extensions/openai/realtime-quicksilver-bridge.test.ts b/extensions/openai/realtime-quicksilver-bridge.test.ts index 91ceca298e94..6bcb03df22a6 100644 --- a/extensions/openai/realtime-quicksilver-bridge.test.ts +++ b/extensions/openai/realtime-quicksilver-bridge.test.ts @@ -209,6 +209,28 @@ describe("OpenAIQuicksilverVoiceBridge", () => { harness.bridge.close(); }); + it("discards audio closed before the first connection and reconnects fresh", async () => { + const harness = createHarness(); + + harness.bridge.sendAudio(Buffer.from("queued-before-connect")); + harness.bridge.close(); + harness.bridge.close(); + harness.bridge.sendAudio(Buffer.from("sent-after-close")); + + expect(harness.connections).toHaveLength(0); + expect(harness.onClose).not.toHaveBeenCalled(); + + await harness.bridge.connect(); + + expect( + sentEvents(harness.socket).filter((event) => event.type === "input_audio.append"), + ).toHaveLength(0); + + harness.bridge.close(); + expect(harness.onClose).toHaveBeenCalledOnce(); + expect(harness.onClose).toHaveBeenCalledWith("completed"); + }); + it("does not carry queued audio across terminal close and explicit reconnect", async () => { const sockets: FakeSocket[] = []; const bridge = new OpenAIQuicksilverVoiceBridge({ diff --git a/extensions/openai/realtime-quicksilver-bridge.ts b/extensions/openai/realtime-quicksilver-bridge.ts index e0dd6583d4c0..de8583cfcca1 100644 --- a/extensions/openai/realtime-quicksilver-bridge.ts +++ b/extensions/openai/realtime-quicksilver-bridge.ts @@ -361,10 +361,13 @@ export class OpenAIQuicksilverVoiceBridge implements RealtimeVoiceBridge { close(): void { const connection = this.connection; - if (!connection || !this.lifecycle.cancel()) { + if (!this.lifecycle.cancel()) { return; } this.resetTerminalState(); + if (!connection) { + return; + } if (this.socket?.readyState === WEBSOCKET_OPEN) { this.sendEvent({ type: "session.close" }); } diff --git a/extensions/openai/realtime-voice-lifecycle.test.ts b/extensions/openai/realtime-voice-lifecycle.test.ts index 85b044e94f2a..181077ee0ec9 100644 --- a/extensions/openai/realtime-voice-lifecycle.test.ts +++ b/extensions/openai/realtime-voice-lifecycle.test.ts @@ -2,6 +2,20 @@ import { describe, expect, it } from "vitest"; import { OpenAIRealtimeVoiceLifecycle } from "./realtime-voice-lifecycle.js"; describe("OpenAIRealtimeVoiceLifecycle", () => { + it("terminalizes preconnect cancellation until an explicit fresh connection", () => { + const lifecycle = new OpenAIRealtimeVoiceLifecycle(); + + expect(lifecycle.phase()).toBe("idle"); + expect(lifecycle.cancel()).toBe(true); + expect(lifecycle.phase()).toBe("terminal"); + expect(lifecycle.cancel()).toBe(false); + + const connection = lifecycle.connect(); + expect(lifecycle.phase()).toBe("connecting"); + expect(lifecycle.ready(connection)).toBe(true); + expect(lifecycle.phase()).toBe("ready"); + }); + it("moves a connection from connecting to ready", () => { const lifecycle = new OpenAIRealtimeVoiceLifecycle(); const connection = lifecycle.connect(); diff --git a/extensions/openai/realtime-voice-lifecycle.ts b/extensions/openai/realtime-voice-lifecycle.ts index a436a5ac92af..1bfe25ef5556 100644 --- a/extensions/openai/realtime-voice-lifecycle.ts +++ b/extensions/openai/realtime-voice-lifecycle.ts @@ -1,4 +1,9 @@ -type OpenAIRealtimeVoiceLifecyclePhase = "connecting" | "ready" | "retry-wait" | "terminal"; +type OpenAIRealtimeVoiceLifecyclePhase = + | "idle" + | "connecting" + | "ready" + | "retry-wait" + | "terminal"; type OpenAIRealtimeVoiceTerminalOutcome = "completed" | "error"; @@ -7,20 +12,29 @@ export type OpenAIRealtimeVoiceConnection = Readonly<{ signal: AbortSignal; }>; -type OpenAIRealtimeVoiceLifecycleState = { +type OpenAIRealtimeVoiceIdleState = { + phase: "idle" | "terminal"; + terminalOutcome?: "completed"; +}; + +type OpenAIRealtimeVoiceConnectionState = { connection: OpenAIRealtimeVoiceConnection; controller: AbortController; - phase: OpenAIRealtimeVoiceLifecyclePhase; + phase: Exclude; retryAttempts: number; terminalOutcome?: OpenAIRealtimeVoiceTerminalOutcome; terminalNotified: boolean; }; export class OpenAIRealtimeVoiceLifecycle { - private state: OpenAIRealtimeVoiceLifecycleState | undefined; + private state: OpenAIRealtimeVoiceIdleState | OpenAIRealtimeVoiceConnectionState = { + phase: "idle", + }; connect(): OpenAIRealtimeVoiceConnection { - this.state?.controller.abort(new Error("OpenAI realtime voice connection replaced")); + if ("controller" in this.state) { + this.state.controller.abort(new Error("OpenAI realtime voice connection replaced")); + } const controller = new AbortController(); const connection = this.createConnection(controller); this.state = { @@ -72,9 +86,16 @@ export class OpenAIRealtimeVoiceLifecycle { cancel(): boolean { const state = this.state; - if (!state || state.terminalOutcome) { + if (state.phase === "terminal") { return false; } + if (!("controller" in state)) { + this.state = { + phase: "terminal", + terminalOutcome: "completed", + }; + return true; + } state.phase = "terminal"; state.terminalOutcome = "completed"; state.controller.abort(new Error("OpenAI realtime voice session canceled")); @@ -125,8 +146,8 @@ export class OpenAIRealtimeVoiceLifecycle { return this.state?.phase === "ready"; } - phase(): OpenAIRealtimeVoiceLifecyclePhase | undefined { - return this.state?.phase; + phase(): OpenAIRealtimeVoiceLifecyclePhase { + return this.state.phase; } terminalOutcome( @@ -141,7 +162,9 @@ export class OpenAIRealtimeVoiceLifecycle { private currentState( connection: OpenAIRealtimeVoiceConnection, - ): OpenAIRealtimeVoiceLifecycleState | undefined { - return this.state?.connection.id === connection.id ? this.state : undefined; + ): OpenAIRealtimeVoiceConnectionState | undefined { + return "connection" in this.state && this.state.connection.id === connection.id + ? this.state + : undefined; } } diff --git a/extensions/openai/realtime-voice-provider.test.ts b/extensions/openai/realtime-voice-provider.test.ts index 7b8eca6101b7..e57091cab983 100644 --- a/extensions/openai/realtime-voice-provider.test.ts +++ b/extensions/openai/realtime-voice-provider.test.ts @@ -1551,6 +1551,43 @@ describe("buildOpenAIRealtimeVoiceProvider", () => { bridge.close(); }); + it("discards audio closed before the first connection and reconnects fresh", async () => { + const provider = buildOpenAIRealtimeVoiceProvider(); + const onClose = vi.fn(); + const bridge = provider.createBridge({ + providerConfig: { apiKey: "sk-test" }, // pragma: allowlist secret + onAudio: vi.fn(), + onClearAudio: vi.fn(), + onClose, + }); + + bridge.sendAudio(Buffer.from("queued-before-connect")); + bridge.close(); + bridge.close(); + bridge.sendAudio(Buffer.from("sent-after-close")); + + expect(FakeWebSocket.instances).toHaveLength(0); + expect(onClose).not.toHaveBeenCalled(); + + const connecting = bridge.connect(); + const socket = FakeWebSocket.instances[0]; + if (!socket) { + throw new Error("expected bridge to connect"); + } + socket.readyState = FakeWebSocket.OPEN; + socket.emit("open"); + socket.emit("message", Buffer.from(JSON.stringify({ type: "session.updated" }))); + await connecting; + + expect( + parseSent(socket).filter((event) => event.type === "input_audio_buffer.append"), + ).toHaveLength(0); + + bridge.close(); + expect(onClose).toHaveBeenCalledOnce(); + expect(onClose).toHaveBeenCalledWith("completed"); + }); + it("does not carry queued audio across terminal close and explicit reconnect", async () => { const provider = buildOpenAIRealtimeVoiceProvider(); const bridge = provider.createBridge({ diff --git a/extensions/openai/realtime-voice-provider.ts b/extensions/openai/realtime-voice-provider.ts index 5c1f45305394..678ea0aac0c9 100644 --- a/extensions/openai/realtime-voice-provider.ts +++ b/extensions/openai/realtime-voice-provider.ts @@ -758,10 +758,13 @@ class OpenAIRealtimeVoiceBridge implements RealtimeVoiceBridge { close(): void { const connection = this.connection; - if (!connection || !this.lifecycle.cancel()) { + if (!this.lifecycle.cancel()) { return; } this.resetTerminalState(); + if (!connection) { + return; + } const ws = this.ws; this.ws = null; ws?.close(1000, "Bridge closed");