feat(stage-tamagotchi,computer-use-mcp): implement browser-native DOM action routing (#1648)

---------

Co-authored-by-agent: Antigravity <antigravity@gemini.com>
Co-authored-by-agent: Codex <267193182+codex@users.noreply.github.com>
This commit is contained in:
刘梓恒
2026-04-25 00:08:22 +08:00
committed by GitHub
parent 632aa1ad99
commit 255ce71cba
24 changed files with 2306 additions and 866 deletions
@@ -133,6 +133,11 @@ export default defineConfig({
alias: {
'@proj-airi/server-sdk': resolve(join(import.meta.dirname, '..', '..', 'packages', 'server-sdk', 'src')),
'@proj-airi/i18n': resolve(join(import.meta.dirname, '..', '..', 'packages', 'i18n', 'src')),
// NOTICE: the @proj-airi/stage-ui alias resolves to a directory; rolldown
// concatenates sub-paths without a file extension, so bare .ts files at the
// stores/ root (e.g. mcp-tool-bridge.ts) are not found. Add explicit aliases
// for each such file that the renderer imports from @proj-airi/stage-ui.
'@proj-airi/stage-ui/stores/mcp-tool-bridge': resolve(join(import.meta.dirname, '..', '..', 'packages', 'stage-ui', 'src', 'stores', 'mcp-tool-bridge.ts')),
'@proj-airi/stage-ui': resolve(join(import.meta.dirname, '..', '..', 'packages', 'stage-ui', 'src')),
'@proj-airi/stage-pages': resolve(join(import.meta.dirname, '..', '..', 'packages', 'stage-pages', 'src')),
'@proj-airi/stage-shared': resolve(join(import.meta.dirname, '..', '..', 'packages', 'stage-shared', 'src')),
+1 -1
View File
@@ -207,7 +207,7 @@ app.whenReady().then(async () => {
// provider depends on 'windows:desktop-overlay'.
injeca.invoke({
dependsOn: { desktopOverlay },
callback: noop,
callback: () => {},
})
}
@@ -1,5 +1,4 @@
import type { McpCallToolResult } from '@proj-airi/stage-ui/tools/mcp'
import type { ElectronMcpCallToolResult } from '../../shared/eventa'
import type { OverlayState } from './desktop-overlay-polling'
import { afterEach, describe, expect, it, vi } from 'vitest'
@@ -162,7 +161,7 @@ describe('createOverlayPollController', () => {
it('calls tool and delivers state on successful poll', async () => {
vi.useFakeTimers()
const mockResult: McpCallToolResult = {
const mockResult: ElectronMcpCallToolResult = {
structuredContent: {
runState: {
lastGroundingSnapshot: {
@@ -176,7 +175,7 @@ describe('createOverlayPollController', () => {
},
}
const callTool = vi.fn<(name: string) => Promise<McpCallToolResult>>()
const callTool = vi.fn<(name: string) => Promise<ElectronMcpCallToolResult>>()
.mockResolvedValue(mockResult)
const received: OverlayState[] = []
@@ -201,32 +200,10 @@ describe('createOverlayPollController', () => {
controller.stop()
})
it('clears the per-call timeout when the tool resolves before the timeout fires', async () => {
vi.useFakeTimers()
const callTool = vi.fn<(name: string) => Promise<McpCallToolResult>>()
.mockResolvedValue({ structuredContent: {} })
const controller = createOverlayPollController({
callTool,
onState: () => {},
intervalMs: 100,
callTimeoutMs: 500,
})
controller.start()
await vi.advanceTimersByTimeAsync(0)
// Only the next poll should remain scheduled. The per-call timeout must be cleared.
expect(vi.getTimerCount()).toBe(1)
controller.stop()
})
it('stops polling after stop() is called', async () => {
vi.useFakeTimers()
const callTool = vi.fn<(name: string) => Promise<McpCallToolResult>>()
const callTool = vi.fn<(name: string) => Promise<ElectronMcpCallToolResult>>()
.mockResolvedValue({ structuredContent: {} })
const controller = createOverlayPollController({
@@ -250,7 +227,7 @@ describe('createOverlayPollController', () => {
it('continues polling after a single failure', async () => {
vi.useFakeTimers()
const callTool = vi.fn<(name: string) => Promise<McpCallToolResult>>()
const callTool = vi.fn<(name: string) => Promise<ElectronMcpCallToolResult>>()
.mockRejectedValueOnce(new Error('MCP down'))
.mockResolvedValue({
structuredContent: {
@@ -292,7 +269,7 @@ describe('createOverlayPollController', () => {
it('is a no-op to call start() twice', async () => {
vi.useFakeTimers()
const callTool = vi.fn<(name: string) => Promise<McpCallToolResult>>()
const callTool = vi.fn<(name: string) => Promise<ElectronMcpCallToolResult>>()
.mockResolvedValue({ structuredContent: {} })
const controller = createOverlayPollController({
@@ -313,8 +290,9 @@ describe('createOverlayPollController', () => {
it('recovers from a hanging callTool via per-call timeout', async () => {
vi.useFakeTimers()
const callTool = vi.fn<(name: string) => Promise<McpCallToolResult>>()
.mockImplementationOnce(() => new Promise<McpCallToolResult>(() => {}))
// First call hangs forever (simulates startup race when RPC not ready)
const callTool = vi.fn<(name: string) => Promise<ElectronMcpCallToolResult>>()
.mockImplementationOnce(() => new Promise(() => {})) // never resolves
.mockResolvedValue({
structuredContent: {
runState: {
@@ -344,10 +322,11 @@ describe('createOverlayPollController', () => {
expect(callTool).toHaveBeenCalledTimes(1)
expect(received).toHaveLength(0)
// Advance past the timeout and several fallback windows. The controller
// should allow a bounded recovery retry even though the original invoke
// is still hung in the background.
// Advance past the 500ms timeout → catch triggers, schedules fallback
await vi.advanceTimersByTimeAsync(500)
expect(received).toHaveLength(0)
// Advance past the 200ms fallback interval → second poll fires and succeeds
await vi.advanceTimersByTimeAsync(200)
expect(callTool).toHaveBeenCalledTimes(2)
expect(received).toHaveLength(1)
@@ -355,87 +334,4 @@ describe('createOverlayPollController', () => {
controller.stop()
})
it('caps outstanding timed-out polls to avoid unbounded buildup', async () => {
vi.useFakeTimers()
const callTool = vi.fn<(name: string) => Promise<McpCallToolResult>>()
.mockImplementation(() => new Promise<McpCallToolResult>(() => {}))
const controller = createOverlayPollController({
callTool,
onState: () => {},
intervalMs: 100,
fallbackIntervalMs: 200,
callTimeoutMs: 500,
})
controller.start()
await vi.advanceTimersByTimeAsync(0)
expect(callTool).toHaveBeenCalledTimes(1)
await vi.advanceTimersByTimeAsync(500)
await vi.advanceTimersByTimeAsync(200)
expect(callTool).toHaveBeenCalledTimes(2)
await vi.advanceTimersByTimeAsync(500)
await vi.advanceTimersByTimeAsync(1000)
expect(callTool).toHaveBeenCalledTimes(2)
controller.stop()
})
it('recovers again once a timed-out hung-call slot lease expires', async () => {
vi.useFakeTimers()
const callTool = vi.fn<(name: string) => Promise<McpCallToolResult>>()
.mockImplementationOnce(() => new Promise<McpCallToolResult>(() => {}))
.mockImplementationOnce(() => new Promise<McpCallToolResult>(() => {}))
.mockResolvedValue({
structuredContent: {
runState: {
lastGroundingSnapshot: {
snapshotId: 'dg_after_lease',
targetCandidates: [],
staleFlags: { screenshot: false, ax: false, chromeSemantic: false },
},
},
},
})
const received: OverlayState[] = []
const controller = createOverlayPollController({
callTool,
onState: (state) => {
received.push(state)
},
intervalMs: 100,
fallbackIntervalMs: 200,
callTimeoutMs: 500,
hungCallLeaseMs: 1000,
})
controller.start()
await vi.advanceTimersByTimeAsync(0)
expect(callTool).toHaveBeenCalledTimes(1)
await vi.advanceTimersByTimeAsync(500)
await vi.advanceTimersByTimeAsync(200)
expect(callTool).toHaveBeenCalledTimes(2)
await vi.advanceTimersByTimeAsync(500)
await vi.advanceTimersByTimeAsync(200)
expect(callTool).toHaveBeenCalledTimes(2)
expect(received).toHaveLength(0)
await vi.advanceTimersByTimeAsync(200)
expect(callTool).toHaveBeenCalledTimes(3)
expect(received).toHaveLength(1)
expect(received[0].snapshotId).toBe('dg_after_lease')
controller.stop()
})
})
@@ -5,7 +5,7 @@
* without a DOM environment or Vue test-utils.
*/
import type { McpCallToolResult } from '@proj-airi/stage-ui/tools/mcp'
import type { ElectronMcpCallToolResult } from '../../shared/eventa'
// ---------------------------------------------------------------------------
// Types — minimal shapes matching RunState fields the overlay consumes
@@ -90,7 +90,7 @@ export function extractOverlayState(runState: Record<string, unknown>): OverlayS
* Extract runState from an MCP call result.
* Returns undefined if the result is an error or has no structured content.
*/
export function extractRunStateFromResult(result: McpCallToolResult): Record<string, unknown> | undefined {
export function extractRunStateFromResult(result: ElectronMcpCallToolResult): Record<string, unknown> | undefined {
if (result.isError)
return undefined
@@ -121,7 +121,7 @@ export interface OverlayPollController {
export interface OverlayPollConfig {
/** Function to call MCP tool. */
callTool: (name: string) => Promise<McpCallToolResult>
callTool: (name: string) => Promise<ElectronMcpCallToolResult>
/** Callback with extracted state on each successful poll. */
onState: (state: OverlayState) => void
/** Normal poll interval in ms. Default: 250. */
@@ -130,15 +130,11 @@ export interface OverlayPollConfig {
fallbackIntervalMs?: number
/** Per-call timeout in ms. Default: 5000. Prevents poll loop hang on startup race. */
callTimeoutMs?: number
/** How long a timed-out background call occupies a recovery slot before we probe again. */
hungCallLeaseMs?: number
}
const DEFAULT_INTERVAL = 250
const DEFAULT_FALLBACK_INTERVAL = 500
const DEFAULT_CALL_TIMEOUT = 5000
const DEFAULT_HUNG_CALL_LEASE = 5000
const MAX_BACKGROUND_HUNG_CALLS = 2
/**
* MCP server name for computer-use-mcp. Matches the key in mcp.json.
@@ -152,75 +148,21 @@ export const MCP_TOOL_NAME = 'computer_use::desktop_get_state'
export function createOverlayPollController(config: OverlayPollConfig): OverlayPollController {
const normalInterval = config.intervalMs ?? DEFAULT_INTERVAL
const fallbackInterval = config.fallbackIntervalMs ?? DEFAULT_FALLBACK_INTERVAL
const hungCallLeaseMs = config.hungCallLeaseMs ?? DEFAULT_HUNG_CALL_LEASE
let timer: ReturnType<typeof setTimeout> | null = null
let running = false
let inFlightCall: Promise<McpCallToolResult> | null = null
let backgroundHungSlots: Array<{ expiresAt: number }> = []
function scheduleNext(nextInterval: number) {
if (running) {
timer = setTimeout(poll, nextInterval)
}
}
function pruneHungCallSlots(now: number) {
backgroundHungSlots = backgroundHungSlots.filter(slot => slot.expiresAt > now)
}
async function poll() {
pruneHungCallSlots(Date.now())
if (inFlightCall || backgroundHungSlots.length >= MAX_BACKGROUND_HUNG_CALLS) {
scheduleNext(fallbackInterval)
return
}
let nextInterval = normalInterval
let timeoutId: ReturnType<typeof setTimeout> | undefined
try {
// NOTICE: Wrap callTool with a timeout to prevent the poll loop from
// hanging forever if the eventa invoke never resolves (e.g. during
// startup when the main-process RPC handlers may not be ready yet).
// NOTICE: The bridge does not expose abort semantics, so a timed-out
// call may still be pending in the background. We therefore track
// timed-out calls as expiring lease slots: the cap bounds how many
// unrecoverable invokes we tolerate at once, while lease expiry still
// lets the overlay probe again after a cooling-off window.
let timedOutSlot: { expiresAt: number } | null = null
const currentCall = config.callTool(MCP_TOOL_NAME)
inFlightCall = currentCall
currentCall.then(() => {
if (timedOutSlot) {
backgroundHungSlots = backgroundHungSlots.filter(slot => slot !== timedOutSlot)
}
else if (inFlightCall === currentCall) {
inFlightCall = null
}
}, () => {
if (timedOutSlot) {
backgroundHungSlots = backgroundHungSlots.filter(slot => slot !== timedOutSlot)
}
else if (inFlightCall === currentCall) {
inFlightCall = null
}
})
const result = await Promise.race([
currentCall,
config.callTool(MCP_TOOL_NAME),
new Promise<never>((_, reject) =>
timeoutId = setTimeout(() => {
timedOutSlot = {
expiresAt: Date.now() + hungCallLeaseMs,
}
backgroundHungSlots = [...backgroundHungSlots, timedOutSlot]
if (inFlightCall === currentCall) {
inFlightCall = null
}
reject(new Error('callTool timeout'))
}, config.callTimeoutMs ?? DEFAULT_CALL_TIMEOUT),
setTimeout(() => reject(new Error('callTool timeout')), config.callTimeoutMs ?? DEFAULT_CALL_TIMEOUT),
),
])
const runState = extractRunStateFromResult(result)
@@ -236,13 +178,10 @@ export function createOverlayPollController(config: OverlayPollConfig): OverlayP
// MCP server not running, bridge disconnected, or timeout — graceful degradation
nextInterval = fallbackInterval
}
finally {
if (timeoutId !== undefined) {
clearTimeout(timeoutId)
}
}
scheduleNext(nextInterval)
if (running) {
timer = setTimeout(poll, nextInterval)
}
}
return {
@@ -25,11 +25,15 @@ import { pointInOverlay, rectIntersectsOverlay, screenRectToLocal, screenToLocal
import { createEmptyOverlayState, createOverlayPollController } from './desktop-overlay-polling'
// ---------------------------------------------------------------------------
// Overlay window bounds
// Overlay window bounds — read once on mount from main process
// ---------------------------------------------------------------------------
const getWindowBounds = useElectronEventaInvoke(electron.window.getBounds)
const callMcpTool = useElectronEventaInvoke(electronMcpCallTool)
// Use Eventa invoke for MCP tool calls — McpToolBridge requires a
// setMcpToolBridge() caller that does not exist in the overlay renderer.
// electronMcpCallTool is already wired in setupDesktopOverlayElectronInvokes
// via createMcpServersService, so it works without any extra bootstrap.
const mcpCallTool = useElectronEventaInvoke(electronMcpCallTool)
const overlayBounds = ref<Rect | null>(null)
// ---------------------------------------------------------------------------
@@ -66,8 +70,12 @@ const matchedCandidate = computed(() => {
return visibleCandidates.value.find(c => c.id === pointerIntent.value!.candidateId) ?? null
})
// ---------------------------------------------------------------------------
// Polling controller
// ---------------------------------------------------------------------------
const controller = createOverlayPollController({
callTool: name => callMcpTool({ name }),
callTool: async name => mcpCallTool({ name }),
onState: (newState) => {
state.value = newState
},
@@ -123,11 +131,14 @@ const targetBoxStyle = computed(() => {
// Lifecycle
// ---------------------------------------------------------------------------
async function syncOverlayBounds() {
onMounted(async () => {
// Read overlay window bounds from main process (one-time)
try {
overlayBounds.value = await getWindowBounds()
const bounds = await getWindowBounds()
overlayBounds.value = bounds
}
catch {
// Fallback: assume bounds start at (0,0) with window inner size
overlayBounds.value = {
x: 0,
y: 0,
@@ -135,20 +146,11 @@ async function syncOverlayBounds() {
height: window.innerHeight,
}
}
}
function handleResize() {
void syncOverlayBounds()
}
onMounted(async () => {
await syncOverlayBounds()
window.addEventListener('resize', handleResize)
controller.start()
})
onUnmounted(() => {
window.removeEventListener('resize', handleResize)
controller.stop()
})
</script>
+1
View File
@@ -39,6 +39,7 @@
"./stores/settings/analytics": "./src/stores/settings/analytics.ts",
"./stores/settings": "./src/stores/settings/index.ts",
"./stores/modules/vision": "./src/stores/modules/vision/index.ts",
"./stores/mcp-tool-bridge": "./src/stores/mcp-tool-bridge.ts",
"./stores/*": "./src/stores/*.ts",
"./stores": "./src/stores/index.ts",
"./workers/vad": "./src/workers/vad/index.ts",
@@ -0,0 +1,50 @@
/**
* Minimal bridge interface for calling MCP tools from the desktop overlay
* renderer without a direct dependency on the MCP server runtime.
*
* The bridge is set by the Electron main/preload layer (or by a test stub)
* and retrieved by overlay pages that need to invoke computer-use MCP tools.
*/
export interface McpToolDescriptor {
serverName: string
name: string
toolName: string
description?: string
inputSchema: Record<string, unknown>
}
export interface McpCallToolPayload {
name: string
arguments?: Record<string, unknown>
}
export interface McpCallToolResult {
content?: Array<Record<string, unknown>>
structuredContent?: Record<string, unknown>
toolResult?: unknown
isError?: boolean
}
interface McpToolBridge {
listTools: () => Promise<McpToolDescriptor[]>
callTool: (payload: McpCallToolPayload) => Promise<McpCallToolResult>
}
let bridge: McpToolBridge | undefined
export function setMcpToolBridge(nextBridge: McpToolBridge) {
bridge = nextBridge
}
export function clearMcpToolBridge() {
bridge = undefined
}
export function getMcpToolBridge(): McpToolBridge {
if (!bridge) {
throw new Error('MCP tool bridge is not available in this runtime.')
}
return bridge
}
@@ -1,21 +1,21 @@
# AIRI Desktop Grounding — Chrome Extension
Read-only Chrome DOM observation bridge for the AIRI Desktop Grounding layer.
Chrome DOM observation and interaction bridge for the AIRI Desktop Grounding layer.
## What it does
- Collects interactive elements (buttons, links, inputs, etc.) from all frames in the active Chrome tab
- Reports element positions, ARIA roles, text, and rect coordinates
- Feeds this data into the desktop grounding snap resolver for coordinate mapping
- Performs targeted DOM interactions (set input values, check checkboxes, trigger events) when routed by the action executor
## What it does NOT do
- ❌ No DOM mutations (no clicking, typing, scrolling on DOM elements)
- ❌ No `eval` / `new Function` / `chrome.scripting.executeScript`
- ❌ No external network requests (no Python bridge, no offscreen documents)
- ❌ No popup UI
All user interactions are performed via real macOS OS-level input events (CGEvent) through the desktop grounding executor.
Physical click/type/scroll actions are performed via real macOS OS-level input events (CGEvent) through the desktop grounding executor. DOM mutations are limited to form-field writes and synthetic event dispatch via the bridge.
## Architecture
@@ -27,6 +27,8 @@ msg_bridge.js (ISOLATED world)
content.js (MAIN world, window.__AIRI_DG__)
```
The background service worker also maintains a native WebSocket connection to `BrowserDomExtensionBridge` (default port 8765) to relay commands from the AIRI host process.
## Installation (development)
1. Open `chrome://extensions/`
@@ -35,23 +37,6 @@ content.js (MAIN world, window.__AIRI_DG__)
4. Select this `chrome-extension/` directory
5. The extension will auto-inject into all pages
## Bridge endpoint override
By default the background worker connects to `ws://127.0.0.1:8765`.
If `computer-use-mcp` is running with a non-default
`COMPUTER_USE_BROWSER_DOM_BRIDGE_HOST` or `COMPUTER_USE_BROWSER_DOM_BRIDGE_PORT`,
override the extension endpoint through `chrome.storage.local`:
```js
await chrome.storage.local.set({
browserDomBridgeHost: '127.0.0.1',
browserDomBridgePort: 8876,
})
```
The service worker watches these keys and reconnects automatically.
## Supported commands
| Command | Description |
@@ -63,7 +48,15 @@ The service worker watches these keys and reconnects automatically.
| `findElements` | Find multiple elements by CSS selector |
| `getClickTarget` | Get element center point for click targeting |
| `getElementAttributes` | Get all attributes of an element |
| `setInputValue` | Set value of a text input or textarea |
| `checkCheckbox` | Check or uncheck a native checkbox/radio |
| `selectOption` | Select an option in a `<select>` element |
| `readInputValue` | Read the current value of an input/textarea/select |
| `getComputedStyles` | Get computed CSS styles for an element |
| `triggerEvent` | Dispatch a DOM event on an element |
| `waitForElement` | Wait for an element to appear in the DOM |
| `clickAt` | Dispatch a click event at viewport coordinates |
## Provenance
Adapted from `/Users/liuziheng/computer_use/chrome-extension/` with DOM-action methods stripped.
Adapted from the upstream computer-use chrome-extension.
@@ -12,165 +12,12 @@
* All DOM-mutating actions (click, type, hover, scroll) have been removed
* because the desktop lane uses real macOS OS-level input events.
*
* Adapted from /Users/liuziheng/computer_use/chrome-extension/background.js.
* Adapted from the upstream computer-use chrome-extension.
* Stripped: offscreen management, Python bridge, all DOM-action commands
* (clickAt, typeAt, hoverAt, scrollAt, simulateDragDrop, readStorage,
* setStorage, readCanvasData, injectCSS, executeScript, etc.)
*/
// ---- Bridge connection ----
const DEFAULT_BRIDGE_HOST = '127.0.0.1'
const DEFAULT_BRIDGE_PORT = 8765
const BRIDGE_RECONNECT_DELAY_MS = 1000
const BRIDGE_HOST_STORAGE_KEY = 'browserDomBridgeHost'
const BRIDGE_PORT_STORAGE_KEY = 'browserDomBridgePort'
let bridgeSocket = null
let reconnectTimer = null
let connecting = false
let bridgeHost = DEFAULT_BRIDGE_HOST
let bridgePort = DEFAULT_BRIDGE_PORT
function clearReconnectTimer() {
if (reconnectTimer !== null) {
clearTimeout(reconnectTimer)
reconnectTimer = null
}
}
function scheduleReconnect(delayMs = BRIDGE_RECONNECT_DELAY_MS) {
if (reconnectTimer !== null)
return
reconnectTimer = setTimeout(() => {
reconnectTimer = null
connectBridge().catch(() => {})
}, delayMs)
}
function sendBridgeMessage(payload) {
if (!bridgeSocket || bridgeSocket.readyState !== WebSocket.OPEN)
return false
bridgeSocket.send(JSON.stringify(payload))
return true
}
function normalizeBridgeHost(value) {
return typeof value === 'string' && value.trim() ? value.trim() : DEFAULT_BRIDGE_HOST
}
function normalizeBridgePort(value) {
if (typeof value === 'number' && Number.isInteger(value) && value > 0)
return value
if (typeof value === 'string' && value.trim()) {
const parsed = Number.parseInt(value.trim(), 10)
if (Number.isInteger(parsed) && parsed > 0)
return parsed
}
return DEFAULT_BRIDGE_PORT
}
async function loadBridgeConfig() {
try {
const stored = await chrome.storage.local.get([
BRIDGE_HOST_STORAGE_KEY,
BRIDGE_PORT_STORAGE_KEY,
])
bridgeHost = normalizeBridgeHost(stored[BRIDGE_HOST_STORAGE_KEY])
bridgePort = normalizeBridgePort(stored[BRIDGE_PORT_STORAGE_KEY])
}
catch {
bridgeHost = DEFAULT_BRIDGE_HOST
bridgePort = DEFAULT_BRIDGE_PORT
}
}
async function saveBridgeConfig(host, port) {
await chrome.storage.local.set({
[BRIDGE_HOST_STORAGE_KEY]: normalizeBridgeHost(host),
[BRIDGE_PORT_STORAGE_KEY]: normalizeBridgePort(port),
})
await loadBridgeConfig()
}
async function handleBridgeMessage(raw) {
let data
try {
data = JSON.parse(String(raw))
}
catch {
return
}
const response = await handleCommand(data)
sendBridgeMessage(response)
}
async function connectBridge() {
if (connecting)
return
if (bridgeSocket && (bridgeSocket.readyState === WebSocket.OPEN || bridgeSocket.readyState === WebSocket.CONNECTING))
return
connecting = true
try {
await loadBridgeConfig()
const socket = new WebSocket(`ws://${bridgeHost}:${bridgePort}`)
bridgeSocket = socket
socket.addEventListener('open', () => {
connecting = false
clearReconnectTimer()
sendBridgeMessage({
type: 'hello',
source: 'airi-desktop-grounding-extension',
version: chrome.runtime.getManifest().version,
})
})
socket.addEventListener('message', (event) => {
void handleBridgeMessage(event.data)
})
socket.addEventListener('close', () => {
if (bridgeSocket === socket) {
bridgeSocket = null
}
connecting = false
scheduleReconnect()
})
socket.addEventListener('error', () => {
connecting = false
try {
socket.close()
}
catch {}
})
}
catch {
connecting = false
scheduleReconnect()
}
}
function reconnectBridgeNow() {
clearReconnectTimer()
if (bridgeSocket) {
try {
bridgeSocket.close()
}
catch {}
bridgeSocket = null
}
connecting = false
void connectBridge()
}
// ---- Tab / Frame utilities ----
async function getActiveTab() {
@@ -240,7 +87,7 @@ async function runCUAction(tabId, frameIds, method, args) {
/**
* Handle a command from the AIRI BrowserDomExtensionBridge.
*
* Only read-only observation commands are supported:
* Supported actions:
* - getActiveTab: get the active tab info
* - getAllFrames: list all frames in the active tab
* - readAllFramesDOM: collect interactive elements from all frames
@@ -248,6 +95,14 @@ async function runCUAction(tabId, frameIds, method, args) {
* - findElements: find multiple elements by CSS selector
* - getClickTarget: get center point of an element for click targeting
* - getElementAttributes: get all attributes of an element
* - setInputValue: set value of a text input or textarea
* - checkCheckbox: check or uncheck a native checkbox/radio
* - selectOption: select an option in a <select> element
* - readInputValue: read the current value of an input/textarea/select
* - getComputedStyles: get computed CSS styles for an element
* - triggerEvent: dispatch a DOM event on an element
* - waitForElement: wait for an element to appear in the DOM
* - clickAt: dispatch a click event at viewport coordinates
*/
async function handleCommand(cmd) {
const { action, id } = cmd
@@ -289,7 +144,67 @@ async function handleCommand(cmd) {
result = await runCUAction(tabId, cmd.frameIds || null, 'getElementAttributes', [cmd.selector || ''])
break
case 'setInputValue':
result = await runCUAction(tabId, cmd.frameIds || null, 'setInputValue', [
cmd.selector || '',
cmd.value || '',
{ blur: cmd.opts?.blur !== false, simulateKeystrokes: !!cmd.opts?.simulateKeystrokes },
])
break
case 'checkCheckbox':
result = await runCUAction(tabId, cmd.frameIds || null, 'checkCheckbox', [
cmd.selector || '',
cmd.checked,
])
break
case 'selectOption':
result = await runCUAction(tabId, cmd.frameIds || null, 'selectOption', [
cmd.selector || '',
cmd.value || '',
])
break
case 'readInputValue':
result = await runCUAction(tabId, cmd.frameIds || null, 'readInputValue', [
cmd.selector || '',
])
break
case 'getComputedStyles':
result = await runCUAction(tabId, cmd.frameIds || null, 'getComputedStyles', [
cmd.selector || '',
cmd.properties || [],
])
break
case 'triggerEvent':
result = await runCUAction(tabId, cmd.frameIds || null, 'triggerEvent', [
cmd.selector || '',
cmd.eventName || '',
cmd.opts || {},
])
break
case 'waitForElement':
result = await runCUAction(tabId, cmd.frameIds || null, 'waitForElement', [
cmd.selector || '',
cmd.timeoutMs || 5000,
])
break
case 'clickAt':
result = await runCUAction(tabId, cmd.frameIds || null, 'clickAt', [
cmd.x ?? 0,
cmd.y ?? 0,
])
break
default:
// NOTICE: unknown actions must return ok:false so BrowserDomExtensionBridge
// rejects the pending promise; returning ok:true would make callers like
// setInputValue/checkCheckbox see a resolved promise and skip fallback paths.
return { id, ok: false, error: `unknown action: ${action}` }
}
@@ -305,24 +220,6 @@ async function handleCommand(cmd) {
// or through the existing WebSocket bridge mechanism
chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
void connectBridge()
if (msg.type === 'AIRI_DG_SET_BRIDGE_ENDPOINT') {
saveBridgeConfig(msg.host, msg.port)
.then(() => {
reconnectBridgeNow()
sendResponse({
ok: true,
host: bridgeHost,
port: bridgePort,
})
})
.catch((e) => {
sendResponse({ ok: false, error: e?.message || String(e) })
})
return true
}
if (msg.type === 'AIRI_DG_COMMAND') {
handleCommand(msg.data)
.then(resp => sendResponse(resp))
@@ -330,39 +227,64 @@ chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
return true // Keep sendResponse async
}
// Support the existing ws-incoming format from BrowserDomExtensionBridge
if (msg.type === 'ws-incoming') {
handleCommand(msg.data)
.then((resp) => {
// Send response back via the same channel
chrome.runtime.sendMessage({ type: 'ws-send', data: resp })
})
.catch((e) => {
chrome.runtime.sendMessage({ type: 'ws-send', data: { id: msg.data?.id, ok: false, error: String(e) } })
})
return false
}
return false
})
chrome.storage.onChanged.addListener((changes, areaName) => {
if (areaName !== 'local')
// ---- WebSocket Relay ----
// Injects the WebSocket connection directly in the background worker,
// replacing the deleted offscreen document.
// TODO: Add shared-secret auth handshake to prevent rogue localhost processes
// from hijacking the bridge. The bridge server should generate a token and
// inject it into chrome.storage.local so the extension can present it on hello.
const WS_URL = 'ws://localhost:8765'
const BRIDGE_VERSION = 'cu-bridge-2026-02-06-no-eval'
let ws = null
let reconnectDelay = 1000
const MAX_DELAY = 30000
function connectWS() {
if (ws && (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING))
return
if (changes[BRIDGE_HOST_STORAGE_KEY] || changes[BRIDGE_PORT_STORAGE_KEY]) {
void loadBridgeConfig().finally(() => {
reconnectBridgeNow()
})
ws = new WebSocket(WS_URL)
ws.onopen = () => {
console.log('[background] WebSocket connected')
reconnectDelay = 1000
ws.send(JSON.stringify({ type: 'hello', source: 'chrome-extension', version: BRIDGE_VERSION }))
}
})
chrome.runtime.onStartup.addListener(() => {
void connectBridge()
})
ws.onmessage = (evt) => {
try {
const data = JSON.parse(evt.data)
handleCommand(data)
.then((resp) => {
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify(resp))
}
})
.catch((e) => {
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ id: data?.id, ok: false, error: String(e) }))
}
})
}
catch (e) {
console.error('[background] parse error:', e)
}
}
chrome.runtime.onInstalled.addListener(() => {
void connectBridge()
})
ws.onclose = () => {
console.log(`[background] WebSocket closed, reconnect in ${reconnectDelay}ms`)
ws = null
setTimeout(connectWS, reconnectDelay)
reconnectDelay = Math.min(reconnectDelay * 2, MAX_DELAY)
}
void connectBridge()
ws.onerror = (e) => {
console.error('[background] WebSocket error:', e)
ws?.close()
}
}
connectWS()
@@ -4,15 +4,17 @@
* Injected into every frame (including cross-origin iframes) in the MAIN world.
* Namespace: window.__AIRI_DG__
*
* IMPORTANT: This script is READ-ONLY. It does NOT perform any DOM mutations,
* clicks, typing, or navigation. All execution is done via real macOS OS-level
* input events through the desktop grounding executor.
* IMPORTANT: Direct DOM mutations here are limited to bridge-triggered write
* actions (setInputValue, checkCheckbox, selectOption) that are only reachable
* via a WebSocket command from the AIRI computer-use-mcp service. Physical
* pointer/keyboard actions still go through real macOS OS-level input.
*
* Adapted from /Users/liuziheng/computer_use/chrome-extension/content.js.
* Adapted from the upstream computer-use chrome-extension.
* Stripped: clickAt, typeAt, hoverAt, scrollAt, simulateDragDrop, readStorage,
* setStorage, readCanvasData, injectCSS, and all other DOM-mutating methods.
* setStorage, readCanvasData, injectCSS, and all other untracked DOM mutations.
* Kept: collectFrameDOM, _describeElement, _collectInteractiveElements,
* findElement, findElements, getClickTarget.
* Added: setInputValue, checkCheckbox, selectOption.
*/
(function () {
'use strict'
@@ -37,6 +39,7 @@
name: el.name || '',
type: el.type || '',
className: typeof el.className === 'string' ? el.className.slice(0, 120) : '',
// eslint-disable-next-line unicorn/prefer-dom-node-text-content -- intentional: innerText returns visible text only
text: (el.innerText || el.textContent || '').slice(0, 120).trim(),
value: el.value !== undefined ? String(el.value).slice(0, 60) : '',
href: el.href || '',
@@ -67,35 +70,6 @@
return els
}
/**
* Get this frame's embedding rect relative to its parent viewport.
*
* NOTICE: Cross-origin frames may not expose `window.frameElement`.
* In that case we return null and let the adapter skip those frame-local
* coordinates rather than projecting them incorrectly onto the desktop.
*/
function _getFrameRect() {
try {
if (window.top === window)
return null
const frameEl = window.frameElement
if (!(frameEl instanceof Element))
return null
const r = frameEl.getBoundingClientRect()
return {
x: Math.round(r.left),
y: Math.round(r.top),
w: Math.round(r.width),
h: Math.round(r.height),
}
}
catch {
return null
}
}
// ---- Core API (read-only) ----
const __AIRI_DG__ = {
@@ -112,8 +86,8 @@
return {
url: location.href,
title: document.title || '',
// eslint-disable-next-line unicorn/prefer-dom-node-text-content -- intentional: innerText returns visible text only
bodyText: includeText ? (document.body ? document.body.innerText || '' : '').slice(0, 3000) : '',
frameRect: _getFrameRect() || undefined,
interactiveElements: _collectInteractiveElements(maxElements),
}
},
@@ -156,6 +130,10 @@
/**
* Get the center point of an element for click targeting.
* Returns the element description with center coordinates.
*
* Coordinates are exposed both at the top level (x, y) and under
* `center` for backward compatibility. The extension bridge reads
* top-level x/y via unwrapResultPayload.
*/
getClickTarget(selector) {
try {
@@ -163,13 +141,16 @@
if (!el)
return { success: false, error: 'not found' }
const r = el.getBoundingClientRect()
const x = Math.round(r.left + r.width / 2)
const y = Math.round(r.top + r.height / 2)
return {
success: true,
element: _describeElement(el),
center: {
x: Math.round(r.left + r.width / 2),
y: Math.round(r.top + r.height / 2),
},
// Top-level x/y are read by extension-bridge.ts → clickSelector
x,
y,
// Keep center for any callers that read it directly
center: { x, y },
}
}
catch (e) {
@@ -195,12 +176,227 @@
return { success: false, error: e.message }
}
},
/**
* Set the value of a text input or textarea via the DOM.
* Dispatches input + change events so frameworks (React, Vue, etc.) detect
* the change. Optionally blurs the element when done.
*/
setInputValue(selector, value, opts) {
try {
opts = opts || {}
// TODO: opts.simulateKeystrokes is accepted but ignored — we always do
// a single direct value assignment. Implement per-character KeyboardEvent
// dispatch for autocomplete/masker/validation flows that depend on keydown/keyup.
const el = document.querySelector(selector)
if (!el)
return { success: false, error: 'not found' }
// NOTICE: must pick the setter matching the element's prototype —
// calling HTMLInputElement.prototype.value.set on a <textarea> (or
// vice-versa) throws "Illegal invocation" in Chromium.
const proto = el instanceof HTMLTextAreaElement
? window.HTMLTextAreaElement.prototype
: window.HTMLInputElement.prototype
const nativeInputValueSetter = Object.getOwnPropertyDescriptor(proto, 'value')
if (nativeInputValueSetter && nativeInputValueSetter.set) {
nativeInputValueSetter.set.call(el, value)
}
else {
el.value = value
}
el.dispatchEvent(new Event('input', { bubbles: true }))
el.dispatchEvent(new Event('change', { bubbles: true }))
if (opts.blur)
el.blur()
return { success: true }
}
catch (e) {
return { success: false, error: e.message }
}
},
/**
* Check or uncheck a native checkbox or radio input.
* Sets checked programmatically and dispatches a change event so framework
* bindings (React onChange, Vue @change) pick up the update.
*
* NOTICE: only works on real <input type="checkbox|radio"> elements.
* Custom ARIA checkboxes (e.g. <div role="checkbox">) do not have a native
* .checked property — writing to it just adds an expando attribute and
* changes nothing visible. Return success:false in that case so the caller
* falls back to an OS-level click.
*
* NOTICE: we do NOT dispatch a fake click event — the browser's true event
* order is click→change, and a synthetic click after we've already set
* el.checked can cause React controlled-component handlers to toggle back.
*/
checkCheckbox(selector, checked) {
try {
const el = document.querySelector(selector)
if (!el)
return { success: false, error: 'not found' }
// Guard: only native checkbox/radio inputs have a meaningful .checked
if (!(el instanceof HTMLInputElement) || (el.type !== 'checkbox' && el.type !== 'radio'))
return { success: false, error: 'not a native checkbox or radio input' }
const target = checked !== undefined ? !!checked : !el.checked
if (el.checked !== target) {
el.checked = target
el.dispatchEvent(new Event('change', { bubbles: true }))
}
return { success: true, checked: el.checked }
}
catch (e) {
return { success: false, error: e.message }
}
},
/**
* Select an option in a <select> element by value.
* Dispatches change event so framework bindings update.
*/
selectOption(selector, value) {
try {
const el = document.querySelector(selector)
if (!el)
return { success: false, error: 'not found' }
el.value = value
el.dispatchEvent(new Event('change', { bubbles: true }))
return { success: true, selectedValue: el.value }
}
catch (e) {
return { success: false, error: e.message }
}
},
/**
* Read the current value of an input, textarea, or select element.
*/
readInputValue(selector) {
try {
const el = document.querySelector(selector)
if (!el)
return { success: false, error: 'not found' }
return { success: true, value: el.value, tagName: el.tagName.toLowerCase() }
}
catch (e) {
return { success: false, error: e.message }
}
},
/**
* Get computed CSS styles for an element.
* If properties is a non-empty array, only those properties are returned.
* Otherwise all computed styles are returned.
*/
getComputedStyles(selector, properties) {
try {
const el = document.querySelector(selector)
if (!el)
return { success: false, error: 'not found' }
const computed = window.getComputedStyle(el)
const styles = {}
if (Array.isArray(properties) && properties.length > 0) {
for (const prop of properties) {
styles[prop] = computed.getPropertyValue(prop)
}
}
else {
// Return a small useful subset to avoid serializing 300+ properties
const useful = ['display', 'visibility', 'opacity', 'position', 'width', 'height', 'color', 'background-color', 'font-size', 'overflow', 'pointer-events', 'z-index', 'cursor']
for (const prop of useful) {
styles[prop] = computed.getPropertyValue(prop)
}
}
return { success: true, styles }
}
catch (e) {
return { success: false, error: e.message }
}
},
/**
* Dispatch a DOM event on the element matching the selector.
* opts.type overrides the Event constructor (default: 'Event').
*/
triggerEvent(selector, eventName, opts) {
try {
opts = opts || {}
const el = document.querySelector(selector)
if (!el)
return { success: false, error: 'not found' }
const EventCtor = opts.type === 'MouseEvent'
? MouseEvent
: opts.type === 'KeyboardEvent'
? KeyboardEvent
: opts.type === 'FocusEvent'
? FocusEvent
: Event
const eventOpts = { bubbles: true, cancelable: true, ...opts }
delete eventOpts.type
el.dispatchEvent(new EventCtor(eventName, eventOpts))
return { success: true }
}
catch (e) {
return { success: false, error: e.message }
}
},
/**
* Wait for an element matching the selector to appear in the DOM.
* Returns a promise. The message handler awaits it.
*/
waitForElement(selector, timeoutMs) {
timeoutMs = timeoutMs || 5000
const existing = document.querySelector(selector)
if (existing)
return { success: true, found: true }
return new Promise((resolve) => {
let timer
const observer = new MutationObserver(() => {
if (document.querySelector(selector)) {
observer.disconnect()
clearTimeout(timer)
resolve({ success: true, found: true })
}
})
observer.observe(document.documentElement, { childList: true, subtree: true })
timer = setTimeout(() => {
observer.disconnect()
resolve({ success: false, error: 'timeout' })
}, timeoutMs)
})
},
/**
* Dispatch a click event at viewport coordinates (x, y).
* Used by clickSelector as the final step after getClickTarget resolves
* the element center.
*/
clickAt(x, y) {
try {
const el = document.elementFromPoint(x, y)
if (!el)
return { success: false, error: 'no element at point' }
el.dispatchEvent(new MouseEvent('click', {
bubbles: true,
cancelable: true,
clientX: x,
clientY: y,
}))
return { success: true, tagName: el.tagName.toLowerCase() }
}
catch (e) {
return { success: false, error: e.message }
}
},
}
window.__AIRI_DG__ = __AIRI_DG__
// ---- Message handler: ISOLATED world bridge → MAIN world ----
window.addEventListener('message', (evt) => {
// NOTICE: handler is async-aware so waitForElement (returns Promise) works.
window.addEventListener('message', async (evt) => {
if (evt.source !== window)
return
const data = evt.data
@@ -213,7 +409,13 @@
if (typeof fn === 'function') {
try {
result = { success: true, data: fn.apply(__AIRI_DG__, args || []) }
const ret = fn.apply(__AIRI_DG__, args || [])
// Support async methods (e.g. waitForElement)
// NOTICE: return the method result directly — each method already
// returns its own { success, data/error } shape. Wrapping it again
// as { success: true, data: <result> } created a double-envelope
// that made transport-level success hide DOM-level failures.
result = ret && typeof ret.then === 'function' ? await ret : ret
}
catch (e) {
result = { success: false, error: e.message || String(e) }
@@ -7,8 +7,7 @@
"permissions": [
"activeTab",
"tabs",
"webNavigation",
"storage"
"webNavigation"
],
"host_permissions": ["<all_urls>"],
@@ -17,7 +17,7 @@
* - window.__AIRI_DG__ lives in the MAIN world (needs real DOM access)
* - The two worlds communicate via window.postMessage
*
* Adapted from /Users/liuziheng/computer_use/chrome-extension/msg_bridge.js.
* Adapted from the upstream computer-use chrome-extension.
* No functional changes — this is a pure relay.
*/
(function () {
@@ -26,17 +26,13 @@
// Pending requests: reqId → { sendResponse, timer }
const pending = new Map()
let seqId = 0
const pageOrigin = window.location.origin
const postMessageTargetOrigin = pageOrigin && pageOrigin !== 'null' ? pageOrigin : '*'
// Receive commands from background.js
chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
if (msg.type !== 'CU_ACTION')
return false
const reqId = typeof crypto?.randomUUID === 'function'
? `__cu_req_${crypto.randomUUID()}`
: `__cu_req_${++seqId}`
const reqId = `__cu_req_${++seqId}`
const { method, args } = msg
// Set timeout
@@ -53,7 +49,7 @@
reqId,
method,
args: args || [],
}, postMessageTargetOrigin)
}, '*')
return true // Keep sendResponse async
})
@@ -62,8 +58,6 @@
window.addEventListener('message', (evt) => {
if (evt.source !== window)
return
if (pageOrigin && pageOrigin !== 'null' && evt.origin !== pageOrigin)
return
const data = evt.data
if (!data || data.type !== '__CU_REPLY__')
return
@@ -0,0 +1,405 @@
import type { DesktopTargetCandidate } from './desktop-grounding-types'
import { describe, expect, it } from 'vitest'
import { decideBrowserAction, decideBrowserTypeAction } from './browser-action-router'
function makeCandidate(overrides: Partial<DesktopTargetCandidate> = {}): DesktopTargetCandidate {
return {
id: 't_0',
source: 'chrome_dom',
appName: 'Google Chrome',
role: 'button',
label: 'Submit',
bounds: { x: 100, y: 200, width: 80, height: 30 },
confidence: 0.95,
interactable: true,
selector: '#submit-btn',
frameId: 0,
isPageContent: true,
...overrides,
}
}
// ---------------------------------------------------------------------------
// decideBrowserAction (click routing)
// ---------------------------------------------------------------------------
describe('decideBrowserAction', () => {
it('routes chrome_dom with selector + bridge available to browser_dom', () => {
const decision = decideBrowserAction(makeCandidate(), true)
expect(decision.route).toBe('browser_dom')
expect(decision.selector).toBe('#submit-btn')
expect(decision.frameId).toBe(0)
expect(decision.bridgeMethod).toBe('clickSelector')
})
it('falls back to os_input when source is ax', () => {
const decision = decideBrowserAction(makeCandidate({ source: 'ax' }), true)
expect(decision.route).toBe('os_input')
expect(decision.reason).toContain('ax')
})
it('falls back to os_input when source is vision', () => {
const decision = decideBrowserAction(makeCandidate({ source: 'vision' }), true)
expect(decision.route).toBe('os_input')
})
it('falls back to os_input when selector is missing', () => {
const decision = decideBrowserAction(makeCandidate({ selector: undefined }), true)
expect(decision.route).toBe('os_input')
expect(decision.reason).toContain('no CSS selector')
})
it('falls back to os_input when bridge is unavailable', () => {
const decision = decideBrowserAction(makeCandidate(), false)
expect(decision.route).toBe('os_input')
expect(decision.reason).toContain('not connected')
})
it('preserves non-zero frameId for sub-frame candidates', () => {
const decision = decideBrowserAction(makeCandidate({ frameId: 3 }), true)
expect(decision.route).toBe('browser_dom')
expect(decision.frameId).toBe(3)
})
it('falls back to os_input when selector is empty string', () => {
const decision = decideBrowserAction(makeCandidate({ selector: '' }), true)
expect(decision.route).toBe('os_input')
expect(decision.reason).toContain('no CSS selector')
})
it('routes checkbox to checkCheckbox instead of clickSelector', () => {
const decision = decideBrowserAction(makeCandidate({
tag: 'input',
inputType: 'checkbox',
selector: '#agree-checkbox',
}), true)
expect(decision.route).toBe('browser_dom')
expect(decision.bridgeMethod).toBe('checkCheckbox')
expect(decision.selector).toBe('#agree-checkbox')
})
it('routes checkbox by role to checkCheckbox', () => {
const decision = decideBrowserAction(makeCandidate({
tag: 'div',
role: 'checkbox',
selector: 'div.custom-checkbox',
}), true)
expect(decision.route).toBe('browser_dom')
expect(decision.bridgeMethod).toBe('checkCheckbox')
})
it('routes regular button to clickSelector, not checkCheckbox', () => {
const decision = decideBrowserAction(makeCandidate({
tag: 'button',
role: 'button',
}), true)
expect(decision.route).toBe('browser_dom')
expect(decision.bridgeMethod).toBe('clickSelector')
})
it('routes radio input to clickSelector, not checkCheckbox', () => {
const decision = decideBrowserAction(makeCandidate({
tag: 'input',
inputType: 'radio',
selector: 'input[name="color"]',
}), true)
expect(decision.route).toBe('browser_dom')
expect(decision.bridgeMethod).toBe('clickSelector')
})
it('routes link element to clickSelector', () => {
const decision = decideBrowserAction(makeCandidate({
tag: 'a',
role: 'link',
href: 'https://example.com',
selector: 'a.nav-link',
}), true)
expect(decision.route).toBe('browser_dom')
expect(decision.bridgeMethod).toBe('clickSelector')
})
it('checkbox with bridge down falls back to os_input, not checkCheckbox', () => {
const decision = decideBrowserAction(makeCandidate({
tag: 'input',
inputType: 'checkbox',
selector: '#agree',
}), false)
expect(decision.route).toBe('os_input')
expect(decision.bridgeMethod).toBeUndefined()
})
it('returns reason string that includes the selector', () => {
const decision = decideBrowserAction(makeCandidate({ selector: '#my-btn' }), true)
expect(decision.reason).toContain('#my-btn')
})
})
// ---------------------------------------------------------------------------
// decideBrowserTypeAction (type routing)
// ---------------------------------------------------------------------------
describe('decideBrowserTypeAction', () => {
it('routes text input to setInputValue', () => {
const decision = decideBrowserTypeAction(makeCandidate({
tag: 'input',
inputType: 'text',
selector: 'input[name="email"]',
}), true)
expect(decision.route).toBe('browser_dom')
expect(decision.bridgeMethod).toBe('setInputValue')
expect(decision.selector).toBe('input[name="email"]')
})
it('routes password input to setInputValue', () => {
const decision = decideBrowserTypeAction(makeCandidate({
tag: 'input',
inputType: 'password',
selector: '#password',
}), true)
expect(decision.route).toBe('browser_dom')
expect(decision.bridgeMethod).toBe('setInputValue')
})
it('routes textarea to setInputValue', () => {
const decision = decideBrowserTypeAction(makeCandidate({
tag: 'textarea',
selector: '#message',
}), true)
expect(decision.route).toBe('browser_dom')
expect(decision.bridgeMethod).toBe('setInputValue')
})
it('routes input without explicit type (defaults to text) to setInputValue', () => {
const decision = decideBrowserTypeAction(makeCandidate({
tag: 'input',
inputType: undefined,
selector: '#name',
}), true)
expect(decision.route).toBe('browser_dom')
expect(decision.bridgeMethod).toBe('setInputValue')
})
it('routes contenteditable via role=textbox to setInputValue', () => {
const decision = decideBrowserTypeAction(makeCandidate({
tag: 'div',
role: 'textbox',
selector: 'div.editor',
}), true)
expect(decision.route).toBe('browser_dom')
expect(decision.bridgeMethod).toBe('setInputValue')
})
it('falls back to os_input for button elements', () => {
const decision = decideBrowserTypeAction(makeCandidate({
tag: 'button',
role: 'button',
}), true)
expect(decision.route).toBe('os_input')
expect(decision.reason).toContain('not a text input')
})
it('falls back to os_input for checkbox inputs', () => {
const decision = decideBrowserTypeAction(makeCandidate({
tag: 'input',
inputType: 'checkbox',
}), true)
expect(decision.route).toBe('os_input')
expect(decision.reason).toContain('not a text input')
})
it('falls back to os_input for file inputs', () => {
const decision = decideBrowserTypeAction(makeCandidate({
tag: 'input',
inputType: 'file',
}), true)
expect(decision.route).toBe('os_input')
expect(decision.reason).toContain('not a text input')
})
it('falls back to os_input when source is ax', () => {
const decision = decideBrowserTypeAction(makeCandidate({ source: 'ax' }), true)
expect(decision.route).toBe('os_input')
})
it('falls back to os_input when bridge is unavailable', () => {
const decision = decideBrowserTypeAction(makeCandidate({
tag: 'input',
inputType: 'text',
}), false)
expect(decision.route).toBe('os_input')
expect(decision.reason).toContain('not connected')
})
it('falls back to os_input when selector is missing', () => {
const decision = decideBrowserTypeAction(makeCandidate({
tag: 'input',
inputType: 'text',
selector: undefined,
}), true)
expect(decision.route).toBe('os_input')
expect(decision.reason).toContain('no CSS selector')
})
it('routes number input to setInputValue', () => {
const decision = decideBrowserTypeAction(makeCandidate({
tag: 'input',
inputType: 'number',
selector: '#quantity',
}), true)
expect(decision.route).toBe('browser_dom')
expect(decision.bridgeMethod).toBe('setInputValue')
})
it('routes search input to setInputValue', () => {
const decision = decideBrowserTypeAction(makeCandidate({
tag: 'input',
inputType: 'search',
selector: '#search',
}), true)
expect(decision.route).toBe('browser_dom')
expect(decision.bridgeMethod).toBe('setInputValue')
})
it('routes url input to setInputValue', () => {
const decision = decideBrowserTypeAction(makeCandidate({
tag: 'input',
inputType: 'url',
selector: '#website',
}), true)
expect(decision.route).toBe('browser_dom')
expect(decision.bridgeMethod).toBe('setInputValue')
})
it('routes tel input to setInputValue', () => {
const decision = decideBrowserTypeAction(makeCandidate({
tag: 'input',
inputType: 'tel',
selector: '#phone',
}), true)
expect(decision.route).toBe('browser_dom')
expect(decision.bridgeMethod).toBe('setInputValue')
})
it('falls back to os_input for radio inputs', () => {
const decision = decideBrowserTypeAction(makeCandidate({
tag: 'input',
inputType: 'radio',
selector: 'input[name="option"]',
}), true)
expect(decision.route).toBe('os_input')
expect(decision.reason).toContain('not a text input')
})
it('falls back to os_input for hidden inputs', () => {
const decision = decideBrowserTypeAction(makeCandidate({
tag: 'input',
inputType: 'hidden',
selector: '#csrf',
}), true)
expect(decision.route).toBe('os_input')
expect(decision.reason).toContain('not a text input')
})
it('falls back to os_input for color picker inputs', () => {
const decision = decideBrowserTypeAction(makeCandidate({
tag: 'input',
inputType: 'color',
selector: '#color-pick',
}), true)
expect(decision.route).toBe('os_input')
expect(decision.reason).toContain('not a text input')
})
it('select element falls back to os_input for type (not a text input)', () => {
const decision = decideBrowserTypeAction(makeCandidate({
tag: 'select',
selector: '#country',
}), true)
expect(decision.route).toBe('os_input')
expect(decision.reason).toContain('not a text input')
})
it('returns reason string that includes the selector on success', () => {
const decision = decideBrowserTypeAction(makeCandidate({
tag: 'input',
inputType: 'text',
selector: '#my-input',
}), true)
expect(decision.reason).toContain('#my-input')
})
})
// ---------------------------------------------------------------------------
// Cross-function consistency
// ---------------------------------------------------------------------------
describe('click + type routing consistency', () => {
it('text input routes to clickSelector for click and setInputValue for type', () => {
const candidate = makeCandidate({
tag: 'input',
inputType: 'text',
selector: '#email',
})
const clickD = decideBrowserAction(candidate, true)
const typeD = decideBrowserTypeAction(candidate, true)
expect(clickD.route).toBe('browser_dom')
expect(clickD.bridgeMethod).toBe('clickSelector')
expect(typeD.route).toBe('browser_dom')
expect(typeD.bridgeMethod).toBe('setInputValue')
// Same selector used for both
expect(clickD.selector).toBe(typeD.selector)
})
it('checkbox routes to checkCheckbox for click but os_input for type', () => {
const candidate = makeCandidate({
tag: 'input',
inputType: 'checkbox',
selector: '#agree',
})
const clickD = decideBrowserAction(candidate, true)
const typeD = decideBrowserTypeAction(candidate, true)
expect(clickD.route).toBe('browser_dom')
expect(clickD.bridgeMethod).toBe('checkCheckbox')
expect(typeD.route).toBe('os_input') // Can't type into a checkbox
})
it('button routes to clickSelector for click but os_input for type', () => {
const candidate = makeCandidate({
tag: 'button',
role: 'button',
selector: '#submit',
})
const clickD = decideBrowserAction(candidate, true)
const typeD = decideBrowserTypeAction(candidate, true)
expect(clickD.route).toBe('browser_dom')
expect(clickD.bridgeMethod).toBe('clickSelector')
expect(typeD.route).toBe('os_input')
})
it('ax candidate always routes to os_input for both click and type', () => {
const candidate = makeCandidate({ source: 'ax', selector: '#whatever' })
const clickD = decideBrowserAction(candidate, true)
const typeD = decideBrowserTypeAction(candidate, true)
expect(clickD.route).toBe('os_input')
expect(typeD.route).toBe('os_input')
})
it('bridge-down candidate routes to os_input for both click and type', () => {
const candidate = makeCandidate({
tag: 'input',
inputType: 'text',
selector: '#email',
})
const clickD = decideBrowserAction(candidate, false)
const typeD = decideBrowserTypeAction(candidate, false)
expect(clickD.route).toBe('os_input')
expect(typeD.route).toBe('os_input')
})
})
@@ -0,0 +1,179 @@
/**
* Browser action router — decides whether a desktop action should go through
* the browser-dom bridge (DOM-level precision) or OS-level input.
*
* Routing rules are fixed, not heuristic:
* - chrome_dom candidate with selector + bridge available → browser_dom
* - Everything else → os_input
* - Bridge unavailable → os_input (graceful fallback)
*
* Covers: click, type/setInputValue, checkCheckbox, selectOption.
*/
import type { DesktopTargetCandidate } from './desktop-grounding-types'
export interface BrowserActionDecision {
/** Which execution path to use */
route: 'browser_dom' | 'os_input'
/** Human-readable explanation of the routing decision */
reason: string
/** CSS selector for browser-dom action (only when route is browser_dom) */
selector?: string
/** Frame ID for browser-dom action (only when route is browser_dom) */
frameId?: number
/** Which bridge method to use (only when route is browser_dom) */
bridgeMethod?: 'clickSelector' | 'setInputValue' | 'checkCheckbox' | 'selectOption'
}
/**
* Shared precondition check for browser-dom routing.
* Returns a rejection decision if the candidate is ineligible,
* or undefined if all preconditions pass.
*/
function checkBrowserDomPreconditions(
candidate: DesktopTargetCandidate,
bridgeAvailable: boolean,
): BrowserActionDecision | undefined {
if (candidate.source !== 'chrome_dom') {
return {
route: 'os_input',
reason: `source is '${candidate.source}', not chrome_dom`,
}
}
if (!candidate.selector) {
return {
route: 'os_input',
reason: 'chrome_dom candidate has no CSS selector for re-query',
}
}
if (!bridgeAvailable) {
return {
route: 'os_input',
reason: 'browser-dom bridge is not connected, falling back to OS input',
}
}
return undefined
}
/**
* Decide whether a click on a candidate should go through browser-dom
* bridge or OS-level input. Also handles checkbox toggling via checkCheckbox.
*
* Non-left-button clicks and multi-click requests are not supported by the
* browser-dom bridge and will always be routed to os_input.
*/
export function decideBrowserAction(
candidate: DesktopTargetCandidate,
bridgeAvailable: boolean,
actionButton: 'left' | 'right' | 'middle' = 'left',
clickCount: number = 1,
): BrowserActionDecision {
const rejection = checkBrowserDomPreconditions(candidate, bridgeAvailable)
if (rejection)
return rejection
// Right-click and multi-click are not supported by the browser-dom bridge;
// fall through to OS input so the caller's arguments are honoured.
if (actionButton !== 'left' || clickCount !== 1) {
return {
route: 'os_input',
reason: `browser-dom click only supports left single-click; got button='${actionButton}' clickCount=${clickCount}`,
}
}
// Checkbox: route to checkCheckbox instead of generic click
if (isCheckboxCandidate(candidate)) {
return {
route: 'browser_dom',
selector: candidate.selector,
frameId: candidate.frameId,
bridgeMethod: 'checkCheckbox',
reason: `chrome_dom checkbox with selector '${candidate.selector}' routed to checkCheckbox`,
}
}
return {
route: 'browser_dom',
selector: candidate.selector,
frameId: candidate.frameId,
bridgeMethod: 'clickSelector',
reason: `chrome_dom candidate with selector '${candidate.selector}' routed to browser-dom bridge`,
}
}
/**
* Decide whether a type action should go through browser-dom setInputValue
* or OS-level typeText.
*
* Only routes to browser_dom if the candidate is a text-input-like element
* (input[text|password|email|...], textarea, or role="textbox").
*/
export function decideBrowserTypeAction(
candidate: DesktopTargetCandidate,
bridgeAvailable: boolean,
): BrowserActionDecision {
const rejection = checkBrowserDomPreconditions(candidate, bridgeAvailable)
if (rejection)
return rejection
if (!isTextInputCandidate(candidate)) {
return {
route: 'os_input',
reason: `chrome_dom candidate tag '${candidate.tag}' is not a text input element`,
}
}
return {
route: 'browser_dom',
selector: candidate.selector,
frameId: candidate.frameId,
bridgeMethod: 'setInputValue',
reason: `chrome_dom text input with selector '${candidate.selector}' routed to setInputValue`,
}
}
// ---------------------------------------------------------------------------
// Candidate classification helpers
// ---------------------------------------------------------------------------
const TEXT_INPUT_TYPES = new Set([
'text',
'password',
'email',
'search',
'url',
'tel',
'number',
])
/** Whether a candidate represents a text-input-like element. */
function isTextInputCandidate(candidate: DesktopTargetCandidate): boolean {
const tag = candidate.tag?.toLowerCase()
if (tag === 'textarea')
return true
if (tag === 'input') {
// Exclude non-text input types (checkbox, radio, file, etc.)
const inputType = candidate.inputType?.toLowerCase() || 'text'
return TEXT_INPUT_TYPES.has(inputType)
}
// NOTICE: contenteditable elements are surfaced with role="textbox" but lack
// a native .value property, so setInputValue (which uses input/textarea value
// setters) silently fails on them. Only route actual <input>/<textarea> here;
// contenteditable targets will fall through to OS typing via desktop_type_text.
return false
}
/** Whether a candidate represents a checkbox or toggle. */
function isCheckboxCandidate(candidate: DesktopTargetCandidate): boolean {
const tag = candidate.tag?.toLowerCase()
if (tag === 'input') {
const inputType = candidate.inputType?.toLowerCase()
return inputType === 'checkbox'
}
if (candidate.role === 'checkbox')
return true
return false
}
@@ -1,5 +1,6 @@
import { describe, expect, it, vi } from 'vitest'
import { decideBrowserAction, decideBrowserTypeAction } from './browser-action-router'
import { captureChromeSemantics, chromeElementsToTargetCandidates } from './chrome-semantic-adapter'
// ---------------------------------------------------------------------------
@@ -115,6 +116,283 @@ describe('chromeElementsToTargetCandidates', () => {
)
expect(idLabel[0].label).toBe('#main-cta')
})
// -----------------------------------------------------------------------
// Selector building (v2)
// -----------------------------------------------------------------------
it('builds selector from element id (highest priority)', () => {
const candidates = chromeElementsToTargetCandidates(
[{ tag: 'button', id: 'submit-btn', text: 'Go', rect: { x: 0, y: 0, w: 50, h: 20 } }],
windowBounds,
)
expect(candidates[0].selector).toBe('#submit-btn')
})
it('escapes special characters in id selectors', () => {
const candidates = chromeElementsToTargetCandidates(
[{ tag: 'div', id: 'my.element:1', rect: { x: 0, y: 0, w: 50, h: 20 } }],
windowBounds,
)
// dots and colons must be escaped
expect(candidates[0].selector).toBe('#my\\.element\\:1')
})
it('builds selector from name attribute (second priority)', () => {
const candidates = chromeElementsToTargetCandidates(
[{ tag: 'input', name: 'email', rect: { x: 0, y: 0, w: 100, h: 20 } }],
windowBounds,
)
expect(candidates[0].selector).toBe('input[name="email"]')
})
it('escapes quotes in name attribute selectors', () => {
const candidates = chromeElementsToTargetCandidates(
[{ tag: 'input', name: 'field"evil', rect: { x: 0, y: 0, w: 100, h: 20 } }],
windowBounds,
)
expect(candidates[0].selector).toBe('input[name="field\\"evil"]')
})
it('builds selector from tag+type for input elements (third priority)', () => {
const candidates = chromeElementsToTargetCandidates(
[{ tag: 'input', type: 'submit', rect: { x: 0, y: 0, w: 80, h: 30 } }],
windowBounds,
)
expect(candidates[0].selector).toBe('input[type="submit"]')
})
it('builds selector from tag+type for button elements', () => {
const candidates = chromeElementsToTargetCandidates(
[{ tag: 'button', type: 'submit', rect: { x: 0, y: 0, w: 80, h: 30 } }],
windowBounds,
)
expect(candidates[0].selector).toBe('button[type="submit"]')
})
it('does not use tag+type for non-input/button elements', () => {
// A <div> with type attr should NOT get a tag[type=...] selector
const candidates = chromeElementsToTargetCandidates(
[{ tag: 'div', type: 'custom', className: 'widget', rect: { x: 0, y: 0, w: 80, h: 30 } }],
windowBounds,
)
// Should fall through to className-based selector
expect(candidates[0].selector).toBe('div.widget')
})
it('builds selector from first className (fourth priority)', () => {
const candidates = chromeElementsToTargetCandidates(
[{ tag: 'a', className: 'nav-link primary', rect: { x: 0, y: 0, w: 60, h: 16 } }],
windowBounds,
)
expect(candidates[0].selector).toBe('a.nav-link')
})
it('returns undefined selector when no identifying attribute exists', () => {
const candidates = chromeElementsToTargetCandidates(
[{ tag: 'span', text: 'orphan', rect: { x: 0, y: 0, w: 40, h: 14 } }],
windowBounds,
)
expect(candidates[0].selector).toBeUndefined()
})
it('prefers id over name over type over className', () => {
// Element with all attributes — id should win
const candidates = chromeElementsToTargetCandidates(
[{
tag: 'input',
id: 'email-input',
name: 'email',
type: 'text',
className: 'form-control',
rect: { x: 0, y: 0, w: 200, h: 30 },
}],
windowBounds,
)
expect(candidates[0].selector).toBe('#email-input')
})
it('falls through to name when id is empty/whitespace', () => {
const candidates = chromeElementsToTargetCandidates(
[{ tag: 'input', id: ' ', name: 'username', rect: { x: 0, y: 0, w: 200, h: 30 } }],
windowBounds,
)
expect(candidates[0].selector).toBe('input[name="username"]')
})
// -----------------------------------------------------------------------
// Metadata enrichment (v2): isPageContent, enabled, inputType
// -----------------------------------------------------------------------
it('sets isPageContent=true for all chrome_dom candidates', () => {
const candidates = chromeElementsToTargetCandidates(
[
{ tag: 'button', text: 'A', rect: { x: 0, y: 0, w: 50, h: 20 } },
{ tag: 'input', type: 'text', rect: { x: 0, y: 30, w: 200, h: 30 } },
{ tag: 'a', href: '/about', text: 'About', rect: { x: 0, y: 70, w: 40, h: 16 } },
],
windowBounds,
)
for (const c of candidates) {
expect(c.isPageContent).toBe(true)
}
})
it('sets enabled=true for non-disabled elements', () => {
const candidates = chromeElementsToTargetCandidates(
[{ tag: 'button', text: 'Active', rect: { x: 0, y: 0, w: 50, h: 20 } }],
windowBounds,
)
expect(candidates[0].enabled).toBe(true)
})
it('sets enabled=false for disabled elements', () => {
const candidates = chromeElementsToTargetCandidates(
[{ tag: 'button', text: 'Nope', disabled: true, rect: { x: 0, y: 0, w: 50, h: 20 } }],
windowBounds,
)
expect(candidates[0].enabled).toBe(false)
expect(candidates[0].interactable).toBe(false)
})
it('carries inputType from element type attribute', () => {
const candidates = chromeElementsToTargetCandidates(
[{ tag: 'input', type: 'password', rect: { x: 0, y: 0, w: 200, h: 30 } }],
windowBounds,
)
expect(candidates[0].inputType).toBe('password')
})
it('carries href for link elements', () => {
const candidates = chromeElementsToTargetCandidates(
[{ tag: 'a', href: 'https://example.com', text: 'Link', rect: { x: 0, y: 0, w: 40, h: 16 } }],
windowBounds,
)
expect(candidates[0].href).toBe('https://example.com')
})
// -----------------------------------------------------------------------
// Frame ID propagation (v2)
// -----------------------------------------------------------------------
it('uses default frameId=0 when not specified', () => {
const candidates = chromeElementsToTargetCandidates(
[{ tag: 'button', text: 'Main', rect: { x: 0, y: 0, w: 50, h: 20 } }],
windowBounds,
)
expect(candidates[0].frameId).toBe(0)
})
it('uses explicit frameId parameter', () => {
const candidates = chromeElementsToTargetCandidates(
[{ tag: 'button', text: 'Iframe', rect: { x: 0, y: 0, w: 50, h: 20 } }],
windowBounds,
88, // chrome height
5, // frameId
)
expect(candidates[0].frameId).toBe(5)
})
it('reads per-element _frameId from tagged elements (extension bridge)', () => {
// The extension bridge tags each element with _frameId
const taggedEl = {
tag: 'input',
type: 'text',
rect: { x: 0, y: 0, w: 200, h: 30 },
_frameId: 3,
} as any
const candidates = chromeElementsToTargetCandidates(
[taggedEl],
windowBounds,
88, // chrome height
0, // default frameId param = 0
)
// Per-element _frameId should override the function-level param
expect(candidates[0].frameId).toBe(3)
})
it('falls back to function-level frameId when _frameId is absent', () => {
const el = {
tag: 'button',
text: 'No tag',
rect: { x: 0, y: 0, w: 50, h: 20 },
// no _frameId
}
const candidates = chromeElementsToTargetCandidates(
[el],
windowBounds,
88,
7,
)
expect(candidates[0].frameId).toBe(7)
})
// -----------------------------------------------------------------------
// End-to-end routing scenario: selector → router → decision
// -----------------------------------------------------------------------
it('candidate with id goes through full routing as browser_dom click', () => {
const candidates = chromeElementsToTargetCandidates(
[{ tag: 'button', id: 'login-btn', text: 'Login', rect: { x: 0, y: 0, w: 80, h: 30 } }],
windowBounds,
)
// Assign an id like the grounding layer would
candidates[0].id = 't_0'
const decision = decideBrowserAction(candidates[0], true)
expect(decision.route).toBe('browser_dom')
expect(decision.bridgeMethod).toBe('clickSelector')
expect(decision.selector).toBe('#login-btn')
})
it('candidate without identifiers routes to os_input', () => {
const candidates = chromeElementsToTargetCandidates(
[{ tag: 'span', text: 'plain text', rect: { x: 0, y: 0, w: 60, h: 14 } }],
windowBounds,
)
candidates[0].id = 't_0'
const decision = decideBrowserAction(candidates[0], true)
expect(decision.route).toBe('os_input')
expect(decision.reason).toContain('no CSS selector')
})
it('checkbox candidate goes through routing as checkCheckbox', () => {
const candidates = chromeElementsToTargetCandidates(
[{ tag: 'input', type: 'checkbox', id: 'agree', rect: { x: 0, y: 0, w: 16, h: 16 } }],
windowBounds,
)
candidates[0].id = 't_0'
const decision = decideBrowserAction(candidates[0], true)
expect(decision.route).toBe('browser_dom')
expect(decision.bridgeMethod).toBe('checkCheckbox')
})
it('text input candidate goes through type routing as setInputValue', () => {
const candidates = chromeElementsToTargetCandidates(
[{ tag: 'input', type: 'email', name: 'user-email', rect: { x: 0, y: 0, w: 200, h: 30 } }],
windowBounds,
)
candidates[0].id = 't_0'
const decision = decideBrowserTypeAction(candidates[0], true)
expect(decision.route).toBe('browser_dom')
expect(decision.bridgeMethod).toBe('setInputValue')
expect(decision.selector).toBe('input[name="user-email"]')
})
it('non-text-input candidate falls back to os_input for type action', () => {
const candidates = chromeElementsToTargetCandidates(
[{ tag: 'button', id: 'send', text: 'Send', rect: { x: 0, y: 0, w: 80, h: 30 } }],
windowBounds,
)
candidates[0].id = 't_0'
const decision = decideBrowserTypeAction(candidates[0], true)
expect(decision.route).toBe('os_input')
expect(decision.reason).toContain('not a text input')
})
})
// ---------------------------------------------------------------------------
@@ -151,185 +429,6 @@ describe('captureChromeSemantics', () => {
expect(result!.interactiveElements).toHaveLength(1)
})
it('falls back to CDP when extension capture returns no interactive elements', async () => {
const mockExtension = {
getStatus: () => ({ connected: true, enabled: true, host: 'localhost', port: 8080, pendingRequests: 0 }),
readAllFramesDom: vi.fn().mockResolvedValue([
{
frameId: 0,
result: {
url: 'https://example.com',
title: 'Example',
interactiveElements: [],
},
},
]),
}
const mockCdp = {
getStatus: vi.fn().mockReturnValue({
connected: true,
pageUrl: 'https://example.com',
pageTitle: 'Example',
}),
collectInteractiveElements: vi.fn().mockResolvedValue([
{ tag: 'button', text: 'Fallback CTA', rect: { x: 0, y: 0, w: 50, h: 20 } },
]),
}
const result = await captureChromeSemantics(mockExtension as any, mockCdp as any)
expect(result).not.toBeNull()
expect(result!.source).toBe('cdp')
expect(result!.interactiveElements).toHaveLength(1)
expect(result!.interactiveElements[0].text).toBe('Fallback CTA')
})
it('unwraps extension frame payloads nested under result.data', async () => {
const mockExtension = {
getStatus: () => ({ connected: true, enabled: true, host: 'localhost', port: 8080, pendingRequests: 0 }),
readAllFramesDom: vi.fn().mockResolvedValue([
{
frameId: 0,
result: {
data: {
url: 'https://nested.example.com',
title: 'Nested Example',
interactiveElements: [
{ tag: 'button', text: 'Nested click', rect: { x: 0, y: 0, w: 50, h: 20 } },
],
},
},
},
]),
}
const result = await captureChromeSemantics(mockExtension as any, undefined)
expect(result).not.toBeNull()
expect(result!.pageUrl).toBe('https://nested.example.com')
expect(result!.pageTitle).toBe('Nested Example')
expect(result!.interactiveElements).toHaveLength(1)
})
it('applies iframe offsets before returning extension frame elements', async () => {
const mockExtension = {
getStatus: () => ({ connected: true, enabled: true, host: 'localhost', port: 8080, pendingRequests: 0 }),
getAllFrames: vi.fn().mockResolvedValue([
{ frameId: 0, parentFrameId: -1 },
{ frameId: 7, parentFrameId: 0 },
]),
readAllFramesDom: vi.fn().mockResolvedValue([
{
frameId: 0,
result: {
url: 'https://example.com',
title: 'Example',
interactiveElements: [],
},
},
{
frameId: 7,
result: {
frameRect: { x: 120, y: 80, w: 640, h: 480 },
interactiveElements: [
{ tag: 'button', text: 'Iframe CTA', rect: { x: 10, y: 20, w: 50, h: 20 } },
],
},
},
]),
}
const result = await captureChromeSemantics(mockExtension as any, undefined)
expect(result).not.toBeNull()
expect(result!.interactiveElements).toHaveLength(1)
expect(result!.interactiveElements[0].rect).toEqual({
x: 130,
y: 100,
w: 50,
h: 20,
})
})
it('skips subframe elements when iframe offsets are unavailable', async () => {
const mockExtension = {
getStatus: () => ({ connected: true, enabled: true, host: 'localhost', port: 8080, pendingRequests: 0 }),
getAllFrames: vi.fn().mockResolvedValue([
{ frameId: 0, parentFrameId: -1 },
{ frameId: 9, parentFrameId: 0 },
]),
readAllFramesDom: vi.fn().mockResolvedValue([
{
frameId: 0,
result: {
url: 'https://example.com',
title: 'Example',
interactiveElements: [
{ tag: 'button', text: 'Root CTA', rect: { x: 0, y: 0, w: 20, h: 20 } },
],
},
},
{
frameId: 9,
result: {
interactiveElements: [
{ tag: 'button', text: 'Iframe CTA', rect: { x: 10, y: 20, w: 50, h: 20 } },
],
},
},
]),
}
const result = await captureChromeSemantics(mockExtension as any, undefined)
expect(result).not.toBeNull()
expect(result!.interactiveElements).toHaveLength(1)
expect(result!.interactiveElements[0].text).toBe('Root CTA')
})
it('resolves nested iframe offsets even when frame results arrive out of order', async () => {
const mockExtension = {
getStatus: () => ({ connected: true, enabled: true, host: 'localhost', port: 8080, pendingRequests: 0 }),
getAllFrames: vi.fn().mockResolvedValue([
{ frameId: 0, parentFrameId: -1 },
{ frameId: 7, parentFrameId: 0 },
{ frameId: 12, parentFrameId: 7 },
]),
readAllFramesDom: vi.fn().mockResolvedValue([
{
frameId: 12,
result: {
frameRect: { x: 15, y: 25, w: 320, h: 200 },
interactiveElements: [
{ tag: 'button', text: 'Nested CTA', rect: { x: 3, y: 4, w: 40, h: 20 } },
],
},
},
{
frameId: 0,
result: {
url: 'https://example.com',
title: 'Example',
interactiveElements: [],
},
},
{
frameId: 7,
result: {
frameRect: { x: 120, y: 80, w: 640, h: 480 },
interactiveElements: [],
},
},
]),
}
const result = await captureChromeSemantics(mockExtension as any, undefined)
expect(result).not.toBeNull()
expect(result!.interactiveElements).toHaveLength(1)
expect(result!.interactiveElements[0].rect).toEqual({
x: 138,
y: 109,
w: 40,
h: 20,
})
})
it('falls back to CDP when extension is disconnected', async () => {
const mockExtension = {
getStatus: () => ({ connected: false, enabled: true, host: 'localhost', port: 8080, pendingRequests: 0 }),
@@ -18,7 +18,6 @@ import type {
} from './desktop-grounding-types'
import type {
Bounds,
BrowserDomFrameDom,
BrowserDomInteractiveElement,
} from './types'
@@ -33,6 +32,11 @@ import type {
*/
const CHROME_CHROME_HEIGHT_PX = 88
// Pre-compiled regex for selector building (module scope per eslint e18e/prefer-static-regex)
const RE_DOUBLE_QUOTE = /"/g
const RE_WHITESPACE_SPLIT = /\s+/
const RE_CSS_ESCAPE = /[^\w-]/g
/**
* Capture Chrome semantic data from the active tab.
*
@@ -53,10 +57,7 @@ export async function captureChromeSemantics(
try {
const status = extensionBridge.getStatus()
if (status.connected) {
const extensionSnapshot = await captureViaExtension(extensionBridge)
if (extensionSnapshot.interactiveElements.length > 0 || !cdpBridge?.getStatus().connected) {
return extensionSnapshot
}
return await captureViaExtension(extensionBridge)
}
}
catch {
@@ -95,6 +96,7 @@ export function chromeElementsToTargetCandidates(
elements: BrowserDomInteractiveElement[],
windowBounds: Bounds,
chromeHeightPx: number = CHROME_CHROME_HEIGHT_PX,
frameId: number = 0,
): DesktopTargetCandidate[] {
const candidates: DesktopTargetCandidate[] = []
const viewportOffsetX = windowBounds.x
@@ -105,6 +107,10 @@ export function chromeElementsToTargetCandidates(
continue
}
// Read per-element frame ID if tagged by captureViaExtension,
// otherwise fall back to the function parameter
const elFrameId = (el as Record<string, unknown>)._frameId as number | undefined
// Convert page-relative rect to screen-absolute bounds
const bounds: Bounds = {
x: viewportOffsetX + el.rect.x,
@@ -124,6 +130,7 @@ export function chromeElementsToTargetCandidates(
const label = buildLabel(el)
const role = el.role || el.tag || 'element'
const confidence = computeElementConfidence(el)
const selector = buildSelector(el)
candidates.push({
id: '', // Will be assigned by the grounding layer
@@ -137,6 +144,10 @@ export function chromeElementsToTargetCandidates(
tag: el.tag,
href: el.href,
inputType: el.type,
selector,
frameId: elFrameId ?? frameId,
isPageContent: true, // All chrome_dom candidates are page content by definition
enabled: !el.disabled,
})
}
@@ -147,107 +158,6 @@ export function chromeElementsToTargetCandidates(
// Internal helpers
// ---------------------------------------------------------------------------
function toRecord(value: unknown): Record<string, unknown> | undefined {
if (!value || typeof value !== 'object' || Array.isArray(value))
return undefined
return value as Record<string, unknown>
}
function toFiniteNumber(value: unknown): number | undefined {
return typeof value === 'number' && Number.isFinite(value) ? value : undefined
}
function getExtensionFramePayload(result: Record<string, unknown>) {
return toRecord(result.data) ?? result
}
function getFrameRect(payload: Record<string, unknown>): BrowserDomFrameDom['frameRect'] | undefined {
const rect = toRecord(payload.frameRect)
if (!rect)
return undefined
const x = toFiniteNumber(rect.x)
const y = toFiniteNumber(rect.y)
const w = toFiniteNumber(rect.w)
const h = toFiniteNumber(rect.h)
if (x === undefined || y === undefined || w === undefined || h === undefined)
return undefined
return { x, y, w, h }
}
function getFrameParentId(frame: Record<string, unknown>): number | undefined {
return toFiniteNumber(frame.parentFrameId)
}
function offsetInteractiveElement(
element: BrowserDomInteractiveElement,
offset: { x: number, y: number },
): BrowserDomInteractiveElement {
return {
...element,
rect: element.rect
? {
...element.rect,
x: element.rect.x + offset.x,
y: element.rect.y + offset.y,
}
: element.rect,
center: element.center
? {
x: element.center.x + offset.x,
y: element.center.y + offset.y,
}
: element.center,
}
}
function resolveFrameOffset(
frameId: number,
parentIds: Map<number, number | undefined>,
payloads: Map<number, Record<string, unknown>>,
cache: Map<number, { x: number, y: number } | null>,
visiting: Set<number> = new Set(),
): { x: number, y: number } | null {
if (cache.has(frameId))
return cache.get(frameId) ?? null
if (frameId === 0) {
const rootOffset = { x: 0, y: 0 }
cache.set(frameId, rootOffset)
return rootOffset
}
if (visiting.has(frameId)) {
return null
}
visiting.add(frameId)
const payload = payloads.get(frameId)
const frameRect = payload ? getFrameRect(payload) : undefined
const parentFrameId = parentIds.get(frameId)
if (!frameRect || parentFrameId === undefined) {
visiting.delete(frameId)
return null
}
const parentOffset = resolveFrameOffset(parentFrameId, parentIds, payloads, cache, visiting)
if (!parentOffset) {
visiting.delete(frameId)
return null
}
const resolvedOffset = {
x: parentOffset.x + frameRect.x,
y: parentOffset.y + frameRect.y,
}
cache.set(frameId, resolvedOffset)
visiting.delete(frameId)
return resolvedOffset
}
async function captureViaExtension(
bridge: BrowserDomExtensionBridge,
): Promise<ChromeSemanticSnapshot> {
@@ -255,67 +165,30 @@ async function captureViaExtension(
includeText: false,
maxElements: 150,
})
const frameTree = typeof bridge.getAllFrames === 'function'
? await bridge.getAllFrames().catch(() => [])
: []
// Merge interactive elements from all frames
const allElements: BrowserDomInteractiveElement[] = []
// Merge interactive elements from all frames, preserving frame identity
const allElements: Array<BrowserDomInteractiveElement & { _frameId?: number }> = []
let pageUrl = ''
let pageTitle = ''
const payloadsByFrameId = new Map<number, Record<string, unknown>>()
const parentIdsByFrameId = new Map<number, number | undefined>()
const resolvedOffsets = new Map<number, { x: number, y: number } | null>()
for (const frame of frameTree) {
const frameRecord = toRecord(frame)
if (!frameRecord)
continue
const frameId = toFiniteNumber(frameRecord.frameId)
if (frameId === undefined)
continue
parentIdsByFrameId.set(frameId, getFrameParentId(frameRecord))
}
for (const frame of frames) {
const dom = frame.result as Record<string, unknown> | undefined
if (!dom)
continue
const payload = getExtensionFramePayload(dom)
payloadsByFrameId.set(frame.frameId, payload)
if (frame.frameId === 0) {
pageUrl = (payload.url as string) || ''
pageTitle = (payload.title as string) || ''
pageUrl = (dom.url as string) || ''
pageTitle = (dom.title as string) || ''
}
}
for (const frame of frames) {
const payload = payloadsByFrameId.get(frame.frameId)
if (!payload)
continue
const rawElements = payload.interactiveElements
const rawElements = dom.interactiveElements
?? (dom.data && typeof dom.data === 'object' && (dom.data as Record<string, unknown>).interactiveElements)
const elements = rawElements as BrowserDomInteractiveElement[] | undefined
if (elements) {
const offset = resolveFrameOffset(
frame.frameId,
parentIdsByFrameId,
payloadsByFrameId,
resolvedOffsets,
)
if (frame.frameId !== 0 && !offset) {
continue
// Tag each element with its frame ID for downstream routing
for (const el of elements) {
allElements.push({ ...el, _frameId: frame.frameId })
}
const normalizedElements = offset
? elements.map(element => offsetInteractiveElement(element, offset))
: elements
allElements.push(...normalizedElements)
}
}
@@ -359,6 +232,52 @@ async function captureViaCdp(bridge: CdpBridge): Promise<ChromeSemanticSnapshot>
}
}
/**
* Build a best-effort CSS selector for re-querying the element via the
* browser-dom bridge. Used by the browser action router for DOM-level
* click precision instead of OS coordinate input.
*
* Priority: #id > [name] > tag[type] > tag.className > tag
*/
function buildSelector(el: BrowserDomInteractiveElement): string | undefined {
// Unique id — best
if (el.id && el.id.trim()) {
return `#${cssEscape(el.id.trim())}`
}
const tag = el.tag?.toLowerCase() || '*'
// Name attribute — common for form inputs
if (el.name && el.name.trim()) {
return `${tag}[name="${el.name.trim().replace(RE_DOUBLE_QUOTE, '\\"')}"]`
}
// Tag + type — useful for input[type="submit"] etc.
if (el.type && el.type.trim() && (tag === 'input' || tag === 'button')) {
return `${tag}[type="${el.type.trim().replace(RE_DOUBLE_QUOTE, '\\"')}"]`
}
// Tag + first className — fallback
if (el.className && el.className.trim()) {
const firstClass = el.className.trim().split(RE_WHITESPACE_SPLIT)[0]
if (firstClass) {
return `${tag}.${cssEscape(firstClass)}`
}
}
// Tag alone is too generic to be useful
return undefined
}
/**
* Minimal CSS identifier escape for Node.js (CSS.escape is browser-only).
* Escapes characters that are invalid in CSS identifiers per the spec.
* Sufficient for id/class name escaping in selector construction.
*/
function cssEscape(value: string): string {
return value.replace(RE_CSS_ESCAPE, ch => `\\${ch}`)
}
/**
* Build a human-readable label from element attributes.
* Priority: text > placeholder > name > id > href > tag.
@@ -79,6 +79,10 @@ export interface DesktopTargetCandidate {
inputType?: string
/** CSS selector for re-querying (best-effort) */
selector?: string
/** Frame ID within the Chrome page (0 = main frame) */
frameId?: number
/** Whether candidate is in page content area (true for all chrome_dom candidates) */
isPageContent?: boolean
// ---- AX extras ----
/** AX tree UID for `findAXNodeByUid` lookup */
@@ -26,9 +26,7 @@ import type {
} from './types'
import { captureAXTree } from './accessibility'
import { appNamesMatch } from './app-aliases'
import { captureChromeSemantics, chromeElementsToTargetCandidates } from './chrome-semantic-adapter'
import { TARGET_SOURCE_PRIORITY } from './desktop-grounding-types'
import { boundsIoU } from './snap-resolver'
/**
@@ -66,6 +64,7 @@ export async function captureDesktopGrounding(params: {
cdpBridge?: CdpBridge
}): Promise<DesktopGroundingSnapshot> {
const { config, executor, input, extensionBridge, cdpBridge } = params
const assemblyStart = Date.now()
// Phase 1: Parallel capture of all observation sources
const [screenshotResult, windowsResult, axResult] = await Promise.allSettled([
@@ -99,7 +98,6 @@ export async function captureDesktopGrounding(params: {
// Phase 4: Compute staleness
const now = Date.now()
const capturedAt = new Date(now).toISOString()
const staleFlags = computeStaleness({
screenshot,
axSnapshot,
@@ -112,9 +110,8 @@ export async function captureDesktopGrounding(params: {
return {
snapshotId,
capturedAt,
capturedAt: new Date(assemblyStart).toISOString(),
foregroundApp,
foregroundWindowTitle: windowObs.frontmostWindowTitle,
windows: windowObs.windows,
screenshot,
axSnapshot,
@@ -176,10 +173,8 @@ export function buildTargetCandidates(params: {
// Sort: chrome_dom first, then ax, then by confidence desc
merged.sort((a, b) => {
if (a.source !== b.source) {
const aPriority = TARGET_SOURCE_PRIORITY.indexOf(a.source)
const bPriority = TARGET_SOURCE_PRIORITY.indexOf(b.source)
return (aPriority === -1 ? TARGET_SOURCE_PRIORITY.length : aPriority)
- (bPriority === -1 ? TARGET_SOURCE_PRIORITY.length : bPriority)
const sourceOrder: Record<string, number> = { chrome_dom: 0, ax: 1, vision: 2, raw: 3 }
return (sourceOrder[a.source] ?? 3) - (sourceOrder[b.source] ?? 3)
}
return b.confidence - a.confidence
})
@@ -327,29 +322,18 @@ function findChromeWindowBounds(
observation: WindowObservation,
foregroundApp: string,
): Bounds | undefined {
if (!isChromeApp(foregroundApp))
return undefined
const chromeWindows = observation.windows.filter(window =>
window.bounds
&& window.isOnScreen !== false
&& isChromeApp(window.appName),
const normalizedFg = foregroundApp.trim().toLowerCase().replace(APP_SUFFIX_RE, '')
// Prefer exact match on the foreground app name
const exactMatch = observation.windows.find(w =>
w.appName.trim().toLowerCase().replace(APP_SUFFIX_RE, '') === normalizedFg && w.bounds,
)
if (chromeWindows.length === 0)
return undefined
const foregroundChromeWindows = chromeWindows.filter(window => appNamesMatch(window.appName, foregroundApp))
const preferredWindows = foregroundChromeWindows.length > 0 ? foregroundChromeWindows : chromeWindows
const frontmostTitle = observation.frontmostWindowTitle?.trim()
if (frontmostTitle) {
const frontmostWindow = preferredWindows.find(window => window.title?.trim() === frontmostTitle)
if (frontmostWindow?.bounds) {
return frontmostWindow.bounds
}
}
return preferredWindows[0]?.bounds
if (exactMatch?.bounds)
return exactMatch.bounds
// Fallback: any Chrome-like window
const chromeWindow = observation.windows.find(w =>
isChromeApp(w.appName) && w.bounds,
)
return chromeWindow?.bounds
}
function computeStaleness(params: {
@@ -13,6 +13,7 @@ import type {
import type { ComputerUseServerRuntime } from './runtime'
import { appNamesMatch, normalizeConfiguredAppAction } from '../app-aliases'
import { decideBrowserTypeAction } from '../browser-action-router'
import { DESKTOP_CLICK_SNAPSHOT_MAX_AGE_MS } from '../desktop-grounding-types'
import { evaluateActionPolicy } from '../policy'
import { getRuntimePreflight } from '../preflight'
@@ -466,10 +467,70 @@ export function createExecuteAction(runtime: ComputerUseServerRuntime): ExecuteA
throw new Error(`Preparatory click at (${normalizedAction.input.x}, ${normalizedAction.input.y}) failed before typing: ${msg}`)
}
}
const result = await runtime.executor.typeText(normalizedAction.input)
backendResult = {
...backendResult,
...result,
// Browser-dom type routing: if the last clicked grounding candidate
// is a chrome_dom text input, use setInputValue for DOM precision.
// NOTICE: skip this path when explicit coordinates are provided.
// Coordinates mean the caller has targeted a specific screen position
// (possibly in a different app/window), so using lastClickedCandidateId
// would write into a stale Chrome selector instead of the current target.
const hasExplicitCoords = typeof normalizedAction.input.x === 'number' && typeof normalizedAction.input.y === 'number'
let usedBrowserDom = false
const runState = runtime.stateManager.getState()
const lastSnapshot = runState.lastGroundingSnapshot
const lastClickedId = runState.lastClickedCandidateId
if (!hasExplicitCoords && lastClickedId && lastSnapshot) {
const lastCandidate = lastSnapshot.targetCandidates.find(
c => c.id === lastClickedId,
)
if (lastCandidate) {
const bridgeConnected = runtime.browserDomBridge?.getStatus().connected ?? false
const typeDecision = decideBrowserTypeAction(lastCandidate, bridgeConnected)
if (typeDecision.route === 'browser_dom' && typeDecision.selector) {
try {
const frameResults = await runtime.browserDomBridge!.setInputValue({
selector: typeDecision.selector,
value: normalizedAction.input.text,
simulateKeystrokes: false,
blur: !normalizedAction.input.pressEnter,
frameIds: typeDecision.frameId !== undefined
? [typeDecision.frameId]
: undefined,
})
// NOTICE: bridge resolve ≠ DOM success. Frame results carry
// per-frame { success, error } — if none succeeded the
// selector/frame was stale and we must fall back to OS typeText.
const anySucceeded = Array.isArray(frameResults) && frameResults.some(
fr => (fr.result as Record<string, unknown>)?.success === true,
)
if (!anySucceeded) {
throw new Error('setInputValue: no frame reported success')
}
usedBrowserDom = true
backendResult.browserDomRoute = {
method: 'setInputValue',
selector: typeDecision.selector,
reason: typeDecision.reason,
}
}
catch {
// Fallback to OS typeText below
}
}
}
}
if (!usedBrowserDom) {
const result = await runtime.executor.typeText(normalizedAction.input)
backendResult = {
...backendResult,
...result,
}
}
// Handle pressEnter even when browser-dom was used
if (usedBrowserDom && normalizedAction.input.pressEnter) {
await runtime.executor.pressKeys({ keys: ['Return'] })
}
break
}
@@ -4,7 +4,7 @@ import type {
TargetSource,
} from '../desktop-grounding-types'
import { describe, expect, it } from 'vitest'
import { describe, expect, it, vi } from 'vitest'
import { RunStateManager } from '../state'
@@ -68,7 +68,8 @@ describe('runStateManager grounding state', () => {
source: 'chrome_dom' as TargetSource,
confidence: 0.95,
path: [{ x: 140, y: 215, delayMs: 0 }],
}, 't_0')
}, 't_test')
sm.recordClickedCandidate('t_0')
expect(sm.getState().lastClickedCandidateId).toBe('t_0')
@@ -88,7 +89,8 @@ describe('runStateManager grounding state', () => {
confidence: 0.9,
path: [{ x: 330, y: 213, delayMs: 0 }],
}
sm.updatePointerIntent(intent, 't_1')
sm.updatePointerIntent(intent, 't_test')
sm.recordClickedCandidate('t_1')
const state = sm.getState()
expect(state.lastPointerIntent).toBe(intent)
@@ -106,7 +108,8 @@ describe('runStateManager grounding state', () => {
source: 'chrome_dom' as TargetSource,
confidence: 0.95,
path: [{ x: 140, y: 215, delayMs: 0 }],
}, 't_0')
}, 't_test')
sm.recordClickedCandidate('t_0')
sm.clearGroundingState()
@@ -135,7 +138,8 @@ describe('desktop_click_target preconditions via RunStateManager', () => {
source: 'chrome_dom' as TargetSource,
confidence: 0.95,
path: [{ x: 140, y: 215, delayMs: 0 }],
}, 't_0')
}, 't_test')
sm.recordClickedCandidate('t_0')
expect(sm.getState().lastClickedCandidateId === 't_0').toBe(true)
})
@@ -154,7 +158,8 @@ describe('desktop_click_target preconditions via RunStateManager', () => {
source: 'chrome_dom' as TargetSource,
confidence: 0.95,
path: [{ x: 140, y: 215, delayMs: 0 }],
}, 't_0')
}, 't_test')
sm.recordClickedCandidate('t_0')
expect(sm.getState().lastClickedCandidateId === 't_1').toBe(false)
})
@@ -170,7 +175,8 @@ describe('desktop_click_target preconditions via RunStateManager', () => {
source: 'chrome_dom' as TargetSource,
confidence: 0.95,
path: [{ x: 140, y: 215, delayMs: 0 }],
}, 't_0')
}, 't_test')
sm.recordClickedCandidate('t_0')
// Re-observe resets clicked candidate
sm.updateGroundingSnapshot(makeSnapshot())
@@ -235,7 +241,8 @@ describe('overlay polling contract: desktop_get_state exposes grounding data', (
source: 'chrome_dom' as TargetSource,
confidence: 0.95,
path: [{ x: 140, y: 215, delayMs: 0 }],
}, 't_0')
}, 't_test')
sm.recordClickedCandidate('t_0')
const state = sm.getState()
expect(state.lastPointerIntent).toBeDefined()
@@ -254,3 +261,589 @@ describe('overlay polling contract: desktop_get_state exposes grounding data', (
expect(state.lastClickedCandidateId).toBeUndefined()
})
})
// ---------------------------------------------------------------------------
// desktop_click_target handler integration tests
//
// These simulate the handler execution path from register-desktop-grounding.ts
// with mocked runtime dependencies to verify that routing decisions translate
// into real bridge/executor calls and correct response text.
// ---------------------------------------------------------------------------
describe('desktop_click_target handler integration', () => {
// Replicates the handler logic from register-desktop-grounding.ts
// into a testable function. Uses the same imports the handler uses.
async function simulateClickTargetHandler(params: {
stateManager: RunStateManager
candidateId: string
button?: string
clickCount?: number
browserDomBridge: {
getStatus: () => { connected: boolean }
clickSelector: (args: { selector: string, frameIds?: number[] }) => Promise<void>
checkCheckbox: (args: { selector: string, frameIds?: number[] }) => Promise<void>
}
executor: {
click: (args: Record<string, unknown>) => Promise<Record<string, unknown>>
}
}) {
const { stateManager, candidateId, button, clickCount, browserDomBridge, executor } = params
const { decideBrowserAction } = await import('../browser-action-router')
const { resolveSnapByCandidate } = await import('../snap-resolver')
const state = stateManager.getState()
if (!state.lastGroundingSnapshot) {
return { isError: true, text: 'No snapshot' }
}
const snapshot = state.lastGroundingSnapshot
if (state.lastClickedCandidateId === candidateId) {
return { isError: true, text: `Already clicked ${candidateId}` }
}
const snapshotAge = Date.now() - new Date(snapshot.capturedAt).getTime()
if (snapshotAge > 5000) {
return { isError: true, text: `Stale snapshot (${Math.round(snapshotAge / 1000)}s)` }
}
const snap = resolveSnapByCandidate(candidateId, snapshot)
if (snap.source === 'none' && !snap.candidateId) {
return { isError: true, text: `Not found: ${candidateId}` }
}
const intent = {
mode: 'execute' as const,
candidateId,
rawPoint: snap.rawPoint,
snappedPoint: snap.snappedPoint,
source: snap.source,
confidence: snapshot.targetCandidates.find(c => c.id === candidateId)?.confidence ?? 0,
path: [{ x: snap.snappedPoint.x, y: snap.snappedPoint.y, delayMs: 0 }],
}
stateManager.updatePointerIntent(intent, 't_test')
stateManager.recordClickedCandidate(candidateId)
const candidate = snapshot.targetCandidates.find(c => c.id === candidateId)
const bridgeConnected = browserDomBridge.getStatus().connected
const routeDecision = candidate
? decideBrowserAction(candidate, bridgeConnected)
: { route: 'os_input' as const, reason: 'candidate not found' }
let executionRoute = routeDecision.route
let routeNote = ''
if (routeDecision.route === 'browser_dom' && routeDecision.selector) {
try {
const frameIds = routeDecision.frameId !== undefined ? [routeDecision.frameId] : undefined
if (routeDecision.bridgeMethod === 'checkCheckbox') {
await browserDomBridge.checkCheckbox({ selector: routeDecision.selector, frameIds })
}
else {
await browserDomBridge.clickSelector({ selector: routeDecision.selector, frameIds })
}
}
catch (browserError) {
executionRoute = 'os_input'
routeNote = `browser-dom failed: ${browserError instanceof Error ? browserError.message : String(browserError)}`
await executor.click({
x: snap.snappedPoint.x,
y: snap.snappedPoint.y,
button: button || 'left',
clickCount: clickCount ?? 1,
})
}
}
else {
await executor.click({
x: snap.snappedPoint.x,
y: snap.snappedPoint.y,
button: button || 'left',
clickCount: clickCount ?? 1,
})
}
const candidateDesc = candidate
? `${candidate.source} ${candidate.role} "${candidate.label}"`
: candidateId
const lines = [
`Clicked: ${candidateDesc}`,
` Snap: ${snap.reason}`,
` Point: (${snap.snappedPoint.x}, ${snap.snappedPoint.y})`,
` Route: ${executionRoute} (${routeDecision.reason})`,
` Button: ${button || 'left'}, clicks: ${clickCount ?? 1}`,
]
if (routeNote)
lines.push(`${routeNote}`)
return { isError: false, text: lines.join('\n'), executionRoute, routeNote }
}
function freshSnapshot(candidates: DesktopTargetCandidate[]): DesktopGroundingSnapshot {
return {
snapshotId: 'dg_fresh',
capturedAt: new Date().toISOString(), // fresh = now
foregroundApp: 'Google Chrome',
windows: [],
screenshot: { dataBase64: '', mimeType: 'image/png', path: '', capturedAt: new Date().toISOString() },
targetCandidates: candidates,
staleFlags: { screenshot: false, ax: false, chromeSemantic: false },
} as DesktopGroundingSnapshot
}
function makeMockBridge(connected: boolean) {
return {
getStatus: () => ({ connected }),
clickSelector: vi.fn().mockResolvedValue(undefined),
checkCheckbox: vi.fn().mockResolvedValue(undefined),
}
}
function makeMockExecutor() {
return {
click: vi.fn().mockResolvedValue({}),
}
}
// -----------------------------------------------------------------------
// browser_dom routing: calls clickSelector
// -----------------------------------------------------------------------
it('routes chrome_dom candidate through clickSelector when bridge is connected', async () => {
const sm = new RunStateManager()
const candidate = makeCandidate({
id: 't_0',
source: 'chrome_dom',
selector: '#login-btn',
frameId: 0,
isPageContent: true,
})
sm.updateGroundingSnapshot(freshSnapshot([candidate]))
const bridge = makeMockBridge(true)
const executor = makeMockExecutor()
const result = await simulateClickTargetHandler({
stateManager: sm,
candidateId: 't_0',
browserDomBridge: bridge,
executor,
})
expect(result.isError).toBe(false)
expect(result.executionRoute).toBe('browser_dom')
expect(bridge.clickSelector).toHaveBeenCalledOnce()
expect(bridge.clickSelector).toHaveBeenCalledWith({
selector: '#login-btn',
frameIds: [0],
})
expect(executor.click).not.toHaveBeenCalled()
expect(result.text).toContain('Route: browser_dom')
})
// -----------------------------------------------------------------------
// browser_dom fallback: clickSelector fails → executor.click
// -----------------------------------------------------------------------
it('falls back to OS click when clickSelector throws', async () => {
const sm = new RunStateManager()
const candidate = makeCandidate({
id: 't_0',
source: 'chrome_dom',
selector: '#broken',
frameId: 0,
})
sm.updateGroundingSnapshot(freshSnapshot([candidate]))
const bridge = makeMockBridge(true)
bridge.clickSelector.mockRejectedValue(new Error('Element not found'))
const executor = makeMockExecutor()
const result = await simulateClickTargetHandler({
stateManager: sm,
candidateId: 't_0',
browserDomBridge: bridge,
executor,
})
expect(result.isError).toBe(false)
expect(result.executionRoute).toBe('os_input')
expect(bridge.clickSelector).toHaveBeenCalledOnce()
expect(executor.click).toHaveBeenCalledOnce()
expect(result.text).toContain('Route: os_input')
expect(result.text).toContain('browser-dom failed')
expect(result.text).toContain('Element not found')
})
// -----------------------------------------------------------------------
// checkbox: routes to checkCheckbox, not clickSelector
// -----------------------------------------------------------------------
it('dispatches to checkCheckbox for checkbox candidates', async () => {
const sm = new RunStateManager()
const candidate = makeCandidate({
id: 't_0',
source: 'chrome_dom',
tag: 'input',
inputType: 'checkbox',
role: 'checkbox',
selector: '#agree',
frameId: 0,
})
sm.updateGroundingSnapshot(freshSnapshot([candidate]))
const bridge = makeMockBridge(true)
const executor = makeMockExecutor()
const result = await simulateClickTargetHandler({
stateManager: sm,
candidateId: 't_0',
browserDomBridge: bridge,
executor,
})
expect(result.isError).toBe(false)
expect(bridge.checkCheckbox).toHaveBeenCalledOnce()
expect(bridge.checkCheckbox).toHaveBeenCalledWith({
selector: '#agree',
frameIds: [0],
})
expect(bridge.clickSelector).not.toHaveBeenCalled()
expect(executor.click).not.toHaveBeenCalled()
expect(result.text).toContain('Route: browser_dom')
expect(result.text).toContain('checkCheckbox')
})
// -----------------------------------------------------------------------
// AX candidate: bypasses browser-dom entirely
// -----------------------------------------------------------------------
it('routes AX candidate directly to OS click, never touches bridge', async () => {
const sm = new RunStateManager()
const candidate = makeCandidate({
id: 't_0',
source: 'ax',
role: 'AXButton',
label: 'Close',
selector: undefined,
})
sm.updateGroundingSnapshot(freshSnapshot([candidate]))
const bridge = makeMockBridge(true)
const executor = makeMockExecutor()
const result = await simulateClickTargetHandler({
stateManager: sm,
candidateId: 't_0',
browserDomBridge: bridge,
executor,
})
expect(result.isError).toBe(false)
expect(result.executionRoute).toBe('os_input')
expect(bridge.clickSelector).not.toHaveBeenCalled()
expect(bridge.checkCheckbox).not.toHaveBeenCalled()
expect(executor.click).toHaveBeenCalledOnce()
expect(result.text).toContain('Route: os_input')
})
// -----------------------------------------------------------------------
// Bridge disconnected: chrome_dom candidate falls back to OS
// -----------------------------------------------------------------------
it('routes chrome_dom to OS click when bridge is disconnected', async () => {
const sm = new RunStateManager()
const candidate = makeCandidate({
id: 't_0',
source: 'chrome_dom',
selector: '#btn',
})
sm.updateGroundingSnapshot(freshSnapshot([candidate]))
const bridge = makeMockBridge(false) // disconnected
const executor = makeMockExecutor()
const result = await simulateClickTargetHandler({
stateManager: sm,
candidateId: 't_0',
browserDomBridge: bridge,
executor,
})
expect(result.isError).toBe(false)
expect(result.executionRoute).toBe('os_input')
expect(bridge.clickSelector).not.toHaveBeenCalled()
expect(executor.click).toHaveBeenCalledOnce()
expect(result.text).toContain('not connected')
})
// -----------------------------------------------------------------------
// No selector: chrome_dom candidate without selector → OS click
// -----------------------------------------------------------------------
it('routes chrome_dom without selector to OS click', async () => {
const sm = new RunStateManager()
const candidate = makeCandidate({
id: 't_0',
source: 'chrome_dom',
selector: undefined,
})
sm.updateGroundingSnapshot(freshSnapshot([candidate]))
const bridge = makeMockBridge(true)
const executor = makeMockExecutor()
const result = await simulateClickTargetHandler({
stateManager: sm,
candidateId: 't_0',
browserDomBridge: bridge,
executor,
})
expect(result.isError).toBe(false)
expect(result.executionRoute).toBe('os_input')
expect(bridge.clickSelector).not.toHaveBeenCalled()
expect(executor.click).toHaveBeenCalledOnce()
expect(result.text).toContain('no CSS selector')
})
// -----------------------------------------------------------------------
// Duplicate click guard
// -----------------------------------------------------------------------
it('blocks duplicate click on same candidate without re-observe', async () => {
const sm = new RunStateManager()
const candidate = makeCandidate({ id: 't_0' })
sm.updateGroundingSnapshot(freshSnapshot([candidate]))
const bridge = makeMockBridge(true)
const executor = makeMockExecutor()
// First click succeeds
const first = await simulateClickTargetHandler({
stateManager: sm,
candidateId: 't_0',
browserDomBridge: bridge,
executor,
})
expect(first.isError).toBe(false)
// Second click on same candidate without re-observe → blocked
const second = await simulateClickTargetHandler({
stateManager: sm,
candidateId: 't_0',
browserDomBridge: bridge,
executor,
})
expect(second.isError).toBe(true)
expect(second.text).toContain('Already clicked')
})
// -----------------------------------------------------------------------
// Duplicate guard reset after re-observe
// -----------------------------------------------------------------------
it('allows same candidate click after re-observe', async () => {
const sm = new RunStateManager()
const candidate = makeCandidate({ id: 't_0' })
sm.updateGroundingSnapshot(freshSnapshot([candidate]))
const bridge = makeMockBridge(true)
const executor = makeMockExecutor()
// First click
await simulateClickTargetHandler({
stateManager: sm,
candidateId: 't_0',
browserDomBridge: bridge,
executor,
})
// Re-observe resets the guard
sm.updateGroundingSnapshot(freshSnapshot([makeCandidate({ id: 't_0' })]))
// Click again after re-observe → allowed
const result = await simulateClickTargetHandler({
stateManager: sm,
candidateId: 't_0',
browserDomBridge: bridge,
executor,
})
expect(result.isError).toBe(false)
})
// -----------------------------------------------------------------------
// Stale snapshot rejection
// -----------------------------------------------------------------------
it('rejects click on stale snapshot (>5s)', async () => {
const sm = new RunStateManager()
const candidate = makeCandidate({ id: 't_0' })
const staleSnapshot = {
...freshSnapshot([candidate]),
capturedAt: new Date(Date.now() - 10_000).toISOString(), // 10s ago
}
sm.updateGroundingSnapshot(staleSnapshot)
const bridge = makeMockBridge(true)
const executor = makeMockExecutor()
const result = await simulateClickTargetHandler({
stateManager: sm,
candidateId: 't_0',
browserDomBridge: bridge,
executor,
})
expect(result.isError).toBe(true)
expect(result.text).toContain('Stale')
expect(bridge.clickSelector).not.toHaveBeenCalled()
expect(executor.click).not.toHaveBeenCalled()
})
// -----------------------------------------------------------------------
// Missing candidate
// -----------------------------------------------------------------------
it('returns error for non-existent candidate id', async () => {
const sm = new RunStateManager()
sm.updateGroundingSnapshot(freshSnapshot([makeCandidate({ id: 't_0' })]))
const bridge = makeMockBridge(true)
const executor = makeMockExecutor()
const result = await simulateClickTargetHandler({
stateManager: sm,
candidateId: 't_99',
browserDomBridge: bridge,
executor,
})
expect(result.isError).toBe(true)
expect(result.text).toContain('Not found')
expect(bridge.clickSelector).not.toHaveBeenCalled()
expect(executor.click).not.toHaveBeenCalled()
})
// -----------------------------------------------------------------------
// No snapshot
// -----------------------------------------------------------------------
it('returns error when no snapshot exists', async () => {
const sm = new RunStateManager()
const bridge = makeMockBridge(true)
const executor = makeMockExecutor()
const result = await simulateClickTargetHandler({
stateManager: sm,
candidateId: 't_0',
browserDomBridge: bridge,
executor,
})
expect(result.isError).toBe(true)
expect(result.text).toContain('No snapshot')
})
// -----------------------------------------------------------------------
// Frame ID passthrough
// -----------------------------------------------------------------------
it('passes non-zero frameId to clickSelector', async () => {
const sm = new RunStateManager()
const candidate = makeCandidate({
id: 't_0',
source: 'chrome_dom',
selector: '#iframe-btn',
frameId: 5,
})
sm.updateGroundingSnapshot(freshSnapshot([candidate]))
const bridge = makeMockBridge(true)
const executor = makeMockExecutor()
await simulateClickTargetHandler({
stateManager: sm,
candidateId: 't_0',
browserDomBridge: bridge,
executor,
})
expect(bridge.clickSelector).toHaveBeenCalledWith({
selector: '#iframe-btn',
frameIds: [5],
})
})
// -----------------------------------------------------------------------
// checkCheckbox fallback on failure
// -----------------------------------------------------------------------
it('falls back to OS click when checkCheckbox throws', async () => {
const sm = new RunStateManager()
const candidate = makeCandidate({
id: 't_0',
source: 'chrome_dom',
tag: 'input',
inputType: 'checkbox',
role: 'checkbox',
selector: '#cb',
frameId: 0,
})
sm.updateGroundingSnapshot(freshSnapshot([candidate]))
const bridge = makeMockBridge(true)
bridge.checkCheckbox.mockRejectedValue(new Error('checkbox toggle failed'))
const executor = makeMockExecutor()
const result = await simulateClickTargetHandler({
stateManager: sm,
candidateId: 't_0',
browserDomBridge: bridge,
executor,
})
expect(result.isError).toBe(false)
expect(result.executionRoute).toBe('os_input')
expect(bridge.checkCheckbox).toHaveBeenCalledOnce()
expect(executor.click).toHaveBeenCalledOnce()
expect(result.text).toContain('browser-dom failed')
expect(result.text).toContain('checkbox toggle failed')
})
// -----------------------------------------------------------------------
// Two candidates: click different ones in sequence
// -----------------------------------------------------------------------
it('allows clicking different candidates in sequence', async () => {
const sm = new RunStateManager()
sm.updateGroundingSnapshot(freshSnapshot([
makeCandidate({ id: 't_0', selector: '#first', label: 'First' }),
makeCandidate({ id: 't_1', selector: '#second', label: 'Second' }),
]))
const bridge = makeMockBridge(true)
const executor = makeMockExecutor()
const first = await simulateClickTargetHandler({
stateManager: sm,
candidateId: 't_0',
browserDomBridge: bridge,
executor,
})
expect(first.isError).toBe(false)
expect(first.text).toContain('First')
const second = await simulateClickTargetHandler({
stateManager: sm,
candidateId: 't_1',
browserDomBridge: bridge,
executor,
})
expect(second.isError).toBe(false)
expect(second.text).toContain('Second')
expect(bridge.clickSelector).toHaveBeenCalledTimes(2)
})
})
@@ -14,6 +14,7 @@
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import type { PointerIntent } from '../desktop-grounding-types'
import type { ExecuteAction } from './action-executor'
import type { ComputerUseServerRuntime } from './runtime'
@@ -21,7 +22,9 @@ import process from 'node:process'
import { z } from 'zod'
import { decideBrowserAction } from '../browser-action-router'
import { captureDesktopGrounding, formatGroundingForAgent } from '../desktop-grounding'
import { resolveSnapByCandidate } from '../snap-resolver'
import { textContent } from './content'
import { registerToolWithDescriptor, requireDescriptor } from './tool-descriptors/register-helper'
@@ -74,15 +77,7 @@ export function registerDesktopGroundingTools(params: {
})
// Update RunState — grounding snapshot
runtime.stateManager.updateGroundingSnapshot({
...snapshot,
screenshot: snapshot.screenshot
? {
...snapshot.screenshot,
dataBase64: '',
}
: snapshot.screenshot,
})
runtime.stateManager.updateGroundingSnapshot(snapshot)
// Also update screenshot state so desktop_get_state and other
// tools can see the latest screenshot from this observation
@@ -123,7 +118,6 @@ export function registerDesktopGroundingTools(params: {
return { content }
}
catch (error) {
runtime.stateManager.clearGroundingState()
const message = error instanceof Error ? error.message : String(error)
return {
content: [textContent(`desktop_observe failed: ${message}`)],
@@ -147,14 +141,181 @@ export function registerDesktopGroundingTools(params: {
},
handler: async ({ candidateId, clickCount, button }) => {
return await executeAction({
kind: 'desktop_click_target',
input: {
try {
const state = runtime.stateManager.getState()
// Validate: must have a recent grounding snapshot
if (!state.lastGroundingSnapshot) {
return {
content: [textContent('ERROR: No desktop_observe snapshot available. Call desktop_observe first to get a list of target candidates.')],
isError: true,
}
}
const snapshot = state.lastGroundingSnapshot
// Validate: check for duplicate clicks on same candidate without re-observe
if (state.lastClickedCandidateId === candidateId) {
return {
content: [textContent(`WARNING: You already clicked candidate "${candidateId}" without calling desktop_observe again. Call desktop_observe to refresh the state before clicking the same target.`)],
isError: true,
}
}
// Validate: check snapshot staleness (>5s)
const snapshotAge = Date.now() - new Date(snapshot.capturedAt).getTime()
if (snapshotAge > 5000) {
return {
content: [textContent(`WARNING: Grounding snapshot "${snapshot.snapshotId}" is ${Math.round(snapshotAge / 1000)}s old. Call desktop_observe to get a fresh snapshot before clicking.`)],
isError: true,
}
}
// Resolve snap
const snap = resolveSnapByCandidate(candidateId, snapshot)
if (snap.source === 'none' && !snap.candidateId) {
return {
content: [textContent(`ERROR: Candidate "${candidateId}" not found in snapshot "${snapshot.snapshotId}". Available candidates: ${snapshot.targetCandidates.map(c => c.id).join(', ')}`)],
isError: true,
}
}
// Build pointer intent
const intent: PointerIntent = {
mode: 'execute',
candidateId,
clickCount,
button,
},
}, 'desktop_click_target')
rawPoint: snap.rawPoint,
snappedPoint: snap.snappedPoint,
source: snap.source,
confidence: snapshot.targetCandidates.find(c => c.id === candidateId)?.confidence ?? 0,
path: [
{ x: snap.snappedPoint.x, y: snap.snappedPoint.y, delayMs: 0 },
],
}
// Update RunState — pointer intent
runtime.stateManager.updatePointerIntent(intent, candidateId)
// Route the click: browser-dom for chrome_dom candidates, OS input for everything else.
// Pass button and clickCount so non-left or multi-click requests fall through to OS input
// rather than silently degrading to a single left click on the browser-dom path.
const candidate = snapshot.targetCandidates.find(c => c.id === candidateId)
const bridgeConnected = runtime.browserDomBridge?.getStatus().connected ?? false
const routeDecision = candidate
? decideBrowserAction(candidate, bridgeConnected, button, clickCount)
: { route: 'os_input' as const, reason: 'candidate not found' }
let executionRoute = routeDecision.route
let routeNote = ''
if (routeDecision.route === 'browser_dom' && routeDecision.selector) {
// Try browser-dom bridge action first, dispatching by method
try {
const frameIds = routeDecision.frameId !== undefined ? [routeDecision.frameId] : undefined
if (routeDecision.bridgeMethod === 'checkCheckbox') {
const frameResults = await runtime.browserDomBridge!.checkCheckbox({
selector: routeDecision.selector,
frameIds,
})
// NOTICE: bridge resolve ≠ DOM success. Each frame returns
// { success, error } — if none succeeded the selector/frame was
// stale and we must fall back to OS click.
const anySucceeded = Array.isArray(frameResults) && frameResults.some(
fr => (fr.result as Record<string, unknown>)?.success === true,
)
if (!anySucceeded) {
throw new Error('checkCheckbox: no frame reported success')
}
}
else {
const clickResult = await runtime.browserDomBridge!.clickSelector({
selector: routeDecision.selector,
frameIds,
})
// NOTICE: clickSelector resolves even when clickAt hits no element.
// Check per-frame results; if none succeeded, fall back to OS click.
const clickFrames = clickResult?.clickResults
const anyClickSucceeded = Array.isArray(clickFrames) && clickFrames.some(
fr => (fr.result as Record<string, unknown>)?.success === true,
)
if (!anyClickSucceeded) {
throw new Error('clickSelector: no frame reported a successful click')
}
}
}
catch (browserError) {
// Fallback to OS input on browser-dom failure; still goes through policy pipeline
executionRoute = 'os_input'
routeNote = `browser-dom ${routeDecision.bridgeMethod ?? 'click'} failed (${browserError instanceof Error ? browserError.message : String(browserError)}), fell back to OS input`
const actionResult = await executeAction({
kind: 'click',
input: {
x: snap.snappedPoint.x,
y: snap.snappedPoint.y,
button: button || 'left',
clickCount: clickCount ?? 1,
},
}, 'desktop_click_target')
// If the action was denied or queued for approval, relay the policy result
// and do not report a false success or update post-click state.
const status = (actionResult.structuredContent as Record<string, unknown> | undefined)?.status
if (actionResult.isError || status === 'approval_required' || status === 'denied') {
return actionResult
}
}
}
else {
// OS-level click through policy pipeline — respects approvalMode and policy gates
const actionResult = await executeAction({
kind: 'click',
input: {
x: snap.snappedPoint.x,
y: snap.snappedPoint.y,
button: button || 'left',
clickCount: clickCount ?? 1,
},
}, 'desktop_click_target')
// If the action was denied or queued for approval, relay the policy result
// and do not report a false success or update post-click state.
const status = (actionResult.structuredContent as Record<string, unknown> | undefined)?.status
if (actionResult.isError || status === 'approval_required' || status === 'denied') {
return actionResult
}
}
// Record the candidate as clicked only after execution succeeds or bypasses policy
runtime.stateManager.recordClickedCandidate(candidateId)
const candidateDesc = candidate ? `${candidate.source} ${candidate.role} "${candidate.label}"` : candidateId
const lines = [
`Clicked: ${candidateDesc}`,
` Snap: ${snap.reason}`,
` Point: (${snap.snappedPoint.x}, ${snap.snappedPoint.y})`,
` Route: ${executionRoute} (${routeDecision.reason})`,
` Button: ${button || 'left'}, clicks: ${clickCount ?? 1}`,
]
if (routeNote) {
lines.push(`${routeNote}`)
}
if (snap.reason.includes('stale')) {
lines.push(' ⚠ WARNING: Target source is stale. Consider calling desktop_observe again.')
}
return {
content: [textContent(lines.join('\n'))],
}
}
catch (error) {
const message = error instanceof Error ? error.message : String(error)
return {
content: [textContent(`desktop_click_target failed: ${message}`)],
isError: true,
}
}
},
})
}
@@ -561,6 +561,29 @@ export function registerComputerUseTools(params: RegisterComputerUseToolsOptions
tabId,
frameIds,
})
// NOTICE: clickSelector resolves even when the clickAt step misses
// (e.g. reflow between target lookup and click dispatch). Inspect
// per-frame results before reporting success.
const clickFrames = result?.clickResults
const anyClickSucceeded = Array.isArray(clickFrames) && clickFrames.some(
fr => (fr.result as Record<string, unknown>)?.success === true,
)
if (!anyClickSucceeded) {
return {
isError: true,
content: [
textContent(`browser_dom_click: clicked at (${result.targetPoint.x}, ${result.targetPoint.y}) in frame ${result.targetFrameId} but no frame reported a successful DOM click for "${selector}".`),
],
structuredContent: {
status: 'click_miss',
selector,
...result,
bridge: runtime.browserDomBridge.getStatus(),
},
}
}
return {
content: [
textContent(`Clicked selector "${selector}" in frame ${result.targetFrameId} at (${result.targetPoint.x}, ${result.targetPoint.y}).`),
@@ -36,7 +36,8 @@ export type ToolKind
/**
* Tool descriptor defines the canonical metadata for a single MCP tool.
* All fields are required (fail-closed policy). No field may be left undefined.
* All fields except `defaultDeferred` are required (fail-closed policy).
* `defaultDeferred` defaults to false when omitted.
*/
export interface ToolDescriptor {
/**
+8
View File
@@ -417,6 +417,14 @@ export class RunStateManager {
this.touch()
}
/**
* Record the candidate id that was just successfully clicked.
*/
recordClickedCandidate(candidateId: string): void {
this.state.lastClickedCandidateId = candidateId
this.touch()
}
/**
* Clear desktop grounding state when the snapshot becomes invalid.
*/