diff --git a/extensions/openai/realtime-voice-provider.live.test.ts b/extensions/openai/realtime-voice-provider.live.test.ts new file mode 100644 index 000000000000..539de439389e --- /dev/null +++ b/extensions/openai/realtime-voice-provider.live.test.ts @@ -0,0 +1,50 @@ +// OpenAI tests cover the native realtime voice bridge against the live API. +import { describe, expect, it } from "vitest"; +import { buildOpenAIRealtimeVoiceProvider } from "./realtime-voice-provider.js"; + +const OPENAI_API_KEY = process.env.OPENAI_API_KEY?.trim() ?? ""; +const LIVE_ENABLED = OPENAI_API_KEY.length > 0 && process.env.OPENCLAW_LIVE_TEST === "1"; +const describeLive = LIVE_ENABLED ? describe : describe.skip; + +describeLive("OpenAI realtime voice lifecycle live", () => { + it("reuses a bridge after a terminal close", async () => { + let closeCount = 0; + let readyCount = 0; + const errors: Error[] = []; + const bridge = buildOpenAIRealtimeVoiceProvider().createBridge({ + providerConfig: { + apiKey: OPENAI_API_KEY, + model: "gpt-realtime-2.1", + voice: "marin", + }, + instructions: "Keep this lifecycle verification session silent.", + autoRespondToAudio: false, + onAudio: () => {}, + onClearAudio: () => {}, + onClose: () => { + closeCount += 1; + }, + onError: (error) => { + errors.push(error); + }, + onReady: () => { + readyCount += 1; + }, + }); + + try { + await bridge.connect(); + expect(bridge.isConnected()).toBe(true); + bridge.close(); + + await bridge.connect(); + expect(bridge.isConnected()).toBe(true); + } finally { + bridge.close(); + } + + expect(errors).toEqual([]); + expect(readyCount).toBe(2); + expect(closeCount).toBe(2); + }, 60_000); +}); diff --git a/extensions/openai/realtime-voice-provider.test.ts b/extensions/openai/realtime-voice-provider.test.ts index 8b258ef19d28..8f087a454cf0 100644 --- a/extensions/openai/realtime-voice-provider.test.ts +++ b/extensions/openai/realtime-voice-provider.test.ts @@ -1673,6 +1673,50 @@ describe("buildOpenAIRealtimeVoiceProvider", () => { bridge.close(); }); + it("fails terminally when the readiness callback throws", async () => { + vi.useFakeTimers(); + const readyError = new Error("readiness callback failed"); + const onClose = vi.fn(); + const onError = vi.fn(); + const onReady = vi.fn(() => { + throw readyError; + }); + const bridge = createNativeBridge({ onClose, onError, onReady }); + const { connecting, socket } = beginBridgeConnection(bridge); + let connectError: unknown; + const observedConnect = connecting.catch((error: unknown) => { + connectError = error; + }); + + openSocket(socket); + bridge.sendAudio(Buffer.from("queued-before-ready")); + emitSessionUpdated(socket); + await vi.advanceTimersByTimeAsync(0); + const immediateConnectError = connectError; + + bridge.close(); + await observedConnect; + + expect(immediateConnectError).toBe(readyError); + expect(onReady).toHaveBeenCalledOnce(); + expect(onError).toHaveBeenCalledOnce(); + expect(onError).toHaveBeenCalledWith(readyError); + expect(onClose).toHaveBeenCalledOnce(); + expect(onClose).toHaveBeenCalledWith("error"); + expect(socket.closed).toBe(true); + expect(bridge.isConnected()).toBe(false); + expect( + parseSent(socket).filter((event) => event.type === "input_audio_buffer.append"), + ).toHaveLength(0); + + emitSessionUpdated(socket); + await expect(bridge.connect()).rejects.toBe(readyError); + expect(FakeWebSocket.instances).toHaveLength(1); + expect(onReady).toHaveBeenCalledOnce(); + expect(onError).toHaveBeenCalledOnce(); + expect(onClose).toHaveBeenCalledOnce(); + }); + it("suppresses auto responses before draining queued initial greeting audio", async () => { const bridgeRef: { current?: RealtimeVoiceBridge } = {}; const onReady = vi.fn(() => { @@ -1763,12 +1807,14 @@ describe("buildOpenAIRealtimeVoiceProvider", () => { vi.useFakeTimers(); const onError = vi.fn(); const onEvent = vi.fn(); - const bridge = createNativeBridge({ onError, onEvent }); + const onReady = vi.fn(); + const bridge = createNativeBridge({ onError, onEvent, onReady }); const { connecting, socket: firstSocket } = beginBridgeConnection(bridge); openSocket(firstSocket); emitSessionUpdated(firstSocket); await connecting; + expect(onReady).toHaveBeenCalledOnce(); firstSocket.emit( "message", @@ -1814,10 +1860,72 @@ describe("buildOpenAIRealtimeVoiceProvider", () => { }), ); expect(bridge.isConnected()).toBe(true); + expect(onReady).toHaveBeenCalledOnce(); bridge.close(); }); + it("clears canceled rotation metadata before an explicit reconnect", async () => { + vi.useFakeTimers(); + const onClose = vi.fn(); + const onError = vi.fn(); + const onEvent = vi.fn(); + const onReady = vi.fn(); + const bridge = createNativeBridge({ onClose, onError, onEvent, onReady }); + const { connecting, socket: firstSocket } = beginBridgeConnection(bridge); + + firstSocket.deferClose = true; + openSocket(firstSocket); + emitSessionUpdated(firstSocket); + await connecting; + expect(onReady).toHaveBeenCalledOnce(); + + emitServerEvent(firstSocket, { + type: "error", + error: { message: "Your session hit the maximum duration of 60 minutes." }, + }); + expect(firstSocket.closed).toBe(true); + + bridge.close(); + bridge.close(); + expect(onClose).toHaveBeenCalledOnce(); + expect(onClose).toHaveBeenCalledWith("completed"); + + const { connecting: reconnecting, socket: secondSocket } = beginBridgeConnection(bridge, 1); + firstSocket.emitDeferredClose(); + openSocket(secondSocket); + emitSessionUpdated(secondSocket); + await reconnecting; + + expect(onReady).toHaveBeenCalledTimes(2); + expect(onEvent).not.toHaveBeenCalledWith( + expect.objectContaining({ type: "session.rotation.ready" }), + ); + expect(onError).not.toHaveBeenCalled(); + + secondSocket.readyState = FakeWebSocket.CLOSED; + secondSocket.emit("close", 1006, Buffer.from("ordinary drop")); + await vi.advanceTimersByTimeAsync(0); + + expect(onEvent).toHaveBeenCalledWith({ + direction: "client", + type: "session.reconnect.scheduled", + detail: "reason=websocket-close attempt=1 delayMs=1000", + }); + expect(onEvent).not.toHaveBeenCalledWith( + expect.objectContaining({ + type: "session.reconnect.scheduled", + detail: expect.stringContaining("reason=max-duration"), + }), + ); + + bridge.close(); + await vi.advanceTimersByTimeAsync(0); + expect(vi.getTimerCount()).toBe(0); + expect(onClose).toHaveBeenCalledTimes(2); + expect(onClose).toHaveBeenLastCalledWith("completed"); + }); + it("cancels a pending reconnect and allows a later explicit connect", async () => { vi.useFakeTimers(); const onError = vi.fn(); diff --git a/extensions/openai/realtime-voice-provider.ts b/extensions/openai/realtime-voice-provider.ts index dc6bbcab7663..acbb5fa0e838 100644 --- a/extensions/openai/realtime-voice-provider.ts +++ b/extensions/openai/realtime-voice-provider.ts @@ -858,10 +858,22 @@ class OpenAIRealtimeVoiceBridge implements RealtimeVoiceBridge { ); return; } - this.handleEvent(event, lifecycleConnection); if (event.type === "session.updated") { + try { + this.handleEvent(event, lifecycleConnection); + } catch (error) { + const readyError = error instanceof Error ? error : new Error(String(error)); + attempt.reject(readyError); + this.failConnection(readyError, ws, lifecycleConnection, { + code: 1011, + reason: "Readiness callback failed", + }); + return; + } attempt.resolve(this.lifecycle.isReady()); + return; } + this.handleEvent(event, lifecycleConnection); } catch (error) { if (error instanceof OpenAIRealtimeMalformedAudioError) { attempt.reject(error); @@ -1680,6 +1692,11 @@ class OpenAIRealtimeVoiceBridge implements RealtimeVoiceBridge { } private resetTerminalState(): void { + // Transport retries preserve readiness and rotation attribution. A terminal + // session clears both so explicit bridge reuse starts as a new session. + this.sessionReadyFired = false; + this.reconnectReason = undefined; + this.activeConnectionReason = undefined; this.resetRealtimeSessionState(); } diff --git a/test/scripts/test-live-shard.test.ts b/test/scripts/test-live-shard.test.ts index 3b6b9dc469af..02cd2680ed8f 100644 --- a/test/scripts/test-live-shard.test.ts +++ b/test/scripts/test-live-shard.test.ts @@ -131,6 +131,7 @@ describe("scripts/test-live-shard", () => { "extensions/openai/openai.live.test.ts", "extensions/openai/realtime-quicksilver-gateway-bridge.live.test.ts", "extensions/openai/realtime-quicksilver.live.test.ts", + "extensions/openai/realtime-voice-provider.live.test.ts", ]); expect(selectLiveShardFiles("native-live-extensions-l-n", allFiles)).toEqual([ "extensions/memory-lancedb/memory-lancedb.live.test.ts",