|
| 1 | +import type { CuaBrowserExpectation } from "@onkernel/cua-ai"; |
| 2 | +import { staticTextRun, type AXNode, type BrowserObservation } from "./browser-observation"; |
| 3 | +import type { BrowserExpectationEvidence, BrowserWaitForResult, BrowserWaitReason } from "./types"; |
| 4 | + |
| 5 | +/** Internal expectation truth and optional lifecycle reason. */ |
| 6 | +export interface BrowserExpectationEvaluation extends BrowserExpectationEvidence { |
| 7 | + reason?: BrowserWaitReason; |
| 8 | +} |
| 9 | + |
| 10 | +type RefExpectation = Extract<CuaBrowserExpectation, { type: "ref" }>; |
| 11 | +/** Resolve a ref expectation against one structured observation. */ |
| 12 | +export type BrowserRefResolver = (expectation: RefExpectation, observation: BrowserObservation) => BrowserExpectationEvaluation; |
| 13 | + |
| 14 | +/** Browser capabilities required by the semantic wait engine. */ |
| 15 | +export interface BrowserWaitRuntime { |
| 16 | + selectTarget(tabId?: string): Promise<string>; |
| 17 | + observeTarget(targetId: string): Promise<BrowserObservation>; |
| 18 | + dialogCount(): number; |
| 19 | + targetExists(targetId: string): Promise<boolean>; |
| 20 | + liveGeneration(frameId: string): number; |
| 21 | + resolveRef: BrowserRefResolver; |
| 22 | + now?(): number; |
| 23 | + delay?(ms: number): Promise<void>; |
| 24 | +} |
| 25 | + |
| 26 | +/** Timeout, polling, target, and condition options for a semantic wait. */ |
| 27 | +export interface BrowserWaitOptions { |
| 28 | + expect: CuaBrowserExpectation; |
| 29 | + timeoutMs?: number; |
| 30 | + pollMs?: number; |
| 31 | + tabId?: string; |
| 32 | +} |
| 33 | + |
| 34 | +function expectationNodes(observation: BrowserObservation): AXNode[] { |
| 35 | + const nodes = observation.nodes.map(({ node }) => node); |
| 36 | + for (const tree of [observation.tree, ...observation.stitches.values()]) { |
| 37 | + for (const node of tree.byId.values()) { |
| 38 | + const children = node.childIds ?? []; |
| 39 | + for (let index = 0; index < children.length; index += 1) { |
| 40 | + const run = staticTextRun(tree.byId, children, index); |
| 41 | + if (run) { nodes.push(run.node); index = run.end; } |
| 42 | + } |
| 43 | + } |
| 44 | + } |
| 45 | + return nodes; |
| 46 | +} |
| 47 | + |
| 48 | +/** Evaluate a semantic condition without minting refs. Unknown means the observation cannot prove the claim. */ |
| 49 | +export function evaluateBrowserExpectation( |
| 50 | + expectation: CuaBrowserExpectation, |
| 51 | + observation: BrowserObservation, |
| 52 | + baseline: BrowserObservation, |
| 53 | + resolveRef: BrowserRefResolver, |
| 54 | +): BrowserExpectationEvaluation { |
| 55 | + if ("all" in expectation || "any" in expectation) { |
| 56 | + const all = "all" in expectation; |
| 57 | + const children = (all ? expectation.all : expectation.any).map((child) => evaluateBrowserExpectation(child, observation, baseline, resolveRef)); |
| 58 | + const truth = all |
| 59 | + ? children.some((child) => child.truth === false) ? false : children.some((child) => child.truth === undefined) ? undefined : true |
| 60 | + : children.some((child) => child.truth === true) ? true : children.some((child) => child.truth === undefined) ? undefined : false; |
| 61 | + return { |
| 62 | + truth, |
| 63 | + details: children.flatMap((child) => child.details), |
| 64 | + ...(truth === undefined ? { reason: children.find((child) => child.truth === undefined)?.reason } : {}), |
| 65 | + }; |
| 66 | + } |
| 67 | + if (expectation.type === "text" || expectation.type === "role_name") { |
| 68 | + const found = expectationNodes(observation).some((node) => !node.ignored && (expectation.type === "text" |
| 69 | + ? (node.name?.value ?? "").replace(/\s+/g, " ").toLowerCase().includes(expectation.text.replace(/\s+/g, " ").toLowerCase()) |
| 70 | + : (expectation.role === undefined || node.role?.value === expectation.role) && (expectation.name === undefined || node.name?.value === expectation.name))); |
| 71 | + const expected = expectation.exists ?? true; |
| 72 | + const truth = !found && !observation.complete ? undefined : found === expected; |
| 73 | + return { truth, details: [`${expectation.type} ${found ? "present" : "absent"}${observation.complete ? "" : "; observation incomplete"}`], ...(!observation.complete && !found ? { reason: "incomplete_observation" as const } : {}) }; |
| 74 | + } |
| 75 | + if (expectation.type === "url" || expectation.type === "title") { |
| 76 | + const value = observation[expectation.type]; |
| 77 | + const initial = baseline[expectation.type]; |
| 78 | + const truth = (expectation.equals === undefined || value === expectation.equals) && |
| 79 | + (expectation.contains === undefined || value.includes(expectation.contains)) && |
| 80 | + (expectation.changed === undefined || (value !== initial) === expectation.changed); |
| 81 | + return { truth, details: [`${expectation.type}=${JSON.stringify(value)}`] }; |
| 82 | + } |
| 83 | + if (expectation.type === "ref") return resolveRef(expectation, observation); |
| 84 | + return { truth: undefined, details: ["unsupported expectation"] }; |
| 85 | +} |
| 86 | + |
| 87 | +/** Poll a semantic browser condition; in-flight browser reads settle before timeout is reported. */ |
| 88 | +export async function waitForBrowserExpectation(runtime: BrowserWaitRuntime, options: BrowserWaitOptions): Promise<BrowserWaitForResult> { |
| 89 | + const now = runtime.now ?? Date.now; |
| 90 | + const sleep = runtime.delay ?? ((ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms))); |
| 91 | + const started = now(); |
| 92 | + const timeout = options.timeoutMs ?? 2_000; |
| 93 | + const poll = options.pollMs ?? 50; |
| 94 | + const remaining = () => Math.max(0, timeout - (now() - started)); |
| 95 | + const expired = () => now() - started >= timeout; |
| 96 | + let targetId: string; |
| 97 | + let baseline: BrowserObservation; |
| 98 | + try { |
| 99 | + targetId = await beforeDeadline(() => runtime.selectTarget(options.tabId), remaining(), now); |
| 100 | + if (expired()) return timedOut(started, now()); |
| 101 | + baseline = await runtime.observeTarget(targetId); |
| 102 | + if (expired()) return timedOut(started, now()); |
| 103 | + } catch (error) { |
| 104 | + if (error instanceof WaitDeadlineError) return timedOut(started, now()); |
| 105 | + return failedObservation(started, now(), error); |
| 106 | + } |
| 107 | + const initial = evaluateBrowserExpectation(options.expect, baseline, baseline, runtime.resolveRef); |
| 108 | + if (expired()) return timedOut(started, now()); |
| 109 | + if (initial.reason === "stale_ref") return terminal("interrupted", "unverifiable", initial, initial, started, now(), initial.reason); |
| 110 | + if (initial.truth === true) return terminal("satisfied", "preexisting", initial, initial, started, now()); |
| 111 | + const dialogs = runtime.dialogCount(); |
| 112 | + let final = initial; |
| 113 | + while (!expired()) { |
| 114 | + await sleep(Math.min(poll, remaining())); |
| 115 | + if (expired()) break; |
| 116 | + let exists: boolean; |
| 117 | + try { exists = await beforeDeadline(() => runtime.targetExists(targetId), remaining(), now); } |
| 118 | + catch (error) { if (error instanceof WaitDeadlineError) break; return failedObservation(started, now(), error, initial, final); } |
| 119 | + if (expired()) break; |
| 120 | + if (!exists) return terminal("interrupted", "unverifiable", initial, final, started, now(), "target_detached"); |
| 121 | + let observation: BrowserObservation; |
| 122 | + try { observation = await runtime.observeTarget(targetId); } |
| 123 | + catch (error) { return failedObservation(started, now(), error, initial, final); } |
| 124 | + if (observation.targetId !== targetId) return terminal("interrupted", "unverifiable", initial, final, started, now(), "target_changed"); |
| 125 | + if (runtime.dialogCount() > dialogs) return terminal("interrupted", "unverifiable", initial, final, started, now(), "dialog"); |
| 126 | + const navigated = observation.navigationEpoch !== baseline.navigationEpoch || [...observation.generations].some(([key, generation]) => generation !== runtime.liveGeneration(key) || (baseline.generations.has(key) && baseline.generations.get(key) !== generation)); |
| 127 | + if (navigated) { |
| 128 | + if (isLocationExpectation(options.expect)) { |
| 129 | + final = evaluateBrowserExpectation(options.expect, observation, baseline, runtime.resolveRef); |
| 130 | + if (final.truth === true && !expired()) return terminal("satisfied", "newly_verified", initial, final, started, now()); |
| 131 | + } |
| 132 | + return terminal("interrupted", "unverifiable", initial, final, started, now(), "navigation"); |
| 133 | + } |
| 134 | + if (expired()) break; |
| 135 | + final = evaluateBrowserExpectation(options.expect, observation, baseline, runtime.resolveRef); |
| 136 | + if (expired()) break; |
| 137 | + if (final.reason === "stale_ref") return terminal("interrupted", "unverifiable", initial, final, started, now(), final.reason); |
| 138 | + if (final.truth === true) return terminal("satisfied", "newly_verified", initial, final, started, now()); |
| 139 | + } |
| 140 | + if (final.truth === undefined) return terminal("unverifiable", "unverifiable", initial, final, started, now(), final.reason ?? "incomplete_observation"); |
| 141 | + return terminal("timed_out", "failed", initial, final, started, now()); |
| 142 | +} |
| 143 | + |
| 144 | +class WaitDeadlineError extends Error {} |
| 145 | + |
| 146 | +async function beforeDeadline<T>(operation: () => Promise<T>, remaining: number, now: () => number): Promise<T> { |
| 147 | + if (remaining <= 0) throw new WaitDeadlineError(); |
| 148 | + const started = now(); |
| 149 | + try { |
| 150 | + const result = await operation(); |
| 151 | + if (Number.isFinite(remaining) && now() - started >= remaining) throw new WaitDeadlineError(); |
| 152 | + return result; |
| 153 | + } catch (error) { |
| 154 | + if (error instanceof WaitDeadlineError || Number.isFinite(remaining) && now() - started >= remaining) throw new WaitDeadlineError(); |
| 155 | + throw error; |
| 156 | + } |
| 157 | +} |
| 158 | + |
| 159 | +function isLocationExpectation(expectation: CuaBrowserExpectation): boolean { |
| 160 | + if ("all" in expectation) return expectation.all.every(isLocationExpectation); |
| 161 | + if ("any" in expectation) return expectation.any.every(isLocationExpectation); |
| 162 | + return expectation.type === "url" || expectation.type === "title"; |
| 163 | +} |
| 164 | + |
| 165 | +function timedOut(started: number, ended: number): BrowserWaitForResult { |
| 166 | + const evidence = { truth: undefined, details: [] }; |
| 167 | + return terminal("timed_out", "failed", evidence, evidence, started, ended); |
| 168 | +} |
| 169 | + |
| 170 | +function terminal(status: BrowserWaitForResult["status"], evidence: BrowserWaitForResult["evidence"], initial: BrowserExpectationEvidence, final: BrowserExpectationEvidence, started: number, ended: number, reason?: BrowserWaitReason): BrowserWaitForResult { |
| 171 | + return { status, evidence, initial, final, elapsed_ms: Math.max(0, ended - started), ...(reason ? { reason } : {}), details: [...initial.details.map((detail) => `initial: ${detail}`), ...final.details.map((detail) => `final: ${detail}`)] }; |
| 172 | +} |
| 173 | + |
| 174 | +function failedObservation(started: number, ended: number, error: unknown, initial: BrowserExpectationEvidence = { truth: undefined, details: [] }, final = initial): BrowserWaitForResult { |
| 175 | + const message = error instanceof Error ? error.message : String(error); |
| 176 | + return { ...terminal("unverifiable", "unverifiable", initial, final, started, ended, "observation_failed"), details: [...initial.details, message] }; |
| 177 | +} |
0 commit comments