feat(desktop): coordinate clicks, caret insertion, and drag for the browser agent

This commit is contained in:
Siddharth Ganesan
2026-08-13 15:25:32 -07:00
parent f54f7e003f
commit 6f36331b7a
9 changed files with 1743 additions and 24 deletions
+172 -20
View File
@@ -10,6 +10,7 @@
*/
import type { BrowserTheme } from '@sim/browser-protocol'
import { createLogger } from '@sim/logger'
import { sleep } from '@sim/utils/helpers'
import type { WebContents, WebFrameMain } from 'electron'
const logger = createLogger('BrowserAgentCdp')
@@ -127,12 +128,29 @@ export async function setColorScheme(contents: WebContents, theme: BrowserTheme)
})
}
/** Live drag-interception state while a dragPointer call is in flight. */
interface DragInterception {
intercepted: boolean
data: Record<string, unknown> | null
}
const dragInterceptionsByContents = new WeakMap<WebContents, DragInterception>()
function handleDebuggerEvent(
contents: WebContents,
method: string,
params: Record<string, unknown>,
parentSessionId?: string
): void {
if (method === 'Input.dragIntercepted') {
const interception = dragInterceptionsByContents.get(contents)
if (interception) {
interception.intercepted = true
const data = params.data
interception.data =
data && typeof data === 'object' ? (data as Record<string, unknown>) : null
}
return
}
if (method === 'Target.attachedToTarget') {
const sessionId = typeof params.sessionId === 'string' ? params.sessionId : ''
const targetInfo = params.targetInfo
@@ -367,7 +385,9 @@ interface CdpViewport {
* edge within {@link MAX_SCREENSHOT_EDGE}. Falls back to an unclipped capture
* when layout metrics are unavailable.
*/
export async function captureScreenshot(contents: WebContents): Promise<string> {
export async function captureScreenshot(
contents: WebContents
): Promise<{ dataUrl: string; scale: number }> {
const metrics = await send<{
cssLayoutViewport?: CdpViewport
layoutViewport?: CdpViewport
@@ -376,6 +396,8 @@ export async function captureScreenshot(contents: WebContents): Promise<string>
const viewport = metrics?.cssLayoutViewport ?? metrics?.layoutViewport
const width = viewport?.clientWidth ?? 0
const height = viewport?.clientHeight ?? 0
const scale =
width > 0 && height > 0 ? Math.min(1, MAX_SCREENSHOT_EDGE / Math.max(width, height)) : 1
const clip =
width > 0 && height > 0
? {
@@ -383,7 +405,7 @@ export async function captureScreenshot(contents: WebContents): Promise<string>
y: 0,
width,
height,
scale: Math.min(1, MAX_SCREENSHOT_EDGE / Math.max(width, height)),
scale,
}
: undefined
@@ -392,7 +414,7 @@ export async function captureScreenshot(contents: WebContents): Promise<string>
quality: SCREENSHOT_QUALITY,
...(clip ? { clip } : {}),
})
return `data:image/jpeg;base64,${result.data}`
return { dataUrl: `data:image/jpeg;base64,${result.data}`, scale }
}
/** One half of a trusted key press (`Input.dispatchKeyEvent` params). */
@@ -430,7 +452,8 @@ export async function clickAt(
contents: WebContents,
x: number,
y: number,
moveBeforePress = true
moveBeforePress = true,
clickCount = 1
): Promise<void> {
if (moveBeforePress) await moveMouse(contents, x, y)
let pressed = false
@@ -439,22 +462,26 @@ export async function clickAt(
// response (navigation/process swap). In that ambiguous case a release is
// safer than leaving Blink's pointer state stuck down.
pressed = true
await sendInput(contents, 'Input.dispatchMouseEvent', {
type: 'mousePressed',
x,
y,
button: 'left',
buttons: 1,
clickCount: 1,
})
await sendInput(contents, 'Input.dispatchMouseEvent', {
type: 'mouseReleased',
x,
y,
button: 'left',
buttons: 0,
clickCount: 1,
})
// A multi-click is a sequence of press/release pairs with an increasing
// clickCount — Blink synthesizes dblclick from the pair whose count is 2.
for (let count = 1; count <= clickCount; count++) {
await sendInput(contents, 'Input.dispatchMouseEvent', {
type: 'mousePressed',
x,
y,
button: 'left',
buttons: 1,
clickCount: count,
})
await sendInput(contents, 'Input.dispatchMouseEvent', {
type: 'mouseReleased',
x,
y,
button: 'left',
buttons: 0,
clickCount: count,
})
}
pressed = false
} finally {
if (pressed && !contents.isDestroyed()) {
@@ -473,6 +500,131 @@ export async function clickAt(
}
}
/**
* Drags the pointer from one viewport point to another through the trusted
* pipeline: press, a threshold-crossing nudge, interpolated moves with the
* button held, a settle hold over the target, then release or drop.
*
* Two drag models are covered by the one call. Pointer-sensor libraries
* (dnd-kit, react-beautiful-dnd, canvas apps) treat the held-button move
* sequence exactly like a human drag. Native HTML5 `draggable="true"`
* sources instead START a Blink drag session on the press+move — with
* `Input.setInterceptDrags` enabled, Chromium reports it as
* `Input.dragIntercepted` and the remaining movement is delivered as trusted
* `Input.dispatchDragEvent` dragEnter/dragOver events ending in a `drop`
* (the technique Playwright uses). Both paths are trusted input.
*/
export async function dragPointer(
contents: WebContents,
from: { x: number; y: number },
to: { x: number; y: number },
steps = 12,
stepDelayMs = 20
): Promise<{ nativeDragIntercepted: boolean }> {
const interception: DragInterception = { intercepted: false, data: null }
dragInterceptionsByContents.set(contents, interception)
let interceptEnabled = false
try {
await send(contents, 'Input.setInterceptDrags', { enabled: true })
interceptEnabled = true
} catch {
// Chromium without drag interception: the pointer-only path still works
// for pointer-sensor drags; native HTML5 sources will report no effect.
}
await moveMouse(contents, from.x, from.y)
let pressed = false
let dragEnterSent = false
const dragMove = async (x: number, y: number) => {
if (interception.intercepted && interception.data) {
await sendInput(contents, 'Input.dispatchDragEvent', {
type: dragEnterSent ? 'dragOver' : 'dragEnter',
x,
y,
data: interception.data,
})
dragEnterSent = true
} else {
await sendInput(contents, 'Input.dispatchMouseEvent', {
type: 'mouseMoved',
x,
y,
button: 'left',
buttons: 1,
})
}
}
try {
pressed = true
await sendInput(contents, 'Input.dispatchMouseEvent', {
type: 'mousePressed',
x: from.x,
y: from.y,
button: 'left',
buttons: 1,
clickCount: 1,
})
// Small first nudge so libraries with a start threshold (commonly 3-8px)
// register the drag before the pointer sweeps across the page.
await dragMove(from.x + Math.sign(to.x - from.x || 1) * 4, from.y + 2)
await sleep(stepDelayMs)
const stepCount = Math.max(2, steps)
for (let step = 1; step <= stepCount; step++) {
const progress = step / stepCount
await dragMove(from.x + (to.x - from.x) * progress, from.y + (to.y - from.y) * progress)
await sleep(stepDelayMs)
}
// Hold over the target so drop zones running enter/over animations settle
// before the release lands.
await sleep(120)
if (interception.intercepted && interception.data) {
await sendInput(contents, 'Input.dispatchDragEvent', {
type: 'drop',
x: to.x,
y: to.y,
data: interception.data,
})
// Blink ended the intercepted drag session itself; a trailing
// mouseReleased would be a stray click on the drop target.
pressed = false
} else {
await sendInput(contents, 'Input.dispatchMouseEvent', {
type: 'mouseReleased',
x: to.x,
y: to.y,
button: 'left',
buttons: 0,
clickCount: 1,
})
pressed = false
}
return { nativeDragIntercepted: interception.intercepted }
} finally {
if (pressed && !contents.isDestroyed()) {
if (interception.intercepted && interception.data) {
await sendInput(contents, 'Input.dispatchDragEvent', {
type: 'dragCancel',
x: to.x,
y: to.y,
data: interception.data,
}).catch(() => {})
} else {
await sendInput(contents, 'Input.dispatchMouseEvent', {
type: 'mouseReleased',
x: to.x,
y: to.y,
button: 'left',
buttons: 0,
clickCount: 1,
}).catch(() => {})
}
}
dragInterceptionsByContents.delete(contents)
if (interceptEnabled && !contents.isDestroyed()) {
await send(contents, 'Input.setInterceptDrags', { enabled: false }).catch(() => {})
}
}
}
/**
* Inserts text at the focused element's selection (replacing it) through the
* native IME path — works in plain fields and code editors alike.
@@ -1874,4 +1874,213 @@ describe('credential protection', () => {
expect(result).toMatchObject({ ok: true, result: { dispatched: true } })
expect(clickReads).toBeGreaterThan(1)
})
it('clicks a coordinate point with native input and reports the target', async () => {
const contents = await openPage()
respondWith(contents, {
describePointTarget: {
found: true,
element: 'button "Send"',
editable: false,
secret: false,
fileInput: false,
cursor: 'pointer',
},
readActiveElementState: {},
readPageActionState: {},
})
const result = await driver.executeTool('chat-test', 'browser_click_at', { x: 120, y: 240 })
expect(result).toMatchObject({
ok: true,
result: {
dispatched: true,
trusted: true,
clickedAt: { x: 120, y: 240 },
target: 'button "Send"',
},
})
const presses = cdpCalls(contents, 'Input.dispatchMouseEvent').filter(
([, event]) => (event as { type?: string }).type === 'mousePressed'
)
expect(presses).toHaveLength(1)
})
it('double-clicks a coordinate point as a rising clickCount sequence', async () => {
const contents = await openPage()
respondWith(contents, {
describePointTarget: { found: true, element: 'canvas', editable: false },
readActiveElementState: {},
readPageActionState: {},
})
const result = await driver.executeTool('chat-test', 'browser_click_at', {
x: 10,
y: 20,
clickCount: 2,
})
expect(result).toMatchObject({ ok: true, result: { clickCount: 2 } })
const counts = cdpCalls(contents, 'Input.dispatchMouseEvent')
.filter(([, event]) => (event as { type?: string }).type === 'mousePressed')
.map(([, event]) => (event as { clickCount?: number }).clickCount)
expect(counts).toEqual([1, 2])
})
it('refuses a coordinate click on a file input', async () => {
const contents = await openPage()
respondWith(contents, {
describePointTarget: { found: true, element: 'input', fileInput: true },
})
const result = await driver.executeTool('chat-test', 'browser_click_at', { x: 5, y: 5 })
expect(result.ok).toBe(false)
expect(result.error).toMatch(/file input/)
expect(cdpCalls(contents, 'Input.dispatchMouseEvent')).toHaveLength(0)
})
it('rejects a coordinate click outside the viewport with mapping guidance', async () => {
const contents = await openPage()
respondWith(contents, {
describePointTarget: { error: 'outside-viewport' },
})
const result = await driver.executeTool('chat-test', 'browser_click_at', { x: 9999, y: 5 })
expect(result.ok).toBe(false)
expect(result.error).toMatch(/divide image pixels by its scale/)
})
it('inserts text into the focused editable at the caret', async () => {
const contents = await openPage()
respondWith(contents, {
activeElementSecrecy: 'safe',
describeFocusedEditable: { editable: true, kind: 'contenteditable' },
readActiveElementState: { activeElement: 'div', valueLength: 12 },
readPageActionState: {},
})
const result = await driver.executeTool('chat-test', 'browser_insert_text', {
text: 'hello world',
})
expect(result).toMatchObject({
ok: true,
result: { dispatched: true, trusted: true, kind: 'contenteditable', insertedChars: 11 },
})
expect(cdpCalls(contents, 'Input.insertText')).toHaveLength(1)
})
it('refuses insertion when nothing editable holds focus', async () => {
const contents = await openPage()
respondWith(contents, {
activeElementSecrecy: 'safe',
describeFocusedEditable: { editable: false, reason: 'none' },
})
const result = await driver.executeTool('chat-test', 'browser_insert_text', { text: 'x' })
expect(result.ok).toBe(false)
expect(result.error).toMatch(/No element is focused/)
expect(cdpCalls(contents, 'Input.insertText')).toHaveLength(0)
})
it('refuses insertion while a password field holds focus', async () => {
const contents = await openPage()
respondWith(contents, { activeElementSecrecy: 'secret' })
const result = await driver.executeTool('chat-test', 'browser_insert_text', { text: 'x' })
expect(result.ok).toBe(false)
expect(result.error).toMatch(/Refusing to act on a password field/)
expect(cdpCalls(contents, 'Input.insertText')).toHaveLength(0)
})
it('drags between coordinate points through the trusted pointer pipeline', async () => {
const contents = await openPage()
respondWith(contents, {
describePointTarget: { found: true, element: 'div "Card"' },
readActiveElementState: {},
readPageActionState: {},
})
const result = await driver.executeTool('chat-test', 'browser_drag', {
fromX: 40,
fromY: 50,
toX: 200,
toY: 260,
})
expect(result).toMatchObject({
ok: true,
result: { dispatched: true, trusted: true, from: { x: 40, y: 50 }, to: { x: 200, y: 260 } },
})
const events = cdpCalls(contents, 'Input.dispatchMouseEvent').map(
([, event]) => (event as { type?: string }).type
)
expect(events[0]).toBe('mouseMoved')
expect(events).toContain('mousePressed')
expect(events[events.length - 1]).toBe('mouseReleased')
expect(cdpCalls(contents, 'Input.setInterceptDrags').length).toBeGreaterThan(0)
})
it('drags from a snapshot element to a coordinate target', async () => {
const contents = await openPage()
respondWith(contents, {
clickElement: { dispatched: false, x: 24, y: 48, element: 'Card "Ship it"' },
describePointTarget: { found: true, element: 'section "Done"' },
readActiveElementState: {},
readPageActionState: {},
})
const result = await driver.executeTool('chat-test', 'browser_drag', {
fromElementId: 0,
toX: 300,
toY: 60,
})
expect(result).toMatchObject({
ok: true,
result: { dispatched: true, from: { x: 24, y: 48, element: 'Card "Ship it"' } },
})
})
it('rejects a drag whose endpoints are the same point', async () => {
const contents = await openPage()
respondWith(contents, {
describePointTarget: { found: true, element: 'div' },
})
const result = await driver.executeTool('chat-test', 'browser_drag', {
fromX: 10,
fromY: 10,
toX: 10,
toY: 10,
})
expect(result.ok).toBe(false)
expect(result.error).toMatch(/same point/)
})
it('returns the screenshot scale for coordinate mapping', async () => {
const contents = await openPage()
vi.mocked(contents.debugger.sendCommand).mockImplementation((method: string) => {
if (method === 'Page.getLayoutMetrics') {
return Promise.resolve({
cssLayoutViewport: { clientWidth: 2048, clientHeight: 1024 },
})
}
if (method === 'Page.captureScreenshot') {
return Promise.resolve({ data: 'c2lt' })
}
return Promise.resolve(undefined)
})
respondWith(contents, { getViewportInfo: { width: 2048, height: 1024 } })
const result = await driver.executeTool('chat-test', 'browser_screenshot', {})
expect(result).toMatchObject({ ok: true, result: { scale: 0.5 } })
})
})
+294 -4
View File
@@ -46,6 +46,8 @@ import {
activeElementSecrecy,
clickElement,
collectSnapshot,
describeFocusedEditable,
describePointTarget,
focusElementForTyping,
getViewportInfo,
hoverElement,
@@ -892,6 +894,11 @@ function unwrapPageResult(result: unknown): unknown {
if (code === 'not-editable') {
throw new ToolError('That element is not a text input — pick an editable element.')
}
if (code === 'outside-viewport') {
throw new ToolError(
'That point is outside the visible viewport. Coordinates are CSS pixels within the current viewport — when reading them off a browser_screenshot, divide image pixels by its scale, and scroll the target into view first.'
)
}
if (code === 'ambiguous-editable') {
throw new ToolError(
'That composite control contains multiple editable fields. Take a fresh browser_snapshot and target the exact field.'
@@ -2002,19 +2009,21 @@ async function executeToolInner(
case 'browser_screenshot': {
const contents = session.requireAutomationTab().view.webContents
const dataUrl = await cdp.captureScreenshot(contents).catch(() => null)
if (dataUrl === null) {
const shot = await cdp.captureScreenshot(contents).catch(() => null)
if (shot === null) {
throw new ToolError(
'Could not capture the page. Use browser_snapshot or browser_read_text instead.'
)
}
if (dataUrl.length > 8_000_000) {
if (shot.dataUrl.length > 8_000_000) {
throw new ToolError(
'The screenshot result was too large to return safely. Use browser_snapshot or browser_read_text instead.'
)
}
const viewport = await execInPage(contents, getViewportInfo, []).catch(() => null)
return { dataUrl, viewport }
// scale maps image pixels back to CSS viewport pixels for the
// coordinate tools: cssX = imageX / scale.
return { dataUrl: shot.dataUrl, viewport, scale: shot.scale }
}
case 'browser_extract': {
@@ -3106,6 +3115,287 @@ async function executeToolInner(
}
}
case 'browser_click_at': {
const clickedTab = session.requireAutomationTab()
const contents = clickedTab.view.webContents
const x = requireNum(params, 'x')
const y = requireNum(params, 'y')
const clickCount = num(params, 'clickCount') ?? 1
if (![1, 2, 3].includes(clickCount)) {
throw new ToolError('clickCount must be 1 (click), 2 (double-click), or 3 (triple-click).')
}
const clickNavigationEpoch = navigationEpoch(contents)
assertCurrentExecution()
assertActiveContents(contents)
const pointTarget = unwrapPageResult(
await execInPage(contents, describePointTarget, [x, y], false, executionDeadline)
)
if (!isRecordLike(pointTarget) || pointTarget.found !== true) {
throw new ToolError(
'Nothing is rendered at that point. Coordinates are CSS pixels in the current viewport — when reading them off a browser_screenshot, divide image pixels by its scale.'
)
}
if (pointTarget.fileInput === true) {
throw new ToolError(
'Refusing to click a file input because it opens a native chooser the browser agent cannot inspect or complete. Ask the user to upload the file themselves.'
)
}
const beforePage = await pageActionState(contents, true)
const beforeElement = await activeElementState(contents)
assertCurrentExecution()
assertActiveContents(contents, clickNavigationEpoch)
try {
await cdp.clickAt(contents, x, y, true, clickCount)
} catch (error) {
throw new ToolError(
`Native click dispatch failed (${getErrorMessage(error)}). The action was not retried because a partial pointer press may already have reached the page. Take a fresh snapshot before continuing.`
)
}
await sleep(150)
const afterElement = await activeElementState(contents)
const afterPage = await pageActionState(contents)
const observation = pageEffect(beforePage, afterPage, beforeElement, afterElement)
const activeTab = session.automationTab()
const tabChanged = activeTab?.id !== clickedTab.id
const effectObserved =
observation.effect.urlChanged ||
observation.effect.dialogChanged ||
observation.effect.popupChanged ||
observation.effect.targetChanged ||
(pointTarget.editable === true && observation.effect.focusChanged) ||
tabChanged
const dialogs = Array.isArray(afterPage.dialogs) ? afterPage.dialogs.map(String) : []
const notes: string[] = []
if (pointTarget.secret === true) {
notes.push(
'The point resolves to a password field. Focusing it is fine, but typing there is refused — call browser_request_takeover for credentials.'
)
}
if (pointTarget.crossOriginFrame === true) {
notes.push(
'The point lands inside an embedded frame that could not be inspected; the click was dispatched but its target is unverified.'
)
}
if (!effectObserved) {
notes.push(
observation.possibleEffectObserved || tabChanged
? 'Only background DOM/title churn followed the click; inspect the page before treating it as successful.'
: 'No strong observable page change followed the click; inspect the page before treating it as successful.'
)
}
return {
dispatched: true,
trusted: true,
activation: 'native-pointer',
clickedAt: { x, y },
clickCount,
target: pointTarget.element,
targetCursor: pointTarget.cursor,
effectObserved,
possibleEffectObserved: observation.possibleEffectObserved || tabChanged,
effect: { ...observation.effect, tabChanged },
dialogs,
...(tabChanged && activeTab
? { activeTab: { tabId: activeTab.id, url: activeTab.view.webContents.getURL() } }
: {}),
...(notes.length > 0 ? { note: notes.join(' ') } : {}),
}
}
case 'browser_insert_text': {
const text = requireStr(params, 'text')
const submit = params.submit === true
const contents = session.requireAutomationTab().view.webContents
const insertNavigationEpoch = navigationEpoch(contents)
const target: PageExecutionTarget = focusedPageTarget(contents)
const secrecy = await execInPage(target, activeElementSecrecy, []).catch(() => 'opaque')
if (secrecy === 'secret') throw new ToolError(PASSWORD_REFUSAL)
if (secrecy === 'opaque') {
throw new ToolError(
'Focus is inside a cross-origin frame whose contents cannot be inspected, so this ' +
'insertion could reach a password field. Call browser_request_takeover if the user needs to type here.'
)
}
const focusState = unwrapPageResult(
await execInPage(target, describeFocusedEditable, [], false, executionDeadline)
)
if (!isRecordLike(focusState) || focusState.editable !== true) {
const reason = isRecordLike(focusState) ? String(focusState.reason || '') : ''
throw new ToolError(
reason === 'none'
? 'No element is focused. Click the field first (browser_click or browser_click_at), then insert text.'
: `The focused element does not accept text${reason ? ` (${reason})` : ''}. Focus an editable field first.`
)
}
const beforePage = await pageActionState(target, true)
const beforeElement = await activeElementState(target)
assertCurrentExecution()
assertActiveContents(contents, insertNavigationEpoch)
assertFocusedTargetUnchanged(contents, target)
try {
await cdp.insertText(contents, text)
} catch (error) {
throw new ToolError(
`Native text insertion failed (${getErrorMessage(error)}). Take a fresh snapshot before retrying.`
)
}
let submitDispatched = false
if (submit) {
await sleep(25)
assertCurrentExecution()
assertActiveContents(contents, insertNavigationEpoch)
try {
await dispatchKeyCombo(contents, parseKeyCombo('Enter'))
submitDispatched = true
} catch {
// Reported below through submitDispatched: false.
}
}
await sleep(150)
const state = await activeElementState(target)
const afterPage = await pageActionState(target)
const observation = pageEffect(beforePage, afterPage, beforeElement, state)
const effectObserved =
observation.effect.fieldChanged ||
observation.effect.urlChanged ||
observation.effect.dialogChanged ||
observation.effect.targetChanged
return {
dispatched: true,
trusted: true,
kind: focusState.kind,
insertedChars: text.length,
...state,
effectObserved,
possibleEffectObserved: observation.possibleEffectObserved,
effect: observation.effect,
submitRequested: submit,
submitDispatched,
...(focusState.kind === 'canvas' || focusState.kind === 'textbox-role'
? {
note: 'The focused editor is canvas/model-backed, so field readback cannot confirm the text — verify visually with browser_screenshot.',
}
: !effectObserved
? {
note: 'Insertion produced no observable field or page change; inspect the page before continuing.',
}
: {}),
}
}
case 'browser_drag': {
const draggedTab = session.requireAutomationTab()
const contents = draggedTab.view.webContents
const dragNavigationEpoch = navigationEpoch(contents)
const resolveEndpoint = async (
which: 'from' | 'to'
): Promise<{ x: number; y: number; element?: string }> => {
const elementId = num(params, `${which}ElementId`)
if (elementId !== undefined) {
const target = pageTargetForElement(contents, elementId)
if (frameExecutionTarget(target, contents)) {
throw new ToolError(
`Dragging elements inside embedded frames is not supported. Use ${which}X/${which}Y viewport coordinates instead.`
)
}
const prepared = unwrapPageResult(
await execInPageWithSettleGrace(
target,
clickElement,
[elementId, false, false],
executionDeadline
)
)
if (
!isRecordLike(prepared) ||
typeof prepared.x !== 'number' ||
typeof prepared.y !== 'number'
) {
throw new ToolError(`Could not resolve the ${which} element for the drag.`)
}
return {
x: prepared.x,
y: prepared.y,
...(typeof prepared.element === 'string' ? { element: prepared.element } : {}),
}
}
const pointX = num(params, `${which}X`)
const pointY = num(params, `${which}Y`)
if (pointX === undefined || pointY === undefined) {
throw new ToolError(
`Provide either ${which}ElementId or both ${which}X and ${which}Y for the drag ${which === 'from' ? 'source' : 'target'}.`
)
}
const probe = unwrapPageResult(
await execInPage(
contents,
describePointTarget,
[pointX, pointY],
false,
executionDeadline
)
)
if (!isRecordLike(probe) || probe.found !== true) {
throw new ToolError(
`Nothing is rendered at the ${which} point. Coordinates are CSS pixels in the current viewport — when reading them off a browser_screenshot, divide image pixels by its scale.`
)
}
return {
x: pointX,
y: pointY,
...(typeof probe.element === 'string' ? { element: probe.element } : {}),
}
}
assertCurrentExecution()
assertActiveContents(contents)
const from = await resolveEndpoint('from')
const to = await resolveEndpoint('to')
if (Math.abs(from.x - to.x) < 1 && Math.abs(from.y - to.y) < 1) {
throw new ToolError('The drag source and target are the same point; nothing to drag.')
}
const beforePage = await pageActionState(contents, true)
const beforeElement = await activeElementState(contents)
assertCurrentExecution()
assertActiveContents(contents, dragNavigationEpoch)
let interception: { nativeDragIntercepted: boolean }
try {
interception = await cdp.dragPointer(contents, from, to)
} catch (error) {
throw new ToolError(
`Native drag dispatch failed (${getErrorMessage(error)}). The pointer may have been mid-drag; take a fresh snapshot to see the page's current state before retrying.`
)
}
await sleep(200)
const afterElement = await activeElementState(contents)
const afterPage = await pageActionState(contents)
const observation = pageEffect(beforePage, afterPage, beforeElement, afterElement)
const effectObserved =
observation.effect.domChanged ||
observation.effect.urlChanged ||
observation.effect.dialogChanged ||
observation.effect.targetChanged ||
observation.effect.scrollChanged
const dialogs = Array.isArray(afterPage.dialogs) ? afterPage.dialogs.map(String) : []
return {
dispatched: true,
trusted: true,
nativeHtml5Drag: interception.nativeDragIntercepted,
from,
to,
effectObserved,
possibleEffectObserved: observation.possibleEffectObserved,
effect: observation.effect,
dialogs,
...(!effectObserved
? {
note: 'No observable page change followed the drag. Verify with browser_snapshot or browser_screenshot; some drop targets only commit on their own animation frame.',
}
: {}),
}
}
case 'browser_request_takeover': {
// The reason renders in the chat's tool row, not here — but require it
// so the model always tells the user why control was handed over.
@@ -5,6 +5,8 @@ import {
activeElementSecrecy,
clickElement,
collectSnapshot,
describeFocusedEditable,
describePointTarget,
focusElementForTyping,
getViewportInfo,
hoverElement,
@@ -155,6 +157,8 @@ describe('serialization contract', () => {
['readPageText', readPageText, []],
['pageContainsText', pageContainsText, ['needle']],
['getViewportInfo', getViewportInfo, []],
['describePointTarget', describePointTarget, [10, 10]],
['describeFocusedEditable', describeFocusedEditable, []],
]
it.each(cases)('%s is self-contained', (_name, fn, args) => {
@@ -1373,3 +1377,80 @@ describe('pressKeyOnPage', () => {
expect(seen).toEqual(['a'])
})
})
describe('describePointTarget', () => {
function pointAt(el: Element | null): void {
Object.defineProperty(document, 'elementFromPoint', {
configurable: true,
value: () => el,
})
}
it('describes the element at a viewport point', () => {
document.body.innerHTML = '<button aria-label="Send message">Send</button>'
pointAt(document.querySelector('button'))
expect(describePointTarget(10, 10)).toMatchObject({
found: true,
tag: 'button',
element: 'button "Send message"',
editable: false,
fileInput: false,
secret: false,
})
})
it('flags file inputs and password fields at the point', () => {
document.body.innerHTML = '<input type="file" />'
pointAt(document.querySelector('input'))
expect(describePointTarget(10, 10)).toMatchObject({ found: true, fileInput: true })
document.body.innerHTML = '<input type="password" />'
pointAt(document.querySelector('input'))
expect(describePointTarget(10, 10)).toMatchObject({
found: true,
secret: true,
editable: true,
})
})
it('rejects points outside the viewport', () => {
expect(describePointTarget(-5, 10)).toEqual({ error: 'outside-viewport' })
expect(describePointTarget(10, window.innerHeight + 5)).toEqual({
error: 'outside-viewport',
})
})
})
describe('describeFocusedEditable', () => {
it('reports no focus when the body holds focus', () => {
setActiveElement(document, document.body)
expect(describeFocusedEditable()).toEqual({ editable: false, reason: 'none' })
})
it('reports a writable input as insertable', () => {
document.body.innerHTML = '<input type="text" />'
setActiveElement(document, document.querySelector('input'))
expect(describeFocusedEditable()).toEqual({ editable: true, kind: 'input:text' })
})
it('reports a read-only input as not insertable', () => {
document.body.innerHTML = '<input type="text" readonly />'
setActiveElement(document, document.querySelector('input'))
expect(describeFocusedEditable()).toEqual({ editable: false, reason: 'readonly' })
})
it('reports a focused contenteditable editor as insertable', () => {
document.body.innerHTML = '<div></div>'
const editor = document.querySelector('div') as HTMLElement
Object.defineProperty(editor, 'isContentEditable', { get: () => true })
setActiveElement(document, editor)
expect(describeFocusedEditable()).toEqual({ editable: true, kind: 'contenteditable' })
})
it('treats a focused canvas editor surface as insertable', () => {
document.body.innerHTML = '<canvas></canvas>'
setActiveElement(document, document.querySelector('canvas'))
expect(describeFocusedEditable()).toEqual({ editable: true, kind: 'canvas' })
})
})
@@ -2676,3 +2676,155 @@ export function getViewportInfo(): unknown {
height: window.innerHeight,
}
}
/**
* Describes whatever sits at a viewport point, descending through open shadow
* roots and same-origin iframes. Coordinate-addressed actions have no
* snapshot ref to revalidate, so this probe is their safety check: the driver
* refuses file inputs outright and reports what the point resolves to so the
* model can confirm it hit what the screenshot showed.
*/
export function describePointTarget(x: number, y: number): unknown {
if (
!Number.isFinite(x) ||
!Number.isFinite(y) ||
x < 0 ||
y < 0 ||
x >= window.innerWidth ||
y >= window.innerHeight
) {
return { error: 'outside-viewport' }
}
let doc: Document = document
let localX = x
let localY = y
let element: Element | null = null
for (let depth = 0; depth < 10; depth++) {
if (typeof doc.elementFromPoint !== 'function') break
let found: Element | null = doc.elementFromPoint(localX, localY)
// Open shadow roots re-hit-test at the same point until a leaf host.
for (let shadowDepth = 0; shadowDepth < 10; shadowDepth++) {
const shadow = (found as HTMLElement | null)?.shadowRoot
const inner = shadow?.elementFromPoint(localX, localY)
if (!inner || inner === found) break
found = inner
}
element = found
const tag = String(found?.tagName || '').toUpperCase()
if ((tag !== 'IFRAME' && tag !== 'FRAME') || !found) break
try {
const innerDoc = (found as HTMLIFrameElement).contentDocument
if (!innerDoc) break
const rect = found.getBoundingClientRect()
localX -= rect.left + (found as HTMLIFrameElement).clientLeft
localY -= rect.top + (found as HTMLIFrameElement).clientTop
doc = innerDoc
} catch {
// Cross-origin frame — cannot inspect further; report the frame itself.
break
}
}
if (!element) return { found: false }
const tag = String(element.tagName || '').toUpperCase()
const inputType =
tag === 'INPUT' ? String((element as HTMLInputElement).type || 'text').toLowerCase() : ''
const secret =
tag === 'INPUT' &&
(inputType === 'password' ||
String(element.getAttribute('autocomplete') || '')
.toLowerCase()
.split(/\s+/)
.some((token) => token === 'current-password' || token === 'new-password'))
const editable = Boolean(
tag === 'TEXTAREA' ||
(tag === 'INPUT' &&
['text', 'search', 'email', 'url', 'tel', 'number', 'password'].includes(inputType)) ||
(element as HTMLElement).isContentEditable
)
const name = (
element.getAttribute('aria-label') ||
element.getAttribute('title') ||
element.getAttribute('alt') ||
((element as HTMLElement).innerText ?? element.textContent ?? '')
)
.replace(/\s+/g, ' ')
.trim()
.slice(0, 80)
const role = element.getAttribute('role') || ''
const style = element.ownerDocument.defaultView?.getComputedStyle(element)
return {
found: true,
element: name
? `${tag.toLowerCase()}${role ? `[${role}]` : ''} "${name}"`
: `${tag.toLowerCase()}${role ? `[${role}]` : ''}`,
tag: tag.toLowerCase(),
role,
editable,
secret,
fileInput: tag === 'INPUT' && inputType === 'file',
disabled:
(element as HTMLInputElement).disabled === true ||
element.getAttribute('aria-disabled') === 'true',
canvas: tag === 'CANVAS',
crossOriginFrame: tag === 'IFRAME' || tag === 'FRAME',
cursor: style?.cursor || '',
}
}
/**
* Reports whether the currently-focused element accepts text insertion, for
* browser_insert_text — which types at the caret instead of addressing a
* snapshot ref. Secrecy is separately (and authoritatively) probed by
* activeElementSecrecy before any insertion.
*/
export function describeFocusedEditable(): unknown {
let active = document.activeElement as HTMLElement | null
for (let depth = 0; active && depth < 10; depth++) {
const shadow = active.shadowRoot
if (shadow?.activeElement) {
active = shadow.activeElement as HTMLElement
continue
}
break
}
if (!active || active === document.body) return { editable: false, reason: 'none' }
const tag = String(active.tagName || '').toUpperCase()
const inputType =
tag === 'INPUT' ? String((active as HTMLInputElement).type || 'text').toLowerCase() : ''
if (tag === 'INPUT' || tag === 'TEXTAREA') {
const field = active as HTMLInputElement | HTMLTextAreaElement
if (field.disabled || active.getAttribute('aria-disabled') === 'true') {
return { editable: false, reason: 'disabled' }
}
if (field.readOnly || active.getAttribute('aria-readonly') === 'true') {
return { editable: false, reason: 'readonly' }
}
if (
tag === 'INPUT' &&
!['text', 'search', 'email', 'url', 'tel', 'number', 'password'].includes(inputType)
) {
return { editable: false, reason: 'not-text' }
}
return { editable: true, kind: tag === 'TEXTAREA' ? 'textarea' : `input:${inputType}` }
}
if (active.isContentEditable) {
if (active.getAttribute('aria-disabled') === 'true') {
return { editable: false, reason: 'disabled' }
}
if (active.getAttribute('aria-readonly') === 'true') {
return { editable: false, reason: 'readonly' }
}
return { editable: true, kind: 'contenteditable' }
}
// A canvas-rendered editor (Google Docs) focuses a hidden proxy or the
// canvas region itself; trusted IME insertion still reaches it, so report
// it as insertable rather than refusing.
if (tag === 'CANVAS' || active.getAttribute('role') === 'textbox') {
return { editable: true, kind: tag === 'CANVAS' ? 'canvas' : 'textbox-role' }
}
return { editable: false, reason: 'not-editable' }
}
@@ -11,11 +11,14 @@ export interface ToolCatalogEntry {
| 'auth'
| 'browser'
| 'browser_click'
| 'browser_click_at'
| 'browser_close_tab'
| 'browser_drag'
| 'browser_extract'
| 'browser_go_back'
| 'browser_go_forward'
| 'browser_hover'
| 'browser_insert_text'
| 'browser_list_sessions'
| 'browser_list_tabs'
| 'browser_navigate'
@@ -132,11 +135,14 @@ export interface ToolCatalogEntry {
| 'auth'
| 'browser'
| 'browser_click'
| 'browser_click_at'
| 'browser_close_tab'
| 'browser_drag'
| 'browser_extract'
| 'browser_go_back'
| 'browser_go_forward'
| 'browser_hover'
| 'browser_insert_text'
| 'browser_list_sessions'
| 'browser_list_tabs'
| 'browser_navigate'
@@ -450,6 +456,135 @@ export const BrowserClick: ToolCatalogEntry = {
clientExecutable: true,
}
export const BrowserClickAt: ToolCatalogEntry = {
id: 'browser_click_at',
name: 'browser_click_at',
route: 'client',
mode: 'async',
parameters: {
type: 'object',
properties: {
clickCount: {
type: 'number',
description: '1 = single click (default), 2 = double-click, 3 = triple-click.',
},
x: {
type: 'number',
description:
"X in CSS pixels within the current viewport. When read off a browser_screenshot, divide the image pixel value by the screenshot's scale.",
},
y: {
type: 'number',
description:
'Y in CSS pixels within the current viewport, converted from screenshot pixels the same way as x.',
},
},
required: ['x', 'y'],
},
resultSchema: {
type: 'object',
properties: {
activeTab: {
type: 'object',
description: 'New active tab after a tab-changing click.',
properties: {
tabId: { type: 'string', description: 'Stable browser tab id.' },
url: { type: 'string', description: 'New active tab URL.' },
},
},
clickCount: {
type: 'number',
description: 'The click count that was dispatched (1, 2, or 3).',
},
clickedAt: {
type: 'object',
description: 'The CSS-pixel viewport point that was clicked.',
properties: {
x: { type: 'number', description: 'Clicked X in CSS viewport pixels.' },
y: { type: 'number', description: 'Clicked Y in CSS viewport pixels.' },
},
},
dialogs: {
type: 'array',
description: 'Visible DOM dialogs remaining after the click.',
items: { type: 'string' },
},
dispatched: {
type: 'boolean',
description: 'Whether the native pointer click was dispatched at the point.',
},
effect: {
type: 'object',
description:
'Detailed postcondition signals; generic title/DOM/scroll churn is weak evidence unless the tool documents otherwise.',
properties: {
dialogChanged: { type: 'boolean', description: 'The visible DOM dialog set changed.' },
domChanged: {
type: 'boolean',
description: 'The DOM mutation revision changed; weak evidence on its own.',
},
fieldChanged: {
type: 'boolean',
description: 'The safely inspectable focused-field state changed.',
},
focusChanged: { type: 'boolean', description: 'The focused element changed.' },
popupChanged: { type: 'boolean', description: 'The visible popup/menu set changed.' },
scrollChanged: {
type: 'boolean',
description: 'A tracked scroll offset changed; weak evidence except for scroll keys.',
},
tabChanged: { type: 'boolean', description: 'The active browser tab changed.' },
targetChanged: {
type: 'boolean',
description: "The requested target's checked/selected/expanded/open state changed.",
},
titleChanged: {
type: 'boolean',
description: 'The document title changed; weak evidence on its own.',
},
urlChanged: { type: 'boolean', description: 'The observed URL changed.' },
},
},
effectObserved: {
type: 'boolean',
description:
'Whether a strong page change (URL/dialog/popup/target, or focus into an editable) followed the click.',
},
note: {
type: 'string',
description: 'Caution or follow-up guidance, present when the click needs verification.',
},
notices: {
type: 'array',
description:
'Pending auto-handled JavaScript alert/confirm/prompt notices since the previous successful browser result.',
items: { type: 'string' },
},
possibleEffectObserved: {
type: 'boolean',
description: 'Whether only weaker background churn (DOM/title) was observed.',
},
target: {
type: 'string',
description:
'What the point resolved to before the click (tag/role and accessible name) — confirm it matches the intended target.',
},
targetCursor: {
type: 'string',
description:
"The CSS cursor at the point (e.g. 'pointer', 'text', 'crosshair') — a hint about what kind of surface was hit.",
},
trusted: {
type: 'boolean',
description:
'True — coordinate and drag input always use the trusted Chromium pointer pipeline.',
},
},
required: ['dispatched'],
},
clientExecutable: true,
}
export const BrowserCloseTab: ToolCatalogEntry = {
id: 'browser_close_tab',
name: 'browser_close_tab',
@@ -468,6 +603,130 @@ export const BrowserCloseTab: ToolCatalogEntry = {
clientExecutable: true,
}
export const BrowserDrag: ToolCatalogEntry = {
id: 'browser_drag',
name: 'browser_drag',
route: 'client',
mode: 'async',
parameters: {
type: 'object',
properties: {
fromElementId: {
type: 'number',
description: 'Drag source element id from the latest snapshot. Alternative to fromX/fromY.',
},
fromX: {
type: 'number',
description:
'Drag source X in CSS viewport pixels (paired with fromY) when no source element id is available.',
},
fromY: { type: 'number', description: 'Drag source Y in CSS viewport pixels.' },
toElementId: {
type: 'number',
description: 'Drop target element id from the latest snapshot. Alternative to toX/toY.',
},
toX: {
type: 'number',
description: 'Drop target X in CSS viewport pixels (paired with toY).',
},
toY: { type: 'number', description: 'Drop target Y in CSS viewport pixels.' },
},
},
resultSchema: {
type: 'object',
properties: {
dialogs: {
type: 'array',
description: 'Visible DOM dialogs remaining after the click.',
items: { type: 'string' },
},
dispatched: { type: 'boolean', description: 'Whether the full drag gesture was dispatched.' },
effect: {
type: 'object',
description:
'Detailed postcondition signals; generic title/DOM/scroll churn is weak evidence unless the tool documents otherwise.',
properties: {
dialogChanged: { type: 'boolean', description: 'The visible DOM dialog set changed.' },
domChanged: {
type: 'boolean',
description: 'The DOM mutation revision changed; weak evidence on its own.',
},
fieldChanged: {
type: 'boolean',
description: 'The safely inspectable focused-field state changed.',
},
focusChanged: { type: 'boolean', description: 'The focused element changed.' },
popupChanged: { type: 'boolean', description: 'The visible popup/menu set changed.' },
scrollChanged: {
type: 'boolean',
description: 'A tracked scroll offset changed; weak evidence except for scroll keys.',
},
tabChanged: { type: 'boolean', description: 'The active browser tab changed.' },
targetChanged: {
type: 'boolean',
description: "The requested target's checked/selected/expanded/open state changed.",
},
titleChanged: {
type: 'boolean',
description: 'The document title changed; weak evidence on its own.',
},
urlChanged: { type: 'boolean', description: 'The observed URL changed.' },
},
},
effectObserved: {
type: 'boolean',
description:
'Whether an observable page change (DOM/URL/dialog/target/scroll) followed the drag.',
},
from: {
type: 'object',
description: 'The resolved drag source.',
properties: {
element: { type: 'string', description: 'What that endpoint resolved to, when known.' },
x: { type: 'number', description: 'Endpoint X in CSS viewport pixels.' },
y: { type: 'number', description: 'Endpoint Y in CSS viewport pixels.' },
},
},
nativeHtml5Drag: {
type: 'boolean',
description:
'True when the page started a native HTML5 drag and it was completed as a real drag-and-drop; false for a pointer-sensor drag.',
},
note: {
type: 'string',
description:
'Caution or follow-up guidance — e.g. verify the drop with a fresh snapshot when no effect was observed.',
},
notices: {
type: 'array',
description:
'Pending auto-handled JavaScript alert/confirm/prompt notices since the previous successful browser result.',
items: { type: 'string' },
},
possibleEffectObserved: {
type: 'boolean',
description: 'Whether only weaker background churn (DOM/title) was observed.',
},
to: {
type: 'object',
description: 'The resolved drop target.',
properties: {
element: { type: 'string', description: 'What that endpoint resolved to, when known.' },
x: { type: 'number', description: 'Endpoint X in CSS viewport pixels.' },
y: { type: 'number', description: 'Endpoint Y in CSS viewport pixels.' },
},
},
trusted: {
type: 'boolean',
description:
'True — coordinate and drag input always use the trusted Chromium pointer pipeline.',
},
},
required: ['dispatched'],
},
clientExecutable: true,
}
export const BrowserExtract: ToolCatalogEntry = {
id: 'browser_extract',
name: 'browser_extract',
@@ -630,6 +889,116 @@ export const BrowserHover: ToolCatalogEntry = {
clientExecutable: true,
}
export const BrowserInsertText: ToolCatalogEntry = {
id: 'browser_insert_text',
name: 'browser_insert_text',
route: 'client',
mode: 'async',
parameters: {
type: 'object',
properties: {
submit: { type: 'boolean', description: 'Press Enter after inserting. Default false.' },
text: { type: 'string', description: 'The text to insert at the caret. Must be non-empty.' },
},
required: ['text'],
},
resultSchema: {
type: 'object',
properties: {
activeElement: { type: 'string', description: 'Focused element kind after the action.' },
dispatched: {
type: 'boolean',
description: 'Whether the text was inserted through the native IME pipeline.',
},
effect: {
type: 'object',
description:
'Detailed postcondition signals; generic title/DOM/scroll churn is weak evidence unless the tool documents otherwise.',
properties: {
dialogChanged: { type: 'boolean', description: 'The visible DOM dialog set changed.' },
domChanged: {
type: 'boolean',
description: 'The DOM mutation revision changed; weak evidence on its own.',
},
fieldChanged: {
type: 'boolean',
description: 'The safely inspectable focused-field state changed.',
},
focusChanged: { type: 'boolean', description: 'The focused element changed.' },
popupChanged: { type: 'boolean', description: 'The visible popup/menu set changed.' },
scrollChanged: {
type: 'boolean',
description: 'A tracked scroll offset changed; weak evidence except for scroll keys.',
},
tabChanged: { type: 'boolean', description: 'The active browser tab changed.' },
targetChanged: {
type: 'boolean',
description: "The requested target's checked/selected/expanded/open state changed.",
},
titleChanged: {
type: 'boolean',
description: 'The document title changed; weak evidence on its own.',
},
urlChanged: { type: 'boolean', description: 'The observed URL changed.' },
},
},
effectObserved: {
type: 'boolean',
description:
'Whether a field or page change confirmed the insertion. Canvas editors cannot echo one — verify visually.',
},
insertedChars: { type: 'number', description: 'How many characters were inserted.' },
kind: {
type: 'string',
description:
'The kind of focused editable that received the text (input:*, textarea, contenteditable, canvas, textbox-role).',
},
note: {
type: 'string',
description:
'Caution or follow-up guidance, e.g. that a canvas editor needs visual verification.',
},
notices: {
type: 'array',
description:
'Pending auto-handled JavaScript alert/confirm/prompt notices since the previous successful browser result.',
items: { type: 'string' },
},
possibleEffectObserved: {
type: 'boolean',
description: 'Whether only weaker background churn was observed.',
},
redacted: {
type: 'boolean',
description: 'Whether sensitive focused-field details were withheld.',
},
selectedChars: {
type: 'number',
description: 'Number of selected characters when safely inspectable.',
},
submitDispatched: { type: 'boolean', description: 'Whether Enter was actually dispatched.' },
submitRequested: {
type: 'boolean',
description: 'Whether Enter was requested after insertion.',
},
trusted: {
type: 'boolean',
description: 'True — insertion uses the trusted input pipeline.',
},
valueLength: {
type: 'number',
description: 'Focused non-secret field length when safely inspectable.',
},
valuePreview: {
type: 'string',
description: 'Bounded focused non-secret field preview when safely inspectable.',
},
},
required: ['dispatched'],
},
clientExecutable: true,
}
export const BrowserListSessions: ToolCatalogEntry = {
id: 'browser_list_sessions',
name: 'browser_list_sessions',
@@ -6617,11 +6986,14 @@ export const TOOL_CATALOG: Record<string, ToolCatalogEntry> = {
[Auth.id]: Auth,
[Browser.id]: Browser,
[BrowserClick.id]: BrowserClick,
[BrowserClickAt.id]: BrowserClickAt,
[BrowserCloseTab.id]: BrowserCloseTab,
[BrowserDrag.id]: BrowserDrag,
[BrowserExtract.id]: BrowserExtract,
[BrowserGoBack.id]: BrowserGoBack,
[BrowserGoForward.id]: BrowserGoForward,
[BrowserHover.id]: BrowserHover,
[BrowserInsertText.id]: BrowserInsertText,
[BrowserListSessions.id]: BrowserListSessions,
[BrowserListTabs.id]: BrowserListTabs,
[BrowserNavigate.id]: BrowserNavigate,
@@ -214,6 +214,160 @@ export const TOOL_RUNTIME_SCHEMAS: Record<string, ToolRuntimeSchemaEntry> = {
required: ['dispatched'],
},
},
browser_click_at: {
parameters: {
type: 'object',
properties: {
clickCount: {
type: 'number',
description: '1 = single click (default), 2 = double-click, 3 = triple-click.',
},
x: {
type: 'number',
description:
"X in CSS pixels within the current viewport. When read off a browser_screenshot, divide the image pixel value by the screenshot's scale.",
},
y: {
type: 'number',
description:
'Y in CSS pixels within the current viewport, converted from screenshot pixels the same way as x.',
},
},
required: ['x', 'y'],
},
resultSchema: {
type: 'object',
properties: {
activeTab: {
type: 'object',
description: 'New active tab after a tab-changing click.',
properties: {
tabId: {
type: 'string',
description: 'Stable browser tab id.',
},
url: {
type: 'string',
description: 'New active tab URL.',
},
},
},
clickCount: {
type: 'number',
description: 'The click count that was dispatched (1, 2, or 3).',
},
clickedAt: {
type: 'object',
description: 'The CSS-pixel viewport point that was clicked.',
properties: {
x: {
type: 'number',
description: 'Clicked X in CSS viewport pixels.',
},
y: {
type: 'number',
description: 'Clicked Y in CSS viewport pixels.',
},
},
},
dialogs: {
type: 'array',
description: 'Visible DOM dialogs remaining after the click.',
items: {
type: 'string',
},
},
dispatched: {
type: 'boolean',
description: 'Whether the native pointer click was dispatched at the point.',
},
effect: {
type: 'object',
description:
'Detailed postcondition signals; generic title/DOM/scroll churn is weak evidence unless the tool documents otherwise.',
properties: {
dialogChanged: {
type: 'boolean',
description: 'The visible DOM dialog set changed.',
},
domChanged: {
type: 'boolean',
description: 'The DOM mutation revision changed; weak evidence on its own.',
},
fieldChanged: {
type: 'boolean',
description: 'The safely inspectable focused-field state changed.',
},
focusChanged: {
type: 'boolean',
description: 'The focused element changed.',
},
popupChanged: {
type: 'boolean',
description: 'The visible popup/menu set changed.',
},
scrollChanged: {
type: 'boolean',
description: 'A tracked scroll offset changed; weak evidence except for scroll keys.',
},
tabChanged: {
type: 'boolean',
description: 'The active browser tab changed.',
},
targetChanged: {
type: 'boolean',
description: "The requested target's checked/selected/expanded/open state changed.",
},
titleChanged: {
type: 'boolean',
description: 'The document title changed; weak evidence on its own.',
},
urlChanged: {
type: 'boolean',
description: 'The observed URL changed.',
},
},
},
effectObserved: {
type: 'boolean',
description:
'Whether a strong page change (URL/dialog/popup/target, or focus into an editable) followed the click.',
},
note: {
type: 'string',
description: 'Caution or follow-up guidance, present when the click needs verification.',
},
notices: {
type: 'array',
description:
'Pending auto-handled JavaScript alert/confirm/prompt notices since the previous successful browser result.',
items: {
type: 'string',
},
},
possibleEffectObserved: {
type: 'boolean',
description: 'Whether only weaker background churn (DOM/title) was observed.',
},
target: {
type: 'string',
description:
'What the point resolved to before the click (tag/role and accessible name) — confirm it matches the intended target.',
},
targetCursor: {
type: 'string',
description:
"The CSS cursor at the point (e.g. 'pointer', 'text', 'crosshair') — a hint about what kind of surface was hit.",
},
trusted: {
type: 'boolean',
description:
'True — coordinate and drag input always use the trusted Chromium pointer pipeline.',
},
},
required: ['dispatched'],
},
},
browser_close_tab: {
parameters: {
type: 'object',
@@ -227,6 +381,171 @@ export const TOOL_RUNTIME_SCHEMAS: Record<string, ToolRuntimeSchemaEntry> = {
},
resultSchema: undefined,
},
browser_drag: {
parameters: {
type: 'object',
properties: {
fromElementId: {
type: 'number',
description:
'Drag source element id from the latest snapshot. Alternative to fromX/fromY.',
},
fromX: {
type: 'number',
description:
'Drag source X in CSS viewport pixels (paired with fromY) when no source element id is available.',
},
fromY: {
type: 'number',
description: 'Drag source Y in CSS viewport pixels.',
},
toElementId: {
type: 'number',
description: 'Drop target element id from the latest snapshot. Alternative to toX/toY.',
},
toX: {
type: 'number',
description: 'Drop target X in CSS viewport pixels (paired with toY).',
},
toY: {
type: 'number',
description: 'Drop target Y in CSS viewport pixels.',
},
},
},
resultSchema: {
type: 'object',
properties: {
dialogs: {
type: 'array',
description: 'Visible DOM dialogs remaining after the click.',
items: {
type: 'string',
},
},
dispatched: {
type: 'boolean',
description: 'Whether the full drag gesture was dispatched.',
},
effect: {
type: 'object',
description:
'Detailed postcondition signals; generic title/DOM/scroll churn is weak evidence unless the tool documents otherwise.',
properties: {
dialogChanged: {
type: 'boolean',
description: 'The visible DOM dialog set changed.',
},
domChanged: {
type: 'boolean',
description: 'The DOM mutation revision changed; weak evidence on its own.',
},
fieldChanged: {
type: 'boolean',
description: 'The safely inspectable focused-field state changed.',
},
focusChanged: {
type: 'boolean',
description: 'The focused element changed.',
},
popupChanged: {
type: 'boolean',
description: 'The visible popup/menu set changed.',
},
scrollChanged: {
type: 'boolean',
description: 'A tracked scroll offset changed; weak evidence except for scroll keys.',
},
tabChanged: {
type: 'boolean',
description: 'The active browser tab changed.',
},
targetChanged: {
type: 'boolean',
description: "The requested target's checked/selected/expanded/open state changed.",
},
titleChanged: {
type: 'boolean',
description: 'The document title changed; weak evidence on its own.',
},
urlChanged: {
type: 'boolean',
description: 'The observed URL changed.',
},
},
},
effectObserved: {
type: 'boolean',
description:
'Whether an observable page change (DOM/URL/dialog/target/scroll) followed the drag.',
},
from: {
type: 'object',
description: 'The resolved drag source.',
properties: {
element: {
type: 'string',
description: 'What that endpoint resolved to, when known.',
},
x: {
type: 'number',
description: 'Endpoint X in CSS viewport pixels.',
},
y: {
type: 'number',
description: 'Endpoint Y in CSS viewport pixels.',
},
},
},
nativeHtml5Drag: {
type: 'boolean',
description:
'True when the page started a native HTML5 drag and it was completed as a real drag-and-drop; false for a pointer-sensor drag.',
},
note: {
type: 'string',
description:
'Caution or follow-up guidance — e.g. verify the drop with a fresh snapshot when no effect was observed.',
},
notices: {
type: 'array',
description:
'Pending auto-handled JavaScript alert/confirm/prompt notices since the previous successful browser result.',
items: {
type: 'string',
},
},
possibleEffectObserved: {
type: 'boolean',
description: 'Whether only weaker background churn (DOM/title) was observed.',
},
to: {
type: 'object',
description: 'The resolved drop target.',
properties: {
element: {
type: 'string',
description: 'What that endpoint resolved to, when known.',
},
x: {
type: 'number',
description: 'Endpoint X in CSS viewport pixels.',
},
y: {
type: 'number',
description: 'Endpoint Y in CSS viewport pixels.',
},
},
},
trusted: {
type: 'boolean',
description:
'True — coordinate and drag input always use the trusted Chromium pointer pipeline.',
},
},
required: ['dispatched'],
},
},
browser_extract: {
parameters: {
type: 'object',
@@ -410,6 +729,142 @@ export const TOOL_RUNTIME_SCHEMAS: Record<string, ToolRuntimeSchemaEntry> = {
required: ['hovered'],
},
},
browser_insert_text: {
parameters: {
type: 'object',
properties: {
submit: {
type: 'boolean',
description: 'Press Enter after inserting. Default false.',
},
text: {
type: 'string',
description: 'The text to insert at the caret. Must be non-empty.',
},
},
required: ['text'],
},
resultSchema: {
type: 'object',
properties: {
activeElement: {
type: 'string',
description: 'Focused element kind after the action.',
},
dispatched: {
type: 'boolean',
description: 'Whether the text was inserted through the native IME pipeline.',
},
effect: {
type: 'object',
description:
'Detailed postcondition signals; generic title/DOM/scroll churn is weak evidence unless the tool documents otherwise.',
properties: {
dialogChanged: {
type: 'boolean',
description: 'The visible DOM dialog set changed.',
},
domChanged: {
type: 'boolean',
description: 'The DOM mutation revision changed; weak evidence on its own.',
},
fieldChanged: {
type: 'boolean',
description: 'The safely inspectable focused-field state changed.',
},
focusChanged: {
type: 'boolean',
description: 'The focused element changed.',
},
popupChanged: {
type: 'boolean',
description: 'The visible popup/menu set changed.',
},
scrollChanged: {
type: 'boolean',
description: 'A tracked scroll offset changed; weak evidence except for scroll keys.',
},
tabChanged: {
type: 'boolean',
description: 'The active browser tab changed.',
},
targetChanged: {
type: 'boolean',
description: "The requested target's checked/selected/expanded/open state changed.",
},
titleChanged: {
type: 'boolean',
description: 'The document title changed; weak evidence on its own.',
},
urlChanged: {
type: 'boolean',
description: 'The observed URL changed.',
},
},
},
effectObserved: {
type: 'boolean',
description:
'Whether a field or page change confirmed the insertion. Canvas editors cannot echo one — verify visually.',
},
insertedChars: {
type: 'number',
description: 'How many characters were inserted.',
},
kind: {
type: 'string',
description:
'The kind of focused editable that received the text (input:*, textarea, contenteditable, canvas, textbox-role).',
},
note: {
type: 'string',
description:
'Caution or follow-up guidance, e.g. that a canvas editor needs visual verification.',
},
notices: {
type: 'array',
description:
'Pending auto-handled JavaScript alert/confirm/prompt notices since the previous successful browser result.',
items: {
type: 'string',
},
},
possibleEffectObserved: {
type: 'boolean',
description: 'Whether only weaker background churn was observed.',
},
redacted: {
type: 'boolean',
description: 'Whether sensitive focused-field details were withheld.',
},
selectedChars: {
type: 'number',
description: 'Number of selected characters when safely inspectable.',
},
submitDispatched: {
type: 'boolean',
description: 'Whether Enter was actually dispatched.',
},
submitRequested: {
type: 'boolean',
description: 'Whether Enter was requested after insertion.',
},
trusted: {
type: 'boolean',
description: 'True — insertion uses the trusted input pipeline.',
},
valueLength: {
type: 'number',
description: 'Focused non-secret field length when safely inspectable.',
},
valuePreview: {
type: 'string',
description: 'Bounded focused non-secret field preview when safely inspectable.',
},
},
required: ['dispatched'],
},
},
browser_list_sessions: {
parameters: {
type: 'object',
@@ -525,7 +525,10 @@ const TOOL_TITLES: Record<string, string> = {
browser_read_text: 'Reading page',
browser_screenshot: 'Taking screenshot',
browser_click: 'Clicking element',
browser_click_at: 'Clicking point',
browser_type: 'Typing text',
browser_insert_text: 'Inserting text',
browser_drag: 'Dragging element',
browser_select_option: 'Selecting option',
browser_hover: 'Hovering element',
// Subagent trigger tools, when surfaced as a tool call.
@@ -1015,6 +1018,8 @@ const COMPLETED_VERB_REWRITES: Record<string, string> = {
Creating: 'Created',
Deleting: 'Deleted',
Deploying: 'Deployed',
Dragging: 'Dragged',
Inserting: 'Inserted',
Publishing: 'Published',
Unpublishing: 'Unpublished',
Analyzing: 'Analyzed',
+3
View File
@@ -33,11 +33,14 @@ export const BROWSER_TOOL_NAMES = [
'browser_screenshot',
'browser_extract',
'browser_click',
'browser_click_at',
'browser_type',
'browser_insert_text',
'browser_press_key',
'browser_scroll',
'browser_select_option',
'browser_hover',
'browser_drag',
'browser_request_takeover',
] as const