mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 06:30:42 +00:00
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:
@@ -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.
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user