From 337a2b3fc7efb3770fe55fb35ec56fcca18569bc Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Fri, 31 Jul 2026 16:42:54 +0800 Subject: [PATCH] fix(openai): discard preconnect audio on close --- CHANGELOG.md | 1 + .../realtime-quicksilver-bridge.test.ts | 22 +++++++++++ .../openai/realtime-quicksilver-bridge.ts | 5 ++- .../openai/realtime-voice-provider.test.ts | 37 +++++++++++++++++++ extensions/openai/realtime-voice-provider.ts | 5 ++- 5 files changed, 68 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7fc8f587723b..3e4f39696aec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -57,6 +57,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- **OpenAI realtime preconnect close:** discard queued Talk audio when a bridge closes before its first connection, keep repeated closes idempotent, and require an explicit fresh connect before audio can flow again. - **Control UI session refreshes:** preserve explicitly queued list filters and background hydration across later Gateway event invalidation, while keeping append pagination followed by a canonical refresh. Fixes #116697. Thanks @shakkernerd. - **Control UI dynamic deep links:** reuse the initial route loader result when publishing real agent, session, dashboard, Workboard, Memory, and Plugins paths, avoiding redundant route-loader work during startup. Thanks @shakkernerd. - **Linux gateway service ownership:** refuse user-scope systemd publication and activation when the same gateway unit name is already owned or cannot be verified in the system scope, including `--force`, with actionable recovery guidance instead of creating restart-looping dual managers. Fixes #116129. 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-provider.test.ts b/extensions/openai/realtime-voice-provider.test.ts index 701d9d32c2a1..5980daf8b2cd 100644 --- a/extensions/openai/realtime-voice-provider.test.ts +++ b/extensions/openai/realtime-voice-provider.test.ts @@ -1549,6 +1549,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 68f13eb388c7..c1e18f520ed4 100644 --- a/extensions/openai/realtime-voice-provider.ts +++ b/extensions/openai/realtime-voice-provider.ts @@ -734,10 +734,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");