From 916eda16c19fce77c8930c707365dc38b16d51b9 Mon Sep 17 00:00:00 2001 From: BsnizND Date: Mon, 27 Apr 2026 01:28:14 -0700 Subject: [PATCH] fix(google-meet): keep tool sessions gateway-owned Routes stateful Google Meet tool actions through the gateway-owned runtime so create/join/status/speak/leave share the same session owner instead of losing tool-created realtime sessions after the agent turn. Also preserves structured gateway error details for missing session ids and tightens node-host child cleanup for already-closed sessions. Fixes #72440. Co-authored-by: BSnizND <199837910+BsnizND@users.noreply.github.com> --- CHANGELOG.md | 1 + extensions/google-meet/index.create.test.ts | 19 ++- extensions/google-meet/index.test.ts | 44 ++++- extensions/google-meet/index.ts | 157 ++++++++++++------ extensions/google-meet/src/node-host.ts | 10 +- .../src/test-support/plugin-harness.ts | 45 +++++ 6 files changed, 217 insertions(+), 59 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 53dcfac9c64..c4f6a23a895 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -56,6 +56,7 @@ Docs: https://docs.openclaw.ai - 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. - Google Meet: clear queued Gemini Live playback when realtime interruptions arrive, restart Chrome command-pair audio output after clears, and expose Google Live interruption/VAD config knobs for Meet and Voice Call realtime bridges. Fixes #72523. (#72524) Thanks @BsnizND. - Google Meet: add `realtime.agentId` so live meeting consults can target a named OpenClaw agent instead of always using `main`. (#72381) Thanks @BsnizND. +- Google Meet: route stateful `google_meet` tool actions through the gateway-owned runtime so created or joined realtime sessions remain visible to status, speak, and leave after the agent turn ends. Fixes #72440. (#72441) 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/index.create.test.ts b/extensions/google-meet/index.create.test.ts index 9638b59a8f8..29ec2468af2 100644 --- a/extensions/google-meet/index.create.test.ts +++ b/extensions/google-meet/index.create.test.ts @@ -1,10 +1,14 @@ import { Command } from "commander"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import plugin from "./index.js"; +import plugin, { __testing as googleMeetPluginTesting } from "./index.js"; import { registerGoogleMeetCli } from "./src/cli.js"; import { resolveGoogleMeetConfig } from "./src/config.js"; import type { GoogleMeetRuntime } from "./src/runtime.js"; -import { captureStdout, setupGoogleMeetPlugin } from "./src/test-support/plugin-harness.js"; +import { + captureStdout, + invokeGoogleMeetGatewayMethodForTest, + setupGoogleMeetPlugin, +} from "./src/test-support/plugin-harness.js"; import { CREATE_MEET_FROM_BROWSER_SCRIPT } from "./src/transports/chrome-create.js"; const voiceCallMocks = vi.hoisted(() => ({ @@ -40,7 +44,15 @@ function setup( config?: Parameters[1], options?: Parameters[2], ) { - return setupGoogleMeetPlugin(plugin, config, options); + const harness = setupGoogleMeetPlugin(plugin, config, options); + googleMeetPluginTesting.setCallGatewayFromCliForTests( + async (method, _opts, params) => + (await invokeGoogleMeetGatewayMethodForTest(harness.methods, method, params)) as Record< + string, + unknown + >, + ); + return harness; } async function runCreateMeetBrowserScript(params: { buttonText: string }) { @@ -83,6 +95,7 @@ describe("google-meet create flow", () => { afterEach(() => { vi.unstubAllGlobals(); + googleMeetPluginTesting.setCallGatewayFromCliForTests(); }); it("CLI create prints the new meeting URL", async () => { diff --git a/extensions/google-meet/index.test.ts b/extensions/google-meet/index.test.ts index fb6a164cf4a..55c46e77425 100644 --- a/extensions/google-meet/index.test.ts +++ b/extensions/google-meet/index.test.ts @@ -5,7 +5,7 @@ import path from "node:path"; import { PassThrough, Writable } from "node:stream"; import type { RealtimeVoiceProviderPlugin } from "openclaw/plugin-sdk/realtime-voice"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import plugin from "./index.js"; +import plugin, { __testing as googleMeetPluginTesting } from "./index.js"; import { extractGoogleMeetUriFromCalendarEvent, findGoogleMeetCalendarEvent, @@ -25,7 +25,11 @@ import { handleGoogleMeetNodeHostCommand } from "./src/node-host.js"; import { startNodeRealtimeAudioBridge } from "./src/realtime-node.js"; import { startCommandRealtimeAudioBridge } from "./src/realtime.js"; import { normalizeMeetUrl } from "./src/runtime.js"; -import { noopLogger, setupGoogleMeetPlugin } from "./src/test-support/plugin-harness.js"; +import { + invokeGoogleMeetGatewayMethodForTest, + noopLogger, + setupGoogleMeetPlugin, +} from "./src/test-support/plugin-harness.js"; import { __testing as chromeTransportTesting } from "./src/transports/chrome.js"; import { buildMeetDtmfSequence, normalizeDialInNumber } from "./src/transports/twilio.js"; @@ -62,7 +66,15 @@ function setup( config?: Parameters[1], options?: Parameters[2], ) { - return setupGoogleMeetPlugin(plugin, config, options); + const harness = setupGoogleMeetPlugin(plugin, config, options); + googleMeetPluginTesting.setCallGatewayFromCliForTests( + async (method, _opts, params) => + (await invokeGoogleMeetGatewayMethodForTest(harness.methods, method, params)) as Record< + string, + unknown + >, + ); + return harness; } function jsonResponse(value: unknown): Response { @@ -228,6 +240,7 @@ describe("google-meet plugin", () => { afterEach(() => { vi.unstubAllGlobals(); chromeTransportTesting.setDepsForTest(null); + googleMeetPluginTesting.setCallGatewayFromCliForTests(); }); it("defaults to chrome realtime with safe read-only tools", () => { @@ -358,6 +371,31 @@ describe("google-meet plugin", () => { ); }); + it("returns structured gateway errors for missing session ids", async () => { + const { methods } = setup(); + for (const method of ["googlemeet.leave", "googlemeet.speak"]) { + const handler = methods.get(method) as + | ((ctx: { + params: Record; + respond: ReturnType; + }) => Promise) + | undefined; + const respond = vi.fn(); + + await handler?.({ params: {}, respond }); + + expect(respond).toHaveBeenCalledWith( + false, + { error: "sessionId required" }, + { + code: "INVALID_REQUEST", + message: "sessionId required", + details: { error: "sessionId required" }, + }, + ); + } + }); + it("uses a provider-safe flat tool parameter schema", () => { const { tools } = setup(); const tool = tools[0] as { description?: string; parameters: unknown }; diff --git a/extensions/google-meet/index.ts b/extensions/google-meet/index.ts index 9e81567b725..281d44ba112 100644 --- a/extensions/google-meet/index.ts +++ b/extensions/google-meet/index.ts @@ -1,3 +1,8 @@ +import { + callGatewayFromCli, + ErrorCodes, + errorShape, +} from "openclaw/plugin-sdk/browser-node-runtime"; import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime"; import type { GatewayRequestHandlerOptions } from "openclaw/plugin-sdk/gateway-runtime"; import { definePluginEntry, type OpenClawPluginApi } from "openclaw/plugin-sdk/plugin-entry"; @@ -287,6 +292,78 @@ function shouldJoinCreatedMeet(raw: Record): boolean { return raw.join !== false && raw.join !== "false"; } +const googleMeetToolDeps = { + callGatewayFromCli, +}; + +export const __testing = { + setCallGatewayFromCliForTests(next?: typeof callGatewayFromCli): void { + googleMeetToolDeps.callGatewayFromCli = next ?? callGatewayFromCli; + }, +}; + +type GoogleMeetGatewayToolAction = + | "join" + | "create" + | "status" + | "recover_current_tab" + | "setup_status" + | "leave" + | "speak" + | "test_speech"; + +function googleMeetGatewayMethodForToolAction(action: GoogleMeetGatewayToolAction): string { + switch (action) { + case "recover_current_tab": + return "googlemeet.recoverCurrentTab"; + case "setup_status": + return "googlemeet.setup"; + case "test_speech": + return "googlemeet.testSpeech"; + default: + return `googlemeet.${action}`; + } +} + +function resolveGoogleMeetToolGatewayTimeoutMs(config: GoogleMeetConfig): number { + return Math.max( + 60_000, + config.chrome.joinTimeoutMs + 30_000, + config.voiceCall.requestTimeoutMs + 10_000, + ); +} + +function readGatewayErrorDetails(err: unknown): unknown { + if (!err || typeof err !== "object" || !("details" in err)) { + return undefined; + } + return (err as { details?: unknown }).details; +} + +async function callGoogleMeetGatewayFromTool(params: { + config: GoogleMeetConfig; + action: GoogleMeetGatewayToolAction; + raw: Record; +}): Promise { + try { + return await googleMeetToolDeps.callGatewayFromCli( + googleMeetGatewayMethodForToolAction(params.action), + { + json: true, + timeout: String(resolveGoogleMeetToolGatewayTimeoutMs(params.config)), + }, + params.raw, + { progress: false }, + ); + } catch (err) { + const details = readGatewayErrorDetails(err); + if (details && typeof details === "object") { + return details; + } + throw err; + } +} + async function createMeetFromParams(params: { config: GoogleMeetConfig; runtime: OpenClawPluginApi["runtime"]; @@ -498,8 +575,23 @@ export default definePluginEntry({ const formatGatewayError = (err: unknown) => isGoogleMeetBrowserManualActionError(err) ? err.payload : { error: formatErrorMessage(err) }; - const sendError = (respond: (ok: boolean, payload?: unknown) => void, err: unknown) => { - respond(false, formatGatewayError(err)); + const sendError = ( + respond: GatewayRequestHandlerOptions["respond"], + err: unknown, + code: Parameters[0] = ErrorCodes.UNAVAILABLE, + ) => { + const payload = formatGatewayError(err); + respond( + false, + payload, + errorShape( + code, + typeof payload.error === "string" ? payload.error : "Google Meet request failed", + { + details: payload, + }, + ), + ); }; api.registerGatewayMethod( @@ -699,7 +791,7 @@ export default definePluginEntry({ try { const sessionId = normalizeOptionalString(params?.sessionId); if (!sessionId) { - respond(false, { error: "sessionId required" }); + sendError(respond, new Error("sessionId required"), ErrorCodes.INVALID_REQUEST); return; } const rt = await ensureRuntime(); @@ -716,7 +808,7 @@ export default definePluginEntry({ try { const sessionId = normalizeOptionalString(params?.sessionId); if (!sessionId) { - respond(false, { error: "sessionId required" }); + sendError(respond, new Error("sessionId required"), ErrorCodes.INVALID_REQUEST); return; } const rt = await ensureRuntime(); @@ -759,61 +851,32 @@ export default definePluginEntry({ try { switch (raw.action) { case "join": { - const rt = await ensureRuntime(); - return json( - await rt.join({ - url: resolveMeetingInput(config, raw.url), - transport: normalizeTransport(raw.transport), - mode: normalizeMode(raw.mode), - dialInNumber: normalizeOptionalString(raw.dialInNumber), - pin: normalizeOptionalString(raw.pin), - dtmfSequence: normalizeOptionalString(raw.dtmfSequence), - message: normalizeOptionalString(raw.message), - }), - ); + return json(await callGoogleMeetGatewayFromTool({ config, action: "join", raw })); } case "create": { - return json( - shouldJoinCreatedMeet(raw) - ? await createAndJoinMeetFromParams({ - config, - runtime: api.runtime, - raw, - ensureRuntime, - }) - : await createMeetFromParams({ config, runtime: api.runtime, raw }), - ); + return json(await callGoogleMeetGatewayFromTool({ config, action: "create", raw })); } case "test_speech": { - const rt = await ensureRuntime(); return json( - await rt.testSpeech({ - url: resolveMeetingInput(config, raw.url), - transport: normalizeTransport(raw.transport), - mode: normalizeMode(raw.mode), - dialInNumber: normalizeOptionalString(raw.dialInNumber), - pin: normalizeOptionalString(raw.pin), - dtmfSequence: normalizeOptionalString(raw.dtmfSequence), - message: normalizeOptionalString(raw.message), - }), + await callGoogleMeetGatewayFromTool({ config, action: "test_speech", raw }), ); } case "status": { - const rt = await ensureRuntime(); - return json(rt.status(normalizeOptionalString(raw.sessionId))); + return json(await callGoogleMeetGatewayFromTool({ config, action: "status", raw })); } case "recover_current_tab": { - const rt = await ensureRuntime(); return json( - await rt.recoverCurrentTab({ - url: normalizeOptionalString(raw.url), - transport: normalizeTransport(raw.transport), + await callGoogleMeetGatewayFromTool({ + config, + action: "recover_current_tab", + raw, }), ); } case "setup_status": { - const rt = await ensureRuntime(); - return json(await rt.setupStatus({ transport: normalizeTransport(raw.transport) })); + return json( + await callGoogleMeetGatewayFromTool({ config, action: "setup_status", raw }), + ); } case "resolve_space": { const { token: _token, ...result } = await resolveSpaceFromParams(config, raw); @@ -890,20 +953,18 @@ export default definePluginEntry({ return json(await exportGoogleMeetBundleFromParams(config, raw)); } case "leave": { - const rt = await ensureRuntime(); const sessionId = normalizeOptionalString(raw.sessionId); if (!sessionId) { throw new Error("sessionId required"); } - return json(await rt.leave(sessionId)); + return json(await callGoogleMeetGatewayFromTool({ config, action: "leave", raw })); } case "speak": { - const rt = await ensureRuntime(); const sessionId = normalizeOptionalString(raw.sessionId); if (!sessionId) { throw new Error("sessionId required"); } - return json(rt.speak(sessionId, normalizeOptionalString(raw.message))); + return json(await callGoogleMeetGatewayFromTool({ config, action: "speak", raw })); } default: throw new Error("unknown google_meet action"); diff --git a/extensions/google-meet/src/node-host.ts b/extensions/google-meet/src/node-host.ts index c10db654ecf..adc3b901064 100644 --- a/extensions/google-meet/src/node-host.ts +++ b/extensions/google-meet/src/node-host.ts @@ -103,14 +103,14 @@ function wake(session: NodeBridgeSession) { } function stopSession(session: NodeBridgeSession) { - if (session.closed) { - return; - } + const wasClosed = session.closed; session.closed = true; - session.closedAt = new Date().toISOString(); + session.closedAt ??= new Date().toISOString(); terminateChild(session.input); terminateChild(session.output); - wake(session); + if (!wasClosed) { + wake(session); + } } function attachOutputProcessHandlers(session: NodeBridgeSession, outputProcess: ChildProcess) { diff --git a/extensions/google-meet/src/test-support/plugin-harness.ts b/extensions/google-meet/src/test-support/plugin-harness.ts index da9b586c57b..16e9daff524 100644 --- a/extensions/google-meet/src/test-support/plugin-harness.ts +++ b/extensions/google-meet/src/test-support/plugin-harness.ts @@ -168,3 +168,48 @@ export function setupGoogleMeetPlugin( nodeHostCommands, }; } + +export async function invokeGoogleMeetGatewayMethodForTest( + methods: Map, + method: string, + params?: unknown, +): Promise { + const handler = methods.get(method) as + | ((opts: { + params: Record; + respond: ( + ok: boolean, + payload?: unknown, + error?: { message?: string; details?: unknown }, + ) => void; + }) => Promise | void) + | undefined; + if (!handler) { + throw new Error(`gateway method not registered: ${method}`); + } + return await new Promise((resolve, reject) => { + const respond = ( + ok: boolean, + payload?: unknown, + error?: { message?: string; details?: unknown }, + ) => { + if (ok) { + resolve(payload); + return; + } + const err = new Error(error?.message ?? "gateway request failed") as Error & { + details?: unknown; + }; + err.details = error?.details ?? payload; + reject(err); + }; + void Promise.resolve( + handler({ + params: (params && typeof params === "object" && !Array.isArray(params) + ? params + : {}) as Record, + respond, + }), + ).catch(reject); + }); +}