diff --git a/extensions/openai/realtime-transcription-provider.bounds.test.ts b/extensions/openai/realtime-transcription-provider.bounds.test.ts index 3bb710870803..9a930fef9e3f 100644 --- a/extensions/openai/realtime-transcription-provider.bounds.test.ts +++ b/extensions/openai/realtime-transcription-provider.bounds.test.ts @@ -94,6 +94,113 @@ describe("OpenAI realtime transcription terminal history bounds", () => { vi.unstubAllEnvs(); }); + it.each([ + { label: "without item identity", itemId: undefined, committed: false }, + { label: "before item commit", itemId: "uncommitted-item", committed: false }, + { label: "after item commit", itemId: "committed-item", committed: true }, + ])("rejects an oversized final transcript $label", async ({ itemId, committed }) => { + const onError = vi.fn(); + const onTranscript = vi.fn(); + const session = buildOpenAIRealtimeTranscriptionProvider().createSession({ + providerConfig: { apiKey: "sk-test" }, // pragma: allowlist secret + onError, + onTranscript, + }); + const socket = await connectFakeSession(session); + if (committed) { + emitJson(socket, { + type: "input_audio_buffer.committed", + item_id: itemId, + previous_item_id: null, + }); + } + + const transcript = `${"🙂".repeat((256 * 1024) / 4)}x`; + emitJson(socket, { + type: "conversation.item.input_audio_transcription.completed", + ...(itemId ? { item_id: itemId } : {}), + transcript, + }); + emitJson(socket, { + type: "conversation.item.input_audio_transcription.completed", + ...(itemId ? { item_id: itemId } : {}), + transcript: "late transcript", + }); + + expect(onError).toHaveBeenCalledExactlyOnceWith( + expect.objectContaining({ + message: "OpenAI realtime transcription exceeded the 256 KiB retained transcript limit", + }), + ); + expect(onTranscript).not.toHaveBeenCalled(); + expect(session.isConnected()).toBe(false); + expect(socket.closed).toBe(true); + session.close(); + }); + + it("accepts an exact-limit UTF-8 final after releasing its own partial", async () => { + const onError = vi.fn(); + const onPartial = vi.fn(); + const onTranscript = vi.fn(); + const session = buildOpenAIRealtimeTranscriptionProvider().createSession({ + providerConfig: { apiKey: "sk-test" }, // pragma: allowlist secret + onError, + onPartial, + onTranscript, + }); + const socket = await connectFakeSession(session); + const transcript = "🙂".repeat((256 * 1024) / 4); + emitJson(socket, { + type: "conversation.item.input_audio_transcription.delta", + item_id: "uncommitted-item", + delta: transcript, + }); + emitJson(socket, { + type: "conversation.item.input_audio_transcription.completed", + item_id: "uncommitted-item", + transcript, + }); + + expect(onPartial).toHaveBeenCalledExactlyOnceWith(transcript); + expect(onTranscript).toHaveBeenCalledExactlyOnceWith(transcript); + expect(onError).not.toHaveBeenCalled(); + expect(session.isConnected()).toBe(true); + session.close(); + }); + + it("counts retained sibling text when admitting a final transcript", async () => { + const onError = vi.fn(); + const onTranscript = vi.fn(); + const session = buildOpenAIRealtimeTranscriptionProvider().createSession({ + providerConfig: { apiKey: "sk-test" }, // pragma: allowlist secret + onError, + onTranscript, + }); + const socket = await connectFakeSession(session); + const partial = "🙂".repeat((128 * 1024) / 4); + for (const itemId of ["retained-item", "completing-item"]) { + emitJson(socket, { + type: "conversation.item.input_audio_transcription.delta", + item_id: itemId, + delta: partial, + }); + } + emitJson(socket, { + type: "conversation.item.input_audio_transcription.completed", + item_id: "completing-item", + transcript: `${partial}x`, + }); + + expect(onError).toHaveBeenCalledExactlyOnceWith( + expect.objectContaining({ + message: "OpenAI realtime transcription exceeded the 256 KiB retained transcript limit", + }), + ); + expect(onTranscript).not.toHaveBeenCalled(); + expect(session.isConnected()).toBe(false); + session.close(); + }); + it("does not re-admit a terminal item after the settled frontier fills", async () => { const onError = vi.fn(); const onPartial = vi.fn(); diff --git a/extensions/openai/realtime-transcription-provider.ts b/extensions/openai/realtime-transcription-provider.ts index 3d2c56700b5e..d84ffd3a17cd 100644 --- a/extensions/openai/realtime-transcription-provider.ts +++ b/extensions/openai/realtime-transcription-provider.ts @@ -384,6 +384,14 @@ function createOpenAIRealtimeTranscriptionSession( const partialBytes = pendingTranscripts.get(key)?.bytes ?? 0; pendingTranscripts.delete(key); retainedTranscriptBytes -= partialBytes; + const transcriptBytes = transcript ? Buffer.byteLength(transcript, "utf8") : 0; + if ( + transcriptBytes > + OPENAI_REALTIME_TRANSCRIPTION_MAX_RETAINED_TRANSCRIPT_BYTES - retainedTranscriptBytes + ) { + failTerminal(new Error(OPENAI_REALTIME_TRANSCRIPTION_TEXT_OVERFLOW_MESSAGE), transport); + return false; + } if (!itemId || !committedItems.has(itemId)) { if (itemId) { if (!settleItem(itemId, transport)) { @@ -397,14 +405,6 @@ function createOpenAIRealtimeTranscriptionSession( } return true; } - const transcriptBytes = transcript ? Buffer.byteLength(transcript, "utf8") : 0; - if ( - transcriptBytes > - OPENAI_REALTIME_TRANSCRIPTION_MAX_RETAINED_TRANSCRIPT_BYTES - retainedTranscriptBytes - ) { - failTerminal(new Error(OPENAI_REALTIME_TRANSCRIPTION_TEXT_OVERFLOW_MESSAGE), transport); - return false; - } completedTranscripts.set(itemId, transcript); retainedTranscriptBytes += transcriptBytes; return flushCompletedTranscripts(transport);