From 1787ae0f5d2e91cd6bf3f8401302b085b036b3ff Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 25 Apr 2026 00:03:50 +0100 Subject: [PATCH] fix(google-meet): reuse create tabs on retry --- CHANGELOG.md | 3 +- docs/plugins/google-meet.md | 7 +- extensions/google-meet/index.test.ts | 80 ++++++++++++++++ .../google-meet/src/transports/chrome.ts | 92 ++++++++++++++++--- 4 files changed, 168 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a6ee280d5d2..74ad264195c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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.` or `direct.` 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. diff --git a/docs/plugins/google-meet.md b/docs/plugins/google-meet.md index cec61f6f460..c2c4baafed8 100644 --- a/docs/plugins/google-meet.md +++ b/docs/plugins/google-meet.md @@ -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 diff --git a/extensions/google-meet/index.test.ts b/extensions/google-meet/index.test.ts index 2a5899ac91b..55e83fc004c 100644 --- a/extensions/google-meet/index.test.ts +++ b/extensions/google-meet/index.test.ts @@ -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; + respond: ReturnType; + }) => Promise) + | 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."], [ diff --git a/extensions/google-meet/src/transports/chrome.ts b/extensions/google-meet/src/transports/chrome.ts index 4bd028593ea..0f2f8d7a39a 100644 --- a/extensions/google-meet/src/transports/chrome.ts +++ b/extensions/google-meet/src/transports/chrome.ts @@ -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 { + 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 { + 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(