Skip to content

add _experimental_batch - #2608

Open
seanmcguire12 wants to merge 34 commits into
v4-spikefrom
add-experimental-batch
Open

add _experimental_batch#2608
seanmcguire12 wants to merge 34 commits into
v4-spikefrom
add-experimental-batch

Conversation

@seanmcguire12

@seanmcguire12 seanmcguire12 commented Aug 5, 2026

Copy link
Copy Markdown
Member

why

client-server round trips can be expensive on remote browsers. this PR adds an experimental batch API that runs a callback inside the Stagehand extension service worker. commands issued by that callback route directly to the existing worker runtime, avoiding the client-server round trip between each operation

what changed

  • added stagehand._experimental_batch() to the TypeScript and Python SDKs and Stagehand.ExperimentalBatch() to Go
  • TS accepts an async JavaScript callback. Python and Go accept self-contained JavaScript source, because callbacks cannot be translated between languages
  • installed globalThis.__stagehandRunCallbackBatch in the service worker, which selects the active or explicitly requested page, constructs the callback context, applies the overall timeout, invokes the callback, and returns a JSON result/error envelope
  • added a transport-independent StagehandCommandClient interface and moved the public Page, Locator, BrowserContext, Response, clipboard, and WebMCP wrappers onto it
    • regular SDK calls use the remote RPC client, while batch callbacks use an in-browser client backed by the existing RPCRouter
  • preserved normal Stagehand routing and validation inside batches, including deep locator resolution, response handles, context page registration, WebMCP wrappers, and Zod protocol validation
  • exposed page, context, act, observe, extract, and metrics to callbacks. the callback context intentionally excludes context.close() and does not expose Stagehand lifecycle or recursive batch operations
  • added one-batch-at-a-time protection and cooperative overall timeouts. an operation already running in the router may still finish after the caller receives a timeout
  • added JSON validation for callback input and output, explicit handling for undefined, and reconstructed worker errors in the calling SDK
  • kept host-only conveniences out of the worker contract:
    • screenshot path cannot write to the caller's filesystem;
    • local paths cannot be used for file uploads or init scripts;
    • worker screenshot bytes are Uint8Array rather than a Node Buffer
  • added matching TS, Python, & Go examples and documented the TS/Python APIs in the v4 Stagehand reference docs
  • updated cross-SDK, example, and docs parity checks to treat exported Go ExperimentalBatch as the equivalent of _experimental_batch in TypeScript and Python

TypeScript:

const result = await stagehand._experimental_batch(
  async ({ page }, input) => {
    await page.goto(input.url);
    return {
      title: await page.title(),
      heading: await page.locator("h1").innerText(),
    };
  },
  { url: "https://example.com" },
  { timeout: 30_000 },
);

Python:

result = await stagehand._experimental_batch(
    """
    async ({ page }, input) => {
      await page.goto(input.url);
      return { title: await page.title() };
    }
    """,
    {"url": "https://example.com"},
    timeout=30_000,
)

Go:

var result struct {
    Title string `json:"title"`
}

err := client.ExperimentalBatch(
    ctx,
    `async ({ page }, input) => {
        await page.goto(input.url)
        return { title: await page.title() }
    }`,
    map[string]any{"url": "https://example.com"},
    &result,
    stagehand.ExperimentalBatchOptions{Timeout: 30 * time.Second},
)

test plan

  • Verify the service worker runner routes shared Page and Locator operations through the in-browser RPC router
  • Verify context.close() is absent from the callback surface
  • Verify callback input is serialized independently from callback source and options
  • Verify ordinary JSON results and the distinct undefined result envelope
  • Verify the generated CDP expression includes the worker capability guard and uses awaitPromise/returnByValue.
  • Verify the TypeScript API is async and forwards callback source, input, page, and timeout
  • Verify Python CDP evaluation, static typing, and transport compatibility

Summary by cubic

Adds an experimental batch API that runs a trusted JavaScript callback inside the Stagehand service worker for faster multi-step flows with direct access to page/context and built‑in act/observe/extract. Batches run via JSON‑RPC stagehand.callback_batch with a runtime‑attached callback; screenshots now return Uint8Array.

  • New Features

    • _experimental_batch (TS/Python) and ExperimentalBatch (Go) expose { page, context, act, observe, extract, metrics }, support optional page scoping and timeout, return JSON‑serializable results, preserve undefined, and keep callback names via a bundled __name helper.
    • Transport and worker flow: delivered as stagehand.callback_batch with opaque input/value; CDP/transport attaches the executable callback to Runtime.evaluate while using the normal pending RPC path; the service worker receives runtime attachments, enforces one active batch, blocks internal Stagehand/context APIs, normalizes extract, routes per‑operation to the correct page, propagates cancellation/timeouts, and forwards W3C trace context.
    • Refactor: introduced transport‑independent StagehandCommandClient and moved public wrappers (Page, Locator, BrowserContext, Response, clipboard, WebMCP) onto it for in‑worker execution.
    • Docs: added _experimental_batch to v4 reference and clarified page.screenshot() returns Uint8Array.
  • Bug Fixes

    • Binary/FS handling: page.screenshot() returns Uint8Array; canonical base64 decoder with strict validation; disallow path in batches; avoid double‑encoding file payloads; throw RangeError for uploads >50MB; Node‑only FS access is dynamically imported with clear errors (including init scripts).
    • Robustness/validation: reject empty page IDs and null options; safely coerce batch timeouts across SDKs and include stagehand.callback_batch in timeout policy; prefilter input before JSON unmarshal in Go; fail fast on invalid worker responses; Python omits page_id when no page is provided and checks for a closed RPC client before sending; corrected StagehandMetrics typing.

Written for commit f176737. Summary will update on new commits.

Review in cubic

@changeset-bot

changeset-bot Bot commented Aug 5, 2026

Copy link
Copy Markdown

⚠️ No Changeset found

Latest commit: f176737

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

This PR includes no changesets

When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

Click here to learn what changesets are, and how to add one.

Click here if you're a maintainer who wants to add a changeset to this PR

@seanmcguire12

Copy link
Copy Markdown
Member Author

@cubic-dev-ai

@cubic-dev-ai

cubic-dev-ai Bot commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

@cubic-dev-ai

@seanmcguire12 I have started the AI code review. It will take a few minutes to complete.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

4 issues found across 37 files

Confidence score: 3/5

  • In packages/server/tests/callback-batch.test.ts, the test expects camelCase pageId even though InProcessCommandClient.send/encodeWireValue forwards snake_case, so callback-batch contract checks can fail or validate the wrong shape — align the assertion with the wire-format path (or normalize before asserting).
  • In packages/sdk-python/tests/test_cdp_client.py, run_callback_batch has untested branches (valueIsUndefinedNone, ok: false, and other post-eval outcomes), which risks silent behavior regressions for Python callers when responses vary — add branch-focused tests for each response form.
  • In packages/sdk-ts/tests/object-wrapper.test.ts, the added batch test only verifies transport plumbing and never executes the callback body, so callback execution semantics could break without detection — add cases that run the callback and assert return/error propagation.
  • In packages/sdk-ts/src/fileUpload.ts, the browser fallback base64 path does per-byte string concatenation, which can become very slow on large uploads and increase timeout risk in workers — switch to chunked conversion to keep output identical with lower runtime overhead.
Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="packages/sdk-ts/tests/object-wrapper.test.ts">

<violation number="1" location="packages/sdk-ts/tests/object-wrapper.test.ts:119">
P3: The new batch test only exercises transport plumbing (callbackSource stringification, input/timeout pass-through) because the fake runCallbackBatch never executes the callback. Consider adding focused cases that run the callback logic, assert pageId is forwarded when options.page is set, and cover the timeout/abort path and explicit-undefined envelope the PR calls out, so these behaviors are encoded rather than relying on manual verification.</violation>
</file>

<file name="packages/server/tests/callback-batch.test.ts">

<violation number="1" location="packages/server/tests/callback-batch.test.ts:42">
P2: This assertion expects camelCase `pageId`, but the params that reach the router come from `InProcessCommandClient.send`, which wire-encodes them to snake_case via `encodeWireValue`, producing `{ page_id: "page-1", selector: "button" }`. The test would therefore fail; assert against the snake_case key instead.</violation>
</file>

<file name="packages/sdk-ts/src/fileUpload.ts">

<violation number="1" location="packages/sdk-ts/src/fileUpload.ts:106">
P3: The browser fallback base64 encoder does per-byte string concatenation, which is very slow for large uploads and can increase timeout risk in worker execution. Chunked conversion keeps the same output with much lower overhead.</violation>
</file>

<file name="packages/sdk-python/tests/test_cdp_client.py">

<violation number="1" location="packages/sdk-python/tests/test_cdp_client.py:208">
P2: The new `run_callback_batch` behavior in `cdp_client.py` adds several distinct post-evaluation branches that the single added test does not exercise: the `valueIsUndefined: true` → `None` return path, the `ok: false` error envelope → `RuntimeError` reconstruction path, and the `exceptionDetails` → `RuntimeError` path. Likewise, the new `Stagehand._experimental_batch` validation (rejecting non-string/empty source, non-int/positive timeout, non-JSON-serializable input) and the `RPCClient.run_callback_batch` fallback guard for transports without a runner have no unit coverage. Adding focused tests for these branches (undefined envelope handling and error-envelope round-trip, plus at least one `_experimental_batch` validation case) would protect the serialization/error-contract logic from regressions.</violation>
</file>
Architecture diagram
sequenceDiagram
    participant SDK as SDK Client (TS/Py/Go)
    participant CDP as CDP Session (Worker)
    participant SW as Service Worker
    participant Router as RPCRouter
    participant InProc as InProcessCommandClient
    participant Page as Page / Locator Wrappers
    participant Browser as Browser Runtime

    Note over SDK,Browser: NEW: Experimental Batch Flow

    SDK->>CDP: Runtime.evaluate (callback batch expression)
    Note over SDK,CDP: Wraps callback source, serialized input, options with pageId & timeout

    CDP->>SW: globalThis.__stagehandRunCallbackBatch(callback, input, options)
    alt Another batch already active
        SW-->>CDP: { ok: false, error: "Another batch running" }
        CDP-->>SDK: Propagate error
    else Valid batch
        SW->>SW: Mark active, create AbortController, set timeout
        SW->>InProc: new InProcessCommandClient(router, signal)
        SW->>Router: context.pages (or context.active_page)
        Router->>InProc: Return page list / active page
        InProc-->>SW: Page reference

        Note over SW,Page: Callback executes with worker-local objects

        SW->>Page: callback({ page, context, act, observe, extract, metrics }, input)
        Page->>InProc: locator.click({ selector: "button" })
        InProc->>Router: handle({ method: "locator.click", params })
        Router->>Browser: Execute CDP click command
        Browser-->>Router: Result
        Router-->>InProc: { clicked: true }
        InProc-->>Page: Return result
        Page->>InProc: page.title()
        InProc->>Router: handle({ method: "page.title", params })
        Router->>Browser: Execute CDP title command
        Browser-->>Router: "Example Page"
        Router-->>InProc: "Example Page"
        InProc-->>Page: Return title
        Page-->>SW: { title: "Example Page" }

        SW->>SW: JSON round-trip result
        alt Result is undefined
            SW-->>CDP: { ok: true, valueIsUndefined: true }
        else JSON-serializable result
            SW-->>CDP: { ok: true, value: { title: "Example Page" } }
        end
        CDP-->>SDK: Decode envelope, return result
    end

    Note over SDK,SW: Key Constraints<br/>- context.close() excluded from callback scope<br/>- No recursive batch calls<br/>- Overall timeout via AbortController + cooperative check<br/>- CDP uses awaitPromise: true, returnByValue: true

    Note over SDK,CDP: Host-only features excluded in worker:<br/>- screenshot path (no Node fs)<br/>- file upload local paths<br/>- Buffer → Uint8Array conversion
Loading

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

Comment thread packages/sdk-ts/src/batch.ts Outdated
Comment thread packages/server/callbackBatch.ts Outdated
Comment thread packages/sdk-python/src/stagehand/cdp_client.py Outdated
Comment thread packages/server/callbackBatch.ts Outdated
Comment thread packages/sdk-python/src/stagehand/rpc_client.py Outdated
Comment thread packages/server/callbackBatch.ts Outdated
Comment thread packages/sdk-go/stagehand.go Outdated
Comment thread packages/sdk-ts/src/cdpClient.ts Outdated
Comment thread packages/sdk-go/cdp_client.go Outdated
Comment thread packages/sdk-ts/src/fileUpload.ts Outdated
@seanmcguire12
seanmcguire12 marked this pull request as ready for review August 6, 2026 01:54
@seanmcguire12
seanmcguire12 requested a review from a team as a code owner August 6, 2026 01:54
@seanmcguire12 seanmcguire12 changed the title [wip]: add _experimental_batch add _experimental_batch Aug 6, 2026

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

4 issues found across 40 files

Confidence score: 3/5

  • In packages/sdk-ts/src/stagehand.ts, malformed options.page/pageId can silently fall back to the active page, which risks executing a callback batch against the wrong page instead of the caller’s target — add upfront validation and fail fast before payload construction.
  • In packages/sdk-ts/src/stagehand.ts, method-reference callbacks may serialize into non-expression source and only fail later with a generic evaluation syntax error, making failures harder to diagnose and recover from — run a local parse/shape check and throw a clear TypeError before dispatch.
  • In packages/sdk-ts/tests/object-wrapper.test.ts, the mocked node:fs/promises path may not reliably intercept the runtime-concatenated dynamic import used by setInputFiles, so the fallback-path test can pass or fail for tooling reasons rather than behavior — adjust the test to hook the actual import path or refactor loading to a mockable seam.
  • In packages/server/tests/callback-batch.test.ts, failure and timeout runner paths are currently untested, leaving regression risk in the exact error/timeout handling called out by the test plan — add explicit timeout and runner-failure cases to de-risk callback batch robustness.
Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="packages/server/tests/callback-batch.test.ts">

<violation number="1" location="packages/server/tests/callback-batch.test.ts:84">
P3: This new test file covers the happy-path, undefined-envelope, extract-normalization, and callback-surface cases, but the runner's failure and timeout paths are untested. The PR test plan explicitly calls for validating worker error reconstruction, the timeout abort, and the one-batch-at-a-time guard, yet none of those branches in callbackBatch.ts (`{ ok: false, error }` envelope, `failure()`, the race between callbackPromise and the abort signal) are exercised here. Consider adding a couple of focused tests that make the in-process router throw and that simulate an abort/timeout, asserting the returned `ok:false` envelope.</violation>
</file>

<file name="packages/sdk-ts/src/stagehand.ts">

<violation number="1" location="packages/sdk-ts/src/stagehand.ts:141">
P2: Method-reference callbacks can fail late with a generic evaluation syntax error because their serialized source is not a valid expression. A local parse check before dispatch would surface a clear TypeError and avoid remote execution attempts.</violation>

<violation number="2" location="packages/sdk-ts/src/stagehand.ts:157">
P2: Malformed `options.page` can silently route the batch to the active page instead of failing fast. Validating `options.page` (or `pageId`) before building the payload would prevent wrong-page execution when caller input is invalid.</violation>
</file>

<file name="packages/sdk-ts/tests/object-wrapper.test.ts">

<violation number="1" location="packages/sdk-ts/tests/object-wrapper.test.ts:1487">
P3: This test forces the non-Node file-path fallback by mocking `node:fs/promises`, but `setInputFiles` loads that module through a runtime-concatenated `import(/* @vite-ignore */ ...)` which bypasses Vite's static import analysis. Whether `vi.doMock` intercepts that native dynamic import of a builtin is loader-dependent; if it doesn't, the test would actually open `example.txt` on disk and fail with "could not read file" instead of exercising the intended guard. Consider verifying the mock intercepts (e.g., also asserting no real fs access) or structuring the guard so the non-Node path is testable without relying on mocking a builtin used via `@vite-ignore`.</violation>
</file>
Architecture diagram
sequenceDiagram
    participant SDK as SDK Client (TS/Python/Go)
    participant CDP as CDP Client / WebSocket
    participant SW as Service Worker
    participant CB as callbackBatch Runner
    participant RPCR as RPCRouter (In-Browser)
    participant Page as Page/Locator Wrapper
    participant Context as BrowserContext

    Note over SDK,Context: NEW: Experimental Batch Flow (avoiding SDK↔Server RTT per operation)

    SDK->>CDP: sendCommand("Runtime.evaluate", { expression, awaitPromise, returnByValue })
    Note over SDK,CDP: Expression wraps callback source + serialized input + options

    CDP->>SW: Runtime.evaluate CDP command (target service worker session)

    alt SW supports batches
        SW->>SW: Check globalThis.__stagehandRunCallbackBatch exists
        SW->>CB: invoke __stagehandRunCallbackBatch(callback, input, options)
        CB->>CB: Check no other batch active (one-batch-at-a-time guard)
        CB->>CB: Create InProcessCommandClient backed by RPCRouter
        CB->>Context: Resolve selected page (by pageId or activePage())
        Context->>RPCR: context.pages / context.active_page
        RPCR-->>Context: Page list / active page ref
        Context-->>CB: Page object
        CB->>CB: Build callback context { page, context, act, observe, extract, metrics }
        Note over CB: context.close() is intentionally excluded
        CB->>CB: Invoke callback(stagehand, input)
        loop Inside callback
            Page->>CB: page.goto(url)
            CB->>RPCR: stagehand.act / stagehand.observe / stagehand.extract
            RPCR-->>CB: Result
            Page->>CB: page.locator("h1").innerText()
            CB->>RPCR: locator.innerText
            RPCR-->>CB: Text value
        end
        alt Result is undefined
            CB-->>SW: { ok: true, valueIsUndefined: true }
        else Result is JSON-serializable
            CB->>CB: JSON round-trip validation
            CB-->>SW: { ok: true, value: <JSON> }
        end
    else SW incompatible
        SW-->>CDP: { ok: false, error: "StagehandRuntimeIncompatibleError" }
    end

    SW-->>CDP: Runtime.evaluate result (envelope JSON)
    CDP->>CDP: Parse CallbackBatchEnvelopeSchema
    alt OK envelope
        alt valueIsUndefined
            CDP-->>SDK: undefined/null
        else has value
            CDP-->>SDK: Decoded result
        end
    else Error envelope
        CDP-->>SDK: Reconstructed Error with name & message
    end

    Note over SDK,SW: Timeout handling: cooperative + CDP evaluation grace period
    opt Timeout expires
        CB->>CB: Abort controller triggers
        CB-->>SW: { ok: false, error: "Stagehand callback batch timed out" }
    end
Loading

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

Comment thread packages/sdk-python/src/stagehand/cdp_client.py Outdated
Comment thread packages/docs/v4/reference/stagehand.mdx
Comment thread packages/sdk-ts/src/fileUpload.ts
Comment thread packages/sdk-ts/src/page.ts Outdated
Comment thread packages/sdk-go/stagehand.go
if (!Number.isFinite(timeout) || timeout <= 0) {
throw new RangeError("stagehand._experimental_batch() timeout must be greater than zero");
}
const callbackSource = Function.prototype.toString.call(callback);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Method-reference callbacks can fail late with a generic evaluation syntax error because their serialized source is not a valid expression. A local parse check before dispatch would surface a clear TypeError and avoid remote execution attempts.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/sdk-ts/src/stagehand.ts, line 141:

<comment>Method-reference callbacks can fail late with a generic evaluation syntax error because their serialized source is not a valid expression. A local parse check before dispatch would surface a clear TypeError and avoid remote execution attempts.</comment>

<file context>
@@ -110,6 +114,55 @@ export class Stagehand {
+    if (!Number.isFinite(timeout) || timeout <= 0) {
+      throw new RangeError("stagehand._experimental_batch() timeout must be greater than zero");
+    }
+    const callbackSource = Function.prototype.toString.call(callback);
+    if (nativeFunctionSourcePattern.test(callbackSource)) {
+      throw new TypeError(
</file context>

Comment thread packages/sdk-ts/src/stagehand.ts Outdated
return (await this.connectedRpcClient.runCallbackBatch({
callbackSource,
input,
...(options.page ? { pageId: options.page.pageId } : {}),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Malformed options.page can silently route the batch to the active page instead of failing fast. Validating options.page (or pageId) before building the payload would prevent wrong-page execution when caller input is invalid.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/sdk-ts/src/stagehand.ts, line 157:

<comment>Malformed `options.page` can silently route the batch to the active page instead of failing fast. Validating `options.page` (or `pageId`) before building the payload would prevent wrong-page execution when caller input is invalid.</comment>

<file context>
@@ -110,6 +114,55 @@ export class Stagehand {
+      return (await this.connectedRpcClient.runCallbackBatch({
+        callbackSource,
+        input,
+        ...(options.page ? { pageId: options.page.pageId } : {}),
+        timeout,
+        signal: controller.signal,
</file context>

installCallbackBatchRunner(scope, router);

await expect(
scope.__stagehandRunCallbackBatch?.(async () => undefined, null, { timeout: 1_000 }),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: This new test file covers the happy-path, undefined-envelope, extract-normalization, and callback-surface cases, but the runner's failure and timeout paths are untested. The PR test plan explicitly calls for validating worker error reconstruction, the timeout abort, and the one-batch-at-a-time guard, yet none of those branches in callbackBatch.ts ({ ok: false, error } envelope, failure(), the race between callbackPromise and the abort signal) are exercised here. Consider adding a couple of focused tests that make the in-process router throw and that simulate an abort/timeout, asserting the returned ok:false envelope.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/server/tests/callback-batch.test.ts, line 84:

<comment>This new test file covers the happy-path, undefined-envelope, extract-normalization, and callback-surface cases, but the runner's failure and timeout paths are untested. The PR test plan explicitly calls for validating worker error reconstruction, the timeout abort, and the one-batch-at-a-time guard, yet none of those branches in callbackBatch.ts (`{ ok: false, error }` envelope, `failure()`, the race between callbackPromise and the abort signal) are exercised here. Consider adding a couple of focused tests that make the in-process router throw and that simulate an abort/timeout, asserting the returned `ok:false` envelope.</comment>

<file context>
@@ -0,0 +1,247 @@
+    installCallbackBatchRunner(scope, router);
+
+    await expect(
+      scope.__stagehandRunCallbackBatch?.(async () => undefined, null, { timeout: 1_000 }),
+    ).resolves.toEqual({ ok: true, valueIsUndefined: true });
+  });
</file context>

Comment thread packages/sdk-ts/src/page.ts Outdated
@@ -1354,6 +1483,44 @@ describe("Stagehand TS object wrapper", () => {
}
});

it("reports when file paths are unavailable outside Node.js", async () => {
vi.doMock("node:fs/promises", () => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: This test forces the non-Node file-path fallback by mocking node:fs/promises, but setInputFiles loads that module through a runtime-concatenated import(/* @vite-ignore */ ...) which bypasses Vite's static import analysis. Whether vi.doMock intercepts that native dynamic import of a builtin is loader-dependent; if it doesn't, the test would actually open example.txt on disk and fail with "could not read file" instead of exercising the intended guard. Consider verifying the mock intercepts (e.g., also asserting no real fs access) or structuring the guard so the non-Node path is testable without relying on mocking a builtin used via @vite-ignore.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/sdk-ts/tests/object-wrapper.test.ts, line 1487:

<comment>This test forces the non-Node file-path fallback by mocking `node:fs/promises`, but `setInputFiles` loads that module through a runtime-concatenated `import(/* @vite-ignore */ ...)` which bypasses Vite's static import analysis. Whether `vi.doMock` intercepts that native dynamic import of a builtin is loader-dependent; if it doesn't, the test would actually open `example.txt` on disk and fail with "could not read file" instead of exercising the intended guard. Consider verifying the mock intercepts (e.g., also asserting no real fs access) or structuring the guard so the non-Node path is testable without relying on mocking a builtin used via `@vite-ignore`.</comment>

<file context>
@@ -1354,6 +1483,44 @@ describe("Stagehand TS object wrapper", () => {
   });
 
+  it("reports when file paths are unavailable outside Node.js", async () => {
+    vi.doMock("node:fs/promises", () => {
+      throw new Error("module resolution failed");
+    });
</file context>

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

2 issues found across 40 files (changes from recent commits).

Confidence score: 4/5

  • In packages/sdk-go/rpc_client.go, the new stagehand.callback_batch timeout routing isn’t covered by the timeout policy tests, so a regression could quietly revert to the default 10s deadline and cause unexpected request behavior for Go SDK users — add a focused timeout-policy test for this method path.
  • In packages/sdk-ts/src/rpcClient.ts, the _experimental_batch/StagehandMethods.stagehandCallbackBatch timeout branch is also untested in rpcClient.test.ts, which leaves deadline derivation changes in TypeScript undetected until runtime — extend the timeout matrix tests to include this method.
Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="packages/sdk-go/rpc_client.go">

<violation number="1" location="packages/sdk-go/rpc_client.go:249">
P3: `stagehand.callback_batch` now has custom timeout routing, but the timeout policy test does not verify this new method, so regressions here could silently fall back to the default 10s RPC deadline. Adding a focused `rpcResponseTimeout("stagehand.callback_batch", ...)` test (including a large-timeout clamp case) would make this behavior durable.

(Based on your team's feedback about adding unit tests for new behavior.) .</violation>
</file>

<file name="packages/sdk-ts/src/rpcClient.ts">

<violation number="1" location="packages/sdk-ts/src/rpcClient.ts:485">
P3: The new callback-batch timeout branch is untested in `rpcClient.test.ts`, so deadline derivation regressions for `_experimental_batch` can ship unnoticed. Consider adding `StagehandMethods.stagehandCallbackBatch` to the existing JSON-RPC deadline table test.

(Based on your team's feedback about adding unit tests for new behavior.) .</violation>
</file>

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

Comment thread packages/sdk-python/src/stagehand/cdp_client.py
Comment thread packages/protocol/schema-registry.ts
Comment thread packages/extension/callbackBatch.ts
case "stagehand.act",
"stagehand.extract",
"stagehand.observe",
"stagehand.callback_batch",

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: stagehand.callback_batch now has custom timeout routing, but the timeout policy test does not verify this new method, so regressions here could silently fall back to the default 10s RPC deadline. Adding a focused rpcResponseTimeout("stagehand.callback_batch", ...) test (including a large-timeout clamp case) would make this behavior durable.

(Based on your team's feedback about adding unit tests for new behavior.) .

View Feedback

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/sdk-go/rpc_client.go, line 249:

<comment>`stagehand.callback_batch` now has custom timeout routing, but the timeout policy test does not verify this new method, so regressions here could silently fall back to the default 10s RPC deadline. Adding a focused `rpcResponseTimeout("stagehand.callback_batch", ...)` test (including a large-timeout clamp case) would make this behavior durable.

(Based on your team's feedback about adding unit tests for new behavior.) .</comment>

<file context>
@@ -263,6 +246,7 @@ func rpcResponseTimeout(method string, params json.RawMessage) (time.Duration, b
 	case "stagehand.act",
 		"stagehand.extract",
 		"stagehand.observe",
+		"stagehand.callback_batch",
 		"page.goto",
 		"page.reload",
</file context>

Comment thread packages/sdk-python/tests/test_rpc_client.py Outdated
case StagehandMethods.stagehandAct.name:
case StagehandMethods.stagehandExtract.name:
case StagehandMethods.stagehandObserve.name:
case StagehandMethods.stagehandCallbackBatch.name:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: The new callback-batch timeout branch is untested in rpcClient.test.ts, so deadline derivation regressions for _experimental_batch can ship unnoticed. Consider adding StagehandMethods.stagehandCallbackBatch to the existing JSON-RPC deadline table test.

(Based on your team's feedback about adding unit tests for new behavior.) .

View Feedback

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/sdk-ts/src/rpcClient.ts, line 485:

<comment>The new callback-batch timeout branch is untested in `rpcClient.test.ts`, so deadline derivation regressions for `_experimental_batch` can ship unnoticed. Consider adding `StagehandMethods.stagehandCallbackBatch` to the existing JSON-RPC deadline table test.

(Based on your team's feedback about adding unit tests for new behavior.) .</comment>

<file context>
@@ -503,6 +482,7 @@ function rpcResponseTimeoutMs(method: string, params: unknown): number | undefin
     case StagehandMethods.stagehandAct.name:
     case StagehandMethods.stagehandExtract.name:
     case StagehandMethods.stagehandObserve.name:
+    case StagehandMethods.stagehandCallbackBatch.name:
     case StagehandMethods.pageGoto.name:
     case StagehandMethods.pageReload.name:
</file context>

Comment thread rules/ast-grep/sdk-parity.test.ts Outdated
Comment thread packages/sdk-go/stagehand.go Outdated
Comment thread packages/extension/tests/callback-batch.test.ts
Comment thread packages/sdk-go/cdp_client.go

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

1 issue found across 13 files (changes from recent commits).

Confidence score: 5/5

  • In packages/sdk-go/cdp_client_test.go, SkipsNonBatchMessages currently validates only the early prefilter path, so regressions in the JSON-decoding or method-dispatch handling for non-batch messages could slip through without test coverage; add a case that passes the marker check and still verifies the non-batch skip behavior after decode/dispatch logic is reached.
Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="packages/sdk-go/cdp_client_test.go">

<violation number="1" location="packages/sdk-go/cdp_client_test.go:577">
P3: The test named “SkipsNonBatchMessages” only exercises the prefilter short-circuit: `not-json` doesn’t contain the `stagehand.callback_batch` marker, so it returns before JSON decoding and the method dispatch. It would still pass if the method-check or error branches regressed, and it doesn’t show the intended case (a valid non-batch JSON message returning `ok=false`). Consider adding a case with a well-formed non-batch message, e.g. `{"method":"page.title"}`, to actually cover the “skip” path.</violation>
</file>

Tip: Review your code locally with the cubic CLI to iterate faster.

Re-trigger cubic

func TestCallbackSourceFromMessageSkipsNonBatchMessages(t *testing.T) {
t.Parallel()

source, ok, err := callbackSourceFromMessage(json.RawMessage(`not-json`))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: The test named “SkipsNonBatchMessages” only exercises the prefilter short-circuit: not-json doesn’t contain the stagehand.callback_batch marker, so it returns before JSON decoding and the method dispatch. It would still pass if the method-check or error branches regressed, and it doesn’t show the intended case (a valid non-batch JSON message returning ok=false). Consider adding a case with a well-formed non-batch message, e.g. {"method":"page.title"}, to actually cover the “skip” path.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/sdk-go/cdp_client_test.go, line 577:

<comment>The test named “SkipsNonBatchMessages” only exercises the prefilter short-circuit: `not-json` doesn’t contain the `stagehand.callback_batch` marker, so it returns before JSON decoding and the method dispatch. It would still pass if the method-check or error branches regressed, and it doesn’t show the intended case (a valid non-batch JSON message returning `ok=false`). Consider adding a case with a well-formed non-batch message, e.g. `{"method":"page.title"}`, to actually cover the “skip” path.</comment>

<file context>
@@ -571,6 +571,15 @@ func TestCDPClientDeliversCallbackBatchWithRuntimeAttachment(t *testing.T) {
+func TestCallbackSourceFromMessageSkipsNonBatchMessages(t *testing.T) {
+	t.Parallel()
+
+	source, ok, err := callbackSourceFromMessage(json.RawMessage(`not-json`))
+	if err != nil || ok || source != "" {
+		t.Fatalf("callbackSourceFromMessage() = %q, %t, %v; want empty, false, nil", source, ok, err)
</file context>

Comment thread packages/sdk-ts/src/page.ts
…batch

# Conflicts:
#	packages/extension/callbackBatch.ts
#	packages/extension/rpcRouter.ts
#	packages/extension/tests/callback-batch.test.ts
#	packages/extension/tests/stagehand-clients.test.ts
#	packages/protocol/stagehand.v4.json
#	packages/sdk-go/internal/extensionassets/stagehand-extension.zip
#	packages/sdk-go/models.gen.go
#	packages/sdk-python/src/stagehand/_generated/input_types.py
#	packages/sdk-python/src/stagehand/_generated/models.py
#	packages/sdk-ts/src/page.ts
#	rules/ast-grep/example-parity.test.ts

@miguelg719 miguelg719 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Two blocking items before merge (correctness, not security — the architecture is sound). Remaining should-fix / nits to follow.

callback_source=source,
input=_models.FieldSchema2.model_validate(input),
options=CallbackBatchOptions(
page_id=page.page_id if page is not None else None,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Blocking — Python default path is broken.

page_id=page.page_id if page is not None else None always sets the field, so model_dump(exclude_unset=True, by_alias=True) keeps it and the wire carries "pageId": null. The worker schema is pageId: z.string().min(1).optional() — optional but not nullable — so it rejects null. Every Python _experimental_batch() call without page= fails with invalid-params before the callback runs, including examples/batch.py.

options=CallbackBatchOptions(
    **({"page_id": page.page_id} if page is not None else {}),
    timeout=timeout,
),

Matches the TS conditional spread and Go's *string + omitempty.



@pytest.mark.asyncio
async def test_experimental_batch_uses_registered_rpc_method(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Blocking — no wire-conformance coverage.

These tests assert against a recording fake, so nothing ever runs real serialized callback_batch params through the zod schemas the extension enforces — which is exactly why the pageId: null bug above slipped through. Please add a round-trip test (real RPCClient serialization → protocol schema parse) covering both page-omitted and page-provided cases, in Python and Go (shared golden wire fixtures would be ideal).

@miguelg719 miguelg719 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

rest of the review — should-fix + nits. none of these block on their own, but the page-pinning and extract heuristic ones are easy footguns.

extract: async (...args) => {
const [instruction, schemaOrOptions, explicitOptions] = args;
const optionsOnly =
args.length < 3 &&

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

extract() only treats arg2 as options if it strict-parses. a typo'd options obj ({ selctor, timeout }) fails the parse and gets shipped as the extraction schema instead — runs unscoped, returns garbage, no error (main sdk throws). reverse edge: a permissive {} schema parses as options and gets dropped. throw when keys intersect the options schema but don't fully validate.

operationOptions ?? {},
);
return await client.send(StagehandMethods.stagehandAct, {
pageId: (operationPage ?? page).pageId,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(operationPage ?? page).pageId pins to the page resolved at batch start, but the outer sdk resolves activePage() per call. newPage + setActivePage + act in a callback silently drives the original page. resolve per-op or document the pinning.

throw new RangeError("stagehand._experimental_batch() timeout must be greater than zero");
}
const callbackSource = Function.prototype.toString.call(callback);
if (nativeFunctionSourcePattern.test(callbackSource)) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nativeFunctionSourcePattern only catches native stubs. a valid shorthand method ({ async run(){} }).run serializes to async run(){...} → interpolates as (async run(){...}) → opaque v8 SyntaxError with no mention of _experimental_batch. validate with new Function(\return (${source})`)` and throw the clean TypeError. also worth a comment that this is dx-only, not a security guard — and it's missing from py/go.

CallbackBatchResult,
)
return (
result.value.model_dump(mode="json")

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

value validates through FieldSchema3 (no StrictInt), so every int in the result comes back as float — {count: 7}7.0 — and inputs lose precision above 2^53. ts/go stay exact. use raw json pass-through (like go's json.RawMessage) or add StrictInt to the unions.

]);
if (result === undefined) return {};
return { value: jsonRoundTrip(result) };
} finally {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

finally only clears the timeout, never aborts the controller after the callback settles. the InProcessCommandClient stays live and any un-awaited op the callback kicked off keeps running in the worker after the result returned — mutating page state under the user's next commands. controller.abort(...) in finally.

}

const controller = new AbortController();
const timeoutId = setTimeout(() => {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

timeout allows up to MAX_SAFE_INTEGER but goes straight to setTimeout, whose delay is signed 32-bit in chromium — anything > 2^31-1 ms wraps and fires immediately, aborting instantly instead of ~never. add .max(2**31-1) to the schema, mirror in py/go.

metrics(): Promise<StagehandMetrics>;
};

export function createCallbackBatchController(router: RPCRouter) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the description + test plan claim one-batch-at-a-time protection, but nothing here / in rpcRouter / in any sdk serializes batches, and callback-batch.test.ts has a test asserting concurrent batches run. no new hazard vs concurrent act(), but the claim and the code disagree — implement an activeBatch gate or fix the description.

}
const bytes =
typeof file.buffer === "string"
? new TextEncoder().encode(file.buffer)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the 50 MiB pre-check for string buffers is gated on globalThis.Buffer, which doesn't exist in the worker. off-node, new TextEncoder().encode(...) fully allocates before the RangeError. compute utf-8 length without the full allocation for that path.

).resolves.toEqual({});
});

it("enforces the callback timeout in the worker", async () => {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the timeout test uses a never-settling callback and only asserts the race rejection, so throwIfAborted — the only thing stopping a timed-out callback from issuing more act/extract after failure — is never exercised; deleting it still passes. add a gated-callback test asserting a post-timeout send rejects and never reaches router.handle.

"stagehand.callback_batch",
CallbackBatchParams(
callback_source=source,
input=_models.FieldSchema2.model_validate(input),

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

two small parity nits here: (1) non-string dict keys pass the json.dumps guard but then raise a raw pydantic ValidationError instead of the documented TypeError — model_validate_json(json.dumps(input)) coerces keys consistently. (2) a no-input callback sees input === undefined in ts (field omitted) but null in py/go. omit the field when unset or document the diff.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants