aboutsummaryrefslogtreecommitdiff
path: root/src/domains/ref-resolve.ts
diff options
context:
space:
mode:
Diffstat (limited to 'src/domains/ref-resolve.ts')
-rw-r--r--src/domains/ref-resolve.ts152
1 files changed, 152 insertions, 0 deletions
diff --git a/src/domains/ref-resolve.ts b/src/domains/ref-resolve.ts
new file mode 100644
index 0000000..3f636df
--- /dev/null
+++ b/src/domains/ref-resolve.ts
@@ -0,0 +1,152 @@
+import type { BrowserClient } from "../client";
+import { type Result, err, ok } from "../util/result";
+import type { ToolErr } from "../util/tool";
+import { buildTree, collectInteractiveTargets, type RawAxNode, type SlimNode } from "./snapshot";
+
+type Box = { x: number; y: number; width: number; height: number; cx: number; cy: number };
+
+const staleErr = (ref: string): ToolErr => ({
+ kind: "invalid_state",
+ message: `Ref ${ref} is unknown or stale — re-run browser_snapshot to get fresh refs.`,
+ details: { ref },
+});
+
+/**
+ * Resolve a ref to its CDP objectId (a handle to the live JS node). Use when a
+ * tool needs to run JS against the element (fill, focus, select, dispatch_key).
+ * Re-resolves through the backendNodeId at call time, so it reflects the current
+ * DOM even after re-renders — and fails loudly if the node was detached.
+ */
+export const resolveRefToObjectId = async (
+ client: BrowserClient,
+ ref: string,
+): Promise<Result<string, ToolErr>> => {
+ const backendId = client.session().resolveRef(ref);
+ if (backendId === undefined) return err(staleErr(ref));
+ const resolved = await client.session().call("DOM.resolveNode", { backendNodeId: backendId });
+ if (!resolved.success) {
+ // A detached node (removed by a re-render) fails to resolve — surface it as
+ // a stale ref rather than a raw CDP error so the agent knows to re-snapshot.
+ return err(staleErr(ref));
+ }
+ const objectId = (resolved.data as { object?: { objectId?: string } }).object?.objectId;
+ if (!objectId) return err(staleErr(ref));
+ return ok(objectId);
+};
+
+/** Resolve a ref to its current backendNodeId (for CDP calls that take one, e.g. DOM.setFileInputFiles). */
+export const resolveRefToBackendId = (client: BrowserClient, ref: string): Result<number, ToolErr> => {
+ const backendId = client.session().resolveRef(ref);
+ if (backendId === undefined) return err(staleErr(ref));
+ return ok(backendId);
+};
+
+/**
+ * Resolve a ref to the current center of its bounding box, in viewport CSS
+ * pixels. Use for browser_click. Fetched fresh via DOM.getBoxModel, so a
+ * re-rendered/moved element is clicked at its new position.
+ */
+export const resolveRefToBox = async (
+ client: BrowserClient,
+ ref: string,
+): Promise<Result<Box, ToolErr>> => {
+ const backendId = client.session().resolveRef(ref);
+ if (backendId === undefined) return err(staleErr(ref));
+ const r = await client.session().call("DOM.getBoxModel", { backendNodeId: backendId });
+ if (!r.success) return err(staleErr(ref));
+ const data = r.data as { model?: { content?: ReadonlyArray<number>; width?: number; height?: number } };
+ const content = data.model?.content;
+ if (!content || content.length < 8) return err(staleErr(ref));
+ const x = content[0]!;
+ const y = content[1]!;
+ const width = data.model!.width ?? 0;
+ const height = data.model!.height ?? 0;
+ if (width <= 0 || height <= 0) return err(staleErr(ref));
+ return ok({
+ x: Math.round(x),
+ y: Math.round(y),
+ width: Math.round(width),
+ height: Math.round(height),
+ cx: Math.round(x + width / 2),
+ cy: Math.round(y + height / 2),
+ });
+};
+
+const sigOf = (n: SlimNode): string => `${n.role}|${n.name ?? ""}|${n.value ?? ""}|${n.state ?? ""}`;
+const keyOf = (n: SlimNode): string => `${n.role}|${n.name ?? ""}`;
+
+/**
+ * Recompute the interactive elements after a mutation and return a compact,
+ * human/LLM-readable diff vs. the prior snapshot's baseline — changed values,
+ * newly-appeared elements (marked `*`), and removed ones. Re-publishes the fresh
+ * ref map so subsequent ref-based calls target the post-mutation DOM.
+ *
+ * Deliberately cheap: it re-reads the AX tree (no per-node box fetch — the
+ * expensive part of a full snapshot) and emits a few lines, not a full tree, so
+ * a long action sequence doesn't blow up the context.
+ *
+ * Returns "" on any failure or when nothing changed — callers append it only if
+ * non-empty, so a diff is best-effort and never blocks the primary action.
+ */
+export const interactiveDiff = async (client: BrowserClient): Promise<string> => {
+ const session = client.session();
+ const prev = session.refSignatures();
+
+ const axRes = await session.call("Accessibility.getFullAXTree", {});
+ if (!axRes.success) return "";
+ const rawNodes = (axRes.data as { nodes: ReadonlyArray<RawAxNode> }).nodes;
+ const slim = buildTree(rawNodes, { interestingOnly: true, maxNodes: 1000 });
+ const targets = collectInteractiveTargets(slim);
+
+ // Re-publish fresh refs so later ref calls resolve against the new DOM.
+ const refMap = new Map<string, number>();
+ const refSig = new Map<string, string>();
+ targets.forEach(({ node, backendId }, i) => {
+ const ref = `e${i + 1}`;
+ node.ref = ref;
+ refMap.set(ref, backendId);
+ refSig.set(ref, sigOf(node));
+ });
+ session.setRefMap(refMap, refSig);
+
+ // Compare by stable identity (role|name) rather than ref position, since refs
+ // renumber when the element set changes. Build prev/current identity → value.
+ const prevByKey = new Map<string, string>();
+ for (const sig of prev.values()) {
+ const parts = sig.split("|");
+ prevByKey.set(`${parts[0]}|${parts[1]}`, sig);
+ }
+ const curByKey = new Map<string, { ref: string; sig: string }>();
+ for (const { node } of targets) {
+ if (node.ref) curByKey.set(keyOf(node), { ref: node.ref, sig: sigOf(node) });
+ }
+
+ const appeared: string[] = [];
+ const changed: string[] = [];
+ for (const [key, { ref, sig }] of curByKey) {
+ const before = prevByKey.get(key);
+ if (before === undefined) {
+ appeared.push(` *[${ref}] ${key.replace("|", ' "')}"`);
+ } else if (before !== sig) {
+ const va = before.split("|")[2] ?? "";
+ const vb = sig.split("|")[2] ?? "";
+ const sa = before.split("|")[3] ?? "";
+ const sb = sig.split("|")[3] ?? "";
+ const detail = va !== vb ? `value ${JSON.stringify(va)} → ${JSON.stringify(vb)}` : `state ${JSON.stringify(sa)} → ${JSON.stringify(sb)}`;
+ changed.push(` [${ref}] ${key.replace("|", ' "')}": ${detail}`);
+ }
+ }
+ const removed: string[] = [];
+ for (const key of prevByKey.keys()) {
+ if (!curByKey.has(key)) removed.push(` ${key.replace("|", ' "')}"`);
+ }
+
+ if (appeared.length === 0 && changed.length === 0 && removed.length === 0) return "";
+ const lines: string[] = ["", "Page changes (re-snapshot for full tree):"];
+ if (changed.length) lines.push("Changed:", ...changed.slice(0, 12));
+ if (appeared.length) lines.push("New (*):", ...appeared.slice(0, 12));
+ if (removed.length) lines.push("Removed:", ...removed.slice(0, 8));
+ const overflow = Math.max(0, changed.length - 12) + Math.max(0, appeared.length - 12) + Math.max(0, removed.length - 8);
+ if (overflow > 0) lines.push(` …and ${overflow} more (browser_snapshot for full state)`);
+ return lines.join("\n");
+};