Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .changeset/wild-loops-lay.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
"@browserbasehq/stagehand-python": minor
"@browserbasehq/stagehand-extension": minor
"@browserbasehq/stagehand-protocol": minor
"@browserbasehq/stagehand-go": minor
"@browserbasehq/stagehand": minor
---

add page level hooks for webmcp tools added and webmcp tools removed events.
15 changes: 13 additions & 2 deletions packages/docs/tests/sdk-reference.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,9 @@ const LANGUAGES = ["TypeScript", "Python", "Go"] as const satisfies readonly Lan
// (model provider, output shape, ...).
const LANGUAGE_TAB_TITLES = new Set<string>(LANGUAGES);
const STAGEHAND_LIFECYCLE_METHODS = new Set(["create", "create-with-client-for-test", "init"]);
// Docs publish separately after release. Remove these entries in the WebMCP hooks docs PR.
// These exceptions apply only to docs coverage, never SDK-to-SDK parity.
const UNRELEASED_REFERENCE_METHODS = new Set(["page/on-tools-added", "page/on-tools-removed"]);
// Cross-language concept references are validated as MDX content, not as one-to-one SDK objects.
const SUPPLEMENTAL_REFERENCE_PAGES = new Set(["response", "webmcp"]);

Expand Down Expand Up @@ -243,10 +246,15 @@ describe("SDK reference surface", () => {
.map(({ classSlug, method }) => `${classSlug}/${method.methodSlug}`)
.sort(),
`${language} method headings must match the public SDK surface`,
).toStrictEqual(expected);
).toStrictEqual(expected.filter((key) => !UNRELEASED_REFERENCE_METHODS.has(key)));
}
}, 30_000);

it("keeps unreleased reference exceptions limited to existing SDK methods", async () => {
const methods = new Set(methodKeys(await readTypescriptMethods()));
expect([...UNRELEASED_REFERENCE_METHODS].filter((key) => !methods.has(key))).toEqual([]);
});

it("has exactly one reference page for every documented SDK object", async () => {
const pageSlugs = (await readReferencePages()).map(({ classSlug }) => classSlug).sort();

Expand Down Expand Up @@ -593,7 +601,10 @@ describe("SDK reference surface", () => {
const documented = documentedMethods(referencePages, language)
.map(({ classSlug, method }) => `${classSlug}/${method.methodSlug}:${method.methodName}`)
.sort();
const expected = methods.map((method) => `${methodKey(method)}:${method.methodName}`).sort();
const expected = methods
.filter((method) => !UNRELEASED_REFERENCE_METHODS.has(methodKey(method)))
.map((method) => `${methodKey(method)}:${method.methodName}`)
.sort();
if (!arraysEqual(documented, expected)) {
differences.push(
`${language}: expected [${expected.join(", ")}], received [${documented.join(", ")}]`,
Expand Down
80 changes: 62 additions & 18 deletions packages/extension/runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ import type {
PageCloseResult,
PageCDPEvent,
PageCDPEventNotification,
PageEventNotification,
PageEventName,
PageAddInitScriptParams,
PageDragAndDropParams,
Expand Down Expand Up @@ -122,7 +123,7 @@ import { StagehandRuntimeStateSchema, type StagehandRuntimeState } from "./runti
import { createStagehandTracing, type StagehandTracing } from "./tracing.js";
import type { HybridSnapshot, SnapshotOptions } from "./types/private/snapshot.js";
import type { SetInputFilesArgument } from "./types/private/fileUpload.js";
import { Page } from "./understudy/page.js";
import { Page, type WebMCPToolsEvent } from "./understudy/page.js";
import { Response } from "./understudy/response.js";
import { StagehandMetricsAccumulator } from "./metrics.js";
import { ResponseHandleTable } from "./responseHandleTable.js";
Expand Down Expand Up @@ -181,7 +182,12 @@ export type UnderstudyRuntimePage = {
subscribeCDPEvent(
pageEventName: PageEventName,
listener: (event: PageCDPEvent) => void,
): () => void;
signal?: AbortSignal,
): Promise<() => void>;
subscribeWebMCPToolsChanged(
listener: (event: WebMCPToolsEvent) => void,
signal?: AbortSignal,
): Promise<() => void>;
};

export type UnderstudyRuntimeScreenshotOptions = Omit<PageScreenshotOptions, "mask"> & {
Expand Down Expand Up @@ -266,6 +272,7 @@ export type StagehandRuntimeAdapters = {
emitLog?: StagehandLogEmitter;
clientLLMGenerate?: (params: LLMGenerateParams) => Promise<LLMGenerateResult>;
emitPageCDPEvent?: (notification: PageCDPEventNotification) => void;
emitPageEvent?: (notification: PageEventNotification) => void;
};

type ResolvedStagehandRuntimeAdapters = Required<StagehandRuntimeAdapters>;
Expand All @@ -289,11 +296,18 @@ export function createStagehandRuntime(
emitLog: adapters.emitLog ?? discardLog,
clientLLMGenerate: adapters.clientLLMGenerate ?? unavailableClientLLM,
emitPageCDPEvent: adapters.emitPageCDPEvent ?? discardPageCDPEvent,
emitPageEvent: adapters.emitPageEvent ?? discardPageCDPEvent,
},
tracing,
);
}

type RuntimePageEventSubscription = {
pageId: string;
controller: AbortController;
dispose?: () => void;
};

export class StagehandRuntime {
readonly logger: StagehandLogger;
readonly metrics = new StagehandMetricsAccumulator();
Expand All @@ -303,10 +317,7 @@ export class StagehandRuntime {
);
browserSession?: StagehandBrowserSession;
pagesById = new Map<string, UnderstudyRuntimePage>();
private readonly pageEventSubscriptions = new Map<
string,
{ pageId: string; dispose: () => void }
>();
private readonly pageEventSubscriptions = new Map<string, RuntimePageEventSubscription>();
private initializationInProgress = false;
private lifecycleTail = Promise.resolve();
private stagehandInstanceClosing = false;
Expand Down Expand Up @@ -736,28 +747,59 @@ export class StagehandRuntime {

async pageClose(params: PageIdParams): Promise<PageCloseResult> {
const page = this.resolvePage(params.pageId);
this.disposePageEventSubscriptions(params.pageId, true);
await page.close();
this.disposePageEventSubscriptions(params.pageId);
this.pagesById.delete(params.pageId);
this.responseHandles.deleteForPage(params.pageId);
return { closed: true };
}

pageOn(params: PageOnParams): PageVoidResult {
async pageOn(params: PageOnParams): Promise<PageVoidResult> {
if (this.pageEventSubscriptions.has(params.subscriptionId)) {
throw new DuplicatePageEventSubscriptionError();
}
const dispose = this.resolvePage(params.pageId).subscribeCDPEvent(params.event, (event) => {
this.adapters.emitPageCDPEvent({ subscriptionId: params.subscriptionId, event });
});
this.pageEventSubscriptions.set(params.subscriptionId, { pageId: params.pageId, dispose });
return { ok: true };
const page = this.resolvePage(params.pageId);
const subscription: RuntimePageEventSubscription = {
pageId: params.pageId,
controller: new AbortController(),
};
this.pageEventSubscriptions.set(params.subscriptionId, subscription);
try {
const isActive = () =>
this.pageEventSubscriptions.get(params.subscriptionId) === subscription &&
!subscription.controller.signal.aborted;
subscription.dispose =
params.event === "console"
? await page.subscribeCDPEvent(
params.event,
(event) => {
if (!isActive()) return;
this.adapters.emitPageCDPEvent({ subscriptionId: params.subscriptionId, event });
},
subscription.controller.signal,
)
: await page.subscribeWebMCPToolsChanged((event) => {
if (!isActive() || event.event !== params.event) return;
this.adapters.emitPageEvent({ ...event, subscriptionId: params.subscriptionId });
}, subscription.controller.signal);
subscription.controller.signal.throwIfAborted();
return { ok: true };
} catch (error) {
subscription.controller.abort();
subscription.dispose?.();
if (this.pageEventSubscriptions.get(params.subscriptionId) === subscription) {
this.pageEventSubscriptions.delete(params.subscriptionId);
}
throw error;
}
}

pageOff(params: PageOffParams): PageVoidResult {
const subscription = this.pageEventSubscriptions.get(params.subscriptionId);
if (!subscription) return { ok: true };
subscription.dispose();
subscription.controller.abort();
subscription.dispose?.();
this.pageEventSubscriptions.delete(params.subscriptionId);
return { ok: true };
}
Expand Down Expand Up @@ -870,6 +912,8 @@ export class StagehandRuntime {

this.stagehandInstanceClosing = true;
const disposal = this.enqueueLifecycle(async () => {
// Pending registrations must release their request leases before disposal can drain them.
this.disposeAllPageEventSubscriptions();
await this.waitForStagehandInstanceRequests();
this.clearStagehandInstance();
});
Expand Down Expand Up @@ -975,17 +1019,17 @@ export class StagehandRuntime {
}
}

private disposePageEventSubscriptions(pageId: string): void {
private disposePageEventSubscriptions(pageId: string, pendingOnly = false): void {
for (const [subscriptionId, subscription] of this.pageEventSubscriptions) {
if (subscription.pageId !== pageId) continue;
subscription.dispose();
this.pageEventSubscriptions.delete(subscriptionId);
if (pendingOnly && subscription.dispose) continue;
this.pageOff({ subscriptionId });
}
}

private disposeAllPageEventSubscriptions(): void {
for (const subscription of this.pageEventSubscriptions.values()) subscription.dispose();
this.pageEventSubscriptions.clear();
for (const subscriptionId of this.pageEventSubscriptions.keys())
this.pageOff({ subscriptionId });
}

registerPage(page: UnderstudyRuntimePage): string {
Expand Down
9 changes: 9 additions & 0 deletions packages/extension/service-worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,15 @@ export function startStagehandServiceWorker(
console.error("[stagehand] Failed to emit page CDP event notification", error);
});
},
emitPageEvent: (notification) => {
void rpcClient
?.notify(StagehandNotifications.pageEvent, notification)
.catch((error: unknown) => {
// This notification cannot safely report its own delivery failure over JSON-RPC.
// oxlint-disable-next-line no-console
console.error("[stagehand] Failed to emit page event notification", error);
});
},
clientLLMGenerate: async (params) => {
if (!rpcClient) throw new Error("Stagehand RPC client is not connected");
return await rpcClient.send(StagehandMethods.llmGenerate, params);
Expand Down
14 changes: 7 additions & 7 deletions packages/extension/tests/page-cdp-events.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,13 +45,13 @@ function createPage(
}

describe("Page CDP event subscriptions", () => {
it("covers the main session plus current and future OOPIF sessions", () => {
it("covers the main session plus current and future OOPIF sessions", async () => {
const main = new FakeCDPSession("main");
const child = new FakeCDPSession("child");
const page = createPage(main);
const events: unknown[] = [];

const unsubscribe = page.subscribeCDPEvent("console", (event) => {
const unsubscribe = await page.subscribeCDPEvent("console", (event) => {
events.push(event);
});
main.emit("Runtime.consoleAPICalled", { type: "log", args: [] });
Expand Down Expand Up @@ -81,29 +81,29 @@ describe("Page CDP event subscriptions", () => {
expect(main.listenerCount("Runtime.consoleAPICalled")).toBe(0);
});

it("removes every raw listener when the page is disposed", () => {
it("removes every raw listener when the page is disposed", async () => {
const main = new FakeCDPSession("main");
const child = new FakeCDPSession("child");
const page = createPage(main);

page.adoptOopifSession(child, "frame-child");
page.subscribeCDPEvent("console", () => {});
await page.subscribeCDPEvent("console", () => {});
page.dispose();

expect(main.listenerCount("Runtime.consoleAPICalled")).toBe(0);
expect(child.listenerCount("Runtime.consoleAPICalled")).toBe(0);
});

it("isolates listener failures so other subscriptions still receive the event", () => {
it("isolates listener failures so other subscriptions still receive the event", async () => {
const main = new FakeCDPSession("main");
const logError = vi.fn();
const page = createPage(main, { error: logError } as unknown as StagehandLogger);
const events: PageCDPEvent[] = [];

page.subscribeCDPEvent("console", () => {
await page.subscribeCDPEvent("console", () => {
throw new Error("listener failed");
});
page.subscribeCDPEvent("console", (event) => events.push(event));
await page.subscribeCDPEvent("console", (event) => events.push(event));

expect(() => main.emit("Runtime.consoleAPICalled", { type: "log", args: [] })).not.toThrow();
expect(events).toHaveLength(1);
Expand Down
9 changes: 8 additions & 1 deletion packages/extension/tests/page-webmcp-invocations.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,8 @@ describe("Page WebMCP invocation lifecycle", () => {
},
]);
expect(session.listenerCount("WebMCP.toolResponded")).toBe(1);
await page.listWebMCPTools({ timeout: 0 });
expect(session.callsFor("WebMCP.enable")).toHaveLength(1);
});

it("routes an OOPIF invocation, response, and cancellation through its child session", async () => {
Expand Down Expand Up @@ -464,20 +466,25 @@ describe("Page WebMCP invocation lifecycle", () => {

it("does not register an invocation whose child session detached during the command", async () => {
let resolveInvocation!: (response: Protocol.WebMCP.InvokeToolResponse) => void;
let markCommandSent!: () => void;
const commandSent = new Promise<void>((resolve) => {
markCommandSent = resolve;
});
const session = new FakeCDPSession();
const childSession = new FakeCDPSession(
{
"WebMCP.invokeTool": () =>
new Promise<Protocol.WebMCP.InvokeToolResponse>((resolve) => {
resolveInvocation = resolve;
markCommandSent();
}),
},
"child",
);
const page = createPage(session);
adoptChildSession(page, childSession);
const invocation = page.invokeWebMCPTool("frame-2", "child");
await Promise.resolve();
await commandSent;

page.detachOopifSession("child");
resolveInvocation({ invocationId: "orphaned-invocation" });
Expand Down
Loading
Loading