diff --git a/packages/docs/tests/sdk-reference.test.ts b/packages/docs/tests/sdk-reference.test.ts index 6643123829..4b16756541 100644 --- a/packages/docs/tests/sdk-reference.test.ts +++ b/packages/docs/tests/sdk-reference.test.ts @@ -1285,26 +1285,37 @@ describe("Mintlify customization boundary", () => { ).toEqual([]); }); - it("includes every MDX content page in docs.json navigation", async () => { + it("includes every indexable MDX content page in docs.json navigation", async () => { const docsConfig = JSON.parse( await readFile(resolve(DOCS_ROOT, "docs.json"), "utf8"), ) as unknown; const navigatedPages = [...collectNavigationPages(docsConfig)] .filter((page) => page.startsWith("v4/")) .sort(); - const contentPages = (await listFiles(V4_DOCS_ROOT, shouldInspectDocsDirectory)) - .filter((filePath) => extname(filePath) === ".mdx") - .map((filePath) => - relative(DOCS_ROOT, filePath) - .split(sep) - .join("/") - .replace(/\.mdx$/u, ""), + const contentPages = ( + await Promise.all( + ( + await listFiles(V4_DOCS_ROOT, shouldInspectDocsDirectory) + ) + .filter((filePath) => extname(filePath) === ".mdx") + .map(async (filePath) => { + const source = await readFile(filePath, "utf8"); + const frontmatter = /^---\r?\n([\s\S]*?)\r?\n---/u.exec(source)?.[1] ?? ""; + if (/^noindex:\s*true\s*$/mu.test(frontmatter)) return undefined; + return relative(DOCS_ROOT, filePath) + .split(sep) + .join("/") + .replace(/\.mdx$/u, ""); + }), ) + ) + .filter((page): page is string => page !== undefined) .sort(); - expect(navigatedPages, "Every MDX content page must be reachable from docs.json").toStrictEqual( - contentPages, - ); + expect( + navigatedPages, + "Every indexable MDX content page must be reachable from docs.json", + ).toStrictEqual(contentPages); }); it("gives every language tab group the same complete language set", async () => { diff --git a/packages/integrations/core/package.json b/packages/integrations/core/package.json index 895932a846..0fadf437bb 100644 --- a/packages/integrations/core/package.json +++ b/packages/integrations/core/package.json @@ -28,6 +28,7 @@ "typecheck": "tsc --noEmit -p tsconfig.json" }, "dependencies": { + "@browserbasehq/sdk": "catalog:", "@browserbasehq/stagehand": "workspace:*", "@modelcontextprotocol/sdk": "catalog:", "zod": "catalog:" diff --git a/packages/integrations/core/src/facade/contract.ts b/packages/integrations/core/src/facade/contract.ts index 97abd1a13f..58ec221606 100644 --- a/packages/integrations/core/src/facade/contract.ts +++ b/packages/integrations/core/src/facade/contract.ts @@ -127,7 +127,7 @@ export const FACADE_AGENT_INSTRUCTIONS = `You control one persistent browser thr - run: provide either snapshot actions or JavaScript using the Playwright-shaped page API. - screenshot: inspect the rendered page visually. -Use snapshot actions for simple interactions and run code for multi-step workflows. Pass run exactly one of code or actions; every action uses "op" and "id", never "kind" or "ref". Snapshot IDs are valid only for the latest snapshot of the active page; snapshot again after navigation or stale IDs. Do not launch another browser.`; +Use snapshot actions for simple interactions and run code for multi-step workflows. Pass run exactly one of code or actions; every action uses "op" and "id", never "kind" or "ref". Snapshot IDs are valid only for the latest snapshot of the active page; snapshot again after navigation or stale IDs. Do not launch another browser. Before finishing, close the owned browser session with \`await browser.close()\`. \`page.close()\` only closes the active page.`; export const NO_HYDRATED_SNAPSHOT_ERROR = "No hydrated snapshot exists for the active page; call snapshot first."; diff --git a/packages/integrations/core/src/facade/index.ts b/packages/integrations/core/src/facade/index.ts index 6bb6f38877..e34fef8b86 100644 --- a/packages/integrations/core/src/facade/index.ts +++ b/packages/integrations/core/src/facade/index.ts @@ -18,9 +18,18 @@ export { type RefAction, type CodeModeRunInput, } from "./contract.js"; -export { StagehandFacadeTools } from "./tools.js"; +export { + StagehandFacadeLifecycleError, + StagehandFacadeTools, + type StagehandFacadeLifecycle, +} from "./tools.js"; export { StagehandFacadeConfigError, stagehandFacadeConfigFromEnv, type StagehandFacadeConfig, } from "./config.js"; +export { + BrowserbaseSessionReleaseError, + releaseBrowserbaseSession, + type BrowserbaseSessionRelease, +} from "./session-release.js"; diff --git a/packages/integrations/core/src/facade/runtime.ts b/packages/integrations/core/src/facade/runtime.ts index 03242d1cab..0dd91ac6a5 100644 --- a/packages/integrations/core/src/facade/runtime.ts +++ b/packages/integrations/core/src/facade/runtime.ts @@ -1938,6 +1938,11 @@ export async function createPlaywrightCompatRuntime( record("calls", "context.newPage"); return await createPage(await rawContext.newPage()); }, + browser: () => browser, + close: async () => { + record("calls", "context.close"); + closeRequested = true; + }, cookies: (urls?: string | string[]) => { record("calls", "context.cookies"); return rawContext.cookies(urls); diff --git a/packages/integrations/core/src/facade/session-release.ts b/packages/integrations/core/src/facade/session-release.ts new file mode 100644 index 0000000000..652c19ffbd --- /dev/null +++ b/packages/integrations/core/src/facade/session-release.ts @@ -0,0 +1,47 @@ +import Browserbase from "@browserbasehq/sdk"; + +export type BrowserbaseSessionRelease = { + apiKey: string; + baseUrl?: string; + sessionId: string; +}; + +const BROWSERBASE_API_URL = "https://api.browserbase.com"; +const SESSION_RELEASE_MAX_RETRIES = 2; +const SESSION_RELEASE_TIMEOUT_MS = 10_000; + +export class BrowserbaseSessionReleaseError extends Error { + override readonly name = "BrowserbaseSessionReleaseError"; + + constructor() { + super("Failed to release the Browserbase session."); + } +} + +export async function releaseBrowserbaseSession(session: BrowserbaseSessionRelease): Promise { + const browserbase = new Browserbase({ + apiKey: session.apiKey, + baseURL: (session.baseUrl ?? BROWSERBASE_API_URL).replace(/\/+$/u, ""), + maxRetries: SESSION_RELEASE_MAX_RETRIES, + timeout: SESSION_RELEASE_TIMEOUT_MS, + }); + // The generated SDK currently interpolates path parameters without encoding + // them. Keep the server-issued ID confined to one URL path segment. + const sessionId = encodeURIComponent(session.sessionId); + + try { + await browserbase.sessions.update(sessionId, { status: "REQUEST_RELEASE" }); + return; + } catch { + // Verify the remote state below before reporting a failed retry. + } + + try { + const remoteSession = await browserbase.sessions.retrieve(sessionId); + if (remoteSession.status === "COMPLETED") return; + } catch { + // Fall through to the stable lifecycle error below. + } + + throw new BrowserbaseSessionReleaseError(); +} diff --git a/packages/integrations/core/src/facade/stdio-server.ts b/packages/integrations/core/src/facade/stdio-server.ts index 3d0c782203..69f01a54c0 100644 --- a/packages/integrations/core/src/facade/stdio-server.ts +++ b/packages/integrations/core/src/facade/stdio-server.ts @@ -23,17 +23,22 @@ import { captureScreenshotWithinBase64Budget, screenshotBase64BudgetFromArgs, } from "./screenshot-transport.js"; +import { releaseBrowserbaseSession } from "./session-release.js"; import { StagehandFacadeTools } from "./tools.js"; type FacadeResources = { browser: StagehandBrowser; stagehand: Stagehand; tools: StagehandFacadeTools; + releaseSession?: () => Promise; }; const server = new McpServer({ name: "stagehand-facade", version: "4.0.0" }); const screenshotBase64Budget = screenshotBase64BudgetFromArgs(process.argv.slice(2)); +let resources: FacadeResources | undefined; let resourcesPromise: Promise | undefined; +let cleanupPromise: Promise = Promise.resolve(); +const cleanupTargets = new Set<() => Promise>(); let closing = false; server.registerTool( @@ -109,11 +114,17 @@ server.server.setRequestHandler(CallToolRequestSchema, async (request) => { }); async function ensureResources(): Promise { - resourcesPromise ??= createResources().catch((error) => { - resourcesPromise = undefined; - throw error; - }); - return await resourcesPromise; + if (resources && !resources.browser.closed) return resources; + + resourcesPromise ??= cleanupPromise.then(retryCleanupTargets).then(createResources); + const pending = resourcesPromise; + try { + const created = await pending; + if (resourcesPromise === pending) resources = created; + return created; + } finally { + if (resourcesPromise === pending) resourcesPromise = undefined; + } } async function createResources(): Promise { @@ -122,12 +133,88 @@ async function createResources(): Promise { config.browser.type === "browserbase" ? await browserbase.launch(config.browser.launchOptions) : await localBrowser.launch(config.browser.launchOptions); + const sessionId = browser.sessionId; + let releaseSession: (() => Promise) | undefined; + if (config.browser.type === "browserbase" && sessionId) { + const { apiKey, baseUrl } = config.browser.launchOptions; + releaseSession = () => releaseBrowserbaseSession({ apiKey, baseUrl, sessionId }); + } try { const stagehand = await Stagehand.create({ browser, ...config.stagehand }); - return { browser, stagehand, tools: new StagehandFacadeTools(stagehand) }; + let tools: StagehandFacadeTools; + tools = new StagehandFacadeTools(stagehand, { + close: () => closeRequestedResources(tools), + }); + return { browser, stagehand, tools, ...(releaseSession ? { releaseSession } : {}) }; } catch (error) { - await browser.close().catch(() => undefined); - throw error; + const cleanupErrors: unknown[] = [error]; + let browserCloseFailed = false; + await browser.close().catch((cleanupError) => { + browserCloseFailed = true; + cleanupErrors.push(cleanupError); + }); + if (browserCloseFailed && releaseSession) { + await releaseSession().catch((releaseError) => { + cleanupTargets.add(releaseSession); + cleanupErrors.push(releaseError); + }); + } + if (cleanupErrors.length === 1) throw error; + throw new AggregateError( + cleanupErrors, + "Stagehand initialization failed and browser cleanup also failed.", + { cause: error }, + ); + } +} + +async function closeRequestedResources(expected: StagehandFacadeTools): Promise { + const current = resources; + if (!current || current.tools !== expected) return; + + resources = undefined; + const closeResult = cleanupPromise.then(() => closeResources(current)); + cleanupPromise = closeResult.then( + () => undefined, + () => undefined, + ); + await closeResult; +} + +async function closeResources(current: FacadeResources): Promise { + const cleanupErrors: unknown[] = []; + let browserCloseFailed = false; + await current.stagehand.close().catch((error) => cleanupErrors.push(error)); + await current.browser.close().catch((error) => { + browserCloseFailed = true; + cleanupErrors.push(error); + }); + if (browserCloseFailed && current.releaseSession) { + cleanupTargets.add(current.releaseSession); + } else if (current.releaseSession) { + cleanupTargets.delete(current.releaseSession); + } + if (cleanupErrors.length === 1) throw cleanupErrors[0]; + if (cleanupErrors.length > 1) { + throw new AggregateError(cleanupErrors, "Failed to close the browser session cleanly."); + } +} + +async function retryCleanupTargets(): Promise { + for (const releaseSession of cleanupTargets) { + await releaseSession(); + cleanupTargets.delete(releaseSession); + } +} + +async function closeResourcesForShutdown(current: FacadeResources): Promise { + try { + await closeResources(current); + } finally { + if (current.releaseSession && cleanupTargets.has(current.releaseSession)) { + await current.releaseSession(); + cleanupTargets.delete(current.releaseSession); + } } } @@ -161,22 +248,25 @@ export function sanitizeErrorMessage(message: string): string { async function shutdown(code: number): Promise { if (closing) return; closing = true; - // A launch still in flight must not stall shutdown past the grace window. - const resources = await Promise.race([ - resourcesPromise?.catch(() => undefined), + // Wait for a launch and any explicit close already in flight. Failed close + // targets remain tracked so shutdown can make one final best-effort attempt. + const launched = await Promise.race([ + (async () => { + const pending = await resourcesPromise?.catch(() => undefined); + await cleanupPromise; + return pending; + })(), new Promise((resolve) => setTimeout(resolve, 5_000)), ]); + const retryTargets = new Set(cleanupTargets); + const activeTargets = new Set(); + if (resources) activeTargets.add(resources); + if (launched) activeTargets.add(launched); const clean = await closeCodeModeStdio([ - ...(resources - ? [ - { - close: async () => { - await resources.stagehand.close().catch(() => undefined); - await resources.browser.close(); - }, - }, - ] - : []), + ...[...retryTargets].map((releaseSession) => ({ close: releaseSession })), + ...[...activeTargets].map((target) => ({ + close: () => closeResourcesForShutdown(target), + })), server, ]); if (!clean) process.stderr.write("Failed to close Stagehand facade cleanly.\n"); diff --git a/packages/integrations/core/src/facade/tools.ts b/packages/integrations/core/src/facade/tools.ts index e291407d3f..59ad1ae891 100644 --- a/packages/integrations/core/src/facade/tools.ts +++ b/packages/integrations/core/src/facade/tools.ts @@ -14,9 +14,22 @@ type ActionResult = { completed: number }; type RunEnvelope = { __stagehandPlaywrightCompat: true; value: unknown; + closeRequested: boolean; executionError?: { name: string; message: string; stack?: string }; }; +export type StagehandFacadeLifecycle = { + close(): Promise; +}; + +export class StagehandFacadeLifecycleError extends Error { + override readonly name = "StagehandFacadeLifecycleError"; + + constructor() { + super("Browser close was requested, but this facade host does not provide lifecycle cleanup."); + } +} + export type StagehandFacadeScreenshot = { data: string; mimeType: "image/png" | "image/jpeg"; @@ -74,9 +87,25 @@ const FACADE_EPILOGUE = ` ...(typeof error?.stack === "string" ? { stack: error.stack } : {}), }; } +if (!executionError) { + try { + // Preflight the user value before returning the envelope. experimentalBatch + // JSON-serializes its complete result; if that serialization throws, the + // host would otherwise lose closeRequested along with the value. + value = JSON.parse(JSON.stringify({ value })).value; + } catch (error) { + value = undefined; + executionError = { + name: "TypeError", + message: "Stagehand facade run result must be JSON-serializable", + ...(typeof error?.stack === "string" ? { stack: error.stack } : {}), + }; + } +} return { __stagehandPlaywrightCompat: true, value, + closeRequested: runtime.closeRequested(), executionError, };`; @@ -84,7 +113,10 @@ export class StagehandFacadeTools { private readonly snapshotsByPage = new Map(); private queue: Promise = Promise.resolve(); - constructor(private readonly stagehand: Stagehand) {} + constructor( + private readonly stagehand: Stagehand, + private readonly lifecycle?: StagehandFacadeLifecycle, + ) {} snapshot(options: { includeIframes?: boolean } = {}): Promise { return this.enqueue(() => this.snapshotNow(options)); @@ -171,12 +203,32 @@ export class StagehandFacadeTools { {}, { page, timeout: 60_000 }, ); + let closeError: unknown; + if (envelope.closeRequested) { + this.snapshotsByPage.clear(); + if (!this.lifecycle) { + closeError = new StagehandFacadeLifecycleError(); + } else { + try { + await this.lifecycle.close(); + } catch (error) { + closeError = error; + } + } + } if (envelope.executionError) { const error = new Error(envelope.executionError.message); error.name = envelope.executionError.name; if (envelope.executionError.stack) error.stack = envelope.executionError.stack; + if (closeError !== undefined) { + throw new AggregateError( + [error, closeError], + "Browser code failed and the browser session could not be closed.", + ); + } throw error; } + if (closeError !== undefined) throw closeError; return envelope.value; } diff --git a/packages/integrations/core/tests/facade-contract.test.ts b/packages/integrations/core/tests/facade-contract.test.ts index 05db01c772..265d88d0b7 100644 --- a/packages/integrations/core/tests/facade-contract.test.ts +++ b/packages/integrations/core/tests/facade-contract.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; import { CodeModeRunInputSchema, + FACADE_AGENT_INSTRUCTIONS, FACADE_TOOLS, NAVIGATED_SNAPSHOT_ERROR, NO_HYDRATED_SNAPSHOT_ERROR, @@ -33,6 +34,11 @@ describe("Stagehand facade contract", () => { expect(FACADE_TOOLS[2].description).toContain('{"type":"jpeg","quality":40,"fullPage":false}'); }); + it("documents host-owned browser cleanup", () => { + expect(FACADE_AGENT_INSTRUCTIONS).toContain("await browser.close()"); + expect(FACADE_AGENT_INSTRUCTIONS).toContain("`page.close()` only closes the active page"); + }); + it("pins snapshot error punctuation", () => { expect(NO_HYDRATED_SNAPSHOT_ERROR).toBe( "No hydrated snapshot exists for the active page; call snapshot first.", diff --git a/packages/integrations/core/tests/facade-lifecycle.test.ts b/packages/integrations/core/tests/facade-lifecycle.test.ts new file mode 100644 index 0000000000..d31c51a6cd --- /dev/null +++ b/packages/integrations/core/tests/facade-lifecycle.test.ts @@ -0,0 +1,151 @@ +import type { Stagehand } from "@browserbasehq/stagehand"; +import { describe, expect, it, vi } from "vitest"; +import { createPlaywrightCompatRuntime } from "../src/facade/runtime.js"; +import { StagehandFacadeLifecycleError, StagehandFacadeTools } from "../src/facade/tools.js"; + +describe("Stagehand facade lifecycle", () => { + it("maps browser and context close calls, but not page close, to a host close request", async () => { + const rawPage = { + pageId: "page-1", + url: vi.fn(async () => "https://example.com"), + evaluate: vi.fn(async (expression: unknown) => { + if (expression === "({ width: innerWidth, height: innerHeight })") { + return { width: 1280, height: 720 }; + } + return undefined; + }), + close: vi.fn(async () => undefined), + }; + const rawContext = { + pages: vi.fn(async () => [rawPage]), + newPage: vi.fn(async () => rawPage), + }; + const runtime = await createPlaywrightCompatRuntime({ + page: rawPage as never, + context: rawContext as never, + }); + const page = runtime.page as { + close(): Promise; + context(): { browser(): { close(): Promise }; close(): Promise }; + }; + const browser = runtime.browser as { isConnected(): boolean }; + + expect(runtime.closeRequested()).toBe(false); + await page.close(); + expect(runtime.closeRequested()).toBe(false); + + await page.context().browser().close(); + expect(runtime.closeRequested()).toBe(true); + expect(browser.isConnected()).toBe(false); + + const contextRuntime = await createPlaywrightCompatRuntime({ + page: rawPage as never, + context: rawContext as never, + }); + const contextPage = contextRuntime.page as { context(): { close(): Promise } }; + expect(contextRuntime.closeRequested()).toBe(false); + await contextPage.context().close(); + expect(contextRuntime.closeRequested()).toBe(true); + }); + + it("consumes a close request in the host before returning a value", async () => { + const events: string[] = []; + const stagehand = fakeStagehand({ + __stagehandPlaywrightCompat: true, + value: "done", + closeRequested: true, + }); + const tools = new StagehandFacadeTools(stagehand, { + close: vi.fn(async () => { + events.push("closed"); + }), + }); + + await expect(tools.run("return 'done';")).resolves.toBe("done"); + events.push("returned"); + + expect(events).toStrictEqual(["closed", "returned"]); + }); + + it("rejects close requests from a host that has not adopted lifecycle cleanup", async () => { + const stagehand = fakeStagehand({ + __stagehandPlaywrightCompat: true, + value: "closed", + closeRequested: true, + }); + const tools = new StagehandFacadeTools(stagehand); + + await expect(tools.run("await browser.close();")).rejects.toBeInstanceOf( + StagehandFacadeLifecycleError, + ); + }); + + it("closes before surfacing a browser-code error", async () => { + const close = vi.fn(async () => undefined); + const stagehand = fakeStagehand({ + __stagehandPlaywrightCompat: true, + value: undefined, + closeRequested: true, + executionError: { name: "RangeError", message: "model code failed" }, + }); + const tools = new StagehandFacadeTools(stagehand, { close }); + + await expect(tools.run("await browser.close(); throw new RangeError();")).rejects.toMatchObject( + { + name: "RangeError", + message: "model code failed", + }, + ); + expect(close).toHaveBeenCalledOnce(); + }); + + it("preserves close requests when the browser-code result cannot be serialized", async () => { + const rawPage = { + pageId: "page-serialization", + url: vi.fn(async () => "https://example.com"), + evaluate: vi.fn(async (expression: unknown) => { + if (expression === "({ width: innerWidth, height: innerHeight })") { + return { width: 1280, height: 720 }; + } + return undefined; + }), + }; + const rawContext = { + pages: vi.fn(async () => [rawPage]), + newPage: vi.fn(async () => rawPage), + }; + const close = vi.fn(async () => undefined); + const stagehand = { + browser: { + context: { + activePage: vi.fn(async () => rawPage), + }, + }, + experimentalBatch: vi.fn(async (callback: (...args: unknown[]) => Promise) => + callback({ page: rawPage, context: rawContext }, {}), + ), + } as unknown as Stagehand; + const tools = new StagehandFacadeTools(stagehand, { close }); + + await expect( + tools.run(` + await browser.close(); + const circular = {}; + circular.self = circular; + return circular; + `), + ).rejects.toThrow("Stagehand facade run result must be JSON-serializable"); + expect(close).toHaveBeenCalledOnce(); + }); +}); + +function fakeStagehand(envelope: Record): Stagehand { + return { + browser: { + context: { + activePage: vi.fn(async () => ({ pageId: "page-1" })), + }, + }, + experimentalBatch: vi.fn(async () => envelope), + } as unknown as Stagehand; +} diff --git a/packages/integrations/core/tests/session-release.test.ts b/packages/integrations/core/tests/session-release.test.ts new file mode 100644 index 0000000000..eabec40890 --- /dev/null +++ b/packages/integrations/core/tests/session-release.test.ts @@ -0,0 +1,93 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { + BrowserbaseSessionReleaseError, + releaseBrowserbaseSession, +} from "../src/facade/session-release.js"; + +const mocks = vi.hoisted(() => ({ + createClient: vi.fn(), + retrieve: vi.fn(), + update: vi.fn(), +})); + +vi.mock("@browserbasehq/sdk", () => ({ + default: class Browserbase { + readonly sessions = { + retrieve: mocks.retrieve, + update: mocks.update, + }; + + constructor(options: unknown) { + mocks.createClient(options); + } + }, +})); + +describe("Browserbase session release", () => { + beforeEach(() => { + mocks.createClient.mockReset(); + mocks.retrieve.mockReset(); + mocks.update.mockReset(); + }); + + it("uses a bounded Browserbase SDK client to request release", async () => { + mocks.update.mockResolvedValueOnce({ status: "COMPLETED" }); + + await expect( + releaseBrowserbaseSession({ apiKey: "test-key", sessionId: "session-one" }), + ).resolves.toBeUndefined(); + expect(mocks.createClient).toHaveBeenCalledWith({ + apiKey: "test-key", + baseURL: "https://api.browserbase.com", + maxRetries: 2, + timeout: 10_000, + }); + expect(mocks.update).toHaveBeenCalledWith("session-one", { + status: "REQUEST_RELEASE", + }); + expect(mocks.retrieve).not.toHaveBeenCalled(); + }); + + it("accepts an already-completed session after a failed release request", async () => { + mocks.update.mockRejectedValueOnce(new Error("network failed")); + mocks.retrieve.mockResolvedValueOnce({ status: "COMPLETED" }); + + await expect( + releaseBrowserbaseSession({ apiKey: "test-key", sessionId: "session-one" }), + ).resolves.toBeUndefined(); + expect(mocks.retrieve).toHaveBeenCalledWith("session-one"); + }); + + it("normalizes trailing slashes in a custom Browserbase API URL", async () => { + mocks.update.mockResolvedValueOnce({ status: "COMPLETED" }); + + await releaseBrowserbaseSession({ + apiKey: "test-key", + baseUrl: "https://api.example.test///", + sessionId: "session-one", + }); + + expect(mocks.createClient).toHaveBeenCalledWith( + expect.objectContaining({ baseURL: "https://api.example.test" }), + ); + }); + + it("reports a release that remains incomplete", async () => { + mocks.update.mockRejectedValueOnce(new Error("release failed")); + mocks.retrieve.mockResolvedValueOnce({ status: "RUNNING" }); + + await expect( + releaseBrowserbaseSession({ apiKey: "test-key", sessionId: "session-one" }), + ).rejects.toBeInstanceOf(BrowserbaseSessionReleaseError); + }); + + it("encodes the session ID as one URL path segment", async () => { + mocks.update.mockResolvedValueOnce({ status: "COMPLETED" }); + + await releaseBrowserbaseSession({ apiKey: "test-key", sessionId: "session/one?#" }); + + expect(mocks.update).toHaveBeenCalledWith("session%2Fone%3F%23", { + status: "REQUEST_RELEASE", + }); + }); +}); diff --git a/packages/integrations/eve/agent/instructions.md b/packages/integrations/eve/agent/instructions.md index fe795400cf..3f58498497 100644 --- a/packages/integrations/eve/agent/instructions.md +++ b/packages/integrations/eve/agent/instructions.md @@ -4,7 +4,4 @@ You control one persistent browser through exactly three tools: - run: provide either snapshot actions or JavaScript using the Playwright-shaped page API. - screenshot: inspect the rendered page visually. -Use snapshot actions for simple interactions and run code for multi-step workflows. -Pass run exactly one of code or actions; every action uses "op" and "id", never "kind" or "ref". -Snapshot IDs are valid only for the latest snapshot of the active page; snapshot again after navigation or stale IDs. -Do not launch another browser. To end a session you must run browser.close() to close the browser. +Use snapshot actions for simple interactions and run code for multi-step workflows. Pass run exactly one of code or actions; every action uses "op" and "id", never "kind" or "ref". Snapshot IDs are valid only for the latest snapshot of the active page; snapshot again after navigation or stale IDs. Do not launch another browser. Before finishing, close the owned browser session with `await browser.close()`. `page.close()` only closes the active page. diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 62ef422543..d04a29bab3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -628,6 +628,9 @@ importers: packages/integrations/core: dependencies: + '@browserbasehq/sdk': + specifier: 'catalog:' + version: 2.16.0 '@browserbasehq/stagehand': specifier: workspace:* version: link:../../sdk-ts