mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-26 20:35:39 -06:00
Merge pull request #118136 from openclaw/fix-openai-realtime-lifecycle-reset-20260802
This commit is contained in:
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user