-
Notifications
You must be signed in to change notification settings - Fork 35
fix(browser): auto-connect provisioned sessions #119
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 | ||||
|---|---|---|---|---|---|---|
|
|
@@ -11,12 +11,16 @@ | |||||
| // `{log, error, warn, info}` API as the real console. | ||||||
| // standard JS globals. | ||||||
| // | ||||||
| // Nothing is auto-loaded. To reuse code from a previous snippet the agent | ||||||
| // writes plain `await import("/abs/path/foo.ts?t=" + Date.now())` against a | ||||||
| // `.ts` file it owns under `<projectDir>/.bcode/agent-workspace/`. Same | ||||||
| // mechanism for a 5-line wrapper and a 500-line scrape script. The Level-2 | ||||||
| // wrapper supplies `ctx.workspaceDir` so `.ts` files written under it can be | ||||||
| // addressed by absolute path; this resolver creates the dir on first use. | ||||||
| // When BU_CDP_WS or BU_CDP_URL binds the process to a provisioned browser, | ||||||
| // the tool ensures the process-scoped Session is connected before running a | ||||||
| // snippet. Local sessions without either variable keep explicit-connect | ||||||
| // behavior. Nothing else is auto-loaded. To reuse code from a previous | ||||||
| // snippet the agent writes plain | ||||||
| // `await import("/abs/path/foo.ts?t=" + Date.now())` against a `.ts` file it | ||||||
| // owns under `<projectDir>/.bcode/agent-workspace/`. Same mechanism for a | ||||||
| // 5-line wrapper and a 500-line scrape script. The Level-2 wrapper supplies | ||||||
| // `ctx.workspaceDir` so `.ts` files written under it can be addressed by | ||||||
| // absolute path; this resolver creates the dir on first use. | ||||||
| // | ||||||
| // Output capture: a per-call `console` object (`{log, error, warn, info}`) | ||||||
| // is bound into the snippet's lexical scope as the second AsyncFunction | ||||||
|
|
@@ -48,6 +52,7 @@ import { Skills } from "./skills" | |||||
|
|
||||||
| const DEFAULT_TIMEOUT_MS = 60 * 1000 | ||||||
| const MAX_TIMEOUT_MS = 10 * 60 * 1000 | ||||||
| const cloudConnections = new WeakMap<ReturnType<typeof SessionStore.get>, Promise<void>>() | ||||||
|
|
||||||
| // Field order matters: providers stream tool-call args in schema-declared | ||||||
| // order, so the model commits to whichever field comes first. `code` is the | ||||||
|
|
@@ -71,9 +76,8 @@ export type Parameters = Schema.Schema.Type<typeof parameters> | |||||
|
|
||||||
| export interface ExecuteContext { | ||||||
| // Identifies the per-opencode-session CDP Session to bind into the snippet. | ||||||
| // The same Session is reused across calls — the agent calls | ||||||
| // `session.connect(...)` in one snippet and subsequent snippets find the | ||||||
| // already-connected Session. | ||||||
| // The same Session is reused across calls. Provisioned cloud sessions | ||||||
| // auto-connect; local sessions are connected explicitly by the agent. | ||||||
| readonly sessionID: string | ||||||
| // Per-project workspace dir: <projectDir>/.bcode/agent-workspace/. Created | ||||||
| // on first call. The agent reads/writes/edits .ts files here via the | ||||||
|
|
@@ -144,9 +148,10 @@ const serialize = (v: unknown): string => { | |||||
| } | ||||||
|
|
||||||
| // Snippet executor. The CDP Session is resolved per-call from `SessionStore` | ||||||
| // keyed on `ctx.sessionID`. The agent connects with `await session.connect(...)` | ||||||
| // in one snippet (Way 1 / Way 2 / Way 3 in skills/browser-execute/SKILL.md); the Session persists | ||||||
| // for follow-up snippets in the same opencode session. | ||||||
| // keyed on `ctx.sessionID`. A provisioned cloud endpoint auto-connects before | ||||||
| // the snippet; otherwise the agent connects explicitly (Way 1 / Way 2 / Way 3 | ||||||
| // in skills/browser-execute/SKILL.md). The Session persists for follow-up | ||||||
| // snippets in the same opencode session. | ||||||
| // | ||||||
| // `dataDir` is opencode's XDG_DATA_HOME for bcode (~/.local/share/bcode/ on | ||||||
| // Linux/Mac). Compiled-mode skills are extracted to `<dataDir>/skills/` once | ||||||
|
|
@@ -167,6 +172,11 @@ export const make = Effect.fn("BrowserExecute.make")(function* (dataDir: string) | |||||
| catch: (err) => new Error(`syntax error in browser_execute snippet: ${err}`), | ||||||
| }) | ||||||
|
|
||||||
| yield* Effect.tryPromise({ | ||||||
| try: () => ensureCloudConnected(session), | ||||||
| catch: (err) => (err instanceof Error ? err : new Error(String(err))), | ||||||
| }) | ||||||
|
|
||||||
| let output = "" | ||||||
| const tee = (...a: unknown[]) => { | ||||||
| output += a.map((x) => (typeof x === "string" ? x : serialize(x))).join(" ") + "\n" | ||||||
|
|
@@ -235,4 +245,20 @@ export const make = Effect.fn("BrowserExecute.make")(function* (dataDir: string) | |||||
| return { parameters, execute, skillsDir } | ||||||
| }) | ||||||
|
|
||||||
| async function ensureCloudConnected(session: ReturnType<typeof SessionStore.get>) { | ||||||
| if (session.isConnected()) return | ||||||
| if (!process.env.BU_CDP_WS && !process.env.BU_CDP_URL) return | ||||||
|
|
||||||
| const existing = cloudConnections.get(session) | ||||||
| if (existing) return existing | ||||||
|
|
||||||
| const connecting = session.connect() | ||||||
|
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. P2: Auto-connect via Prompt for AI agents
Suggested change
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. P2: A tool call that reconnects after Prompt for AI agents |
||||||
| cloudConnections.set(session, connecting) | ||||||
| try { | ||||||
| await connecting | ||||||
| } finally { | ||||||
| if (cloudConnections.get(session) === connecting) cloudConnections.delete(session) | ||||||
| } | ||||||
| } | ||||||
|
|
||||||
| export * as BrowserExecute from "./browser-execute" | ||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,232 @@ | ||
| import { afterAll, expect, test } from "bun:test" | ||
| import fs from "fs/promises" | ||
| import os from "os" | ||
| import path from "path" | ||
| import { Effect } from "effect" | ||
| import { BrowserExecute } from "../src/browser-execute" | ||
| import { SessionStore } from "../src/session-store" | ||
|
|
||
| let connections = 0 | ||
| const server = Bun.serve({ | ||
| port: 0, | ||
| fetch(req, srv) { | ||
| if (srv.upgrade(req)) return undefined | ||
| return new Response("upgrade required", { status: 426 }) | ||
| }, | ||
| websocket: { | ||
| open() { | ||
| connections++ | ||
| }, | ||
| message(ws, message) { | ||
| const request: unknown = JSON.parse(String(message)) | ||
| if ( | ||
| !request || | ||
| typeof request !== "object" || | ||
| !("id" in request) || | ||
| typeof request.id !== "number" || | ||
| !("method" in request) || | ||
| typeof request.method !== "string" | ||
| ) | ||
| return | ||
| const result = | ||
| request.method === "Target.getTargets" | ||
| ? { | ||
| targetInfos: [ | ||
| { | ||
| targetId: "page-1", | ||
| type: "page", | ||
| title: "", | ||
| url: "about:blank", | ||
| attached: false, | ||
| canAccessOpener: false, | ||
| }, | ||
| ], | ||
| } | ||
| : {} | ||
| ws.send(JSON.stringify({ id: request.id, result })) | ||
| }, | ||
| close() {}, | ||
| }, | ||
| }) | ||
|
|
||
| const failingServer = Bun.serve({ | ||
| port: 0, | ||
| fetch() { | ||
| return new Response("unavailable", { status: 503 }) | ||
| }, | ||
| }) | ||
|
|
||
| afterAll(() => { | ||
| server.stop(true) | ||
| failingServer.stop(true) | ||
| }) | ||
|
|
||
| const wsUrl = `ws://127.0.0.1:${server.port}/` | ||
|
|
||
| const withEnv = async <T>(vars: Record<string, string | undefined>, fn: () => Promise<T>): Promise<T> => { | ||
| const previous = Object.fromEntries(Object.keys(vars).map((key) => [key, process.env[key]])) | ||
| Object.entries(vars).forEach(([key, value]) => { | ||
| if (value === undefined) delete process.env[key] | ||
| else process.env[key] = value | ||
| }) | ||
| try { | ||
| return await fn() | ||
| } finally { | ||
| Object.entries(previous).forEach(([key, value]) => { | ||
| if (value === undefined) delete process.env[key] | ||
| else process.env[key] = value | ||
| }) | ||
| } | ||
| } | ||
|
|
||
| const withBrowserExecute = async ( | ||
| name: string, | ||
| fn: ( | ||
| impl: Effect.Success<ReturnType<typeof BrowserExecute.make>>, | ||
| sessionID: string, | ||
| workspaceDir: string, | ||
| ) => Promise<void>, | ||
| ) => { | ||
| const dataDir = await fs.mkdtemp(path.join(os.tmpdir(), `bcode-auto-data-${name}-`)) | ||
| const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), `bcode-auto-ws-${name}-`)) | ||
| const sessionID = `auto-connect-${name}-${Math.random().toString(36).slice(2)}` | ||
| try { | ||
| const impl = await Effect.runPromise(Effect.scoped(BrowserExecute.make(dataDir))) | ||
| await fn(impl, sessionID, workspaceDir) | ||
| } finally { | ||
| await SessionStore.evict(sessionID) | ||
| await Promise.all([dataDir, workspaceDir].map((dir) => fs.rm(dir, { recursive: true, force: true }))) | ||
| } | ||
| } | ||
|
|
||
| test("cloud endpoint auto-connects before raw CDP and reuses the connection", async () => { | ||
| connections = 0 | ||
| await withEnv({ BU_CDP_WS: wsUrl, BU_CDP_URL: undefined }, () => | ||
| withBrowserExecute("reuse", async (impl, sessionID, workspaceDir) => { | ||
| const run = () => | ||
| Effect.runPromise( | ||
| impl.execute( | ||
| { | ||
| description: "List browser targets", | ||
| code: "return (await session.Target.getTargets({})).targetInfos.map((tab) => tab.targetId)", | ||
| }, | ||
| { sessionID, workspaceDir }, | ||
| ), | ||
| ) | ||
|
|
||
| expect(JSON.parse((await run()).result)).toEqual(["page-1"]) | ||
| expect(JSON.parse((await run()).result)).toEqual(["page-1"]) | ||
| expect(connections).toBe(1) | ||
| }), | ||
| ) | ||
| }) | ||
|
|
||
| test("BU_CDP_URL also enables cloud auto-connect", async () => { | ||
| connections = 0 | ||
| await withEnv({ BU_CDP_WS: undefined, BU_CDP_URL: wsUrl }, () => | ||
| withBrowserExecute("cdp-url", async (impl, sessionID, workspaceDir) => { | ||
| const result = await Effect.runPromise( | ||
| impl.execute( | ||
| { | ||
| description: "List browser targets", | ||
| code: "return (await session.Target.getTargets({})).targetInfos.length", | ||
| }, | ||
| { sessionID, workspaceDir }, | ||
| ), | ||
| ) | ||
|
|
||
| expect(JSON.parse(result.result)).toBe(1) | ||
| expect(connections).toBe(1) | ||
| }), | ||
| ) | ||
| }) | ||
|
|
||
| test("parallel first calls share one cloud connection attempt", async () => { | ||
| connections = 0 | ||
| await withEnv({ BU_CDP_WS: wsUrl, BU_CDP_URL: undefined }, () => | ||
| withBrowserExecute("race", async (impl, sessionID, workspaceDir) => { | ||
| const run = () => | ||
| Effect.runPromise( | ||
| impl.execute( | ||
| { | ||
| description: "Read target count", | ||
| code: "return (await session.Target.getTargets({})).targetInfos.length", | ||
| }, | ||
| { sessionID, workspaceDir }, | ||
| ), | ||
| ) | ||
|
|
||
| const results = await Promise.all([run(), run()]) | ||
| expect(results.map((result) => JSON.parse(result.result))).toEqual([1, 1]) | ||
| expect(connections).toBe(1) | ||
| }), | ||
| ) | ||
| }) | ||
|
|
||
| test("an explicit close and reconnect remains under snippet control", async () => { | ||
| connections = 0 | ||
| await withEnv({ BU_CDP_WS: wsUrl, BU_CDP_URL: undefined }, () => | ||
| withBrowserExecute("reconnect", async (impl, sessionID, workspaceDir) => { | ||
| const execute = (code: string) => | ||
| Effect.runPromise( | ||
| impl.execute({ description: "Exercise browser connection", code }, { sessionID, workspaceDir }), | ||
| ) | ||
|
|
||
| await execute("return (await session.Target.getTargets({})).targetInfos.length") | ||
| const reconnected = await execute(` | ||
| session.close() | ||
| await new Promise((resolve) => setTimeout(resolve, 20)) | ||
| await session.connect() | ||
| return (await session.Target.getTargets({})).targetInfos.length | ||
| `) | ||
| const reused = await execute("return (await session.Target.getTargets({})).targetInfos.length") | ||
|
|
||
| expect(JSON.parse(reconnected.result)).toBe(1) | ||
| expect(JSON.parse(reused.result)).toBe(1) | ||
| expect(connections).toBe(2) | ||
| }), | ||
| ) | ||
| }) | ||
|
|
||
| test("sessions without a cloud endpoint still require explicit connect", async () => { | ||
| await withEnv({ BU_CDP_WS: undefined, BU_CDP_URL: undefined }, () => | ||
| withBrowserExecute("local", async (impl, sessionID, workspaceDir) => { | ||
| await expect( | ||
| Effect.runPromise( | ||
| impl.execute( | ||
| { | ||
| description: "Call CDP without connect", | ||
| code: "return await session.Target.getTargets({})", | ||
| }, | ||
| { sessionID, workspaceDir }, | ||
| ), | ||
| ), | ||
| ).rejects.toThrow("Not connected. Call session.connect(...) first.") | ||
| }), | ||
| ) | ||
| }) | ||
|
|
||
| test("failed cloud auto-connect reports the connection error", async () => { | ||
| await withEnv( | ||
| { | ||
| BU_CDP_WS: `ws://127.0.0.1:${failingServer.port}/`, | ||
| BU_CDP_URL: undefined, | ||
| }, | ||
| () => | ||
| withBrowserExecute("failure", async (impl, sessionID, workspaceDir) => { | ||
| const failure = Effect.runPromise( | ||
| impl.execute( | ||
| { | ||
| description: "Do not run snippet", | ||
| code: 'throw new Error("snippet should not run")', | ||
| }, | ||
| { sessionID, workspaceDir }, | ||
| ), | ||
| ) | ||
|
|
||
| await expect(failure).rejects.toThrow(/WS error|WS closed before open/) | ||
| await expect(failure).rejects.not.toThrow("browser_execute snippet threw") | ||
| await expect(failure).rejects.not.toThrow("snippet should not run") | ||
| }), | ||
| ) | ||
| }) |
Uh oh!
There was an error while loading. Please reload this page.
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.
P1: Cloud snippets following the required
browser-executeskill now connect twice: the tool boundary auto-connects, then the skill's first-callsession.connect()opens and replaces the WebSocket becauseSession.connect()is not idempotent. Updating the skill to skipconnect()when the boundary has already connected (for example,if (!session.isConnected()) await session.connect()) would keep the documented flow to one socket.Prompt for AI agents