Skip to content

Commit 0b00bf6

Browse files
committed
Huge
1 parent 68f5857 commit 0b00bf6

28 files changed

Lines changed: 2472 additions & 42 deletions

File tree

apps/desktop/src/main/browser-agent/cdp.ts

Lines changed: 32 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,12 @@
11
/**
22
* CDP instrumentation for agent tabs via `webContents.debugger`: auto-handles
33
* the page states that would otherwise wedge automation (JS dialogs, file
4-
* choosers) and captures screenshots that work even while the view is hidden.
5-
* The user sees and drives the real embedded page, so there is no screencast
6-
* and no synthetic input here.
4+
* choosers), captures screenshots that work even while the view is hidden,
5+
* and dispatches TRUSTED input (key events, text insertion). Trusted input
6+
* goes through Blink's real input pipeline — unlike synthetic DOM
7+
* `KeyboardEvent`s, it triggers default actions (select-all, deletion, caret
8+
* movement, character insertion) and is honored by code editors. The user
9+
* sees and drives the real embedded page, so there is no screencast.
710
*/
811
import { createLogger } from '@sim/logger'
912
import type { WebContents } from 'electron'
@@ -89,3 +92,29 @@ export async function captureScreenshot(contents: WebContents): Promise<string>
8992
})
9093
return `data:image/jpeg;base64,${result.data}`
9194
}
95+
96+
/** One half of a trusted key press (`Input.dispatchKeyEvent` params). */
97+
export interface CdpKeyEvent {
98+
type: 'keyDown' | 'rawKeyDown' | 'keyUp'
99+
modifiers: number
100+
key: string
101+
code: string
102+
windowsVirtualKeyCode: number
103+
nativeVirtualKeyCode: number
104+
text?: string
105+
/** Blink editing commands to run with the event (macOS shortcut parity). */
106+
commands?: string[]
107+
}
108+
109+
/** Dispatches one trusted key event through Blink's input pipeline. */
110+
export async function dispatchKeyEvent(contents: WebContents, event: CdpKeyEvent): Promise<void> {
111+
await send(contents, 'Input.dispatchKeyEvent', event as unknown as Record<string, unknown>)
112+
}
113+
114+
/**
115+
* Inserts text at the focused element's selection (replacing it) through the
116+
* native IME path — works in plain fields and code editors alike.
117+
*/
118+
export async function insertText(contents: WebContents, text: string): Promise<void> {
119+
await send(contents, 'Input.insertText', { text })
120+
}

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

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,72 @@ describe('parseKeyCombo', () => {
3636
})
3737
})
3838

39+
describe('buildKeyDispatchPlan', () => {
40+
let driver: DriverModule
41+
42+
beforeEach(async () => {
43+
driver = await freshDriver()
44+
})
45+
46+
it('carries text for printable keys so Blink inserts the character', () => {
47+
const [down, up] = driver.buildKeyDispatchPlan(driver.parseKeyCombo('a'), 'linux')
48+
expect(down).toMatchObject({ type: 'keyDown', text: 'a', key: 'a', modifiers: 0 })
49+
expect(up).toMatchObject({ type: 'keyUp', key: 'a' })
50+
})
51+
52+
it('sends Enter with a carriage return so defaults fire (form submit)', () => {
53+
const [down] = driver.buildKeyDispatchPlan(driver.parseKeyCombo('Enter'), 'linux')
54+
expect(down).toMatchObject({ type: 'keyDown', text: '\r', windowsVirtualKeyCode: 13 })
55+
})
56+
57+
it('sends editing keys as rawKeyDown without text', () => {
58+
const [down] = driver.buildKeyDispatchPlan(driver.parseKeyCombo('Backspace'), 'linux')
59+
expect(down.type).toBe('rawKeyDown')
60+
expect(down.text).toBeUndefined()
61+
})
62+
63+
it('maps Cmd shortcuts to Blink editing commands on macOS only', () => {
64+
const combo = driver.parseKeyCombo('Cmd+A')
65+
const [macDown] = driver.buildKeyDispatchPlan(combo, 'darwin')
66+
expect(macDown.commands).toEqual(['selectAll'])
67+
expect(macDown.modifiers).toBe(4)
68+
const [linuxDown] = driver.buildKeyDispatchPlan(combo, 'linux')
69+
expect(linuxDown.commands).toBeUndefined()
70+
})
71+
72+
it('treats Control shortcuts as Cmd on macOS (the model does not know the host OS)', () => {
73+
const combo = driver.parseKeyCombo('Control+A')
74+
const [macDown] = driver.buildKeyDispatchPlan(combo, 'darwin')
75+
expect(macDown.commands).toEqual(['selectAll'])
76+
expect(macDown.modifiers).toBe(4) // ctrl normalized away, meta set
77+
// On Linux/Windows Ctrl+A is Blink-native; no rewrite.
78+
const [linuxDown] = driver.buildKeyDispatchPlan(combo, 'linux')
79+
expect(linuxDown.modifiers).toBe(2)
80+
expect(linuxDown.commands).toBeUndefined()
81+
})
82+
83+
it('does not rewrite non-editing Control combos on macOS', () => {
84+
const [down] = driver.buildKeyDispatchPlan(driver.parseKeyCombo('Control+K'), 'darwin')
85+
expect(down.modifiers).toBe(2)
86+
expect(down.commands).toBeUndefined()
87+
})
88+
89+
it('maps Cmd+Shift+Z to redo and Cmd+Z to undo on macOS', () => {
90+
const [redo] = driver.buildKeyDispatchPlan(driver.parseKeyCombo('Cmd+Shift+Z'), 'darwin')
91+
expect(redo.commands).toEqual(['redo'])
92+
const [undo] = driver.buildKeyDispatchPlan(driver.parseKeyCombo('Cmd+Z'), 'darwin')
93+
expect(undo.commands).toEqual(['undo'])
94+
})
95+
96+
it('encodes the CDP modifier bitmask (Alt=1 Ctrl=2 Meta=4 Shift=8)', () => {
97+
const [down] = driver.buildKeyDispatchPlan(driver.parseKeyCombo('Control+Shift+K'), 'linux')
98+
expect(down.modifiers).toBe(2 | 8)
99+
// Modified letters must not carry text — they are shortcuts, not typing.
100+
expect(down.type).toBe('rawKeyDown')
101+
expect(down.text).toBeUndefined()
102+
})
103+
})
104+
39105
describe('executeTool', () => {
40106
let driver: DriverModule
41107

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

Lines changed: 157 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,16 @@
44
* live page state.
55
*
66
* Perception drives through injected page functions (element registry with a
7-
* structural outline); actuation is synthetic DOM events from those same
8-
* functions. The user needs no input translation at all — the real page is
9-
* embedded in the Sim window, so their clicks and typing are native. Tool
10-
* calls serialize through a queue — one real browser can only do one thing at
11-
* a time — and every call is bounded by a watchdog so the Sim side always
12-
* gets a response instead of waiting out its own timeout against silence.
7+
* structural outline). Keyboard actuation (press_key, type) goes through
8+
* TRUSTED CDP input events — synthetic DOM KeyboardEvents never trigger
9+
* default editing actions (select-all, deletion, character insertion) and are
10+
* ignored by code editors, so they exist only as a fallback. Clicks still use
11+
* injected functions (element-targeted, no coordinate math). The user needs
12+
* no input translation at all — the real page is embedded in the Sim window,
13+
* so their clicks and typing are native. Tool calls serialize through a
14+
* queue — one real browser can only do one thing at a time — and every call
15+
* is bounded by a watchdog so the Sim side always gets a response instead of
16+
* waiting out its own timeout against silence.
1317
*/
1418
import type {
1519
BrowserPageState,
@@ -23,12 +27,14 @@ import * as cdp from '@/main/browser-agent/cdp'
2327
import {
2428
clickElement,
2529
collectSnapshot,
30+
focusElementForTyping,
2631
getViewportInfo,
2732
hasTakeoverBanner,
2833
hoverElement,
2934
isTakeoverDone,
3035
pageContainsText,
3136
pressKeyOnPage,
37+
readActiveElementState,
3238
readPageText,
3339
removeTakeoverBanner,
3440
scrollPage,
@@ -338,6 +344,101 @@ export function parseKeyCombo(combo: string): ParsedCombo {
338344
throw new ToolError(`Unrecognized key: "${keyPart}"`)
339345
}
340346

347+
// ---------------------------------------------------------------------------
348+
// Trusted key dispatch (CDP Input.dispatchKeyEvent)
349+
// ---------------------------------------------------------------------------
350+
351+
/** CDP `Input` modifier bitmask: Alt=1, Ctrl=2, Meta=4, Shift=8. */
352+
function cdpModifiers(combo: ParsedCombo): number {
353+
return (combo.alt ? 1 : 0) | (combo.ctrl ? 2 : 0) | (combo.meta ? 4 : 0) | (combo.shift ? 8 : 0)
354+
}
355+
356+
function editingCommandFor(combo: ParsedCombo): string | null {
357+
switch (combo.key.toLowerCase()) {
358+
case 'a':
359+
return 'selectAll'
360+
case 'c':
361+
return 'copy'
362+
case 'x':
363+
return 'cut'
364+
case 'v':
365+
return 'paste'
366+
case 'z':
367+
return combo.shift ? 'redo' : 'undo'
368+
default:
369+
return null
370+
}
371+
}
372+
373+
/**
374+
* On macOS the editing shortcuts are bound in the system menu layer, which
375+
* CDP key events never traverse — so Blink must be told the editing command
376+
* explicitly (same technique as Puppeteer/Playwright). The model doesn't know
377+
* the host OS and often says "Control+A", so on macOS Ctrl is treated as Cmd
378+
* for these shortcuts: both must select all, not silently no-op. On other
379+
* platforms Ctrl+key is handled inside Blink and needs no help.
380+
*/
381+
function normalizeComboForPlatform(combo: ParsedCombo, platform: NodeJS.Platform): ParsedCombo {
382+
if (platform !== 'darwin' || !combo.ctrl || combo.meta || editingCommandFor(combo) === null) {
383+
return combo
384+
}
385+
return { ...combo, ctrl: false, meta: true }
386+
}
387+
388+
function macEditingCommands(combo: ParsedCombo, platform: NodeJS.Platform): string[] {
389+
if (platform !== 'darwin' || !combo.meta) return []
390+
const command = editingCommandFor(combo)
391+
return command ? [command] : []
392+
}
393+
394+
/**
395+
* Builds the trusted keyDown/keyUp pair for a combo. Printable keys without
396+
* ctrl/meta carry `text` so Blink inserts the character; Enter carries "\r"
397+
* so it activates defaults (form submission, newline). Everything else is a
398+
* rawKeyDown, which still drives Blink's default editing actions (Backspace
399+
* deletes, arrows move the caret, Ctrl/Cmd+A selects all).
400+
*/
401+
export function buildKeyDispatchPlan(
402+
rawCombo: ParsedCombo,
403+
platform: NodeJS.Platform = process.platform
404+
): [cdp.CdpKeyEvent, cdp.CdpKeyEvent] {
405+
const combo = normalizeComboForPlatform(rawCombo, platform)
406+
const modifiers = cdpModifiers(combo)
407+
const base = {
408+
modifiers,
409+
key: combo.key,
410+
code: combo.code,
411+
windowsVirtualKeyCode: combo.keyCode,
412+
nativeVirtualKeyCode: combo.keyCode,
413+
}
414+
const printable = combo.key.length === 1 && !combo.ctrl && !combo.meta
415+
const text = combo.key === 'Enter' ? '\r' : printable ? combo.key : undefined
416+
const commands = macEditingCommands(combo, platform)
417+
const down: cdp.CdpKeyEvent = {
418+
...base,
419+
type: text !== undefined ? 'keyDown' : 'rawKeyDown',
420+
...(text !== undefined ? { text } : {}),
421+
...(commands.length > 0 ? { commands } : {}),
422+
}
423+
return [down, { ...base, type: 'keyUp' }]
424+
}
425+
426+
/** Presses a combo through the trusted pipeline. Throws on CDP failure. */
427+
async function dispatchKeyCombo(contents: WebContents, combo: ParsedCombo): Promise<void> {
428+
const [down, up] = buildKeyDispatchPlan(combo)
429+
await cdp.dispatchKeyEvent(contents, down)
430+
await cdp.dispatchKeyEvent(contents, up)
431+
}
432+
433+
/**
434+
* Post-action readback so the model sees the real effect (selection size,
435+
* value length) instead of assuming the key "worked".
436+
*/
437+
async function activeElementState(contents: WebContents): Promise<Record<string, unknown>> {
438+
const state = await execInPage(contents, readActiveElementState, []).catch(() => null)
439+
return typeof state === 'object' && state !== null ? (state as Record<string, unknown>) : {}
440+
}
441+
341442
// ---------------------------------------------------------------------------
342443
// Takeover
343444
// ---------------------------------------------------------------------------
@@ -504,28 +605,62 @@ async function executeToolInner(
504605
}
505606

506607
case 'browser_type': {
608+
const elementId = requireNum(params, 'elementId')
609+
const text = requireStr(params, 'text')
610+
const submit = params.submit === true
507611
const contents = session.requireTab().view.webContents
508-
return unwrapPageResult(
509-
await execInPage(contents, typeIntoElement, [
510-
requireNum(params, 'elementId'),
511-
requireStr(params, 'text'),
512-
params.submit === true,
513-
])
514-
)
612+
613+
// Native path: focus + select current content, then insert through the
614+
// IME pipeline so the text REPLACES what's there — the only write path
615+
// code editors (CodeMirror/Monaco) honor. The DOM selection set by the
616+
// page function covers plain fields; the real select-all keystroke
617+
// right after covers editors that track selection in their own model
618+
// (their keymaps handle it synchronously, where DOM-selection sync is
619+
// async and can lose a race with the insert). Falls back to the
620+
// synthetic value-setter when CDP is unavailable.
621+
unwrapPageResult(await execInPage(contents, focusElementForTyping, [elementId]))
622+
try {
623+
await dispatchKeyCombo(
624+
contents,
625+
parseKeyCombo(process.platform === 'darwin' ? 'Cmd+A' : 'Control+A')
626+
)
627+
await cdp.insertText(contents, text)
628+
} catch {
629+
return unwrapPageResult(
630+
await execInPage(contents, typeIntoElement, [elementId, text, submit])
631+
)
632+
}
633+
if (submit) {
634+
await dispatchKeyCombo(contents, parseKeyCombo('Enter')).catch(() => {})
635+
}
636+
const state = await activeElementState(contents)
637+
return { typed: true, replacedExisting: true, submitted: submit, ...state }
515638
}
516639

517640
case 'browser_press_key': {
518641
const combo = parseKeyCombo(requireStr(params, 'key'))
519642
const contents = session.requireTab().view.webContents
520-
return await execInPage(contents, pressKeyOnPage, [
521-
combo.key,
522-
combo.code,
523-
combo.keyCode,
524-
combo.ctrl,
525-
combo.meta,
526-
combo.shift,
527-
combo.alt,
528-
])
643+
try {
644+
await dispatchKeyCombo(contents, combo)
645+
} catch {
646+
// CDP unavailable (debugger detached): synthetic DOM fallback. It
647+
// cannot trigger default editing actions, so say so in the result.
648+
const fallback = await execInPage(contents, pressKeyOnPage, [
649+
combo.key,
650+
combo.code,
651+
combo.keyCode,
652+
combo.ctrl,
653+
combo.meta,
654+
combo.shift,
655+
combo.alt,
656+
])
657+
return {
658+
...(typeof fallback === 'object' && fallback !== null ? fallback : {}),
659+
note: 'Delivered as a synthetic page event; editing shortcuts may not take effect.',
660+
}
661+
}
662+
const state = await activeElementState(contents)
663+
return { pressed: requireStr(params, 'key'), ...state }
529664
}
530665

531666
case 'browser_scroll': {

0 commit comments

Comments
 (0)