From 2543d5933d1f50a995881bf1495c8937c7bb907f Mon Sep 17 00:00:00 2001 From: MagMueller Date: Tue, 28 Jul 2026 11:53:38 -0700 Subject: [PATCH] feat(browser): support local-only screenshots --- packages/bcode-browser/src/browser-execute.ts | 29 +++++++++++++++++-- .../test/browser-execute.test.ts | 17 +++++++---- .../opencode/src/tool/browser-execute.txt | 2 +- 3 files changed, 39 insertions(+), 9 deletions(-) diff --git a/packages/bcode-browser/src/browser-execute.ts b/packages/bcode-browser/src/browser-execute.ts index 230bacbd99..0d57d91363 100644 --- a/packages/bcode-browser/src/browser-execute.ts +++ b/packages/bcode-browser/src/browser-execute.ts @@ -43,6 +43,7 @@ import fs from "fs/promises" import path from "path" import { Effect, Schema } from "effect" +import type { Page } from "./cdp/generated" import { SessionStore } from "./session-store" import { Skills } from "./skills" @@ -184,6 +185,30 @@ export const make = Effect.fn("BrowserExecute.make")(function* (dataDir: string) debug: tee, }) + // BrowserCode extension to CDP's screenshot params. The wrapper strips + // this field before the command reaches Chrome; false keeps the returned + // base64 available to the snippet without attaching it to model context. + type ScreenshotParams = Page.CaptureScreenshotParams & { + readonly attachToContext?: boolean + } + const localOnlyScreenshotParams = new WeakSet() + const page = Object.assign(Object.create(session.domains.Page), { + captureScreenshot: (params: ScreenshotParams = {}) => { + const { attachToContext, ...cdpParams } = params + if (attachToContext === false) localOnlyScreenshotParams.add(cdpParams) + return session.domains.Page.captureScreenshot(cdpParams) + }, + }) + const domains = Object.assign(Object.create(session.domains), { Page: page }) + const snippetSession = new Proxy(session, { + get(target, property) { + if (property === "Page") return page + if (property === "domains") return domains + const value = Reflect.get(target, property, target) + return typeof value === "function" ? value.bind(target) : value + }, + }) + // Screenshot tap. Subscribes to the Session's call-result stream for // the duration of this execute() call; every successful // `Page.captureScreenshot` is collected (drained into `attachments[]` @@ -209,7 +234,7 @@ export const make = Effect.fn("BrowserExecute.make")(function* (dataDir: string) const mime = screenshotMime(p.format) const ext = screenshotExt(p.format) const idx = seq++ - screenshots.push({ mime, base64: r.data }) + if (!localOnlyScreenshotParams.has(p)) screenshots.push({ mime, base64: r.data }) if (dumpDir) { const filename = `${ctx.sessionID}-${startedAt}-${String(idx).padStart(3, "0")}.${ext}` fs.mkdir(dumpDir, { recursive: true }) @@ -219,7 +244,7 @@ export const make = Effect.fn("BrowserExecute.make")(function* (dataDir: string) }) const ran = yield* Effect.tryPromise({ - try: () => wrapped(session, snippetConsole), + try: () => wrapped(snippetSession, snippetConsole), catch: (err) => new Error(`browser_execute snippet threw: ${err instanceof Error ? err.stack ?? err.message : String(err)}`), }).pipe(Effect.ensuring(Effect.sync(() => unsubscribe()))) diff --git a/packages/bcode-browser/test/browser-execute.test.ts b/packages/bcode-browser/test/browser-execute.test.ts index 5b1ca9eb49..3d61e18fbf 100644 --- a/packages/bcode-browser/test/browser-execute.test.ts +++ b/packages/bcode-browser/test/browser-execute.test.ts @@ -117,21 +117,22 @@ test.skipIf(!enabled)("workspace import inside a snippet", async () => { expect(JSON.parse(result.result)).toBe("bcode-be") }) -test.skipIf(!enabled)("Page.captureScreenshot is collected into result.screenshots", async () => { +test.skipIf(!enabled)("Page.captureScreenshot can stay out of model context", async () => { const result = await Effect.runPromise( Effect.scoped( Effect.gen(function* () { const impl = yield* BrowserExecute.make(dataDir) return yield* impl.execute( { - description: "Capture two screenshots", + description: "Capture context screenshots", code: `await session.Page.enable(); const loaded = session.waitFor("Page.loadEventFired", { timeoutMs: 5000 }); await session.Page.navigate({ url: "data:text/html,shothi" }); await loaded; const a = await session.Page.captureScreenshot({ format: "png" }); const b = await session.Page.captureScreenshot({ format: "jpeg", quality: 50 }); - return { aLen: a.data.length, bLen: b.data.length };`, + const local = await session.Page.captureScreenshot({ format: "webp", attachToContext: false }); + return { aLen: a.data.length, bLen: b.data.length, localLen: local.data.length };`, }, { sessionID, workspaceDir }, ) @@ -141,7 +142,10 @@ test.skipIf(!enabled)("Page.captureScreenshot is collected into result.screensho expect(result.screenshots).toHaveLength(2) expect(result.screenshots[0]!.mime).toBe("image/png") expect(result.screenshots[1]!.mime).toBe("image/jpeg") - // base64 must round-trip back to non-empty bytes for both shots. + // The local-only screenshot still returned data to the snippet but was not + // collected into model-context attachments. + expect(JSON.parse(result.result).localLen).toBeGreaterThan(0) + // Attached base64 must round-trip back to non-empty bytes for both shots. expect(Buffer.from(result.screenshots[0]!.base64, "base64").length).toBeGreaterThan(0) expect(Buffer.from(result.screenshots[1]!.base64, "base64").length).toBeGreaterThan(0) }) @@ -151,20 +155,21 @@ test.skipIf(!enabled)("BCODE_SCREENSHOT_DIR dumps screenshots to disk", async () const prev = process.env.BCODE_SCREENSHOT_DIR process.env.BCODE_SCREENSHOT_DIR = dump try { - await Effect.runPromise( + const result = await Effect.runPromise( Effect.scoped( Effect.gen(function* () { const impl = yield* BrowserExecute.make(dataDir) return yield* impl.execute( { description: "Dump screenshot to disk", - code: `await session.Page.captureScreenshot({ format: "png" });`, + code: `await session.Page.captureScreenshot({ format: "png", attachToContext: false });`, }, { sessionID, workspaceDir }, ) }), ), ) + expect(result.screenshots).toHaveLength(0) // Disk dump is fire-and-forget; give it a tick to land. await new Promise((r) => setTimeout(r, 150)) const files = await fs.readdir(dump) diff --git a/packages/opencode/src/tool/browser-execute.txt b/packages/opencode/src/tool/browser-execute.txt index 3a11ae1745..2e52458e3a 100644 --- a/packages/opencode/src/tool/browser-execute.txt +++ b/packages/opencode/src/tool/browser-execute.txt @@ -4,4 +4,4 @@ Usage: - Use this tool whenever the task requires driving a real browser. - Use this tool to read webpages that block the webfetch tool. - IMPORTANT: you MUST use the skill tool first to load the `browser-execute` skill. This tool will fail if you did not read those directions first. -- Returns console output from the snippet; screenshots taken attach automatically as images. \ No newline at end of file +- Screenshots attach automatically; `Page.captureScreenshot({ attachToContext: false })` returns one only to the snippet and does not save it.