Skip to content

Commit 916eda1

Browse files
authored
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 <[email protected]>
1 parent b09afa2 commit 916eda1

6 files changed

Lines changed: 217 additions & 59 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ Docs: https://docs.openclaw.ai
5656
- 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.
5757
- 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.
5858
- Google Meet: add `realtime.agentId` so live meeting consults can target a named OpenClaw agent instead of always using `main`. (#72381) Thanks @BsnizND.
59+
- 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.
5960
- 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.
6061
- 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.
6162
- 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.

extensions/google-meet/index.create.test.ts

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,14 @@
11
import { Command } from "commander";
22
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
3-
import plugin from "./index.js";
3+
import plugin, { __testing as googleMeetPluginTesting } from "./index.js";
44
import { registerGoogleMeetCli } from "./src/cli.js";
55
import { resolveGoogleMeetConfig } from "./src/config.js";
66
import type { GoogleMeetRuntime } from "./src/runtime.js";
7-
import { captureStdout, setupGoogleMeetPlugin } from "./src/test-support/plugin-harness.js";
7+
import {
8+
captureStdout,
9+
invokeGoogleMeetGatewayMethodForTest,
10+
setupGoogleMeetPlugin,
11+
} from "./src/test-support/plugin-harness.js";
812
import { CREATE_MEET_FROM_BROWSER_SCRIPT } from "./src/transports/chrome-create.js";
913

1014
const voiceCallMocks = vi.hoisted(() => ({
@@ -40,7 +44,15 @@ function setup(
4044
config?: Parameters<typeof setupGoogleMeetPlugin>[1],
4145
options?: Parameters<typeof setupGoogleMeetPlugin>[2],
4246
) {
43-
return setupGoogleMeetPlugin(plugin, config, options);
47+
const harness = setupGoogleMeetPlugin(plugin, config, options);
48+
googleMeetPluginTesting.setCallGatewayFromCliForTests(
49+
async (method, _opts, params) =>
50+
(await invokeGoogleMeetGatewayMethodForTest(harness.methods, method, params)) as Record<
51+
string,
52+
unknown
53+
>,
54+
);
55+
return harness;
4456
}
4557

4658
async function runCreateMeetBrowserScript(params: { buttonText: string }) {
@@ -83,6 +95,7 @@ describe("google-meet create flow", () => {
8395

8496
afterEach(() => {
8597
vi.unstubAllGlobals();
98+
googleMeetPluginTesting.setCallGatewayFromCliForTests();
8699
});
87100

88101
it("CLI create prints the new meeting URL", async () => {

extensions/google-meet/index.test.ts

Lines changed: 41 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import path from "node:path";
55
import { PassThrough, Writable } from "node:stream";
66
import type { RealtimeVoiceProviderPlugin } from "openclaw/plugin-sdk/realtime-voice";
77
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
8-
import plugin from "./index.js";
8+
import plugin, { __testing as googleMeetPluginTesting } from "./index.js";
99
import {
1010
extractGoogleMeetUriFromCalendarEvent,
1111
findGoogleMeetCalendarEvent,
@@ -25,7 +25,11 @@ import { handleGoogleMeetNodeHostCommand } from "./src/node-host.js";
2525
import { startNodeRealtimeAudioBridge } from "./src/realtime-node.js";
2626
import { startCommandRealtimeAudioBridge } from "./src/realtime.js";
2727
import { normalizeMeetUrl } from "./src/runtime.js";
28-
import { noopLogger, setupGoogleMeetPlugin } from "./src/test-support/plugin-harness.js";
28+
import {
29+
invokeGoogleMeetGatewayMethodForTest,
30+
noopLogger,
31+
setupGoogleMeetPlugin,
32+
} from "./src/test-support/plugin-harness.js";
2933
import { __testing as chromeTransportTesting } from "./src/transports/chrome.js";
3034
import { buildMeetDtmfSequence, normalizeDialInNumber } from "./src/transports/twilio.js";
3135

@@ -62,7 +66,15 @@ function setup(
6266
config?: Parameters<typeof setupGoogleMeetPlugin>[1],
6367
options?: Parameters<typeof setupGoogleMeetPlugin>[2],
6468
) {
65-
return setupGoogleMeetPlugin(plugin, config, options);
69+
const harness = setupGoogleMeetPlugin(plugin, config, options);
70+
googleMeetPluginTesting.setCallGatewayFromCliForTests(
71+
async (method, _opts, params) =>
72+
(await invokeGoogleMeetGatewayMethodForTest(harness.methods, method, params)) as Record<
73+
string,
74+
unknown
75+
>,
76+
);
77+
return harness;
6678
}
6779

6880
function jsonResponse(value: unknown): Response {
@@ -228,6 +240,7 @@ describe("google-meet plugin", () => {
228240
afterEach(() => {
229241
vi.unstubAllGlobals();
230242
chromeTransportTesting.setDepsForTest(null);
243+
googleMeetPluginTesting.setCallGatewayFromCliForTests();
231244
});
232245

233246
it("defaults to chrome realtime with safe read-only tools", () => {
@@ -358,6 +371,31 @@ describe("google-meet plugin", () => {
358371
);
359372
});
360373

374+
it("returns structured gateway errors for missing session ids", async () => {
375+
const { methods } = setup();
376+
for (const method of ["googlemeet.leave", "googlemeet.speak"]) {
377+
const handler = methods.get(method) as
378+
| ((ctx: {
379+
params: Record<string, unknown>;
380+
respond: ReturnType<typeof vi.fn>;
381+
}) => Promise<void>)
382+
| undefined;
383+
const respond = vi.fn();
384+
385+
await handler?.({ params: {}, respond });
386+
387+
expect(respond).toHaveBeenCalledWith(
388+
false,
389+
{ error: "sessionId required" },
390+
{
391+
code: "INVALID_REQUEST",
392+
message: "sessionId required",
393+
details: { error: "sessionId required" },
394+
},
395+
);
396+
}
397+
});
398+
361399
it("uses a provider-safe flat tool parameter schema", () => {
362400
const { tools } = setup();
363401
const tool = tools[0] as { description?: string; parameters: unknown };

extensions/google-meet/index.ts

Lines changed: 109 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,8 @@
1+
import {
2+
callGatewayFromCli,
3+
ErrorCodes,
4+
errorShape,
5+
} from "openclaw/plugin-sdk/browser-node-runtime";
16
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
27
import type { GatewayRequestHandlerOptions } from "openclaw/plugin-sdk/gateway-runtime";
38
import { definePluginEntry, type OpenClawPluginApi } from "openclaw/plugin-sdk/plugin-entry";
@@ -287,6 +292,78 @@ function shouldJoinCreatedMeet(raw: Record<string, unknown>): boolean {
287292
return raw.join !== false && raw.join !== "false";
288293
}
289294

295+
const googleMeetToolDeps = {
296+
callGatewayFromCli,
297+
};
298+
299+
export const __testing = {
300+
setCallGatewayFromCliForTests(next?: typeof callGatewayFromCli): void {
301+
googleMeetToolDeps.callGatewayFromCli = next ?? callGatewayFromCli;
302+
},
303+
};
304+
305+
type GoogleMeetGatewayToolAction =
306+
| "join"
307+
| "create"
308+
| "status"
309+
| "recover_current_tab"
310+
| "setup_status"
311+
| "leave"
312+
| "speak"
313+
| "test_speech";
314+
315+
function googleMeetGatewayMethodForToolAction(action: GoogleMeetGatewayToolAction): string {
316+
switch (action) {
317+
case "recover_current_tab":
318+
return "googlemeet.recoverCurrentTab";
319+
case "setup_status":
320+
return "googlemeet.setup";
321+
case "test_speech":
322+
return "googlemeet.testSpeech";
323+
default:
324+
return `googlemeet.${action}`;
325+
}
326+
}
327+
328+
function resolveGoogleMeetToolGatewayTimeoutMs(config: GoogleMeetConfig): number {
329+
return Math.max(
330+
60_000,
331+
config.chrome.joinTimeoutMs + 30_000,
332+
config.voiceCall.requestTimeoutMs + 10_000,
333+
);
334+
}
335+
336+
function readGatewayErrorDetails(err: unknown): unknown {
337+
if (!err || typeof err !== "object" || !("details" in err)) {
338+
return undefined;
339+
}
340+
return (err as { details?: unknown }).details;
341+
}
342+
343+
async function callGoogleMeetGatewayFromTool(params: {
344+
config: GoogleMeetConfig;
345+
action: GoogleMeetGatewayToolAction;
346+
raw: Record<string, unknown>;
347+
}): Promise<unknown> {
348+
try {
349+
return await googleMeetToolDeps.callGatewayFromCli(
350+
googleMeetGatewayMethodForToolAction(params.action),
351+
{
352+
json: true,
353+
timeout: String(resolveGoogleMeetToolGatewayTimeoutMs(params.config)),
354+
},
355+
params.raw,
356+
{ progress: false },
357+
);
358+
} catch (err) {
359+
const details = readGatewayErrorDetails(err);
360+
if (details && typeof details === "object") {
361+
return details;
362+
}
363+
throw err;
364+
}
365+
}
366+
290367
async function createMeetFromParams(params: {
291368
config: GoogleMeetConfig;
292369
runtime: OpenClawPluginApi["runtime"];
@@ -498,8 +575,23 @@ export default definePluginEntry({
498575
const formatGatewayError = (err: unknown) =>
499576
isGoogleMeetBrowserManualActionError(err) ? err.payload : { error: formatErrorMessage(err) };
500577

501-
const sendError = (respond: (ok: boolean, payload?: unknown) => void, err: unknown) => {
502-
respond(false, formatGatewayError(err));
578+
const sendError = (
579+
respond: GatewayRequestHandlerOptions["respond"],
580+
err: unknown,
581+
code: Parameters<typeof errorShape>[0] = ErrorCodes.UNAVAILABLE,
582+
) => {
583+
const payload = formatGatewayError(err);
584+
respond(
585+
false,
586+
payload,
587+
errorShape(
588+
code,
589+
typeof payload.error === "string" ? payload.error : "Google Meet request failed",
590+
{
591+
details: payload,
592+
},
593+
),
594+
);
503595
};
504596

505597
api.registerGatewayMethod(
@@ -699,7 +791,7 @@ export default definePluginEntry({
699791
try {
700792
const sessionId = normalizeOptionalString(params?.sessionId);
701793
if (!sessionId) {
702-
respond(false, { error: "sessionId required" });
794+
sendError(respond, new Error("sessionId required"), ErrorCodes.INVALID_REQUEST);
703795
return;
704796
}
705797
const rt = await ensureRuntime();
@@ -716,7 +808,7 @@ export default definePluginEntry({
716808
try {
717809
const sessionId = normalizeOptionalString(params?.sessionId);
718810
if (!sessionId) {
719-
respond(false, { error: "sessionId required" });
811+
sendError(respond, new Error("sessionId required"), ErrorCodes.INVALID_REQUEST);
720812
return;
721813
}
722814
const rt = await ensureRuntime();
@@ -759,61 +851,32 @@ export default definePluginEntry({
759851
try {
760852
switch (raw.action) {
761853
case "join": {
762-
const rt = await ensureRuntime();
763-
return json(
764-
await rt.join({
765-
url: resolveMeetingInput(config, raw.url),
766-
transport: normalizeTransport(raw.transport),
767-
mode: normalizeMode(raw.mode),
768-
dialInNumber: normalizeOptionalString(raw.dialInNumber),
769-
pin: normalizeOptionalString(raw.pin),
770-
dtmfSequence: normalizeOptionalString(raw.dtmfSequence),
771-
message: normalizeOptionalString(raw.message),
772-
}),
773-
);
854+
return json(await callGoogleMeetGatewayFromTool({ config, action: "join", raw }));
774855
}
775856
case "create": {
776-
return json(
777-
shouldJoinCreatedMeet(raw)
778-
? await createAndJoinMeetFromParams({
779-
config,
780-
runtime: api.runtime,
781-
raw,
782-
ensureRuntime,
783-
})
784-
: await createMeetFromParams({ config, runtime: api.runtime, raw }),
785-
);
857+
return json(await callGoogleMeetGatewayFromTool({ config, action: "create", raw }));
786858
}
787859
case "test_speech": {
788-
const rt = await ensureRuntime();
789860
return json(
790-
await rt.testSpeech({
791-
url: resolveMeetingInput(config, raw.url),
792-
transport: normalizeTransport(raw.transport),
793-
mode: normalizeMode(raw.mode),
794-
dialInNumber: normalizeOptionalString(raw.dialInNumber),
795-
pin: normalizeOptionalString(raw.pin),
796-
dtmfSequence: normalizeOptionalString(raw.dtmfSequence),
797-
message: normalizeOptionalString(raw.message),
798-
}),
861+
await callGoogleMeetGatewayFromTool({ config, action: "test_speech", raw }),
799862
);
800863
}
801864
case "status": {
802-
const rt = await ensureRuntime();
803-
return json(rt.status(normalizeOptionalString(raw.sessionId)));
865+
return json(await callGoogleMeetGatewayFromTool({ config, action: "status", raw }));
804866
}
805867
case "recover_current_tab": {
806-
const rt = await ensureRuntime();
807868
return json(
808-
await rt.recoverCurrentTab({
809-
url: normalizeOptionalString(raw.url),
810-
transport: normalizeTransport(raw.transport),
869+
await callGoogleMeetGatewayFromTool({
870+
config,
871+
action: "recover_current_tab",
872+
raw,
811873
}),
812874
);
813875
}
814876
case "setup_status": {
815-
const rt = await ensureRuntime();
816-
return json(await rt.setupStatus({ transport: normalizeTransport(raw.transport) }));
877+
return json(
878+
await callGoogleMeetGatewayFromTool({ config, action: "setup_status", raw }),
879+
);
817880
}
818881
case "resolve_space": {
819882
const { token: _token, ...result } = await resolveSpaceFromParams(config, raw);
@@ -890,20 +953,18 @@ export default definePluginEntry({
890953
return json(await exportGoogleMeetBundleFromParams(config, raw));
891954
}
892955
case "leave": {
893-
const rt = await ensureRuntime();
894956
const sessionId = normalizeOptionalString(raw.sessionId);
895957
if (!sessionId) {
896958
throw new Error("sessionId required");
897959
}
898-
return json(await rt.leave(sessionId));
960+
return json(await callGoogleMeetGatewayFromTool({ config, action: "leave", raw }));
899961
}
900962
case "speak": {
901-
const rt = await ensureRuntime();
902963
const sessionId = normalizeOptionalString(raw.sessionId);
903964
if (!sessionId) {
904965
throw new Error("sessionId required");
905966
}
906-
return json(rt.speak(sessionId, normalizeOptionalString(raw.message)));
967+
return json(await callGoogleMeetGatewayFromTool({ config, action: "speak", raw }));
907968
}
908969
default:
909970
throw new Error("unknown google_meet action");

extensions/google-meet/src/node-host.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -103,14 +103,14 @@ function wake(session: NodeBridgeSession) {
103103
}
104104

105105
function stopSession(session: NodeBridgeSession) {
106-
if (session.closed) {
107-
return;
108-
}
106+
const wasClosed = session.closed;
109107
session.closed = true;
110-
session.closedAt = new Date().toISOString();
108+
session.closedAt ??= new Date().toISOString();
111109
terminateChild(session.input);
112110
terminateChild(session.output);
113-
wake(session);
111+
if (!wasClosed) {
112+
wake(session);
113+
}
114114
}
115115

116116
function attachOutputProcessHandlers(session: NodeBridgeSession, outputProcess: ChildProcess) {

0 commit comments

Comments
 (0)