Add semantic browser waits - #64
Conversation
|
bugbot run |
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using high effort and found 4 potential issues.
Autofix Details
Bugbot Autofix prepared fixes for all 4 issues found in the latest run.
- ✅ Fixed: Exported wait types lack TSDoc
- Added TSDoc comments to the exported wait evidence, reason, and result types in
translator/types.tsto document the public semantic-wait contract.
- Added TSDoc comments to the exported wait evidence, reason, and result types in
- ✅ Fixed: Location wait deadline misreports outcome
- Removed the deadline break inside the navigation-location branch so navigated URL/title waits now return
satisfiedorinterrupted/navigationinstead of falling through to timeout classification.
- Removed the deadline break inside the navigation-location branch so navigated URL/title waits now return
- ✅ Fixed: Batch status misreports earlier waits
- Changed batch wait status aggregation to report the first unsatisfied wait status rather than the last wait status, preventing later successes from masking earlier failures.
- ✅ Fixed: Post-observe generation race interrupts
- Stopped treating post-observation
liveGenerationdrift as navigation by basing navigation detection only on the collected observation versus baseline generations and navigation epoch.
- Stopped treating post-observation
Or push these changes by commenting:
@cursor push 56e42039ac
Preview (56e42039ac)
diff --git a/packages/agent/src/tools.ts b/packages/agent/src/tools.ts
--- a/packages/agent/src/tools.ts
+++ b/packages/agent/src/tools.ts
@@ -191,7 +191,12 @@
throw new Error(`Actions failed: ${errorMessage(err)}`, { cause: err });
}
const waits = readResults.flatMap((read) => read.type === "browser_wait_for" ? [read.result] : []);
- const statusText = waits.length === 0 ? "Actions executed successfully." : waits.every((wait) => wait.status === "satisfied") ? "Browser condition satisfied." : `Browser condition ${waits.at(-1)!.status}.`;
+ const firstUnsatisfiedWait = waits.find((wait) => wait.status !== "satisfied");
+ const statusText = waits.length === 0
+ ? "Actions executed successfully."
+ : firstUnsatisfiedWait
+ ? `Browser condition ${firstUnsatisfiedWait.status}.`
+ : "Browser condition satisfied.";
return { content, details: { statusText, readResults } };
}
diff --git a/packages/agent/src/translator/browser-wait.ts b/packages/agent/src/translator/browser-wait.ts
--- a/packages/agent/src/translator/browser-wait.ts
+++ b/packages/agent/src/translator/browser-wait.ts
@@ -119,11 +119,11 @@
if (expired()) break;
if (observation.targetId !== targetId) return terminal("interrupted", "unverifiable", initial, final, started, now(), "target_changed");
if (runtime.dialogCount() > dialogs) return terminal("interrupted", "unverifiable", initial, final, started, now(), "dialog");
- const navigated = observation.navigationEpoch !== baseline.navigationEpoch || [...observation.generations].some(([key, generation]) => generation !== runtime.liveGeneration(key) || (baseline.generations.has(key) && baseline.generations.get(key) !== generation));
+ const navigated = observation.navigationEpoch !== baseline.navigationEpoch ||
+ [...observation.generations].some(([key, generation]) => baseline.generations.has(key) && baseline.generations.get(key) !== generation);
if (navigated) {
if (isLocationExpectation(options.expect)) {
final = evaluateBrowserExpectation(options.expect, observation, baseline, runtime.resolveRef);
- if (expired()) break;
if (final.truth === true) return terminal("satisfied", "newly_verified", initial, final, started, now());
}
return terminal("interrupted", "unverifiable", initial, final, started, now(), "navigation");
diff --git a/packages/agent/src/translator/types.ts b/packages/agent/src/translator/types.ts
--- a/packages/agent/src/translator/types.ts
+++ b/packages/agent/src/translator/types.ts
@@ -1,8 +1,14 @@
+/** Evidence gathered while evaluating a semantic browser expectation. */
export interface BrowserExpectationEvidence {
truth?: boolean;
details: string[];
}
+/**
+ * Why a semantic wait stopped before proving the expectation.
+ *
+ * These reasons are part of the public wait contract surfaced to tool callers.
+ */
export type BrowserWaitReason =
| "navigation"
| "dialog"
@@ -12,6 +18,18 @@
| "observation_failed"
| "incomplete_observation";
+/**
+ * Result payload for `browser_wait_for` reads.
+ *
+ * - `status` communicates terminal outcome (`satisfied`, `timed_out`,
+ * `unverifiable`, or `interrupted`).
+ * - `evidence` clarifies whether success was preexisting/newly observed or
+ * whether failure is definitive/unverifiable.
+ * - `initial`/`final` capture expectation evidence snapshots before and after
+ * polling.
+ * - `reason` is present when the wait ended due to a specific interruption or
+ * unverifiable condition.
+ */
export interface BrowserWaitForResult {
status: "satisfied" | "timed_out" | "unverifiable" | "interrupted";
evidence: "preexisting" | "newly_verified" | "failed" | "unverifiable";
diff --git a/packages/agent/test/browser-wait.test.ts b/packages/agent/test/browser-wait.test.ts
--- a/packages/agent/test/browser-wait.test.ts
+++ b/packages/agent/test/browser-wait.test.ts
@@ -118,6 +118,45 @@
expect(result).toMatchObject({ status: "satisfied", evidence: "newly_verified" });
});
+ it("returns location-navigation outcomes even when the deadline is reached during that branch", async () => {
+ let reads = 0;
+ let tick = 0;
+ const result = await waitForBrowserExpectation({
+ selectTarget: async () => "page",
+ observeTarget: async () => reads++ === 0
+ ? observation([], true, { url: "https://a.test/start" })
+ : observation([], true, { url: "https://a.test/done", navigationEpoch: 1 }),
+ dialogCount: () => 0,
+ targetExists: async () => true,
+ liveGeneration: () => 0,
+ resolveRef: missingRef,
+ now: () => tick++,
+ delay: async () => {},
+ }, { expect: { type: "url", contains: "/done" }, timeoutMs: 13, pollMs: 1 });
+ expect(result).toMatchObject({ status: "satisfied", evidence: "newly_verified" });
+ });
+
+ it("does not treat a post-observation live-generation bump as navigation", async () => {
+ let time = 0;
+ let reads = 0;
+ let liveGeneration = 0;
+ const result = await waitForBrowserExpectation({
+ selectTarget: async () => "page",
+ observeTarget: async () => {
+ if (reads++ === 0) return observation();
+ liveGeneration = 1;
+ return observation(["Ready"], true, { generations: new Map([["page", 0]]) });
+ },
+ dialogCount: () => 0,
+ targetExists: async () => true,
+ liveGeneration: () => liveGeneration,
+ resolveRef: missingRef,
+ now: () => time,
+ delay: async (ms) => { time += ms; },
+ }, { expect: { type: "text", text: "Ready" }, timeoutMs: 20, pollMs: 10 });
+ expect(result).toMatchObject({ status: "satisfied", evidence: "newly_verified" });
+ });
+
it("does not accept a condition completed after the deadline", async () => {
let time = 0, reads = 0;
const result = await waitForBrowserExpectation({ selectTarget: async () => "page", observeTarget: async () => { if (reads++ > 0) time += 20; return observation(reads > 1 ? ["Ready"] : []); }, dialogCount: () => 0, targetExists: async () => true, liveGeneration: () => 0, resolveRef: missingRef, now: () => time, delay: async (ms) => { time += ms; } }, { expect: { type: "text", text: "Ready" }, timeoutMs: 20, pollMs: 10 });
diff --git a/packages/agent/test/tool-exhaustiveness.test.ts b/packages/agent/test/tool-exhaustiveness.test.ts
--- a/packages/agent/test/tool-exhaustiveness.test.ts
+++ b/packages/agent/test/tool-exhaustiveness.test.ts
@@ -2,6 +2,8 @@
import type Kernel from "@onkernel/sdk";
import { describe, expect, it } from "vitest";
import { createCuaComputerTools, type KernelBrowser } from "../src/index";
+import { buildCuaComputerTools } from "../src/tools";
+import type { InternalComputerTranslator } from "../src/translator/translator";
const browser = { session_id: "browser_123" } as KernelBrowser;
const client = {} as Kernel;
@@ -161,4 +163,46 @@
expect(result.content.every((block) => block.type !== "image")).toBe(true);
expect(result.details).toMatchObject({ success: false });
});
+
+ it("reports batch wait status from the first unsatisfied browser wait", async () => {
+ const runtime = resolveCuaRuntimeSpec("anthropic:claude-opus-4-7");
+ const batchExecutor = runtime.toolExecutors.find((tool) => tool.definition.name === ANTHROPIC_BATCH_TOOL_NAME);
+ expect(batchExecutor).toBeDefined();
+ const translator = {
+ executeBatch: async () => ({
+ readResults: [
+ {
+ type: "browser_wait_for",
+ result: {
+ status: "interrupted",
+ evidence: "unverifiable",
+ initial: { truth: false, details: ["initial false"] },
+ final: { truth: undefined, details: ["navigation"] },
+ elapsed_ms: 10,
+ reason: "navigation",
+ details: ["initial: initial false", "final: navigation"],
+ },
+ },
+ {
+ type: "browser_wait_for",
+ result: {
+ status: "satisfied",
+ evidence: "newly_verified",
+ initial: { truth: false, details: ["missing"] },
+ final: { truth: true, details: ["present"] },
+ elapsed_ms: 12,
+ details: ["initial: missing", "final: present"],
+ },
+ },
+ ],
+ }),
+ } as unknown as InternalComputerTranslator;
+ const tools = buildCuaComputerTools({ toolExecutors: [batchExecutor!], mode: "computer" }, translator);
+ const batch = tools.find((tool) => tool.name === ANTHROPIC_BATCH_TOOL_NAME);
+ expect(batch).toBeDefined();
+
+ const result = await batch!.execute("call_1", { actions: [] });
+
+ expect(result.details.statusText).toBe("Browser condition interrupted.");
+ });
});You can send follow-ups to the cloud agent here.
4067b8b to
0c29b34
Compare
092e9a6 to
f187fc1
Compare
|
bugbot run |
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using high effort and found 1 potential issue.
Autofix Details
Bugbot Autofix prepared a fix for the issue found in the latest run.
- ✅ Fixed: Navigation epoch not persisted
- Browser ref-state export/import now includes navigation epoch counters and a regression test verifies epochs survive executor restarts.
Or push these changes by commenting:
@cursor push 6821abaee8
Preview (6821abaee8)
diff --git a/packages/agent/src/translator/browser.ts b/packages/agent/src/translator/browser.ts
--- a/packages/agent/src/translator/browser.ts
+++ b/packages/agent/src/translator/browser.ts
@@ -78,6 +78,7 @@
refCounter: number;
activeTargetId?: string;
generations: Array<[string, number]>;
+ navigationEpochs?: Array<[string, number]>;
refs: Array<[string, Omit<RefEntry, "sessionId">]>;
frameParents?: Array<[string, string]>;
frameOwners?: Array<[string, string]>;
@@ -226,6 +227,7 @@
refCounter: this.refCounter,
...(this.activeTargetId ? { activeTargetId: this.activeTargetId } : {}),
generations: [...this.generations],
+ navigationEpochs: [...this.navigationEpochs],
refs: [...this.refs].map(([ref, { sessionId: _sessionId, ...entry }]) => [ref, entry]),
frameParents: [...this.frameParents],
frameOwners: [...this.frameOwners],
@@ -238,6 +240,7 @@
this.refCounter = Math.max(this.refCounter, state.refCounter);
this.activeTargetId = state.activeTargetId ?? this.activeTargetId;
for (const [frameId, generation] of state.generations) this.generations.set(frameId, generation);
+ for (const [targetId, epoch] of state.navigationEpochs ?? []) this.navigationEpochs.set(targetId, epoch);
for (const [ref, entry] of state.refs) this.refs.set(ref, { ...entry, sessionId: "" });
for (const [frame, parent] of state.frameParents ?? []) this.frameParents.set(frame, parent);
for (const [frame, owner] of state.frameOwners ?? []) this.frameOwners.set(frame, owner);
diff --git a/packages/agent/test/translator-browser.test.ts b/packages/agent/test/translator-browser.test.ts
--- a/packages/agent/test/translator-browser.test.ts
+++ b/packages/agent/test/translator-browser.test.ts
@@ -1074,6 +1074,20 @@
expect(pressed).toBeDefined();
});
+ it("persists navigation epochs across ref-state export/import", async () => {
+ const { cdp, emit } = createFakeCdp(BUTTON_TREE);
+ const first = new BrowserExecutor(cdp);
+ await snapshotText(first);
+ emit({ method: "Page.frameNavigated", params: { frame: { id: "TARGET-1" } }, sessionId: "session-1" });
+ const state = first.exportRefState();
+ expect(state.navigationEpochs).toEqual([["TARGET-1", 1]]);
+
+ const second = new BrowserExecutor(createFakeCdp(BUTTON_TREE).cdp);
+ second.importRefState(state);
+ const importedEpochs = (second as unknown as { navigationEpochs: Map<string, number> }).navigationEpochs;
+ expect(importedEpochs.get("TARGET-1")).toBe(1);
+ });
+
it("keeps minting unique refs after import and invalidates imported refs on navigation", async () => {
const first = new BrowserExecutor(createFakeCdp(BUTTON_TREE).cdp);
await snapshotText(first);You can send follow-ups to the cloud agent here.
f187fc1 to
1ad2322
Compare
|
bugbot run |
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using high effort and found 2 potential issues.
Autofix Details
Bugbot Autofix prepared fixes for both issues found in the latest run.
- ✅ Fixed: Missing TSDoc on wait export
- Added TSDoc to
waitForBrowserExpectationdescribing its purpose and result semantics for public API consumers.
- Added TSDoc to
- ✅ Fixed: Deadline orphans in-flight observations
- Updated
beforeDeadlineto await the in-flight operation after deadline expiration before propagating timeout so no observation continues running after the wait returns.
- Updated
Or push these changes by commenting:
@cursor push b0521cc731
Preview (b0521cc731)
diff --git a/packages/agent/src/translator/browser-wait.ts b/packages/agent/src/translator/browser-wait.ts
--- a/packages/agent/src/translator/browser-wait.ts
+++ b/packages/agent/src/translator/browser-wait.ts
@@ -84,6 +84,12 @@
return { truth: undefined, details: ["unsupported expectation"] };
}
+/**
+ * Poll browser observations until an expectation is satisfied, interrupted, unverifiable, or timed out.
+ *
+ * Returns both the initial and last-evaluated evidence so callers can distinguish preexisting matches
+ * from newly verified matches and report why a wait stopped.
+ */
export async function waitForBrowserExpectation(runtime: BrowserWaitRuntime, options: BrowserWaitOptions): Promise<BrowserWaitForResult> {
const now = runtime.now ?? Date.now;
const sleep = runtime.delay ?? ((ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms)));
@@ -149,6 +155,10 @@
let timer: ReturnType<typeof setTimeout> | undefined;
try {
return await Promise.race([pending, new Promise<T>((_, reject) => { timer = setTimeout(() => reject(new WaitDeadlineError()), remaining); })]);
+ } catch (error) {
+ if (!(error instanceof WaitDeadlineError)) throw error;
+ try { await pending; } catch { /* Preserve timeout result once deadline has elapsed. */ }
+ throw error;
} finally { if (timer) clearTimeout(timer); }
}You can send follow-ups to the cloud agent here.
1ad2322 to
07370f2
Compare
|
bugbot run |
There was a problem hiding this comment.
✅ Bugbot reviewed your changes and found no new issues!
Comment @cursor review or bugbot run to trigger another review on this PR
Reviewed by Cursor Bugbot for commit 07370f2. Configure here.
|
bugbot run |
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using high effort and found 2 potential issues.
Autofix Details
Bugbot Autofix prepared fixes for both issues found in the latest run.
- ✅ Fixed: Prune resets trigger false navigation
- Navigation detection now ignores generation regressions caused by frame re-registration and only treats generation advancement/live divergence as navigation signals.
- ✅ Fixed: Absence waits ignore dropped frames
- Wait polling now treats complete observations that drop baseline frame keys as navigation interruptions, preventing false
exists:falsesatisfaction on a shrunk tree.
- Wait polling now treats complete observations that drop baseline frame keys as navigation interruptions, preventing false
Or push these changes by commenting:
@cursor push fb013c6054
Preview (fb013c6054)
diff --git a/packages/agent/src/translator/browser-wait.ts b/packages/agent/src/translator/browser-wait.ts
--- a/packages/agent/src/translator/browser-wait.ts
+++ b/packages/agent/src/translator/browser-wait.ts
@@ -123,7 +123,13 @@
catch (error) { return failedObservation(started, now(), error, initial, final); }
if (observation.targetId !== targetId) return terminal("interrupted", "unverifiable", initial, final, started, now(), "target_changed");
if (runtime.dialogCount() > dialogs) return terminal("interrupted", "unverifiable", initial, final, started, now(), "dialog");
- const navigated = observation.navigationEpoch !== baseline.navigationEpoch || [...observation.generations].some(([key, generation]) => generation !== runtime.liveGeneration(key) || (baseline.generations.has(key) && baseline.generations.get(key) !== generation));
+ const generationOutOfDate = [...observation.generations].some(([key, generation]) => generation !== runtime.liveGeneration(key));
+ const droppedBaselineFrame = observation.complete && [...baseline.generations.keys()].some((key) => !observation.generations.has(key));
+ const generationAdvanced = [...observation.generations].some(([key, generation]) => {
+ const baselineGeneration = baseline.generations.get(key);
+ return baselineGeneration !== undefined && generation > baselineGeneration;
+ });
+ const navigated = observation.navigationEpoch !== baseline.navigationEpoch || generationOutOfDate || droppedBaselineFrame || generationAdvanced;
if (navigated) {
if (isLocationExpectation(options.expect)) {
final = evaluateBrowserExpectation(options.expect, observation, baseline, runtime.resolveRef);
diff --git a/packages/agent/test/browser-wait.test.ts b/packages/agent/test/browser-wait.test.ts
--- a/packages/agent/test/browser-wait.test.ts
+++ b/packages/agent/test/browser-wait.test.ts
@@ -99,6 +99,52 @@
expect(result).toMatchObject({ status: reason === "observation_failed" ? "unverifiable" : "interrupted", reason });
});
+ it("does not treat a reset frame generation as navigation when epochs match", async () => {
+ let time = 0, reads = 0;
+ const generations = new Map<string, number>();
+ const result = await waitForBrowserExpectation({
+ selectTarget: async () => "page",
+ observeTarget: async () => {
+ const current = reads++ === 0
+ ? observation([], true, { generations: new Map([["page", 0], ["frame", 2]]) })
+ : observation([], true, { generations: new Map([["page", 0], ["frame", 0]]) });
+ generations.clear();
+ for (const [key, generation] of current.generations) generations.set(key, generation);
+ return current;
+ },
+ dialogCount: () => 0,
+ targetExists: async () => true,
+ liveGeneration: (key) => generations.get(key) ?? 0,
+ resolveRef: missingRef,
+ now: () => time,
+ delay: async (ms) => { time += ms; },
+ }, { expect: { type: "text", text: "Ready" }, timeoutMs: 20, pollMs: 10 });
+ expect(result).toMatchObject({ status: "timed_out", evidence: "failed" });
+ });
+
+ it("interrupts absence waits when complete observations drop baseline frames", async () => {
+ let time = 0, reads = 0;
+ const generations = new Map<string, number>();
+ const result = await waitForBrowserExpectation({
+ selectTarget: async () => "page",
+ observeTarget: async () => {
+ const current = reads++ === 0
+ ? observation(["Ready"], true, { generations: new Map([["page", 0], ["frame", 1]]) })
+ : observation([], true, { generations: new Map([["page", 0]]) });
+ generations.clear();
+ for (const [key, generation] of current.generations) generations.set(key, generation);
+ return current;
+ },
+ dialogCount: () => 0,
+ targetExists: async () => true,
+ liveGeneration: (key) => generations.get(key) ?? 0,
+ resolveRef: missingRef,
+ now: () => time,
+ delay: async (ms) => { time += ms; },
+ }, { expect: { type: "text", text: "Ready", exists: false }, timeoutMs: 20, pollMs: 10 });
+ expect(result).toMatchObject({ status: "interrupted", reason: "navigation" });
+ });
+
it("interrupts when a ref becomes stale after the baseline", async () => {
let time = 0, resolves = 0;
const resolveRef: BrowserRefResolver = () => resolves++ === 0 ? { truth: false, details: ["not checked"] } : { truth: undefined, details: ["stale"], reason: "stale_ref" };You can send follow-ups to the cloud agent here.
07370f2 to
cf78571
Compare
|
bugbot run |
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using high effort and found 1 potential issue.
Autofix Details
Bugbot Autofix prepared a fix for the issue found in the latest run.
- ✅ Fixed: Incomplete poll ends wait early
- The wait loop now only treats missing baseline generation keys as terminal when the current observation is complete, and a regression test confirms incomplete polls continue until later verification.
Or push these changes by commenting:
@cursor push 9b167ba928
Preview (9b167ba928)
diff --git a/packages/agent/src/translator/browser-wait.ts b/packages/agent/src/translator/browser-wait.ts
--- a/packages/agent/src/translator/browser-wait.ts
+++ b/packages/agent/src/translator/browser-wait.ts
@@ -124,7 +124,7 @@
if (observation.targetId !== targetId) return terminal("interrupted", "unverifiable", initial, final, started, now(), "target_changed");
if (runtime.dialogCount() > dialogs) return terminal("interrupted", "unverifiable", initial, final, started, now(), "dialog");
const removedFrame = [...baseline.generations.keys()].some((key) => !observation.generations.has(key));
- if (removedFrame) return terminal("unverifiable", "unverifiable", initial, final, started, now(), "incomplete_observation");
+ if (removedFrame && observation.complete) return terminal("unverifiable", "unverifiable", initial, final, started, now(), "incomplete_observation");
const navigated = observation.navigationEpoch !== baseline.navigationEpoch;
if (navigated) {
if (isLocationExpectation(options.expect)) {
diff --git a/packages/agent/test/browser-wait.test.ts b/packages/agent/test/browser-wait.test.ts
--- a/packages/agent/test/browser-wait.test.ts
+++ b/packages/agent/test/browser-wait.test.ts
@@ -111,6 +111,22 @@
expect(result).toMatchObject({ status: "unverifiable", reason: "incomplete_observation" });
});
+ it("keeps polling when a baseline frame is missing from an incomplete observation", async () => {
+ let time = 0, reads = 0;
+ const baseline = observation([]);
+ baseline.generations.set("frame", 1);
+ const pending = observation([], false);
+ const settled = observation(["Ready"]);
+ settled.generations.set("frame", 1);
+ const result = await waitForBrowserExpectation({
+ selectTarget: async () => "page",
+ observeTarget: async () => reads++ === 0 ? baseline : reads === 2 ? pending : settled,
+ dialogCount: () => 0, targetExists: async () => true, liveGeneration: () => 0,
+ resolveRef: missingRef, now: () => time, delay: async (ms) => { time += ms; },
+ }, { expect: { type: "text", text: "Ready" }, timeoutMs: 30, pollMs: 10 });
+ expect(result).toMatchObject({ status: "satisfied", evidence: "newly_verified" });
+ });
+
it("interrupts when a ref becomes stale after the baseline", async () => {
let time = 0, resolves = 0;
const resolveRef: BrowserRefResolver = () => resolves++ === 0 ? { truth: false, details: ["not checked"] } : { truth: undefined, details: ["stale"], reason: "stale_ref" };You can send follow-ups to the cloud agent here.
cf78571 to
eac9a33
Compare
|
bugbot run |
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using high effort and found 1 potential issue.
Autofix Details
Bugbot Autofix prepared a fix for the issue found in the latest run.
- ✅ Fixed: Incomplete polls ignore satisfied truth
- I changed browser wait completion logic to treat matches from incomplete observations as unverifiable (not satisfied or timed out) and added regression tests for both initial and polled incomplete-match cases.
Or push these changes by commenting:
@cursor push cdb6f41994
Preview (cdb6f41994)
diff --git a/packages/agent/src/translator/browser-wait.ts b/packages/agent/src/translator/browser-wait.ts
--- a/packages/agent/src/translator/browser-wait.ts
+++ b/packages/agent/src/translator/browser-wait.ts
@@ -105,11 +105,13 @@
return failedObservation(started, now(), error);
}
const initial = evaluateBrowserExpectation(options.expect, baseline, baseline, runtime.resolveRef);
+ const initialObservationComplete = baseline.complete;
if (expired()) return timedOut(started, now());
if (initial.reason === "stale_ref") return terminal("interrupted", "unverifiable", initial, initial, started, now(), initial.reason);
- if (initial.truth === true) return terminal("satisfied", "preexisting", initial, initial, started, now());
+ if (initial.truth === true && initialObservationComplete) return terminal("satisfied", "preexisting", initial, initial, started, now());
const dialogs = runtime.dialogCount();
let final = initial;
+ let finalObservationComplete = initialObservationComplete;
while (!expired()) {
await sleep(Math.min(poll, remaining()));
if (expired()) break;
@@ -125,6 +127,7 @@
if (runtime.dialogCount() > dialogs) return terminal("interrupted", "unverifiable", initial, final, started, now(), "dialog");
if (!observation.complete) {
final = evaluateBrowserExpectation(options.expect, observation, baseline, runtime.resolveRef);
+ finalObservationComplete = false;
continue;
}
const removedFrame = [...baseline.generations.keys()].some((key) => !observation.generations.has(key));
@@ -139,11 +142,12 @@
}
if (expired()) break;
final = evaluateBrowserExpectation(options.expect, observation, baseline, runtime.resolveRef);
+ finalObservationComplete = true;
if (expired()) break;
if (final.reason === "stale_ref") return terminal("interrupted", "unverifiable", initial, final, started, now(), final.reason);
if (final.truth === true) return terminal("satisfied", "newly_verified", initial, final, started, now());
}
- if (final.truth === undefined) return terminal("unverifiable", "unverifiable", initial, final, started, now(), final.reason ?? "incomplete_observation");
+ if (!finalObservationComplete || final.truth === undefined) return terminal("unverifiable", "unverifiable", initial, final, started, now(), final.reason ?? "incomplete_observation");
return terminal("timed_out", "failed", initial, final, started, now());
}
diff --git a/packages/agent/test/browser-wait.test.ts b/packages/agent/test/browser-wait.test.ts
--- a/packages/agent/test/browser-wait.test.ts
+++ b/packages/agent/test/browser-wait.test.ts
@@ -135,6 +135,23 @@
expect(result).toMatchObject({ status: "unverifiable", reason: "incomplete_observation" });
});
+ it("does not mark incomplete preexisting matches as satisfied", async () => {
+ let time = 0;
+ const result = await waitForBrowserExpectation({ selectTarget: async () => "page", observeTarget: async () => observation(["Ready"], false), dialogCount: () => 0, targetExists: async () => true, liveGeneration: () => 0, resolveRef: missingRef, now: () => time, delay: async (ms) => { time += ms; } }, { expect: { type: "text", text: "Ready" }, timeoutMs: 10, pollMs: 10 });
+ expect(result).toMatchObject({ status: "unverifiable", evidence: "unverifiable", reason: "incomplete_observation" });
+ });
+
+ it("returns incomplete matches at timeout as unverifiable", async () => {
+ let time = 0, reads = 0;
+ const states = [observation(), observation(["Ready"], false)];
+ const result = await waitForBrowserExpectation({
+ selectTarget: async () => "page", observeTarget: async () => states[Math.min(reads++, states.length - 1)]!,
+ dialogCount: () => 0, targetExists: async () => true, liveGeneration: () => 0,
+ resolveRef: missingRef, now: () => time, delay: async (ms) => { time += ms; },
+ }, { expect: { type: "text", text: "Ready" }, timeoutMs: 20, pollMs: 10 });
+ expect(result).toMatchObject({ status: "unverifiable", reason: "incomplete_observation" });
+ });
+
it("allows location expectations to be satisfied by navigation", async () => {
let time = 0, reads = 0;
const result = await waitForBrowserExpectation({ selectTarget: async () => "page", observeTarget: async () => reads++ === 0 ? observation() : observation([], true, { url: "https://a.test/done", navigationEpoch: 1 }), dialogCount: () => 0, targetExists: async () => true, liveGeneration: () => 0, resolveRef: missingRef, now: () => time, delay: async (ms) => { time += ms; } }, { expect: { type: "url", contains: "/done" }, timeoutMs: 20, pollMs: 10 });You can send follow-ups to the cloud agent here.
eac9a33 to
8fd179f
Compare
|
bugbot run |
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using high effort and found 2 potential issues.
Autofix Details
Bugbot Autofix prepared fixes for both issues found in the latest run.
- ✅ Fixed: Navigation missed on incomplete polls
- Moved navigation-epoch interruption handling ahead of the incomplete-observation branch and added a regression test for incomplete polls after navigation.
- ✅ Fixed: Exported expectation type lacks TSDoc
- Added TSDoc to the exported CuaBrowserExpectation type describing its leaf conditions and all/any composition semantics.
Or push these changes by commenting:
@cursor push 7d17e21fef
Preview (7d17e21fef)
diff --git a/packages/agent/src/translator/browser-wait.ts b/packages/agent/src/translator/browser-wait.ts
--- a/packages/agent/src/translator/browser-wait.ts
+++ b/packages/agent/src/translator/browser-wait.ts
@@ -124,13 +124,6 @@
catch (error) { return failedObservation(started, now(), error, initial, final); }
if (observation.targetId !== targetId) return terminal("interrupted", "unverifiable", initial, final, started, now(), "target_changed");
if (runtime.dialogCount() > dialogs) return terminal("interrupted", "unverifiable", initial, final, started, now(), "dialog");
- if (!observation.complete) {
- const partial = evaluateBrowserExpectation(options.expect, observation, baseline, runtime.resolveRef);
- final = { ...partial, truth: undefined, reason: "incomplete_observation" };
- continue;
- }
- const removedFrame = [...baseline.generations.keys()].some((key) => !observation.generations.has(key));
- if (removedFrame) return terminal("unverifiable", "unverifiable", initial, final, started, now(), "incomplete_observation");
const navigated = observation.navigationEpoch !== baseline.navigationEpoch;
if (navigated) {
if (isLocationExpectation(options.expect)) {
@@ -139,6 +132,13 @@
}
return terminal("interrupted", "unverifiable", initial, final, started, now(), "navigation");
}
+ if (!observation.complete) {
+ const partial = evaluateBrowserExpectation(options.expect, observation, baseline, runtime.resolveRef);
+ final = { ...partial, truth: undefined, reason: "incomplete_observation" };
+ continue;
+ }
+ const removedFrame = [...baseline.generations.keys()].some((key) => !observation.generations.has(key));
+ if (removedFrame) return terminal("unverifiable", "unverifiable", initial, final, started, now(), "incomplete_observation");
if (expired()) break;
final = evaluateBrowserExpectation(options.expect, observation, baseline, runtime.resolveRef);
if (expired()) break;
diff --git a/packages/agent/test/browser-wait.test.ts b/packages/agent/test/browser-wait.test.ts
--- a/packages/agent/test/browser-wait.test.ts
+++ b/packages/agent/test/browser-wait.test.ts
@@ -129,6 +129,17 @@
expect(result).toMatchObject({ status: "satisfied", evidence: "newly_verified" });
});
+ it("reports navigation even when follow-up observations are incomplete", async () => {
+ let time = 0, reads = 0;
+ const states = [observation(), observation([], false, { navigationEpoch: 1 })];
+ const result = await waitForBrowserExpectation({
+ selectTarget: async () => "page", observeTarget: async () => states[Math.min(reads++, states.length - 1)]!,
+ dialogCount: () => 0, targetExists: async () => true, liveGeneration: () => 0,
+ resolveRef: missingRef, now: () => time, delay: async (ms) => { time += ms; },
+ }, { expect: { type: "text", text: "Ready" }, timeoutMs: 30, pollMs: 10 });
+ expect(result).toMatchObject({ status: "interrupted", reason: "navigation" });
+ });
+
it("returns incomplete observations as unverifiable", async () => {
let time = 0;
const result = await waitForBrowserExpectation({ selectTarget: async () => "page", observeTarget: async () => observation([], false), dialogCount: () => 0, targetExists: async () => true, liveGeneration: () => 0, resolveRef: missingRef, now: () => time, delay: async (ms) => { time += ms; } }, { expect: { type: "text", text: "Gone", exists: false }, timeoutMs: 10, pollMs: 10 });
diff --git a/packages/ai/src/actions/browser.ts b/packages/ai/src/actions/browser.ts
--- a/packages/ai/src/actions/browser.ts
+++ b/packages/ai/src/actions/browser.ts
@@ -66,6 +66,12 @@
| RefExpectation
| LocationExpectation;
type NonEmptyArray<T> = [T, ...T[]];
+/**
+ * Semantic condition used by `browser_wait_for`.
+ *
+ * Leaves match text, role/name, element ref state, or URL/title state, while
+ * `all` and `any` compose one or more leaf expectations with logical AND/OR.
+ */
export type CuaBrowserExpectation = CuaBrowserExpectationLeaf | { all: NonEmptyArray<CuaBrowserExpectationLeaf> } | { any: NonEmptyArray<CuaBrowserExpectationLeaf> };
export interface CuaActionBrowserWaitFor {You can send follow-ups to the cloud agent here.
8fd179f to
7b76f06
Compare
7b76f06 to
3892983
Compare
|
bugbot run |
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using high effort and found 2 potential issues.
Autofix Details
Bugbot Autofix prepared fixes for both issues found in the latest run.
- ✅ Fixed: Mixed location waits interrupt navigation
- Cross-document interruption now only applies when an expectation has no URL/title leaf, so mixed composite waits can evaluate and satisfy after navigation.
- ✅ Fixed: Interrupt paths omit fresh final evidence
- Each fresh observation is now evaluated before target_changed/dialog/navigation interrupts, ensuring terminal final evidence matches the triggering snapshot.
Or push these changes by commenting:
@cursor push fabf749f8d
Preview (fabf749f8d)
diff --git a/packages/agent/src/translator/browser-wait.ts b/packages/agent/src/translator/browser-wait.ts
--- a/packages/agent/src/translator/browser-wait.ts
+++ b/packages/agent/src/translator/browser-wait.ts
@@ -113,6 +113,8 @@
if (expired()) return timedOut(started, now());
if (initial.truth === true) return terminal("satisfied", "preexisting", initial, initial, started, now());
const dialogs = runtime.dialogCount();
+ const locationExpectation = isLocationExpectation(options.expect);
+ const navigationTolerantExpectation = hasLocationExpectation(options.expect);
let final = initial;
while (!expired()) {
await sleep(Math.min(poll, remaining()));
@@ -125,15 +127,14 @@
let observation: BrowserObservation;
try { observation = await runtime.observeTarget(targetId); }
catch (error) { return failedObservation(started, now(), error, initial, final); }
+ final = evaluateBrowserExpectation(options.expect, observation, baseline, runtime.resolveRef);
if (observation.targetId !== targetId) return terminal("interrupted", "unverifiable", initial, final, started, now(), "target_changed");
if (runtime.dialogCount() > dialogs) return terminal("interrupted", "unverifiable", initial, final, started, now(), "dialog");
const crossDocumentNavigation = observation.generations.get(targetId) !== baseline.generations.get(targetId);
const sameDocumentNavigation = observation.navigationEpoch !== baseline.navigationEpoch;
- const locationExpectation = isLocationExpectation(options.expect);
- if (crossDocumentNavigation && !locationExpectation) {
+ if (crossDocumentNavigation && !navigationTolerantExpectation) {
return terminal("interrupted", "unverifiable", initial, final, started, now(), "navigation");
}
- final = evaluateBrowserExpectation(options.expect, observation, baseline, runtime.resolveRef);
if ((crossDocumentNavigation || sameDocumentNavigation) && locationExpectation) {
if (final.truth === true && !expired()) return terminal("satisfied", "newly_verified", initial, final, started, now());
continue;
@@ -173,6 +174,12 @@
return expectation.type === "url" || expectation.type === "title";
}
+function hasLocationExpectation(expectation: CuaBrowserExpectation): boolean {
+ if ("all" in expectation) return expectation.all.some(hasLocationExpectation);
+ if ("any" in expectation) return expectation.any.some(hasLocationExpectation);
+ return expectation.type === "url" || expectation.type === "title";
+}
+
function timedOut(started: number, ended: number): BrowserWaitForResult {
const evidence = { truth: undefined, details: [] };
return terminal("timed_out", "failed", evidence, evidence, started, ended);
diff --git a/packages/agent/test/browser-wait.test.ts b/packages/agent/test/browser-wait.test.ts
--- a/packages/agent/test/browser-wait.test.ts
+++ b/packages/agent/test/browser-wait.test.ts
@@ -114,6 +114,28 @@
expect(result).toMatchObject({ status: reason === "observation_failed" ? "unverifiable" : "interrupted", reason });
});
+ it.each([
+ ["target_changed", { targetId: "other" }],
+ ["dialog", { dialog: true }],
+ ["navigation", { crossDocument: true }],
+ ] as const)("captures fresh final evidence for %s interruptions", async (reason, scenario) => {
+ let time = 0, reads = 0, dialogReads = 0;
+ const result = await waitForBrowserExpectation({
+ selectTarget: async () => "page",
+ observeTarget: async () => observation(reads++ === 0 ? [] : ["Fresh"], true, {
+ ...(reads > 1 && "targetId" in scenario ? { targetId: scenario.targetId } : {}),
+ ...(reads > 1 && "crossDocument" in scenario ? { generations: new Map([["page", 1]]) } : {}),
+ }),
+ dialogCount: () => "dialog" in scenario && scenario.dialog && dialogReads++ > 0 ? 1 : 0,
+ targetExists: async () => true,
+ resolveRef: missingRef,
+ now: () => time,
+ delay: async (ms) => { time += ms; },
+ }, { expect: { type: "text", text: "Fresh" }, timeoutMs: 20, pollMs: 10 });
+ expect(result).toMatchObject({ status: "interrupted", reason });
+ expect(result.final.details).toContain("text present");
+ });
+
it("allows a condition to satisfy after an unrelated baseline frame is removed", async () => {
let time = 0, reads = 0;
const baseline = observation(["Gone"]);
@@ -191,6 +213,22 @@
expect(result).toMatchObject({ status: "satisfied", evidence: "newly_verified" });
});
+ it("allows mixed any waits to satisfy via location after navigation", async () => {
+ let time = 0, reads = 0;
+ const result = await waitForBrowserExpectation({
+ selectTarget: async () => "page",
+ observeTarget: async () => reads++ === 0
+ ? observation()
+ : observation([], true, { url: "https://a.test/done", generations: new Map([["page", 1]]) }),
+ dialogCount: () => 0,
+ targetExists: async () => true,
+ resolveRef: missingRef,
+ now: () => time,
+ delay: async (ms) => { time += ms; },
+ }, { expect: { any: [{ type: "url", contains: "/done" }, { type: "text", text: "Ready" }] }, timeoutMs: 20, pollMs: 10 });
+ expect(result).toMatchObject({ status: "satisfied", evidence: "newly_verified" });
+ });
+
it("reports timeout when a non-matching location change lands at the deadline", async () => {
let time = 0, reads = 0;
const result = await waitForBrowserExpectation({You can send follow-ups to the cloud agent here.
|
bugbot run |
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using high effort and found 2 potential issues.
Autofix Details
Bugbot Autofix prepared fixes for both issues found in the latest run.
- ✅ Fixed: Baseline timeout skips evaluation
- The baseline snapshot is now always evaluated before timeout return and timed-out results preserve evaluated initial/final evidence instead of blank placeholders.
- ✅ Fixed: Same-document epoch never increments
- Same-document navigation now keys off tracked main-frame IDs per target instead of comparing frameId to targetId, so real CDP navigatedWithinDocument events increment navigation epochs.
Or push these changes by commenting:
@cursor push 22613b92e2
Preview (22613b92e2)
diff --git a/packages/agent/src/translator/browser-wait.ts b/packages/agent/src/translator/browser-wait.ts
--- a/packages/agent/src/translator/browser-wait.ts
+++ b/packages/agent/src/translator/browser-wait.ts
@@ -101,7 +101,6 @@
targetId = await beforeDeadline(() => runtime.selectTarget(options.tabId), remaining(), now);
if (expired()) return timedOut(started, now());
baseline = await runtime.observeTarget(targetId);
- if (expired()) return timedOut(started, now());
} catch (error) {
if (error instanceof WaitDeadlineError) return timedOut(started, now());
return failedObservation(started, now(), error);
@@ -110,7 +109,7 @@
if (initial.reason === "stale_ref" && !("any" in options.expect)) {
return terminal("interrupted", "unverifiable", initial, initial, started, now(), initial.reason);
}
- if (expired()) return timedOut(started, now());
+ if (expired()) return timedOut(started, now(), initial);
if (initial.truth === true) return terminal("satisfied", "preexisting", initial, initial, started, now());
const dialogs = runtime.dialogCount();
let final = initial;
@@ -173,9 +172,13 @@
return expectation.type === "url" || expectation.type === "title";
}
-function timedOut(started: number, ended: number): BrowserWaitForResult {
- const evidence = { truth: undefined, details: [] };
- return terminal("timed_out", "failed", evidence, evidence, started, ended);
+function timedOut(
+ started: number,
+ ended: number,
+ initial: BrowserExpectationEvidence = { truth: undefined, details: [] },
+ final: BrowserExpectationEvidence = initial,
+): BrowserWaitForResult {
+ return terminal("timed_out", "failed", initial, final, started, ended);
}
function terminal(status: BrowserWaitForResult["status"], evidence: BrowserWaitForResult["evidence"], initial: BrowserExpectationEvidence, final: BrowserExpectationEvidence, started: number, ended: number, reason?: BrowserWaitReason): BrowserWaitForResult {
diff --git a/packages/agent/src/translator/browser.ts b/packages/agent/src/translator/browser.ts
--- a/packages/agent/src/translator/browser.ts
+++ b/packages/agent/src/translator/browser.ts
@@ -124,6 +124,7 @@
private readonly documents: BrowserDocumentReconciler;
private readonly lastSnapshots = new Map<string, { key: string; shape: string }>();
private readonly navigationEpochs = new Map<string, number>();
+ private readonly mainFrameIds = new Map<string, string>();
private readonly selfNavigations = new Set<string>();
private readonly dialogNotes: string[] = [];
private refCounter = 0;
@@ -160,6 +161,7 @@
}
return;
}
+ if (frame.id) this.mainFrameIds.set(sessionTargetId, frame.id);
if (!this.selfNavigations.delete(sessionTargetId)) this.lifecycle.invalidateTarget(sessionTargetId);
// Record the committed document so it reflects the current generation,
// whether the navigation was ours or page-initiated.
@@ -185,10 +187,12 @@
if (!event.sessionId) return;
const targetId = this.targetsBySession.get(event.sessionId);
const { frameId } = event.params as { frameId?: string };
- if (targetId && frameId === targetId) {
- this.selfNavigations.delete(targetId);
- this.navigationEpochs.set(targetId, (this.navigationEpochs.get(targetId) ?? 0) + 1);
- }
+ if (!targetId || this.frameTargets.has(targetId)) return;
+ const mainFrameId = this.mainFrameIds.get(targetId);
+ if (frameId && mainFrameId && frameId !== mainFrameId) return;
+ if (frameId && !mainFrameId) this.mainFrameIds.set(targetId, frameId);
+ this.selfNavigations.delete(targetId);
+ this.navigationEpochs.set(targetId, (this.navigationEpochs.get(targetId) ?? 0) + 1);
return;
}
case "Target.attachedToTarget": {
@@ -434,6 +438,7 @@
const targetId = await this.resolveTarget(tabId);
const pageSession = await this.attach(targetId);
const pageTree = await this.documents.capturePage(targetId, pageSession);
+ if (pageTree?.frame?.id) this.mainFrameIds.set(targetId, pageTree.frame.id);
const before = (await this.cdp.pageTargets()).find((target) => target.targetId === targetId);
if (!before) throw new ObservationChangedError("Browser target disappeared during observation");
@@ -1057,6 +1062,7 @@
private dropTarget(targetId: string): void {
this.lifecycle.dropTarget(targetId);
this.navigationEpochs.delete(targetId);
+ this.mainFrameIds.delete(targetId);
this.selfNavigations.delete(targetId);
this.lastSnapshots.delete(targetId);
for (const [frameId, owner] of this.frameOwners) {
diff --git a/packages/agent/test/browser-wait.test.ts b/packages/agent/test/browser-wait.test.ts
--- a/packages/agent/test/browser-wait.test.ts
+++ b/packages/agent/test/browser-wait.test.ts
@@ -311,6 +311,26 @@
expect(result).toMatchObject({ status: "interrupted", reason: "stale_ref" });
});
+ it("evaluates the baseline snapshot before returning timed_out at the deadline", async () => {
+ let time = 0;
+ const result = await waitForBrowserExpectation({
+ selectTarget: async () => "page",
+ observeTarget: async () => {
+ time = 10;
+ return observation(["Ready"]);
+ },
+ dialogCount: () => 0,
+ targetExists: async () => true,
+ resolveRef: missingRef,
+ now: () => time,
+ }, { expect: { type: "text", text: "Ready" }, timeoutMs: 10 });
+ expect(result).toMatchObject({
+ status: "timed_out",
+ initial: { truth: true, details: ["text present"] },
+ final: { truth: true, details: ["text present"] },
+ });
+ });
+
it.each(["select", "observe", "evaluate"] as const)("includes initial %s in the hard deadline", async (phase) => {
let time = 0;
const result = await waitForBrowserExpectation({
diff --git a/packages/agent/test/translator-browser.test.ts b/packages/agent/test/translator-browser.test.ts
--- a/packages/agent/test/translator-browser.test.ts
+++ b/packages/agent/test/translator-browser.test.ts
@@ -108,6 +108,7 @@
let nodes = initialNodes as Array<{ backendDOMNodeId?: number }>;
let cursorBackendIds: number[] = [];
let loaderId: string | undefined = "L0";
+ let mainFrameId = "TARGET-1";
const sessionTrees = new Map<string, Array<{ backendDOMNodeId?: number }>>();
const frameTrees = new Map<string, Array<{ backendDOMNodeId?: number }>>();
const iframeFrameIds = new Map<number, string>();
@@ -119,7 +120,7 @@
const targetSessions = new Map<string, string>();
// A same-process child frame's document loaderId defaults to a stable value so
// it verifies unchanged across processes; setFrameLoaderId overrides it.
- const loaderFor = (frameKey: string) => frameLoaderIds.get(frameKey) ?? (frameKey === "TARGET-1" ? loaderId : "L0");
+ const loaderFor = (frameKey: string) => frameLoaderIds.get(frameKey) ?? (frameKey === mainFrameId ? loaderId : "L0");
const sameProcessChildFrames = () => {
const ids = new Set<string>();
for (const frameId of iframeFrameIds.values()) if (!oopifFrameKeys.has(frameId)) ids.add(frameId);
@@ -197,7 +198,7 @@
});
return {
frameTree: {
- frame: { id: "TARGET-1", ...(loaderId !== undefined ? { loaderId } : {}) },
+ frame: { id: mainFrameId, ...(loaderId !== undefined ? { loaderId } : {}) },
...(childFrames.length ? { childFrames } : {}),
},
};
@@ -251,6 +252,9 @@
const setLoaderId = (id: string | undefined) => {
loaderId = id;
};
+ const setMainFrameId = (id: string) => {
+ mainFrameId = id;
+ };
const setFrameLoaderId = (frameKey: string, id: string) => {
frameLoaderIds.set(frameKey, id);
};
@@ -286,6 +290,7 @@
setIframeFrame,
setBoxModel,
setLoaderId,
+ setMainFrameId,
setFrameLoaderId,
addAutoAttachFrame,
failOn,
@@ -383,13 +388,14 @@
});
it("does not let a same-document navigation suppress the next real navigation's invalidation", async () => {
- const { cdp, emit } = createFakeCdp(BUTTON_TREE);
+ const { cdp, emit, setMainFrameId } = createFakeCdp(BUTTON_TREE);
+ setMainFrameId("MAIN-1");
const executor = new BrowserExecutor(cdp);
await executor.execute({ type: "browser_navigate", url: "https://a.test/#section" } as CuaBrowserAction);
- emit({ method: "Page.navigatedWithinDocument", params: { frameId: "TARGET-1", url: "https://a.test/#section" }, sessionId: "session-1" });
+ emit({ method: "Page.navigatedWithinDocument", params: { frameId: "MAIN-1", url: "https://a.test/#section" }, sessionId: "session-1" });
await snapshotText(executor);
- emit({ method: "Page.frameNavigated", params: { frame: { id: "TARGET-1" } }, sessionId: "session-1" });
+ emit({ method: "Page.frameNavigated", params: { frame: { id: "MAIN-1" } }, sessionId: "session-1" });
await expect(executor.execute({ type: "browser_click", ref: "e1" } as CuaBrowserAction)).rejects.toThrow(/stale/);
});You can send follow-ups to the cloud agent here.
|
bugbot run |
There was a problem hiding this comment.
✅ Bugbot reviewed your changes and found no new issues!
Comment @cursor review or bugbot run to trigger another review on this PR
Reviewed by Cursor Bugbot for commit 033e0cd. Configure here.

summary
stack
base:
hypeship/browser-observation-coresize
963 changed lines: 878 additions, 85 deletions.
testing
npm run typechecknpm test --workspace @onkernel/cua-ai— 172 passedgit diff --check hypeship/browser-observation-core...HEADNote
Medium Risk
Touches core browser batch orchestration and ref/navigation lifecycle; behavior is heavily tested but incorrect wait semantics could cause agents to skip actions or misread page state.
Overview
Adds
browser_wait_for, a polling semantic wait that evaluates structured expectations (text, role/name, element refs with value/state, URL/title, andall/anygroups) from accessibility observations and returns explicit satisfied, timed_out, interrupted, or unverifiable results with initial/final evidence.The wait engine lives in
browser-wait.ts;BrowserExecutorwires it through CDP observations, ref resolution/healing, and lifecycle interrupts (navigation, dialogs, detached targets, stale refs). Observations now carrynavigationEpoch, URL, and title so same-document vs cross-document navigation is handled differently for content vs location waits.Batch execution stops and reports
skippedActionswhen a wait is not satisfied; tool layers surface wait text and updated status messages.@onkernel/cua-airegisters the action, JSON schemas, and hybrid/browser tool exposure (wait_for/browser_wait_for).Reviewed by Cursor Bugbot for commit 033e0cd. Bugbot is set up for automated code reviews on this repo. Configure here.