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:
Vincent Koc
2026-07-31 20:18:48 +08:00
6 changed files with 114 additions and 12 deletions
@@ -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();
+33 -10
View File
@@ -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({
+4 -1
View File
@@ -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");