From b616a87b51e229b5e8945fb5b0faed18e58e24c7 Mon Sep 17 00:00:00 2001 From: Anze Mur Date: Fri, 28 Aug 2026 15:54:10 +0200 Subject: [PATCH] fix(google): restart the realtime session on abnormal WebSocket close --- ...ogle-realtime-restart-on-abnormal-close.md | 5 ++ plugins/google/src/realtime/realtime_api.ts | 10 ++- .../src/realtime/realtime_reconnect.test.ts | 85 +++++++++++++++++++ 3 files changed, 99 insertions(+), 1 deletion(-) create mode 100644 .changeset/google-realtime-restart-on-abnormal-close.md create mode 100644 plugins/google/src/realtime/realtime_reconnect.test.ts diff --git a/.changeset/google-realtime-restart-on-abnormal-close.md b/.changeset/google-realtime-restart-on-abnormal-close.md new file mode 100644 index 0000000000..660517bd41 --- /dev/null +++ b/.changeset/google-realtime-restart-on-abnormal-close.md @@ -0,0 +1,5 @@ +--- +'@livekit/agents-plugin-google': patch +--- + +Gemini realtime: restart the session when the WebSocket closes abnormally (e.g. code 1006), mirroring the network-error path - the main task reconnects and re-seeds the chat context instead of leaving the session parked on a dead socket. The emitted error is marked recoverable when a restart will happen. diff --git a/plugins/google/src/realtime/realtime_api.ts b/plugins/google/src/realtime/realtime_api.ts index a14bfd1945..3d9c4feea8 100644 --- a/plugins/google/src/realtime/realtime_api.ts +++ b/plugins/google/src/realtime/realtime_api.ts @@ -1057,6 +1057,11 @@ export class RealtimeSession extends llm.RealtimeSession { const errorMsg = event.reason || `WebSocket closed with code ${event.code}`; this.#logger.error(`Gemini Live session error: ${errorMsg}${truncationNote}`); + // An abnormal close is the same failure as `onerror`'s + // network-level errors: restart so the main task reconnects and + // re-seeds the chat context, instead of leaving the session + // parked forever on a dead socket. + const willRestart = !this.sessionShouldClose.isSet; this.emitError( new APIStatusError({ message: `${errorMsg}${truncationNote}`, @@ -1068,8 +1073,11 @@ export class RealtimeSession extends llm.RealtimeSession { : null, }, }), - false, + willRestart, ); + if (willRestart) { + this.markRestartNeeded(); + } } else { this.#logger.debug('Gemini Live session closed:', event.code, event.reason); } diff --git a/plugins/google/src/realtime/realtime_reconnect.test.ts b/plugins/google/src/realtime/realtime_reconnect.test.ts new file mode 100644 index 0000000000..7ee9c060ff --- /dev/null +++ b/plugins/google/src/realtime/realtime_reconnect.test.ts @@ -0,0 +1,85 @@ +// SPDX-FileCopyrightText: 2026 LiveKit, Inc. +// +// SPDX-License-Identifier: Apache-2.0 +import type * as genai from '@google/genai'; +import { describe, expect, it, vi } from 'vitest'; +import { RealtimeModel } from './realtime_api.js'; + +type LiveCallbacks = { + onopen: () => void; + onmessage: (message: unknown) => void; + onerror: (error: unknown) => void; + onclose: (event: { code: number; reason: string }) => void; +}; + +const { connects } = vi.hoisted(() => ({ + connects: [] as Array<{ callbacks: LiveCallbacks }>, +})); + +vi.mock('@google/genai', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + GoogleGenAI: class { + live = { + connect: async ({ callbacks }: { callbacks: LiveCallbacks }) => { + connects.push({ callbacks }); + callbacks.onopen(); + return { + sendClientContent: () => {}, + sendRealtimeInput: () => {}, + sendToolResponse: () => {}, + close: () => {}, + }; + }, + }; + }, + }; +}); + +/** + * An abnormal WebSocket close is the same failure as `onerror`'s network-level + * errors: without a restart the main task stays parked on + * `sessionShouldClose.wait()` with a dead socket, and the session never + * recovers. + */ +describe('RealtimeSession on abnormal WebSocket close', () => { + it('restarts the session and emits the error as recoverable', async () => { + connects.length = 0; + const session = new RealtimeModel({ + model: 'gemini-2.0-flash-live-001', + apiKey: 'test-key', + }).session(); + const errors: Array<{ error: Error; recoverable: boolean }> = []; + session.on('error', (ev) => errors.push(ev)); + + await vi.waitFor(() => expect(connects).toHaveLength(1)); + connects[0]!.callbacks.onclose({ code: 1006, reason: '' }); + + await vi.waitFor(() => expect(connects).toHaveLength(2)); + expect(errors).toHaveLength(1); + expect(errors[0]!.recoverable).toBe(true); + expect(errors[0]!.error.message).toContain('WebSocket closed with code 1006'); + + await session.close(); + }); + + it('does not restart or emit on a normal close', async () => { + connects.length = 0; + const session = new RealtimeModel({ + model: 'gemini-2.0-flash-live-001', + apiKey: 'test-key', + }).session(); + const errors: Array<{ error: Error; recoverable: boolean }> = []; + session.on('error', (ev) => errors.push(ev)); + + await vi.waitFor(() => expect(connects).toHaveLength(1)); + connects[0]!.callbacks.onclose({ code: 1000, reason: '' }); + + await new Promise((resolve) => setTimeout(resolve, 100)); + expect(connects).toHaveLength(1); + expect(errors).toHaveLength(0); + + await session.close(); + }); +});