From 42f1e8dd4177fbec711deeccc6c0daafa76ace74 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Fri, 31 Jul 2026 16:42:25 +0800 Subject: [PATCH] fix(openai): terminalize preconnect lifecycle --- .../openai/realtime-voice-lifecycle.test.ts | 14 ++++++ extensions/openai/realtime-voice-lifecycle.ts | 43 ++++++++++++++----- 2 files changed, 47 insertions(+), 10 deletions(-) diff --git a/extensions/openai/realtime-voice-lifecycle.test.ts b/extensions/openai/realtime-voice-lifecycle.test.ts index 85b044e94f2a..181077ee0ec9 100644 --- a/extensions/openai/realtime-voice-lifecycle.test.ts +++ b/extensions/openai/realtime-voice-lifecycle.test.ts @@ -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(); diff --git a/extensions/openai/realtime-voice-lifecycle.ts b/extensions/openai/realtime-voice-lifecycle.ts index a436a5ac92af..5f136ca16587 100644 --- a/extensions/openai/realtime-voice-lifecycle.ts +++ b/extensions/openai/realtime-voice-lifecycle.ts @@ -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; 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; } }