fix(openai): terminalize preconnect lifecycle

This commit is contained in:
Vincent Koc
2026-07-31 16:42:25 +08:00
parent 0f31254230
commit 42f1e8dd41
2 changed files with 47 additions and 10 deletions

View File

@@ -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();

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 (state.phase === "idle") {
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;
}
}