diff --git a/CHANGELOG.md b/CHANGELOG.md index f8816741333..d6d95b113ca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,7 @@ Docs: https://docs.openclaw.ai - CLI/help: treat positional `help` invocations like `openclaw channels help` as help paths for startup gating, avoiding model/auth warmup while preserving positional arguments such as `openclaw docs help`. Thanks @gumadeiras. - Web search: route plugin-scoped web_search SecretRefs through the active runtime config snapshot so provider execution receives resolved credentials across app/runtime paths, including `plugins.entries.brave.config.webSearch.apiKey`. Fixes #68690. Thanks @VACInc. - Voice Call: allow SecretRef-backed Twilio auth tokens and call-specific OpenAI/ElevenLabs TTS API keys through the plugin config surface. Fixes #68690. Thanks @joshavant. +- Google Meet: clean stale chrome-node realtime audio bridges by URL before rejoining, expose active node bridge inspection, and tolerate transient node input pull failures instead of dropping the Meet session. Fixes #72371. (#72372) Thanks @BsnizND. - Matrix/E2EE: stabilize recovery and broken-device QA flows while avoiding Matrix device-cleanup sync races that could leave shutdown-time crypto work running. Thanks @gumadeiras. - Cron: treat isolated run-level agent failures as job errors even when no reply payload is produced, synthesizing a safe error payload so model/provider failures increment error counters and trigger failure notifications instead of clearing as successful. Fixes #43604; carries forward #43631. Thanks @SPFAdvisors. - Cron: preserve exact `NO_REPLY` tool results from isolated jobs with empty final assistant turns as quiet successes instead of surfacing incomplete-turn errors. Fixes #68452; carries forward #68453. Thanks @anyech. diff --git a/extensions/google-meet/node-host.test.ts b/extensions/google-meet/node-host.test.ts new file mode 100644 index 00000000000..a9faed6edef --- /dev/null +++ b/extensions/google-meet/node-host.test.ts @@ -0,0 +1,115 @@ +import { EventEmitter } from "node:events"; +import { describe, expect, it, vi } from "vitest"; + +type MockChild = EventEmitter & { + exitCode: number | null; + signalCode: NodeJS.Signals | null; + kill: ReturnType; + stdout?: EventEmitter; + stderr?: EventEmitter; + stdin?: { write: ReturnType }; +}; + +const children: MockChild[] = []; + +vi.mock("node:child_process", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + spawnSync: vi.fn(() => ({ + status: 0, + stdout: "BlackHole 2ch", + stderr: "", + })), + spawn: vi.fn(() => { + const child = Object.assign(new EventEmitter(), { + exitCode: null, + signalCode: null, + kill: vi.fn((signal?: NodeJS.Signals) => { + child.signalCode = signal ?? "SIGTERM"; + return true; + }), + stdout: new EventEmitter(), + stderr: new EventEmitter(), + stdin: { write: vi.fn() }, + }) as MockChild; + children.push(child); + return child; + }), + }; +}); + +describe("google-meet node host bridge sessions", () => { + it("lists active bridge sessions and hides closed sessions", async () => { + const { handleGoogleMeetNodeHostCommand } = await import("./src/node-host.js"); + const originalPlatform = process.platform; + children.length = 0; + + Object.defineProperty(process, "platform", { configurable: true, value: "darwin" }); + try { + const start = JSON.parse( + await handleGoogleMeetNodeHostCommand( + JSON.stringify({ + action: "start", + url: "https://meet.google.com/abc-defg-hij?authuser=1", + mode: "realtime", + launch: false, + audioInputCommand: ["mock-rec"], + audioOutputCommand: ["mock-play"], + }), + ), + ); + + expect(start).toMatchObject({ + audioBridge: { type: "node-command-pair" }, + bridgeId: expect.any(String), + }); + + const activeList = JSON.parse( + await handleGoogleMeetNodeHostCommand( + JSON.stringify({ + action: "list", + url: "https://meet.google.com/abc-defg-hij", + mode: "realtime", + }), + ), + ); + + expect(activeList.bridges).toHaveLength(1); + expect(activeList.bridges[0]).toMatchObject({ + bridgeId: start.bridgeId, + closed: false, + mode: "realtime", + url: "https://meet.google.com/abc-defg-hij?authuser=1", + }); + + children[1]?.emit("exit", 0, null); + + const afterExitList = JSON.parse( + await handleGoogleMeetNodeHostCommand( + JSON.stringify({ + action: "list", + url: "https://meet.google.com/abc-defg-hij", + mode: "realtime", + }), + ), + ); + + expect(afterExitList).toEqual({ bridges: [] }); + + const stopped = JSON.parse( + await handleGoogleMeetNodeHostCommand( + JSON.stringify({ + action: "stopByUrl", + url: "https://meet.google.com/abc-defg-hij", + mode: "realtime", + }), + ), + ); + + expect(stopped).toEqual({ ok: true, stopped: 0 }); + } finally { + Object.defineProperty(process, "platform", { configurable: true, value: originalPlatform }); + } + }); +}); diff --git a/extensions/google-meet/src/node-host.ts b/extensions/google-meet/src/node-host.ts index c360d52c0ab..c1a7260f166 100644 --- a/extensions/google-meet/src/node-host.ts +++ b/extensions/google-meet/src/node-host.ts @@ -361,6 +361,7 @@ function listSessions(params: Record) { const urlKey = normalizeMeetKey(readString(params.url)); const mode = readString(params.mode); const bridges = [...sessions.values()] + .filter((session) => !session.closed) .filter((session) => !urlKey || normalizeMeetKey(session.url) === urlKey) .filter((session) => !mode || session.mode === mode) .map(summarizeSession); @@ -385,9 +386,12 @@ function stopSessionsByUrl(params: Record) { if (mode && session.mode !== mode) { continue; } + const wasClosed = session.closed; stopSession(session); sessions.delete(bridgeId); - stopped += 1; + if (!wasClosed) { + stopped += 1; + } } return { ok: true, stopped }; }