fix(google-meet): reuse meet tabs across retries

This commit is contained in:
Peter Steinberger
2026-04-25 03:10:46 +01:00
parent ba4cd90dbc
commit f9f7d6ffb5
8 changed files with 185 additions and 3 deletions

View File

@@ -119,6 +119,10 @@ openclaw googlemeet create --no-join
the OpenClaw Chrome profile on the node to already be signed in to Google.
Browser automation handles Meet's own first-run microphone prompt; that prompt
is not treated as a Google login failure.
Join and create flows also try to reuse an existing Meet tab before opening a
new one. Matching ignores harmless URL query strings such as `authuser`, so an
agent retry should focus the already-open meeting instead of creating a second
Chrome tab.
The command/tool output includes a `source` field (`api` or `browser`) so agents
can explain which path was used. `create` joins the new meeting by default and
@@ -141,6 +145,14 @@ For an observe-only/browser-control join, set `"mode": "transcribe"`. That does
not start the duplex realtime model bridge, so it will not talk back into the
meeting.
During realtime sessions, `google_meet` status includes browser and audio bridge
health such as `inCall`, `manualActionRequired`, `providerConnected`,
`realtimeReady`, `audioInputActive`, `audioOutputActive`, last input/output
timestamps, byte counters, and bridge closed state. If a safe Meet page prompt
appears, browser automation handles it when it can. Login, host admission, and
browser/OS permission prompts are reported as manual action with a reason and
message for the agent to relay.
Chrome joins as the signed-in Chrome profile. In Meet, pick `BlackHole 2ch` for
the microphone/speaker path used by OpenClaw. For clean duplex audio, use
separate virtual devices or a Loopback-style graph; a single BlackHole device is

View File

@@ -766,6 +766,128 @@ describe("google-meet plugin", () => {
});
});
it("reuses active Meet sessions across URL query differences", async () => {
const { methods, nodesInvoke } = setup(
{
defaultTransport: "chrome-node",
defaultMode: "transcribe",
},
{
nodesInvokeResult: {
payload: {
launched: true,
browser: { inCall: true, micMuted: false },
},
},
},
);
const handler = methods.get("googlemeet.join") as
| ((ctx: {
params: Record<string, unknown>;
respond: ReturnType<typeof vi.fn>;
}) => Promise<void>)
| undefined;
const first = vi.fn();
const second = vi.fn();
await handler?.({
params: { url: "https://meet.google.com/abc-defg-hij?authuser=me@example.com" },
respond: first,
});
await handler?.({
params: { url: "https://meet.google.com/abc-defg-hij" },
respond: second,
});
expect(
nodesInvoke.mock.calls.filter(([call]) => call.command === "googlemeet.chrome"),
).toHaveLength(1);
expect(second.mock.calls[0]?.[1]).toMatchObject({
session: {
notes: expect.arrayContaining(["Reused existing active Meet session."]),
},
});
});
it("reuses existing Meet browser tabs across URL query differences", async () => {
const { methods, nodesInvoke } = setup(
{
defaultTransport: "chrome-node",
defaultMode: "transcribe",
},
{
nodesInvokeHandler: async (params) => {
if (params.command !== "browser.proxy") {
return { payload: { launched: true } };
}
const proxy = params.params as {
path?: string;
body?: { targetId?: string; url?: string };
};
if (proxy.path === "/tabs") {
return {
payload: {
result: {
running: true,
tabs: [
{
targetId: "existing-meet-tab",
title: "Meet",
url: "https://meet.google.com/abc-defg-hij?authuser=me@example.com",
},
],
},
},
};
}
if (proxy.path === "/tabs/focus") {
return { payload: { result: { ok: true } } };
}
if (proxy.path === "/act") {
return {
payload: {
result: {
result: JSON.stringify({
inCall: true,
title: "Meet",
url: "https://meet.google.com/abc-defg-hij?authuser=me@example.com",
}),
},
},
};
}
throw new Error(`unexpected browser proxy path ${proxy.path}`);
},
},
);
const handler = methods.get("googlemeet.join") as
| ((ctx: {
params: Record<string, unknown>;
respond: ReturnType<typeof vi.fn>;
}) => Promise<void>)
| undefined;
const respond = vi.fn();
await handler?.({
params: { url: "https://meet.google.com/abc-defg-hij" },
respond,
});
expect(nodesInvoke).toHaveBeenCalledWith(
expect.objectContaining({
params: expect.objectContaining({
path: "/tabs/focus",
body: { targetId: "existing-meet-tab" },
}),
}),
);
expect(nodesInvoke).not.toHaveBeenCalledWith(
expect.objectContaining({
params: expect.objectContaining({ path: "/tabs/open" }),
}),
);
});
it("exposes a test-speech action that joins the requested meeting", async () => {
const { tools, nodesInvoke } = setup(
{
@@ -1072,6 +1194,14 @@ describe("google-meet plugin", () => {
expect(bridge.triggerGreeting).not.toHaveBeenCalled();
handle.speak("Say exactly: hello from the meeting.");
expect(bridge.triggerGreeting).toHaveBeenLastCalledWith("Say exactly: hello from the meeting.");
expect(handle.getHealth()).toMatchObject({
providerConnected: true,
realtimeReady: true,
audioInputActive: true,
audioOutputActive: true,
lastInputBytes: 3,
lastOutputBytes: 2,
});
expect(callbacks).toMatchObject({
tools: [
expect.objectContaining({
@@ -1226,6 +1356,14 @@ describe("google-meet plugin", () => {
nodeId: "node-1",
bridgeId: "bridge-1",
});
expect(handle.getHealth()).toMatchObject({
providerConnected: true,
realtimeReady: true,
audioInputActive: true,
audioOutputActive: true,
lastInputBytes: 3,
lastOutputBytes: 3,
});
await handle.stop();

View File

@@ -213,6 +213,8 @@ export async function startNodeRealtimeAudioBridge(params: {
getHealth: () => ({
providerConnected: bridge?.bridge.isConnected() ?? false,
realtimeReady,
audioInputActive: lastInputBytes > 0,
audioOutputActive: lastOutputBytes > 0,
lastInputAt,
lastOutputAt,
lastInputBytes,

View File

@@ -234,6 +234,8 @@ export async function startCommandRealtimeAudioBridge(params: {
getHealth: () => ({
providerConnected: bridge?.bridge.isConnected() ?? false,
realtimeReady,
audioInputActive: lastInputBytes > 0,
audioOutputActive: lastOutputBytes > 0,
lastInputAt,
lastOutputAt,
lastInputBytes,

View File

@@ -5,7 +5,7 @@ import type { PluginRuntime, RuntimeLogger } from "openclaw/plugin-sdk/plugin-ru
import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime";
import type { GoogleMeetConfig, GoogleMeetMode, GoogleMeetTransport } from "./config.js";
import { addGoogleMeetSetupCheck, getGoogleMeetSetupStatus } from "./setup.js";
import { resolveChromeNodeInfo } from "./transports/chrome-browser-proxy.js";
import { isSameMeetUrlForReuse, resolveChromeNodeInfo } from "./transports/chrome-browser-proxy.js";
import { createMeetWithBrowserProxyOnNode } from "./transports/chrome-create.js";
import { launchChromeMeet, launchChromeMeetOnNode } from "./transports/chrome.js";
import { buildMeetDtmfSequence, normalizeDialInNumber } from "./transports/twilio.js";
@@ -126,7 +126,7 @@ export class GoogleMeetRuntime {
const reusable = this.list().find(
(session) =>
session.state === "active" &&
session.url === url &&
isSameMeetUrlForReuse(session.url, url) &&
session.transport === transport &&
session.mode === mode,
);

View File

@@ -10,6 +10,31 @@ export type BrowserTab = {
url?: string;
};
export function normalizeMeetUrlForReuse(url: string | undefined): string | undefined {
if (!url) {
return undefined;
}
try {
const parsed = new URL(url);
if (parsed.protocol !== "https:" || parsed.hostname.toLowerCase() !== "meet.google.com") {
return undefined;
}
const match = parsed.pathname.match(/^\/(new|[a-z]{3}-[a-z]{4}-[a-z]{3})(?:\/)?$/i);
if (!match?.[1]) {
return undefined;
}
return `https://meet.google.com/${match[1].toLowerCase()}`;
} catch {
return undefined;
}
}
export function isSameMeetUrlForReuse(a: string | undefined, b: string | undefined): boolean {
const normalizedA = normalizeMeetUrlForReuse(a);
const normalizedB = normalizeMeetUrlForReuse(b);
return Boolean(normalizedA && normalizedB && normalizedA === normalizedB);
}
export type GoogleMeetNodeInfo = {
caps?: string[];
commands?: string[];

View File

@@ -13,6 +13,7 @@ import {
import {
asBrowserTabs,
callBrowserProxyOnNode,
isSameMeetUrlForReuse,
readBrowserTab,
resolveChromeNode,
type BrowserTab,
@@ -305,7 +306,7 @@ async function openMeetWithBrowserProxy(params: {
timeoutMs: Math.min(timeoutMs, 5_000),
}),
);
tab = tabs.find((entry) => entry.url === params.url);
tab = tabs.find((entry) => isSameMeetUrlForReuse(entry.url, params.url));
targetId = tab?.targetId;
if (targetId) {
await callBrowserProxyOnNode({

View File

@@ -27,6 +27,8 @@ export type GoogleMeetChromeHealth = {
manualActionMessage?: string;
providerConnected?: boolean;
realtimeReady?: boolean;
audioInputActive?: boolean;
audioOutputActive?: boolean;
lastInputAt?: string;
lastOutputAt?: string;
lastInputBytes?: number;