mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-27 21:07:01 -06:00
Merge pull request #116735 from vincentkoc/fix/openai-preconnect-lifecycle
* commit 'b218adb7a7d931927aa7fd577f40b31ecc113c3b': chore(openai): drop release-owned changelog entry fix(openai): narrow idle lifecycle cancellation fix(openai): discard preconnect audio on close fix(openai): terminalize preconnect lifecycle
This commit is contained in:
@@ -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({
|
||||
|
||||
@@ -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" });
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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<OpenAIRealtimeVoiceLifecyclePhase, "idle">;
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user