fix(google-meet): reuse create tabs on retry

This commit is contained in:
Peter Steinberger
2026-04-25 00:03:50 +01:00
parent 50e484b22e
commit 1787ae0f5d
4 changed files with 168 additions and 14 deletions

View File

@@ -96,7 +96,8 @@ Docs: https://docs.openclaw.ai
- Group chats/silent replies: tighten `NO_REPLY` prompt guidance so groups stay quiet without narrating silence or emitting fallback chatter when silence is the intended outcome. (#70954, #71209) Thanks @Takhoffman.
- WhatsApp/groups+direct: setting `systemPrompt: ""` on a specific `groups.<id>` or `direct.<peerId>` entry now suppresses the wildcard system prompt instead of falling through to it, so users can silence the global prompt for a specific group or peer. (#70381) Thanks @Bluetegu.
- Browser/tool: tell agents not to pass per-call `timeoutMs` on existing-session type, evaluate, and other Chrome MCP actions that reject timeout overrides. Thanks @steipete.
- Plugins/Google Meet: use browser automation to classify and clear Meet's microphone-choice interstitial during browser meeting creation instead of reporting a false Google login failure. Thanks @steipete.
- Browser/tool: use Playwright's current AI aria snapshot API for `refs="aria"` and fall back to role refs when a node browser cannot provide aria refs, so agents can still inspect and click controls such as Google Meet admission buttons. Thanks @steipete.
- Plugins/Google Meet: use browser automation to classify and clear Meet's microphone-choice interstitial during browser meeting creation, and reuse in-progress create tabs on retry instead of opening duplicates. Thanks @steipete.
- Codex/GPT-5.4: harden fallback, auth-profile, tool-schema, and replay edge cases across native and embedded runtime paths. (#70743) Thanks @100yenadmin.
- Models/fallback: resolve bare fallback model provider ids before model switching, so configured fallback chains keep working when a fallback is named without an explicit provider prefix. Thanks @steipete.
- Voice-call/Telnyx: preserve inbound/outbound callback metadata and read transcription text from Telnyx's current `transcription_data` payload. Thanks @steipete.

View File

@@ -305,7 +305,9 @@ Common failure checks:
config points at the profile you want, for example
`browser.defaultProfile: "user"` or a named existing-session profile.
- Duplicate Meet tabs: leave `chrome.reuseExistingTab: true` enabled. OpenClaw
activates an existing tab for the same Meet URL before opening a new one.
activates an existing tab for the same Meet URL before opening a new one, and
browser meeting creation reuses an in-progress `https://meet.google.com/new`
or Google account prompt tab before opening another one.
- No audio: in Meet, route microphone/speaker through the virtual audio device
path used by OpenClaw; use separate virtual devices or Loopback-style routing
for clean duplex audio.
@@ -820,6 +822,9 @@ to the pinned Chrome node browser. Confirm:
`googlemeet.chrome`.
- For browser fallback: the OpenClaw Chrome profile on that node is signed in
to Google and can open `https://meet.google.com/new`.
- For browser fallback: retries reuse an existing `https://meet.google.com/new`
or Google account prompt tab before opening a new tab. If an agent times out,
retry the tool call rather than manually opening another Meet tab.
- For browser fallback: if Meet shows "Do you want people to hear you in the
meeting?", leave the tab open. OpenClaw should click **Use microphone** or, for
create-only fallback, **Continue without microphone** through browser

View File

@@ -820,6 +820,9 @@ describe("google-meet plugin", () => {
{
nodesInvokeHandler: async (params) => {
const proxy = params.params as { path?: string; body?: { url?: string } };
if (proxy.path === "/tabs") {
return { payload: { result: { tabs: [] } } };
}
if (proxy.path === "/tabs/open") {
return {
payload: {
@@ -877,6 +880,83 @@ describe("google-meet plugin", () => {
);
});
it("reuses an existing browser create tab instead of opening duplicates", async () => {
const { methods, nodesInvoke } = setup(
{
defaultTransport: "chrome-node",
chromeNode: { node: "parallels-macos" },
},
{
nodesInvokeHandler: async (params) => {
const proxy = params.params as { path?: string; body?: { targetId?: string } };
if (proxy.path === "/tabs") {
return {
payload: {
result: {
tabs: [
{
targetId: "existing-create-tab",
title: "Meet",
url: "https://meet.google.com/new",
},
],
},
},
};
}
if (proxy.path === "/tabs/focus") {
return { payload: { result: { ok: true } } };
}
if (proxy.path === "/act") {
return {
payload: {
result: {
ok: true,
targetId: proxy.body?.targetId ?? "existing-create-tab",
result: {
meetingUri: "https://meet.google.com/reu-sedx-tab",
browserUrl: "https://meet.google.com/reu-sedx-tab",
browserTitle: "Meet",
},
},
},
};
}
throw new Error(`unexpected browser proxy path ${proxy.path}`);
},
},
);
const handler = methods.get("googlemeet.create") as
| ((ctx: {
params: Record<string, unknown>;
respond: ReturnType<typeof vi.fn>;
}) => Promise<void>)
| undefined;
const respond = vi.fn();
await handler?.({ params: {}, respond });
expect(respond.mock.calls[0]?.[0]).toBe(true);
expect(respond.mock.calls[0]?.[1]).toMatchObject({
source: "browser",
meetingUri: "https://meet.google.com/reu-sedx-tab",
browser: { nodeId: "node-1", targetId: "existing-create-tab" },
});
expect(nodesInvoke).toHaveBeenCalledWith(
expect.objectContaining({
params: expect.objectContaining({
path: "/tabs/focus",
body: { targetId: "existing-create-tab" },
}),
}),
);
expect(nodesInvoke).not.toHaveBeenCalledWith(
expect.objectContaining({
params: expect.objectContaining({ path: "/tabs/open" }),
}),
);
});
it.each([
["Use microphone", "Accepted Meet microphone prompt with browser automation."],
[

View File

@@ -231,6 +231,12 @@ type BrowserTab = {
url?: string;
};
const GOOGLE_MEET_NEW_URL = "https://meet.google.com/new";
const GOOGLE_MEET_BROWSER_CREATE_TIMEOUT_MS = 60_000;
const GOOGLE_MEET_BROWSER_STEP_TIMEOUT_MS = 10_000;
const GOOGLE_MEET_BROWSER_NAVIGATION_RETRY_MS = 1_000;
const GOOGLE_MEET_BROWSER_POLL_MS = 500;
type BrowserCreateStepResult = {
meetingUri?: string;
browserUrl?: string;
@@ -324,6 +330,50 @@ function readBrowserTab(result: unknown): BrowserTab | undefined {
return result && typeof result === "object" ? (result as BrowserTab) : undefined;
}
function isGoogleMeetCreateTab(tab: BrowserTab): boolean {
const url = tab.url ?? "";
if (/^https:\/\/meet\.google\.com\/(?:new|[a-z]{3}-[a-z]{4}-[a-z]{3})(?:$|[/?#])/i.test(url)) {
return true;
}
return (
url.startsWith("https://accounts.google.com/") &&
/sign in|google accounts|meet/i.test(tab.title ?? "")
);
}
async function findGoogleMeetCreateTab(params: {
runtime: PluginRuntime;
nodeId: string;
timeoutMs: number;
}): Promise<BrowserTab | undefined> {
const tabs = asBrowserTabs(
await callBrowserProxyOnNode({
runtime: params.runtime,
nodeId: params.nodeId,
method: "GET",
path: "/tabs",
timeoutMs: params.timeoutMs,
}),
);
return tabs.find(isGoogleMeetCreateTab);
}
async function focusBrowserTab(params: {
runtime: PluginRuntime;
nodeId: string;
targetId: string;
timeoutMs: number;
}): Promise<void> {
await callBrowserProxyOnNode({
runtime: params.runtime,
nodeId: params.nodeId,
method: "POST",
path: "/tabs/focus",
body: { targetId: params.targetId },
timeoutMs: params.timeoutMs,
});
}
function readStringArray(value: unknown): string[] | undefined {
return Array.isArray(value)
? value.filter((entry): entry is string => typeof entry === "string")
@@ -454,17 +504,35 @@ export async function createMeetWithBrowserProxyOnNode(params: {
runtime: params.runtime,
requestedNode: params.config.chromeNode.node,
});
const timeoutMs = Math.max(15_000, params.config.chrome.joinTimeoutMs);
const tab = readBrowserTab(
await callBrowserProxyOnNode({
const timeoutMs = Math.max(
GOOGLE_MEET_BROWSER_CREATE_TIMEOUT_MS,
params.config.chrome.joinTimeoutMs,
);
const stepTimeoutMs = Math.min(timeoutMs, GOOGLE_MEET_BROWSER_STEP_TIMEOUT_MS);
let tab = await findGoogleMeetCreateTab({
runtime: params.runtime,
nodeId,
timeoutMs: stepTimeoutMs,
});
if (tab?.targetId) {
await focusBrowserTab({
runtime: params.runtime,
nodeId,
method: "POST",
path: "/tabs/open",
body: { url: "https://meet.google.com/new" },
timeoutMs,
}),
);
targetId: tab.targetId,
timeoutMs: stepTimeoutMs,
});
} else {
tab = readBrowserTab(
await callBrowserProxyOnNode({
runtime: params.runtime,
nodeId,
method: "POST",
path: "/tabs/open",
body: { url: GOOGLE_MEET_NEW_URL },
timeoutMs: stepTimeoutMs,
}),
);
}
const targetId = tab?.targetId;
if (!targetId) {
throw new Error("Browser fallback opened Google Meet but did not return a targetId.");
@@ -485,7 +553,7 @@ export async function createMeetWithBrowserProxyOnNode(params: {
targetId,
fn: CREATE_MEET_FROM_BROWSER_SCRIPT,
},
timeoutMs: Math.min(timeoutMs, 10_000),
timeoutMs: stepTimeoutMs,
});
const result = readBrowserCreateResult(evaluated);
lastResult = result;
@@ -509,13 +577,13 @@ export async function createMeetWithBrowserProxyOnNode(params: {
}
throw new Error(result.manualAction);
}
await sleep(result.retryAfterMs ?? 500);
await sleep(result.retryAfterMs ?? GOOGLE_MEET_BROWSER_POLL_MS);
} catch (error) {
lastError = error;
if (!isBrowserNavigationInterruption(error)) {
throw error;
}
await sleep(1_000);
await sleep(GOOGLE_MEET_BROWSER_NAVIGATION_RETRY_MS);
}
}
throw new Error(