Skip to content

Add semantic browser waits - #64

Merged
rgarcia merged 4 commits into
mainfrom
hypeship/browser-semantic-waits
Jul 24, 2026
Merged

Add semantic browser waits#64
rgarcia merged 4 commits into
mainfrom
hypeship/browser-semantic-waits

Conversation

@rgarcia

@rgarcia rgarcia commented Jul 14, 2026

Copy link
Copy Markdown
Contributor

summary

  • add standalone semantic waits for refs, text, roles, URL, title, and composed conditions
  • return explicit satisfied, timed-out, interrupted, and unverifiable evidence
  • harden recursive iframe and OOPIF ownership, invalidation, and persisted ref rebinding
  • apply semantic polling deadlines while allowing in-flight browser observations to settle
  • retry transient incomplete observations, prioritize navigation, and treat removed baseline frames as incomplete

stack

  1. Fence browser observations by generation #63 — observation core
  2. this PR — semantic waits and frame hardening
  3. Add verified browser action plans #65 — verified dependent action plans

base: hypeship/browser-observation-core

size

963 changed lines: 878 additions, 85 deletions.

testing

  • npm run typecheck
  • npm test --workspace @onkernel/cua-ai — 172 passed
  • focused wait tests — 31 passed
  • git diff --check hypeship/browser-observation-core...HEAD

Note

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, and all/any groups) 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; BrowserExecutor wires it through CDP observations, ref resolution/healing, and lifecycle interrupts (navigation, dialogs, detached targets, stale refs). Observations now carry navigationEpoch, URL, and title so same-document vs cross-document navigation is handled differently for content vs location waits.

Batch execution stops and reports skippedActions when a wait is not satisfied; tool layers surface wait text and updated status messages. @onkernel/cua-ai registers 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.

@rgarcia

rgarcia commented Jul 14, 2026

Copy link
Copy Markdown
Contributor Author

bugbot run

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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.ts to document the public semantic-wait contract.
  • ✅ Fixed: Location wait deadline misreports outcome
    • Removed the deadline break inside the navigation-location branch so navigated URL/title waits now return satisfied or interrupted/navigation instead of falling through to timeout classification.
  • ✅ 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 liveGeneration drift as navigation by basing navigation detection only on the collected observation versus baseline generations and navigation epoch.

Create PR

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.

Comment thread packages/agent/src/translator/types.ts
Comment thread packages/agent/src/translator/browser-wait.ts Outdated
Comment thread packages/agent/src/tools.ts Outdated
Comment thread packages/agent/src/translator/browser-wait.ts
@rgarcia
rgarcia force-pushed the hypeship/browser-observation-core branch from 4067b8b to 0c29b34 Compare July 14, 2026 03:08
@rgarcia
rgarcia force-pushed the hypeship/browser-semantic-waits branch from 092e9a6 to f187fc1 Compare July 14, 2026 03:08
@rgarcia

rgarcia commented Jul 14, 2026

Copy link
Copy Markdown
Contributor Author

bugbot run

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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.

Create PR

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.

Comment thread packages/agent/src/translator/browser.ts Outdated
@rgarcia
rgarcia force-pushed the hypeship/browser-semantic-waits branch from f187fc1 to 1ad2322 Compare July 14, 2026 03:17
@rgarcia

rgarcia commented Jul 14, 2026

Copy link
Copy Markdown
Contributor Author

bugbot run

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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 waitForBrowserExpectation describing its purpose and result semantics for public API consumers.
  • ✅ Fixed: Deadline orphans in-flight observations
    • Updated beforeDeadline to await the in-flight operation after deadline expiration before propagating timeout so no observation continues running after the wait returns.

Create PR

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.

Comment thread packages/agent/src/translator/browser-wait.ts
Comment thread packages/agent/src/translator/browser-wait.ts
@rgarcia
rgarcia force-pushed the hypeship/browser-semantic-waits branch from 1ad2322 to 07370f2 Compare July 14, 2026 03:39
@rgarcia

rgarcia commented Jul 14, 2026

Copy link
Copy Markdown
Contributor Author

bugbot run

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

✅ 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.

@rgarcia

rgarcia commented Jul 14, 2026

Copy link
Copy Markdown
Contributor Author

bugbot run

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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:false satisfaction on a shrunk tree.

Create PR

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.

Comment thread packages/agent/src/translator/browser-wait.ts
Comment thread packages/agent/src/translator/browser-wait.ts Outdated
@rgarcia
rgarcia force-pushed the hypeship/browser-semantic-waits branch from 07370f2 to cf78571 Compare July 14, 2026 03:56
@rgarcia

rgarcia commented Jul 14, 2026

Copy link
Copy Markdown
Contributor Author

bugbot run

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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.

Create PR

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.

Comment thread packages/agent/src/translator/browser-wait.ts Outdated
@rgarcia
rgarcia force-pushed the hypeship/browser-semantic-waits branch from cf78571 to eac9a33 Compare July 14, 2026 04:05
@rgarcia

rgarcia commented Jul 14, 2026

Copy link
Copy Markdown
Contributor Author

bugbot run

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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.

Create PR

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.

Comment thread packages/agent/src/translator/browser-wait.ts
@rgarcia
rgarcia force-pushed the hypeship/browser-semantic-waits branch from eac9a33 to 8fd179f Compare July 14, 2026 04:13
@rgarcia

rgarcia commented Jul 14, 2026

Copy link
Copy Markdown
Contributor Author

bugbot run

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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.

Create PR

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.

Comment thread packages/agent/src/translator/browser-wait.ts Outdated
Comment thread packages/ai/src/actions/browser.ts
@rgarcia
rgarcia force-pushed the hypeship/browser-semantic-waits branch from 8fd179f to 7b76f06 Compare July 14, 2026 04:22
@rgarcia
rgarcia changed the base branch from hypeship/browser-observation-core to main July 24, 2026 16:27
@rgarcia
rgarcia marked this pull request as ready for review July 24, 2026 16:27
@rgarcia
rgarcia force-pushed the hypeship/browser-semantic-waits branch from 7b76f06 to 3892983 Compare July 24, 2026 16:27
@rgarcia

rgarcia commented Jul 24, 2026

Copy link
Copy Markdown
Contributor Author

bugbot run

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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.

Create PR

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.

Comment thread packages/agent/src/translator/browser-wait.ts
Comment thread packages/agent/src/translator/browser-wait.ts
@rgarcia

rgarcia commented Jul 24, 2026

Copy link
Copy Markdown
Contributor Author

bugbot run

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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.

Create PR

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.

Comment thread packages/agent/src/translator/browser-wait.ts Outdated
Comment thread packages/agent/src/translator/browser.ts
@rgarcia

rgarcia commented Jul 24, 2026

Copy link
Copy Markdown
Contributor Author

bugbot run

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

✅ 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.

@rgarcia
rgarcia merged commit c7bb4c5 into main Jul 24, 2026
6 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant