Merge pull request #118136 from openclaw/fix-openai-realtime-lifecycle-reset-20260802

This commit is contained in:
Vincent Koc
2026-08-03 04:04:33 +08:00
4 changed files with 178 additions and 2 deletions
@@ -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);
});
@@ -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();
+18 -1
View File
@@ -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();
}
+1
View File
@@ -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",