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>
This commit is contained in:
BsnizND
2026-04-27 01:28:14 -07:00
committed by GitHub
parent b09afa2993
commit 916eda16c1
6 changed files with 217 additions and 59 deletions

View File

@@ -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.

View File

@@ -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<typeof setupGoogleMeetPlugin>[1],
options?: Parameters<typeof setupGoogleMeetPlugin>[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 () => {

View File

@@ -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<typeof setupGoogleMeetPlugin>[1],
options?: Parameters<typeof setupGoogleMeetPlugin>[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<string, unknown>;
respond: ReturnType<typeof vi.fn>;
}) => Promise<void>)
| 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 };

View File

@@ -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<string, unknown>): 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<string, unknown>;
}): Promise<unknown> {
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<typeof errorShape>[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");

View File

@@ -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) {

View File

@@ -168,3 +168,48 @@ export function setupGoogleMeetPlugin(
nodeHostCommands,
};
}
export async function invokeGoogleMeetGatewayMethodForTest(
methods: Map<string, unknown>,
method: string,
params?: unknown,
): Promise<unknown> {
const handler = methods.get(method) as
| ((opts: {
params: Record<string, unknown>;
respond: (
ok: boolean,
payload?: unknown,
error?: { message?: string; details?: unknown },
) => void;
}) => Promise<void> | 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<string, unknown>,
respond,
}),
).catch(reject);
});
}