-
Notifications
You must be signed in to change notification settings - Fork 25
feat(app): add embedded-browser automation tools for the agent (#1186) #1212
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,60 @@ | ||
| import { expect, type Locator } from "@playwright/test" | ||
| import { fileURLToPath } from "node:url" | ||
| import { test } from "../fixtures" | ||
| import { composeGrid, snapOutputPath, type Shot } from "./_compose" | ||
|
|
||
| test.use({ viewport: { width: 520, height: 420 }, deviceScaleFactor: 2 }) | ||
|
|
||
| const LANGUAGE_KEY = "pawwork.global.dat:language" | ||
| const fixturePath = fileURLToPath(new URL("./fixtures/browser-tools-fixture.tsx", import.meta.url)) | ||
|
|
||
| async function captureBlock(name: string, block: Locator): Promise<Shot> { | ||
| await expect(block).toBeVisible({ timeout: 30_000 }) | ||
| return { name, buf: await block.screenshot() } | ||
| } | ||
|
|
||
| async function waitForThemeBoot(page: import("@playwright/test").Page): Promise<void> { | ||
| await page.waitForFunction( | ||
| () => getComputedStyle(document.documentElement).getPropertyValue("--bg-base").trim().length > 0, | ||
| null, | ||
| { timeout: 30_000 }, | ||
| ) | ||
| } | ||
|
|
||
| test("browser-tools", async ({ page }) => { | ||
| test.setTimeout(120_000) | ||
|
|
||
| await page.addInitScript((key) => { | ||
| localStorage.setItem(key, JSON.stringify({ locale: "zh" })) | ||
| }, LANGUAGE_KEY) | ||
|
|
||
| await page.goto("/") | ||
| await waitForThemeBoot(page) | ||
| await page.evaluate(async (path) => { | ||
| const mod = await import(path) | ||
| mod.mountBrowserToolsFixture(document.body) | ||
| }, `/@fs/${fixturePath}`) | ||
|
|
||
| const cards = page.locator('[data-snap="browser-tool-cards"]') | ||
| await expect(cards).toBeVisible({ timeout: 30_000 }) | ||
|
|
||
| // Each tool renders its localized title via the shared toolInfoForInput map, | ||
| // not the generic "调用 browser_navigate" fallback. | ||
| await expect(cards).toContainText("打开网页", { timeout: 30_000 }) | ||
| await expect(cards).toContainText("网页截图", { timeout: 30_000 }) | ||
| await expect(cards).toContainText("提取文本", { timeout: 30_000 }) | ||
| await expect(cards).toContainText("等待", { timeout: 30_000 }) | ||
| await expect(cards).toContainText("点击", { timeout: 30_000 }) | ||
| await expect(cards).toContainText("输入", { timeout: 30_000 }) | ||
| // Subtitle carries the call's target (url / selector). | ||
| await expect(cards).toContainText("https://news.ycombinator.com/", { timeout: 30_000 }) | ||
| await expect(cards).toContainText("main article", { timeout: 30_000 }) | ||
| // The leading "browser" family icon is supplied by the trow summary via | ||
| // toolIcon() (locked separately in tool-info.test.ts); these standalone cards | ||
| // exercise the card title/subtitle content the timeline shows. | ||
|
|
||
| const shots: Shot[] = [await captureBlock("browser-tool-cards", cards)] | ||
| const out = snapOutputPath("browser-tools") | ||
| await composeGrid(shots, out, { cols: 1 }) | ||
| process.stdout.write(`\n[snap] browser-tools grid -> ${out}\n\n`) | ||
| }) | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,55 @@ | ||
| import { For } from "solid-js" | ||
| import { Dynamic, render } from "solid-js/web" | ||
| import { I18nProvider } from "@opencode-ai/ui/context" | ||
| import { ToolRegistry } from "@opencode-ai/ui/message-part" | ||
| import { zhI18n } from "./trow-snap-fixture-data" | ||
|
|
||
| // One representative call per embedded-browser tool, so the snap shows each | ||
| // card's icon, title, and subtitle exactly as the timeline renders them. | ||
| const CARDS: Array<{ tool: string; input: Record<string, unknown> }> = [ | ||
| { tool: "browser_navigate", input: { url: "https://news.ycombinator.com/" } }, | ||
| { tool: "browser_screenshot", input: {} }, | ||
| { tool: "browser_extract", input: { selector: "main article" } }, | ||
| { tool: "browser_wait", input: { selector: ".results" } }, | ||
| { tool: "browser_click", input: { selector: "button[type=submit]" } }, | ||
| { tool: "browser_type", input: { selector: "input[name=q]", text: "pawwork" } }, | ||
| ] | ||
|
|
||
| function BrowserToolsFixture() { | ||
| return ( | ||
| <div | ||
| data-snap="browser-tool-cards" | ||
| style={{ | ||
| display: "grid", | ||
| gap: "8px", | ||
| padding: "24px", | ||
| background: "var(--bg-base)", | ||
| color: "var(--fg-base)", | ||
| width: "440px", | ||
| }} | ||
| > | ||
| <For each={CARDS}> | ||
| {(card) => { | ||
| const component = ToolRegistry.render(card.tool) | ||
| return ( | ||
| <div data-slot="trow-result-body" data-timeline-anchor={`tool:${card.tool}`}> | ||
| <Dynamic component={component} tool={card.tool} input={card.input} metadata={{}} status="completed" /> | ||
| </div> | ||
| ) | ||
| }} | ||
| </For> | ||
| </div> | ||
| ) | ||
| } | ||
|
|
||
| export function mountBrowserToolsFixture(root: HTMLElement) { | ||
| root.innerHTML = "" | ||
| render( | ||
| () => ( | ||
| <I18nProvider value={zhI18n}> | ||
| <BrowserToolsFixture /> | ||
| </I18nProvider> | ||
| ), | ||
| root, | ||
| ) | ||
| } |
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,25 @@ | ||||||||||||||||||||||||||
| import { resolveAutomationController } from "../ipc/browser" | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| /** | ||||||||||||||||||||||||||
| * Inject the controller-backed browser automation into the in-process opencode | ||||||||||||||||||||||||||
| * server. The server's browser_* tools call BrowserBridge (a registrable port); | ||||||||||||||||||||||||||
| * here in main — where the WebContentsView controllers live — we register the | ||||||||||||||||||||||||||
| * concrete implementation. This is the inverse of the usual main -> server | ||||||||||||||||||||||||||
| * direction: main hands an implementation down into the server. | ||||||||||||||||||||||||||
| * | ||||||||||||||||||||||||||
| * Each call resolves the focused (or only) window fresh, so the agent always | ||||||||||||||||||||||||||
| * drives the window the user is looking at, and a window opened or closed later | ||||||||||||||||||||||||||
| * needs no re-registration. Tools are gated out of the registry on non-desktop | ||||||||||||||||||||||||||
| * clients, so this is the only place an implementation is ever registered. | ||||||||||||||||||||||||||
| */ | ||||||||||||||||||||||||||
| export async function registerBrowserAutomationBridge(): Promise<void> { | ||||||||||||||||||||||||||
| const { BrowserBridge } = await import("virtual:opencode-server") | ||||||||||||||||||||||||||
| BrowserBridge.register({ | ||||||||||||||||||||||||||
| navigate: ({ url }) => resolveAutomationController().navigateAndReport(url), | ||||||||||||||||||||||||||
| screenshot: () => resolveAutomationController().captureScreenshot(), | ||||||||||||||||||||||||||
| extract: ({ selector, maxChars }) => resolveAutomationController().extractText(selector, maxChars), | ||||||||||||||||||||||||||
| waitFor: ({ selector, text, timeoutMs }) => resolveAutomationController().waitFor(selector, text, timeoutMs), | ||||||||||||||||||||||||||
| click: ({ selector }) => resolveAutomationController().clickSelector(selector), | ||||||||||||||||||||||||||
| type: ({ selector, text, submit }) => resolveAutomationController().typeText(selector, text, submit), | ||||||||||||||||||||||||||
|
Comment on lines
+18
to
+23
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. If resolveAutomationController() throws a synchronous error (such as NoBrowserWindowError), calling these bridge methods will throw synchronously instead of returning a rejected promise. This can bypass promise-based error handling or cause unexpected synchronous exceptions in the caller. Marking the bridge implementation methods as async ensures that any synchronous exceptions thrown during window resolution are safely converted into standard promise rejections.
Suggested change
|
||||||||||||||||||||||||||
| }) | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -1,10 +1,27 @@ | ||||||||||||||||||||||||||
| import { WebContentsView, shell, type BrowserWindow } from "electron" | ||||||||||||||||||||||||||
| import type { BrowserState, BrowserViewLayout } from "@opencode-ai/app/desktop-api" | ||||||||||||||||||||||||||
| import { browserViewWebPreferences } from "./options" | ||||||||||||||||||||||||||
| import { clearDataReloadAction, computeViewBounds, deriveBrowserState, parseNavigable, safeExternalUrl } from "./logic" | ||||||||||||||||||||||||||
| import { | ||||||||||||||||||||||||||
| buildClickRectScript, | ||||||||||||||||||||||||||
| buildExtractScript, | ||||||||||||||||||||||||||
| buildFocusScript, | ||||||||||||||||||||||||||
| buildWaitScript, | ||||||||||||||||||||||||||
| clearDataReloadAction, | ||||||||||||||||||||||||||
| clickPointFromRect, | ||||||||||||||||||||||||||
| computeViewBounds, | ||||||||||||||||||||||||||
| deriveBrowserState, | ||||||||||||||||||||||||||
| parseNavigable, | ||||||||||||||||||||||||||
| safeExternalUrl, | ||||||||||||||||||||||||||
| } from "./logic" | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| export const BROWSER_STATE_CHANNEL = "browser:state" | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| // How often browser_wait re-checks its page predicate. Human-paced page loads | ||||||||||||||||||||||||||
| // don't need tighter polling, and each tick is a round-trip into the page. | ||||||||||||||||||||||||||
| const WAIT_POLL_INTERVAL_MS = 200 | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| const delay = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms)) | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| /** | ||||||||||||||||||||||||||
| * Owns one embedded browser per window: a WebContentsView painted over the | ||||||||||||||||||||||||||
| * panel's content region. The view is a native layer above the DOM, so the | ||||||||||||||||||||||||||
|
|
@@ -156,6 +173,83 @@ export class BrowserViewController { | |||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| // --- Agent automation (BrowserBridge) --- | ||||||||||||||||||||||||||
| // These drive the same WebContentsView the user sees: the agent acts on the | ||||||||||||||||||||||||||
| // live, logged-in page (page scripts via executeJavaScript, synthetic input via | ||||||||||||||||||||||||||
| // sendInputEvent), not a separate headless fetch. Return shapes are plain JSON so | ||||||||||||||||||||||||||
| // they cross the in-process bridge into the opencode server unchanged. | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| async navigateAndReport(input: string): Promise<{ url: string; title: string }> { | ||||||||||||||||||||||||||
| const url = parseNavigable(input) | ||||||||||||||||||||||||||
| if (!url) throw new Error("URL must start with http:// or https://") | ||||||||||||||||||||||||||
| await this.loadInternal(url) | ||||||||||||||||||||||||||
| return { url: this.wc.getURL(), title: this.wc.getTitle() } | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
|
Comment on lines
+182
to
+187
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. If the window or webContents is destroyed before or during the asynchronous navigation, calling this.wc.getURL() or this.wc.getTitle() will throw a fatal "Object has been destroyed" exception. Adding defensive guards before and after the asynchronous loadInternal call ensures that we handle destruction gracefully. async navigateAndReport(input: string): Promise<{ url: string; title: string }> {
if (this.destroyed || this.wc.isDestroyed()) throw new Error("Browser view is destroyed")
const url = parseNavigable(input)
if (!url) throw new Error("URL must start with http:// or https://")
await this.loadInternal(url)
if (this.destroyed || this.wc.isDestroyed()) throw new Error("Browser view was destroyed during navigation")
return { url: this.wc.getURL(), title: this.wc.getTitle() }
}
Comment on lines
+182
to
+187
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Propagate navigation failures instead of reporting stale page metadata.
Suggested fix async navigateAndReport(input: string): Promise<{ url: string; title: string }> {
const url = parseNavigable(input)
if (!url) throw new Error("URL must start with http:// or https://")
- await this.loadInternal(url)
+ await this.wc.loadURL(url)
return { url: this.wc.getURL(), title: this.wc.getTitle() }
}📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| async captureScreenshot(): Promise<{ mime: string; base64: string; width: number; height: number }> { | ||||||||||||||||||||||||||
| const image = await this.wc.capturePage() | ||||||||||||||||||||||||||
| const size = image.getSize() | ||||||||||||||||||||||||||
| return { mime: "image/png", base64: image.toPNG().toString("base64"), width: size.width, height: size.height } | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
|
Comment on lines
+189
to
+193
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. If the window or webContents is destroyed before calling capturePage, calling this.wc.capturePage() will throw a fatal "Object has been destroyed" exception. Adding a defensive guard at the start of the method ensures that we handle destruction gracefully.
Suggested change
|
||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| async extractText( | ||||||||||||||||||||||||||
| selector: string | undefined, | ||||||||||||||||||||||||||
| maxChars: number, | ||||||||||||||||||||||||||
| ): Promise<{ url: string; title: string; text: string; truncated: boolean }> { | ||||||||||||||||||||||||||
| const raw = await this.wc.executeJavaScript(buildExtractScript(selector), true) | ||||||||||||||||||||||||||
| const text = typeof raw === "string" ? raw : "" | ||||||||||||||||||||||||||
| const truncated = text.length > maxChars | ||||||||||||||||||||||||||
| return { | ||||||||||||||||||||||||||
| url: this.wc.getURL(), | ||||||||||||||||||||||||||
| title: this.wc.getTitle(), | ||||||||||||||||||||||||||
| text: truncated ? text.slice(0, maxChars) : text, | ||||||||||||||||||||||||||
| truncated, | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
|
Comment on lines
+195
to
+208
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. If the webContents is destroyed during the asynchronous executeJavaScript call, calling this.wc.getURL() or this.wc.getTitle() will throw a fatal "Object has been destroyed" exception. Adding defensive guards before and after the asynchronous call ensures that we handle destruction gracefully. async extractText(
selector: string | undefined,
maxChars: number,
): Promise<{ url: string; title: string; text: string; truncated: boolean }> {
if (this.destroyed || this.wc.isDestroyed()) throw new Error("Browser view is destroyed")
const raw = await this.wc.executeJavaScript(buildExtractScript(selector), true)
if (this.destroyed || this.wc.isDestroyed()) throw new Error("Browser view was destroyed during text extraction")
const text = typeof raw === "string" ? raw : ""
const truncated = text.length > maxChars
return {
url: this.wc.getURL(),
title: this.wc.getTitle(),
text: truncated ? text.slice(0, maxChars) : text,
truncated,
}
} |
||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| async waitFor( | ||||||||||||||||||||||||||
| selector: string | undefined, | ||||||||||||||||||||||||||
| text: string | undefined, | ||||||||||||||||||||||||||
| timeoutMs: number, | ||||||||||||||||||||||||||
| ): Promise<{ found: boolean; waitedMs: number; reason: "selector" | "text" | "timeout" }> { | ||||||||||||||||||||||||||
| const script = buildWaitScript(selector, text) | ||||||||||||||||||||||||||
| const start = Date.now() | ||||||||||||||||||||||||||
| const deadline = start + timeoutMs | ||||||||||||||||||||||||||
| while (Date.now() < deadline) { | ||||||||||||||||||||||||||
| if (this.destroyed || this.wc.isDestroyed()) break | ||||||||||||||||||||||||||
| const hit = await this.wc.executeJavaScript(script, true).catch(() => false) | ||||||||||||||||||||||||||
| if (hit === true) return { found: true, waitedMs: Date.now() - start, reason: selector ? "selector" : "text" } | ||||||||||||||||||||||||||
| await delay(WAIT_POLL_INTERVAL_MS) | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
| return { found: false, waitedMs: Date.now() - start, reason: "timeout" } | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| async clickSelector(selector: string): Promise<{ matched: boolean; x: number; y: number }> { | ||||||||||||||||||||||||||
| const rect = await this.wc.executeJavaScript(buildClickRectScript(selector), true) | ||||||||||||||||||||||||||
| const point = clickPointFromRect(rect) | ||||||||||||||||||||||||||
| if (!point) return { matched: false, x: 0, y: 0 } | ||||||||||||||||||||||||||
| this.wc.sendInputEvent({ type: "mouseDown", x: point.x, y: point.y, button: "left", clickCount: 1 }) | ||||||||||||||||||||||||||
| this.wc.sendInputEvent({ type: "mouseUp", x: point.x, y: point.y, button: "left", clickCount: 1 }) | ||||||||||||||||||||||||||
| return { matched: true, x: point.x, y: point.y } | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
|
Comment on lines
+227
to
+234
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. If the webContents is destroyed before or during the asynchronous executeJavaScript call, calling sendInputEvent will throw a fatal exception. Adding defensive guards before and after the asynchronous call ensures that we handle destruction gracefully. async clickSelector(selector: string): Promise<{ matched: boolean; x: number; y: number }> {
if (this.destroyed || this.wc.isDestroyed()) throw new Error("Browser view is destroyed")
const rect = await this.wc.executeJavaScript(buildClickRectScript(selector), true)
if (this.destroyed || this.wc.isDestroyed()) throw new Error("Browser view was destroyed during click")
const point = clickPointFromRect(rect)
if (!point) return { matched: false, x: 0, y: 0 }
this.wc.sendInputEvent({ type: "mouseDown", x: point.x, y: point.y, button: "left", clickCount: 1 })
this.wc.sendInputEvent({ type: "mouseUp", x: point.x, y: point.y, button: "left", clickCount: 1 })
return { matched: true, x: point.x, y: point.y }
} |
||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| async typeText( | ||||||||||||||||||||||||||
| selector: string | undefined, | ||||||||||||||||||||||||||
| text: string, | ||||||||||||||||||||||||||
| submit: boolean, | ||||||||||||||||||||||||||
| ): Promise<{ matched: boolean; submitted: boolean }> { | ||||||||||||||||||||||||||
| if (selector) { | ||||||||||||||||||||||||||
| const focused = await this.wc.executeJavaScript(buildFocusScript(selector), true).catch(() => false) | ||||||||||||||||||||||||||
| if (focused !== true) return { matched: false, submitted: false } | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
| for (const char of text) this.wc.sendInputEvent({ type: "char", keyCode: char }) | ||||||||||||||||||||||||||
| if (submit) { | ||||||||||||||||||||||||||
| this.wc.sendInputEvent({ type: "keyDown", keyCode: "Return" }) | ||||||||||||||||||||||||||
| this.wc.sendInputEvent({ type: "keyUp", keyCode: "Return" }) | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
| return { matched: true, submitted: submit } | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
|
Comment on lines
+236
to
+251
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. If the webContents is destroyed before or during the asynchronous executeJavaScript call, calling sendInputEvent will throw a fatal exception. Adding defensive guards before and after the asynchronous call ensures that we handle destruction gracefully. async typeText(
selector: string | undefined,
text: string,
submit: boolean,
): Promise<{ matched: boolean; submitted: boolean }> {
if (this.destroyed || this.wc.isDestroyed()) throw new Error("Browser view is destroyed")
if (selector) {
const focused = await this.wc.executeJavaScript(buildFocusScript(selector), true).catch(() => false)
if (focused !== true) return { matched: false, submitted: false }
}
if (this.destroyed || this.wc.isDestroyed()) throw new Error("Browser view was destroyed during typing")
for (const char of text) this.wc.sendInputEvent({ type: "char", keyCode: char })
if (submit) {
this.wc.sendInputEvent({ type: "keyDown", keyCode: "Return" })
this.wc.sendInputEvent({ type: "keyUp", keyCode: "Return" })
}
return { matched: true, submitted: submit }
} |
||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| destroy() { | ||||||||||||||||||||||||||
| if (this.destroyed) return | ||||||||||||||||||||||||||
| this.destroyed = true | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Normalize the
/@fsfixture path to POSIX separators before browser import.fileURLToPath(...)returns backslashes on Windows, and passing that raw value into/@fs/${fixturePath}can break the dynamic import URL in Line 36. Normalize the path first so this snapshot test stays cross-platform.Suggested patch
Also applies to: 33-36
🤖 Prompt for AI Agents