Skip to content

Commit 90f0319

Browse files
committed
fix(browser): close the edge cases review found in the desktop agent changes
- A held right-click renews its agent context-menu marker before release, so Windows' release-time menu stays suppressed after a hold over one second. - A failed chord releases only the keys whose press was attempted. - A disk probe that resolves after a download completed can no longer cancel it. - A disablePortal modal nested inside another open modal is picked as topmost. - holdMs is an integer bounded to 0-10000 in the tool contract.
1 parent 9da1870 commit 90f0319

10 files changed

Lines changed: 148 additions & 20 deletions

File tree

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

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import {
1414
import {
1515
captureScreenshot,
1616
clickAt,
17+
consumeAgentContextMenu,
1718
ensureInstrumented,
1819
evaluateInIsolatedFrame,
1920
insertText,
@@ -275,6 +276,28 @@ describe('browser-agent CDP instrumentation', () => {
275276
}
276277
})
277278

279+
it('keeps a held right-click marked as the agent context menu until release', async () => {
280+
const contents = new WebContentsView().webContents
281+
vi.useFakeTimers()
282+
try {
283+
const rightHold = { ...PRIMARY_CLICK, button: 'right' as const, holdMs: 1500 }
284+
await Promise.all([
285+
clickAt(contents, 5, 6, false, rightHold),
286+
vi.advanceTimersByTimeAsync(1500),
287+
])
288+
expect(consumeAgentContextMenu(contents)).toBe(true)
289+
290+
const click = clickAt(contents, 5, 6, false, rightHold)
291+
await vi.advanceTimersByTimeAsync(0)
292+
expect(consumeAgentContextMenu(contents)).toBe(true)
293+
await vi.advanceTimersByTimeAsync(1500)
294+
await click
295+
expect(consumeAgentContextMenu(contents)).toBe(false)
296+
} finally {
297+
vi.useRealTimers()
298+
}
299+
})
300+
278301
it('releases the mouse after a partial click failure', async () => {
279302
const contents = new WebContentsView().webContents
280303
vi.mocked(contents.debugger.sendCommand)

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

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1012,7 +1012,14 @@ export async function clickAt(
10121012
modifiers,
10131013
clickCount: count,
10141014
})
1015-
if (holdMs > 0) await sleep(holdMs)
1015+
if (holdMs > 0) {
1016+
await sleep(holdMs)
1017+
// Windows opens the context menu on release, after the hold; renew a marker a
1018+
// press-time menu has not already consumed.
1019+
if (button === 'right' && agentContextClicks.has(contents)) {
1020+
agentContextClicks.set(contents, Date.now())
1021+
}
1022+
}
10161023
await sendInput(contents, 'Input.dispatchMouseEvent', {
10171024
type: 'mouseReleased',
10181025
x,

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

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { toRecord } from '@sim/utils/object'
12
import { describe, expect, it, vi } from 'vitest'
23

34
vi.mock('electron', () => import('@/test/electron-mock'))
@@ -312,6 +313,23 @@ describe('dispatchKeyCombo', () => {
312313
])
313314
})
314315

316+
it('releases only the chord keys whose press was attempted', async () => {
317+
const contents = new WebContentsView().webContents
318+
vi.mocked(contents.debugger.sendCommand)
319+
.mockResolvedValueOnce({})
320+
.mockRejectedValueOnce(new Error('shift-down response lost'))
321+
322+
await expect(dispatchKeyCombo(contents, parseKeyCombo('Ctrl+Shift+K'))).rejects.toMatchObject({
323+
name: KeyDispatchError.name,
324+
keyDownDispatched: true,
325+
})
326+
327+
const keys = vi
328+
.mocked(contents.debugger.sendCommand)
329+
.mock.calls.map(([, params]) => `${toRecord(params).type} ${toRecord(params).key}`)
330+
expect(keys).toEqual(['rawKeyDown Control', 'rawKeyDown Shift', 'keyUp Shift', 'keyUp Control'])
331+
})
332+
315333
it('does not turn menu-restoration cleanup failure into a duplicate key retry signal', async () => {
316334
const contents = new WebContentsView().webContents
317335
vi.mocked(contents.setIgnoreMenuShortcuts).mockImplementation((ignored: boolean) => {

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

Lines changed: 17 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -372,28 +372,37 @@ export async function dispatchKeyCombo(contents: WebContents, combo: ParsedCombo
372372
if (depth === 0) contents.setIgnoreMenuShortcuts(true)
373373
applicationMenuIsolationDepth.set(contents, depth + 1)
374374
}
375-
let keyDownDispatched = false
375+
const presses = [...modifierKeys.downs, down]
376+
let pressesAttempted = 0
376377
try {
377-
// Mark before awaiting: Blink may receive the key-down and then lose the
378+
// Count before awaiting: Blink may receive a key-down and then lose the
378379
// CDP acknowledgement during navigation/process swap. In that ambiguous
379380
// case cleanup is required and a synthetic retry could double-act.
380-
keyDownDispatched = true
381-
for (const event of modifierKeys.downs) await cdp.dispatchKeyEvent(contents, event)
382-
await cdp.dispatchKeyEvent(contents, down)
381+
for (const press of presses) {
382+
pressesAttempted++
383+
await cdp.dispatchKeyEvent(contents, press)
384+
}
383385
await cdp.dispatchKeyEvent(contents, up)
384386
for (const event of modifierKeys.ups) await cdp.dispatchKeyEvent(contents, event)
385387
} catch (error) {
386-
if (keyDownDispatched && !contents.isDestroyed()) {
388+
if (pressesAttempted > 0 && !contents.isDestroyed()) {
387389
// Like pointer cleanup, this is best effort. The original key-up may
388390
// have reached Blink before its CDP response was lost; a duplicate
389391
// release is harmless, while omitting it can leave input state stuck.
390-
for (const release of [up, ...modifierKeys.ups]) {
392+
// Only keys whose press was attempted are released: a key-up for a key
393+
// that never went down can itself trigger a page's keyup handler.
394+
const heldModifiers = Math.min(pressesAttempted, modifierKeys.downs.length)
395+
const releases = [
396+
...(pressesAttempted === presses.length ? [up] : []),
397+
...modifierKeys.ups.slice(modifierKeys.ups.length - heldModifiers),
398+
]
399+
for (const release of releases) {
391400
await cdp.dispatchKeyEvent(contents, release).catch(() => {})
392401
}
393402
}
394403
throw new KeyDispatchError(
395404
getErrorMessage(error, 'Trusted key dispatch failed'),
396-
keyDownDispatched
405+
pressesAttempted > 0
397406
)
398407
} finally {
399408
if (isolatesApplicationMenu) {

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

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2536,4 +2536,19 @@ describe('modal hidden together with its own app root', () => {
25362536
expect(outline).not.toContain('Discard')
25372537
expect((readPageActionState() as { dialogs: string[] }).dialogs).toEqual(['Upper'])
25382538
})
2539+
2540+
it('exposes a disablePortal modal nested inside another open modal', () => {
2541+
document.body.innerHTML = `
2542+
<div id="__next" aria-hidden="true"><main>
2543+
<div role="dialog" aria-modal="true" aria-label="Outer"><button>Discard</button>
2544+
<div role="dialog" aria-modal="true" aria-label="Inner"><button>Confirm</button></div>
2545+
</div>
2546+
</main></div>`
2547+
showAll()
2548+
2549+
const outline = outlineOf(collectSnapshot())
2550+
2551+
expect(outline).toContain('Confirm')
2552+
expect(outline).not.toContain('Discard')
2553+
})
25392554
})

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

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,7 @@ export function installPageHelpers(): void {
7171
* mounts inside the app root it just hid, so its own ancestor carries aria-hidden. Visibility
7272
* checks skip only the aria-hidden test on ancestors above this modal. Null when any modal is
7373
* visible unmodified (the portaled case) or no single topmost modal is contained by all of its
74-
* hidden ancestors.
74+
* hidden ancestors. A modal nested inside another open modal is the topmost of the two.
7575
*/
7676
let exemptModal: Element | null | undefined
7777
window.__simAgentFindExemptModal = (): Element | null => {
@@ -109,8 +109,10 @@ export function installPageHelpers(): void {
109109
if (visible) rendered.push({ modal, hidden })
110110
}
111111
if (rendered.some(({ hidden }) => hidden.length === 0)) return null
112-
const topmost = rendered.filter(({ hidden }) =>
113-
hidden.every((ancestor) => rendered.every(({ modal }) => ancestor.contains(modal)))
112+
const topmost = rendered.filter(
113+
({ modal, hidden }) =>
114+
hidden.every((ancestor) => rendered.every((other) => ancestor.contains(other.modal))) &&
115+
!rendered.some((other) => other.modal !== modal && modal.contains(other.modal))
114116
)
115117
return topmost.length === 1 ? topmost[0].modal : null
116118
}

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

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3495,6 +3495,44 @@ describe('browser-agent session', () => {
34953495
})
34963496
})
34973497

3498+
it('ignores a progress disk probe that resolves after the download completed', async () => {
3499+
vi.useFakeTimers({ toFake: ['Date'] })
3500+
try {
3501+
const directory = mkdtempSync(join(tmpdir(), 'sim-browser-downloads-'))
3502+
const progressProbe = deferred<number>()
3503+
const getFreeDiskBytes = vi
3504+
.fn<(directory: string) => number | Promise<number>>()
3505+
.mockReturnValueOnce(Number.MAX_SAFE_INTEGER)
3506+
.mockReturnValueOnce(progressProbe.promise)
3507+
session = freshSession(win, {}, undefined, {
3508+
getDirectory: () => directory,
3509+
getFreeDiskBytes,
3510+
})
3511+
const contents = (session.ensureTab().view as unknown as MockView).webContents
3512+
const download = mockDownloadItem({ filename: 'late-probe.bin', totalBytes: 100 })
3513+
3514+
startMockDownload(contents, download)
3515+
await vi.waitFor(() => expect(download.item.resume).toHaveBeenCalledOnce())
3516+
vi.setSystemTime(Date.now() + 1_000)
3517+
download.emitUpdated()
3518+
expect(getFreeDiskBytes).toHaveBeenCalledTimes(2)
3519+
3520+
download.emitDone('completed')
3521+
progressProbe.resolve(0)
3522+
3523+
await vi.waitFor(() =>
3524+
expect(session.getBrowserDownloadsState('chat-test').downloads[0]).toMatchObject({
3525+
filename: 'late-probe.bin',
3526+
state: 'completed',
3527+
})
3528+
)
3529+
expect(download.item.cancel).not.toHaveBeenCalled()
3530+
expect(finishedDownloadFiles(directory)).toEqual(['late-probe.bin'])
3531+
} finally {
3532+
vi.useRealTimers()
3533+
}
3534+
})
3535+
34983536
it('interrupts a completed download whose staging file cannot be moved into place', async () => {
34993537
const directory = mkdtempSync(join(tmpdir(), 'sim-browser-downloads-'))
35003538
session = freshSession(win, {}, undefined, {

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

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -647,6 +647,16 @@ function publishActiveBrowserDownload(active: ActiveBrowserDownload): void {
647647
publishBrowserDownloads(liveScopeId)
648648
}
649649

650+
/** A disk probe that resolves after its download finished or stopped must not cancel it. */
651+
function isDiskCheckStale(active: ActiveBrowserDownload): boolean {
652+
return (
653+
active.terminal ||
654+
active.finished ||
655+
Boolean(active.limitReason) ||
656+
!activeBrowserDownloads.has(active)
657+
)
658+
}
659+
650660
function checkBrowserDownloadDiskSpace(
651661
active: ActiveBrowserDownload,
652662
check: 'admission' | 'progress',
@@ -664,9 +674,7 @@ function checkBrowserDownloadDiskSpace(
664674
active.diskCheckInFlight = true
665675
void browserDownloadFreeDiskBytes(active.directory)
666676
.then((freeDiskBytes) => {
667-
if (active.terminal || active.limitReason || !activeBrowserDownloads.has(active)) {
668-
return
669-
}
677+
if (isDiskCheckStale(active)) return
670678
const requiredFreeDiskBytes =
671679
MIN_BROWSER_DOWNLOAD_FREE_DISK_BYTES +
672680
activeDownloadReservations(check === 'admission' ? active : undefined)
@@ -683,7 +691,7 @@ function checkBrowserDownloadDiskSpace(
683691
if (check === 'admission' && active.download.state === 'progressing') active.item.resume()
684692
})
685693
.catch((error) => {
686-
if (active.terminal || active.limitReason || !activeBrowserDownloads.has(active)) return
694+
if (isDiskCheckStale(active)) return
687695
logger.warn('Could not complete an agent browser download disk-space check', {
688696
error: getErrorMessage(error),
689697
})

‎apps/sim/lib/mothership/generated/tool-catalog-v1.ts‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -530,9 +530,11 @@ export const BrowserClick: ToolCatalogEntry = {
530530
"The element id to act on (from the current tab's most recent browser_snapshot). Treat refs as invalid across tab switches or later snapshots.",
531531
},
532532
holdMs: {
533-
type: 'number',
533+
type: 'integer',
534534
description:
535535
'Keep the button pressed this many milliseconds before releasing (0 to 10000, default 0), for press-and-hold controls. Single clicks only.',
536+
minimum: 0,
537+
maximum: 10000,
536538
},
537539
modifiers: {
538540
type: 'array',
@@ -673,9 +675,11 @@ export const BrowserClickAt: ToolCatalogEntry = {
673675
required: ['accept'],
674676
},
675677
holdMs: {
676-
type: 'number',
678+
type: 'integer',
677679
description:
678680
'Keep the button pressed this many milliseconds before releasing (0 to 10000, default 0), for press-and-hold controls. Single clicks only.',
681+
minimum: 0,
682+
maximum: 10000,
679683
},
680684
modifiers: {
681685
type: 'array',

‎apps/sim/lib/mothership/generated/tool-schemas-v1.ts‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -219,9 +219,11 @@ export const TOOL_RUNTIME_SCHEMAS: Record<string, ToolRuntimeSchemaEntry> = {
219219
"The element id to act on (from the current tab's most recent browser_snapshot). Treat refs as invalid across tab switches or later snapshots.",
220220
},
221221
holdMs: {
222-
type: 'number',
222+
type: 'integer',
223223
description:
224224
'Keep the button pressed this many milliseconds before releasing (0 to 10000, default 0), for press-and-hold controls. Single clicks only.',
225+
minimum: 0,
226+
maximum: 10000,
225227
},
226228
modifiers: {
227229
type: 'array',
@@ -396,9 +398,11 @@ export const TOOL_RUNTIME_SCHEMAS: Record<string, ToolRuntimeSchemaEntry> = {
396398
required: ['accept'],
397399
},
398400
holdMs: {
399-
type: 'number',
401+
type: 'integer',
400402
description:
401403
'Keep the button pressed this many milliseconds before releasing (0 to 10000, default 0), for press-and-hold controls. Single clicks only.',
404+
minimum: 0,
405+
maximum: 10000,
402406
},
403407
modifiers: {
404408
type: 'array',

0 commit comments

Comments
 (0)