fix(google-meet): surface browser create manual actions

This commit is contained in:
Peter Steinberger
2026-04-25 04:56:55 +01:00
parent 70fd1c91aa
commit 0f0c855a8b
5 changed files with 222 additions and 6 deletions

View File

@@ -197,6 +197,80 @@ describe("google-meet create flow", () => {
);
});
it("reports structured manual action when browser creation needs Google login", async () => {
const { methods } = setup(
{
defaultTransport: "chrome-node",
chromeNode: { node: "parallels-macos" },
},
{
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: {
result: {
targetId: "login-tab",
title: "New Tab",
url: proxy.body?.url,
},
},
};
}
if (proxy.path === "/act") {
return {
payload: {
result: {
ok: true,
targetId: "login-tab",
result: {
manualActionReason: "google-login-required",
manualAction:
"Sign in to Google in the OpenClaw browser profile, then retry meeting creation.",
browserUrl: "https://accounts.google.com/signin",
browserTitle: "Sign in - Google Accounts",
notes: ["Sign-in page detected."],
},
},
},
};
}
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(false);
expect(respond.mock.calls[0]?.[1]).toMatchObject({
source: "browser",
error:
"google-login-required: Sign in to Google in the OpenClaw browser profile, then retry meeting creation.",
manualActionRequired: true,
manualActionReason: "google-login-required",
manualActionMessage:
"Sign in to Google in the OpenClaw browser profile, then retry meeting creation.",
browser: {
nodeId: "node-1",
targetId: "login-tab",
browserUrl: "https://accounts.google.com/signin",
browserTitle: "Sign in - Google Accounts",
notes: ["Sign-in page detected."],
},
});
});
it("creates and joins a Meet through the create tool action by default", async () => {
const { tools, nodesInvoke } = setup(
{
@@ -292,6 +366,71 @@ describe("google-meet create flow", () => {
);
});
it("returns structured manual action from the create tool action", async () => {
const { tools } = setup(
{
defaultTransport: "chrome-node",
chromeNode: { node: "parallels-macos" },
},
{
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: {
result: {
targetId: "permission-tab",
title: "Meet",
url: proxy.body?.url,
},
},
};
}
if (proxy.path === "/act") {
return {
payload: {
result: {
ok: true,
targetId: "permission-tab",
result: {
manualActionReason: "meet-permission-required",
manualAction:
"Allow microphone/camera permissions for Meet in the OpenClaw browser profile, then retry meeting creation.",
browserUrl: "https://meet.google.com/new",
browserTitle: "Meet",
},
},
},
};
}
throw new Error(`unexpected browser proxy path ${proxy.path}`);
},
},
);
const tool = tools[0] as {
execute: (id: string, params: unknown) => Promise<{ details: Record<string, unknown> }>;
};
const result = await tool.execute("id", { action: "create" });
expect(result.details).toMatchObject({
source: "browser",
manualActionRequired: true,
manualActionReason: "meet-permission-required",
manualActionMessage:
"Allow microphone/camera permissions for Meet in the OpenClaw browser profile, then retry meeting creation.",
browser: {
nodeId: "node-1",
targetId: "permission-tab",
browserUrl: "https://meet.google.com/new",
browserTitle: "Meet",
},
});
});
it("reuses an existing browser create tab instead of opening duplicates", async () => {
const { methods, nodesInvoke } = setup(
{

View File

@@ -19,6 +19,7 @@ import { buildGoogleMeetPreflightReport, fetchGoogleMeetSpace } from "./src/meet
import { handleGoogleMeetNodeHostCommand } from "./src/node-host.js";
import { resolveGoogleMeetAccessToken } from "./src/oauth.js";
import { GoogleMeetRuntime } from "./src/runtime.js";
import { isGoogleMeetBrowserManualActionError } from "./src/transports/chrome-create.js";
const googleMeetConfigSchema = {
parse(value: unknown) {
@@ -250,8 +251,11 @@ export default definePluginEntry({
return runtime;
};
const formatGatewayError = (err: unknown) =>
isGoogleMeetBrowserManualActionError(err) ? err.payload : { error: formatErrorMessage(err) };
const sendError = (respond: (ok: boolean, payload?: unknown) => void, err: unknown) => {
respond(false, { error: formatErrorMessage(err) });
respond(false, formatGatewayError(err));
};
api.registerGatewayMethod(
@@ -485,7 +489,7 @@ export default definePluginEntry({
throw new Error("unknown google_meet action");
}
} catch (err) {
return json({ error: formatErrorMessage(err) });
return json(formatGatewayError(err));
}
},
});

View File

@@ -35,6 +35,42 @@ export type GoogleMeetBrowserCreateResult = {
source: "browser";
};
export type GoogleMeetBrowserManualAction = {
source: "browser";
error: string;
manualActionRequired: true;
manualActionReason?: GoogleMeetChromeHealth["manualActionReason"];
manualActionMessage: string;
browser: {
nodeId: string;
targetId?: string;
browserUrl?: string;
browserTitle?: string;
notes?: string[];
};
};
export class GoogleMeetBrowserManualActionError extends Error {
readonly payload: GoogleMeetBrowserManualAction;
constructor(payload: Omit<GoogleMeetBrowserManualAction, "source" | "error">) {
const prefix = payload.manualActionReason ? `${payload.manualActionReason}: ` : "";
super(`${prefix}${payload.manualActionMessage}`);
this.name = "GoogleMeetBrowserManualActionError";
this.payload = {
source: "browser",
error: this.message,
...payload,
};
}
}
export function isGoogleMeetBrowserManualActionError(
error: unknown,
): error is GoogleMeetBrowserManualActionError {
return error instanceof GoogleMeetBrowserManualActionError;
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
@@ -298,10 +334,18 @@ export async function createMeetWithBrowserProxyOnNode(params: {
};
}
if (result.manualAction) {
if (result.manualActionReason) {
throw new Error(`${result.manualActionReason}: ${result.manualAction}`);
}
throw new Error(result.manualAction);
throw new GoogleMeetBrowserManualActionError({
manualActionRequired: true,
manualActionReason: result.manualActionReason,
manualActionMessage: result.manualAction,
browser: {
nodeId,
targetId,
browserUrl: result.browserUrl,
browserTitle: result.browserTitle,
notes: [...notes],
},
});
}
await sleep(result.retryAfterMs ?? GOOGLE_MEET_BROWSER_POLL_MS);
} catch (error) {