Skip to content

Commit fafeaf7

Browse files
committed
Merge branch 'staging-v4' of github.com:simstudioai/sim into staging-v4
2 parents 6d929c0 + 0b00bf6 commit fafeaf7

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,10 +27,12 @@ import * as cdp from '@/main/browser-agent/cdp'
2327
import {
2428
clickElement,
2529
collectSnapshot,
30+
focusElementForTyping,
2631
getViewportInfo,
2732
hoverElement,
2833
pageContainsText,
2934
pressKeyOnPage,
35+
readActiveElementState,
3036
readPageText,
3137
scrollPage,
3238
selectOptionInElement,
@@ -343,6 +349,101 @@ export function parseKeyCombo(combo: string): ParsedCombo {
343349
throw new ToolError(`Unrecognized key: "${keyPart}"`)
344350
}
345351

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

515616
case 'browser_type': {
617+
const elementId = requireNum(params, 'elementId')
618+
const text = requireStr(params, 'text')
619+
const submit = params.submit === true
516620
const contents = session.requireTab().view.webContents
517-
return unwrapPageResult(
518-
await execInPage(contents, typeIntoElement, [
519-
requireNum(params, 'elementId'),
520-
requireStr(params, 'text'),
521-
params.submit === true,
522-
])
523-
)
621+
622+
// Native path: focus + select current content, then insert through the
623+
// IME pipeline so the text REPLACES what's there — the only write path
624+
// code editors (CodeMirror/Monaco) honor. The DOM selection set by the
625+
// page function covers plain fields; the real select-all keystroke
626+
// right after covers editors that track selection in their own model
627+
// (their keymaps handle it synchronously, where DOM-selection sync is
628+
// async and can lose a race with the insert). Falls back to the
629+
// synthetic value-setter when CDP is unavailable.
630+
unwrapPageResult(await execInPage(contents, focusElementForTyping, [elementId]))
631+
try {
632+
await dispatchKeyCombo(
633+
contents,
634+
parseKeyCombo(process.platform === 'darwin' ? 'Cmd+A' : 'Control+A')
635+
)
636+
await cdp.insertText(contents, text)
637+
} catch {
638+
return unwrapPageResult(
639+
await execInPage(contents, typeIntoElement, [elementId, text, submit])
640+
)
641+
}
642+
if (submit) {
643+
await dispatchKeyCombo(contents, parseKeyCombo('Enter')).catch(() => {})
644+
}
645+
const state = await activeElementState(contents)
646+
return { typed: true, replacedExisting: true, submitted: submit, ...state }
524647
}
525648

526649
case 'browser_press_key': {
527650
const combo = parseKeyCombo(requireStr(params, 'key'))
528651
const contents = session.requireTab().view.webContents
529-
return await execInPage(contents, pressKeyOnPage, [
530-
combo.key,
531-
combo.code,
532-
combo.keyCode,
533-
combo.ctrl,
534-
combo.meta,
535-
combo.shift,
536-
combo.alt,
537-
])
652+
try {
653+
await dispatchKeyCombo(contents, combo)
654+
} catch {
655+
// CDP unavailable (debugger detached): synthetic DOM fallback. It
656+
// cannot trigger default editing actions, so say so in the result.
657+
const fallback = await execInPage(contents, pressKeyOnPage, [
658+
combo.key,
659+
combo.code,
660+
combo.keyCode,
661+
combo.ctrl,
662+
combo.meta,
663+
combo.shift,
664+
combo.alt,
665+
])
666+
return {
667+
...(typeof fallback === 'object' && fallback !== null ? fallback : {}),
668+
note: 'Delivered as a synthetic page event; editing shortcuts may not take effect.',
669+
}
670+
}
671+
const state = await activeElementState(contents)
672+
return { pressed: requireStr(params, 'key'), ...state }
538673
}
539674

540675
case 'browser_scroll': {

0 commit comments

Comments
 (0)