From 641d934337fa0dfa37fe4d960091a744e6d35b04 Mon Sep 17 00:00:00 2001 From: drakezhang Date: Tue, 1 Sep 2026 14:55:36 +0800 Subject: [PATCH 1/8] perf(vom): optimize rendering algorithms --- packages/vom/src/__tests__/render.test.ts | 105 ++++++++++++ packages/vom/src/render.ts | 200 +++++++++++++++------- 2 files changed, 239 insertions(+), 66 deletions(-) diff --git a/packages/vom/src/__tests__/render.test.ts b/packages/vom/src/__tests__/render.test.ts index fa02ae5..a064df6 100644 --- a/packages/vom/src/__tests__/render.test.ts +++ b/packages/vom/src/__tests__/render.test.ts @@ -298,6 +298,34 @@ describe("renderVom single-layer page", () => { expect(coreRefs(out.refs)).toEqual([{ ref: "e1", backendNodeId: 4 }]); }); + it("renders through deeply nested transparent wrappers without exhausting the call stack", () => { + const wrapperCount = 10_000; + const nodes = [node({ id: 1, role: "RootWebArea", name: "Doc" })]; + for (let index = 0; index < wrapperCount; index += 1) { + nodes.push( + node({ + id: index + 2, + parentId: index + 1, + role: "generic", + }), + ); + } + nodes.push( + node({ + id: wrapperCount + 2, + parentId: wrapperCount + 1, + role: "button", + name: "Deep action", + tag: "button", + }), + ); + + const out = renderVom(scene(nodes)); + + expect(out.text).toContain('@e1 button "Deep action"'); + expect(coreRefs(out.refs)).toEqual([{ ref: "e1", backendNodeId: wrapperCount + 2 }]); + }); + it("truncates when maxTokens is exceeded while keeping refs in sync with rendered text", () => { const out = renderVom( scene([ @@ -785,6 +813,55 @@ describe("renderVom single-layer page", () => { expect(out.text).not.toContain("北京市小客车指标调控管理信息系统]"); }); + it("derives duplicate-label context from indexed DOM ancestry without semantic proximity", () => { + const out = renderVom( + scene([ + node({ id: 1, role: "RootWebArea", name: "Doc", contextScopeId: "root" }), + node({ + id: 3, + parentId: 1, + role: "button", + name: "View", + tag: "button", + domParentId: 2, + domAncestorIds: [2, 1], + contextScopeId: "root", + }), + node({ + id: 5, + parentId: 1, + role: "button", + name: "View", + tag: "button", + domParentId: 4, + domAncestorIds: [4, 1], + contextScopeId: "root", + }), + node({ + id: 2, + parentId: 1, + role: "group", + name: "Project Alpha", + domParentId: 1, + domAncestorIds: [1], + contextScopeId: "root", + }), + node({ + id: 4, + parentId: 1, + role: "group", + name: "Project Beta", + domParentId: 1, + domAncestorIds: [1], + contextScopeId: "root", + }), + ]), + ); + + expect(out.text).toContain('@e1 button "View" [ctx: Project Alpha]'); + expect(out.text).toContain('@e2 button "View" [ctx: Project Beta]'); + }); + it("does not add low-value root context to duplicated brand links", () => { const out = renderVom( scene([ @@ -833,6 +910,34 @@ describe("renderVom single-layer page", () => { }); describe("renderVom double-layer page", () => { + it("collects blocking-layer descendants even when children precede parents in source order", () => { + const out = renderVom( + scene([ + node({ id: 1, role: "RootWebArea", name: "Doc" }), + node({ + id: 3, + parentId: 2, + role: "button", + name: "Close", + tag: "button", + paintOrder: 0, + }), + node({ + id: 2, + parentId: 1, + role: "dialog", + name: "Modal", + rect: { x: 0, y: 0, w: 1280, h: 720 }, + paintOrder: 10, + position: "fixed", + }), + ]), + ); + + expect(out.text).toContain('@e1 button "Close"'); + expect(coreRefs(out.refs)).toEqual([{ ref: "e1", backendNodeId: 3 }]); + }); + it("renders a blocking modal first and folds the base page into an occluded summary", () => { const out = renderVom( scene([ diff --git a/packages/vom/src/render.ts b/packages/vom/src/render.ts index b943dbd..c7c4164 100644 --- a/packages/vom/src/render.ts +++ b/packages/vom/src/render.ts @@ -421,6 +421,41 @@ function buildChildren(nodes: VomNode[]): Map { return children; } +const CONTEXT_LABEL_ROLES = new Set([ + "heading", + "article", + "section", + "group", + "region", + "row", + "form", +]); + +interface DomContextIndex { + nonBoundaryByScopeParent: Map>; + sourceOrderById: Map; +} + +function buildDomContextIndex(nodes: VomNode[]): DomContextIndex { + const nonBoundaryByScopeParent = new Map>(); + const sourceOrderById = new Map(); + for (const [sourceOrder, node] of nodes.entries()) { + sourceOrderById.set(node.id, sourceOrder); + if (!CONTEXT_LABEL_ROLES.has(normalizedRole(node)) || isContextBoundary(node)) continue; + const parentId = node.domParentId; + if (parentId === undefined || parentId === null) continue; + let byParent = nonBoundaryByScopeParent.get(node.contextScopeId); + if (!byParent) { + byParent = new Map(); + nonBoundaryByScopeParent.set(node.contextScopeId, byParent); + } + const siblings = byParent.get(parentId) ?? []; + siblings.push(node); + byParent.set(parentId, siblings); + } + return { nonBoundaryByScopeParent, sourceOrderById }; +} + function duplicateReferenceNames(nodes: VomNode[]): Set { const counts = new Map(); for (const node of nodes) { @@ -440,13 +475,7 @@ function needsHandleContext(nodeName: string, duplicateRefNames: Set): b } function contextLabel(node: VomNode, targetName: string): string | undefined { - if ( - !["heading", "article", "section", "group", "region", "row", "form"].includes( - normalizedRole(node), - ) - ) { - return undefined; - } + if (!CONTEXT_LABEL_ROLES.has(normalizedRole(node))) return undefined; return weakContextText( cleaned(node.name) ?? cleaned(node.text) ?? cleaned(node.nearbyText), targetName, @@ -607,19 +636,45 @@ function collectDomContext( ): ContextCandidate[] { const ancestorIds = node.domAncestorIds ?? []; if (ancestorIds.length === 0) return []; - const ancestors = new Set(ancestorIds); const contexts: ContextCandidate[] = []; + const ancestorRank = new Map(); + for (const [rank, ancestorId] of ancestorIds.entries()) { + if (!ancestorRank.has(ancestorId)) ancestorRank.set(ancestorId, rank); + } - for (const candidate of state.nodesById.values()) { + const candidates = new Map(); + const nonBoundaryByParent = state.domContextIndex.nonBoundaryByScopeParent.get( + node.contextScopeId, + ); + for (const ancestorId of ancestorRank.keys()) { + const boundary = state.nodesById.get(ancestorId); + const boundaryParentId = boundary?.domParentId; + if ( + boundary && + sharesContextScope(node, boundary) && + isContextBoundary(boundary) && + boundaryParentId !== undefined && + boundaryParentId !== null && + ancestorRank.has(boundaryParentId) + ) { + candidates.set(boundary.id, boundary); + } + for (const candidate of nonBoundaryByParent?.get(ancestorId) ?? []) { + candidates.set(candidate.id, candidate); + } + } + + const sourceOrderById = state.domContextIndex.sourceOrderById; + const orderedCandidates = [...candidates.values()].sort( + (a, b) => (sourceOrderById.get(a.id) ?? 0) - (sourceOrderById.get(b.id) ?? 0), + ); + for (const candidate of orderedCandidates) { if (candidate.id === node.id) continue; - if (!sharesContextScope(node, candidate)) continue; - if (isContextBoundary(candidate) && !ancestorIds.includes(candidate.id)) continue; const parentId = candidate.domParentId; - if (parentId === undefined || parentId === null || !ancestors.has(parentId)) continue; + if (parentId === undefined || parentId === null) continue; const label = contextLabel(candidate, nodeName); if (!label) continue; - const rank = ancestorIds.indexOf(parentId); - contexts.push({ text: label, source: "dom", order: rank < 0 ? contexts.length : rank }); + contexts.push({ text: label, source: "dom", order: ancestorRank.get(parentId) ?? 0 }); } return contexts; @@ -734,6 +789,7 @@ interface RenderState { children: Map; parentMap: Map; nodesById: Map; + domContextIndex: DomContextIndex; duplicateRefNames: Set; surfaceMap: Map; scopeMap: Map; @@ -768,20 +824,25 @@ function emitActiveScopeBlock(scope: ActiveScopeBlock, depth: number, state: Ren } } -function renderTree( - children: Map, - parentId: number | null, +function pushRenderChildren( + stack: Array<{ node: VomNode; depth: number }>, + children: readonly VomNode[], depth: number, - state: RenderState, ): void { - if (state.stopped) return; + for (let index = children.length - 1; index >= 0; index -= 1) { + stack.push({ node: children[index], depth }); + } +} - const nodes = children.get(parentId) ?? []; - for (const node of nodes) { - if (state.stopped) return; +function renderTree(children: Map, state: RenderState): void { + const stack: Array<{ node: VomNode; depth: number }> = []; + pushRenderChildren(stack, children.get(null) ?? [], 1); + + while (stack.length > 0 && !state.stopped) { + const { node, depth } = stack.pop() as { node: VomNode; depth: number }; if (!shouldRender(node)) { - renderTree(children, node.id, depth, state); + pushRenderChildren(stack, children.get(node.id) ?? [], depth); continue; } @@ -798,7 +859,7 @@ function renderTree( if (nextTokens > state.maxTokens) { state.truncated = true; state.stopped = true; - return; + break; } state.lines.push(line); @@ -823,7 +884,7 @@ function renderTree( if ((ref && shouldSkipRedundantRefChildren(node)) || shouldSkipRedundantChildren(node, state)) { continue; } - renderTree(children, node.id, depth + 1, state); + pushRenderChildren(stack, children.get(node.id) ?? [], depth + 1); } } @@ -848,12 +909,13 @@ function renderNodes( children, parentMap: buildParentMap(nodes), nodesById: new Map(nodes.map((node) => [node.id, node])), + domContextIndex: buildDomContextIndex(nodes), duplicateRefNames: duplicateReferenceNames(nodes), surfaceMap: new Map(surfaces.map((surface) => [surface.triggerId, surface])), scopeMap: new Map(activeScopeBlocks.map((scope) => [scope.triggerId, scope])), }; - renderTree(children, null, 1, state); + renderTree(children, state); return state; } @@ -922,23 +984,33 @@ function paintLineageByFrame( return lineage; } +function cachedPaintLineageByFrame( + node: VomNode, + parentMap: Map, + nodesById: Map, + cache: Map>, +): Map { + const cached = cache.get(node.id); + if (cached) return cached; + const lineage = paintLineageByFrame(node, parentMap, nodesById); + cache.set(node.id, lineage); + return lineage; +} + /** Resolve both nodes to the nearest document where their paint order is comparable. */ function comparablePaintNodes( target: VomNode, blocker: VomNode, parentMap: Map, nodesById: Map, + lineageCache: Map>, ): { target: VomNode; blocker: VomNode } | null { if (target.frameId === blocker.frameId) return { target, blocker }; - const targetLineage = paintLineageByFrame(target, parentMap, nodesById); - let blockerInFrame: VomNode | undefined = blocker; - let guard = 0; - while (blockerInFrame && guard <= parentMap.size) { - const targetInFrame = targetLineage.get(blockerInFrame.frameId); + const targetLineage = cachedPaintLineageByFrame(target, parentMap, nodesById, lineageCache); + const blockerLineage = cachedPaintLineageByFrame(blocker, parentMap, nodesById, lineageCache); + for (const [frameId, blockerInFrame] of blockerLineage) { + const targetInFrame = targetLineage.get(frameId); if (targetInFrame) return { target: targetInFrame, blocker: blockerInFrame }; - const parentId: number | null = parentMap.get(blockerInFrame.id) ?? null; - blockerInFrame = parentId === null ? undefined : nodesById.get(parentId); - guard += 1; } return null; } @@ -949,12 +1021,13 @@ function isBlockedByRegion( parentMap: Map, nodesById: Map, viewportCoverage: number, + lineageCache: Map>, ): boolean { if (target.id === blocker.id) return false; if (isAncestorOf(parentMap, blocker.id, target.id)) return false; if (isAncestorOf(parentMap, target.id, blocker.id)) return false; if (!target.rect || !blocker.rect) return false; - const paintNodes = comparablePaintNodes(target, blocker, parentMap, nodesById); + const paintNodes = comparablePaintNodes(target, blocker, parentMap, nodesById, lineageCache); if (!paintNodes) return false; const points = interactionPoints(target.rect); @@ -979,17 +1052,22 @@ function isBlockedByRegion( return points.some(([x, y]) => rectContains(blocker.rect as Rect, x, y)); } -function collectDescendantsOfIds(nodes: VomNode[], roots: Set): Set { +function collectDescendants(nodes: VomNode[], roots: Set): Set { + const children = new Map(); + for (const node of nodes) { + if (node.parentId === null) continue; + const siblings = children.get(node.parentId) ?? []; + siblings.push(node.id); + children.set(node.parentId, siblings); + } + const included = new Set(roots); - let changed = true; - while (changed) { - changed = false; - for (const node of nodes) { - if (included.has(node.id)) continue; - if (node.parentId !== null && included.has(node.parentId)) { - included.add(node.id); - changed = true; - } + const queue = [...roots]; + for (let index = 0; index < queue.length; index += 1) { + for (const childId of children.get(queue[index]) ?? []) { + if (included.has(childId)) continue; + included.add(childId); + queue.push(childId); } } return included; @@ -998,6 +1076,7 @@ function collectDescendantsOfIds(nodes: VomNode[], roots: Set): Set [node.id, node])); + const lineageCache = new Map>(); const candidates = nodes .filter(isPositionedRegionCandidate) .map((node) => { @@ -1014,40 +1093,29 @@ function applyActiveRegionPolicy(nodes: VomNode[], scene: VomScene): VomNode[] { for (const target of nodes) { if (!isVomReferenceNode(target)) continue; const blocked = candidates.some((candidate) => - isBlockedByRegion(target, candidate.node, parentMap, nodesById, candidate.viewportCoverage), + isBlockedByRegion( + target, + candidate.node, + parentMap, + nodesById, + candidate.viewportCoverage, + lineageCache, + ), ); if (blocked) blockedRoots.add(target.id); } if (blockedRoots.size === 0) return nodes; - const blocked = collectDescendantsOfIds(nodes, blockedRoots); + const blocked = collectDescendants(nodes, blockedRoots); return nodes.filter((node) => !blocked.has(node.id)); } -function collectDescendantsOfMembers(nodes: VomNode[], members: Set): Set { - const included = new Set(members); - let changed = true; - - while (changed) { - changed = false; - for (const node of nodes) { - if (included.has(node.id)) continue; - if (node.parentId !== null && included.has(node.parentId)) { - included.add(node.id); - changed = true; - } - } - } - - return included; -} - function countRenderable(nodes: VomNode[]): number { return nodes.filter(shouldRender).length; } function renderDoubleLayer(scene: VomScene, layer: BlockingLayer, options: VomOptions): VomResult { - const included = collectDescendantsOfMembers(scene.nodes, layer.members); + const included = collectDescendants(scene.nodes, layer.members); const visibleNodes = scene.nodes.filter((node) => included.has(node.id)); const hiddenCount = countRenderable(scene.nodes.filter((node) => !included.has(node.id))); const header = [ From 536d3a9844c4347e5f12195c8d9db446ab3c6383 Mon Sep 17 00:00:00 2001 From: drakezhang Date: Tue, 1 Sep 2026 18:39:06 +0800 Subject: [PATCH 2/8] chore: ignore local pnpm store and git worktrees Both directories are local scratch space that kept showing up as untracked noise in `git status`. Co-authored-by: Cursor --- .gitignore | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.gitignore b/.gitignore index 744b61c..3a8a21f 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,10 @@ dist/ .output/ .wxt/ .web-extension-id +.pnpm-store/ + +# Git worktrees +.worktrees/ # Logs *.log From 59d6533eff4f9a15504143c33670ab7ba1ff8e9d Mon Sep 17 00:00:00 2001 From: drakezhang Date: Tue, 1 Sep 2026 18:39:19 +0800 Subject: [PATCH 3/8] docs(skill): trim agent instructions to a leaner core The CLI skill had grown to 20.8 KB of command tables and flag catalogs that `bsk --help` already answers at runtime, so every agent paid for them on every task. Rewrite both skills around the durable rules an agent cannot rediscover -- session lifecycle, the observe/act/observe loop, the Agent Window boundary, and the stop-on-success contract -- and delegate flag details to the CLI's own help output. The CLI skill drops to 6.9 KB (-67%) and the DSH skill to 5.1 KB. Keep the parts that help discovery has no answer for: the literal `[hover first: ...]` observation marker, the `request-help` outcome values, the complete command-name inventory, and the required flags agents guess wrong (`fill --value`, `select --value`, `screenshot --out`, `emulate --device`). Also bind `--no-focus` explicitly to `bsk session start`, since a bare mention led agents to pass it to `bsk navigate`. Validated with 36 agent runs of the `core` eval suite against a real browser, comparing the old and new instructions with everything else held constant. Pass rate did not regress (the lean version was better on `form-controls`) and token cost per task came out even. The inventory and flag signatures cut `--help` round-trips from 1.7 to 0.7 per task and removed the invented `bsk back` / `bsk forward` calls the first draft produced. Tighten the DSH size assertion to a range so the budget is bounded from both sides and accidental truncation fails the test. Co-authored-by: Cursor --- crates/bsk-cli/skill/SKILL.md | 381 +++++------------- .../dsh-plugin-browserskill/skill/SKILL.md | 175 +++----- .../tests/skill.test.ts | 5 +- skill/SKILL.md | 381 +++++------------- 4 files changed, 266 insertions(+), 676 deletions(-) diff --git a/crates/bsk-cli/skill/SKILL.md b/crates/bsk-cli/skill/SKILL.md index 42bb452..f310516 100644 --- a/crates/bsk-cli/skill/SKILL.md +++ b/crates/bsk-cli/skill/SKILL.md @@ -1,327 +1,142 @@ --- name: browser-skill description: | - Use when the user asks to perform browser automation tasks against their - logged-in browser: visit and read pages, fill forms, scrape data, click - through a flow, regression-test a PR's UI, validate a deployed page. - Requires the bsk CLI installed and the browser-skill extension loaded. + Use when the user asks to automate their logged-in Chromium browser: visit + and read pages, fill forms, scrape data, click through flows, regression-test + a PR's UI, validate a deployed page, or operate a tab they identify. Requires + the bsk CLI and browser extension. --- # browser-skill -Drive the user's **real Chromium browser** (with their logins and cookies) through the `bsk` CLI. The extension opens an isolated **Agent Window** for automation; the user's normal windows stay protected unless you explicitly borrow a tab. +Drive the user's real Chromium browser through `bsk`. Automation runs in an isolated **Agent +Window** with the user's existing logins and cookies. User-window tabs remain protected unless they +are explicitly borrowed. -## When to use +Do not use this skill for tasks with no browser, for extension installation, or when the user only +wants instructions. Never extract credentials, cookies, tokens, or other secrets from pages. -- Open pages, read titles/text, scrape structured data from sites the user can already access -- Fill forms, click through multi-step flows, smoke-test a UI change -- Understand pages with `bsk snapshot` first; use `bsk get-html` or `bsk screenshot` only when the snapshot is insufficient -- Operate on a specific user tab they point you at (after `bsk tab borrow`) +## Required lifecycle -## When NOT to use +Every browser task owns a bounded session: -- Tasks with **no browser** involved (files, APIs, databases only) -- Installing or configuring the extension (point the user to setup docs instead) -- **Credential harvesting** — never run `bsk evaluate` on banking, SSO, or password-manager pages to extract tokens, cookies, or secrets -- Long-lived control of a user's personal login window — borrow only for the immediate step, then `bsk tab return` or end the session -- Replacing the user's manual browsing when they only wanted an explanation - -## Prerequisites - -1. `bsk` on `PATH` (Rust CLI from browser-skill) -2. browser-skill **extension** loaded in Chromium and connected (popup shows green, or an upgrade reminder — still connected; continue) -3. Any `bsk` command auto-starts background services as needed; use `bsk doctor` if anything fails - -## Mandatory workflow - -Every automation task **must** follow this lifecycle. Do **not** rely on idle timeouts (default session idle is 5 minutes). - -``` -1. bsk session start → capture the 4-letter session id printed on stdout -2. … every tool command … → always pass --session -3. bsk session stop → REQUIRED when done (even on error paths) +```text +1. bsk session start # retain the printed 4-letter session id +2. bsk ... --session # pass it to every session-scoped command +3. bsk session stop # always run on success and error paths ``` -Optional: `bsk session start --browser ` when multiple browsers are connected (`bsk browsers` / error output lists them). Add `--no-focus` to open the Agent Window in the background without stealing focus from the user's current window. +Do not rely on the idle timeout for cleanup. Stop the session as soon as the goal is met unless the +user explicitly asks to keep it open. Stopping also returns borrowed tabs. -Emergency cleanup: `bsk session stop --all` or the Agent Window overlay **Stop all**. +Any `bsk` command auto-starts the background services it needs; never manage the daemon by hand. +When multiple browsers are connected, use `bsk browsers` and start with +`bsk session start --browser `. Add `--no-focus` to that same start command when the +Agent Window does not need to interrupt the user's current work; it is not a flag on other commands. +Run `bsk doctor` when startup or transport problems persist after one retry. -## Stop when the goal is met +## Work toward one observable goal -Every task is a **bounded goal**, not open-ended browsing. The goal may come from the user's request, a recorded `trace.json`, or both. +- Derive a concrete success condition from the user's request or a supplied trace. +- Take the shortest purposeful path: observe, act, then make at most one observation to confirm an + ambiguous result. +- Once success is visible, do not click, refresh, navigate, switch tabs, or perform extra checks. +- If a human-only step appears or two attempts make no progress, request help instead of + brute-forcing. -1. **Define success first** — one concrete, observable condition derived from the user's words, `purpose`, or the last meaningful step in a trace (e.g. "form submitted", "item added to cart", "playback started"). -2. **Take the shortest path** — snapshot → act → at most one check. Do not wander, re-try unrelated actions, or stack exploratory steps. -3. **Stop as soon as success is reached** — run `bsk session stop ` immediately unless the user explicitly asked to keep the session open (e.g. "don't close yet", "keep browsing"). -4. **No post-success work** — once the goal is met, do not click, refresh, navigate, re-search, switch tabs, or "double-check" that it worked. Further verification is a new task. -5. **When blocked, pause — do not brute-force** — if the page requires human input (login, captcha, OTP, payment confirmation) or an action fails twice with no progress, call `bsk request-help` instead of retrying blindly. See **Ask the human for help** below. -6. **When unsure** — at most one extra `bsk snapshot`. If success looks met, stop. If not, ask the user; do not keep clicking. +With a trace, follow its semantic target information and values in order, but treat its refs as +record-local hints. Stop when its purpose or last meaningful effect is satisfied. A trace guides the +task; it does not expand the user's goal or authorize additional actions. -**With a trace:** replay steps in order using `target` role/name/tag and raw `value`/`selection` fields. After the last step (or when its `effect.navigated_to` / success hint is satisfied), apply rules 3–4 immediately. The trace guides execution; it does not extend control beyond the goal. +## Observe, act, observe -**Without a trace:** the user's request *is* the success condition. Satisfying it ends the task — same stop rules apply. +Use this default loop: -## Core interaction loop - -Write operations only affect tabs in the **Agent Window** (or tabs you **borrowed** into it). - -``` +```text bsk navigate --session -bsk observe --session → primary semantic VOM view; reveals hover/focus surfaces -bsk snapshot --session → static aria tree fallback when VOM is insufficient -bsk hover @e3 --session → reveal hover-triggered menus before re-observing/clicking -bsk click @e4 --session → or bsk fill, bsk select, bsk press -bsk observe --session → again after navigation / DOM change +bsk observe --session +bsk click|hover|fill|select|press ... --session +bsk observe --session # after navigation or a meaningful DOM change ``` -**Refs invalidate after navigation** — always re-snapshot before clicking, filling, or selecting on a new page. +Prefer fresh `@eN` refs over CSS selectors. Navigation invalidates refs; large DOM changes may also +make them stale. Observe again before the next interaction. -Prefer `@eN` refs from the latest snapshot over raw CSS selectors. Use `--ref` / `--selector` when ambiguous (`bsk click --help`). +An observation marks a hover-only surface as `@e1 button "Products" [hover first: Shoes | Bags]`. +The listed items are labels, not usable refs: hover the trigger, observe again, then act on the +revealed item's own ref. Do not click the trigger itself unless the user wants the trigger's action. -When VOM renders `[hover first: …]` on an element, the listed items are not currently clickable refs. Run `bsk hover --session `, then immediately run `bsk snapshot` or `bsk observe` again and click the newly visible menu item ref. Do not click the trigger itself unless the user explicitly wants the trigger action. +Escalate page reading only as needed: -## Observation priority +1. `bsk observe` for normal semantic understanding, text, controls, and refs. +2. `bsk snapshot` when a stricter static accessibility tree is more useful. +3. `bsk get-html` for exact markup or hidden metadata that semantic views cannot provide. +4. `bsk screenshot` for layout, styling, canvas, images, or requested visual evidence. -Start with `bsk observe` to understand page structure, text, controls, element refs, and conditional hover/focus surfaces. Use `bsk snapshot` only when you need the stricter static accessibility tree or VOM is insufficient. Only escalate to raw HTML or screenshots when the latest observation cannot answer the question: +Do not start with raw HTML or screenshots merely to discover ordinary controls. When interaction is +needed, obtain a fresh observation before acting on screenshot or HTML findings. -1. `bsk observe` — primary semantic VOM observation; may run bounded perception probes such as hover-surface discovery -2. `bsk snapshot` — strict static accessibility tree fallback -3. `bsk get-html` — when hidden DOM, metadata, or markup details are required -4. `bsk screenshot` — when visual layout, canvas/image content, or styling cannot be inferred from the observation. Use `--ref @eN` (from the latest snapshot/observe) to crop to one element; omit `--ref` for the full visible tab. +## Respect the Agent Window boundary -Do **not** call `bsk get-html` or `bsk screenshot` first just to inspect a page. +Normal page writes affect only Agent Window tabs. To operate a user tab, first list it with +`bsk tab list --scope user --session `, then `bsk tab borrow `. Return it immediately +after the relevant step with `bsk tab return `; never invent a tab id or keep a personal tab +borrowed across unrelated work. -## Sandbox rules +## Ask the human when needed -| Rule | Detail | -|------|--------| -| Agent Window | `bsk tab create`, `bsk navigate`, `bsk click`, etc. work on agent tabs by default | -| User tabs | Read-only until borrowed: `bsk tab list --session --scope user` then `bsk tab borrow --session ` | -| Return borrowed tabs | Call `bsk tab return --session ` when finished; unreturned tabs are **auto-returned** on `bsk session stop` | -| Writes off-agent | Commands that mutate the page fail if the tab is not in the Agent Window — borrow or create a tab first | +Use `bsk request-help` for login, captcha, OTP, payment confirmation, consent, or another step the +user must complete. Give a precise prompt and pass fresh `--target` refs/selectors when concrete +controls can be highlighted. Use completion criteria only when the page has a clear stable success +signal. -## Global flags +The result `outcome` is one of `continued`, `completed`, `cancelled`, `timed_out`, or `disabled` +(`navigated` is deprecated — never treat navigation as a completion signal). Resume only after +`continued` or `completed`. Treat `cancelled` as rejection, and `timed_out` or `disabled` as a +blocker rather than a reason to retry. After control returns, run a fresh `bsk observe` before +reasoning about the page or using refs. -| Flag | Purpose | -|------|---------| -| `--json` | Machine-readable JSON on stdout (errors too) | -| `--quiet` | Suppress informational stderr | -| `-v` / `-vv` | More verbose logging | +## Command inventory -Auto-update is **on by default** — the background daemon upgrades `bsk` itself when a new release is available (postponed while a session is active). Set `BSK_AUTO_UPDATE=off` to disable it and upgrade manually with `bsk update`. +This list of names is complete. Never invent a command outside it; read +`bsk --help` for flags instead of guessing them. -Command-specific flags (timeouts, `--tab-id`, `--wait-until`, …): **`bsk --help`** - -## CLI command reference (one line each) - -Details and flags: **`bsk --help`** - -### Diagnostics - -| Command | Summary | -|---------|---------| -| `bsk status` | Connection health, connected browsers, active sessions | -| `bsk doctor` | Deep diagnostics and repair hints | -| `bsk browsers` | List connected browser instances (ids, labels, versions) | - -### Session - -| Command | Summary | -|---------|---------| -| `bsk session start` | Open Agent Window (`--width`/`--height` for initial size); prints **4-letter session id** | -| `bsk session start --no-focus` | Open Agent Window in the background without stealing focus | -| `bsk session stop ` | End session, close Agent Window, auto-return borrowed tabs | -| `bsk session stop --all` | Stop every active session | -| `bsk session list` | List active sessions | - -### Window (require `--session `) - -| Command | Summary | -|---------|---------| -| `bsk window resize` | Resize the Agent Window (`--width`, `--height`; 100..=7680 CSS px) | - -### Device emulation — `bsk emulate` (requires `--session `) - -Emulate a mobile device environment on the agent tab via CDP — viewport, User-Agent, and touch — to debug a page's mobile layout and behaviour: - -```bash -bsk emulate --session --device iphone-14 -bsk emulate --session --width 390 --height 844 --dpr 3 --mobile --ua "Mozilla/5.0 (iPhone…" --touch -bsk emulate --session --off +```text +session start|stop|list browsers status doctor update logs +navigate navigate-back navigate-forward reload wait-for-navigation wait-ms +observe snapshot get-html screenshot console network +click hover fill select press evaluate +tab list|create|close|select|borrow|return window resize emulate +request-help record start|stop ``` -- Presets (`--device`): `iphone-14`, `iphone-14-pro-max`, `iphone-se`, `pixel-7`, `galaxy-s23`, `ipad-mini`, `galaxy-tab-s8`. Manual flags (`--width`/`--height`/`--dpr`/`--mobile`/`--ua`/`--accept-language`/`--touch`/`--max-touch-points`) also work without a preset, or override individual preset fields; `--no-mobile`/`--no-touch` turn a preset's mobile viewport / touch emulation back off. -- Repeated runs merge field by field onto the tab's current emulation state — only the flags you pass change. E.g. after `--device iphone-14`, `bsk emulate --session --width 390 --height 844` keeps the preset's dpr (`3`) and mobile viewport. (The extension remembers the state per tab; after an extension reload the next run applies only the fields it carries.) -- `--off` clears every override (viewport, UA, touch) and the remembered state, restoring the tab's real environment. -- Scope: overrides apply to **one tab only** (CDP per-target) and are **not inherited by new tabs** — re-run `bsk emulate` after opening or switching to another tab (default target is the session's active tab; `--tab-id` overrides). -- Emulation covers viewport/UA/touch only; it does not throttle the network, fake geolocation, or synthesise real touch-event streams. +Required flags that are easy to get wrong: -### Tabs (require `--session `) - -| Command | Summary | -|---------|---------| -| `bsk tab list` | List tabs (`--scope user\|agent\|all`, default `all`) | -| `bsk tab create` | New tab in Agent Window (`--url`, `--no-active`, `--index`) | -| `bsk tab close ` | Close an agent tab | -| `bsk tab select ` | Focus an agent tab | -| `bsk tab borrow ` | Move a user tab into the Agent Window | -| `bsk tab return ` | Return a borrowed tab to its original window | - -### Observation (require `--session` unless noted) - -| Command | Summary | -|---------|---------| -| `bsk snapshot` | First-choice static page understanding: accessibility tree with `@eN` element refs | -| `bsk observe` | Semantic VOM observation with bounded perception probes for conditional surfaces | -| `bsk get-html` | Raw HTML dump after snapshot is insufficient (high token cost) | -| `bsk screenshot` | PNG capture after snapshot is insufficient: full visible tab, or `--ref @eN` to crop to one element (`--out` path optional) | - -### Console & network debugging (read-only; require `--session`) - -| Command | Summary | -|---------|---------| -| `bsk console` | Buffered page console messages, JS exceptions, and browser log entries (`--include-stack` for stack traces) | -| `bsk network` | Buffered network responses (status, method, URL, MIME/resource type) and failures (`net::ERR_*` reason) | - -Both capture from the moment the tab is attached and read a bounded per-tab buffer: `--since ` pages from a cursor (`next_since` in the result), `--limit` (default 50, max 200), `--max-text-chars` (default 1000, max 4096), `--tab-id` to target a non-active tab. Both are strictly read-only — they never intercept or modify traffic, and request/response headers, bodies, and timings are not captured. - -### Navigation - -| Command | Summary | -|---------|---------| -| `bsk navigate ` | Go to URL in agent tab (`--wait-until`, `--timeout`) | -| `bsk navigate-back` | History back one step | -| `bsk navigate-forward` | History forward one step | -| `bsk reload` | Reload current tab (`--hard` bypass cache) | - -(`bsk navigate back` / `bsk navigate forward` are equivalent subcommands.) - -### Interaction - -| Command | Summary | -|---------|---------| -| `bsk click ` | Click element (`--button`, `--click-count`, `--modifiers`) | -| `bsk hover ` | Move the mouse to an element and wait for hover UI to settle (`--settle`, `--modifiers`) | -| `bsk fill --value ` | Clear and type into input | -| `bsk select --value ` | Set `` option(s) by `value` (repeat `--value` for multi-select) | -| `bsk press ` | Key/combo (`Enter`, `Ctrl+A`, …; optional `--ref` to focus first) | - -### Scripting & timing - -| Command | Summary | -|---------|---------| -| `bsk evaluate ` | Run JS in agent tab (see red lines). JS throw → stderr, **exit 0** (RPC success); use `--json` and check `.ok` to detect JS errors | -| `bsk wait-for-navigation` | Block until load/DOM idle/etc. (`--wait-until`, `--timeout`) | -| `bsk wait-ms ` | Sleep (`500ms`, `2s`, `1m`; **no** `--session`) | - -### Ask the human for help — `bsk request-help` - -When a step needs a human (captcha, login, OTP) or you want the user to -confirm an important action, pause and ask: - - bsk request-help --session --prompt "Solve the captcha, then click Done only after the site accepts it" \ - --title "Captcha required" --target @e7 --target "#submit" --timeout 5m - -- `--prompt` (required): what the user should do. -- `--title` (optional): custom title for the overlay panel. When omitted, - the extension shows its default localized title. -- `--target` (repeatable): a snapshot ref (`@e7`) or CSS selector - (`#submit`) to scroll to and flash-highlight. **Strongly recommended** — - whenever the prompt refers to a concrete element (a button to click, a - field to fill, a checkbox to toggle), pass its `@eN` ref / selector so the - user is guided straight to the right spot instead of hunting for it. For - interaction scenarios, always include the relevant target(s); reserve a - prompt with no `--target` for cases where there is genuinely no specific - element to point at (e.g. "wait for the page to finish loading"). -- `--timeout` (default `5m`): how long to wait. -- `--completion-criteria` (optional): JSON success detector. Use it only - when there is a concrete post-help success signal, e.g. - `{"any":[{"url_contains":"/dashboard"},{"selector_exists":"[data-testid='account-menu']"}],"stable_for_ms":1000}`. - -The target tab is brought to the foreground; the page stays interactive -while the agent control mask is hidden. The call blocks until the user -explicitly acts, the timeout expires, cancellation arrives, or explicit -completion criteria match. Page reloads, SPA route changes, and captcha -refreshes do not return control by themselves. The result `outcome` is one of: - -- `continued` — the user finished and clicked Done / return control (treat as confirm). -- `cancelled` — the user clicked Cancel (treat as reject/abort). -- `timed_out` — nobody acted within the timeout. -- `completed` — the explicit `--completion-criteria` matched while the user had control. -- `navigated` — deprecated legacy outcome. Do not rely on navigation as a completion signal. - -`note` carries any text the user typed back. `resolved_targets` reports -which refs/selectors matched a live element. - -`request-help` does not refresh the page model after the user returns -control. After a `continued` or `completed` result, issue a separate -observation tool call (usually `bsk snapshot --session `) before using -new refs or reasoning about the post-help page state. -#### Disabling request-help (unattended mode) -Set `BSK_REQUEST_HELP=off` on unattended servers: `bsk request-help` then -returns immediately with `outcome="disabled"` (no overlay, no waiting, -exit 0). Any other value keeps it enabled. If you get `disabled`, do not -retry — complete the task autonomously or stop gracefully. - -### Recording — `bsk record` - -Capture the user's own actions in the Agent Window to a **trace bundle**, for later LLM-driven automation. New CLI builds request **trace v3** (page observations + action chain); older extensions may still return **trace v2** (actions only), which the CLI exports as a single `trace.json` without `states/`. - -```bash -bsk record start --browser \ - [--url https://…] [--purpose "publish a wiki doc"] \ - [--max-page-tokens 3000] [--redact-values] \ - [--output trace] -# `--url` is optional; default https://example.com/ when omitted (must be http(s)). -# Blocks until the user clicks Finish in the recording panel, then writes: -# trace/trace.json — action chain (+ state index when v3) -# trace/states/ — v3 only: one `sN.txt` observe snapshot per settled page state - -bsk record stop [--output trace] # terminal fallback if the browser panel is unavailable +```text +bsk fill --value bsk select --value +bsk screenshot --out bsk emulate --device ``` -- **v3 bundle (preferred):** `--output` is a directory (default `./trace`) containing `trace.json` and `states/`. `trace.json` lists `states[]` (page observation ids) and `steps[]` (each step binds `state` = observe snapshot *before* the action and `result.state` = snapshot *after* settle). Page bodies live in `states/sN.txt` using the same VOM format as `bsk observe`. -- **v2 fallback:** when the connected extension is older, export may contain only legacy `trace.json` with `pages[]` action context and no `states/` observation files. Update the extension for full v3 bundles. -- Each `states[]` entry is one **settled page observation** (captured after recording start, navigation landing, or action settle — not on a timer). -- `target.ref` values like `@e12` exist **only inside** the bundle for disambiguation; do **not** copy `@eN` refs into SKILL.md or agent runbooks — use visible names from the observation text instead. -- `--purpose` is optional context metadata; it does **not** change what gets captured. -- `--redact-values` masks all form values in page files as `[filled]` / `[empty]`. -- Do **not** record on banking/SSO/password-manager pages; passwords are redacted but traces may still contain sensitive text. +`select` matches an option's `value` attribute, not its visible label. Device preset ids are +lowercase and hyphenated, such as `iphone-14`. -## Error handling +- `console` and `network` provide bounded, read-only debugging evidence. +- `emulate` applies viewport, user-agent, and touch overrides to one tab; new tabs do not inherit + them. Use `--off` to restore the real environment. +- `evaluate` is a last resort when observe plus normal interactions cannot complete the task. With + `--json`, inspect `.ok`: a JavaScript exception may still have CLI exit code 0 because the RPC + succeeded. Never evaluate credential surfaces to read storage, cookies, or auth data. +- `record` captures a user's actions for later replay. Read `bsk record start --help` before use, + and never record banking, SSO, password-manager, or other sensitive pages. -### Exit codes (`echo $?` after `bsk …`) +## Recover without wandering -| Code | Meaning | What to do | -|------|---------|------------| -| `0` | Success (including `evaluate` where JS threw but RPC succeeded) | Continue; for `evaluate`, check `--json` `.ok` to distinguish JS success from RPC success | -| `1` | User error — bad args, unknown session, tab not in Agent Window, stale ref | Fix args; `bsk session list`; re-snapshot | -| `2` | Protocol / transport — service unreachable, IPC failure | `bsk doctor`; check extension connected; retry the command | -| `3` | Browser / CDP execution failed | Retry; simplify selector; check tab still open | -| `4` | Timeout | Increase `--timeout`; try `--wait-until domcontentloaded` | -| `5` | Unknown RPC method | This command is not implemented in the current build. Continue the task with other commands. If a newly added command is missing, suggest `bsk update`. | +- Stale ref: observe again and retry the intended action once. +- Unknown tab or session: list current tabs/sessions; never guess identifiers. +- Timeout: inspect current page state before deciding whether one longer purposeful wait is useful. +- Unsupported command: continue with available capabilities; suggest updating only when the missing + command is necessary. +- Unrecoverable failure: report the blocker and stop the session in a finally-style path. -Human errors print `error:` + `hint:` on stderr; `--json` includes `code`, `message`, `hint`, `exit_code`. - -### When to run diagnostics - -| Situation | Command | -|-----------|---------| -| Before first task in a session | `bsk status` — extension connected? | -| Any failure you cannot fix in one retry | `bsk doctor` | -| Multiple browsers / wrong target | `bsk browsers` then `bsk session start --browser ` | - -Always **`bsk session stop `** in a `finally`-style path so the Agent Window closes and borrowed tabs return. - -## Red lines - -1. **No token theft** — do not `bsk evaluate` on sensitive sites to read `localStorage`, cookies, or auth headers for exfiltration. -2. **No long borrow** — do not leave a user's personal tab in the Agent Window across unrelated tasks. -3. **No skip stop** — always `bsk session stop `; never assume idle timeout will clean up. -4. **No post-success control** — once the user’s goal (or last trace step) is met, do not keep operating the page; stop the session unless they asked to keep it open. -5. **No raw observe escalation before snapshot/observe** — use `bsk snapshot` first; use `bsk observe` when VOM semantics or conditional surfaces help. Only use `bsk get-html` or `bsk screenshot` when snapshot/observe is insufficient. Element screenshots (`--ref @eN`) still require a fresh snapshot/observe ref — never skip observation just to grab a visual. -6. **`evaluate` is powerful and risky** — use only when snapshot + click/fill/select cannot suffice; never on credential surfaces. - ---- - -**More detail for any command:** `bsk --help` +The CLI's current help and error hints are authoritative for flags, parameters, and recovery +details. From 05a36075b8edc743152badf439a41deee1625035 Mon Sep 17 00:00:00 2001 From: drakezhang Date: Tue, 1 Sep 2026 22:35:12 +0800 Subject: [PATCH 4/8] perf(vom): make active hover probing opt-in on observe MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every tool.observe moved the real cursor across the page to look for hover-only menus and tooltips. Measured against real Chrome, that cost a median of 2.5s and up to 4.7s per observation — 73% to 99% of total observe wall clock — and returned 3 hits across 12 pages. A positive control confirms the probes work; the yield on real pages simply does not justify putting them on the default path. observe now probes only when asked, via `probe_hover` (`--probe-hover`). Agents that know which element hides a menu should keep using `bsk hover `, which is both cheaper and precise. A held hover latch still suppresses probing, since probing would move the cursor off the element the caller is deliberately holding. Correctness fixes that apply whether or not probing is requested: - Hover probing ran between the DOM snapshot and the accessibility tree fetch, so a hover that opened a menu left one half of an observation describing the page before the change and the other half after it. Both chains now run after all capture is complete. - The overlay bypass toggled once per chain and swallowed restore failures, which could leave the agent overlay hidden for the rest of the tab's life. It is now reference counted across both chains, and a failed restore is reported instead of discarded. - Observations report whether they hovered the page and whether that revealed content the static tree lacks, so callers can judge how far the live page may have drifted from the returned snapshot. Probe budgets were checked at the top of the loop, so a candidate could start with 1ms remaining and still run a full settle window, overshooting by up to 634ms. Candidates are now admitted only if they fit. Raising MAX_HOVER_PROBE_MS to 2600 is part of that fix rather than a relaxation: 2000 was never the real ceiling, and enforcing it literally would have silently dropped a candidate the positive control depends on. Tooltip misses are cached too, with a shorter TTL than hits, so repeat observations of the same page no longer re-probe every unlabelled control from scratch. --- .../src/tools/__tests__/observation.test.ts | 572 +++++++++++------- apps/extension/src/tools/dispatcher.ts | 6 +- apps/extension/src/tools/observation.ts | 125 +++- .../src/tools/vom/__tests__/capture.test.ts | 20 +- .../vom/__tests__/hover-perception.test.ts | 84 +++ .../vom/__tests__/name-enrichment.test.ts | 31 + apps/extension/src/tools/vom/capture.ts | 145 +++-- .../src/tools/vom/hover-perception.ts | 88 +++ .../src/tools/vom/name-enrichment.ts | 41 +- .../src/tools/vom/record-safe-observation.ts | 13 + apps/extension/src/transport/types.ts | 5 + crates/bsk-cli/skill/SKILL.md | 12 +- crates/bsk-cli/src/cli/observe.rs | 7 + crates/bsk-cli/tests/tools_ipc.rs | 2 + .../schema/tool_observe_params.json | 7 +- .../schema/tool_observe_result.json | 29 + crates/bsk-protocol/src/tools/observation.rs | 56 +- skill/SKILL.md | 12 +- 18 files changed, 917 insertions(+), 338 deletions(-) create mode 100644 apps/extension/src/tools/vom/__tests__/hover-perception.test.ts diff --git a/apps/extension/src/tools/__tests__/observation.test.ts b/apps/extension/src/tools/__tests__/observation.test.ts index 2e18a41..92a0727 100644 --- a/apps/extension/src/tools/__tests__/observation.test.ts +++ b/apps/extension/src/tools/__tests__/observation.test.ts @@ -1904,36 +1904,41 @@ describe("buildVomScene", () => { backendDOMNodeId: 20, }, ]; - const scene = buildVomScene(axNodes, { - viewport: { width: 1000, height: 800 }, - iframeNodes: new Map(), - excludedBackendNodeIds: new Set(), - surfaceProbes: [ - { triggerBackendNodeId: 20, triggerAction: "hover", subItems: ["Shoes", "Bags"] }, - ], - nodes: [ - { - backendNodeId: 10, - parentBackendNodeId: null, - tag: "body", - attrs: {}, - rect: { x: 0, y: 0, w: 1000, h: 800 }, - paintOrder: 0, - position: "static", - pointerEvents: "auto", - }, - { - backendNodeId: 20, - parentBackendNodeId: 10, - tag: "button", - attrs: {}, - rect: { x: 20, y: 20, w: 120, h: 40 }, - paintOrder: 1, - position: "static", - pointerEvents: "auto", - }, - ], - }); + const scene = buildVomScene( + axNodes, + { + viewport: { width: 1000, height: 800 }, + iframeNodes: new Map(), + excludedBackendNodeIds: new Set(), + nodes: [ + { + backendNodeId: 10, + parentBackendNodeId: null, + tag: "body", + attrs: {}, + rect: { x: 0, y: 0, w: 1000, h: 800 }, + paintOrder: 0, + position: "static", + pointerEvents: "auto", + }, + { + backendNodeId: 20, + parentBackendNodeId: 10, + tag: "button", + attrs: {}, + rect: { x: 20, y: 20, w: 120, h: 40 }, + paintOrder: 1, + position: "static", + pointerEvents: "auto", + }, + ], + }, + { + surfaceProbes: [ + { triggerBackendNodeId: 20, triggerAction: "hover", subItems: ["Shoes", "Bags"] }, + ], + }, + ); expect(scene.surfaces).toEqual([ { triggerId: 20, triggerAction: "hover", subItems: ["Shoes", "Bags"] }, @@ -1957,46 +1962,51 @@ describe("buildVomScene", () => { backendDOMNodeId: 21, }, ]; - const scene = buildVomScene(axNodes, { - viewport: { width: 1000, height: 800 }, - iframeNodes: new Map(), - excludedBackendNodeIds: new Set(), - surfaceProbes: [ - { triggerBackendNodeId: 20, triggerAction: "hover", subItems: ["My profile"] }, - ], - nodes: [ - { - backendNodeId: 10, - parentBackendNodeId: null, - tag: "body", - attrs: {}, - rect: { x: 0, y: 0, w: 1000, h: 800 }, - paintOrder: 0, - position: "static", - pointerEvents: "auto", - }, - { - backendNodeId: 20, - parentBackendNodeId: 10, - tag: "div", - attrs: { class: "tg-avatar" }, - rect: { x: 900, y: 10, w: 30, h: 30 }, - paintOrder: 1, - position: "static", - pointerEvents: "auto", - }, - { - backendNodeId: 21, - parentBackendNodeId: 20, - tag: "div", - attrs: { class: "tg-avatar__inner" }, - rect: { x: 902, y: 12, w: 26, h: 26 }, - paintOrder: 2, - position: "static", - pointerEvents: "auto", - }, - ], - }); + const scene = buildVomScene( + axNodes, + { + viewport: { width: 1000, height: 800 }, + iframeNodes: new Map(), + excludedBackendNodeIds: new Set(), + nodes: [ + { + backendNodeId: 10, + parentBackendNodeId: null, + tag: "body", + attrs: {}, + rect: { x: 0, y: 0, w: 1000, h: 800 }, + paintOrder: 0, + position: "static", + pointerEvents: "auto", + }, + { + backendNodeId: 20, + parentBackendNodeId: 10, + tag: "div", + attrs: { class: "tg-avatar" }, + rect: { x: 900, y: 10, w: 30, h: 30 }, + paintOrder: 1, + position: "static", + pointerEvents: "auto", + }, + { + backendNodeId: 21, + parentBackendNodeId: 20, + tag: "div", + attrs: { class: "tg-avatar__inner" }, + rect: { x: 902, y: 12, w: 26, h: 26 }, + paintOrder: 2, + position: "static", + pointerEvents: "auto", + }, + ], + }, + { + surfaceProbes: [ + { triggerBackendNodeId: 20, triggerAction: "hover", subItems: ["My profile"] }, + ], + }, + ); expect(renderVom(scene).text).toContain('@e1 button "image" [hover first: My profile]'); }); @@ -2017,48 +2027,53 @@ describe("buildVomScene", () => { backendDOMNodeId: 21, }, ]; - const scene = buildVomScene(axNodes, { - viewport: { width: 1000, height: 800 }, - iframeNodes: new Map(), - excludedBackendNodeIds: new Set(), - surfaceProbes: [ - { triggerBackendNodeId: 20, triggerAction: "hover", subItems: ["My profile"] }, - { triggerBackendNodeId: 20, triggerAction: "hover", subItems: ["Sign out"] }, - { triggerBackendNodeId: 21, triggerAction: "hover", subItems: ["Settings"] }, - ], - nodes: [ - { - backendNodeId: 10, - parentBackendNodeId: null, - tag: "body", - attrs: {}, - rect: { x: 0, y: 0, w: 1000, h: 800 }, - paintOrder: 0, - position: "static", - pointerEvents: "auto", - }, - { - backendNodeId: 20, - parentBackendNodeId: 10, - tag: "div", - attrs: { class: "tg-avatar" }, - rect: { x: 900, y: 10, w: 30, h: 30 }, - paintOrder: 1, - position: "static", - pointerEvents: "auto", - }, - { - backendNodeId: 21, - parentBackendNodeId: 20, - tag: "div", - attrs: { class: "tg-avatar__inner" }, - rect: { x: 902, y: 12, w: 26, h: 26 }, - paintOrder: 2, - position: "static", - pointerEvents: "auto", - }, - ], - }); + const scene = buildVomScene( + axNodes, + { + viewport: { width: 1000, height: 800 }, + iframeNodes: new Map(), + excludedBackendNodeIds: new Set(), + nodes: [ + { + backendNodeId: 10, + parentBackendNodeId: null, + tag: "body", + attrs: {}, + rect: { x: 0, y: 0, w: 1000, h: 800 }, + paintOrder: 0, + position: "static", + pointerEvents: "auto", + }, + { + backendNodeId: 20, + parentBackendNodeId: 10, + tag: "div", + attrs: { class: "tg-avatar" }, + rect: { x: 900, y: 10, w: 30, h: 30 }, + paintOrder: 1, + position: "static", + pointerEvents: "auto", + }, + { + backendNodeId: 21, + parentBackendNodeId: 20, + tag: "div", + attrs: { class: "tg-avatar__inner" }, + rect: { x: 902, y: 12, w: 26, h: 26 }, + paintOrder: 2, + position: "static", + pointerEvents: "auto", + }, + ], + }, + { + surfaceProbes: [ + { triggerBackendNodeId: 20, triggerAction: "hover", subItems: ["My profile"] }, + { triggerBackendNodeId: 20, triggerAction: "hover", subItems: ["Sign out"] }, + { triggerBackendNodeId: 21, triggerAction: "hover", subItems: ["Settings"] }, + ], + }, + ); expect(scene.surfaces).toEqual([ { triggerId: 21, triggerAction: "hover", subItems: ["My profile"] }, @@ -2081,54 +2096,59 @@ describe("buildVomScene", () => { backendDOMNodeId: 21, }, ]; - const scene = buildVomScene(axNodes, { - viewport: { width: 1000, height: 800 }, - iframeNodes: new Map(), - excludedBackendNodeIds: new Set(), - surfaceProbes: [ - { - triggerBackendNodeId: 20, - triggerPoint: { x: 915, y: 25 }, - triggerAction: "hover", - subItems: ["My profile", "Sign out"], - }, - ], - nodes: [ - { - backendNodeId: 10, - parentBackendNodeId: null, - tag: "body", - attrs: {}, - rect: { x: 0, y: 0, w: 1000, h: 800 }, - paintOrder: 0, - position: "static", - pointerEvents: "auto", - cursor: "auto", - }, - { - backendNodeId: 20, - parentBackendNodeId: 10, - tag: "div", - attrs: { class: "tg-avatar" }, - rect: { x: 900, y: 10, w: 30, h: 30 }, - paintOrder: 1, - position: "static", - pointerEvents: "auto", - cursor: "pointer", - }, - { - backendNodeId: 21, - parentBackendNodeId: 20, - tag: "div", - attrs: { class: "tg-avatar__inner" }, - rect: { x: 902, y: 12, w: 26, h: 26 }, - paintOrder: 2, - position: "static", - pointerEvents: "auto", - cursor: "pointer", - }, - ], - }); + const scene = buildVomScene( + axNodes, + { + viewport: { width: 1000, height: 800 }, + iframeNodes: new Map(), + excludedBackendNodeIds: new Set(), + nodes: [ + { + backendNodeId: 10, + parentBackendNodeId: null, + tag: "body", + attrs: {}, + rect: { x: 0, y: 0, w: 1000, h: 800 }, + paintOrder: 0, + position: "static", + pointerEvents: "auto", + cursor: "auto", + }, + { + backendNodeId: 20, + parentBackendNodeId: 10, + tag: "div", + attrs: { class: "tg-avatar" }, + rect: { x: 900, y: 10, w: 30, h: 30 }, + paintOrder: 1, + position: "static", + pointerEvents: "auto", + cursor: "pointer", + }, + { + backendNodeId: 21, + parentBackendNodeId: 20, + tag: "div", + attrs: { class: "tg-avatar__inner" }, + rect: { x: 902, y: 12, w: 26, h: 26 }, + paintOrder: 2, + position: "static", + pointerEvents: "auto", + cursor: "pointer", + }, + ], + }, + { + surfaceProbes: [ + { + triggerBackendNodeId: 20, + triggerPoint: { x: 915, y: 25 }, + triggerAction: "hover", + subItems: ["My profile", "Sign out"], + }, + ], + }, + ); expect(scene.surfaces).toEqual([ { triggerId: 20, triggerAction: "hover", subItems: ["My profile", "Sign out"] }, @@ -2154,41 +2174,46 @@ describe("buildVomScene", () => { backendDOMNodeId: 42, }, ]; - const scene = buildVomScene(axNodes, { - viewport: { width: 1000, height: 800 }, - iframeNodes: new Map(), - excludedBackendNodeIds: new Set(), - surfaceProbes: [ - { - triggerBackendNodeId: 999, - triggerPoint: { x: 900, y: 20 }, - triggerAction: "hover", - subItems: ["My profile"], - }, - ], - nodes: [ - { - backendNodeId: 10, - parentBackendNodeId: null, - tag: "body", - attrs: {}, - rect: { x: 0, y: 0, w: 1000, h: 800 }, - paintOrder: 0, - position: "static", - pointerEvents: "auto", - }, - { - backendNodeId: 42, - parentBackendNodeId: 10, - tag: "a", - attrs: {}, - rect: { x: 860, y: 120, w: 40, h: 30 }, - paintOrder: 1, - position: "static", - pointerEvents: "auto", - }, - ], - }); + const scene = buildVomScene( + axNodes, + { + viewport: { width: 1000, height: 800 }, + iframeNodes: new Map(), + excludedBackendNodeIds: new Set(), + nodes: [ + { + backendNodeId: 10, + parentBackendNodeId: null, + tag: "body", + attrs: {}, + rect: { x: 0, y: 0, w: 1000, h: 800 }, + paintOrder: 0, + position: "static", + pointerEvents: "auto", + }, + { + backendNodeId: 42, + parentBackendNodeId: 10, + tag: "a", + attrs: {}, + rect: { x: 860, y: 120, w: 40, h: 30 }, + paintOrder: 1, + position: "static", + pointerEvents: "auto", + }, + ], + }, + { + surfaceProbes: [ + { + triggerBackendNodeId: 999, + triggerPoint: { x: 900, y: 20 }, + triggerAction: "hover", + subItems: ["My profile"], + }, + ], + }, + ); expect(scene.surfaces).toBeUndefined(); expect(renderVom(scene).text).not.toContain("[hover first:"); @@ -2606,7 +2631,106 @@ describe("handleSnapshot", () => { ); }); - it("enables conditional surface probing for semantic observe", async () => { + it("hovers the page only after the accessibility tree has been captured", async () => { + // Hovering can open menus and reflow the page. Probing between the DOM and + // AX captures would leave the two halves of one observation describing the + // page on either side of that change. + const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) }); + await sm.start("aa11"); + const root: CdpAxNode = { + nodeId: "1", + role: { type: "role", value: "RootWebArea" }, + name: { type: "computedString", value: "Example" }, + backendDOMNodeId: 100, + childIds: ["2"], + }; + const button: CdpAxNode = { + nodeId: "2", + parentId: "1", + role: { type: "role", value: "button" }, + name: { type: "computedString", value: "Products" }, + backendDOMNodeId: 200, + }; + const strings = ["body", "button", "position", "static", "pointer-events", "auto", "cursor"]; + const i = (s: string) => strings.indexOf(s); + const methodOrder: string[] = []; + const send = vi.fn(async (_tabId: number, method: string, params?: object) => { + methodOrder.push(method); + if (method === "Accessibility.enable") return {}; + if (method === "Accessibility.getFullAXTree") return { nodes: [root, button] }; + if (method === "Page.getLayoutMetrics") { + return { cssLayoutViewport: { clientWidth: 1000, clientHeight: 800, pageX: 0, pageY: 0 } }; + } + if (method === "DOMSnapshot.enable") return {}; + if (method === "DOMSnapshot.captureSnapshot") { + return { + strings, + documents: [ + { + nodes: { + parentIndex: [-1, 0], + nodeName: [i("body"), i("button")], + backendNodeId: [100, 200], + attributes: [[], []], + }, + layout: { + nodeIndex: [0, 1], + styles: [ + [i("static"), i("auto"), i("auto")], + [i("static"), i("auto"), i("auto")], + ], + bounds: [ + [0, 0, 1000, 800], + [20, 20, 120, 40], + ], + paintOrders: [0, 1], + }, + }, + ], + }; + } + if (method === "Input.dispatchMouseEvent") return {}; + if (method === "Runtime.evaluate") { + const expression = (params as { expression?: string } | undefined)?.expression ?? ""; + // Report the button as a `:hover` rule target so a real hover fires. + if (expression.includes("document.styleSheets")) { + return { result: { value: [{ x: 80, y: 40 }] } }; + } + return { result: { value: [] } }; + } + throw new Error(`unexpected CDP method ${method}`); + }); + + const res = await handleObserve( + sm, + { session_id: "aa11", probe_hover: true }, + { + cdp: { + send: send as unknown as ( + tabId: number, + method: string, + params?: object, + ) => Promise, + trackSessionTab: vi.fn(), + }, + tabsApi: { + get: vi.fn( + async (tabId: number) => + ({ id: tabId, windowId: 100, active: true }) as chrome.tabs.Tab, + ), + query: vi.fn(async () => [{ id: 4, windowId: 100, active: true } as chrome.tabs.Tab]), + }, + }, + ); + + if ("code" in res) throw new Error(`unexpected error: ${JSON.stringify(res)}`); + const firstHover = methodOrder.indexOf("Input.dispatchMouseEvent"); + expect(firstHover).toBeGreaterThan(-1); + expect(methodOrder.lastIndexOf("DOMSnapshot.captureSnapshot")).toBeLessThan(firstHover); + expect(methodOrder.lastIndexOf("Accessibility.getFullAXTree")).toBeLessThan(firstHover); + }); + + it("runs conditional surface probing only when observe opts in", async () => { const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) }); await sm.start("aa11"); const root: CdpAxNode = { @@ -2662,30 +2786,46 @@ describe("handleSnapshot", () => { if (method === "Runtime.evaluate") return { result: { value: [] } }; throw new Error(`unexpected CDP method ${method}`); }); - const res = await handleObserve( + const deps = { + cdp: { + send: send as unknown as ( + tabId: number, + method: string, + params?: object, + ) => Promise, + trackSessionTab: vi.fn(), + }, + tabsApi: { + get: vi.fn( + async (tabId: number) => ({ id: tabId, windowId: 100, active: true }) as chrome.tabs.Tab, + ), + query: vi.fn(async () => [{ id: 4, windowId: 100, active: true } as chrome.tabs.Tab]), + }, + }; + + const withoutProbe = await handleObserve( sm, { session_id: "aa11", debug_surfaces: true }, - { - cdp: { - send: send as unknown as ( - tabId: number, - method: string, - params?: object, - ) => Promise, - trackSessionTab: vi.fn(), - }, - tabsApi: { - get: vi.fn( - async (tabId: number) => - ({ id: tabId, windowId: 100, active: true }) as chrome.tabs.Tab, - ), - query: vi.fn(async () => [{ id: 4, windowId: 100, active: true } as chrome.tabs.Tab]), - }, - }, + deps, + ); + if ("code" in withoutProbe) { + throw new Error(`unexpected error: ${JSON.stringify(withoutProbe)}`); + } + expect(withoutProbe.hover_probe).toBeUndefined(); + expect(send).not.toHaveBeenCalledWith( + 4, + "Runtime.evaluate", + expect.objectContaining({ returnByValue: true }), ); - if ("code" in res) throw new Error(`unexpected error: ${JSON.stringify(res)}`); - expect(res.debug).toEqual({ surface_probes: [] }); + const withProbe = await handleObserve( + sm, + { session_id: "aa11", debug_surfaces: true, probe_hover: true }, + deps, + ); + if ("code" in withProbe) throw new Error(`unexpected error: ${JSON.stringify(withProbe)}`); + expect(withProbe.debug).toEqual({ surface_probes: [] }); + expect(withProbe.hover_probe).toEqual({ performed: true, revealed_content: false }); expect(send).toHaveBeenCalledWith( 4, "Runtime.evaluate", diff --git a/apps/extension/src/tools/dispatcher.ts b/apps/extension/src/tools/dispatcher.ts index e0a5ac4..2a9b326 100644 --- a/apps/extension/src/tools/dispatcher.ts +++ b/apps/extension/src/tools/dispatcher.ts @@ -383,7 +383,11 @@ export class ToolDispatcher { ? { cdp: this.cdp, tabsApi: chromeTabsCaptureApi, - conditionalSurfaceProbe: !this.hasHoverLatchForScope(hoverScope), + // Active hover probing is opt-in. A held hover latch still + // suppresses it, because probing would move the cursor off + // the element the caller is deliberately holding. + conditionalSurfaceProbe: + params.probe_hover === true && !this.hasHoverLatchForScope(hoverScope), hoverProbeBypassOverlay: bypassOverlay, } : undefined, diff --git a/apps/extension/src/tools/observation.ts b/apps/extension/src/tools/observation.ts index ec78d18..bae4969 100644 --- a/apps/extension/src/tools/observation.ts +++ b/apps/extension/src/tools/observation.ts @@ -49,11 +49,14 @@ import { import { resolveSnapshotRef } from "./snapshot-ref"; import { type CapturedNode, + type CapturedSurfaceProbe, type CapturedViewModel, captureViewModel, collectOverlayExcludedBackendIds, + probeHoverSurfaces, } from "./vom/capture"; import { type CapturedFrameDocument, captureFrameData } from "./vom/frame-capture"; +import { withOverlayBypass } from "./vom/hover-perception"; import { probeTooltipNames } from "./vom/name-enrichment"; import { type CaptureVomObservationResult, @@ -525,8 +528,11 @@ function buildActiveScopeBlocks(nodes: VomNode[], signals: VomNodeDomSignals): A return blocks; } -function buildConditionalSurfaces(nodes: VomNode[], captured: CapturedViewModel): CondSurface[] { - const probes = captured.surfaceProbes ?? []; +function buildConditionalSurfaces( + nodes: VomNode[], + captured: CapturedViewModel, + probes: CapturedSurfaceProbe[], +): CondSurface[] { if (probes.length === 0) return []; const surfaces: CondSurface[] = []; @@ -649,6 +655,7 @@ function findSurfaceNodeByPoint( export interface BuildVomSceneOptions { pageUrl?: string; supplementalNames?: ReadonlyMap; + surfaceProbes?: CapturedSurfaceProbe[]; } export type VomFrameDocument = CapturedFrameDocument; @@ -817,18 +824,19 @@ export function buildFrameVomScene( excludedBackendNodeIds: captured.excludedBackendNodeIds, supplementalNames: options.supplementalNames, }); - return attachCapturedSceneAnnotations(scene, documents, captured); + return attachCapturedSceneAnnotations(scene, documents, captured, options.surfaceProbes ?? []); } function attachCapturedSceneAnnotations( scene: VomScene, documents: VomFrameDocument[], captured: CapturedViewModel, + surfaceProbes: CapturedSurfaceProbe[], ): VomScene { const rootDocument = documents.find((document) => document.frameId === scene.rootFrameId); const signals = capturedOnlySignals(rootDocument?.domNodes ?? captured.nodes); const activeScopeBlocks = buildActiveScopeBlocks(scene.nodes, signals); - const surfaces = buildConditionalSurfaces(scene.nodes, captured); + const surfaces = buildConditionalSurfaces(scene.nodes, captured, surfaceProbes); return { ...scene, ...(surfaces.length > 0 ? { surfaces } : {}), @@ -1010,17 +1018,69 @@ async function captureForVom( options: CaptureVomObservationOptions, ): Promise { try { - return await captureViewModel(cdp, tabId, { - conditionalSurfaceProbe: options.conditionalSurfaceProbe ?? false, - hoverProbeBypassOverlay: options.hoverProbeBypassOverlay, - signal: options.signal, - }); + return await captureViewModel(cdp, tabId, { signal: options.signal }); } catch (error) { if (isAbortError(error)) throw error; return fallbackCapturedViewModel(cdp, tabId, options.signal); } } +export interface HoverProbeOutcome { + /** Whether any active hover was dispatched during this observation. */ + performed: boolean; + /** + * Whether hovering surfaced content absent from the static snapshot. Cached + * tooltip names count, so this may over-report on repeat observations — it + * errs towards telling the caller the snapshot is less fresh than it looks. + */ + revealedContent: boolean; + surfaceProbes: CapturedSurfaceProbe[]; + tooltipNames: Map; +} + +const NO_HOVER_PROBES: HoverProbeOutcome = { + performed: false, + revealedContent: false, + surfaceProbes: [], + tooltipNames: new Map(), +}; + +/** + * Runs both active-hover chains back to back. + * + * Ordering matters: this happens after DOM *and* accessibility capture so a + * hover that opens a menu cannot leave one half of the observation describing + * the page before the change and the other half after it. Sharing one overlay + * bypass span also means the agent overlay toggles once per observation + * instead of once per chain. + */ +async function runHoverProbes( + cdp: CdpRunner, + tabId: number, + captured: CapturedViewModel, + documents: VomFrameDocument[], + staticSemantics: ReturnType, + options: CaptureVomObservationOptions, +): Promise { + if (!options.conditionalSurfaceProbe) return NO_HOVER_PROBES; + + return withOverlayBypass(options.hoverProbeBypassOverlay, tabId, async () => { + const surfaceProbes = await probeHoverSurfaces(cdp, tabId, captured.nodes, { + signal: options.signal, + }); + throwIfAborted(options.signal, "observation"); + const tooltipNames = await probeTooltipNames(cdp, tabId, documents, staticSemantics, { + signal: options.signal, + }); + return { + performed: true, + revealedContent: surfaceProbes.length > 0 || tooltipNames.size > 0, + surfaceProbes, + tooltipNames, + }; + }); +} + export interface CaptureVomObservationOptions extends VomOptions { conditionalSurfaceProbe?: boolean; hoverProbeBypassOverlay?: (tabId: number, enabled: boolean) => Promise; @@ -1051,19 +1111,26 @@ export async function captureVomObservation( excludedBackendNodeIds: captured.excludedBackendNodeIds, }); const staticSemantics = resolveSemanticGraph(semanticGraph, { identifierFallback: false }); - const tooltipNames = options.conditionalSurfaceProbe - ? await probeTooltipNames(cdp, tabId, normalizedDocuments, staticSemantics, { - signal: options.signal, - hoverProbeBypassOverlay: options.hoverProbeBypassOverlay, - }) - : new Map(); + const hoverProbes = await runHoverProbes( + cdp, + tabId, + captured, + normalizedDocuments, + staticSemantics, + options, + ); throwIfAborted(options.signal, "observation"); const scene = projectSemanticGraph( normalizeSemanticStructure( - resolveSemanticGraph(semanticGraph, { supplementalNames: tooltipNames }), + resolveSemanticGraph(semanticGraph, { supplementalNames: hoverProbes.tooltipNames }), ), ); - const decoratedScene = attachCapturedSceneAnnotations(scene, normalizedDocuments, captured); + const decoratedScene = attachCapturedSceneAnnotations( + scene, + normalizedDocuments, + captured, + hoverProbes.surfaceProbes, + ); const rendered = renderVom(decoratedScene, { maxDepth: options.maxDepth, maxTokens: options.maxTokens, @@ -1075,7 +1142,11 @@ export async function captureVomObservation( rootFrameId: captured.rootFrameId ?? normalizedDocuments[0]?.frameId ?? "root", frameDocuments: normalizedDocuments, rendered, - surfaceProbes: captured.surfaceProbes, + surfaceProbes: hoverProbes.surfaceProbes, + hoverProbe: { + performed: hoverProbes.performed, + revealedContent: hoverProbes.revealedContent, + }, }); } @@ -1141,6 +1212,14 @@ async function handleVomObservation( ref_count: observation.refs.length, tab_id: target.tabId, truncated: observation.truncated, + ...(toolName === "observe" && observation.hoverProbe?.performed + ? { + hover_probe: { + performed: true, + revealed_content: observation.hoverProbe.revealedContent, + }, + } + : {}), ...(toolName === "observe" && (params as ObserveParams).debug_surfaces ? { debug: { @@ -1179,5 +1258,13 @@ export async function handleObserve( deps: SnapshotDeps = getDefaultDeps(), signal?: AbortSignal, ): Promise { - return handleVomObservation(manager, params, "observe", "transient_input", true, deps, signal); + return handleVomObservation( + manager, + params, + "observe", + "transient_input", + params.probe_hover === true, + deps, + signal, + ); } diff --git a/apps/extension/src/tools/vom/__tests__/capture.test.ts b/apps/extension/src/tools/vom/__tests__/capture.test.ts index 9463383..86a2f00 100644 --- a/apps/extension/src/tools/vom/__tests__/capture.test.ts +++ b/apps/extension/src/tools/vom/__tests__/capture.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it, vi } from "vitest"; import { OVERLAY_HOST_MARKER_ATTR, OVERLAY_HOST_NAME } from "../../../lib/overlay-bridge"; -import { captureViewModel, collectOverlayExcludedBackendIds } from "../capture"; +import { captureViewModel, collectOverlayExcludedBackendIds, probeHoverSurfaces } from "../capture"; // Minimal but format-accurate captureSnapshot reply: a body with one // fixed full-screen overlay div carrying a password input. @@ -251,11 +251,10 @@ describe("captureViewModel", () => { expect(input?.attrs).toEqual({ type: "password" }); }); - it("does not run conditional surface probes by default", async () => { + it("never hovers the page while capturing", async () => { const cdp = makeCdp(fakeSnapshotReply()); - const result = await captureViewModel(cdp, 4); + await captureViewModel(cdp, 4); - expect(result.surfaceProbes).toEqual([]); expect(cdp.send).not.toHaveBeenCalledWith(4, "Input.dispatchMouseEvent", expect.anything()); }); @@ -319,9 +318,10 @@ describe("captureViewModel", () => { }) as unknown as (tabId: number, method: string, params?: object) => Promise, }; - const result = await captureViewModel(cdp, 4, { conditionalSurfaceProbe: true }); + const captured = await captureViewModel(cdp, 4); + const surfaceProbes = await probeHoverSurfaces(cdp, 4, captured.nodes); - expect(result.surfaceProbes).toEqual([ + expect(surfaceProbes).toEqual([ { triggerBackendNodeId: 12, triggerPoint: { x: 956, y: 32 }, @@ -378,9 +378,10 @@ describe("captureViewModel", () => { }) as unknown as (tabId: number, method: string, params?: object) => Promise, }; - const result = await captureViewModel(cdp, 4, { conditionalSurfaceProbe: true }); + const captured = await captureViewModel(cdp, 4); + const surfaceProbes = await probeHoverSurfaces(cdp, 4, captured.nodes); - expect(result.surfaceProbes).toEqual([ + expect(surfaceProbes).toEqual([ expect.objectContaining({ triggerBackendNodeId: 12, subItems: ["My profile", "Sign out"], @@ -422,7 +423,8 @@ describe("captureViewModel", () => { }) as unknown as (tabId: number, method: string, params?: object) => Promise, }; - await captureViewModel(cdp, 4, { conditionalSurfaceProbe: true }); + const captured = await captureViewModel(cdp, 4); + await probeHoverSurfaces(cdp, 4, captured.nodes); expect(hoverMoves).toBe(1); }); diff --git a/apps/extension/src/tools/vom/__tests__/hover-perception.test.ts b/apps/extension/src/tools/vom/__tests__/hover-perception.test.ts new file mode 100644 index 0000000..32c662b --- /dev/null +++ b/apps/extension/src/tools/vom/__tests__/hover-perception.test.ts @@ -0,0 +1,84 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { ProbeBudget, resetOverlayBypassDepth, withOverlayBypass } from "../hover-perception"; + +afterEach(() => { + resetOverlayBypassDepth(); +}); + +describe("ProbeBudget", () => { + it("refuses a candidate that cannot finish inside the budget", () => { + const budget = new ProbeBudget(500); + + expect(budget.canAfford(400)).toBe(true); + expect(budget.canAfford(600)).toBe(false); + }); + + it("stops admitting candidates once the remaining budget is smaller than one", async () => { + const budget = new ProbeBudget(60); + await new Promise((resolve) => setTimeout(resolve, 40)); + + // A top-of-loop `elapsed > total` check would still admit this candidate + // and then overshoot by its full cost. + expect(budget.canAfford(50)).toBe(false); + }); +}); + +describe("withOverlayBypass", () => { + it("toggles the overlay once around nested probe phases", async () => { + const bypass = vi.fn(async () => {}); + + await withOverlayBypass(bypass, 4, async () => { + await withOverlayBypass(bypass, 4, async () => { + expect(bypass).toHaveBeenCalledTimes(1); + }); + expect(bypass).toHaveBeenCalledTimes(1); + }); + + expect(bypass.mock.calls).toEqual([ + [4, true], + [4, false], + ]); + }); + + it("restores the overlay when the probe throws", async () => { + const bypass = vi.fn(async () => {}); + + await expect( + withOverlayBypass(bypass, 4, async () => { + throw new Error("probe exploded"); + }), + ).rejects.toThrow("probe exploded"); + + expect(bypass).toHaveBeenLastCalledWith(4, false); + }); + + it("reports a failed restore instead of swallowing it", async () => { + const onRestoreFailure = vi.fn(); + const bypass = vi.fn(async (_tabId: number, enabled: boolean) => { + if (!enabled) throw new Error("tab is gone"); + }); + + await withOverlayBypass(bypass, 4, async () => undefined, { onRestoreFailure }); + + expect(onRestoreFailure).toHaveBeenCalledTimes(1); + }); + + it("does not pin the overlay off for the tab when a restore fails", async () => { + const failing = vi.fn(async (_tabId: number, enabled: boolean) => { + if (!enabled) throw new Error("tab is gone"); + }); + await withOverlayBypass(failing, 4, async () => undefined, { onRestoreFailure: () => {} }); + + const bypass = vi.fn(async () => {}); + await withOverlayBypass(bypass, 4, async () => undefined); + + expect(bypass.mock.calls).toEqual([ + [4, true], + [4, false], + ]); + }); + + it("runs the probe untouched when no bypass is wired up", async () => { + await expect(withOverlayBypass(undefined, 4, async () => "done")).resolves.toBe("done"); + }); +}); diff --git a/apps/extension/src/tools/vom/__tests__/name-enrichment.test.ts b/apps/extension/src/tools/vom/__tests__/name-enrichment.test.ts index 425517e..b4b2e4d 100644 --- a/apps/extension/src/tools/vom/__tests__/name-enrichment.test.ts +++ b/apps/extension/src/tools/vom/__tests__/name-enrichment.test.ts @@ -132,6 +132,37 @@ describe("tooltip name enrichment", () => { expect(cdp.sendToTarget).not.toHaveBeenCalled(); }); + it("remembers controls that revealed no tooltip so the miss is paid once", async () => { + const cdp: CdpRunner = { + send: vi.fn(async () => ({})) as CdpRunner["send"], + sendToTarget: vi.fn(async (_target, method) => { + if (method === "Page.createIsolatedWorld") return { executionContextId: 31 }; + if (method === "DOM.resolveNode") return { object: { objectId: "button-801" } }; + if (method === "Runtime.callFunctionOn") return { result: { value: [] } }; + return {}; + }) as CdpRunner["sendToTarget"], + }; + + const documents = [ + document({ tabId: 11, sessionId: "miss-session" }, 801, undefined, "miss-frame"), + ]; + const graph = semantics(documents); + + const first = await probeTooltipNames(cdp, 11, documents, graph); + expect(first.size).toBe(0); + const hoversAfterFirst = vi + .mocked(cdp.send) + .mock.calls.filter(([, method]) => method === "Input.dispatchMouseEvent").length; + expect(hoversAfterFirst).toBeGreaterThan(0); + + const second = await probeTooltipNames(cdp, 11, documents, graph); + expect(second.size).toBe(0); + expect( + vi.mocked(cdp.send).mock.calls.filter(([, method]) => method === "Input.dispatchMouseEvent") + .length, + ).toBe(hoversAfterFirst); + }); + it("keeps tooltip evidence isolated across sibling frame sessions", async () => { let hoveredX = -10; const cdp: CdpRunner = { diff --git a/apps/extension/src/tools/vom/capture.ts b/apps/extension/src/tools/vom/capture.ts index 1db0e29..6c7de38 100644 --- a/apps/extension/src/tools/vom/capture.ts +++ b/apps/extension/src/tools/vom/capture.ts @@ -9,7 +9,7 @@ import { evaluateHoverTrigger } from "@/lib/hover-trigger-policy"; import { isOverlayHostNode, OVERLAY_HOST_SELECTOR } from "../../lib/overlay-bridge"; import { childFrameProjection, type GeometryProjection, projectRectToViewport } from "../geometry"; import type { CdpRunner } from "../shared"; -import { clearHover, waitForHover } from "./hover-perception"; +import { clearHover, ProbeBudget, waitForHover } from "./hover-perception"; const REQUESTED_STYLES = ["position", "pointer-events", "cursor", "visibility", "opacity"] as const; const STYLE_COL = Object.fromEntries( @@ -71,14 +71,11 @@ export interface CapturedViewModel { /** Explicit DOMSnapshot frame ancestry; never inferred from backend node ids. */ frameParentIds?: Map; rootFrameId?: string; - surfaceProbes?: CapturedSurfaceProbe[]; /** Backend node ids belonging to the agent overlay host + its shadow subtree. */ excludedBackendNodeIds: Set; } export interface CaptureViewModelOptions { - conditionalSurfaceProbe?: boolean; - hoverProbeBypassOverlay?: (tabId: number, enabled: boolean) => Promise; signal?: AbortSignal; } @@ -368,7 +365,16 @@ function collectBackendIdsFromDomNode(node: CdpDomNode | undefined, out: Set { - const started = Date.now(); + const budget = new ProbeBudget(MAX_HOVER_PROBE_MS); try { throwIfAborted(options.signal); const cssScan = await cdp.send(tabId, "Runtime.evaluate", { @@ -635,60 +663,54 @@ async function probeHoverSurfaces( const results: CapturedSurfaceProbe[] = []; const seen = new Set(); throwIfAborted(options.signal); - await options.hoverProbeBypassOverlay?.(tabId, true).catch(() => undefined); - try { + for (const candidate of candidates.slice(0, MAX_HOVER_TRIGGERS)) { throwIfAborted(options.signal); - for (const candidate of candidates.slice(0, MAX_HOVER_TRIGGERS)) { + if (!budget.canAfford(HOVER_CANDIDATE_COST_MS)) break; + if (results.length >= MAX_HOVER_SURFACES) break; + if (seen.has(candidate.backendNodeId)) continue; + try { + await clearHover(cdp, tabId); throwIfAborted(options.signal); - if (Date.now() - started > MAX_HOVER_PROBE_MS) break; - if (results.length >= MAX_HOVER_SURFACES) break; - if (seen.has(candidate.backendNodeId)) continue; - try { - await clearHover(cdp, tabId); - throwIfAborted(options.signal); - await waitForHover(HOVER_SETTLE_MS, options.signal); - const baselineReply = await cdp.send(tabId, "Runtime.evaluate", { - expression: hoverStateExpression(), - returnByValue: true, - }); - throwIfAborted(options.signal); - const baselineItems = runtimeValue(baselineReply) ?? []; + await waitForHover(HOVER_SETTLE_MS, options.signal); + const baselineReply = await cdp.send(tabId, "Runtime.evaluate", { + expression: hoverStateExpression(), + returnByValue: true, + }); + throwIfAborted(options.signal); + const baselineItems = runtimeValue(baselineReply) ?? []; - throwIfAborted(options.signal); - await cdp.send(tabId, "Input.dispatchMouseEvent", { - type: "mouseMoved", - x: candidate.x, - y: candidate.y, - }); - throwIfAborted(options.signal); - await waitForHover(HOVER_SETTLE_MS, options.signal); - const collected = await cdp.send(tabId, "Runtime.evaluate", { - expression: hoverStateExpression(), - returnByValue: true, - }); - throwIfAborted(options.signal); - const subItems = diffHoverItems( - baselineItems, - runtimeValue(collected) ?? [], - ); - if (subItems.length === 0) continue; - seen.add(candidate.backendNodeId); - results.push({ - triggerBackendNodeId: candidate.backendNodeId, - triggerPoint: { x: candidate.x, y: candidate.y }, - triggerAction: "hover", - subItems, - confidence: confidenceForHover(candidate, subItems), - }); - } catch (error) { - if (isAbortError(error)) throw error; - continue; - } finally { - await clearHover(cdp, tabId); - } + throwIfAborted(options.signal); + await cdp.send(tabId, "Input.dispatchMouseEvent", { + type: "mouseMoved", + x: candidate.x, + y: candidate.y, + }); + throwIfAborted(options.signal); + await waitForHover(HOVER_SETTLE_MS, options.signal); + const collected = await cdp.send(tabId, "Runtime.evaluate", { + expression: hoverStateExpression(), + returnByValue: true, + }); + throwIfAborted(options.signal); + const subItems = diffHoverItems( + baselineItems, + runtimeValue(collected) ?? [], + ); + if (subItems.length === 0) continue; + seen.add(candidate.backendNodeId); + results.push({ + triggerBackendNodeId: candidate.backendNodeId, + triggerPoint: { x: candidate.x, y: candidate.y }, + triggerAction: "hover", + subItems, + confidence: confidenceForHover(candidate, subItems), + }); + } catch (error) { + if (isAbortError(error)) throw error; + continue; + } finally { + await clearHover(cdp, tabId); } - } finally { - await options.hoverProbeBypassOverlay?.(tabId, false).catch(() => undefined); } return results; } catch (err) { @@ -1042,7 +1064,6 @@ export async function captureViewModel( nodes: [], viewport, iframeNodes: new Map(), - surfaceProbes: [], excludedBackendNodeIds: new Set(), }; } @@ -1071,11 +1092,6 @@ export async function captureViewModel( await enrichFormControlStates(cdp, tabId, [nodes, ...iframeNodes.values()], options.signal); throwIfAborted(options.signal); - const surfaceProbes = options.conditionalSurfaceProbe - ? await probeHoverSurfaces(cdp, tabId, nodes, options) - : []; - throwIfAborted(options.signal); - const frameNodes = new Map(); if (topContext.frameId) frameNodes.set(topContext.frameId, nodes); for (const iframeFrameNodes of iframeNodes.values()) { @@ -1096,7 +1112,6 @@ export async function captureViewModel( frameOwnerBackendNodeIds: frameParsed.frameOwnerBackendNodeIds, frameParentIds: frameParsed.frameParentIds, ...(topContext.frameId ? { rootFrameId: topContext.frameId } : {}), - surfaceProbes, excludedBackendNodeIds, }; } diff --git a/apps/extension/src/tools/vom/hover-perception.ts b/apps/extension/src/tools/vom/hover-perception.ts index 253f792..c177daf 100644 --- a/apps/extension/src/tools/vom/hover-perception.ts +++ b/apps/extension/src/tools/vom/hover-perception.ts @@ -35,3 +35,91 @@ export async function clearHover(cdp: CdpRunner, tabId: number): Promise { }), ); } + +/** + * Wall-clock budget for a probe phase. + * + * Callers ask whether the *next* candidate still fits before starting it, + * rather than checking elapsed time at the top of the loop. A top-of-loop + * check lets a candidate start with 1ms of budget left and then run to + * completion, overshooting by a full settle window. + */ +export class ProbeBudget { + private readonly startedAt = Date.now(); + + constructor(private readonly totalMs: number) {} + + get elapsedMs(): number { + return Date.now() - this.startedAt; + } + + /** True when a candidate costing `estimatedMs` still fits in the budget. */ + canAfford(estimatedMs: number): boolean { + return this.elapsedMs + estimatedMs <= this.totalMs; + } +} + +export type OverlayBypass = (tabId: number, enabled: boolean) => Promise; + +export interface OverlayBypassScopeOptions { + /** + * Called when the overlay could not be restored. Defaults to `console.error` + * so a stuck-bypassed overlay is never silent; tests inject a spy. + */ + onRestoreFailure?: (error: unknown) => void; +} + +const overlayBypassDepth = new Map(); + +/** + * Runs `body` with the agent overlay bypassed, restoring it afterwards. + * + * Reference counted per tab so nested probe phases share a single bypass span + * instead of toggling the overlay once per phase. Restore failures are + * reported rather than swallowed, and the depth is released even when restore + * throws so a failure cannot pin the overlay off for the tab's lifetime. + */ +export async function withOverlayBypass( + bypass: OverlayBypass | undefined, + tabId: number, + body: () => Promise, + options: OverlayBypassScopeOptions = {}, +): Promise { + if (!bypass) return body(); + + const depth = overlayBypassDepth.get(tabId) ?? 0; + overlayBypassDepth.set(tabId, depth + 1); + if (depth === 0) { + try { + await bypass(tabId, true); + } catch (error) { + // Failing to hide the overlay only risks probing our own UI, so continue. + console.debug("[bsk hover] overlay bypass could not be enabled", error); + } + } + + try { + return await body(); + } finally { + const current = overlayBypassDepth.get(tabId) ?? 1; + if (current <= 1) { + overlayBypassDepth.delete(tabId); + try { + await bypass(tabId, false); + } catch (error) { + (options.onRestoreFailure ?? defaultRestoreFailure)(error); + } + } else { + overlayBypassDepth.set(tabId, current - 1); + } + } +} + +function defaultRestoreFailure(error: unknown): void { + console.error("[bsk hover] agent overlay could not be restored after probing", error); +} + +/** Test seam: drops any bypass depth left behind by a crashed probe. */ +export function resetOverlayBypassDepth(): void { + overlayBypassDepth.clear(); +} diff --git a/apps/extension/src/tools/vom/name-enrichment.ts b/apps/extension/src/tools/vom/name-enrichment.ts index 1f42ee3..acff86a 100644 --- a/apps/extension/src/tools/vom/name-enrichment.ts +++ b/apps/extension/src/tools/vom/name-enrichment.ts @@ -1,7 +1,7 @@ import { isVomReferenceNode } from "@browser-skill/vom"; import { type CdpRunner, sendToCdpTarget } from "../shared"; import type { FrameDocument, FrameOwnedAxNode } from "./frame-document"; -import { clearHover, waitForHover } from "./hover-perception"; +import { clearHover, ProbeBudget, waitForHover } from "./hover-perception"; import { stableIdentifierName } from "./semantic-graph/name-evidence"; import { frameBackendKey, type ResolvedSemanticGraph } from "./semantic-graph/types"; @@ -9,11 +9,18 @@ const MAX_TOOLTIP_CANDIDATES = 8; const MAX_TOOLTIP_PROBE_MS = 2_400; const TOOLTIP_SETTLE_MS = 250; const CACHE_TTL_MS = 5 * 60_000; +/** + * Misses expire sooner than hits: a control that gains a tooltip once the app + * finishes booting should still be discoverable, but not at the cost of + * re-probing every unlabelled control on every observation. + */ +const MISS_CACHE_TTL_MS = 60_000; const MAX_CACHE_ENTRIES = 500; const FORM_CONTROL_TAGS = new Set(["input", "select", "textarea"]); interface TooltipCacheEntry { - name: string; + /** `null` records "probed, revealed no tooltip" so the miss is not re-paid. */ + name: string | null; expiresAt: number; } @@ -27,7 +34,6 @@ interface TooltipCandidate { export interface TooltipNameProbeOptions { signal?: AbortSignal; - hoverProbeBypassOverlay?: (tabId: number, enabled: boolean) => Promise; } const tooltipCache = new Map(); @@ -39,10 +45,11 @@ function cacheKey( return `${document.target.tabId}\u0000${document.frameId}\u0000${document.url ?? ""}\u0000${backendNodeId}`; } -function cachedName( +/** Returns the cache entry, or `undefined` when this control was never probed. */ +function cachedEntry( document: FrameDocument, backendNodeId: number, -): string | undefined { +): TooltipCacheEntry | undefined { const key = cacheKey(document, backendNodeId); const entry = tooltipCache.get(key); if (!entry) return undefined; @@ -50,13 +57,13 @@ function cachedName( tooltipCache.delete(key); return undefined; } - return entry.name; + return entry; } function storeName( document: FrameDocument, backendNodeId: number, - name: string, + name: string | null, ): void { if (tooltipCache.size >= MAX_CACHE_ENTRIES) { const oldest = tooltipCache.keys().next().value; @@ -64,7 +71,7 @@ function storeName( } tooltipCache.set(cacheKey(document, backendNodeId), { name, - expiresAt: Date.now() + CACHE_TTL_MS, + expiresAt: Date.now() + (name === null ? MISS_CACHE_TTL_MS : CACHE_TTL_MS), }); } @@ -237,22 +244,27 @@ export async function probeTooltipNames( const names = new Map(); const pending: TooltipCandidate[] = []; for (const candidate of candidates(documents, graph)) { - const cached = cachedName(candidate.document, candidate.backendNodeId); + const cached = cachedEntry(candidate.document, candidate.backendNodeId); if (cached) { - names.set(frameBackendKey(candidate.document.frameId, candidate.backendNodeId), cached); + // A cached miss is still a decision: skip the control instead of re-probing. + if (cached.name !== null) { + names.set( + frameBackendKey(candidate.document.frameId, candidate.backendNodeId), + cached.name, + ); + } } else if (pending.length < MAX_TOOLTIP_CANDIDATES) { pending.push(candidate); } } if (pending.length === 0) return names; - const started = Date.now(); + const budget = new ProbeBudget(MAX_TOOLTIP_PROBE_MS); const contexts = new Map(); - await options.hoverProbeBypassOverlay?.(tabId, true).catch(() => undefined); try { for (const candidate of pending) { if (options.signal?.aborted) throw new DOMException("observation aborted", "AbortError"); - if (Date.now() - started >= MAX_TOOLTIP_PROBE_MS) break; + if (!budget.canAfford(TOOLTIP_SETTLE_MS)) break; let objectId: string | undefined; try { let contextId = contexts.get(candidate.document.frameId); @@ -278,9 +290,9 @@ export async function probeTooltipNames( await waitForHover(TOOLTIP_SETTLE_MS, options.signal); const after = await tooltipTexts(cdp, candidate.document, objectId); const name = cleanTooltipDiff(before, after); + storeName(candidate.document, candidate.backendNodeId, name ?? null); if (!name) continue; names.set(frameBackendKey(candidate.document.frameId, candidate.backendNodeId), name); - storeName(candidate.document, candidate.backendNodeId, name); } catch (error) { if (error instanceof Error && error.name === "AbortError") throw error; } finally { @@ -290,7 +302,6 @@ export async function probeTooltipNames( } } finally { await clearHover(cdp, tabId); - await options.hoverProbeBypassOverlay?.(tabId, false).catch(() => undefined); } return names; } diff --git a/apps/extension/src/tools/vom/record-safe-observation.ts b/apps/extension/src/tools/vom/record-safe-observation.ts index d58e757..13c4f5d 100644 --- a/apps/extension/src/tools/vom/record-safe-observation.ts +++ b/apps/extension/src/tools/vom/record-safe-observation.ts @@ -37,6 +37,17 @@ export interface CaptureVomObservationResult { frames: CaptureVomFrame[]; matchNodes: CaptureVomMatchNode[]; surfaceProbes?: CapturedSurfaceProbe[]; + /** + * Whether this observation actively hovered the page, and whether that + * revealed content the static tree does not contain. Lets callers judge how + * far the page may have drifted from the returned snapshot. + */ + hoverProbe?: HoverProbeReport; +} + +export interface HoverProbeReport { + performed: boolean; + revealedContent: boolean; } function projectFrames(documents: CapturedFrameDocument[]): CaptureVomFrame[] { @@ -75,6 +86,7 @@ export function projectRecordSafeObservation(input: { frameDocuments: CapturedFrameDocument[]; rendered: VomResult; surfaceProbes?: CapturedSurfaceProbe[]; + hoverProbe?: HoverProbeReport; }): CaptureVomObservationResult { return { text: input.rendered.text, @@ -84,5 +96,6 @@ export function projectRecordSafeObservation(input: { frames: projectFrames(input.frameDocuments), matchNodes: projectMatchNodes(input.frameDocuments), surfaceProbes: input.surfaceProbes, + ...(input.hoverProbe ? { hoverProbe: input.hoverProbe } : {}), }; } diff --git a/apps/extension/src/transport/types.ts b/apps/extension/src/transport/types.ts index dc60fe2..7ac8435 100644 --- a/apps/extension/src/transport/types.ts +++ b/apps/extension/src/transport/types.ts @@ -318,9 +318,14 @@ export interface SnapshotResult { export interface ObserveParams extends SnapshotParams { debug_surfaces?: boolean; + probe_hover?: boolean; } export interface ObserveResult extends SnapshotResult { + hover_probe?: { + performed: boolean; + revealed_content: boolean; + }; debug?: { surface_probes?: Array<{ trigger_backend_node_id: number; diff --git a/crates/bsk-cli/skill/SKILL.md b/crates/bsk-cli/skill/SKILL.md index 42bb452..ffe436a 100644 --- a/crates/bsk-cli/skill/SKILL.md +++ b/crates/bsk-cli/skill/SKILL.md @@ -67,7 +67,7 @@ Write operations only affect tabs in the **Agent Window** (or tabs you **borrowe ``` bsk navigate --session -bsk observe --session → primary semantic VOM view; reveals hover/focus surfaces +bsk observe --session → primary semantic VOM view bsk snapshot --session → static aria tree fallback when VOM is insufficient bsk hover @e3 --session → reveal hover-triggered menus before re-observing/clicking bsk click @e4 --session → or bsk fill, bsk select, bsk press @@ -80,11 +80,13 @@ Prefer `@eN` refs from the latest snapshot over raw CSS selectors. Use `--ref` / When VOM renders `[hover first: …]` on an element, the listed items are not currently clickable refs. Run `bsk hover --session `, then immediately run `bsk snapshot` or `bsk observe` again and click the newly visible menu item ref. Do not click the trigger itself unless the user explicitly wants the trigger action. +`bsk observe` does not hover the page on its own. When a menu bar, toolbar, or icon row looks like it must hide content behind hover and you cannot tell which element to hover, add `--probe-hover` for one observation: it moves the real cursor over a bounded set of likely triggers to discover hover-only menus and tooltips. It costs a few seconds and touches the live page, so prefer `bsk hover ` once you know the trigger. + ## Observation priority -Start with `bsk observe` to understand page structure, text, controls, element refs, and conditional hover/focus surfaces. Use `bsk snapshot` only when you need the stricter static accessibility tree or VOM is insufficient. Only escalate to raw HTML or screenshots when the latest observation cannot answer the question: +Start with `bsk observe` to understand page structure, text, controls, and element refs. Use `bsk snapshot` only when you need the stricter static accessibility tree or VOM is insufficient. Only escalate to raw HTML or screenshots when the latest observation cannot answer the question: -1. `bsk observe` — primary semantic VOM observation; may run bounded perception probes such as hover-surface discovery +1. `bsk observe` — primary semantic VOM observation; add `--probe-hover` only when hover-only content is suspected 2. `bsk snapshot` — strict static accessibility tree fallback 3. `bsk get-html` — when hidden DOM, metadata, or markup details are required 4. `bsk screenshot` — when visual layout, canvas/image content, or styling cannot be inferred from the observation. Use `--ref @eN` (from the latest snapshot/observe) to crop to one element; omit `--ref` for the full visible tab. @@ -172,7 +174,7 @@ bsk emulate --session --off | Command | Summary | |---------|---------| | `bsk snapshot` | First-choice static page understanding: accessibility tree with `@eN` element refs | -| `bsk observe` | Semantic VOM observation with bounded perception probes for conditional surfaces | +| `bsk observe` | Semantic VOM observation; `--probe-hover` adds bounded active hover probing for conditional surfaces | | `bsk get-html` | Raw HTML dump after snapshot is insufficient (high token cost) | | `bsk screenshot` | PNG capture after snapshot is insufficient: full visible tab, or `--ref @eN` to crop to one element (`--out` path optional) | @@ -319,7 +321,7 @@ Always **`bsk session stop `** in a `finally`-style path so the Agent Window 2. **No long borrow** — do not leave a user's personal tab in the Agent Window across unrelated tasks. 3. **No skip stop** — always `bsk session stop `; never assume idle timeout will clean up. 4. **No post-success control** — once the user’s goal (or last trace step) is met, do not keep operating the page; stop the session unless they asked to keep it open. -5. **No raw observe escalation before snapshot/observe** — use `bsk snapshot` first; use `bsk observe` when VOM semantics or conditional surfaces help. Only use `bsk get-html` or `bsk screenshot` when snapshot/observe is insufficient. Element screenshots (`--ref @eN`) still require a fresh snapshot/observe ref — never skip observation just to grab a visual. +5. **No raw observe escalation before snapshot/observe** — use `bsk snapshot` first; use `bsk observe` when VOM semantics help. Only use `bsk get-html` or `bsk screenshot` when snapshot/observe is insufficient. Element screenshots (`--ref @eN`) still require a fresh snapshot/observe ref — never skip observation just to grab a visual. 6. **`evaluate` is powerful and risky** — use only when snapshot + click/fill/select cannot suffice; never on credential surfaces. --- diff --git a/crates/bsk-cli/src/cli/observe.rs b/crates/bsk-cli/src/cli/observe.rs index bae34fe..e8ef4ee 100644 --- a/crates/bsk-cli/src/cli/observe.rs +++ b/crates/bsk-cli/src/cli/observe.rs @@ -33,6 +33,12 @@ pub struct ObserveArgs { /// Include conditional surface probe diagnostics in JSON output. #[arg(long = "debug-surfaces")] pub debug_surfaces: bool, + + /// Actively hover page controls to reveal hover-only menus and tooltips. + /// Costs seconds of wall clock and touches the live page, so it is off + /// unless a static observation looks like it is missing hover content. + #[arg(long = "probe-hover")] + pub probe_hover: bool, } pub fn dispatch(args: ObserveArgs, format: Format) -> Result<(), CliError> { @@ -47,6 +53,7 @@ fn run(sock: PathBuf, args: ObserveArgs, format: Format) -> Result<(), CliError> max_depth: args.max_depth, max_tokens: args.max_tokens, debug_surfaces: args.debug_surfaces, + probe_hover: args.probe_hover, }; let reply: ObserveResult = call(sock, params)?; match format { diff --git a/crates/bsk-cli/tests/tools_ipc.rs b/crates/bsk-cli/tests/tools_ipc.rs index d40bb9c..4d8d2bc 100644 --- a/crates/bsk-cli/tests/tools_ipc.rs +++ b/crates/bsk-cli/tests/tools_ipc.rs @@ -478,6 +478,7 @@ async fn observe_returns_semantic_text_and_ref_count() { tab_id: 13, truncated: false, dialogs: vec![], + hover_probe: None, debug: None, }) .unwrap(), @@ -494,6 +495,7 @@ async fn observe_returns_semantic_text_and_ref_count() { max_depth: None, max_tokens: None, debug_surfaces: false, + probe_hover: false, }, ) .await diff --git a/crates/bsk-protocol/schema/tool_observe_params.json b/crates/bsk-protocol/schema/tool_observe_params.json index 10e57a0..a89e9ec 100644 --- a/crates/bsk-protocol/schema/tool_observe_params.json +++ b/crates/bsk-protocol/schema/tool_observe_params.json @@ -1,7 +1,7 @@ { "$schema": "http://json-schema.org/draft-07/schema#", "title": "ObserveParams", - "description": "Parameters for `tool.observe` — produce a semantic VOM observation for one tab plus a fresh `@e` ref-store on the extension side.\n\nUnlike `tool.snapshot`, this path may run bounded perception probes such as hover-surface discovery. It must not submit input, click, navigate, or otherwise commit page state.", + "description": "Parameters for `tool.observe` — produce a semantic VOM observation for one tab plus a fresh `@e` ref-store on the extension side.\n\nUnlike `tool.snapshot`, this path may run bounded perception probes such as hover-surface discovery, but only when `probe_hover` asks for them. It must not submit input, click, navigate, or otherwise commit page state.", "type": "object", "required": [ "session_id" @@ -30,6 +30,11 @@ "format": "uint32", "minimum": 0.0 }, + "probe_hover": { + "description": "Opt in to active hover probing: the extension moves the real cursor over a bounded set of controls to discover hover-only menus and tooltips.\n\nOff by default. Probing costs seconds of wall clock, mutates the live page, and pays off only on pages whose content is genuinely hidden behind hover. Request it when a static observation looks like it is missing hover-revealed structure.", + "default": false, + "type": "boolean" + }, "session_id": { "type": "string" }, diff --git a/crates/bsk-protocol/schema/tool_observe_result.json b/crates/bsk-protocol/schema/tool_observe_result.json index 22ee410..3e89a2f 100644 --- a/crates/bsk-protocol/schema/tool_observe_result.json +++ b/crates/bsk-protocol/schema/tool_observe_result.json @@ -24,6 +24,17 @@ "$ref": "#/definitions/JavaScriptDialogInfo" } }, + "hover_probe": { + "description": "Present only when `probe_hover` was requested and probing actually ran.", + "anyOf": [ + { + "$ref": "#/definitions/HoverProbeReport" + }, + { + "type": "null" + } + ] + }, "ref_count": { "description": "Number of `@e` refs registered for this session by this observation. Equivalent to the size of the new ref-store map.", "type": "integer", @@ -46,6 +57,24 @@ } }, "definitions": { + "HoverProbeReport": { + "description": "Reports what active hover probing did during an observation, so the agent can judge how far the live page may have drifted from the returned text.", + "type": "object", + "required": [ + "performed", + "revealed_content" + ], + "properties": { + "performed": { + "description": "The real cursor was moved over page content during this observation.", + "type": "boolean" + }, + "revealed_content": { + "description": "Hovering surfaced content the static tree does not contain, so the page reacts to the cursor and may not have fully reverted.", + "type": "boolean" + } + } + }, "JavaScriptDialogHandledAction": { "description": "How the extension resolved the dialog so CDP could continue.", "type": "string", diff --git a/crates/bsk-protocol/src/tools/observation.rs b/crates/bsk-protocol/src/tools/observation.rs index 0103ea5..9cbfc8d 100644 --- a/crates/bsk-protocol/src/tools/observation.rs +++ b/crates/bsk-protocol/src/tools/observation.rs @@ -58,8 +58,9 @@ pub struct SnapshotResult { /// for one tab plus a fresh `@e` ref-store on the extension side. /// /// Unlike `tool.snapshot`, this path may run bounded perception probes -/// such as hover-surface discovery. It must not submit input, click, -/// navigate, or otherwise commit page state. +/// such as hover-surface discovery, but only when `probe_hover` asks for +/// them. It must not submit input, click, navigate, or otherwise commit +/// page state. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] pub struct ObserveParams { pub session_id: String, @@ -77,6 +78,16 @@ pub struct ObserveParams { /// Include implementation diagnostics for conditional surface probes. #[serde(default)] pub debug_surfaces: bool, + /// Opt in to active hover probing: the extension moves the real cursor + /// over a bounded set of controls to discover hover-only menus and + /// tooltips. + /// + /// Off by default. Probing costs seconds of wall clock, mutates the live + /// page, and pays off only on pages whose content is genuinely hidden + /// behind hover. Request it when a static observation looks like it is + /// missing hover-revealed structure. + #[serde(default)] + pub probe_hover: bool, } #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] @@ -102,6 +113,17 @@ pub struct ObserveDebug { pub surface_probes: Vec, } +/// Reports what active hover probing did during an observation, so the agent +/// can judge how far the live page may have drifted from the returned text. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] +pub struct HoverProbeReport { + /// The real cursor was moved over page content during this observation. + pub performed: bool, + /// Hovering surfaced content the static tree does not contain, so the page + /// reacts to the cursor and may not have fully reverted. + pub revealed_content: bool, +} + #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] pub struct ObserveResult { /// Semantic VOM observation text. Refs are rendered as `@e` so @@ -118,6 +140,9 @@ pub struct ObserveResult { pub truncated: bool, #[serde(default, skip_serializing_if = "Vec::is_empty")] pub dialogs: Vec, + /// Present only when `probe_hover` was requested and probing actually ran. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub hover_probe: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub debug: Option, } @@ -308,6 +333,7 @@ mod tests { tab_id: 42, truncated: false, dialogs: Vec::new(), + hover_probe: None, debug: None, }; let v = serde_json::to_value(&r).unwrap(); @@ -315,4 +341,30 @@ mod tests { let round: ObserveResult = serde_json::from_value(v).unwrap(); assert_eq!(round, r); } + + #[test] + fn observe_params_default_to_no_hover_probing() { + let params: ObserveParams = + serde_json::from_value(serde_json::json!({ "session_id": "s1" })).unwrap(); + assert!(!params.probe_hover); + } + + #[test] + fn observe_result_round_trips_with_hover_probe_report() { + let r = ObserveResult { + text: "@vom 1\n".into(), + ref_count: 0, + tab_id: 42, + truncated: false, + dialogs: Vec::new(), + hover_probe: Some(HoverProbeReport { + performed: true, + revealed_content: true, + }), + debug: None, + }; + let v = serde_json::to_value(&r).unwrap(); + let round: ObserveResult = serde_json::from_value(v).unwrap(); + assert_eq!(round, r); + } } diff --git a/skill/SKILL.md b/skill/SKILL.md index 42bb452..ffe436a 100644 --- a/skill/SKILL.md +++ b/skill/SKILL.md @@ -67,7 +67,7 @@ Write operations only affect tabs in the **Agent Window** (or tabs you **borrowe ``` bsk navigate --session -bsk observe --session → primary semantic VOM view; reveals hover/focus surfaces +bsk observe --session → primary semantic VOM view bsk snapshot --session → static aria tree fallback when VOM is insufficient bsk hover @e3 --session → reveal hover-triggered menus before re-observing/clicking bsk click @e4 --session → or bsk fill, bsk select, bsk press @@ -80,11 +80,13 @@ Prefer `@eN` refs from the latest snapshot over raw CSS selectors. Use `--ref` / When VOM renders `[hover first: …]` on an element, the listed items are not currently clickable refs. Run `bsk hover --session `, then immediately run `bsk snapshot` or `bsk observe` again and click the newly visible menu item ref. Do not click the trigger itself unless the user explicitly wants the trigger action. +`bsk observe` does not hover the page on its own. When a menu bar, toolbar, or icon row looks like it must hide content behind hover and you cannot tell which element to hover, add `--probe-hover` for one observation: it moves the real cursor over a bounded set of likely triggers to discover hover-only menus and tooltips. It costs a few seconds and touches the live page, so prefer `bsk hover ` once you know the trigger. + ## Observation priority -Start with `bsk observe` to understand page structure, text, controls, element refs, and conditional hover/focus surfaces. Use `bsk snapshot` only when you need the stricter static accessibility tree or VOM is insufficient. Only escalate to raw HTML or screenshots when the latest observation cannot answer the question: +Start with `bsk observe` to understand page structure, text, controls, and element refs. Use `bsk snapshot` only when you need the stricter static accessibility tree or VOM is insufficient. Only escalate to raw HTML or screenshots when the latest observation cannot answer the question: -1. `bsk observe` — primary semantic VOM observation; may run bounded perception probes such as hover-surface discovery +1. `bsk observe` — primary semantic VOM observation; add `--probe-hover` only when hover-only content is suspected 2. `bsk snapshot` — strict static accessibility tree fallback 3. `bsk get-html` — when hidden DOM, metadata, or markup details are required 4. `bsk screenshot` — when visual layout, canvas/image content, or styling cannot be inferred from the observation. Use `--ref @eN` (from the latest snapshot/observe) to crop to one element; omit `--ref` for the full visible tab. @@ -172,7 +174,7 @@ bsk emulate --session --off | Command | Summary | |---------|---------| | `bsk snapshot` | First-choice static page understanding: accessibility tree with `@eN` element refs | -| `bsk observe` | Semantic VOM observation with bounded perception probes for conditional surfaces | +| `bsk observe` | Semantic VOM observation; `--probe-hover` adds bounded active hover probing for conditional surfaces | | `bsk get-html` | Raw HTML dump after snapshot is insufficient (high token cost) | | `bsk screenshot` | PNG capture after snapshot is insufficient: full visible tab, or `--ref @eN` to crop to one element (`--out` path optional) | @@ -319,7 +321,7 @@ Always **`bsk session stop `** in a `finally`-style path so the Agent Window 2. **No long borrow** — do not leave a user's personal tab in the Agent Window across unrelated tasks. 3. **No skip stop** — always `bsk session stop `; never assume idle timeout will clean up. 4. **No post-success control** — once the user’s goal (or last trace step) is met, do not keep operating the page; stop the session unless they asked to keep it open. -5. **No raw observe escalation before snapshot/observe** — use `bsk snapshot` first; use `bsk observe` when VOM semantics or conditional surfaces help. Only use `bsk get-html` or `bsk screenshot` when snapshot/observe is insufficient. Element screenshots (`--ref @eN`) still require a fresh snapshot/observe ref — never skip observation just to grab a visual. +5. **No raw observe escalation before snapshot/observe** — use `bsk snapshot` first; use `bsk observe` when VOM semantics help. Only use `bsk get-html` or `bsk screenshot` when snapshot/observe is insufficient. Element screenshots (`--ref @eN`) still require a fresh snapshot/observe ref — never skip observation just to grab a visual. 6. **`evaluate` is powerful and risky** — use only when snapshot + click/fill/select cannot suffice; never on credential surfaces. --- From ee620acf64f2283eb85feaf35939dfcf911bd83d Mon Sep 17 00:00:00 2001 From: drakezhang Date: Wed, 2 Sep 2026 10:30:54 +0800 Subject: [PATCH 5/8] docs(skill): give probe-hover a decidable trigger condition MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous wording asked the agent to notice when a page "looks like" it hides content behind hover. That is not something an agent reading a text tree can judge: hover-only content is invisible by definition, and the marker that used to point at it came from the probing that is now off by default. Replaces it with a condition the agent can actually evaluate — an expected control is missing and no [has-submenu] marker points at a trigger — and places it on the escalation ladder ahead of get-html and screenshot, which is where that dead end is currently reached. Also documents [has-submenu]/[expanded] as the primary hover signal. It comes from aria-haspopup in the accessibility tree, costs nothing, and is unaffected by probing being off, so it covers menus that are marked up correctly. --- crates/bsk-cli/skill/SKILL.md | 13 +++++++------ skill/SKILL.md | 13 +++++++------ 2 files changed, 14 insertions(+), 12 deletions(-) diff --git a/crates/bsk-cli/skill/SKILL.md b/crates/bsk-cli/skill/SKILL.md index ffe436a..47be6c3 100644 --- a/crates/bsk-cli/skill/SKILL.md +++ b/crates/bsk-cli/skill/SKILL.md @@ -78,18 +78,19 @@ bsk observe --session → again after navigation / DOM change Prefer `@eN` refs from the latest snapshot over raw CSS selectors. Use `--ref` / `--selector` when ambiguous (`bsk click --help`). -When VOM renders `[hover first: …]` on an element, the listed items are not currently clickable refs. Run `bsk hover --session `, then immediately run `bsk snapshot` or `bsk observe` again and click the newly visible menu item ref. Do not click the trigger itself unless the user explicitly wants the trigger action. +When VOM marks a control `[has-submenu]` or `[expanded]`, its menu items are not in the observation yet. Run `bsk hover --session `, then immediately run `bsk snapshot` or `bsk observe` again and click the newly visible menu item ref. Do not click the trigger itself unless the user explicitly wants the trigger action. `[hover first: …]` means the same thing and additionally lists what that trigger revealed. -`bsk observe` does not hover the page on its own. When a menu bar, toolbar, or icon row looks like it must hide content behind hover and you cannot tell which element to hover, add `--probe-hover` for one observation: it moves the real cursor over a bounded set of likely triggers to discover hover-only menus and tooltips. It costs a few seconds and touches the live page, so prefer `bsk hover ` once you know the trigger. +`bsk observe` does not hover the page on its own. Reach for `--probe-hover` when a control you have good reason to expect is absent from the observation **and** no `[has-submenu]` marker points at a trigger — that combination is what a CSS-only hover menu looks like from here. Try it before falling back to `bsk get-html` or `bsk screenshot`. It hovers a bounded set of likely triggers to surface hover-only menus and tooltips; it costs a few seconds and touches the live page, so once you know which element hides the menu, `bsk hover ` is cheaper and more precise. ## Observation priority Start with `bsk observe` to understand page structure, text, controls, and element refs. Use `bsk snapshot` only when you need the stricter static accessibility tree or VOM is insufficient. Only escalate to raw HTML or screenshots when the latest observation cannot answer the question: -1. `bsk observe` — primary semantic VOM observation; add `--probe-hover` only when hover-only content is suspected -2. `bsk snapshot` — strict static accessibility tree fallback -3. `bsk get-html` — when hidden DOM, metadata, or markup details are required -4. `bsk screenshot` — when visual layout, canvas/image content, or styling cannot be inferred from the observation. Use `--ref @eN` (from the latest snapshot/observe) to crop to one element; omit `--ref` for the full visible tab. +1. `bsk observe` — primary semantic VOM observation +2. `bsk observe --probe-hover` — re-run once when an expected control is missing and nothing marks a hover trigger +3. `bsk snapshot` — strict static accessibility tree fallback +4. `bsk get-html` — when hidden DOM, metadata, or markup details are required +5. `bsk screenshot` — when visual layout, canvas/image content, or styling cannot be inferred from the observation. Use `--ref @eN` (from the latest snapshot/observe) to crop to one element; omit `--ref` for the full visible tab. Do **not** call `bsk get-html` or `bsk screenshot` first just to inspect a page. diff --git a/skill/SKILL.md b/skill/SKILL.md index ffe436a..47be6c3 100644 --- a/skill/SKILL.md +++ b/skill/SKILL.md @@ -78,18 +78,19 @@ bsk observe --session → again after navigation / DOM change Prefer `@eN` refs from the latest snapshot over raw CSS selectors. Use `--ref` / `--selector` when ambiguous (`bsk click --help`). -When VOM renders `[hover first: …]` on an element, the listed items are not currently clickable refs. Run `bsk hover --session `, then immediately run `bsk snapshot` or `bsk observe` again and click the newly visible menu item ref. Do not click the trigger itself unless the user explicitly wants the trigger action. +When VOM marks a control `[has-submenu]` or `[expanded]`, its menu items are not in the observation yet. Run `bsk hover --session `, then immediately run `bsk snapshot` or `bsk observe` again and click the newly visible menu item ref. Do not click the trigger itself unless the user explicitly wants the trigger action. `[hover first: …]` means the same thing and additionally lists what that trigger revealed. -`bsk observe` does not hover the page on its own. When a menu bar, toolbar, or icon row looks like it must hide content behind hover and you cannot tell which element to hover, add `--probe-hover` for one observation: it moves the real cursor over a bounded set of likely triggers to discover hover-only menus and tooltips. It costs a few seconds and touches the live page, so prefer `bsk hover ` once you know the trigger. +`bsk observe` does not hover the page on its own. Reach for `--probe-hover` when a control you have good reason to expect is absent from the observation **and** no `[has-submenu]` marker points at a trigger — that combination is what a CSS-only hover menu looks like from here. Try it before falling back to `bsk get-html` or `bsk screenshot`. It hovers a bounded set of likely triggers to surface hover-only menus and tooltips; it costs a few seconds and touches the live page, so once you know which element hides the menu, `bsk hover ` is cheaper and more precise. ## Observation priority Start with `bsk observe` to understand page structure, text, controls, and element refs. Use `bsk snapshot` only when you need the stricter static accessibility tree or VOM is insufficient. Only escalate to raw HTML or screenshots when the latest observation cannot answer the question: -1. `bsk observe` — primary semantic VOM observation; add `--probe-hover` only when hover-only content is suspected -2. `bsk snapshot` — strict static accessibility tree fallback -3. `bsk get-html` — when hidden DOM, metadata, or markup details are required -4. `bsk screenshot` — when visual layout, canvas/image content, or styling cannot be inferred from the observation. Use `--ref @eN` (from the latest snapshot/observe) to crop to one element; omit `--ref` for the full visible tab. +1. `bsk observe` — primary semantic VOM observation +2. `bsk observe --probe-hover` — re-run once when an expected control is missing and nothing marks a hover trigger +3. `bsk snapshot` — strict static accessibility tree fallback +4. `bsk get-html` — when hidden DOM, metadata, or markup details are required +5. `bsk screenshot` — when visual layout, canvas/image content, or styling cannot be inferred from the observation. Use `--ref @eN` (from the latest snapshot/observe) to crop to one element; omit `--ref` for the full visible tab. Do **not** call `bsk get-html` or `bsk screenshot` first just to inspect a page. From 95571f8b70afee75ae1dd1da48c918d2f3416c7f Mon Sep 17 00:00:00 2001 From: drakezhang Date: Wed, 2 Sep 2026 15:11:40 +0800 Subject: [PATCH 6/8] =?UTF-8?q?fix(borrow):=20improve=20confirmation=20UX?= =?UTF-8?q?=20=E2=80=94=20proactive=20focus=20and=20longer=20timeout?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The borrow confirmation overlay was easily missed because the Agent Window stole focus and the 5-second auto-deny was too aggressive for users who had context-switched away. - Focus the user window proactively when a borrow request is issued so the overlay is immediately visible even when the Agent Window was last focused. - Raise CONFIRMATION_TIMEOUT_MS from 5 s to 60 s, giving users enough time to notice and respond — the OS notification with requireInteraction: true now has a matching window of usefulness. Co-authored-by: Cursor --- .../BorrowConfirmationOverlay.test.tsx | 8 ++--- .../__tests__/borrow-confirmation.test.ts | 32 +++++++++++++++++++ .../src/tools/borrow-confirmation.ts | 9 +++++- 3 files changed, 44 insertions(+), 5 deletions(-) diff --git a/apps/extension/src/content/__tests__/BorrowConfirmationOverlay.test.tsx b/apps/extension/src/content/__tests__/BorrowConfirmationOverlay.test.tsx index d273ef8..f1a8368 100644 --- a/apps/extension/src/content/__tests__/BorrowConfirmationOverlay.test.tsx +++ b/apps/extension/src/content/__tests__/BorrowConfirmationOverlay.test.tsx @@ -23,7 +23,7 @@ describe("BorrowConfirmationOverlay", () => { id: "req-1", isActiveTab: true, tabTitle: "Example", - timeoutMs: 5000, + timeoutMs: 60_000, onAllow, onDeny, }, @@ -32,7 +32,7 @@ describe("BorrowConfirmationOverlay", () => { ); expect(screen.getByText("允许借用标签页?")).toBeTruthy(); - for (let i = 0; i < 5; i++) { + for (let i = 0; i < 60; i++) { await act(async () => { await vi.advanceTimersByTimeAsync(1000); }); @@ -66,7 +66,7 @@ describe("BorrowConfirmationOverlay", () => { id: "req-fallback", isActiveTab: true, tabTitle: "Example", - timeoutMs: 5000, + timeoutMs: 60_000, onAllow, onDeny, }, @@ -74,7 +74,7 @@ describe("BorrowConfirmationOverlay", () => { />, ); - for (let i = 0; i < 5; i++) { + for (let i = 0; i < 60; i++) { await act(async () => { await vi.advanceTimersByTimeAsync(1000); }); diff --git a/apps/extension/src/tools/__tests__/borrow-confirmation.test.ts b/apps/extension/src/tools/__tests__/borrow-confirmation.test.ts index 0cb106b..784fa80 100644 --- a/apps/extension/src/tools/__tests__/borrow-confirmation.test.ts +++ b/apps/extension/src/tools/__tests__/borrow-confirmation.test.ts @@ -127,6 +127,38 @@ describe("requestBorrowConfirmation", () => { expect(message.requestId.length).toBeGreaterThan(0); }); + it("proactively focuses the user window before sending the overlay message", async () => { + tabs.get.mockResolvedValueOnce({ id: 42, title: "Tab" }); + windows.getLastFocused.mockResolvedValueOnce( + userWindowWithActiveTab({ windowId: 77, tabId: 7, url: "https://app.example/" }), + ); + tabs.sendMessage.mockResolvedValueOnce({ type: "borrow-response", allowed: true }); + + const pending = requestBorrowConfirmation(42, { + deps: { tabs, windows, notifications }, + }); + await vi.runAllTimersAsync(); + await pending; + + expect(windows.update).toHaveBeenCalledWith(77, { focused: true }); + }); + + it("still proceeds when proactive window focus fails", async () => { + tabs.get.mockResolvedValueOnce({ id: 42, title: "Tab" }); + windows.getLastFocused.mockResolvedValueOnce( + userWindowWithActiveTab({ windowId: 77, tabId: 7, url: "https://app.example/" }), + ); + windows.update.mockRejectedValueOnce(new Error("window gone")); + tabs.sendMessage.mockResolvedValueOnce({ type: "borrow-response", allowed: true }); + + const pending = requestBorrowConfirmation(42, { + deps: { tabs, windows, notifications }, + }); + await vi.runAllTimersAsync(); + + expect(await pending).toBe(true); + }); + it("sends confirmation to the borrow target when it is the active tab", async () => { tabs.get.mockResolvedValueOnce({ id: 42, title: "Active Tab" }); windows.getLastFocused.mockResolvedValueOnce( diff --git a/apps/extension/src/tools/borrow-confirmation.ts b/apps/extension/src/tools/borrow-confirmation.ts index 68a889f..b575084 100644 --- a/apps/extension/src/tools/borrow-confirmation.ts +++ b/apps/extension/src/tools/borrow-confirmation.ts @@ -24,7 +24,7 @@ export interface BorrowResponseMessage { allowed: boolean; } -export const CONFIRMATION_TIMEOUT_MS = 5000; +export const CONFIRMATION_TIMEOUT_MS = 60_000; const EXIT_ANIMATION_MS = 150; /** Matches BorrowConfirmationOverlay progress ring/bar transition (duration-1000). */ export const PROGRESS_TRANSITION_MS = 1000; @@ -480,6 +480,13 @@ export async function requestBorrowConfirmation( settle(false); }, BACKGROUND_TIMEOUT_MS); + // Bring the user window to the front so the overlay is visible even + // when the Agent Window has stolen focus. This is fire-and-forget: if + // it fails the overlay / OS notification still give the user a path. + void windowsApi.update(notificationAnchor.windowId, { focused: true }).catch((err) => { + console.debug("[bsk borrow] proactive focus of user window failed", err); + }); + // Surface the OS notification *before* messaging any candidate so the // user has a parallel signal even if every content script is missing. // The Allow / Deny buttons let the user authorize explicitly when the From 51473c01ffbf040bbbdd1a73ff15d6121c1aac4b Mon Sep 17 00:00:00 2001 From: drakezhang Date: Wed, 2 Sep 2026 16:14:24 +0800 Subject: [PATCH 7/8] fix(dsh-plugin): add PiP close button and sniff observation thumbnail media type MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Document PiP window had no close/return entry once popped out — users could not dismiss it from inside the window. Add an onClosePip callback and render a close button in the PiP header; clicking it calls pipWindow.close() and the content returns to its carrier. The observation thumbnail publisher hardcoded mediaType as "image/png" regardless of the actual capture bytes. Reuse the existing sniffImageMediaType helper (exported from image.ts) so the HTTP thumbnail endpoint declares the correct Content-Type when Chromium returns JPEG captures. Closes #153 (Bug 2 fully; Bug 1 was fixed in #135, this covers the remaining observation-thumbnail path). Co-authored-by: Cursor --- .../src/client/ObservationOverlay.tsx | 15 +++++++++++ .../src/client/observation-sidebar.tsx | 1 + packages/dsh-plugin-browserskill/src/image.ts | 2 +- .../src/observation.ts | 3 ++- .../tests/client/observation-overlay.test.tsx | 27 +++++++++++++++++++ .../tests/observation.test.ts | 12 +++++++++ 6 files changed, 58 insertions(+), 2 deletions(-) diff --git a/packages/dsh-plugin-browserskill/src/client/ObservationOverlay.tsx b/packages/dsh-plugin-browserskill/src/client/ObservationOverlay.tsx index edca403..4faf550 100644 --- a/packages/dsh-plugin-browserskill/src/client/ObservationOverlay.tsx +++ b/packages/dsh-plugin-browserskill/src/client/ObservationOverlay.tsx @@ -14,6 +14,7 @@ import { cn } from "@browser-skill/ui"; import { RiArrowDownSLine, RiCheckLine, + RiCloseLine, RiCloseCircleLine, RiErrorWarningLine, RiPictureInPicture2Line, @@ -28,6 +29,7 @@ const asIcon = (component: unknown): IconComponent => component as IconComponent const IconStop = asIcon(RiStopCircleLine); const IconPip = asIcon(RiPictureInPicture2Line); const IconDown = asIcon(RiArrowDownSLine); +const IconClose = asIcon(RiCloseLine); const IconWarn = asIcon(RiErrorWarningLine); const IconPin = asIcon(RiPushpinFill); const IconCloseSession = asIcon(RiCloseCircleLine); @@ -320,6 +322,7 @@ export function OverlayBody(props: { now: number; onPopOut?: (() => void) | undefined; onCollapse?: (() => void) | undefined; + onClosePip?: (() => void) | undefined; inPip: boolean; onHeaderPointerDown?: (event: ReactPointerEvent) => void; }) { @@ -333,6 +336,7 @@ export function OverlayBody(props: { now, onPopOut, onCollapse, + onClosePip, inPip, onHeaderPointerDown, } = props; @@ -389,6 +393,16 @@ export function OverlayBody(props: { ) : null} + {onClosePip !== undefined ? ( + + ) : null}
{displayUrl !== undefined ? ( @@ -572,6 +586,7 @@ export function ObservationOverlay({ store }: { store: ObservationClientStore }) : undefined } onCollapse={pipWindow === null ? () => setCollapsed(true) : undefined} + onClosePip={pipWindow !== null ? () => pipWindow.close() : undefined} onHeaderPointerDown={pipWindow === null ? beginMove : undefined} /> ); diff --git a/packages/dsh-plugin-browserskill/src/client/observation-sidebar.tsx b/packages/dsh-plugin-browserskill/src/client/observation-sidebar.tsx index 96095d8..a4fea16 100644 --- a/packages/dsh-plugin-browserskill/src/client/observation-sidebar.tsx +++ b/packages/dsh-plugin-browserskill/src/client/observation-sidebar.tsx @@ -149,6 +149,7 @@ export function ObservationSidebarTab({ now={now} inPip={pipWindow !== null} onPopOut={pipWindow === null && pipSupported ? () => popOut() : undefined} + onClosePip={pipWindow !== null ? () => pipWindow.close() : undefined} /> ); diff --git a/packages/dsh-plugin-browserskill/src/image.ts b/packages/dsh-plugin-browserskill/src/image.ts index a0b1801..8f5ed5e 100644 --- a/packages/dsh-plugin-browserskill/src/image.ts +++ b/packages/dsh-plugin-browserskill/src/image.ts @@ -42,7 +42,7 @@ interface LlmLike { * @param data - first bytes of the capture. * @returns the detected media type, or undefined when unrecognized. */ -function sniffImageMediaType(data: Uint8Array): string | undefined { +export function sniffImageMediaType(data: Uint8Array): string | undefined { if (data.length >= 4) { // PNG signature: 89 50 4E 47 (0D 0A 1A 0A) if (data[0] === 0x89 && data[1] === 0x50 && data[2] === 0x4e && data[3] === 0x47) { diff --git a/packages/dsh-plugin-browserskill/src/observation.ts b/packages/dsh-plugin-browserskill/src/observation.ts index 8705b24..5c280f2 100644 --- a/packages/dsh-plugin-browserskill/src/observation.ts +++ b/packages/dsh-plugin-browserskill/src/observation.ts @@ -17,6 +17,7 @@ import { readFile, unlink } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import type { Context } from "@deepseek-ai/cordis"; +import { sniffImageMediaType } from "./image"; import type { KeyedExecutor } from "./queue"; import { BskError, type BskRunner, parseBskJson, runWithSessionBusyRetry } from "./runner"; import type { SessionRegistry } from "./sessions"; @@ -467,7 +468,7 @@ export class ObservationService { const seq = (this.seqs.get(sessionId) ?? 0) + 1; this.seqs.set(sessionId, seq); const id = `obs-${sessionId}-${seq}`; - this.frames.set(id, { data, mediaType: "image/png" }); + this.frames.set(id, { data, mediaType: sniffImageMediaType(data) ?? "image/png" }); this.hashes.set(sessionId, hash); const ring = this.rings.get(sessionId) ?? []; ring.push(id); diff --git a/packages/dsh-plugin-browserskill/tests/client/observation-overlay.test.tsx b/packages/dsh-plugin-browserskill/tests/client/observation-overlay.test.tsx index 7290296..21cbdbd 100644 --- a/packages/dsh-plugin-browserskill/tests/client/observation-overlay.test.tsx +++ b/packages/dsh-plugin-browserskill/tests/client/observation-overlay.test.tsx @@ -298,6 +298,33 @@ describe("ObservationOverlay", () => { expect(close).toHaveBeenCalledTimes(1); }); + it("shows a close button inside the PiP window that closes it", async () => { + const h = makeHarness([BUSY]); + const pipDoc = document.implementation.createHTMLDocument("pip"); + const close = vi.fn(); + const listeners = new Map void)[]>(); + const requestWindow = vi.fn(async () => { + return { + document: pipDoc, + addEventListener: (name: string, fn: () => void) => { + listeners.set(name, [...(listeners.get(name) ?? []), fn]); + }, + close, + } as unknown as Window; + }); + (window as unknown as Record).documentPictureInPicture = { requestWindow }; + render(); + const popout = await screen.findByRole("button", { name: /Pop out/ }); + fireEvent.click(popout); + await waitFor(() => expect(pipDoc.body.textContent).toContain("s1")); + const closeBtn = pipDoc.body.querySelector( + 'button[aria-label="Close mini window"]', + ); + expect(closeBtn).not.toBeNull(); + closeBtn!.click(); + expect(close).toHaveBeenCalledTimes(1); + }); + it("renders the strip for two sessions and pins focus on click", async () => { const older: SessionObservation = { sessionId: "s1", diff --git a/packages/dsh-plugin-browserskill/tests/observation.test.ts b/packages/dsh-plugin-browserskill/tests/observation.test.ts index 98182e2..05b2eb2 100644 --- a/packages/dsh-plugin-browserskill/tests/observation.test.ts +++ b/packages/dsh-plugin-browserskill/tests/observation.test.ts @@ -335,6 +335,18 @@ describe("thumbnail cadence", () => { await waitFor(() => service.getState()[0]?.thumbnailAttachmentId === "obs-s1-1"); expect((await service.readThumbnail("obs-s1-1"))?.mediaType).toBe("image/png"); }); + + it("detects JPEG thumbnail bytes instead of hardcoding image/png", async () => { + const scheduler = fakeScheduler(); + const runner = fakeRunner({ + screenshotBytes: () => new Uint8Array([0xff, 0xd8, 0xff, 0xdb, 0x00, 0x01]), + }); + const { service } = setup({ runner, scheduler }); + service.addSession("s1"); + scheduler.runNext(); + await waitFor(() => service.getState()[0]?.thumbnailAttachmentId === "obs-s1-1"); + expect((await service.readThumbnail("obs-s1-1"))?.mediaType).toBe("image/jpeg"); + }); }); describe("observation traffic isolation", () => { From fee7c0983cb107634969693cc13f94d14081eae2 Mon Sep 17 00:00:00 2001 From: drakezhang Date: Wed, 2 Sep 2026 16:21:26 +0800 Subject: [PATCH 8/8] fix: sort import order to satisfy biome organizeImports Co-authored-by: Cursor --- .../dsh-plugin-browserskill/src/client/ObservationOverlay.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/dsh-plugin-browserskill/src/client/ObservationOverlay.tsx b/packages/dsh-plugin-browserskill/src/client/ObservationOverlay.tsx index 4faf550..a07fe98 100644 --- a/packages/dsh-plugin-browserskill/src/client/ObservationOverlay.tsx +++ b/packages/dsh-plugin-browserskill/src/client/ObservationOverlay.tsx @@ -14,8 +14,8 @@ import { cn } from "@browser-skill/ui"; import { RiArrowDownSLine, RiCheckLine, - RiCloseLine, RiCloseCircleLine, + RiCloseLine, RiErrorWarningLine, RiPictureInPicture2Line, RiPushpinFill,