Skip to content

Commit df9d2f6

Browse files
committed
feat(browser): add scoped observations and shared wait budgets
1 parent cc4f3b9 commit df9d2f6

10 files changed

Lines changed: 352 additions & 70 deletions

File tree

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

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3257,6 +3257,53 @@ describe('credential protection', () => {
32573257
})
32583258
})
32593259

3260+
it.each(['browser_snapshot', 'browser_find'] as const)(
3261+
'omits an absent scope from the serialized %s page call',
3262+
async (tool) => {
3263+
const contents = await openPage()
3264+
vi.mocked(contents.executeJavaScript).mockClear()
3265+
await driver.executeTool('chat-test', tool, { query: 'Continue' })
3266+
const expressions = vi
3267+
.mocked(contents.executeJavaScript)
3268+
.mock.calls.map(([expression]) => expression)
3269+
.filter((expression) => isPageCall(expression, 'collectSnapshot'))
3270+
expect(expressions).toHaveLength(1)
3271+
expect(expressions[0]).toContain('.apply(null, [1])')
3272+
}
3273+
)
3274+
3275+
it.each(['browser_snapshot', 'browser_find'] as const)(
3276+
'passes the current root ref to %s and invalidates previous refs',
3277+
async (tool) => {
3278+
const contents = await openPage()
3279+
respondWith(contents, {
3280+
collectSnapshot: {
3281+
url: 'https://example.com/login',
3282+
title: 'Scoped',
3283+
scoped: true,
3284+
outline: '- button "Save" [ref=1]',
3285+
truncated: false,
3286+
refIds: [1],
3287+
refLineIndexes: { 1: 0 },
3288+
nextElementId: 2,
3289+
},
3290+
})
3291+
const result = await driver.executeTool('chat-test', tool, { elementId: 0, query: 'Save' })
3292+
expect(result).toMatchObject({ ok: true, result: { scoped: true } })
3293+
expect(
3294+
vi
3295+
.mocked(contents.executeJavaScript)
3296+
.mock.calls.some(
3297+
([expression]) =>
3298+
isPageCall(expression, 'collectSnapshot') &&
3299+
expression.includes('.apply(null, [1,0])')
3300+
)
3301+
).toBe(true)
3302+
const stale = await driver.executeTool('chat-test', 'browser_click', { elementId: 0 })
3303+
expect(stale).toMatchObject({ ok: false })
3304+
}
3305+
)
3306+
32603307
it.each([
32613308
[1, false, 1],
32623309
[100, false, 50],
@@ -3405,6 +3452,47 @@ describe('credential protection', () => {
34053452
})
34063453
})
34073454

3455+
it.each([true, false])(
3456+
'polls delayed checkable state without redispatching input (updates=%s)',
3457+
async (updates) => {
3458+
const contents = await openPage()
3459+
let reads = 0
3460+
vi.mocked(contents.executeJavaScript).mockImplementation(async (expression: string) => {
3461+
if (isPageCall(expression, 'readCheckableElementState')) {
3462+
reads++
3463+
return { checked: updates && reads >= 4, kind: 'input:checkbox' }
3464+
}
3465+
if (isPageCall(expression, 'clickElement'))
3466+
return { dispatched: false, x: 24, y: 48, element: 'Checkbox' }
3467+
if (isPageCall(expression, 'readPageActionState'))
3468+
return {
3469+
url: 'https://example.com/login',
3470+
title: 'Example',
3471+
focus: 'body',
3472+
mutationRevision: 0,
3473+
dialogs: [],
3474+
scroll: [0],
3475+
}
3476+
if (isPageCall(expression, 'readActiveElementState')) return {}
3477+
})
3478+
vi.useFakeTimers()
3479+
try {
3480+
const pending = driver.executeTool('chat-test', 'browser_set_checked', {
3481+
elementId: 0,
3482+
checked: true,
3483+
})
3484+
await vi.advanceTimersByTimeAsync(2000)
3485+
const result = await pending
3486+
expect(result.ok).toBe(updates)
3487+
expect(reads).toBeGreaterThanOrEqual(4)
3488+
expect(cdpCalls(contents, 'Input.dispatchMouseEvent')).toHaveLength(3)
3489+
if (!updates) expect(result.error).toContain('did not reach the requested checked state')
3490+
} finally {
3491+
vi.useRealTimers()
3492+
}
3493+
}
3494+
)
3495+
34083496
it.each(['hidden', 'detached'])(
34093497
'does not treat a failed probe as element state %s',
34103498
async (state) => {

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

Lines changed: 52 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1203,6 +1203,11 @@ function unwrapPageResult(result: unknown): unknown {
12031203
'Element-state waits are limited to the top page. Use a text or URL condition for framed content.'
12041204
)
12051205
}
1206+
if (code === 'framed-snapshot') {
1207+
throw new ToolError(
1208+
'Scoped snapshots require a top-page element. Omit elementId to capture framed content.'
1209+
)
1210+
}
12061211
if (code === 'no-option') {
12071212
const options = (result as { options?: string[] }).options ?? []
12081213
throw new ToolError(
@@ -1951,12 +1956,21 @@ function validateSnapshotRefs(
19511956
* policy intentionally lacks; password redaction still runs inside every frame
19521957
* before any result crosses back to the driver.
19531958
*/
1954-
async function captureSnapshot(contents: WebContents, notAfter?: number): Promise<unknown> {
1959+
async function captureSnapshot(
1960+
contents: WebContents,
1961+
notAfter?: number,
1962+
elementId?: number
1963+
): Promise<unknown> {
19551964
const state = driverScopeState()
19561965
const tab = session.requireAutomationTab()
19571966
if (tab.view.webContents !== contents) {
19581967
throw new ToolError('The active tab changed before the snapshot started. Try again.')
19591968
}
1969+
if (elementId !== undefined && pageTargetForElement(contents, elementId) !== contents) {
1970+
throw new ToolError(
1971+
'Scoped snapshots require a top-page element. Omit elementId to capture framed content.'
1972+
)
1973+
}
19601974
invalidateSnapshot(state)
19611975
const captureEpoch = state.snapshotCaptureEpoch
19621976
const capturedTabId = tab.id
@@ -1977,12 +1991,14 @@ async function captureSnapshot(contents: WebContents, notAfter?: number): Promis
19771991
}
19781992

19791993
const mainStartingElementId = state.nextElementRefId
1980-
const mainSnapshot = await execInPage(
1981-
contents,
1982-
collectSnapshot,
1983-
[mainStartingElementId],
1984-
false,
1985-
notAfter
1994+
const mainSnapshot = unwrapPageResult(
1995+
await execInPage(
1996+
contents,
1997+
collectSnapshot,
1998+
elementId === undefined ? [mainStartingElementId] : [mainStartingElementId, elementId],
1999+
false,
2000+
notAfter
2001+
)
19862002
)
19872003
if (!stillCurrent()) {
19882004
throw new ToolError('The tab changed while its snapshot was being captured. Try again.')
@@ -2012,7 +2028,7 @@ async function captureSnapshot(contents: WebContents, notAfter?: number): Promis
20122028
let capturedCrossOriginFrames = 0
20132029
let unreadableCrossOriginFrames = 0
20142030
let hiddenCrossOriginFrames = 0
2015-
const boundaryFrames = crossOriginBoundaryFrames(contents)
2031+
const boundaryFrames = elementId === undefined ? crossOriginBoundaryFrames(contents) : []
20162032
const frames = boundaryFrames.slice(0, MAX_CROSS_ORIGIN_SCAN_FRAMES)
20172033
if (boundaryFrames.length > frames.length) truncated = true
20182034

@@ -2432,7 +2448,7 @@ async function executeToolInner(
24322448
case 'browser_snapshot': {
24332449
const contents = session.requireAutomationTab().view.webContents
24342450
assertCurrentExecution()
2435-
return await captureSnapshot(contents, executionDeadline)
2451+
return await captureSnapshot(contents, executionDeadline, num(params, 'elementId'))
24362452
}
24372453

24382454
case 'browser_find': {
@@ -2441,7 +2457,9 @@ async function executeToolInner(
24412457
const requestedMax = num(params, 'maxResults')
24422458
const maxResults = Math.min(50, Math.max(1, Math.floor(requestedMax ?? 20)))
24432459
const contents = session.requireAutomationTab().view.webContents
2444-
const snapshot = toRecord(await captureSnapshot(contents, executionDeadline))
2460+
const snapshot = toRecord(
2461+
await captureSnapshot(contents, executionDeadline, num(params, 'elementId'))
2462+
)
24452463
const outline = typeof snapshot.outline === 'string' ? snapshot.outline : ''
24462464
const needle = query.toLowerCase()
24472465
const matches = outline.split('\n').flatMap((line) => {
@@ -2457,6 +2475,7 @@ async function executeToolInner(
24572475
truncated: snapshot.truncated === true || matches.length > maxResults,
24582476
url: snapshot.url,
24592477
title: snapshot.title,
2478+
...(snapshot.scoped === true ? { scoped: true } : {}),
24602479
}
24612480
}
24622481

@@ -3656,16 +3675,31 @@ async function executeToolInner(
36563675
invocationEpoch
36573676
)
36583677
)
3659-
await sleep(100)
3660-
const after = toRecord(
3661-
unwrapPageResult(
3662-
await execInPage(target, readCheckableElementState, [elementId], false, executionDeadline)
3663-
)
3678+
const readbackDeadline = Math.min(
3679+
Date.now() + SETTLE_GRACE_MS,
3680+
executionDeadline ?? Number.POSITIVE_INFINITY
36643681
)
3665-
if (after.checked !== checked) {
3666-
throw new ToolError(
3667-
'The control did not retain the requested checked state. Take a fresh browser_snapshot and inspect the page.'
3682+
let after: Record<string, unknown>
3683+
for (;;) {
3684+
assertCurrentExecution()
3685+
after = toRecord(
3686+
unwrapPageResult(
3687+
await execInPage(
3688+
target,
3689+
readCheckableElementState,
3690+
[elementId],
3691+
false,
3692+
executionDeadline
3693+
)
3694+
)
36683695
)
3696+
if (after.checked === checked) break
3697+
if (Date.now() + SETTLE_PROBE_INTERVAL_MS > readbackDeadline) {
3698+
throw new ToolError(
3699+
'The control did not reach the requested checked state. Take a fresh browser_snapshot and inspect the page.'
3700+
)
3701+
}
3702+
await sleep(SETTLE_PROBE_INTERVAL_MS)
36693703
}
36703704
return {
36713705
checked,

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

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -954,6 +954,87 @@ describe('collectSnapshot', () => {
954954
})
955955

956956
describe('semantic control state', () => {
957+
it('scopes a fresh snapshot to one card without reading sibling geometry', () => {
958+
document.body.innerHTML =
959+
'<div role="group" tabindex="0" aria-label="Selected card"><button>Save card</button><input type="password" value="private" /></div><button>Outside card</button>'
960+
for (const element of document.querySelectorAll('*')) visible(element)
961+
const full = outlineOf(collectSnapshot())
962+
const oldRootRef = refFor(full, 'Selected card')
963+
const outside = document.querySelector('body > button')!
964+
const outsideGeometry = vi.spyOn(outside, 'getBoundingClientRect')
965+
966+
const scoped = runSerialized(collectSnapshot, [100, oldRootRef]) as {
967+
scoped: boolean
968+
outline: string
969+
refIds: number[]
970+
}
971+
expect(scoped.scoped).toBe(true)
972+
expect(scoped.outline).toContain('Selected card')
973+
expect(scoped.outline).toContain('Save card')
974+
expect(scoped.outline).not.toContain('Outside card')
975+
expect(scoped.outline).not.toContain('private')
976+
expect(scoped.refIds.every((id) => id >= 100)).toBe(true)
977+
expect(window.__simAgentResolveElement?.(oldRootRef)).toBeNull()
978+
expect(outsideGeometry).not.toHaveBeenCalled()
979+
})
980+
981+
it('rejects stale or framed snapshot roots without replacing the page registry', () => {
982+
const button = document.createElement('button')
983+
document.body.append(visible(button))
984+
register(button)
985+
button.remove()
986+
expect(collectSnapshot(10, 0)).toMatchObject({ error: 'stale' })
987+
expect(window.__simAgentElements?.[0]).toBe(button)
988+
989+
const frame = document.createElement('iframe')
990+
document.body.append(frame)
991+
const child = frame.contentDocument!.createElement('button')
992+
frame.contentDocument!.body.append(visible(child))
993+
register(child)
994+
expect(collectSnapshot(10, 0)).toEqual({ error: 'framed-snapshot' })
995+
})
996+
997+
it('marks unreadable scoped frame content truncated', () => {
998+
const root = visible(document.createElement('div'))
999+
const frame = visible(document.createElement('iframe'))
1000+
root.append(frame)
1001+
document.body.append(root)
1002+
register(root)
1003+
Object.defineProperty(frame, 'contentDocument', { value: null })
1004+
expect(collectSnapshot(10, 0)).toMatchObject({ scoped: true, truncated: true })
1005+
})
1006+
1007+
it('does not recover scoped refs into a different card after the original closes', () => {
1008+
document.body.innerHTML =
1009+
'<div tabindex="0" aria-label="Selected card"><button id="save">Save card</button></div>'
1010+
for (const element of document.querySelectorAll('*')) visible(element)
1011+
const root = document.querySelector('body > div')!
1012+
const rootRef = refFor(outlineOf(collectSnapshot()), 'Selected card')
1013+
const saveRef = refFor(outlineOf(collectSnapshot(10, rootRef)), 'Save card')
1014+
const replacement = root.cloneNode(true) as HTMLElement
1015+
for (const element of [replacement, ...replacement.querySelectorAll('*')]) visible(element)
1016+
root.replaceWith(replacement)
1017+
1018+
expect(window.__simAgentResolveElement?.(saveRef)).toBeNull()
1019+
expect(window.__simAgentStaleReason).toContain('scoped snapshot root')
1020+
})
1021+
1022+
it('keeps scoped snapshots bounded when a selected container is very large', () => {
1023+
const root = document.createElement('div')
1024+
root.tabIndex = 0
1025+
root.setAttribute('aria-label', 'Large card')
1026+
document.body.append(visible(root))
1027+
register(root)
1028+
for (let index = 0; index < 400; index++) {
1029+
const button = visible(document.createElement('button'))
1030+
button.textContent = `Action ${index}`
1031+
root.append(button)
1032+
}
1033+
const scoped = collectSnapshot(10, 0) as { refIds: number[]; truncated: boolean }
1034+
expect(scoped.refIds).toHaveLength(300)
1035+
expect(scoped.truncated).toBe(true)
1036+
})
1037+
9571038
it('distinguishes hidden registered nodes from detached nodes without action recovery', () => {
9581039
const button = visible(document.createElement('button'))
9591040
document.body.append(button)

0 commit comments

Comments
 (0)