Skip to content

Commit 9da1870

Browse files
committed
refactor(browser): tighten the computer-use desktop changes
- Modifier aliases (Ctrl, Cmd, Command, Option) share one descriptor with their canonical key, so a bare alias sets its own flag; a bare modifier's key-up reports it released. - The framed-control fallback no longer treats a held click as a plain click, and a batch refuses press-and-hold so eight holds cannot outlast its watchdog. - installPageHelpers caches the modal lookup per invocation, and one serializePageCall builds the page expression for the driver and the tests. - Download completion drops a branch whose state was never published.
1 parent e44dca8 commit 9da1870

9 files changed

Lines changed: 169 additions & 381 deletions

File tree

‎apps/desktop/src/main/browser-agent/context-menu.test.ts‎

Lines changed: 3 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { describe, expect, it, vi } from 'vitest'
44
vi.mock('electron', () => import('@/test/electron-mock'))
55

66
import { Menu, WebContentsView } from 'electron'
7-
import { clickAt } from '@/main/browser-agent/cdp'
7+
import { clickAt, PRIMARY_CLICK } from '@/main/browser-agent/cdp'
88
import {
99
attachAgentContextMenu,
1010
BASE_ZOOM_FACTOR,
@@ -228,12 +228,7 @@ describe('attachAgentContextMenu', () => {
228228
ContextMenuListener,
229229
][]
230230
const onContextMenu = listeners.find(([event]) => event === 'context-menu')![1]
231-
await clickAt(contents, 10, 20, false, {
232-
button: 'right',
233-
clickCount: 1,
234-
modifiers: 0,
235-
holdMs: 0,
236-
})
231+
await clickAt(contents, 10, 20, false, { ...PRIMARY_CLICK, button: 'right' })
237232
vi.mocked(Menu.buildFromTemplate).mockClear()
238233

239234
onContextMenu({}, params())
@@ -257,12 +252,7 @@ describe('attachAgentContextMenu', () => {
257252
][]
258253
const onInput = listeners.find(([event]) => event === 'input-event')?.[1]
259254
const onContextMenu = listeners.find(([event]) => event === 'context-menu')![1]
260-
await clickAt(contents, 10, 20, false, {
261-
button: 'right',
262-
clickCount: 1,
263-
modifiers: 0,
264-
holdMs: 0,
265-
})
255+
await clickAt(contents, 10, 20, false, { ...PRIMARY_CLICK, button: 'right' })
266256
vi.mocked(Menu.buildFromTemplate).mockClear()
267257

268258
onInput?.({}, { type: inputEvent })

‎apps/desktop/src/main/browser-agent/driver.test.ts‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3624,6 +3624,17 @@ describe('credential protection', () => {
36243624
error: expect.stringContaining('Batch action 0'),
36253625
})
36263626
expect(observed).toMatchObject({ ok: false, error: expect.stringContaining('cannot observe') })
3627+
3628+
const held = await driver.executeTool('chat-test', 'browser_batch', {
3629+
actions: [
3630+
{ tool: 'browser_click', args: { elementId: 0, holdMs: 2000 } },
3631+
{ tool: 'browser_click', args: { elementId: 0 } },
3632+
],
3633+
})
3634+
expect(held).toMatchObject({
3635+
ok: false,
3636+
error: expect.stringContaining('cannot press and hold'),
3637+
})
36273638
})
36283639

36293640
it('keeps element ids valid when an observed action is refused before dispatch', async () => {

‎apps/desktop/src/main/browser-agent/driver.ts‎

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,7 @@ import {
7676
resolveFileInputTarget,
7777
scrollPage,
7878
selectOptionInElement,
79+
serializePageCall,
7980
setFocusedInputValue,
8081
typeIntoElement,
8182
} from '@/main/browser-agent/page-functions'
@@ -187,6 +188,9 @@ function parseBatchActions(params: Record<string, unknown>): BatchAction[] {
187188
if ('observe' in action.args) {
188189
throw new ToolError(`Batch action ${index} cannot observe; pass observe on the batch itself.`)
189190
}
191+
if (num(action.args, 'holdMs')) {
192+
throw new ToolError(`Batch action ${index} cannot press and hold; run it as its own click.`)
193+
}
190194
return { tool: action.tool, args: action.args }
191195
})
192196
}
@@ -1121,10 +1125,10 @@ const POINTER_BUTTONS: ReadonlySet<string> = new Set(['left', 'right', 'middle']
11211125
/** Enough to walk a slider or list by keyboard in one call without flooding the page. */
11221126
const MAX_KEY_REPEAT = 50
11231127

1124-
/** The optional click gesture shared by `browser_click` and `browser_click_at`. */
11251128
/** Longest press-and-hold a click may request; well inside the click tool's watchdog. */
11261129
const MAX_POINTER_HOLD_MS = 10_000
11271130

1131+
/** The optional click gesture shared by `browser_click` and `browser_click_at`. */
11281132
function pointerClick(params: Record<string, unknown>): cdp.PointerClick {
11291133
const button = str(params, 'button') ?? 'left'
11301134
if (!POINTER_BUTTONS.has(button)) throw new ToolError('button must be left, right, or middle.')
@@ -1176,7 +1180,9 @@ function uploadPaths(params: Record<string, unknown>): string[] {
11761180
}
11771181

11781182
function isPrimaryClick(click: cdp.PointerClick): boolean {
1179-
return click.button === 'left' && click.clickCount === 1 && click.modifiers === 0
1183+
return (
1184+
click.button === 'left' && click.clickCount === 1 && click.modifiers === 0 && click.holdMs === 0
1185+
)
11801186
}
11811187

11821188
const DIALOG_ANSWERING_TOOLS: ReadonlySet<BrowserToolName> = new Set([
@@ -1287,7 +1293,7 @@ async function execInPage<Args extends unknown[], Result>(
12871293
'The active tab is blank. Call browser_navigate before using page inspection or interaction tools.'
12881294
)
12891295
}
1290-
const invocation = `(${String(fn)}).apply(null, ${JSON.stringify(args)})`
1296+
const invocation = serializePageCall(fn as (...args: never[]) => unknown, args)
12911297
const expression =
12921298
typeof notAfter === 'number'
12931299
? `(Date.now() >= ${Math.floor(notAfter)} ? ({error: "expired"}) : ${invocation})`
@@ -3274,7 +3280,7 @@ async function executeToolInner(
32743280
} else {
32753281
if (!isPrimaryClick(click)) {
32763282
throw new ToolError(
3277-
'This framed control has no reliable pointer position, so only a plain left click can activate it. Use browser_screenshot and browser_click_at for other buttons, click counts, or modifiers.'
3283+
'This framed control has no reliable pointer position, so only a plain left click can activate it. Use browser_screenshot and browser_click_at for other buttons, click counts, holds, or modifiers.'
32783284
)
32793285
}
32803286
const activationKey = prepared.activationKey

‎apps/desktop/src/main/browser-agent/keyboard.test.ts‎

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -208,10 +208,23 @@ describe('modifierKeyEvents', () => {
208208
code: 'ControlLeft',
209209
modifiers: 2,
210210
})
211-
expect(up).toMatchObject({ type: 'keyUp', key: 'Control' })
211+
expect(up).toMatchObject({ type: 'keyUp', key: 'Control', modifiers: 0 })
212212
expect(modifierKeyEvents(combo, 'linux')).toEqual({ downs: [], ups: [] })
213213
})
214214

215+
it('treats modifier aliases like their canonical key', () => {
216+
for (const [alias, key, flag] of [
217+
['Ctrl', 'Control', 2],
218+
['Option', 'Alt', 1],
219+
['Cmd', 'Meta', 4],
220+
['Command', 'Meta', 4],
221+
] as const) {
222+
const [down, up] = buildKeyDispatchPlan(parseKeyCombo(alias, 'linux'), 'linux')
223+
expect(down).toMatchObject({ key, modifiers: flag })
224+
expect(up).toMatchObject({ type: 'keyUp', key, modifiers: 0 })
225+
}
226+
})
227+
215228
it('sends no extra events for a key without modifiers', () => {
216229
expect(modifierKeyEvents(parseKeyCombo('a', 'linux'), 'linux')).toEqual({ downs: [], ups: [] })
217230
})

‎apps/desktop/src/main/browser-agent/keyboard.ts‎

Lines changed: 38 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,11 @@ interface KeyDescriptor {
1616
keyCode: number
1717
}
1818

19+
const CONTROL_KEY: KeyDescriptor = { key: 'Control', code: 'ControlLeft', keyCode: 17 }
20+
const SHIFT_KEY: KeyDescriptor = { key: 'Shift', code: 'ShiftLeft', keyCode: 16 }
21+
const ALT_KEY: KeyDescriptor = { key: 'Alt', code: 'AltLeft', keyCode: 18 }
22+
const META_KEY: KeyDescriptor = { key: 'Meta', code: 'MetaLeft', keyCode: 91 }
23+
1924
const NAMED_KEYS: Record<string, KeyDescriptor> = {
2025
enter: { key: 'Enter', code: 'Enter', keyCode: 13 },
2126
escape: { key: 'Escape', code: 'Escape', keyCode: 27 },
@@ -51,14 +56,14 @@ const NAMED_KEYS: Record<string, KeyDescriptor> = {
5156
'`': { key: '`', code: 'Backquote', keyCode: 192 },
5257
plus: { key: '+', code: 'Equal', keyCode: 187 },
5358
insert: { key: 'Insert', code: 'Insert', keyCode: 45 },
54-
control: { key: 'Control', code: 'ControlLeft', keyCode: 17 },
55-
ctrl: { key: 'Control', code: 'ControlLeft', keyCode: 17 },
56-
shift: { key: 'Shift', code: 'ShiftLeft', keyCode: 16 },
57-
alt: { key: 'Alt', code: 'AltLeft', keyCode: 18 },
58-
option: { key: 'Alt', code: 'AltLeft', keyCode: 18 },
59-
meta: { key: 'Meta', code: 'MetaLeft', keyCode: 91 },
60-
cmd: { key: 'Meta', code: 'MetaLeft', keyCode: 91 },
61-
command: { key: 'Meta', code: 'MetaLeft', keyCode: 91 },
59+
control: CONTROL_KEY,
60+
ctrl: CONTROL_KEY,
61+
shift: SHIFT_KEY,
62+
alt: ALT_KEY,
63+
option: ALT_KEY,
64+
meta: META_KEY,
65+
cmd: META_KEY,
66+
command: META_KEY,
6267
...Object.fromEntries(
6368
Array.from({ length: 12 }, (_, index) => [
6469
`f${index + 1}`,
@@ -108,12 +113,12 @@ const BASE_FOR_SHIFTED_CHARACTER: Record<string, string> = Object.fromEntries(
108113
Object.entries(SHIFTED_CHARACTERS).map(([base, shifted]) => [shifted, base])
109114
)
110115

111-
/** Modifier keys in the order a chord presses them, each with the flag its key-down sets. */
116+
/** Modifier keys in the fixed order a chord presses them, each with the flag its key-down sets. */
112117
const MODIFIER_KEYS: readonly { flag: keyof KeyModifiers; descriptor: KeyDescriptor }[] = [
113-
{ flag: 'ctrl', descriptor: NAMED_KEYS.control },
114-
{ flag: 'alt', descriptor: NAMED_KEYS.alt },
115-
{ flag: 'shift', descriptor: NAMED_KEYS.shift },
116-
{ flag: 'meta', descriptor: NAMED_KEYS.meta },
118+
{ flag: 'ctrl', descriptor: CONTROL_KEY },
119+
{ flag: 'alt', descriptor: ALT_KEY },
120+
{ flag: 'shift', descriptor: SHIFT_KEY },
121+
{ flag: 'meta', descriptor: META_KEY },
117122
]
118123

119124
export interface KeyModifiers {
@@ -312,7 +317,11 @@ export function buildKeyDispatchPlan(
312317
...(text !== undefined ? { text } : {}),
313318
...(commands.length > 0 ? { commands } : {}),
314319
}
315-
return [down, { ...base, type: 'keyUp' }]
320+
const ownModifier = MODIFIER_KEYS.find(({ descriptor }) => descriptor.key === combo.key)
321+
const upModifiers = ownModifier
322+
? cdpModifiers({ ...combo, [ownModifier.flag]: false })
323+
: modifiers
324+
return [down, { ...base, type: 'keyUp', modifiers: upModifiers }]
316325
}
317326

318327
/**
@@ -326,25 +335,22 @@ export function modifierKeyEvents(
326335
): { downs: cdp.CdpKeyEvent[]; ups: cdp.CdpKeyEvent[] } {
327336
const combo = normalizeComboForPlatform(rawCombo, platform)
328337
const held = { ctrl: false, meta: false, shift: false, alt: false }
329-
const downs: cdp.CdpKeyEvent[] = []
330-
const ups: cdp.CdpKeyEvent[] = []
331-
for (const { flag, descriptor } of MODIFIER_KEYS) {
332-
if (!combo[flag] || descriptor.key === combo.key) continue
338+
const pressed = MODIFIER_KEYS.filter(
339+
({ flag, descriptor }) => combo[flag] && descriptor.key !== combo.key
340+
)
341+
const event = ({ key, code, keyCode }: KeyDescriptor) => ({
342+
key,
343+
code,
344+
windowsVirtualKeyCode: keyCode,
345+
})
346+
const downs = pressed.map(({ flag, descriptor }) => {
333347
held[flag] = true
334-
const event = {
335-
key: descriptor.key,
336-
code: descriptor.code,
337-
windowsVirtualKeyCode: descriptor.keyCode,
338-
}
339-
downs.push({ ...event, type: 'rawKeyDown', modifiers: cdpModifiers(held) })
340-
ups.unshift({ ...event, type: 'keyUp', modifiers: 0 })
341-
}
342-
let remaining = { ...held }
343-
for (const up of ups) {
344-
const flag = MODIFIER_KEYS.find((modifier) => modifier.descriptor.key === up.key)?.flag
345-
if (flag) remaining = { ...remaining, [flag]: false }
346-
up.modifiers = cdpModifiers(remaining)
347-
}
348+
return { ...event(descriptor), type: 'rawKeyDown' as const, modifiers: cdpModifiers(held) }
349+
})
350+
const ups = [...pressed].reverse().map(({ flag, descriptor }) => {
351+
held[flag] = false
352+
return { ...event(descriptor), type: 'keyUp' as const, modifiers: cdpModifiers(held) }
353+
})
348354
return { downs, ups }
349355
}
350356

‎apps/desktop/src/main/browser-agent/page-functions.test.ts‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {
1111
getElementScreenshotRect,
1212
getViewportInfo,
1313
hoverElement,
14+
installPageHelpers,
1415
pageContainsText,
1516
pressKeyOnPage,
1617
readActiveElementState,
@@ -22,6 +23,7 @@ import {
2223
resolveFileInputTarget,
2324
scrollPage,
2425
selectOptionInElement,
26+
serializePageCall,
2527
setFocusedInputValue,
2628
typeIntoElement,
2729
} from '@/main/browser-agent/page-functions'
@@ -60,8 +62,7 @@ function installDomShims(): void {
6062
* fails here exactly as it would in a real page.
6163
*/
6264
function runSerialized(fn: (...args: never[]) => unknown, args: unknown[]): unknown {
63-
const expression = `(${String(fn)}).apply(null, ${JSON.stringify(args)})`
64-
return new Function(`return ${expression}`)()
65+
return new Function(`return ${serializePageCall(fn, args)}`)()
6566
}
6667

6768
function visible<T extends Element>(el: T): T {
@@ -116,6 +117,7 @@ beforeEach(() => {
116117
window.__simAgentMutationStates = undefined
117118
window.__simAgentNextElementId = 0
118119
window.__simAgentShownElements = undefined
120+
installPageHelpers()
119121
window.__simAgentResolveElement = undefined
120122
installDomShims()
121123
Reflect.deleteProperty(document, 'activeElement')

0 commit comments

Comments
 (0)