Skip to content

Commit 384d7da

Browse files
committed
fix(browser): recover oversized screenshot context
1 parent 0f9f56b commit 384d7da

5 files changed

Lines changed: 79 additions & 16 deletions

File tree

packages/bcode-browser/src/browser-execute.ts

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@
4343
import fs from "fs/promises"
4444
import path from "path"
4545
import { Effect, Schema } from "effect"
46+
import type { Page } from "./cdp/generated"
4647
import { SessionStore } from "./session-store"
4748
import { Skills } from "./skills"
4849

@@ -184,6 +185,30 @@ export const make = Effect.fn("BrowserExecute.make")(function* (dataDir: string)
184185
debug: tee,
185186
})
186187

188+
// BrowserCode extension to CDP's screenshot params. The wrapper strips
189+
// this field before the command reaches Chrome; false keeps the returned
190+
// base64 available to the snippet without attaching it to model context.
191+
type ScreenshotParams = Page.CaptureScreenshotParams & {
192+
readonly attachToContext?: boolean
193+
}
194+
const localOnlyScreenshotParams = new WeakSet<object>()
195+
const page = Object.assign(Object.create(session.domains.Page), {
196+
captureScreenshot: (params: ScreenshotParams = {}) => {
197+
const { attachToContext, ...cdpParams } = params
198+
if (attachToContext === false) localOnlyScreenshotParams.add(cdpParams)
199+
return session.domains.Page.captureScreenshot(cdpParams)
200+
},
201+
})
202+
const domains = Object.assign(Object.create(session.domains), { Page: page })
203+
const snippetSession = new Proxy(session, {
204+
get(target, property) {
205+
if (property === "Page") return page
206+
if (property === "domains") return domains
207+
const value = Reflect.get(target, property, target)
208+
return typeof value === "function" ? value.bind(target) : value
209+
},
210+
})
211+
187212
// Screenshot tap. Subscribes to the Session's call-result stream for
188213
// the duration of this execute() call; every successful
189214
// `Page.captureScreenshot` is collected (drained into `attachments[]`
@@ -209,7 +234,7 @@ export const make = Effect.fn("BrowserExecute.make")(function* (dataDir: string)
209234
const mime = screenshotMime(p.format)
210235
const ext = screenshotExt(p.format)
211236
const idx = seq++
212-
screenshots.push({ mime, base64: r.data })
237+
if (!localOnlyScreenshotParams.has(p)) screenshots.push({ mime, base64: r.data })
213238
if (dumpDir) {
214239
const filename = `${ctx.sessionID}-${startedAt}-${String(idx).padStart(3, "0")}.${ext}`
215240
fs.mkdir(dumpDir, { recursive: true })
@@ -219,7 +244,7 @@ export const make = Effect.fn("BrowserExecute.make")(function* (dataDir: string)
219244
})
220245

221246
const ran = yield* Effect.tryPromise({
222-
try: () => wrapped(session, snippetConsole),
247+
try: () => wrapped(snippetSession, snippetConsole),
223248
catch: (err) => new Error(`browser_execute snippet threw: ${err instanceof Error ? err.stack ?? err.message : String(err)}`),
224249
}).pipe(Effect.ensuring(Effect.sync(() => unsubscribe())))
225250

packages/bcode-browser/test/browser-execute.test.ts

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -117,21 +117,22 @@ test.skipIf(!enabled)("workspace import inside a snippet", async () => {
117117
expect(JSON.parse(result.result)).toBe("bcode-be")
118118
})
119119

120-
test.skipIf(!enabled)("Page.captureScreenshot is collected into result.screenshots", async () => {
120+
test.skipIf(!enabled)("Page.captureScreenshot can stay out of model context", async () => {
121121
const result = await Effect.runPromise(
122122
Effect.scoped(
123123
Effect.gen(function* () {
124124
const impl = yield* BrowserExecute.make(dataDir)
125125
return yield* impl.execute(
126126
{
127-
description: "Capture two screenshots",
127+
description: "Capture context screenshots",
128128
code: `await session.Page.enable();
129129
const loaded = session.waitFor("Page.loadEventFired", { timeoutMs: 5000 });
130130
await session.Page.navigate({ url: "data:text/html,<title>shot</title><body>hi" });
131131
await loaded;
132132
const a = await session.Page.captureScreenshot({ format: "png" });
133133
const b = await session.Page.captureScreenshot({ format: "jpeg", quality: 50 });
134-
return { aLen: a.data.length, bLen: b.data.length };`,
134+
const local = await session.Page.captureScreenshot({ format: "webp", attachToContext: false });
135+
return { aLen: a.data.length, bLen: b.data.length, localLen: local.data.length };`,
135136
},
136137
{ sessionID, workspaceDir },
137138
)
@@ -141,7 +142,10 @@ test.skipIf(!enabled)("Page.captureScreenshot is collected into result.screensho
141142
expect(result.screenshots).toHaveLength(2)
142143
expect(result.screenshots[0]!.mime).toBe("image/png")
143144
expect(result.screenshots[1]!.mime).toBe("image/jpeg")
144-
// base64 must round-trip back to non-empty bytes for both shots.
145+
// The local-only screenshot still returned data to the snippet but was not
146+
// collected into model-context attachments.
147+
expect(JSON.parse(result.result).localLen).toBeGreaterThan(0)
148+
// Attached base64 must round-trip back to non-empty bytes for both shots.
145149
expect(Buffer.from(result.screenshots[0]!.base64, "base64").length).toBeGreaterThan(0)
146150
expect(Buffer.from(result.screenshots[1]!.base64, "base64").length).toBeGreaterThan(0)
147151
})
@@ -151,20 +155,21 @@ test.skipIf(!enabled)("BCODE_SCREENSHOT_DIR dumps screenshots to disk", async ()
151155
const prev = process.env.BCODE_SCREENSHOT_DIR
152156
process.env.BCODE_SCREENSHOT_DIR = dump
153157
try {
154-
await Effect.runPromise(
158+
const result = await Effect.runPromise(
155159
Effect.scoped(
156160
Effect.gen(function* () {
157161
const impl = yield* BrowserExecute.make(dataDir)
158162
return yield* impl.execute(
159163
{
160164
description: "Dump screenshot to disk",
161-
code: `await session.Page.captureScreenshot({ format: "png" });`,
165+
code: `await session.Page.captureScreenshot({ format: "png", attachToContext: false });`,
162166
},
163167
{ sessionID, workspaceDir },
164168
)
165169
}),
166170
),
167171
)
172+
expect(result.screenshots).toHaveLength(0)
168173
// Disk dump is fire-and-forget; give it a tick to land.
169174
await new Promise((r) => setTimeout(r, 150))
170175
const files = await fs.readdir(dump)

packages/opencode/src/cli/cmd/run.ts

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -702,14 +702,15 @@ export const RunCommand = effectCmd({
702702
if (
703703
event.type === "message.updated" &&
704704
event.properties.sessionID === sessionID &&
705-
event.properties.info.role === "assistant" &&
706-
args.format !== "json" &&
707-
toggles.get("start") !== true
705+
event.properties.info.role === "assistant"
708706
) {
709-
UI.empty()
710-
UI.println(`> ${event.properties.info.agent} · ${event.properties.info.modelID}`)
711-
UI.empty()
712-
toggles.set("start", true)
707+
if (event.properties.info.finish && !event.properties.info.error) error = undefined
708+
if (args.format !== "json" && toggles.get("start") !== true) {
709+
UI.empty()
710+
UI.println(`> ${event.properties.info.agent} · ${event.properties.info.modelID}`)
711+
UI.empty()
712+
toggles.set("start", true)
713+
}
713714
}
714715

715716
if (event.type === "message.part.updated") {

packages/opencode/src/tool/browser-execute.txt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,4 +4,4 @@ Usage:
44
- Use this tool whenever the task requires driving a real browser.
55
- Use this tool to read webpages that block the webfetch tool.
66
- 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.
7-
- Returns console output from the snippet; screenshots taken attach automatically as images.
7+
- Returns console output from the snippet; screenshots attach automatically unless `Page.captureScreenshot({ attachToContext: false })` keeps them local.

packages/opencode/test/cli/run/run-process.test.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -241,6 +241,38 @@ describe("opencode run (non-interactive subprocess)", () => {
241241
60_000,
242242
)
243243

244+
cliIt.concurrent(
245+
"exits 0 when compaction recovers a provider size error",
246+
({ llm, opencode }) =>
247+
Effect.gen(function* () {
248+
yield* llm.error(413, {
249+
error: { type: "request_too_large", message: "Request exceeds the maximum size" },
250+
})
251+
yield* llm.text("compacted history")
252+
yield* llm.text("recovered output")
253+
254+
const result = yield* opencode.run("recover after overflow", {
255+
format: "json",
256+
env: { OPENCODE_DISABLE_AUTOCOMPACT: "0" },
257+
})
258+
259+
opencode.expectExit(result, 0)
260+
const events = opencode.parseJsonEvents(result.stdout)
261+
expect(events.some((event) => event.type === "error")).toBe(true)
262+
expect(
263+
events.some(
264+
(event) =>
265+
event.type === "text" &&
266+
typeof event.part === "object" &&
267+
event.part !== null &&
268+
"text" in event.part &&
269+
event.part.text === "recovered output",
270+
),
271+
).toBe(true)
272+
}),
273+
60_000,
274+
)
275+
244276
cliIt.concurrent(
245277
"rejects requested permissions by default and allows them with the dangerous flag",
246278
({ home, llm, opencode }) =>

0 commit comments

Comments
 (0)