Skip to content

Commit 3c3770a

Browse files
authored
feat(browser): batch planned page actions and keep snapshot ids after refused clicks (#8214)
* fix(browser): keep snapshot ids valid after an observed action is refused An observed click refused before dispatch (for example, covered by an overlay) never starts its observation, yet it invalidated every id in the snapshot, so the agent could not click the overlay's own close button without re-observing. A failed observed action with no dispatched outcome now only cancels an in-flight capture, which clears the refs itself when it starts. * feat(browser): run planned page actions in order with browser_batch The desktop runs 2-8 single-page interactions from one authorized call, in order, stopping at the first failed action or after an action navigates, switches tabs, or invalidates the snapshot's element ids. A batch stopped by a failure reports an error status; one stopped by a page change does not. Once an action has run, a cancelled or timed-out batch reports an unknown outcome so the agent does not repeat it. * fix(browser): mark batched actions pending and stop batches on same-document URL changes A batch now reports its outcome as pending before every action, since an action can dispatch input before it returns; a cancelled or timed-out batch therefore never reads as not started. It also stops after an action changes the tab URL within the document, not only on cross-document navigation.
1 parent e919e4c commit 3c3770a

10 files changed

Lines changed: 606 additions & 17 deletions

File tree

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

Lines changed: 222 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { BROWSER_TOOL_QUEUE_WAIT_TIMEOUT_MS } from '@sim/browser-protocol'
2+
import { toRecord } from '@sim/utils/object'
23
import type { MenuItemConstructorOptions, WebContents } from 'electron'
34
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
45

@@ -2097,13 +2098,20 @@ describe('credential protection', () => {
20972098
for (const [fnName, value] of Object.entries(replies)) {
20982099
if (isPageCall(expression, fnName)) return Promise.resolve(value)
20992100
}
2100-
if (isPageCall(expression, 'clickElement')) {
2101-
return Promise.resolve({ dispatched: false, x: 24, y: 48, element: 'Test' })
2102-
}
2101+
if (isPageCall(expression, 'clickElement')) return Promise.resolve(CLICK_TARGET)
21032102
return Promise.resolve(undefined)
21042103
})
21052104
}
21062105

2106+
function mousePresses(contents: Awaited<ReturnType<typeof openPage>>): number {
2107+
return cdpCalls(contents, 'Input.dispatchMouseEvent').filter(
2108+
([, params]) => toRecord(params).type === 'mousePressed'
2109+
).length
2110+
}
2111+
2112+
/** What the page reports for an ordinary click target before native dispatch. */
2113+
const CLICK_TARGET = { dispatched: false, x: 24, y: 48, element: 'Test' }
2114+
21072115
function cdpCalls(contents: Awaited<ReturnType<typeof openPage>>, method: string): unknown[][] {
21082116
return vi
21092117
.mocked(contents.debugger.sendCommand)
@@ -3425,6 +3433,217 @@ describe('credential protection', () => {
34253433
expect(second).not.toMatchObject({ result: { notices: expect.anything() } })
34263434
})
34273435

3436+
it('runs batched actions in order and returns each result', async () => {
3437+
const contents = await openPage()
3438+
respondWith(contents, {})
3439+
3440+
const result = await driver.executeTool('chat-test', 'browser_batch', {
3441+
actions: [
3442+
{ tool: 'browser_click', args: { elementId: 0 } },
3443+
{ tool: 'browser_click', args: { elementId: 0 } },
3444+
],
3445+
})
3446+
3447+
expect(result).toMatchObject({
3448+
ok: true,
3449+
result: {
3450+
completed: true,
3451+
completedCount: 2,
3452+
results: [
3453+
{ index: 0, tool: 'browser_click', result: { dispatched: true } },
3454+
{ index: 1, tool: 'browser_click', result: { dispatched: true } },
3455+
],
3456+
},
3457+
})
3458+
})
3459+
3460+
it('stops a batch at the first failed action and keeps earlier results', async () => {
3461+
const contents = await openPage()
3462+
vi.mocked(contents.executeJavaScript).mockImplementation((expression: string) => {
3463+
if (!isPageCall(expression, 'clickElement')) return Promise.resolve(undefined)
3464+
return Promise.resolve(
3465+
mousePresses(contents) > 0 ? { error: 'obstructed', blocker: 'IMG' } : CLICK_TARGET
3466+
)
3467+
})
3468+
3469+
const result = await driver.executeTool('chat-test', 'browser_batch', {
3470+
actions: [
3471+
{ tool: 'browser_click', args: { elementId: 0 } },
3472+
{ tool: 'browser_click', args: { elementId: 0 } },
3473+
{ tool: 'browser_click', args: { elementId: 0 } },
3474+
],
3475+
})
3476+
3477+
expect(mousePresses(contents)).toBe(1)
3478+
expect(result).toMatchObject({
3479+
ok: true,
3480+
result: {
3481+
completed: false,
3482+
completedCount: 1,
3483+
stoppedIndex: 1,
3484+
stoppedBy: 'failure',
3485+
error: expect.stringContaining('covered by IMG'),
3486+
},
3487+
})
3488+
})
3489+
3490+
it('stops a batch after an action navigates the page', async () => {
3491+
const contents = await openPage()
3492+
respondWith(contents, {})
3493+
const sendCommand = vi.mocked(contents.debugger.sendCommand)
3494+
const dispatch = sendCommand.getMockImplementation()
3495+
sendCommand.mockImplementation((method, params) => {
3496+
if (method === 'Input.dispatchMouseEvent' && toRecord(params).type === 'mouseReleased') {
3497+
emitContentsEvent(contents, 'did-navigate')
3498+
}
3499+
return dispatch?.(method, params) ?? Promise.resolve(undefined)
3500+
})
3501+
3502+
const result = await driver.executeTool('chat-test', 'browser_batch', {
3503+
actions: [
3504+
{ tool: 'browser_click', args: { elementId: 0 } },
3505+
{ tool: 'browser_click', args: { elementId: 0 } },
3506+
],
3507+
})
3508+
3509+
expect(mousePresses(contents)).toBe(1)
3510+
expect(result).toMatchObject({
3511+
ok: true,
3512+
result: { completed: false, completedCount: 1, stoppedIndex: 1, stoppedBy: 'page-change' },
3513+
})
3514+
})
3515+
3516+
it('stops a batch after an action changes the URL within the document', async () => {
3517+
const contents = await openPage()
3518+
respondWith(contents, {})
3519+
const url = contents.getURL()
3520+
const sendCommand = vi.mocked(contents.debugger.sendCommand)
3521+
const dispatch = sendCommand.getMockImplementation()
3522+
sendCommand.mockImplementation((method, params) => {
3523+
if (method === 'Input.dispatchMouseEvent' && toRecord(params).type === 'mouseReleased') {
3524+
vi.mocked(contents.getURL).mockReturnValue(`${url}#next`)
3525+
emitContentsEvent(contents, 'did-navigate-in-page')
3526+
}
3527+
return dispatch?.(method, params) ?? Promise.resolve(undefined)
3528+
})
3529+
3530+
const result = await driver.executeTool('chat-test', 'browser_batch', {
3531+
actions: [
3532+
{ tool: 'browser_click', args: { elementId: 0 } },
3533+
{ tool: 'browser_click', args: { elementId: 0 } },
3534+
],
3535+
})
3536+
3537+
expect(mousePresses(contents)).toBe(1)
3538+
expect(result).toMatchObject({
3539+
ok: true,
3540+
result: { completed: false, completedCount: 1, stoppedIndex: 1, stoppedBy: 'page-change' },
3541+
})
3542+
})
3543+
3544+
it('reports a batch cancelled during its first action as an unknown outcome', async () => {
3545+
const contents = await openPage()
3546+
respondWith(contents, {})
3547+
const sendCommand = vi.mocked(contents.debugger.sendCommand)
3548+
const dispatch = sendCommand.getMockImplementation()
3549+
sendCommand.mockImplementation((method, params) =>
3550+
method === 'Input.dispatchKeyEvent'
3551+
? new Promise(() => {})
3552+
: (dispatch?.(method, params) ?? Promise.resolve(undefined))
3553+
)
3554+
3555+
const pending = driver.executeTool(
3556+
'chat-test',
3557+
'browser_batch',
3558+
{
3559+
actions: [
3560+
{ tool: 'browser_press_key', args: { key: 'Enter' } },
3561+
{ tool: 'browser_click', args: { elementId: 0 } },
3562+
],
3563+
},
3564+
'batch-first-call'
3565+
)
3566+
await vi.waitFor(() => expect(cdpCalls(contents, 'Input.dispatchKeyEvent')).toHaveLength(1))
3567+
driver.cancelTool('chat-test', 'batch-first-call')
3568+
3569+
await expect(pending).resolves.toMatchObject({
3570+
ok: true,
3571+
result: { outcomeUnknown: true, doNotRetry: true },
3572+
})
3573+
})
3574+
3575+
it('reports a batch cancelled after an action ran as an unknown outcome', async () => {
3576+
const contents = await openPage()
3577+
respondWith(contents, {})
3578+
const sendCommand = vi.mocked(contents.debugger.sendCommand)
3579+
const dispatch = sendCommand.getMockImplementation()
3580+
sendCommand.mockImplementation((method, params) =>
3581+
method === 'Input.dispatchKeyEvent'
3582+
? new Promise(() => {})
3583+
: (dispatch?.(method, params) ?? Promise.resolve(undefined))
3584+
)
3585+
3586+
const pending = driver.executeTool(
3587+
'chat-test',
3588+
'browser_batch',
3589+
{
3590+
actions: [
3591+
{ tool: 'browser_click', args: { elementId: 0 } },
3592+
{ tool: 'browser_press_key', args: { key: 'Enter' } },
3593+
],
3594+
},
3595+
'batch-call'
3596+
)
3597+
await vi.waitFor(() => expect(cdpCalls(contents, 'Input.dispatchKeyEvent')).toHaveLength(1))
3598+
driver.cancelTool('chat-test', 'batch-call')
3599+
3600+
await expect(pending).resolves.toMatchObject({
3601+
ok: true,
3602+
result: { outcomeUnknown: true, doNotRetry: true },
3603+
})
3604+
})
3605+
3606+
it('rejects batches that name non-action tools or observe per action', async () => {
3607+
await openPage()
3608+
3609+
const navigation = await driver.executeTool('chat-test', 'browser_batch', {
3610+
actions: [
3611+
{ tool: 'browser_navigate', args: { url: 'https://example.com' } },
3612+
{ tool: 'browser_click', args: { elementId: 0 } },
3613+
],
3614+
})
3615+
const observed = await driver.executeTool('chat-test', 'browser_batch', {
3616+
actions: [
3617+
{ tool: 'browser_click', args: { elementId: 0, observe: {} } },
3618+
{ tool: 'browser_click', args: { elementId: 0 } },
3619+
],
3620+
})
3621+
3622+
expect(navigation).toMatchObject({
3623+
ok: false,
3624+
error: expect.stringContaining('Batch action 0'),
3625+
})
3626+
expect(observed).toMatchObject({ ok: false, error: expect.stringContaining('cannot observe') })
3627+
})
3628+
3629+
it('keeps element ids valid when an observed action is refused before dispatch', async () => {
3630+
const contents = await openPage()
3631+
respondWith(contents, { clickElement: { error: 'obstructed', blocker: 'IMG' } })
3632+
3633+
const refused = await driver.executeTool('chat-test', 'browser_click', {
3634+
elementId: 0,
3635+
observe: {},
3636+
})
3637+
respondWith(contents, {})
3638+
const retried = await driver.executeTool('chat-test', 'browser_click', { elementId: 0 })
3639+
3640+
expect(refused).toEqual({
3641+
ok: false,
3642+
error: expect.stringContaining('That element is covered by IMG'),
3643+
})
3644+
expect(retried).toMatchObject({ ok: true, result: { dispatched: true } })
3645+
})
3646+
34283647
it('invalidates element ids when the active tab changes', async () => {
34293648
await openPage()
34303649
await driver.executeTool('chat-test', 'browser_open_tab', {})

0 commit comments

Comments
 (0)